Prima Get an API key

Module kit / Prospecting

Outreach Campaign Workspace

prospecting/ui/outreach-campaign-workspacelive

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, dompurify
Depends on _shared/design-system-and-tokens, _shared/react-app-skeleton, _shared/error-envelope
Renders prospecting/leads-deals-and-crm-management, prospecting/outreach-campaign-lead-import, prospecting/lead-website-enrichment, prospecting/outreach-ai-draft-composition, prospecting/outreach-confirm-gate-and-send
Entry alchemy_labs/frontend/src/pages/AdminOutreachCampaign.tsx
Reference implementation
  • alchemy_labs/frontend/src/pages/AdminOutreachCampaign.tsx
  • alchemy_labs/frontend/src/components/OutreachEmailPreview.tsx
  • alchemy_labs/frontend/src/components/OutreachLeadImport.tsx
  • alchemy_labs/frontend/src/components/AdminNavigation.tsx
  • alchemy_labs/frontend/src/components/AdminRoute.tsx
  • alchemy_labs/frontend/src/services/outreachService.ts
  • alchemy_labs/frontend/src/types/outreach.ts
  • alchemy_labs/frontend/src/utils/sanitize.ts
  • alchemy_labs/frontend/src/App.tsx
  • alchemy_labs/backend/api/routers/outreach_router.py
  • alchemy_labs/backend/services/outreach/outreach_service.py
  • alchemy_labs/backend/app_fastapi.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

The admin's one screen for a single cold-outreach campaign: at /admin/outreach/:id an admin imports leads (one at a time or a CSV/XLSX drop), triggers the scrape-and-compose pipeline, reviews and edits every AI-drafted email in a modal, and hits Freigeben (the confirm gate) on each one before the send queue is allowed to touch it. The invariant that makes it trustworthy: nothing here sends anything directly; every write this page makes either changes a draft email's content or moves it from draft to approved, and the actual send only ever happens later, out of band, for rows the backend finds already approved (Application logic).

It makes visible five backend recipes: the lead/CRM model this campaign's leads live in (leads-deals-and-crm-management), the two ways leads get onto a campaign (outreach-campaign-lead-import), the scrape half of the pipeline (lead-website-enrichment), the AI-compose half (outreach-ai-draft-composition), and the approval gate plus the sender that reads it (outreach-confirm-gate-and-send).

When to use

Trigger phrases: "the outreach campaign page", "review the drafted emails", "approve this email", "Freigeben", "import leads for outreach", "scrape and generate", "the cold-email workspace". Surface: SPA, an admin-only page behind AdminRoute (redirects a non-admin to /), reached at /admin/outreach/:id (App.tsx line 880); the campaign list it is opened from is /admin/outreach (AdminOutreach.tsx, line 872), a different, simpler recipe, not this one.

Not this recipe: the campaign list/create page (AdminOutreach.tsx), the actual send that happens later for an approved row (outreach-confirm-gate-and-send, the scheduler and Gmail send path this page never calls), the scrape and AI-compose work itself (lead-website-enrichment, outreach-ai-draft-composition, both triggered by one button here but implemented entirely server-side, synchronously, inside the one scrape-and-generate request), the click/open tracking pixel and redirect a sent email's open_count/click_count come from (outreach-confirm-gate-and-send's own tracking routes, read-only here), and the generic CRM lead list a lead also exists in (leads-deals-and-crm-management, a different admin surface this page never links to; Gotchas).

Stack assumptions

S1 tokens and S2 skeleton assumed; pinned per the source at 62ba4fc: react ^19.1.1, react-router-dom ^7.8.2, tailwindcss ^3.4.17, lucide-react ^0.563.0, axios ^1.11.0, dompurify (the email-body sanitizer, not one of the four packages a demo may import from, Frontend). Component library: ../components/alchemy-ui (the page-local re-export @prima-modules/alchemy-ui mirrors), of which the three files together use Text, Heading, Card, Button, Badge and, in the loading branch only, Spinner; Input and Textarea are imported on the main page and never used anywhere in any of the three files, a dead import (Gotchas); every text field on this page, including the preview modal's edit form, is a raw <input>/<textarea> styled by hand, not the design system's form atoms. Data-fetch style: the api singleton (S2), Bearer header from accessToken. Copy language: German, de-CH, uniformly; unlike billing-credits/ui/subscription-manager's one stray English sentence, every string in these three files, including every error fallback, is German.

Data model

Client state (AdminOutreachCampaign.tsx lines 38 to 50), all page-local, nothing persisted beyond S2's two tokens:

interface OutreachCampaign {                 // GET/PUT /api/crm/outreach/campaigns/{id}
  id: string; name: string; description?: string;
  status: 'draft' | 'active' | 'paused' | 'completed';
  send_interval_minutes: number; daily_send_limit: number;
  template_prompt?: string; sender_name?: string; sender_email?: string;
  total_leads: number; total_sent: number; total_opened: number;
  total_clicked: number; total_replied: number; total_bounced: number;
  created_at: string;
}
interface OutreachEmail {                    // one row of GET .../emails, lead fields denormalized server-side
  id: string; campaign_id: string; lead_id: string;
  subject?: string; body_html?: string;
  status: 'pending_scrape'|'scraping'|'scrape_failed'|'pending_compose'|'composing'
        | 'draft'|'approved'|'queued'|'sending'|'sent'|'opened'|'clicked'|'replied'|'bounced'|'failed';
  open_count: number; click_count: number; sent_at?: string; error_message?: string; created_at: string;
  lead_email?: string; lead_name?: string; lead_company?: string; lead_website?: string;
}
campaign: OutreachCampaign | null; emails: OutreachEmail[]; loading: boolean; error: string | null;
activeTab: 'leads' | 'emails' | 'settings'; selectedEmail: OutreachEmail | null;
processing: boolean; approvingAll: boolean; emailFilter: string;
editSettings: CampaignUpdate;                // seeded from campaign on every successful fetchCampaign()
savingSettings: boolean;

OutreachEmailPreview.tsx owns a second, nested slice local to the modal: editing: boolean; subject: string; bodyHtml: string; saving: boolean; approving: boolean; error: string | null, all seeded from the email prop on open and discarded on close. OutreachLeadImport.tsx owns a third: mode: 'idle'|'single'|'file'; singleLead: LeadImportItem; adding/uploading: boolean; error/successMsg: string|null; dragOver: boolean.

The demo's props (demo.tsx): { viewer, workspace, activeTab, campaign, emails, emailFilter?, error?, processing?, approvingAll?, selectedEmail?, previewEditing?, previewSaving?, previewApproving?, previewError? }; editSettings and the preview's subject/bodyHtml are seeded into local useState from campaign/selectedEmail the same way subscription-manager's demo seeds its voucher input.

Browser storage: nothing of its own, relies on S2's accessToken/refreshToken.

API surface

Nine calls (outreachService.ts), all under /api/crm/outreach, all AdminUser + a real WorkspaceID dependency (outreach_router.py):

CallRouteParsedRendered as
getCampaign(id)GET /campaigns/{id}the whole body into campaign, seeds editSettingsheader, quick stats, settings form defaults
listEmails(id, status?)GET /campaigns/{id}/emails{emails, total}.emails; server joins Lead per row (N+1 avoided by a batch query, outreach_router.py 96 to 133) to denormalize lead_email/lead_name/lead_company/lead_websitethe Emails tab's rows; a throw here is swallowed (catch {}, line 76), campaign load is what surfaces the error
addLeads(id, [item])POST /campaigns/{id}/leads{added}OutreachLeadImport's success line
importFile(id, file)POST /campaigns/{id}/import (multipart){added, skipped}same, pluralized with the skip count
scrapeAndGenerate(id)POST /campaigns/{id}/scrape-and-generatediscarded; both fetchCampaign and fetchEmails rerun afterthe whole tab re-renders with whatever the pipeline reached synchronously (Application logic)
updateCampaign(id, data)PUT /campaigns/{id}the updated campaign, replaces campaignused for both the status toggle ({status}) and the settings save (the seven editSettings fields); the same call, two call sites
updateEmail(id, {subject, body_html})PUT /emails/{id}discarded; onUpdate() refetches both campaign and emailsthe modal closes on success (handleEmailUpdate, line 142 to 146)
approveEmail(id)POST /emails/{id}/approvediscarded; same refetch-and-close
approveAll(id)POST /campaigns/{id}/approve-all{approved_count}, discardedjust a refetch of the email list

Error bodies as these files read them: every refusal from all nine calls is read as err.response?.data?.detail (9 call sites across the three files: AdminOutreachCampaign.tsx 67, 98, 111, 124, 136; OutreachEmailPreview.tsx 33, 46; OutreachLeadImport.tsx 34, 60). The Labs envelope wraps a plain HTTPException(detail=...) under the key error, not detail (app_fastapi.py's exception handler, the same shape billing-credits/ui/subscription-manager and identity-auth/ui/admin-users-console each document independently for their own routers), so every one of this page's nine fixed German fallback sentences is what actually reaches the screen; the server's real message ("Cannot approve email with status sent", "Email not found", a scrape timeout's own text) is discarded every time. No WebSocket, no postMessage.

Application logic

  1. On mount, fetchCampaign() and fetchEmails() run in parallel (Promise.all, line 84); while both are in flight the whole page is a single centered spinner inside the shell (line 152 to 159), not per-section loading.
  2. Three counters and the tab bar derive from emails: draftCount, approvedCount (both simple .filter().length), and the emails tab's own label, E-Mails (${emails.length}) (line 175 to 182).
  3. Leads tab: OutreachLeadImport posts one lead or a file, then calls onLeadsAdded which refetches both campaign and emails (handleLeadsAdded, line 148 to 150); a lead import alone never creates an OutreachEmail row by itself in what this page shows; the count only changes because total_leads on the refetched campaign changes. "Scrapen & Generieren" is disabled at total_leads === 0 and otherwise always clickable, regardless of how many emails are already past that stage; it does not filter to "only new leads" visually, the server does that filtering (Data model, pending_scrape/pending_compose queries).
  4. scrapeAndGenerate runs synchronously inside one HTTP request: the backend scrapes every pending_scrape email in a loop, committing after each one, then composes every now-pending_compose email as one batch, and only then returns (outreach_service.py 189 to 235); the frontend has no progress indicator beyond the button's own spinner for however long that takes, and no partial-result rendering; the button either has not returned yet, or the whole tab is up to date.
  5. A row in the Emails tab list is only ever clickable to open the preview modal (onClick={() => setSelectedEmail(email)}, line 345); there is no other way to reach it, and no lead list anywhere on this page; the Leads tab has an import form and nothing else, no table of leads already added (Gotchas).
  6. The preview modal: canEdit = status === 'draft' || status === 'approved'; canApprove = status === 'draft' only (OutreachEmailPreview.tsx line 52 to 53). Editing is a pure client toggle, no network call; Speichern posts {subject, body_html} to PUT /emails/{id} and, on success, calls onUpdate(), which refetches both lists and closes the modal (handleEmailUpdate, not just the edit form); there is no "save and keep reviewing" path, every edit reopens from the list. Freigeben posts to /approve and does the same close-and-refetch.
  7. Approving is the confirm gate the campaign's whole workflow depends on: approve_email (backend) refuses with a 400 unless the current status is exactly draft (outreach_service.py 265 to 276); nothing else in this codebase moves a row to approved except this endpoint and approve-all, and the send path this page does not own (outreach-confirm-gate-and-send) only ever picks up rows already approved.

Invariants

  • Every action that changes what is shown refetches rather than patches locally: handleLeadsAdded, handleScrapeAndGenerate, handleApproveAll, handleEmailUpdate all re-GET before the UI settles, the same pattern subscription-manager's Invariants section names for its own three writes.
  • The gate has one direction and one authority: only POST /emails/{id}/approve (or /approve-all) can turn draft into approved, and only when the current status is draft exactly; approving an already-approved or already-sent row 400s server-side, though this page never attempts to (canApprove hides the button first).
  • Approving does not lock content: update_email (backend, outreach_service.py 251 to 262) applies a PUT /emails/{id} with no status check at all, so an approved email's subject or HTML can still be edited (the modal's canEdit allows it, "Bearbeiten" stays visible) with no re-approval step and no server-side signal that a previously-confirmed draft changed after confirmation; a real gap in the confirm-gate this page's own copy ("Freigeben") implies is final (Gotchas).
  • The raw server error is never shown: all nine writes' catch blocks render one of the page's own fixed German sentences, never err.response?.data?.error or .message (API surface).

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.

A brand-new campaign, no leads yet: the import panel and a disabled "Scrapen & Generieren" leads-tab-empty server rendered, hydrated in view

Kampagne

Q4 Handwerksbetriebe Outreach

Kaltakquise für lokale Handwerksbetriebe im Grossraum Zürich

Leads

0

Gesendet

0

Geöffnet

0

Geklickt

0

Antworten

0

Websites scrapen und E-Mails per KI generieren

24 leads imported: the action bar enabled, and a top-level error from an earlier failed attempt leads-tab-with-leads server rendered, hydrated in view

Kampagne

Q4 Handwerksbetriebe Outreach

Kaltakquise für lokale Handwerksbetriebe im Grossraum Zürich

Leads

24

Gesendet

0

Geöffnet

0

Geklickt

0

Antworten

0

Verarbeitung fehlgeschlagen

Websites scrapen und E-Mails per KI generieren

Leads imported but scrape+generate not run yet: the emails tab’s own fallback card emails-tab-empty server rendered, hydrated in view

Kampagne

Q4 Handwerksbetriebe Outreach

Kaltakquise für lokale Handwerksbetriebe im Grossraum Zürich

Leads

24

Gesendet

0

Geöffnet

0

Geklickt

0

Antworten

0

0 E-Mails

Noch keine E-Mails. Füge Leads hinzu und starte die Generierung.

The pipeline mid-flight: scrape/compose in progress, two drafts, "Alle freigeben (2)" visible emails-tab-drafts-pending-approval server rendered, hydrated in view

Kampagne

Q4 Handwerksbetriebe Outreach

Kaltakquise für lokale Handwerksbetriebe im Grossraum Zürich

Leads

24

Gesendet

0

Geöffnet

0

Geklickt

0

Antworten

0

7 E-Mails

Frau Brunner

@ Muster Gärtnerei

Kein Betreff

Warte auf Scrape

Herr Frei

@ Studio Lindwurm

Kein Betreff

Scraping...

Herr Tanner

@ Holzbau Tanner

Kein Betreff

Scrape fehlgeschlagen

Frau Widmer

@ Atelier Form

Kein Betreff

Warte auf KI

Herr Huber

@ Bäckerei Huber

Kein Betreff

KI schreibt...

Herr Keller

@ Schreinerei Nordwind

Kurze Frage zu Ihrer Website, Schreinerei Nordwind

Entwurf

Frau Steiner

@ Café Fernweh

Idee für Café Fernweh

Entwurf
One approved, the rest through the send pipeline: no draft button, "1 bereit zum Senden" badge emails-tab-approved-ready-to-send server rendered, hydrated in view

Kampagne

Q4 Handwerksbetriebe Outreach

Kaltakquise für lokale Handwerksbetriebe im Grossraum Zürich

Leads

24

Gesendet

8

Geöffnet

4

Geklickt

2

Antworten

1

9 E-Mails

1 bereit zum Senden

Frau Meier

@ Gartenbau Meier

Kurze Idee für Gartenbau Meier

In Warteschlange

Herr Vogel

@ Sanitär Vogel

Sende gerade...

Wird gesendet...

Frau Keller

@ Coiffeur Bellevue

Idee für Coiffeur Bellevue

Gesendet

Herr Frei

@ Elektro Frei

Mehr Anfragen für Elektro Frei

Geöffnet

Frau Sutter

@ Physio Zentral

Website-Idee für Physio Zentral

Geklickt

Herr Wyss

@ Bio-Metzgerei Wyss

Kurzes Angebot für Bio-Metzgerei Wyss

Antwort erhalten

[email protected]

@ Reinigung Blitzblank

Idee für Reinigung Blitzblank

Bounced

Herr Sur

@ Velo Werkstatt Sur

Website-Check für Velo Werkstatt Sur

Fehlgeschlagen

Herr Rufener

@ Malergeschäft Rufener

Mehr Anfragen für Malergeschäft Rufener

Freigegeben
The preview modal open on a draft, in its editing layout (subject + raw HTML textarea) email-preview-draft-editable server rendered, hydrated in view

Kampagne

Q4 Handwerksbetriebe Outreach

Kaltakquise für lokale Handwerksbetriebe im Grossraum Zürich

Leads

24

Gesendet

0

Geöffnet

0

Geklickt

0

Antworten

0

7 E-Mails

Frau Brunner

@ Muster Gärtnerei

Kein Betreff

Warte auf Scrape

Herr Frei

@ Studio Lindwurm

Kein Betreff

Scraping...

Herr Tanner

@ Holzbau Tanner

Kein Betreff

Scrape fehlgeschlagen

Frau Widmer

@ Atelier Form

Kein Betreff

Warte auf KI

Herr Huber

@ Bäckerei Huber

Kein Betreff

KI schreibt...

Herr Keller

@ Schreinerei Nordwind

Kurze Frage zu Ihrer Website, Schreinerei Nordwind

Entwurf

Frau Steiner

@ Café Fernweh

Idee für Café Fernweh

Entwurf

Herr Keller

@ Schreinerei Nordwind

draft

Betreff

E-Mail-Inhalt (HTML)

The preview modal on an approved email: sanitized HTML preview, "Freigeben" gone, "Bearbeiten" still there email-preview-approved-readonly server rendered, hydrated in view

Kampagne

Q4 Handwerksbetriebe Outreach

Kaltakquise für lokale Handwerksbetriebe im Grossraum Zürich

Leads

24

Gesendet

8

Geöffnet

4

Geklickt

2

Antworten

1

9 E-Mails

1 bereit zum Senden

Frau Meier

@ Gartenbau Meier

Kurze Idee für Gartenbau Meier

In Warteschlange

Herr Vogel

@ Sanitär Vogel

Sende gerade...

Wird gesendet...

Frau Keller

@ Coiffeur Bellevue

Idee für Coiffeur Bellevue

Gesendet

Herr Frei

@ Elektro Frei

Mehr Anfragen für Elektro Frei

Geöffnet

Frau Sutter

@ Physio Zentral

Website-Idee für Physio Zentral

Geklickt

Herr Wyss

@ Bio-Metzgerei Wyss

Kurzes Angebot für Bio-Metzgerei Wyss

Antwort erhalten

[email protected]

@ Reinigung Blitzblank

Idee für Reinigung Blitzblank

Bounced

Herr Sur

@ Velo Werkstatt Sur

Website-Check für Velo Werkstatt Sur

Fehlgeschlagen

Herr Rufener

@ Malergeschäft Rufener

Mehr Anfragen für Malergeschäft Rufener

Freigegeben

Herr Rufener

@ Malergeschäft Rufener

approved

Betreff

Mehr Anfragen für Malergeschäft Rufener

Vorschau

Guten Tag Herr Rufener,

viele Betriebe wie Ihrer verlieren Anfragen, weil die Website auf dem Handy schwer zu bedienen ist.

Ich zeige Ihnen gerne in 15 Minuten, was wir dagegen tun können.

Beste Grüsse,
Lea

The settings form, fully populated, campaign paused ("Aktivieren" in the header) settings-tab server rendered, hydrated in view

Kampagne

Q4 Handwerksbetriebe Outreach

Kaltakquise für lokale Handwerksbetriebe im Grossraum Zürich

Leads

24

Gesendet

8

Geöffnet

4

Geklickt

2

Antworten

1

Kampagnen-Name

Beschreibung

Sende-Intervall (Min.)

Tägliches Sendelimit

Absender-Name

Absender-E-Mail

KI-Template Prompt

Zusätzlicher Kontext für die KI beim Verfassen der E-Mails

prospecting/ui/outreach-campaign-workspace/demo.tsx
/**
 * Live demo for prospecting/ui/outreach-campaign-workspace.
 *
 * Labs' AdminOutreachCampaign.tsx (main, 62ba4fc, 491 lines) with its one
 * campaign read (`GET /api/crm/outreach/campaigns/{id}`), its email-list read
 * (`GET .../emails`) and its five writes (scrape+generate, approve-all,
 * toggle status, save settings, plus the two writes inside the preview modal:
 * save an edit, approve one email) replaced by props: the active tab, the
 * campaign object, the email list, the top-level error string, the two
 * campaign-level busy flags (`processing`, `approvingAll`) and the preview
 * modal's own selected email plus its three busy/error props. Rendered
 * inside the shell demo's component the way every admin page is
 * (`AdminNavigation.tsx` is a one-line delegator to `AppShell`, same as
 * `identity-auth/ui/admin-users-console`), with an admin viewer, on
 * `/admin/outreach/:id` (the campaign detail a row in `AdminOutreach.tsx`'s
 * list links to; that list page itself is a different, simpler recipe, not
 * this one).
 *
 * What is kept from the source, close to byte-for-byte: the header (back
 * arrow, "Kampagne" eyebrow, name, description, the Pausieren/Aktivieren
 * toggle), the five-tile quick-stats row, the top-level error card, the
 * three-tab bar, the Leads tab's lead-import panel and "Scrapen &
 * Generieren" action bar, the Emails tab's status filter, "Alle freigeben"
 * and "bereit zum Senden" badge, the empty-state card, the per-email row
 * (avatar, name/company, subject, open/click counts, status badge), the
 * Settings tab's full form, and the email preview modal in both its
 * read-only and its editing layout, including its own status/stats bar,
 * error banner and footer actions. All copy is the source's, German (de-CH).
 * `EMAIL_STATUS_BADGE` and the per-tab conditionals (`draftCount`,
 * `approvedCount`, `isGratis`-style booleans) are reproduced verbatim.
 *
 * What is different, and why: `OutreachEmailPreview`'s real HTML preview
 * runs the drafted body through `sanitizeHtml` (DOMPurify) before
 * `dangerouslySetInnerHTML`; DOMPurify is not one of the packages a demo may
 * import from (AUTHORING.md "The exports"), so this rebuild renders the
 * sample HTML directly, unsanitized, which is safe here only because the
 * sample body strings are authored, trusted, and contain no scripting - the
 * real component's sanitize call is named in Security & tenancy, not
 * dropped silently. `new Date(...).toLocaleString('de-CH')` (the preview's
 * "Gesendet: ..." line) is replaced with a fixed weekday/month table read
 * with UTC getters, the same fix `billing-credits/ui/subscription-manager`
 * applies to its own date formatting, for the same reason (locale- and
 * timezone-dependent formatting does not survive server render into browser
 * hydration). Every network call (`outreachService.*`) is not wired: buttons
 * that trigger a write render their real label, icon and busy state from
 * props, but are inert, the same convention `admin-users-console` and
 * `subscription-manager` use for their own prompt/confirm/toast-gated
 * actions. `OutreachLeadImport`'s own `mode` toggle ('idle' | 'single' |
 * 'file') is local, UI-only state that resets on every mount and has no
 * bearing on campaign or email state, so it is rendered at its 'idle' rest
 * position in every state here rather than threaded through as a prop.
 * `editSettings` (the Settings tab's form) and the preview modal's `subject`
 * / `bodyHtml` draft fields are seeded from props into local `useState`, the
 * same pattern `subscription-manager`'s demo uses for its voucher input:
 * typing has to update a controlled field, but nothing is sent anywhere.
 */
import React, { useState } from 'react';
import {
  ArrowLeft, Users, Mail, Play, Pause, CheckCircle2, Send, Eye, MousePointer,
  MessageSquare, RefreshCw, CheckCheck, AlertCircle, Clock, Globe, Pencil,
  Settings2, X, FileSpreadsheet, Plus,
} from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import { Badge, Button, Card, Heading, Text } from '@prima-modules/alchemy-ui';
import { Shell, type ShellUser, type ShellWorkspace } from '../../../ui-design-system/ui/appshell-navigation/demo';
import { DOMAINS } from '../../../ui-design-system/ui/appshell-navigation/nav';

/* ---- types (types/outreach.ts, unchanged shape) ------------------------- */

export type CampaignStatus = 'draft' | 'active' | 'paused' | 'completed';

export type OutreachEmailStatus =
  | 'pending_scrape' | 'scraping' | 'scrape_failed'
  | 'pending_compose' | 'composing'
  | 'draft' | 'approved'
  | 'queued' | 'sending' | 'sent' | 'opened' | 'clicked' | 'replied'
  | 'bounced' | 'failed';

export interface OutreachCampaign {
  id: string;
  name: string;
  description?: string;
  status: CampaignStatus;
  send_interval_minutes: number;
  daily_send_limit: number;
  template_prompt?: string;
  sender_name?: string;
  sender_email?: string;
  total_leads: number;
  total_sent: number;
  total_opened: number;
  total_clicked: number;
  total_replied: number;
  total_bounced: number;
  created_at: string;
}

export interface OutreachEmail {
  id: string;
  campaign_id: string;
  lead_id: string;
  subject?: string;
  body_html?: string;
  status: OutreachEmailStatus;
  open_count: number;
  click_count: number;
  sent_at?: string;
  error_message?: string;
  created_at: string;
  // Denormalized lead info (outreach_router.py joins Lead onto every row it returns)
  lead_email?: string;
  lead_name?: string;
  lead_company?: string;
  lead_website?: string;
}

export interface Props {
  /** The signed-in admin, for the shell's account row. */
  viewer: ShellUser;
  workspace: ShellWorkspace;
  activeTab: 'leads' | 'emails' | 'settings';
  campaign: OutreachCampaign;
  emails: OutreachEmail[];
  /** The Emails tab's status <select>; '' is "Alle Status" (line 300 to 313). */
  emailFilter?: string;
  /** The page-level error card (line 241 to 245): a scrape/generate, toggle or settings-save failure. */
  error?: string | null;
  /** "Scrapen & Generieren"'s busy flag (line 280 to 289). */
  processing?: boolean;
  /** "Alle freigeben (n)"'s busy flag (line 318 to 321). */
  approvingAll?: boolean;
  /** The row that opens the preview modal; null renders no modal at all. */
  selectedEmail?: OutreachEmail | null;
  /** The modal's own edit-mode toggle, seeded from this and then local (OutreachEmailPreview.tsx line 18). */
  previewEditing?: boolean;
  previewSaving?: boolean;
  previewApproving?: boolean;
  previewError?: string | null;
}

/* ---- helpers -------------------------------------------------------------- */

/** AdminOutreachCampaign.tsx lines 17 to 33, reproduced verbatim. */
const EMAIL_STATUS_BADGE: Record<string, { variant: 'outline' | 'warning' | 'success' | 'primary' | 'error'; label: string }> = {
  pending_scrape: { variant: 'outline', label: 'Warte auf Scrape' },
  scraping: { variant: 'warning', label: 'Scraping...' },
  scrape_failed: { variant: 'error', label: 'Scrape fehlgeschlagen' },
  pending_compose: { variant: 'outline', label: 'Warte auf KI' },
  composing: { variant: 'warning', label: 'KI schreibt...' },
  draft: { variant: 'outline', label: 'Entwurf' },
  approved: { variant: 'primary', label: 'Freigegeben' },
  queued: { variant: 'warning', label: 'In Warteschlange' },
  sending: { variant: 'warning', label: 'Wird gesendet...' },
  sent: { variant: 'success', label: 'Gesendet' },
  opened: { variant: 'success', label: 'Geöffnet' },
  clicked: { variant: 'primary', label: 'Geklickt' },
  replied: { variant: 'primary', label: 'Antwort erhalten' },
  bounced: { variant: 'error', label: 'Bounced' },
  failed: { variant: 'error', label: 'Fehlgeschlagen' },
};

/** Fixed table read with UTC getters, in place of toLocaleString('de-CH'); see the header comment. */
const MONTHS_DE = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
function formatDateTime(iso: string): string {
  const d = new Date(iso);
  const hh = String(d.getUTCHours()).padStart(2, '0');
  const mm = String(d.getUTCMinutes()).padStart(2, '0');
  return `${d.getUTCDate()}. ${MONTHS_DE[d.getUTCMonth()]} ${d.getUTCFullYear()}, ${hh}:${mm}`;
}

/* ---- sample data ----------------------------------------------------------- */

const viewer: ShellUser = { name: 'Rico Baumann', email: '[email protected]', isAdmin: true };
const workspace: ShellWorkspace = { id: 'ws_1', name: 'Atelier Nordwind', slug: 'atelier-nordwind' };

const campaignBase: OutreachCampaign = {
  id: 'camp_1',
  name: 'Q4 Handwerksbetriebe Outreach',
  description: 'Kaltakquise für lokale Handwerksbetriebe im Grossraum Zürich',
  status: 'draft',
  send_interval_minutes: 15,
  daily_send_limit: 50,
  template_prompt: 'Betone unsere kurze Reaktionszeit und dass wir bereits mehrere Betriebe in der Region betreuen.',
  sender_name: 'Lea Vogt',
  sender_email: '[email protected]',
  total_leads: 0,
  total_sent: 0,
  total_opened: 0,
  total_clicked: 0,
  total_replied: 0,
  total_bounced: 0,
  created_at: '2026-09-01T08:00:00Z',
};

const draftEmail1: OutreachEmail = {
  id: 'email_1', campaign_id: 'camp_1', lead_id: 'lead_1',
  subject: 'Kurze Frage zu Ihrer Website, Schreinerei Nordwind',
  body_html: '<p>Guten Tag Herr Keller,</p><p>ich habe mir die Seite von Schreinerei Nordwind angeschaut und eine Idee, wie Sie mehr Anfragen aus der Region bekommen könnten.</p><p>Haben Sie diese Woche 15 Minuten Zeit für ein kurzes Gespräch?</p><p>Beste Grüsse,<br>Lea</p>',
  status: 'draft', open_count: 0, click_count: 0, created_at: '2026-09-10T09:12:00Z',
  lead_email: '[email protected]', lead_name: 'Herr Keller', lead_company: 'Schreinerei Nordwind',
  lead_website: 'https://schreinerei-nordwind.example',
};

const draftEmail2: OutreachEmail = {
  id: 'email_2', campaign_id: 'camp_1', lead_id: 'lead_2',
  subject: 'Idee für Café Fernweh',
  body_html: '<p>Hallo Frau Steiner,</p><p>Café Fernweh hat einen schönen Instagram-Auftritt, aber die Website ist noch nicht verlinkt.</p><p>Gerne zeige ich Ihnen in einem kurzen Call, wie wir das für ähnliche Betriebe gelöst haben.</p><p>Beste Grüsse,<br>Lea</p>',
  status: 'draft', open_count: 0, click_count: 0, created_at: '2026-09-10T09:14:00Z',
  lead_email: '[email protected]', lead_name: 'Frau Steiner', lead_company: 'Café Fernweh',
  lead_website: 'https://cafe-fernweh.example',
};

const pendingPipeline: OutreachEmail[] = [
  { id: 'email_3', campaign_id: 'camp_1', lead_id: 'lead_3', status: 'pending_scrape', open_count: 0, click_count: 0, created_at: '2026-09-10T09:10:00Z', lead_email: '[email protected]', lead_name: 'Frau Brunner', lead_company: 'Muster Gärtnerei', lead_website: 'https://muster-gaertnerei.example' },
  { id: 'email_4', campaign_id: 'camp_1', lead_id: 'lead_4', status: 'scraping', open_count: 0, click_count: 0, created_at: '2026-09-10T09:11:00Z', lead_email: '[email protected]', lead_name: 'Herr Frei', lead_company: 'Studio Lindwurm', lead_website: 'https://studio-lindwurm.example' },
  { id: 'email_5', campaign_id: 'camp_1', lead_id: 'lead_5', status: 'scrape_failed', open_count: 0, click_count: 0, created_at: '2026-09-10T09:11:30Z', error_message: 'Website antwortete nicht (Timeout nach 20s)', lead_email: '[email protected]', lead_name: 'Herr Tanner', lead_company: 'Holzbau Tanner', lead_website: 'https://holzbau-tanner.example' },
  { id: 'email_6', campaign_id: 'camp_1', lead_id: 'lead_6', status: 'pending_compose', open_count: 0, click_count: 0, created_at: '2026-09-10T09:12:30Z', lead_email: '[email protected]', lead_name: 'Frau Widmer', lead_company: 'Atelier Form', lead_website: 'https://atelier-form.example' },
  { id: 'email_7', campaign_id: 'camp_1', lead_id: 'lead_7', status: 'composing', open_count: 0, click_count: 0, created_at: '2026-09-10T09:13:00Z', lead_email: '[email protected]', lead_name: 'Herr Huber', lead_company: 'Bäckerei Huber', lead_website: 'https://baeckerei-huber.example' },
];

const approvedEmail: OutreachEmail = {
  id: 'email_8', campaign_id: 'camp_1', lead_id: 'lead_8',
  subject: 'Mehr Anfragen für Malergeschäft Rufener',
  body_html: '<p>Guten Tag Herr Rufener,</p><p>viele Betriebe wie Ihrer verlieren Anfragen, weil die Website auf dem Handy schwer zu bedienen ist.</p><p>Ich zeige Ihnen gerne in 15 Minuten, was wir dagegen tun können.</p><p>Beste Grüsse,<br>Lea</p>',
  status: 'approved', open_count: 0, click_count: 0, created_at: '2026-09-09T14:00:00Z',
  lead_email: '[email protected]', lead_name: 'Herr Rufener', lead_company: 'Malergeschäft Rufener',
  lead_website: 'https://malergeschaeft-rufener.example',
};

const sentPipeline: OutreachEmail[] = [
  { id: 'email_9', campaign_id: 'camp_1', lead_id: 'lead_9', subject: 'Kurze Idee für Gartenbau Meier', status: 'queued', open_count: 0, click_count: 0, created_at: '2026-09-08T10:00:00Z', lead_email: '[email protected]', lead_name: 'Frau Meier', lead_company: 'Gartenbau Meier' },
  { id: 'email_10', campaign_id: 'camp_1', lead_id: 'lead_10', subject: 'Sende gerade...', status: 'sending', open_count: 0, click_count: 0, created_at: '2026-09-08T10:05:00Z', lead_email: '[email protected]', lead_name: 'Herr Vogel', lead_company: 'Sanitär Vogel' },
  { id: 'email_11', campaign_id: 'camp_1', lead_id: 'lead_11', subject: 'Idee für Coiffeur Bellevue', status: 'sent', open_count: 0, click_count: 0, sent_at: '2026-09-07T08:30:00Z', created_at: '2026-09-06T10:00:00Z', lead_email: '[email protected]', lead_name: 'Frau Keller', lead_company: 'Coiffeur Bellevue' },
  { id: 'email_12', campaign_id: 'camp_1', lead_id: 'lead_12', subject: 'Mehr Anfragen für Elektro Frei', status: 'opened', open_count: 2, click_count: 0, sent_at: '2026-09-06T08:30:00Z', created_at: '2026-09-05T10:00:00Z', lead_email: '[email protected]', lead_name: 'Herr Frei', lead_company: 'Elektro Frei' },
  { id: 'email_13', campaign_id: 'camp_1', lead_id: 'lead_13', subject: 'Website-Idee für Physio Zentral', status: 'clicked', open_count: 3, click_count: 1, sent_at: '2026-09-05T08:30:00Z', created_at: '2026-09-04T10:00:00Z', lead_email: '[email protected]', lead_name: 'Frau Sutter', lead_company: 'Physio Zentral' },
  { id: 'email_14', campaign_id: 'camp_1', lead_id: 'lead_14', subject: 'Kurzes Angebot für Bio-Metzgerei Wyss', status: 'replied', open_count: 1, click_count: 0, sent_at: '2026-09-04T08:30:00Z', created_at: '2026-09-03T10:00:00Z', lead_email: '[email protected]', lead_name: 'Herr Wyss', lead_company: 'Bio-Metzgerei Wyss' },
  { id: 'email_15', campaign_id: 'camp_1', lead_id: 'lead_15', subject: 'Idee für Reinigung Blitzblank', status: 'bounced', open_count: 0, click_count: 0, sent_at: '2026-09-03T08:30:00Z', created_at: '2026-09-02T10:00:00Z', error_message: 'Mailbox existiert nicht (550)', lead_email: '[email protected]', lead_name: '', lead_company: 'Reinigung Blitzblank' },
  { id: 'email_16', campaign_id: 'camp_1', lead_id: 'lead_16', subject: 'Website-Check für Velo Werkstatt Sur', status: 'failed', open_count: 0, click_count: 0, created_at: '2026-09-02T09:00:00Z', error_message: 'Gmail-Integration abgelaufen', lead_email: '[email protected]', lead_name: 'Herr Sur', lead_company: 'Velo Werkstatt Sur' },
  approvedEmail,
];

const base: Props = {
  viewer, workspace,
  activeTab: 'leads',
  campaign: campaignBase,
  emails: [],
  emailFilter: '',
  error: null,
  processing: false,
  approvingAll: false,
  selectedEmail: null,
  previewEditing: false,
  previewSaving: false,
  previewApproving: false,
  previewError: null,
};

const withLeads: OutreachCampaign = { ...campaignBase, status: 'active', total_leads: 24 };
const emailsBackdropDrafts: OutreachEmail[] = [...pendingPipeline, draftEmail1, draftEmail2];
const emailsBackdropApproved: OutreachEmail[] = sentPipeline;
const campaignWithStats: OutreachCampaign = {
  ...withLeads,
  total_sent: 8, total_opened: 4, total_clicked: 2, total_replied: 1, total_bounced: 1,
};

export const states = defineStates<Props>({
  'leads-tab-empty': {
    label: 'A brand-new campaign, no leads yet: the import panel and a disabled "Scrapen & Generieren"',
    props: { ...base, activeTab: 'leads', campaign: campaignBase, emails: [] },
    height: 900, viewport: 1440,
  },
  'leads-tab-with-leads': {
    label: '24 leads imported: the action bar enabled, and a top-level error from an earlier failed attempt',
    props: { ...base, activeTab: 'leads', campaign: withLeads, emails: [], error: 'Verarbeitung fehlgeschlagen' },
    height: 900, viewport: 1440,
  },
  'emails-tab-empty': {
    label: 'Leads imported but scrape+generate not run yet: the emails tab’s own fallback card',
    props: { ...base, activeTab: 'emails', campaign: withLeads, emails: [] },
    height: 900, viewport: 1440,
  },
  'emails-tab-drafts-pending-approval': {
    label: 'The pipeline mid-flight: scrape/compose in progress, two drafts, "Alle freigeben (2)" visible',
    props: { ...base, activeTab: 'emails', campaign: withLeads, emails: emailsBackdropDrafts },
    height: 1300, viewport: 1440,
  },
  'emails-tab-approved-ready-to-send': {
    label: 'One approved, the rest through the send pipeline: no draft button, "1 bereit zum Senden" badge',
    props: { ...base, activeTab: 'emails', campaign: campaignWithStats, emails: emailsBackdropApproved },
    height: 1500, viewport: 1440,
  },
  'email-preview-draft-editable': {
    label: 'The preview modal open on a draft, in its editing layout (subject + raw HTML textarea)',
    props: {
      ...base, activeTab: 'emails', campaign: withLeads, emails: emailsBackdropDrafts,
      selectedEmail: draftEmail1, previewEditing: true,
    },
    height: 900, viewport: 1440,
  },
  'email-preview-approved-readonly': {
    label: 'The preview modal on an approved email: sanitized HTML preview, "Freigeben" gone, "Bearbeiten" still there',
    props: {
      ...base, activeTab: 'emails', campaign: campaignWithStats, emails: emailsBackdropApproved,
      selectedEmail: approvedEmail, previewEditing: false,
    },
    height: 900, viewport: 1440,
  },
  'settings-tab': {
    label: 'The settings form, fully populated, campaign paused ("Aktivieren" in the header)',
    props: { ...base, activeTab: 'settings', campaign: { ...campaignWithStats, status: 'paused' } },
    height: 1100, viewport: 1440,
  },
});

/* ---- Leads tab (OutreachLeadImport.tsx, idle rest position, + action bar) - */

function LeadsTab({ campaign, processing }: { campaign: OutreachCampaign; processing: boolean }) {
  return (
    <div className="space-y-6">
      <div className="space-y-3">
        <div className="flex items-center gap-3">
          <Button size="sm" variant="ghost">
            <Plus className="w-4 h-4" />
            Lead hinzufügen
          </Button>
          <Button size="sm" variant="ghost">
            <FileSpreadsheet className="w-4 h-4" />
            CSV/XLSX importieren
          </Button>
        </div>
      </div>

      <div className="flex items-center gap-3">
        <Button size="sm" loading={processing} disabled={campaign.total_leads === 0}>
          <RefreshCw className="w-4 h-4" />
          Scrapen &amp; Generieren
        </Button>
        <Text size="xs" muted>
          Websites scrapen und E-Mails per KI generieren
        </Text>
      </div>
    </div>
  );
}

/* ---- Emails tab (email list rows, lines 294 to 388) ------------------------ */

function EmailRow({ email, onSelect }: { email: OutreachEmail; onSelect: () => void }) {
  const badge = EMAIL_STATUS_BADGE[email.status] || EMAIL_STATUS_BADGE.draft;
  const showEngagement = email.status === 'sent' || email.status === 'opened' || email.status === 'clicked' || email.status === 'replied';
  return (
    <Card variant="glass" padding="sm" className="cursor-pointer hover:bg-stone-50 transition-colors" onClick={onSelect}>
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-3 flex-1 min-w-0">
          <div className="w-8 h-8 rounded-full bg-[#6844C4]/20 flex items-center justify-center flex-shrink-0">
            <Mail className="w-4 h-4 text-[#6844C4]" />
          </div>
          <div className="min-w-0 flex-1">
            <div className="flex items-center gap-2">
              <Text size="sm" weight="medium" className="text-ink truncate">
                {email.lead_name || email.lead_email || 'Unbekannt'}
              </Text>
              {email.lead_company && (
                <Text size="xs" muted className="truncate">@ {email.lead_company}</Text>
              )}
            </div>
            <Text size="xs" muted className="truncate">{email.subject || 'Kein Betreff'}</Text>
          </div>
        </div>
        <div className="flex items-center gap-4 flex-shrink-0 ml-4">
          {showEngagement ? (
            <div className="hidden md:flex items-center gap-3 text-xs">
              <span className="flex items-center gap-1 text-green-600"><Eye className="w-3 h-3" /> {email.open_count}</span>
              <span className="flex items-center gap-1 text-purple-600"><MousePointer className="w-3 h-3" /> {email.click_count}</span>
            </div>
          ) : null}
          <Badge variant={badge.variant}>{badge.label}</Badge>
        </div>
      </div>
    </Card>
  );
}

function EmailsTab({
  emails, emailFilter, approvingAll, onSelect,
}: { emails: OutreachEmail[]; emailFilter: string; approvingAll: boolean; onSelect: (e: OutreachEmail) => void }) {
  const draftCount = emails.filter((e) => e.status === 'draft').length;
  const approvedCount = emails.filter((e) => e.status === 'approved').length;

  return (
    <div className="space-y-4">
      <div className="flex items-center justify-between">
        <div className="flex items-center gap-3">
          <select
            value={emailFilter}
            onChange={() => {}}
            className="h-9 px-3 bg-paper border border-line rounded-lg text-ink text-sm focus:border-[#6844C4] outline-none"
          >
            <option value="">Alle Status</option>
            <option value="draft">Entwürfe</option>
            <option value="approved">Freigegeben</option>
            <option value="sent">Gesendet</option>
            <option value="opened">Geöffnet</option>
            <option value="clicked">Geklickt</option>
            <option value="replied">Beantwortet</option>
            <option value="bounced">Bounced</option>
            <option value="failed">Fehlgeschlagen</option>
          </select>
          <Text size="xs" muted>{emails.length} E-Mails</Text>
        </div>
        <div className="flex items-center gap-3">
          {draftCount > 0 && (
            <Button size="sm" variant="ghost" loading={approvingAll}>
              <CheckCheck className="w-4 h-4" />
              Alle freigeben ({draftCount})
            </Button>
          )}
          {approvedCount > 0 && <Badge variant="primary">{approvedCount} bereit zum Senden</Badge>}
        </div>
      </div>

      {emails.length === 0 ? (
        <Card variant="glass" padding="lg" className="text-center">
          <Mail className="w-10 h-10 mx-auto mb-3 text-[#6844C4]/40" />
          <Text muted>Noch keine E-Mails. Füge Leads hinzu und starte die Generierung.</Text>
        </Card>
      ) : (
        <div className="space-y-2">
          {emails.map((email) => (
            <EmailRow key={email.id} email={email} onSelect={() => onSelect(email)} />
          ))}
        </div>
      )}
    </div>
  );
}

/* ---- Settings tab (lines 390 to 475) ---------------------------------------- */

function SettingsTab({ campaign }: { campaign: OutreachCampaign }) {
  const [name, setName] = useState(campaign.name);
  const [description, setDescription] = useState(campaign.description || '');
  const [interval, setInterval_] = useState(campaign.send_interval_minutes);
  const [limit, setLimit] = useState(campaign.daily_send_limit);
  const [senderName, setSenderName] = useState(campaign.sender_name || '');
  const [senderEmail, setSenderEmail] = useState(campaign.sender_email || '');
  const [prompt, setPrompt] = useState(campaign.template_prompt || '');

  return (
    <div className="max-w-2xl space-y-6">
      <Card variant="glass" padding="md">
        <div className="space-y-4">
          <div>
            <Text size="xs" weight="semibold" muted className="mb-1">Kampagnen-Name</Text>
            <input
              type="text" value={name} onChange={(e) => setName(e.target.value)}
              className="w-full h-10 px-4 bg-paper border border-line rounded-lg text-ink text-sm focus:border-[#6844C4] outline-none"
            />
          </div>
          <div>
            <Text size="xs" weight="semibold" muted className="mb-1">Beschreibung</Text>
            <textarea
              value={description} onChange={(e) => setDescription(e.target.value)} rows={2}
              className="w-full px-4 py-2 bg-paper border border-line rounded-lg text-ink text-sm focus:border-[#6844C4] outline-none resize-none"
            />
          </div>
          <div className="grid grid-cols-2 gap-4">
            <div>
              <Text size="xs" weight="semibold" muted className="mb-1">Sende-Intervall (Min.)</Text>
              <input
                type="number" value={interval} onChange={(e) => setInterval_(parseInt(e.target.value) || 15)}
                className="w-full h-10 px-4 bg-paper border border-line rounded-lg text-ink text-sm focus:border-[#6844C4] outline-none"
              />
            </div>
            <div>
              <Text size="xs" weight="semibold" muted className="mb-1">Tägliches Sendelimit</Text>
              <input
                type="number" value={limit} onChange={(e) => setLimit(parseInt(e.target.value) || 50)}
                className="w-full h-10 px-4 bg-paper border border-line rounded-lg text-ink text-sm focus:border-[#6844C4] outline-none"
              />
            </div>
          </div>
          <div className="grid grid-cols-2 gap-4">
            <div>
              <Text size="xs" weight="semibold" muted className="mb-1">Absender-Name</Text>
              <input
                type="text" value={senderName} onChange={(e) => setSenderName(e.target.value)}
                placeholder="z.B. Max von Alchemy"
                className="w-full h-10 px-4 bg-paper border border-line rounded-lg text-ink placeholder-ink-40 text-sm focus:border-[#6844C4] outline-none"
              />
            </div>
            <div>
              <Text size="xs" weight="semibold" muted className="mb-1">Absender-E-Mail</Text>
              <input
                type="email" value={senderEmail} onChange={(e) => setSenderEmail(e.target.value)}
                placeholder="[email protected]"
                className="w-full h-10 px-4 bg-paper border border-line rounded-lg text-ink placeholder-ink-40 text-sm focus:border-[#6844C4] outline-none"
              />
            </div>
          </div>
          <div>
            <Text size="xs" weight="semibold" muted className="mb-1">KI-Template Prompt</Text>
            <textarea
              value={prompt} onChange={(e) => setPrompt(e.target.value)} rows={4}
              placeholder="Optionale zusätzliche Anweisungen für die KI-Generierung..."
              className="w-full px-4 py-2 bg-paper border border-line rounded-lg text-ink placeholder-ink-40 text-sm focus:border-[#6844C4] outline-none resize-none"
            />
            <Text size="xs" muted className="mt-1">Zusätzlicher Kontext für die KI beim Verfassen der E-Mails</Text>
          </div>
          <div className="flex justify-end pt-2">
            <Button size="sm">Speichern</Button>
          </div>
        </div>
      </Card>
    </div>
  );
}

/* ---- Preview modal (OutreachEmailPreview.tsx) ------------------------------- */

function PreviewModal({
  email, editingInitial, saving = false, approving = false, error = null, onClose,
}: {
  email: OutreachEmail; editingInitial: boolean; saving?: boolean; approving?: boolean; error?: string | null; onClose: () => void;
}) {
  const [editing, setEditing] = useState(editingInitial);
  const [subject, setSubject] = useState(email.subject || '');
  const [bodyHtml, setBodyHtml] = useState(email.body_html || '');

  const canEdit = email.status === 'draft' || email.status === 'approved';
  const canApprove = email.status === 'draft';

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center">
      <div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
      <Card variant="glass" padding="lg" className="relative z-10 w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden flex flex-col">
        <div className="flex items-center justify-between mb-4">
          <div className="flex-1 min-w-0">
            <div className="flex items-center gap-2">
              <Text size="lg" weight="semibold" className="text-ink truncate">
                {email.lead_name || email.lead_email || 'Unbekannt'}
              </Text>
              {email.lead_company && <Text size="sm" muted>@ {email.lead_company}</Text>}
            </div>
            <div className="flex items-center gap-3 mt-1">
              <Text size="xs" muted>{email.lead_email}</Text>
              {email.lead_website && (
                <a href={email.lead_website} target="_blank" rel="noopener noreferrer"
                  className="flex items-center gap-1 text-xs text-[#6844C4] hover:text-[#523494] transition-colors"
                  onClick={(e) => e.stopPropagation()}>
                  <Globe className="w-3 h-3" />
                  Website
                </a>
              )}
            </div>
          </div>
          <button onClick={onClose} className="p-2 rounded-lg hover:bg-stone-100 transition-colors">
            <X className="w-5 h-5 text-ink-60" />
          </button>
        </div>

        <div className="flex items-center gap-4 mb-4 pb-4 border-b border-line">
          <Badge variant={
            email.status === 'sent' || email.status === 'opened' || email.status === 'clicked' || email.status === 'replied'
              ? 'success'
              : email.status === 'draft' ? 'outline'
              : email.status === 'approved' ? 'primary'
              : email.status === 'bounced' || email.status === 'failed' ? 'error'
              : 'warning'
          }>
            {email.status}
          </Badge>
          {email.sent_at && (
            <span className="flex items-center gap-1 text-xs text-ink-40">
              <Clock className="w-3 h-3" />
              Gesendet: {formatDateTime(email.sent_at)}
            </span>
          )}
          {(email.open_count > 0 || email.click_count > 0) && (
            <div className="flex items-center gap-3 text-xs">
              <span className="flex items-center gap-1 text-green-600"><Eye className="w-3 h-3" /> {email.open_count}x geöffnet</span>
              <span className="flex items-center gap-1 text-purple-600"><MousePointer className="w-3 h-3" /> {email.click_count}x geklickt</span>
            </div>
          )}
        </div>

        {error && (
          <div className="flex items-center gap-2 mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
            <AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
            <Text size="sm" className="text-red-700">{error}</Text>
          </div>
        )}

        <div className="flex-1 overflow-y-auto">
          {editing ? (
            <div className="space-y-4">
              <div>
                <Text size="xs" weight="semibold" muted className="mb-1">Betreff</Text>
                <input
                  type="text" value={subject} onChange={(e) => setSubject(e.target.value)}
                  className="w-full h-10 px-4 bg-paper border border-line rounded-lg text-ink text-sm focus:border-[#6844C4] outline-none"
                />
              </div>
              <div>
                <Text size="xs" weight="semibold" muted className="mb-1">E-Mail-Inhalt (HTML)</Text>
                <textarea
                  value={bodyHtml} onChange={(e) => setBodyHtml(e.target.value)} rows={12}
                  className="w-full px-4 py-3 bg-paper border border-line rounded-lg text-ink text-sm font-mono focus:border-[#6844C4] outline-none resize-none"
                />
              </div>
            </div>
          ) : (
            <div className="space-y-4">
              <div>
                <Text size="xs" weight="semibold" muted className="mb-1">Betreff</Text>
                <Text size="sm" weight="medium" className="text-ink">{email.subject || '(Kein Betreff)'}</Text>
              </div>
              {email.body_html ? (
                <div>
                  <Text size="xs" weight="semibold" muted className="mb-2">Vorschau</Text>
                  {/* Real component: dangerouslySetInnerHTML={{ __html: sanitizeHtml(email.body_html) }}.
                      DOMPurify is outside the demo's import allowlist; see the header comment. */}
                  <div
                    className="bg-white rounded-lg p-4 text-zinc-900 text-sm prose prose-sm max-w-none"
                    dangerouslySetInnerHTML={{ __html: email.body_html }}
                  />
                </div>
              ) : (
                <div className="text-center py-8"><Text muted>Noch kein Inhalt generiert.</Text></div>
              )}
              {email.error_message && (
                <div className="flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-lg">
                  <AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
                  <div>
                    <Text size="xs" weight="semibold" className="text-red-700 mb-1">Fehler</Text>
                    <Text size="xs" className="text-red-600">{email.error_message}</Text>
                  </div>
                </div>
              )}
            </div>
          )}
        </div>

        <div className="flex items-center justify-between pt-4 mt-4 border-t border-line">
          <div>
            {canEdit && !editing && (
              <Button size="sm" variant="ghost" onClick={() => setEditing(true)}>
                <Pencil className="w-4 h-4" />
                Bearbeiten
              </Button>
            )}
          </div>
          <div className="flex items-center gap-2">
            {editing ? (
              <>
                <Button size="sm" variant="ghost" onClick={() => setEditing(false)}>Abbrechen</Button>
                <Button size="sm" loading={saving} onClick={() => setEditing(false)}>Speichern</Button>
              </>
            ) : (
              <>
                {canApprove && (
                  <Button size="sm" loading={approving}>
                    <CheckCircle2 className="w-4 h-4" />
                    Freigeben
                  </Button>
                )}
                <Button size="sm" variant="ghost" onClick={onClose}>Schliessen</Button>
              </>
            )}
          </div>
        </div>
      </Card>
    </div>
  );
}

/* ---- the page (header, quick stats, tab bar) -------------------------------- */

const TABS: { key: Props['activeTab']; label: (n: number) => string; icon: React.ElementType }[] = [
  { key: 'leads', label: () => 'Leads', icon: Users },
  { key: 'emails', label: (n) => `E-Mails (${n})`, icon: Mail },
  { key: 'settings', label: () => 'Einstellungen', icon: Settings2 },
];

function Workspace({
  activeTab, campaign, emails, emailFilter = '', error, processing = false, approvingAll = false,
  selectedEmail, previewEditing = false, previewSaving = false, previewApproving = false, previewError = null,
}: Omit<Props, 'viewer' | 'workspace'>) {
  const stats = [
    { label: 'Leads', value: campaign.total_leads, icon: Users, color: 'text-blue-600' },
    { label: 'Gesendet', value: campaign.total_sent, icon: Send, color: 'text-green-600' },
    { label: 'Geöffnet', value: campaign.total_opened, icon: Eye, color: 'text-emerald-600' },
    { label: 'Geklickt', value: campaign.total_clicked, icon: MousePointer, color: 'text-purple-600' },
    { label: 'Antworten', value: campaign.total_replied, icon: MessageSquare, color: 'text-yellow-600' },
  ];

  return (
    <div className="min-h-screen relative">
      <div className="max-w-7xl mx-auto px-6 py-6">
        <div className="flex items-center gap-4 mb-6">
          <button className="p-2 rounded-lg hover:bg-stone-100 transition-colors">
            <ArrowLeft className="w-5 h-5 text-ink-60" />
          </button>
          <div className="flex-1">
            <Text size="xs" weight="semibold" className="text-[#6844C4] uppercase tracking-[0.3em] mb-1">Kampagne</Text>
            <Heading level={1} className="text-ink">{campaign.name}</Heading>
            {campaign.description && <Text muted className="mt-1">{campaign.description}</Text>}
          </div>
          <div className="flex items-center gap-3">
            {campaign.status !== 'completed' && (
              <Button size="sm" variant={campaign.status === 'active' ? 'ghost' : 'primary'}>
                {campaign.status === 'active' ? (<><Pause className="w-4 h-4" /> Pausieren</>) : (<><Play className="w-4 h-4" /> Aktivieren</>)}
              </Button>
            )}
          </div>
        </div>

        <div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
          {stats.map((stat) => (
            <Card key={stat.label} variant="glass" padding="sm">
              <div className="flex items-center gap-2 mb-1">
                <stat.icon className={`w-4 h-4 ${stat.color}`} />
                <Text size="xs" muted>{stat.label}</Text>
              </div>
              <Text size="lg" weight="bold" className="text-ink">{stat.value}</Text>
            </Card>
          ))}
        </div>

        {error && (
          <Card variant="glass" padding="md" className="mb-6 bg-red-50 border border-red-200">
            <Text className="text-red-700">{error}</Text>
          </Card>
        )}

        <div className="border-b border-line mb-6">
          <nav className="-mb-px flex space-x-6">
            {TABS.map((tab) => {
              const Icon = tab.icon;
              const isActive = activeTab === tab.key;
              return (
                <button
                  key={tab.key}
                  className={`whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
                    isActive ? 'border-[#6844C4] text-ink' : 'border-transparent text-ink-40 hover:text-ink hover:border-stone-300'
                  }`}
                >
                  <Icon className="w-4 h-4" />
                  {tab.label(emails.length)}
                </button>
              );
            })}
          </nav>
        </div>

        {activeTab === 'leads' && <LeadsTab campaign={campaign} processing={processing} />}
        {activeTab === 'emails' && (
          <EmailsTab emails={emails} emailFilter={emailFilter} approvingAll={approvingAll} onSelect={() => {}} />
        )}
        {activeTab === 'settings' && <SettingsTab campaign={campaign} />}
      </div>

      {selectedEmail && (
        <PreviewModal
          email={selectedEmail}
          editingInitial={previewEditing}
          saving={previewSaving}
          approving={previewApproving}
          error={previewError}
          onClose={() => {}}
        />
      )}
    </div>
  );
}

export default function Demo({ viewer, workspace: ws, galleryMode, ...page }: Props & { galleryMode?: boolean }) {
  return (
    <Shell domains={DOMAINS} user={viewer} workspaces={[ws]} currentWorkspaceId={ws.id} activePath="/admin/outreach" hasTour={false} hideChrome={galleryMode}>
      <Workspace {...page} />
    </Shell>
  );
}
Component tree, from the recipe
<Route /admin/outreach/:id> AdminRoute (isAdmin) > AdminNavigation = AppShell
  AdminOutreachCampaign
    [loading]        div.min-h-screen > Spinner xl                              (before the campaign loads, line 152 to 159, not demoed)
    [!campaign]       Card glass: AlertCircle + "Kampagne nicht gefunden"        (line 162 to 172, not demoed)
    Header            back arrow + "Kampagne" eyebrow + Heading name + description
                      [status !== completed] Button (Pausieren | Aktivieren)
    Quick stats       5x Card glass sm: Leads, Gesendet, Geoeffnet, Geklickt, Antworten
    [error]           Card glass: red text, the page-level fallback sentence
    Tabs              nav: Leads | E-Mails (n) | Einstellungen
    [tab === leads]
      OutreachLeadImport   idle (2 ghost buttons) | single-lead Card form | file drop-zone Card    (idle only, demoed)
      Action bar       Button "Scrapen & Generieren" (disabled total_leads===0, loading=processing) + hint text
    [tab === emails]
      Actions bar      status <select> + count text
                       [draftCount>0] Button ghost "Alle freigeben (n)"
                       [approvedCount>0] Badge "n bereit zum Senden"
      [emails.length===0] Card glass: Mail icon + "Noch keine E-Mails..."
      [else]           list: Card glass sm per email (avatar, name/company, subject, open/click if sent+, Badge status) onClick -> selectedEmail
    [tab === settings]
      Card glass md: name input, description textarea, interval/limit number inputs (2-col),
                     sender name/email inputs (2-col), template_prompt textarea, Button "Speichern"
    [selectedEmail]  OutreachEmailPreview modal
      overlay + Card glass: header (name/company, email, website link, close X)
      status/stats bar: Badge status + [sent_at] Clock + date + [counts>0] open/click line
      [error]          red banner
      [editing]        subject <input> + body_html <textarea rows=12 font-mono>, Abbrechen/Speichern
      [else]           subject text, sanitizeHtml(body_html) preview via dangerouslySetInnerHTML, [error_message] red banner
      footer: [canEdit && !editing] Button ghost "Bearbeiten"; [editing] Abbrechen + Speichern;
              [else] [canApprove] Button "Freigeben" + Button ghost "Schliessen"
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 outreach-campaign-workspace/demo.tsx, built from @prima-modules/alchemy-ui and rendered by the docs host as one frame per state. The page's tab state, campaign, email list, and the preview modal's own fields are all props; the tree is the frontmatter's. The shell renders with an admin viewer (isAdmin: true, the page's own AdminRoute gate), activePath="/admin/outreach"; the kit's sample navigation model (ui-design-system/ui/appshell-navigation/nav.tsx) carries a generic "admin" domain matching /admin/users, /admin/tracking, /admin/forms, /admin/communication only, not /admin/outreach (the real IA's "Operate" domain does include it, nav.tsx line 322, but the kit's sample list is intentionally generic, AUTHORING.md), so no rail item highlights active in this rebuild; a genuine, documented gap, not a bug in the demo.

States (rendering.states, in order): leads-tab-empty (a brand-new draft campaign, total_leads: 0, the import panel at rest, "Scrapen & Generieren" disabled); leads-tab-with-leads (24 leads, active, the button enabled, plus the top-level error card from an earlier failed attempt, to show that banner and a populated tab together); emails-tab-empty (leads imported but the pipeline not run yet, emails: [], the tab's own "Noch keine E-Mails" fallback, distinct from the Leads tab's empty state); emails-tab-drafts-pending-approval (a mixed pipeline; pending_scrape, scraping, scrape_failed, pending_compose, composing, and two draft rows; draftCount 2, approvedCount 0, so "Alle freigeben (2)" shows and the ready badge does not); emails-tab-approved-ready-to-send (one approved plus the full send-side status set; queued, sending, sent, opened, clicked, replied, bounced, failed; draftCount 0, approvedCount 1, so the badge shows and the approve-all button does not; between them, states 4 and 5 exercise all fifteen EMAIL_STATUS_BADGE entries); email-preview-draft-editable (the modal open on a draft row, editing already toggled on: subject input, raw-HTML textarea, Abbrechen/Speichern); email-preview-approved-readonly (the modal on the approved row from state 5, not editing: the sanitized-HTML preview, "Freigeben" gone since canApprove is false, "Bearbeiten" still present since canEdit allows approved rows too, the Invariants gap above made visible); settings-tab (the form fully populated, campaign paused so the header shows "Aktivieren").

Tokens and classes as the source uses them: Card glass for every top-level section, sm padding for the stat tiles and each email row; the tab bar's active underline and the accent color used throughout (import success/hover states, the row avatar, the website link) are all the literal #6844C4/#523494 hex pair, not an S1 token, the same "no bare hex" gap other prospecting-adjacent recipes flag for their own admin surfaces; Badge cycles through six of its eight variants across the fifteen email statuses (outline, warning, error, primary, success, and the settings-adjacent default is never used here). No dark: classes anywhere in these three files, unlike subscription-manager and admin-users-console; this surface was written without them from the start, so there is no "dark: dead" trap to reproduce.

Responsive: max-w-7xl single column; the quick-stats grid is grid-cols-2 under md, five columns at md and above; the settings form is max-w-2xl with two 2-column field rows that do not further collapse below sm (an unguarded overflow risk on a narrow phone, left as measured); the preview modal is max-w-2xl mx-4 max-h-[90vh] with its own internal scroll, so it never grows past the viewport. Empty states: leads-tab-empty (no leads at all) and emails-tab-empty (leads but no pipeline run yet) are two genuinely different empty states, both demoed. Loading state: the single top-level spinner (not demoed, a gate the whole page sits behind, in scope for a future state per Reference implementation's own "does not exist as its own frame" note). Error state: the top-level red card, demoed inside leads-tab-with-leads; the preview modal's own red card is demoed nowhere but is structurally identical to subscription-manager's error banner and is exercised by previewError in the props type.

What the demo deliberately does not render: the !campaign "Kampagne nicht gefunden" fallback and the initial loading spinner (both top-level gates before any tab exists, out of this recipe's "three-tab workspace" scope, Gotchas); window.confirm-free real network writes (every busy/disabled state is prop-driven, every button inert, the same convention subscription-manager and admin-users-console use); sanitizeHtml/DOMPurify (Stack assumptions, the sample HTML is authored trusted content instead); the file-drop and single-lead-entry panels of OutreachLeadImport (its mode is UI-only local state, rendered at its idle rest position in every state, Application logic).

Security & tenancy

What the browser may hold: S2's two tokens only. Every one of the nine calls carries AdminUser (a real admin check, not just "signed in") and a real WorkspaceID dependency (outreach_router.py, every handler signature); this is workspace-scoped throughout, unlike subscription-manager's per-user-only billing routes; the service layer re-verifies the campaign belongs to the resolved workspace before touching any email row (get_campaign(..., workspace_id=...) called first in update_email, approve_email, list_emails), so a workspace mismatch 404s rather than leaking another tenant's campaign. Origin/CSRF: the api singleton's Bearer header, S2's own. Surface gating: mounted with surfaces=LABS_SURFACES (app_fastapi.py 878), the same gate every other admin router in this app sits behind (B4/B16 as subscription-manager names them). Hidden by CSS vs enforced: the "Freigeben" button is hidden client-side once a row is not draft (canApprove), and the server independently refuses the same call with a 400 (Application logic Invariants); this is the one action on the page where the client hiding and the server check actually agree; every other button here (Bearbeiten on an approved row, the status toggle, the settings save) is hidden by CSS with no matching server lock. Attacker-controlled input rendered unescaped: body_html, sanitized with DOMPurify's allow-list (sanitize.ts, tags limited to formatting/table/list/image elements, href/src/class/style attributes only) before dangerouslySetInnerHTML; the one place on this page raw HTML from an AI-composition step reaches the DOM, and the one place a missing or weakened sanitize call would be a real stored-XSS surface (compose_service.py's own output is not this recipe's scope).

Tests

  • Colocated with the demo: outreach-campaign-workspace/demo.test.tsx (vitest, jsdom, @testing-library/react, afterEach(cleanup), the same layout test-layout describes): each of the eight states renders its expected tab content; emails-tab-drafts-pending-approval shows "Alle freigeben (2)" and no ready badge, emails-tab-approved-ready-to-send shows the badge and no approve-all button; the two preview states render exactly one of "Freigeben"/no "Freigeben" and the matching editing/read-only body; no state's markup contains an access- or refresh-token shape.
  • The kit's render smoke: node tooling/verify/render-smoke.mjs prospecting/ui/outreach-campaign-workspace renders all eight states with renderToString and fetch stubbed (Acceptance checks).
  • alchemy_labs at 62ba4fc has no unit test of AdminOutreachCampaign.tsx, OutreachEmailPreview.tsx or OutreachLeadImport.tsx (grep -rl AdminOutreachCampaign frontend/src --include='*.test.*' is empty) and no Playwright spec for /admin/outreach/:id. Backend coverage of the nine routes this page calls belongs to the five backend_modules recipes' own suites, not to this page.
  • What a new app adds: a jsdom test asserting the nine catch blocks read data.error ?? data.detail once fixed; a test that editing an approved email and saving does not silently re-open it as draft (it does not, today, per the Invariants gap); a Playwright spec that imports one real lead, runs scrape-and-generate against a seeded scrapeable URL, and asserts the resulting email reaches draft with non-empty subject/body_html.

Deploy & ops

Build target: the Labs Vite bundle (S2), a lazy chunk (App.tsx line 147, lazy(() => import('./pages/AdminOutreachCampaign'))). Env baked at build: VITE_API_URL, S2's own; nothing page-specific. CSP/CORS: nothing of its own; the router answers on the API origin under LABS_SURFACES as above. Deploy path: S13's frontend rsync with the bundle; rollback is the bundle's. Migrations, the scheduler job that actually sends approved rows, and Gmail integration config all belong to outreach-confirm-gate-and-send. Argus: a synthetic admin session calling GET /api/crm/outreach/campaigns/{id} for a seeded campaign and getting 200 is this page's liveness; it has no health signal of its own beyond the top-level fallback sentence.

Gotchas

  • 2026-09-12 (measured at 62ba4fc): all nine catch blocks across AdminOutreachCampaign.tsx, OutreachEmailPreview.tsx and OutreachLeadImport.tsx read err.response?.data?.detail; the Labs envelope's key is error, not detail (app_fastapi.py, the same shape subscription-manager and admin-users-console each document). Every real server message; "Cannot approve email with status sent", "Email not found", a scrape failure's actual text; is discarded in favor of one of nine fixed German sentences.
  • 2026-09-12 (measured at 62ba4fc): update_email (backend) applies a PUT with no status guard at all, while approve_email refuses anything but draft with a 400. The confirm gate this page's copy calls "Freigeben" only locks the transition into approved; it does not lock the content once there. An admin who reopens an already-approved row, edits the HTML, and clicks Speichern changes what the send queue will eventually mail with no second approval and no record that the approved content changed after approval.
  • 2026-09-12 (measured at 62ba4fc): Input and Textarea are imported from ../components/alchemy-ui on the main page (line 11) and never used anywhere across all three files; every text field on this page, including the preview modal's own edit form, is a raw <input>/<textarea>, not the design system's form atoms.
  • 2026-09-12 (measured at 62ba4fc): the Leads tab has no lead list, table, or search of any kind; only the import form and the "Scrapen & Generieren" bar. The only way to see what was actually imported is to switch to the Emails tab and wait for the pipeline to create rows; a lead added but not yet scraped is otherwise invisible on this page.
  • 2026-09-12 (measured at 62ba4fc): scrape_and_generate runs the entire scrape-then-compose pipeline synchronously inside one HTTP request, committing per-lead during the scrape phase but returning nothing until the compose batch is also done; a campaign with many pending leads has no progress indicator beyond the button's own spinner and risks a plain HTTP timeout with partial server-side commits already applied (the scrape half's db.commit() per lead, line 218/210, means a timed-out request can still leave several leads moved to pending_compose with nothing composed).
  • 2026-09-12 (measured at 62ba4fc): fetchEmails's catch block is empty (catch { /* Silently fail */ }, line 76 to 78); a failed email-list fetch leaves whatever emails already held (often [] on first load) with no error surfaced anywhere, distinct from every other failure path on this page, which all set error.
  • 2026-09-12 (measured at 62ba4fc): no dark: classes exist anywhere in these three files, unlike the sibling billing and identity-auth admin recipes; nothing to reproduce or flag as dead here.
  • 2026-09-12 (kit): the kit's sample navigation model does not carry /admin/outreach in its generic "admin" domain's paths, so this demo's activePath highlights no rail item; the real IA does carry it (nav.tsx line 322, "Operate" group), Frontend.

Reference implementation

Branch: alchemy_labs main at 62ba4fc41009b454e1c033e5036c7e8577db2061 (2026-09-12). Paths relative to the repo root:

  • frontend/src/pages/AdminOutreachCampaign.tsx (491 lines): EMAIL_STATUS_BADGE (17 to 33), the component and its state (38 to 50), fetchCampaign/fetchEmails (52 to 79), the four handlers (90 to 140), the loading and not-found returns (152 to 173), the header and quick stats (188 to 239), the error card (241 to 245), the tab bar (247 to 269), the leads tab (272 to 292), the emails tab (294 to 388), the settings tab (390 to 475), the modal mount (479 to 486).
  • frontend/src/components/OutreachEmailPreview.tsx (241 lines): state (18 to 23), handleSave/handleApprove (25 to 50), canEdit/canApprove (52 to 53), the full JSX (55 to 238).
  • frontend/src/components/OutreachLeadImport.tsx (228 lines): handleAddSingle/handleFileUpload/handleDrop/handleFileChange (22 to 77), the idle/single/file JSX (79 to 225).
  • frontend/src/components/AdminNavigation.tsx (22 lines): the delegator to AppShell.
  • frontend/src/components/AdminRoute.tsx: the isAdmin gate.
  • frontend/src/App.tsx: the two lazy imports (146 to 147), the two routes (868 to 881).
  • frontend/src/services/outreachService.ts: the nine-call singleton.
  • frontend/src/utils/sanitize.ts: the DOMPurify allow-list.
  • backend/api/routers/outreach_router.py: every route (26 to 160-plus), the list_emails lead-join (96 to 133).
  • backend/services/outreach/outreach_service.py: scrape_and_generate (189 to 235), update_email (251 to 262), approve_email (265 to 276), approve_all_drafts (279 to 292).
  • backend/app_fastapi.py line 878: the LABS_SURFACES mount; its exception handler (the envelope this page misreads, same lines subscription-manager cites).
  • The demo: modules/prospecting/ui/outreach-campaign-workspace/demo.tsx; the shell it renders in: modules/ui-design-system/ui/appshell-navigation/demo.tsx and nav.tsx.

Deviations of the reference from this recipe, all dated 2026-09-12: the nine detail reads; update_email's missing status guard against approve_email's strict one; the two dead imports; the empty fetchEmails catch block; no lead list on the Leads tab; the synchronous, unbounded scrape-and-generate call.

Acceptance checks

From alchemy_labs/frontend at 62ba4fc with the harness up (tooling/verify/labs-local.sh up, an admin session), or from a new app built to this recipe:

  1. grep -rc "data?.detail" src/pages/AdminOutreachCampaign.tsx src/components/OutreachEmailPreview.tsx src/components/OutreachLeadImport.tsx prints 5, 2, 2 at 62ba4fc and 0 everywhere after the fix.
  2. grep -n "outreachService\.\(get\|list\|add\|import\|scrape\|update\|approve\)" src/pages/AdminOutreachCampaign.tsx src/components/OutreachEmailPreview.tsx src/components/OutreachLeadImport.tsx | wc -l prints 9.
  3. Browser walk-through, signed in as an admin with a seeded campaign: (a) open the campaign with zero leads, observe the disabled "Scrapen & Generieren" button; (b) add one lead with a real scrapeable URL, observe total_leads become 1 and the button enable; (c) click it, wait for the round trip, switch to the Emails tab, observe one draft row with a non-empty subject and body; (d) open it, click Bearbeiten, change the subject, Speichern, observe the modal close and the list show the new subject; (e) reopen it, click Freigeben, observe the modal close, the row now approved, and the ready badge appear; (f) reopen the now-approved row, observe "Freigeben" is gone but "Bearbeiten" is still there; the gap this recipe's Gotchas names.
  4. Demo gate: node tooling/verify/render-smoke.mjs prospecting/ui/outreach-campaign-workspace renders all eight states, 0 failures.
  5. Verifier: node tooling/verify/compare.mjs prospecting/ui/outreach-campaign-workspace --state emails-tab-drafts-pending-approval --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json (an admin session with a seeded campaign carrying at least two draft emails) writes a report with page_errors: []; mismatch_ratio is read for the tab bar and the email-row list only, since the app's created_at-ordered rows and the demo's are different seeded data.

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.