Merchant home dashboard
ui-design-system/ui/merchant-home-dashboardlive
| Kind | frontend |
|---|---|
| Surface | SPA |
| Maturity | prod |
| Reusable | extract |
| Design system | alchemy-ui |
| Stack | react-19, react-router-7, tailwind-3.4, alchemy-ui, axios-1 |
| Depends on | _shared/design-system-and-tokens, _shared/react-app-skeleton, ui-design-system/ui/appshell-navigation |
| Renders | no backend module: a design-system row |
| Entry | alchemy_labs/frontend/src/pages/Home.tsx |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-11 |
| Version | 1 |
Purpose
default server rendered, hydrated in view The first page a merchant sees after signing in: a greeting, one tile per module, six cards with the numbers that matter today, the latest leads, three setup nudges and the help videos. The invariant that makes it trustworthy is that the tiles are generated from the same DOMAINS model that draws the rail (appshell-navigation), so Home can never describe a module the rail does not have or link to a route that does not exist; and every number on a card comes from an endpoint the module itself owns, or is shown as a dash.
It makes visible, read-only: the forms templates and leads, products and orders, workspace bookings, the tracking totals, the workspace's EmDash sites, and the help videos.
When to use
It is the landing route of the merchant surface (SURFACE_HOME in S2's PrivateRoute); every sign-in and every click on the brand header lands here. Trigger phrases: "the dashboard", "the home page", "add a tile", "show a number for my module on Home". Adding a module to Home is adding a Domain to nav.tsx (the tile appears by itself) and, if it has a number, a ModuleCard fed by that module's read endpoint.
Do not use it as a reporting page: the cards are summaries with links, and the per-module pages own the detail. Do not confuse it with Dashboard.tsx, which is an orphan file at fd8558f: /dashboard renders Home (App.tsx lines 409 to 416).
Stack assumptions
S1 tokens, S2's skeleton (the api singleton with the workspace header and the single-flight refresh; authedFetch for the EmDash call), and the shell from appshell-navigation. Pinned per S2 (react ^19.1.1, react-router-dom ^7.8.2, axios ^1.11.0, tailwindcss ^3.4.17). Data-fetch style: api.get for the six JSON reads, wrapped in Promise.allSettled so one failing module cannot blank the page; listWorkspaceSites() from emdashService.ts uses authedFetch and attaches X-Workspace-ID itself (its comment at lines 63 to 66). Copy language: du-Form Swiss German (Guten Tag, Render, Alles aus deinem Workspace an einem Ort., Noch keine Aktivitäten); number formatting de-CH (Intl.NumberFormat('de-CH'), chf() for currency).
Data model
Client state in Home (Home.tsx lines 67 to 130): sum (store.products, store.openOrders, forms.active, forms.submissions, insights.identified, insights.total, bookings.upcoming; -1 means unknown and renders as a dash), activity: ActivityItem[] (id, who, what, amount, domain, source, at), emdashSite: { url, host } | null, and in HelpCenterVideos videos: { slug, title, url, size_bytes }[] | null (null is loading, [] is the honest empty card). Derived: greet from the hour, displayName from the user's first name or the email handle (never the workspace name; comment at lines 71 to 76), workspaceName.
Types: ActivityItem (lines 57 to 65), EmDashSiteStatus from emdashService.ts, DomainId from nav.tsx. Browser storage: none of its own (S2's auth and workspace keys; the onboarding keys of onboarding-coachmarks, whose home tour opens on this page).
API surface
loading server rendered, hydrated in view All reads, all through the interceptors of S2 (Bearer, X-Workspace-ID, one refresh on 401, one bootstrap on 409):
| Route | Used for | Parsed |
|---|---|---|
GET /api/scribe/stats | Insights card | { totals: { visitors, contacts, events } } |
GET /api/forms/templates | Forms card (active, sum of submission_count) | array or { items } |
GET /api/forms/leads | Forms card and the activity feed (six newest by created_at or last_seen) | array or { leads } |
GET /api/products | Store card (is_active !== false) | array or { items } |
GET /api/orders | Store card (status pending or paid) | array or { orders } |
GET /api/calendar/workspace/bookings?workspace_id= | Bookings card (future, not cancelled) | array |
GET /api/emdash/sites (authedFetch) | the EmDash-Login tile (first active site with an emdash_admin_url, else the first with one) | EmDashSiteStatus[] |
GET /api/help/videos | Hilfe-Center | { videos: [{ slug, title, url, size_bytes }] }; presigned links good for an hour, fetched on every mount, never cached |
Envelopes: the array-or-object tolerance is in the local arr() helper (lines 125 to 130). Errors: before the first settle every number is -1 and renders as a dash (sum initial state, lines 85 to 90); after it, a rejected list read counts as an empty list (arr(null) is [], so Store, Forms and Bookings show 0), and a rejected stats read keeps Insights at -1 (a dash); a failed videos call renders the empty card; nothing on this page shows an error banner (measured: there is no error state in the file, a deliberate choice for a landing page and a known weakness, see Gotchas).
Application logic
PrivateRouteadmits the user;Homerenders inside<Navigation>; the shell'shometour may open (seeonboarding-coachmarks).- Greeting: hour of day picks the salutation; the name is the user's first name unless it contains
@, then the email handle, thenda. Schnellzugriff:DOMAINS.filter(d => d.id !== 'home' && !d.hidden && (!d.adminOnly || user.isAdmin))renders oneLinktile per module with the icon, the label, theblurband, forprima, a "Beta" pill (lines 201 to 240). When the workspace has an EmDash site, one filled purple tile links to its admin URL in a new tab (lines 241 to 258).- Numbers: on
currentWorkspace.idchange, six reads run inPromise.allSettled(lines 131 to 141); the cards update from whatever settled; the six newest leads become the activity feed. Dashboard-Werte: sixModuleCards (lines 266 to 337), each aLinkto the module route with alivechip, a big number, a label and two rows; Campaigns and Money show dashes (no read for them here).Letzte Aktivitätenlists the feed withtimeAgo(gerade eben,vor n Std.,vor n Tagen) or the empty sentence;Benötigt deine Aufmerksamkeitis three static setup links (forms, tracking, accounting).Hilfe-Center:HelpCenterVideosfetches on mount;nullrenders "Videos werden geladen",[]renders the "bald verfügbar" card, otherwise a grid of<video controls preload="metadata">with title and size in MB.
Invariants
- The tiles cannot drift from the rail: they are computed from
DOMAINSwith the samehiddenandadminOnlyrules the shell applies (the shell also admits the public demo account; Home does not, see Gotchas). - No number without an endpoint: a card that has no read shows a dash, never a placeholder that looks live. A read that failed shows
0for a list count (see Gotchas). - One failing read never blanks the page (
allSettled). - The greeting names the user, never the workspace.
- Presigned video links are never cached: fetched on mount, good for an hour (
HelpCenterVideoscomment, lines 428 to 432). - The EmDash tile is a plain link to the admin URL: no prefill, no site management on Home (header comment, lines 24 to 29).
- The page paints with tokens (
ink,ink-60,ink-40,line,purple,purple-100,success,success-bg,warn,shadow-lift) and the.card,.chip,tnumandstaggerclasses fromindex.css.
Frontend
The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.
fresh-workspace server rendered, hydrated in view admin server rendered, hydrated in view ui-design-system/ui/merchant-home-dashboard/demo.tsx
/**
* Live demo for ui-design-system/ui/merchant-home-dashboard.
*
* Labs' Home.tsx (fd8558f, 521 lines) with its seven reads replaced by props:
* the summary numbers, the activity feed, the site tile, the help videos, the
* user, the workspace and the navigation model. Rendered inside the shell
* demo's component so the frame shows the page in its chrome, as the recipe
* describes. The greeting is a prop so the frame does not depend on the
* clock; -1 renders as a dash (the `fmt` helper, Home.tsx line 185).
*
* The section headings keep the uppercase tracking of the source (recipe
* Gotchas, against S1 rule 14) because the rebuild shows the page as it ships.
*/
import React from 'react';
import { Link } from 'react-router-dom';
import { defineStates } from '@prima-modules/demo';
import { Shell, type ShellUser, type ShellWorkspace } from '../appshell-navigation/demo';
import { DOMAINS, DomainIcon, type Domain, type DomainId } from '../appshell-navigation/nav';
/* ---- props ----------------------------------------------------------- */
export interface Summary {
store: { products: number; openOrders: number };
forms: { active: number; submissions: number };
insights: { identified: number; total: number };
bookings: { upcoming: number };
}
export interface ActivityItem {
id: string;
who: string;
what: string;
amount: number | null;
domain: DomainId;
source: string;
/** Hours ago; the page computes it from an ISO date, the demo fixes it. */
hoursAgo: number;
}
export interface Video {
slug: string;
title: string;
size_bytes: number;
}
export interface Props {
greeting: 'Guten Morgen' | 'Guten Tag' | 'Guten Abend';
user: ShellUser;
workspace: ShellWorkspace;
domains: Domain[];
sum: Summary;
activity: ActivityItem[];
emdashSite: { url: string; host: string } | null;
/** null is loading, [] is the honest empty card. */
videos: Video[] | null;
galleryMode?: boolean;
}
/* ---- sample data ------------------------------------------------------ */
const member: ShellUser = { name: 'Mira Keller', email: '[email protected]', isAdmin: false };
const admin: ShellUser = { name: 'Jonas Weber', email: '[email protected]', isAdmin: true };
const workspace: ShellWorkspace = { id: 'ws_1', name: 'Praxis am See', slug: 'praxis-am-see' };
const unknown: Summary = {
store: { products: -1, openOrders: -1 },
forms: { active: -1, submissions: -1 },
insights: { identified: -1, total: -1 },
bookings: { upcoming: -1 },
};
const zero: Summary = {
store: { products: 0, openOrders: 0 },
forms: { active: 0, submissions: 0 },
insights: { identified: 0, total: 0 },
bookings: { upcoming: 0 },
};
const populated: Summary = {
store: { products: 12, openOrders: 3 },
forms: { active: 4, submissions: 187 },
insights: { identified: 42, total: 1830 },
bookings: { upcoming: 7 },
};
const feed: ActivityItem[] = [
{ id: 'l1', who: 'Lea Frei', what: 'ist als Lead eingegangen', amount: null, domain: 'forms', source: 'Kontaktformular', hoursAgo: 0 },
{ id: 'l2', who: 'Noah Brunner', what: 'ist als Lead eingegangen', amount: null, domain: 'forms', source: 'Newsletter', hoursAgo: 3 },
{ id: 'l3', who: 'Sara Meier', what: 'hat eine Bestellung aufgegeben', amount: 240, domain: 'store', source: 'Store', hoursAgo: 5 },
{ id: 'l4', who: 'Tim Huber', what: 'hat einen Termin gebucht', amount: null, domain: 'bookings', source: 'Buchungsseite', hoursAgo: 26 },
{ id: 'l5', who: 'Besucher', what: 'ist als Lead eingegangen', amount: null, domain: 'forms', source: 'Kontaktformular', hoursAgo: 49 },
{ id: 'l6', who: 'Anna Roth', what: 'hat eine Bestellung aufgegeben', amount: 1180, domain: 'store', source: 'Store', hoursAgo: 72 },
];
const videos: Video[] = [
{ slug: 'erste-schritte', title: 'Erste Schritte im Workspace', size_bytes: 24 * 1048576 },
{ slug: 'formulare', title: 'Ein Formular bauen und einbetten', size_bytes: 41 * 1048576 },
{ slug: 'insights', title: 'Das Insights-Snippet installieren', size_bytes: 18 * 1048576 },
];
const base: Props = {
greeting: 'Guten Tag',
user: member,
workspace,
domains: DOMAINS,
sum: populated,
activity: feed,
emdashSite: null,
videos,
};
export const states = defineStates<Props>({
default: { label: 'Populated: numbers, feed, videos', props: base, height: 900, viewport: 1440 },
'fresh-workspace': { label: 'Fresh workspace: zeros, the empty feed sentence, the "bald verfügbar" card', props: { ...base, sum: zero, activity: [], videos: [] }, height: 900, viewport: 1440 },
admin: { label: 'Admin: the agency tiles appear', props: { ...base, user: admin }, height: 900, viewport: 1440 },
loading: { label: 'Before the first settle: dashes on every card, videos loading', props: { ...base, sum: unknown, activity: [], videos: null }, height: 900, viewport: 1440 },
'with-site': { label: 'Workspace with a site: the filled tile', props: { ...base, emdashSite: { url: 'https://example.com/admin', host: 'example.com' } }, height: 900, viewport: 1440 },
});
/* ---- helpers (Home.tsx lines 44 to 53 and 185) ----------------------- */
/* Grouping is done by hand, not with Intl.NumberFormat('de-CH'): Node's ICU
emits the ASCII apostrophe as the Swiss grouping separator while Chromium
emits U+2019, so the server-rendered text and the client's differed and
three states failed hydration (measured 2026-09-12). U+2019 is what the
app shows in the browser, so it is the separator here. */
const SEP = '\u2019';
const group = (n: number) => Math.round(Math.abs(n)).toString().replace(/\B(?=(\d{3})+(?!\d))/g, SEP);
const chf = (n: number) => `${n < 0 ? '-' : ''}CHF ${group(n)}`;
const fmt = (n: number) => (n < 0 ? DASH : group(n));
/* The app's placeholder glyph (Home.tsx line 185), written as its code point so the source carries no dash character. */
const DASH = '\u2014';
const timeAgo = (h: number) => {
if (h < 1) return 'gerade eben';
if (h < 24) return `vor ${h} Std.`;
return `vor ${Math.round(h / 24)} Tagen`;
};
/* ---- the page ---------------------------------------------------------- */
function ModuleCard({
id,
title,
to,
stat,
statLabel,
rows,
live,
}: {
id: DomainId;
title: string;
to: string;
stat: string;
statLabel: string;
rows: { label: string; value: string; warn?: boolean }[];
live?: boolean;
}) {
return (
<Link to={to} className="card p-5 hover:shadow-lift hover:-translate-y-0.5 transition-all group block">
<div className="flex items-center gap-2.5 mb-3">
<span className="h-8 w-8 rounded-lg bg-purple-100 text-purple grid place-items-center group-hover:bg-purple group-hover:text-white transition-colors">
<DomainIcon id={id} className="!w-[18px] !h-[18px]" />
</span>
<span className="font-bold text-ink text-[14.5px]">{title}</span>
{live ? <span className="ml-auto chip bg-success-bg text-success text-[11px]">live</span> : <span className="ml-auto chip bg-ink/5 text-ink-40 text-[11px]">concept</span>}
</div>
<div className="text-[24px] font-extrabold tnum text-ink leading-none">{stat}</div>
<div className="text-[12px] text-ink-40 mt-1">{statLabel}</div>
<div className="mt-3 pt-3 border-t border-line space-y-1.5">
{rows.map((r) => (
<div key={r.label} className="flex items-center justify-between text-[12.5px]">
<span className="text-ink-60">{r.label}</span>
<span className={`font-semibold tnum ${r.warn ? 'text-warn' : 'text-ink'}`}>{r.value}</span>
</div>
))}
</div>
</Link>
);
}
function HelpCenterVideos({ videos }: { videos: Video[] | null }) {
if (videos === null) {
return <div className="card p-6 text-center text-ink-40 text-[13px]">Videos werden geladen …</div>;
}
if (videos.length === 0) {
return (
<div className="card p-6 text-center">
<div className="font-bold text-ink text-[15px]">Video-Anleitungen: bald verfügbar</div>
<p className="text-ink-60 text-[13.5px] mt-1 max-w-md mx-auto">Kurze Videos, die dir die wichtigsten Bereiche zeigen. Noch in Arbeit.</p>
</div>
);
}
return (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{videos.map((v) => (
<div key={v.slug} className="card overflow-hidden">
{/* The app sets a presigned src; the demo has no network, so the element stays empty. */}
<video controls preload="none" playsInline className="w-full aspect-video bg-ink/90" />
<div className="px-4 py-3">
<div className="font-bold text-ink text-[14px]">{v.title}</div>
<div className="text-[11.5px] text-ink-40 mt-0.5">{Math.round(v.size_bytes / 1048576)} MB · MP4</div>
</div>
</div>
))}
</div>
);
}
export function HomePage({ greeting, user, workspace, domains, sum, activity, emdashSite, videos }: Props) {
const displayName = user.name.split(' ')[0] || user.email.split('@')[0] || 'da';
return (
<div data-gallery-focus="true">
<div className="mb-7">
<div className="text-[12px] font-semibold uppercase tracking-[0.14em] text-purple mb-1.5">Workspace</div>
<h1 className="text-[28px] leading-none font-extrabold tracking-[-0.02em] text-ink">
{greeting}, {displayName}
</h1>
<p className="text-ink-60 mt-2 text-[15px]">Alles aus {workspace.name} an einem Ort.</p>
</div>
<section className="mb-8">
<h2 className="text-[13px] font-semibold uppercase tracking-[0.1em] text-ink-40 mb-3">Schnellzugriff</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2.5">
{domains
.filter((d) => d.id !== 'home' && !d.hidden && (!d.adminOnly || user.isAdmin))
.map((d) => (
<Link key={d.id} to={d.paths[0]} className="flex items-start gap-2.5 rounded-xl border border-line bg-white px-3.5 py-3 hover:border-purple/40 hover:shadow-lift transition-all group">
<span className="h-8 w-8 rounded-lg bg-purple-100 text-purple grid place-items-center shrink-0 group-hover:bg-purple group-hover:text-white transition-colors">
<DomainIcon id={d.id} className="!w-[18px] !h-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="text-[13.5px] font-semibold text-ink truncate">{d.label}</span>
{d.id === 'prima' && (
<span className="shrink-0 rounded-full border border-purple/30 bg-purple-50 px-1.5 py-0.5 text-[9.5px] font-bold uppercase tracking-[0.08em] leading-none text-purple" title="Experimentelle Beta-Funktion">
Beta
</span>
)}
</span>
{d.blurb && <span className="block text-[11.5px] leading-snug text-ink-60 mt-0.5">{d.blurb}</span>}
</span>
</Link>
))}
{emdashSite && (
<a
href={emdashSite.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-start gap-2.5 rounded-xl border border-purple bg-purple text-white px-3.5 py-3 hover:bg-purple-700 hover:border-purple-700 hover:shadow-lift transition-all group"
>
<span className="h-8 w-8 rounded-lg bg-white/15 text-white grid place-items-center shrink-0">
<DomainIcon id="studio" className="!w-[18px] !h-[18px]" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-semibold truncate">CMS-Login</span>
<span className="block text-[11.5px] leading-snug text-white/75 mt-0.5">{emdashSite.host ? `Zum CMS deiner Website (${emdashSite.host})` : 'Zum CMS deiner Website'}</span>
</span>
</a>
)}
</div>
</section>
<h2 className="text-[13px] font-semibold uppercase tracking-[0.1em] text-ink-40 mb-3">Dashboard-Werte</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 stagger">
<ModuleCard
id="store"
title="Store"
to="/store"
live
stat={fmt(sum.store.products)}
statLabel="aktive Produkte"
rows={[
{ label: 'Aktive Produkte', value: fmt(sum.store.products) },
{ label: 'Offene Bestellungen', value: fmt(sum.store.openOrders) },
]}
/>
<ModuleCard
id="forms"
title="Forms"
to="/forms"
live
stat={fmt(sum.forms.submissions)}
statLabel="Einreichungen"
rows={[
{ label: 'Aktive Formulare', value: fmt(sum.forms.active) },
{ label: 'Einreichungen', value: fmt(sum.forms.submissions) },
]}
/>
<ModuleCard
id="campaigns"
title="Campaigns"
to="/ads"
live
stat={DASH}
statLabel="blended ROAS"
rows={[
{ label: 'Ausgaben · 30 Tage', value: DASH },
{ label: 'Conversions', value: DASH },
]}
/>
<ModuleCard
id="bookings"
title="Bookings"
to="/calendar"
live
stat={fmt(sum.bookings.upcoming)}
statLabel="bevorstehend"
rows={[
{ label: 'Bevorstehend', value: fmt(sum.bookings.upcoming) },
{ label: 'No-Show-Rate', value: DASH },
]}
/>
<ModuleCard
id="insights"
title="Insights"
to="/tracking"
live
stat={fmt(sum.insights.identified)}
statLabel="identifizierte Kontakte"
rows={[
{ label: 'Identifiziert', value: fmt(sum.insights.identified) },
{ label: 'Gesamt gesehen', value: fmt(sum.insights.total) },
]}
/>
<ModuleCard
id="money"
title="Money"
to="/accounting"
live
stat={DASH}
statLabel="ausstehend"
rows={[
{ label: 'Offene Rechnungen', value: DASH },
{ label: 'MWST fällig', value: DASH },
]}
/>
</div>
<div className="grid lg:grid-cols-[1.5fr_1fr] gap-6 mt-6">
<section className="card p-1.5">
<div className="flex items-center justify-between px-4 pt-3 pb-2">
<h2 className="font-bold text-ink">Letzte Aktivitäten</h2>
<Link to="/tracking" className="text-[13px] font-semibold text-purple hover:underline">
Alle anzeigen
</Link>
</div>
<div>
{activity.length === 0 && (
<div className="px-4 py-8 text-center text-ink-40 text-[13px]">Noch keine Aktivitäten. Sie erscheinen hier, sobald Besucher und Leads eingehen.</div>
)}
{activity.map((a) => (
<div key={a.id} className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-purple-50/60 transition-colors">
<span className="h-8 w-8 rounded-lg bg-purple-100 text-purple grid place-items-center shrink-0">
<DomainIcon id={a.domain} className="!w-4 !h-4" />
</span>
<div className="min-w-0 flex-1">
<div className="text-[13.5px] text-ink truncate">
<span className="font-semibold">{a.who}</span> {a.what}
</div>
<div className="text-[12px] text-ink-40">
{a.source} · {timeAgo(a.hoursAgo)}
</div>
</div>
{a.amount !== null && <div className="text-[13px] font-bold tnum text-ink">{chf(a.amount)}</div>}
</div>
))}
</div>
</section>
<section className="card p-5">
<h2 className="font-bold text-ink mb-1">Benötigt deine Aufmerksamkeit</h2>
<p className="text-[13px] text-ink-60 mb-4">Aus dem gesamten Workspace zusammengetragen.</p>
<div className="space-y-2.5">
<Link to="/forms" className="block rounded-xl border border-line p-3 hover:border-purple/40 transition-colors">
<div className="flex items-center justify-between gap-2">
<span className="text-[13.5px] font-semibold text-ink truncate">Veröffentliche dein erstes Formular</span>
<span className="chip bg-purple-100 text-purple shrink-0">Setup</span>
</div>
<div className="text-[12px] text-ink-60">Beginne, Leads von deiner Website zu erfassen</div>
</Link>
<Link to="/tracking" className="block rounded-xl border border-line p-3 hover:border-purple/40 transition-colors">
<div className="flex items-center justify-between gap-2">
<span className="text-[13.5px] font-semibold text-ink truncate">Installiere das Insights-Snippet</span>
<span className="chip bg-purple-100 text-purple shrink-0">Setup</span>
</div>
<div className="text-[12px] text-ink-60">Sieh, wer dich besucht und was konvertiert</div>
</Link>
<div className="rounded-xl border border-line p-3 flex items-center justify-between">
<span className="text-[13.5px] text-ink">Prüfe deine Buchhaltung</span>
<Link to="/accounting" className="text-[12px] font-semibold text-purple hover:underline">
Money
</Link>
</div>
</div>
</section>
</div>
<section className="mt-8">
<h2 className="text-[13px] font-semibold uppercase tracking-[0.1em] text-ink-40 mb-3">Hilfe-Center</h2>
<HelpCenterVideos videos={videos} />
</section>
</div>
);
}
export default function Demo(props: Props) {
return (
<Shell domains={props.domains} user={props.user} workspaces={[props.workspace]} currentWorkspaceId={props.workspace.id} activePath="/dashboard" hasTour hideChrome={props.galleryMode}>
<HomePage {...props} />
</Shell>
);
}
Component tree, from the recipe
<Route /dashboard> PrivateRoute > Home (/home and /workbench redirect here; Dashboard.tsx is an orphan)
Navigation = AppShell (see appshell-navigation)
div
header "Workspace" overline, h1 "Guten Morgen | Guten Tag | Guten Abend, <first name>", "Alles aus <workspace> an einem Ort."
section "Schnellzugriff" grid 1 / 2 / 4 columns
Link.tile x DOMAINS (not home, not hidden, adminOnly only for admins): DomainIcon, label, blurb, "Beta" pill on prima
a.tile--filled "EmDash-Login" -> the workspace's first active site admin URL (only when one exists)
h2 "Dashboard-Werte" grid 1 / 2 / 3 columns, staggered fade-up
ModuleCard x 6: Store (/store), Forms (/forms), Campaigns (/ads), Bookings (/calendar), Insights (/tracking), Money (/accounting)
icon, title, "live" | "concept" chip, big number (tnum), label, two rows
grid 2 : 1
section.card "Letzte Aktivitäten" (six most recent leads, or the empty sentence) . "Alle anzeigen" -> /tracking
section.card "Benötigt deine Aufmerksamkeit" (three setup links: forms, tracking, accounting)
section "Hilfe-Center" HelpCenterVideos: loading sentence | "bald verfügbar" card | grid of <video> cards The rendering is the live rebuild merchant-home-dashboard/demo.tsx, built from @prima-modules/alchemy-ui and @prima-modules/tokens and rendered by the docs host as one frame per state (RENDERING.md, section 3). It is Home.tsx (521 lines at fd8558f) with the seven reads replaced by props, { sum, activity, emdashSite, videos, user, workspace, domains, greeting }, rendered inside the shell rebuild from appshell-navigation/demo.tsx so the frame shows the page in its chrome as the tree describes. The greeting is fixed to "Guten Tag" through sample data so the frame does not depend on the clock; the name, the workspace and the leads are fictional.
States: default (populated: numbers on the four live cards, six leads in the feed, three video cards with sample titles and sizes, no video source since nothing may reach the network), fresh-workspace (zeros and dashes, the empty-feed sentence, the "bald verfügbar" card; the state the scratch workspace renders), admin (the agency tiles appear), loading (every number a dash, -1 semantics from lines 85 to 90, and "Videos werden geladen"), with-site (the filled purple EmDash tile with the host in its subtitle). A 390 px frame is the page alone in one column, because the shell hides its sidebar below sm.
Tokens: paper ground from the shell, white cards with border-line and rounded-xl (.card in index.css line 252), purple for the overline, the icon tiles (purple-100 ground, purple glyph, inverted on hover) and the filled EmDash tile, success-bg and success for the "live" chip, ink-40 for the section headings, shadow-lift on hover, animate-fade-up via the stagger class.
States: populated (numbers, feed, videos); fresh workspace (zeros, dashes, the empty feed sentence, the "bald verfügbar" card, rendered); admin (agency tiles appear); workspace with a site (the filled tile); loading (Videos werden geladen for the help section only; the cards render immediately with dashes); error (none rendered, see API surface). Responsive: tiles 1, 2 then 4 columns (sm, lg); cards 1, 2 then 3; the activity and attention pair stacks below lg.
Security & tenancy
with-site server rendered, hydrated in view The browser holds what S2 says. Every read carries X-Workspace-ID and the backend re-validates membership; the page trusts the response for the current workspace and nothing else. The EmDash tile opens a third-party admin URL returned by the API in a new tab with rel="noopener noreferrer"; the page never holds the site's token (EmDashSiteStatus carries the URL, not the Fernet-encrypted token the registry stores). Video URLs are short-lived presigned links; they are never persisted. adminOnly tiles are not rendered for members, and the pages behind them bounce non-admins (S2): nothing on Home is hidden by CSS alone.
Tests
- No colocated test for
Home.tsxat fd8558f (measured). What a new app adds: a jsdom render withrenderWithProvidersand the six reads stubbed (a) all rejected: the page renders with dashes and the empty sentences; (b) fulfilled with fixtures: the numbers and the six feed rows; (c) as a member: no agency tile; as admin: the agency tiles; (d) with a site: the filled tile with the host in its subtitle. nav.test.tscovers the tile targets indirectly (everypaths[0]resolves to its own domain).- Playwright:
e2e/critical-flows.spec.tssigns in and lands on/dashboard; a gate spec for this page asserts the greeting and one tile per visible domain. - The kit's render smoke (
node tooling/verify/render-smoke.mjs) renders the five demo states withrenderToString;tooling/verify/compare.mjs --state fresh-workspaceagainst/dashboardon the harness is the visual check, and its report is never committed.
Deploy & ops
Ships in the Labs bundle (S2). The video section depends on the backend's GET /api/help/videos and its object store; an empty or failing list never breaks the page. The EmDash tile depends on the sites registry; a workspace without sites simply has no tile. No env of its own. Rollback is the bundle's.
Gotchas
- 2026-09-11 (measured):
src/pages/Dashboard.tsxexists and nothing routes to it;/dashboard,/homeand/workbenchall renderHome. A recipe that says "the dashboard" means this page. - 2026-09-11 (measured,
Home.tsxline 206 versusAppShell.tsxline 65): the tiles admit agency modules foruser.isAdminonly, while the shell also admits the public demo account by email. The demo account sees agency icons in the rail and no agency tiles on Home. A new app keys both on one surface grant. - 2026-09-11 (measured, lines 202, 262, 417): the three section headings are
uppercase tracking-[0.1em]and the overline at line 191 isuppercase tracking-[0.14em], against S1 rule 14 (founder rule of 2026-08-08). Pre-rule code; a new app sets them in sentence case. - 2026-09-11 (measured, lines 290 to 337): the Campaigns and Money cards render dashes for every value; there is no read for either on this page. They are honest placeholders, and the wiki's dashboards configuration (ADR-0038) is the planned successor for per-module summaries.
- 2026-09-11 (measured, lines 86 to 97):
emdash_sites.workspace_idwasNULLfor the early sites, so the earlier project-based lookup found nothing for the clients that had sites; the page now lists sites for the current workspace directly. Sites registered before the column existed still need the backfill. - 2026-09-11 (measured): the page uses pixel-literal type sizes throughout (
text-[28px],text-[13.5px],text-[11.5px]), against S1 rule 1; the shell does the same. A new app names them. - 2026-09-11 (measured, lines 125 to 130 and 142 to 158): a rejected list read is indistinguishable from an empty list on the card (
0), so an API outage on Forms reads as "no forms". A new app keeps-1for a rejected read so the dash says "unknown". - 2026-09-11 (rendering trap): the
hometour opens over this page in a fresh browser; the verifier setsalchemy.onboarding.off=1for this recipe (rendering.verify.storage) and leaves it open foronboarding-coachmarks. - 2026-09-11 (rendering trap): the greeting reads the clock (
Home.tsxline 71) and a video card holds a presigned link; the demo fixes the salutation through a prop and renders the video cards without a source, which is whydefaulton the docs page and/dashboardon the harness are compared on everything except the greeting and the video tiles.
Reference implementation
Branch: alchemy_labs d1a-token-source at fd8558f (2026-09-11), forked from feat/service-desk b1089b6. Paths relative to frontend/:
src/pages/Home.tsx(521 lines): header comment (10 to 41), helperschfandtimeAgo(44 to 53),ActivityItem(57 to 65),Home(67 to 426: greeting 71 to 78, state 80 to 99, EmDash site effect 101 to 122, the six reads 124 to 185, header 190 to 197, Schnellzugriff 201 to 260, Dashboard-Werte 262 to 338, activity and attention 340 to 412, Hilfe-Center 416 to 421),HelpCenterVideos(433 to 477),ModuleCard(479 to 521).src/components/nav.tsx:DOMAINSwithblurbper domain (the tile subtitles),DomainIcon.src/services/emdashService.ts:listWorkspaceSites(67 onward) onauthedFetch,EmDashSiteStatus.src/services/api.ts: theapisingleton (S2).src/App.tsxlines 406 to 416: the redirects and the/dashboardroute; line 62: the orphanDashboardimport.src/index.css:.card(252),.chip(431),staggerandtnumutilities.
Deviations of the reference from this recipe, dated 2026-09-11: the orphan Dashboard.tsx; a failed list read rendering as 0; the demo-account mismatch with the shell; uppercase section headings; dashes on two cards; pixel-literal type sizes; no tests.
Acceptance checks
From alchemy_labs/frontend at fd8558f, or a new app built to this recipe:
grep -n -A5 'path="/dashboard"' src/App.tsx | grep -c "<Home />"prints1;grep -rl "pages/Dashboard'" src | wc -lprints1(the import inApp.tsx) andgrep -c "<Dashboard" src/App.tsxprints0.grep -c "<ModuleCard" src/pages/Home.tsxprints6;grep -c "api.get('/api/" src/pages/Home.tsxprints6.- Browser walk-through as the owner of a fresh workspace: (a) sign in; the URL is
/dashboard; the greeting matches the time of day and the first name; (b) count the tiles: one per rail icon minus Home, none for agency modules; (c) every card shows0or a dash and every card links to its module page; (d) the activity card shows "Noch keine Aktivitäten"; (e) the help section shows the "bald verfügbar" card; (f) submit a form through the hosted widget and return: the Forms card counts one submission and the feed shows the lead with "ist als Lead eingegangen"; (g) resize to 700 px: tiles in two columns, cards in two, the attention card below the activity card. - As an admin: the tiles include Campaigns, Money, Projects, Studio and Operate.
- Demo gate:
npm run checkprints no line for this recipe;node tooling/verify/render-smoke.mjs ui-design-system/ui/merchant-home-dashboardprintsokfor the five states and0 failures. - Verifier:
node tooling/verify/compare.mjs ui-design-system/ui/merchant-home-dashboard --state fresh-workspace --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.jsonwrites a report withpage_errors: []; the verifier readsmismatch_ratiowith the greeting line excluded (time of day) and expects the tiles, the six cards and the two empty sentences to match.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.