Prima Get an API key

Module kit / UI design system

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
  • alchemy_labs/frontend/src/pages/Home.tsx
  • alchemy_labs/frontend/src/components/nav.tsx
  • alchemy_labs/frontend/src/services/emdashService.ts
  • alchemy_labs/frontend/src/services/api.ts
  • alchemy_labs/frontend/src/App.tsx
  • alchemy_labs/frontend/src/index.css
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-11
Version 1

Purpose

Populated: numbers, feed, videos default server rendered, hydrated in view
Workspace

Guten Tag, Mira

Alles aus Praxis am See an einem Ort.

Schnellzugriff

Dashboard-Werte

Letzte Aktivitäten

Alle anzeigen
Lea Frei ist als Lead eingegangen
Kontaktformular · gerade eben
Noah Brunner ist als Lead eingegangen
Newsletter · vor 3 Std.
Sara Meier hat eine Bestellung aufgegeben
Store · vor 5 Std.
CHF 240
Tim Huber hat einen Termin gebucht
Buchungsseite · vor 1 Tagen
Besucher ist als Lead eingegangen
Kontaktformular · vor 2 Tagen
Anna Roth hat eine Bestellung aufgegeben
Store · vor 3 Tagen
CHF 1’180

Benötigt deine Aufmerksamkeit

Aus dem gesamten Workspace zusammengetragen.

Hilfe-Center

Erste Schritte im Workspace
24 MB · MP4
Ein Formular bauen und einbetten
41 MB · MP4
Das Insights-Snippet installieren
18 MB · MP4

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

All reads, all through the interceptors of S2 (Bearer, X-Workspace-ID, one refresh on 401, one bootstrap on 409):

RouteUsed forParsed
GET /api/scribe/statsInsights card{ totals: { visitors, contacts, events } }
GET /api/forms/templatesForms card (active, sum of submission_count)array or { items }
GET /api/forms/leadsForms card and the activity feed (six newest by created_at or last_seen)array or { leads }
GET /api/productsStore card (is_active !== false)array or { items }
GET /api/ordersStore 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/videosHilfe-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

  1. PrivateRoute admits the user; Home renders inside <Navigation>; the shell's home tour may open (see onboarding-coachmarks).
  2. Greeting: hour of day picks the salutation; the name is the user's first name unless it contains @, then the email handle, then da.
  3. Schnellzugriff: DOMAINS.filter(d => d.id !== 'home' && !d.hidden && (!d.adminOnly || user.isAdmin)) renders one Link tile per module with the icon, the label, the blurb and, for prima, 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).
  4. Numbers: on currentWorkspace.id change, six reads run in Promise.allSettled (lines 131 to 141); the cards update from whatever settled; the six newest leads become the activity feed.
  5. Dashboard-Werte: six ModuleCards (lines 266 to 337), each a Link to the module route with a live chip, a big number, a label and two rows; Campaigns and Money show dashes (no read for them here).
  6. Letzte Aktivitäten lists the feed with timeAgo (gerade eben, vor n Std., vor n Tagen) or the empty sentence; Benötigt deine Aufmerksamkeit is three static setup links (forms, tracking, accounting).
  7. Hilfe-Center: HelpCenterVideos fetches on mount; null renders "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 DOMAINS with the same hidden and adminOnly rules 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 0 for 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 (HelpCenterVideos comment, 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, tnum and stagger classes from index.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: zeros, the empty feed sentence, the "bald verfügbar" card fresh-workspace server rendered, hydrated in view
Admin: the agency tiles appear admin server rendered, hydrated in view
Workspace

Guten Tag, Jonas

Alles aus Praxis am See an einem Ort.

Schnellzugriff

Dashboard-Werte

Letzte Aktivitäten

Alle anzeigen
Lea Frei ist als Lead eingegangen
Kontaktformular · gerade eben
Noah Brunner ist als Lead eingegangen
Newsletter · vor 3 Std.
Sara Meier hat eine Bestellung aufgegeben
Store · vor 5 Std.
CHF 240
Tim Huber hat einen Termin gebucht
Buchungsseite · vor 1 Tagen
Besucher ist als Lead eingegangen
Kontaktformular · vor 2 Tagen
Anna Roth hat eine Bestellung aufgegeben
Store · vor 3 Tagen
CHF 1’180

Benötigt deine Aufmerksamkeit

Aus dem gesamten Workspace zusammengetragen.

Hilfe-Center

Erste Schritte im Workspace
24 MB · MP4
Ein Formular bauen und einbetten
41 MB · MP4
Das Insights-Snippet installieren
18 MB · MP4
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
Design system
alchemy-ui
Packages
@prima-modules/tokens 0.1.0, from alchemy_labs fd8558f, @prima-modules/alchemy-ui 0.1.0, from alchemy_labs fd8558f, @prima-modules/demo 0.1.0, from prima-modules 5d496a7
Content
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14.

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

Workspace with a site: the filled tile with-site server rendered, hydrated in view
Workspace

Guten Tag, Mira

Alles aus Praxis am See an einem Ort.

Schnellzugriff

Dashboard-Werte

Letzte Aktivitäten

Alle anzeigen
Lea Frei ist als Lead eingegangen
Kontaktformular · gerade eben
Noah Brunner ist als Lead eingegangen
Newsletter · vor 3 Std.
Sara Meier hat eine Bestellung aufgegeben
Store · vor 5 Std.
CHF 240
Tim Huber hat einen Termin gebucht
Buchungsseite · vor 1 Tagen
Besucher ist als Lead eingegangen
Kontaktformular · vor 2 Tagen
Anna Roth hat eine Bestellung aufgegeben
Store · vor 3 Tagen
CHF 1’180

Benötigt deine Aufmerksamkeit

Aus dem gesamten Workspace zusammengetragen.

Hilfe-Center

Erste Schritte im Workspace
24 MB · MP4
Ein Formular bauen und einbetten
41 MB · MP4
Das Insights-Snippet installieren
18 MB · MP4

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.tsx at fd8558f (measured). What a new app adds: a jsdom render with renderWithProviders and 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.ts covers the tile targets indirectly (every paths[0] resolves to its own domain).
  • Playwright: e2e/critical-flows.spec.ts signs 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 with renderToString; tooling/verify/compare.mjs --state fresh-workspace against /dashboard on 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.tsx exists and nothing routes to it; /dashboard, /home and /workbench all render Home. A recipe that says "the dashboard" means this page.
  • 2026-09-11 (measured, Home.tsx line 206 versus AppShell.tsx line 65): the tiles admit agency modules for user.isAdmin only, 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 is uppercase 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_id was NULL for 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 -1 for a rejected read so the dash says "unknown".
  • 2026-09-11 (rendering trap): the home tour opens over this page in a fresh browser; the verifier sets alchemy.onboarding.off=1 for this recipe (rendering.verify.storage) and leaves it open for onboarding-coachmarks.
  • 2026-09-11 (rendering trap): the greeting reads the clock (Home.tsx line 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 why default on the docs page and /dashboard on 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), helpers chf and timeAgo (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: DOMAINS with blurb per domain (the tile subtitles), DomainIcon.
  • src/services/emdashService.ts: listWorkspaceSites (67 onward) on authedFetch, EmDashSiteStatus.
  • src/services/api.ts: the api singleton (S2).
  • src/App.tsx lines 406 to 416: the redirects and the /dashboard route; line 62: the orphan Dashboard import.
  • src/index.css: .card (252), .chip (431), stagger and tnum utilities.

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:

  1. grep -n -A5 'path="/dashboard"' src/App.tsx | grep -c "<Home />" prints 1; grep -rl "pages/Dashboard'" src | wc -l prints 1 (the import in App.tsx) and grep -c "<Dashboard" src/App.tsx prints 0.
  2. grep -c "<ModuleCard" src/pages/Home.tsx prints 6; grep -c "api.get('/api/" src/pages/Home.tsx prints 6.
  3. 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 shows 0 or 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.
  4. As an admin: the tiles include Campaigns, Money, Projects, Studio and Operate.
  5. Demo gate: npm run check prints no line for this recipe; node tooling/verify/render-smoke.mjs ui-design-system/ui/merchant-home-dashboard prints ok for the five states and 0 failures.
  6. 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.json writes a report with page_errors: []; the verifier reads mismatch_ratio with 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.