Support
The support desk: screens, a chat popup and optional ratings, attachments and feedback that let a signed-in customer write to your team and follow the answer inside the app.
On this page
Introduction#
The desk is the client of magic-starter-laravel's support API (support/conversations), which scopes every conversation to the user who opened it: a teammate asking for someone else's thread gets the same 404 as a missing one. Staff answer from the backend's admin panel; nothing on this side reads or writes another user's conversation.
Enabling the Desk#
Turn the feature on in lib/config/magic_starter.dart:
'features': {
'support': true,
},
Then register the routes from your route provider, beside the other starter routes:
registerMagicStarterAuthRoutes();
registerMagicStarterProfileRoutes();
registerMagicStarterTeamRoutes();
registerMagicStarterSupportRoutes();
The profile dropdown shows its Support item whenever the feature is on, so a host that enables the feature without registering the routes ships a menu item that leads nowhere. A host that draws its own account menu instead of MSUserProfileDropdown adds its own entry pointing at MagicStarterConfig.supportRoute().
The backend has to enable the same feature (Features::support() in config/magic-starter.php); with it off the endpoints do not exist and the screens show their error state. The screens below need magic-starter-laravel with the AI desk columns (topic, awaiting_ai, last_message and the paginated list).
Further flags build on it. All default to false. support_csat, support_feedback, support_attachments and support_client_context need the backend to turn on the feature of the same name (Features::supportCsat(), supportFeedback(), supportAttachments(), supportClientContext()) because they call routes and fields it adds; support_chat and store_review are client only:
'features': {
'support': true,
'support_csat': true,
'support_feedback': true,
'support_attachments': true,
'support_client_context': true,
'support_chat': true,
'store_review': true,
},
| Flag | Adds |
|---|---|
support_csat |
A thumbs rating under a resolved thread |
support_feedback |
MagicStarterFeedbackCard, a "Send feedback" page and menu item |
support_attachments |
Image and PDF files on a new conversation and on a reply, and in the thread |
support_client_context |
Client info (app version, platform, locale) on a new conversation |
support_chat |
MagicStarterSupportChatLauncher, a button with an unread badge that opens a floating chat panel at the bottom right of the screen. Needs support |
store_review |
The native review prompt and a "Rate us" menu item (client only; the backend has no counterpart) |
starter:install --features=support adds registerMagicStarterSupportRoutes(); to route_service_provider.dart, starter:uninstall removes it, and starter:doctor warns when the feature is on and the call is missing.
Screens#
| Path | View key | What it shows |
|---|---|---|
/support |
support.index |
The user's conversations in an Open and a Resolved tab, newest activity first. Each row shows the sender and a preview of the last message, a status badge and an unread dot when staff or the AI wrote since the last read. A "Load more" button reads the next page (20 a page); a refresh rereads every page already loaded, so the poll never collapses them, and the Resolved tab offers "Load more" instead of an empty claim while pages remain |
/support/new |
support.create |
Topic chips and a message with a character counter, posted with source in_app. A "How it works" card sits beside the form on a wide screen and collapses to one line above it on a phone |
/support/:id |
support.thread |
Chat bubbles by who wrote them, activity lines for handoffs, and a composer docked at the bottom |
/support/new and /support/:id are pushed (.stacked()), so the back gesture returns to the list. Opening a thread that has an unread reply sends a seen receipt and clears the dot; a thread with nothing unread sends none.
All the screens share one MagicStarterSupportController. A thread read that answers after the reader has already opened another thread is dropped, so a slow response never puts one conversation's messages under another's composer. A page of the list that answers after the list was reloaded is dropped the same way, and a row already held is not added twice when new activity moves it between two reads.
Chat Popup#
With support_chat on (and support), a host can mount MagicStarterSupportChatLauncher in its shell: a button with an unread count that opens a floating panel with the customer's conversations, one thread or the new-conversation form. Place it once in the shell, such as a sidebar; the panel does not depend on where the button sits:
class AppSidebar extends StatelessWidget {
const AppSidebar({super.key});
@override
Widget build(BuildContext context) {
return WDiv(
className: 'flex flex-col gap-1',
children: [
const MagicStarterSupportChatLauncher(
className:
'flex flex-row items-center gap-3 px-3 py-2 rounded-lg text-sm text-fg',
),
],
);
}
}
The launcher renders nothing unless the flag and support are on and a signed-in, non-guest user is present; signing out closes the panel. Mount it once per app: a second launcher would open a second panel. The props are className (the trigger's classes; null is a full-width sidebar row; a value replaces the default trigger classes rather than adding to them, so give it a full class list including flex flex-row, or the icon stacks above the label), width (400) and height (520), and margin (16), the gap the panel keeps from the screen edges.
The panel floats at the bottom right of the screen, like a chat widget, wherever the launcher sits. It is non-modal: a tap outside does not close it and the page stays interactive. It stays on screen: it is clamped to the safe area, and its height gives way to the keyboard. Escape closes it; opening moves focus into the panel and closing returns it to the launcher. The header has back, the title, "open full page" (closes the panel and goes to /support) and close. An empty conversation list opens the new-conversation form.
Sharing state with the pages#
The popup and the /support pages run side by side without conflict. MagicStarterSupportSync owns the one 30 second poll, the one broadcast listener and one refetch after a reconnect for every open surface, and a closed launcher holds no poll. The controller keeps a thread per conversation id, so the popup and a page can show different threads, and a write locks per surface ('page' for the pages, 'chat' for the panel): a reply on the page and a new conversation in the popup run together, a second tap on the same surface is refused. A host that replaces support.chat_panel takes over the shared-refresh retain() and release(), the thread watch and the seen receipt.
Limit: validation errors and write errors (getError, writeError) are still held once on the controller, not per surface, so an error one surface raised is visible to the other while both are open. The knowledge-base suggestions on the create form are shared the same way: there is one list, and forgetArticleSuggestions runs when a create form mounts, so opening the panel's form clears the suggestions of the form on the page. On native macOS the Escape key inside the composer may be consumed by the text field before it reaches the panel; the web is unaffected.
The copy lives under magic_starter.support.chat.*. The package ships English only: a host adds the keys to each catalogue it ships, and an app with a published en.json merges them from assets/stubs/install/en.stub. On a layout with no room for the launcher, the /support routes remain the way in.
The AI Desk#
When the backend has an AI responder bound, a verified customer's conversation starts with the AI answering. The screens read three fields the backend computes and never guess at them:
| Field | Meaning | What the screen shows |
|---|---|---|
awaiting_ai |
Pending, and the newest customer message has no AI answer yet | An "AI answering" pill, a typing bubble in the thread and the line "Our AI answers right away and hands off to the team when needed" above the composer |
waiting_on_team |
Open and waiting on a person | A "Team answering" pill and the line "Our team usually replies within a few hours" above the composer |
| neither | Answered, or finished | The plain "Open" badge, or the "Resolved" pill |
"AI answering", "Team answering" and "Resolved" are soft tinted pills with a leading dot (bg-ai-soft, bg-warning-soft, bg-success-soft); the assistant's bubble carries a 2px border-ai left edge. A customer message sent on a pending conversation flips the local copy to awaiting_ai at once, so the typing bubble shows before the next refetch.
pending and snoozed are the desk's own bookkeeping and read as open. A message from the assistant carries sender_type ai and its name; it renders on the left with a sparkle avatar on bg-ai-soft and a chip under the bubble, a teammate shows their first name and photo or initials, and the customer sits on the right. A message with kind activity (the handoff line, a status change) renders as a centred divider line, not a bubble.
If the AI has been pending for 45 seconds, counted from when the screen first saw it pending, the typing bubble gives way to "This is taking longer than usual. If the AI cannot answer, our team will take over." The backend's sweeper hands the turn to staff after a few minutes, so a stranded thread does not stay in the typing state.
The composer sits in the page scaffold's footer, above the keyboard. On web and desktop Enter sends and Shift+Enter breaks the line (a pending IME composition keeps Enter); on a phone Enter breaks the line and the round send button sends. A write the backend refuses for a reason other than validation shows under the field as writeError.
Knowledge Base Articles#
When the backend runs magic-starter-laravel with support.articles.enabled, the create form and AI answers point the customer at its public knowledge-base articles:
- While writing. Once the new-conversation message has 12 characters and typing has paused for 600 ms, the form asks
GET support/articles?search=&locale=(the message cut to 200 characters, the app's language code) and shows up to three articles, question and excerpt, under a "These may help" label above Start conversation. A slower earlier answer never replaces a newer one, falling under 12 characters clears them, and a failed or switched-off (404) lookup shows nothing. - Under an AI answer. An AI message parses
articles(slug,question,url), and its bubble shows oneMagicStarterSupportArticleCardper article. No other sender's message renders them.
A tap opens the article's url, the public FAQ page of its language anchored on the article, with Launch.url; a url the system cannot open shows article_open_error, and an article without a url is plain text. The labels are magic_starter.support.articles_label and magic_starter.support.article_open_error: add both to every locale the app ships, since the translator merges no fallback.
Topics#
A new conversation is filed under a topic. The chips come from magic_starter.support.topics (read by MagicStarterConfig.supportTopics()), which defaults to billing, technical, account, sales and other. The keys are wire values the backend validates against its own support.topics, so a topic added here has to exist there as well. A chip's label is magic_starter.support.topic.<topic>, and a topic a host added without translating it shows its key.
'support': {
'topics': ['billing', 'technical', 'other'],
},
The backend derives the subject from the first line of the message (the topic is shown beside it as a tag), so the create form asks for none; createConversation still accepts an optional subject.
The counter under the message field reads magic_starter.support.message_max_length (MagicStarterConfig.supportMessageMaxLength(), default 2000). It is display only: set it to the backend's support.message_max_length, which is what refuses a longer message.
On a phone the thread hides the bottom navigation, like a chat screen: registerMagicStarterSupportRoutes() registers it in the app shell (MagicStarterLayoutIds.app) with the page wrapped in MagicStarterHideBottomNav, so the bar slides away with the page and moving between support pages never rebuilds the layout. The list and the create page keep it.
Live Updates#
The list and the thread refetch when the backend broadcasts support.conversation.updated on the private channel App.Models.User.{id}. They subscribe through BroadcastListeners from magic_socket (see Listeners), which the notification bell shares, so neither listener replaces the other. With no broadcast connection configured, or while the socket is down, both screens poll every 30 seconds instead.
Contact Sales#
With the support feature on, the billing screen's custom tier ("Contact sales") opens a conversation with source contact_sales and the subject Contact sales: <plan name>, then lands on its thread. It names no topic: the backend files a Contact sales conversation under sales. With the feature off the button keeps its toast. A refusal toasts the backend's message and stays on the billing screen.
Rating a Resolved Conversation#
With support_csat on, a resolved thread ends with "How did we do?": a thumbs up or down and an optional comment (1000 characters at most), posted to support/conversations/{id}/rating. The block shows when the backend reports can_rate, or when the conversation already carries a rating, which the customer can then change: the backend overwrites it for as long as the conversation stays resolved. A down rating alerts staff on the backend, once per resolution. MagicStarterSupportController.rate() does the write and a refusal lands in getError('rating').
Attachments#
With support_attachments on, the create form and the thread composer get an "Attach files" button, and a message that has files may have no text. A bubble lists its files: an image shows as a thumbnail that opens full size, a PDF as a row that opens in the system viewer.
The files go up in the same request as the message. As soon as one is attached the request is multipart/form-data with one part per file (attachments[0] to attachments[2]); without a file it stays JSON. The controller refuses a pick the backend would refuse before it uploads it, from these keys:
'support': {
'attachments': {
'max_files': 3,
'max_bytes': 5 * 1024 * 1024,
'extensions': ['jpg', 'jpeg', 'png', 'webp', 'pdf'],
},
},
The defaults match the backend's support.attachments (max_files, max_kb, types); a host that changes one changes the other, since the backend enforces its own. An image is read with the bearer token (MagicStarterSupportController.attachmentHeaders()), because the file endpoint answers to the token alone. A PDF asks for a 60 second signed url (attachmentLink()) and hands it to the system viewer.
Client Info#
With support_client_context on, a new conversation carries the device and app details the customer did not type (MagicStarterSupportClientContext.collect()), sent as client_context[...]: app_version, build_number, platform, os_version, locale and timezone. Each value is cut to 120 characters, and a value the device cannot tell is left out.
Page Context#
A customer chatting from a monitor page should not have to describe it. MagicStarter.usePageContext registers a provider that answers which things the current screen is about, as MagicStarterSupportPageRefs: a kind and an id, a typed pointer and never free text. The package knows no kinds of its own; you choose them, and the backend allows only the kinds it is configured with.
MagicStarter.usePageContext(() {
final id = MagicRouter.instance.routerConfig.state.pathParameters['monitor'];
return [
if (id != null) MagicStarterSupportPageRef(kind: 'monitor', id: id),
];
});
Read the top route through routerConfig.state. A page opened from a list is pushed, and MagicRouter.instance.pathParameter and the router's currentConfiguration keep naming the page underneath it.
The provider is asked when a message is sent, for a new conversation and for every reply, so it answers for the screen behind the Send button, and the references travel as page_context. At most three are kept, in order, duplicates dropped, and kind and id are cut to 64 characters; one with an empty value is left out. The key is omitted when there are none.
The full /support pages send nothing as long as your provider returns nothing for those routes, while the chat panel, which floats over another screen, sends that screen's references. A provider that throws is logged as a warning and the message goes without references. Registering again replaces the provider, and there is no feature flag: a registered provider is the opt-in.
The Feedback Card#
With support_feedback on, MagicStarterFeedbackCard asks "How likely are you to recommend us?" on a 0 to 10 scale with an optional comment (2000 characters at most). Place it yourself, on the screen where a customer finishes something:
const MagicStarterFeedbackCard()
It renders nothing until the backend says the account is due (GET support/feedback, which the backend paces by account age and time since the last answer), and nothing for a guest or a signed-out visitor. "Not now" records a dismissal, which paces it like an answer. A score the backend counts as low opens, or continues, a conversation, and the thanks line then links to it. The backend never lets the AI desk answer that conversation.
The same form is the "Send feedback" page (/support/feedback, view key support.feedback) behind a profile menu item. That page and MagicStarterFeedbackCard(onDemand: true) skip the eligibility read and the dismissal. A guest or an account with an unverified email is not offered the menu item, since the backend refuses it.
Store Review#
With store_review on, the app can ask for the native store review sheet and offer a "Rate us" link.
Ask for the review yourself, at a moment that went well (a first check passed, a tenth incident resolved):
await MagicStarterStoreReview.maybeRequest();
maybeRequest() stays quiet unless every rule holds:
- never on the web;
- never for a guest or a signed-out user;
- never in the first 7 days of an account (
magic_starter.store_review.min_account_days); - at most once per 120 days on this device (
magic_starter.store_review.interval_days, remembered in theVault); - never in a run where the feedback card was shown, so a customer is not asked a question and then asked for a review in the same session;
With
support_feedbackon, the call first waits for the card's eligibility read (shared with the card) and stays quiet when the card is due, even if the card has not rendered yet; - never from a button. The platforms decide whether a sheet appears and say nothing back, so a button wired to it would sometimes do nothing. Call it from your own milestone.
It answers whether the platform was asked, not whether a sheet showed. "Rate us" in the profile menu is the button instead: it calls MagicStarterStoreReview.openStoreListing(), which opens the store page and has no quota. It shows on Android, and on iOS and macOS once the App Store id is set:
'store_review': {
'app_store_id': '1234567890',
'min_account_days': 7,
'interval_days': 120,
},
Android finds its listing from the package name. The feature uses the in_app_review package; the client info uses package_info_plus for the version and build number. Both come with magic_starter, and a host with no other use for them adds nothing.
Overriding a Screen#
Each screen resolves through the view registry, so a host replaces one the usual way:
MagicStarter.view.register('support.thread', () => const MySupportThreadView());
support.feedback joins support.index, support.create and support.thread when support_feedback is on, and support.chat (the launcher) and support.chat_panel (the floating panel) when support_chat is on.
Copy lives under magic_starter.support.*, magic_starter.nav.support and magic_starter.titles.support*. A host that published its own en.json adds those keys from assets/stubs/install/en.stub.
Gotchas#
| Mistake | Fix |
|---|---|
Enabling support without calling registerMagicStarterSupportRoutes() |
The flag alone mounts no page, and the profile menu still shows its Support item. Call it from your route provider; starter:doctor warns when it is missing. |
Turning on support_csat, support_feedback, support_attachments or support_client_context on the client only |
They call routes and fields the backend adds only with its feature of the same name. Turn on both sides. |
Mounting MagicStarterSupportChatLauncher more than once |
A second launcher opens a second panel. Mount it once in the shell. |
Passing a className to the launcher that only tweaks the default |
A value replaces the default trigger classes. Give it a full list, including flex flex-row. |
Adding a topic in magic_starter.support.topics the backend does not know |
The keys are wire values the backend validates against its own support.topics. Add it on both sides. |
Calling MagicStarterStoreReview.maybeRequest() from a button |
The platform decides whether a sheet appears and says nothing back. Call it from a milestone; use "Rate us" (openStoreListing()) for a button. |
Related Documentation#
- Profile: the settings hub and the profile menu that carries the Support item.
- Teams: the billing screen whose "Contact sales" opens a conversation.
- Views and Layouts: the view registry the screens resolve through.
- Configuration: the feature flags and the
supportkeys. - Listeners: the broadcast listeners the live updates share.