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 |
|
| 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):
| Call | Route | Parsed | Rendered as |
|---|---|---|---|
getCampaign(id) | GET /campaigns/{id} | the whole body into campaign, seeds editSettings | header, 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_website | the 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-generate | discarded; both fetchCampaign and fetchEmails rerun after | the whole tab re-renders with whatever the pipeline reached synchronously (Application logic) |
updateCampaign(id, data) | PUT /campaigns/{id} | the updated campaign, replaces campaign | used 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 emails | the modal closes on success (handleEmailUpdate, line 142 to 146) |
approveEmail(id) | POST /emails/{id}/approve | discarded; same refetch-and-close | |
approveAll(id) | POST /campaigns/{id}/approve-all | {approved_count}, discarded | just 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
- On mount,
fetchCampaign()andfetchEmails()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. - 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). - Leads tab:
OutreachLeadImportposts one lead or a file, then callsonLeadsAddedwhich refetches both campaign and emails (handleLeadsAdded, line 148 to 150); a lead import alone never creates anOutreachEmailrow by itself in what this page shows; the count only changes becausetotal_leadson the refetched campaign changes. "Scrapen & Generieren" is disabled attotal_leads === 0and 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_composequeries). scrapeAndGenerateruns synchronously inside one HTTP request: the backend scrapes everypending_scrapeemail in a loop, committing after each one, then composes every now-pending_composeemail as one batch, and only then returns (outreach_service.py189 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.- 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). - The preview modal:
canEdit = status === 'draft' || status === 'approved';canApprove = status === 'draft'only (OutreachEmailPreview.tsxline 52 to 53). Editing is a pure client toggle, no network call; Speichern posts{subject, body_html}toPUT /emails/{id}and, on success, callsonUpdate(), 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/approveand does the same close-and-refetch. - Approving is the confirm gate the campaign's whole workflow depends on:
approve_email(backend) refuses with a 400 unless the current status is exactlydraft(outreach_service.py265 to 276); nothing else in this codebase moves a row toapprovedexcept this endpoint andapprove-all, and the send path this page does not own (outreach-confirm-gate-and-send) only ever picks up rows alreadyapproved.
Invariants
- Every action that changes what is shown refetches rather than patches locally:
handleLeadsAdded,handleScrapeAndGenerate,handleApproveAll,handleEmailUpdateall re-GETbefore the UI settles, the same patternsubscription-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 turndraftintoapproved, and only when the current status isdraftexactly; approving an already-approved or already-sent row 400s server-side, though this page never attempts to (canApprovehides the button first). - Approving does not lock content:
update_email(backend,outreach_service.py251 to 262) applies aPUT /emails/{id}with no status check at all, so anapprovedemail's subject or HTML can still be edited (the modal'scanEditallows 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?.erroror.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.
leads-tab-empty server rendered, hydrated in view leads-tab-with-leads server rendered, hydrated in view emails-tab-empty server rendered, hydrated in view emails-tab-drafts-pending-approval server rendered, hydrated in view emails-tab-approved-ready-to-send server rendered, hydrated in view email-preview-draft-editable server rendered, hydrated in view email-preview-approved-readonly server rendered, hydrated in view settings-tab server rendered, hydrated in view 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 & 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" 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 layouttest-layoutdescribes): each of the eight states renders its expected tab content;emails-tab-drafts-pending-approvalshows "Alle freigeben (2)" and no ready badge,emails-tab-approved-ready-to-sendshows 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-workspacerenders all eight states withrenderToStringandfetchstubbed (Acceptance checks). - alchemy_labs at 62ba4fc has no unit test of
AdminOutreachCampaign.tsx,OutreachEmailPreview.tsxorOutreachLeadImport.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 fivebackend_modulesrecipes' own suites, not to this page. - What a new app adds: a jsdom test asserting the nine catch blocks read
data.error ?? data.detailonce fixed; a test that editing anapprovedemail and saving does not silently re-open it asdraft(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 reachesdraftwith non-emptysubject/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.tsxandOutreachLeadImport.tsxreaderr.response?.data?.detail; the Labs envelope's key iserror, notdetail(app_fastapi.py, the same shapesubscription-managerandadmin-users-consoleeach 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 aPUTwith no status guard at all, whileapprove_emailrefuses anything butdraftwith a 400. The confirm gate this page's copy calls "Freigeben" only locks the transition intoapproved; 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):
InputandTextareaare imported from../components/alchemy-uion 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_generateruns 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'sdb.commit()per lead, line 218/210, means a timed-out request can still leave several leads moved topending_composewith 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 whateveremailsalready held (often[]on first load) with no error surfaced anywhere, distinct from every other failure path on this page, which all seterror. - 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/outreachin its generic "admin" domain'spaths, so this demo'sactivePathhighlights no rail item; the real IA does carry it (nav.tsxline 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 toAppShell.frontend/src/components/AdminRoute.tsx: theisAdmingate.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), thelist_emailslead-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.pyline 878: theLABS_SURFACESmount; its exception handler (the envelope this page misreads, same linessubscription-managercites).- The demo:
modules/prospecting/ui/outreach-campaign-workspace/demo.tsx; the shell it renders in:modules/ui-design-system/ui/appshell-navigation/demo.tsxandnav.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:
grep -rc "data?.detail" src/pages/AdminOutreachCampaign.tsx src/components/OutreachEmailPreview.tsx src/components/OutreachLeadImport.tsxprints5,2,2at 62ba4fc and0everywhere after the fix.grep -n "outreachService\.\(get\|list\|add\|import\|scrape\|update\|approve\)" src/pages/AdminOutreachCampaign.tsx src/components/OutreachEmailPreview.tsx src/components/OutreachLeadImport.tsx | wc -lprints9.- 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_leadsbecome 1 and the button enable; (c) click it, wait for the round trip, switch to the Emails tab, observe onedraftrow 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 nowapproved, 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. - Demo gate:
node tooling/verify/render-smoke.mjs prospecting/ui/outreach-campaign-workspacerenders all eight states, 0 failures. - 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 withpage_errors: [];mismatch_ratiois read for the tab bar and the email-row list only, since the app'screated_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.