Prima Get an API key

Module kit / Ads

Ads Manager

ads/ui/ads-managerlive

Kind frontend
Surface SPA
Maturity unmerged
Reusable extract
Design system alchemy-ui
Stack react-19, react-router-7, tailwind-3.4, alchemy-ui, axios-1, recharts-2, lucide-react
Depends on _shared/design-system-and-tokens, _shared/react-app-skeleton, _shared/error-envelope
Renders ads/ad-platform-connections, ads/cross-platform-campaign-creation, ads/google-ads-campaign-management, ads/linkedin-ads-campaigns-and-audiences, ads/retargeting-funnel-deployment
Entry alchemy_labs/frontend/src/pages/AdsManager.tsx
Reference implementation
  • alchemy_labs/frontend/src/pages/AdsManager.tsx
  • alchemy_labs/frontend/src/components/ads/MultiplatformSetups.tsx
  • alchemy_labs/frontend/src/components/ads/InsightsPanel.tsx
  • alchemy_labs/frontend/src/components/Navigation.tsx
  • alchemy_labs/frontend/src/services/adsService.ts
  • alchemy_labs/frontend/src/services/api.ts
  • alchemy_labs/backend/api/routers/ads_router.py
  • alchemy_labs/backend/api/routers/campaign_setup_router.py
  • alchemy_labs/backend/app_fastapi.py
Measured on alchemy_labs main 62ba4fc41009b454e1c033e5036c7e8577db2061 2026-09-12
Version 1

Purpose

The agency's one screen for every paid platform: an operator opens /ads and sees which of Meta, Google, LinkedIn and Microsoft are connected, a single searchable, filterable table of every campaign across the connected platforms (grouped by client, when clients are set up), and, without leaving the page, a tab that lists every cross-platform campaign brief ever submitted with its per-platform outcome and a tab that runs ad-hoc Meta or Google performance reports. The invariant that makes it trustworthy: the table, the connection dots and the spend chart are all a straight read of GET /api/ads/status, /api/ads/accounts, /api/ads/campaigns and /api/ads/groups, fetched together on every mount and after every Sync click, so an operator never acts on a platform state the page itself invented.

It makes visible four backend recipes directly and hosts the entry point of a fifth: ad-platform-connections (the status/accounts reads the Platforms card renders), google-ads-campaign-management and linkedin-ads-campaigns-and-audiences (the per-platform campaign rows and toggles the unified /campaigns and /campaigns/{id}/toggle routes proxy to), retargeting-funnel-deployment (one button away, "Retargeting", to the older single-platform builder this hub superseded as the primary route), and cross-platform-campaign-creation (the "New Campaign" wizard's POST /api/ads/campaigns/setup, whose results this page's own Multiplatform tab lists).

When to use

Trigger phrases: "the ads hub", "ads manager", "campaign table", "which platforms are connected", "multiplatform setups", "ad insights report", "the /ads page". Surface: SPA, a private Labs page behind PrivateRoute, mounted at /ads (App.tsx line 485 to 490, lazy-imported line 75) - the primary ads route; /ads/retargeting (a separate, older single-platform Meta retargeting-funnel builder, reached by this page's own "Retargeting" button) is a distinct, less current surface and not this recipe.

Not this recipe: /ads/retargeting and /ads/retargeting/:funnelId (the 5-stage Meta-only retargeting builder, retargeting-funnel-deployment's own frontend surface, out of scope here); /ads/campaigns/:campaignId (a campaign detail route this page's CampaignRow links to via onView, never rendered by this component); the "New Campaign" wizard's own step content (CreateCampaignModal, opened by a button on this page but page-local, not part of it, Frontend) and the "Clients" ad-account grouping dialog (AdGroupsModal, same); MultiplatformSetups.tsx and InsightsPanel.tsx themselves are child components this recipe renders inside two of its tabs, ported here rather than split into their own recipes because neither is reachable except through this hub and neither has a route of its own.

Stack assumptions

S1 tokens and S2 skeleton assumed; pinned per the source at 62ba4fc: react 19, react-router-dom 7, tailwindcss 3.4, lucide-react, axios 1, recharts 2 (the 14-day spend chart only, not one of the four packages a demo may import from, Frontend). Component library: @prima-modules/alchemy-ui, of which the page imports Button, Heading, Text, Card, Badge, Divider, Input, Toggle, Spinner - Toggle and Divider are imported and never once rendered in the file, a dead import pair (Gotchas). Data-fetch style: the api singleton (S2), four parallel GETs in fetchData, each individually .catch(() => null)'d so one platform's failure never blocks the other three (Application logic). Copy language: English - the one page in this recipe kit's ads domain that is not de-CH; every string in the source is English, reproduced as measured.

Data model

Client state (AdsManager.tsx lines 1172 to 1193), nothing persisted beyond S2's two auth tokens:

type Platform = 'meta' | 'google' | 'linkedin' | 'microsoft';
type CampaignStatus = 'active' | 'paused' | 'draft' | 'completed' | 'error';   // 'error' never produced by mapCampaign (Gotchas)
type Objective = 'awareness' | 'traffic' | 'leads' | 'conversions' | 'sales';

interface Campaign {
  id: string; name: string; objective: Objective; status: CampaignStatus;
  platforms: Platform[];                 // mapCampaign sets this to [raw.platform], one element, always
  daily_budget: number; currency: string; start_date: string; end_date?: string;
  metrics: CampaignMetrics;               // spend, impressions, clicks, conversions, ctr, cpc, cpa, roas - ctr/cpc/cpa derived client-side (line 946 to 949)
  platform_metrics: Record<Platform, CampaignMetrics>;   // always four zeroed entries, never populated (Gotchas)
  account_id?: string;
}
interface PlatformConnection { platform: Platform; connected: boolean; accounts: AdAccount[]; last_synced?: string }
interface AdGroup { name: string; meta_account_id?: string; google_account_id?: string; linkedin_account_id?: string; microsoft_account_id?: string }

loading: boolean (initially true); campaigns: Campaign[]; connections: PlatformConnection[4] (initially all disconnected);
chartData: {date, meta, google, linkedin}[]; adGroups: AdGroup[]; error: string | null (set, never rendered, Gotchas);
toast: {kind: 'success'|'warning'|'error', text: string} | null;
searchQuery: string; statusFilter: CampaignStatus | 'all'; platformFilter: Platform | 'all'; showInactive: boolean;
activeGroup: string; activeTab: 'campaigns' | 'multiplatform' | 'insights'; showCreate/showGroupSettings: boolean.

MultiplatformSetups.tsx and InsightsPanel.tsx own their state independently (CampaignSetupRecord[]/expanded: string | null; rows: FlatRow[]/platform/accountId/level and range selects) - the demo's props include both tabs' data since the demo has no lifecycle to fetch them on tab switch.

The demo's props (demo.tsx): { viewer, workspace, loading, activeTab, connections, campaigns, chartData, adGroups, toast, initialSearchQuery?, initialStatusFilter?, initialPlatformFilter?, initialShowInactive?, initialActiveGroup?, setups, initialExpandedSetupId?, metaAccounts, googleAccounts, metaRows, googleRows }; the five initial* fields and the setup/insights local selections seed local useState, the same convention subscription-manager's voucher input uses, since the filters, the expanded row and the platform toggle are all things a viewer types or clicks.

Browser storage: nothing of its own, relies on S2's accessToken/refreshToken. No workspace id is read from storage; it comes from the shell's current-workspace context and is attached by the API client's interceptor (Security & tenancy).

API surface

Contracts consumed, all under /api/ads (ads_router.py, mount_router(..., prefix="/api/ads", surfaces=LABS_SURFACES), app_fastapi.py 863):

CallRouteAuth / tenancyParsedRendered as
api.get('/api/ads/status')GET /status (ads_router.py 1051)CurrentUser + DatabaseSession, workspace from _require_workspace(request) (a header, not the URL)platforms array or object, normalized to an object keyed by platform (1270 to 1275)connected/last_synced per PlatformConnectionCard; does not carry accounts (Gotchas)
api.get('/api/ads/accounts')GET /accounts (1084)sameflat account list, grouped client-side by platform (1258 to 1261)the account count and list each PlatformConnectionCard and AdGroupsModal's per-platform selects show
api.get('/api/ads/campaigns?days=30&hide_inactive=…')GET /campaigns (1151)samecampaigns[], each through mapCampaignevery CampaignRow, the four MetricCard aggregates
api.get('/api/ads/groups')GET /groups (1909)samegroups[]client tabs, AdGroupsModal's existing rows
api.post('/api/ads/campaigns/setup', {..., dry_run:false, idempotency_key})POST /campaigns/setup (campaign_setup_router.py, ads/cross-platform-campaign-creation)same, CampaignBrief in PAUSED-only outSetupResponse.results[]the bottom toast (success/partial/failed text built from platform/reason/error per row, line 1213 to 1227)
api.post('/api/ads/campaigns/{id}/toggle', {platform})POST /campaigns/{id}/toggle (1324)same; action inferred from the row's current status, not sent explicitly by this page (Gotchas)nothing (fetchData() refetches)the row's dot/badge/icon on the next render; a failed toggle is swallowed silently (catch { /* Silently fail for now */ }, line 1396 to 1398, Gotchas)
api.put('/api/ads/groups', {groups})PUT /groups (1922)samenothing on successadGroups state set directly from the request body, not from the response (line 1748 to 1749)
api.post('/api/meta/connect' | '/api/google-ads/connect' | '/api/linkedin/connect', {workspace_id})platform-specific OAuth routerssameauthorization_urla centered popup window (handleConnect, 1359 to 1386); Microsoft has no entry in connectMap and its Connect button is a dead click (Gotchas)
MultiplatformSetups.tsx: listCampaignSetups, getCampaignSetup, retryCampaignSetup (adsService.ts 82, 90, 95)GET /campaigns/setups, GET /campaigns/setups/{id}, POST /campaigns/setups/{id}/retrysameCampaignSetupRecord[] / one recordthe Multiplatform tab's list and expandable rollup
InsightsPanel.tsx: getMetaInsights, runGoogleReport (147, 289)Meta Graph insights / Google Ads report proxiessamerows flattened to one FlatRow shape per platformthe Insights tab's totals strip and table

Error bodies: every refusal from /status, /accounts, /campaigns, /groups and the connect endpoints is swallowed by fetchData's own per-call .catch(() => null) (1250 to 1255) - a failed read renders as if that section returned nothing, not as a visible error, the same as the page-level error state never being read (Gotchas). Only handleCreateCampaign's and handleConnect's catches surface anything, and both read err.response?.data?.detail, the field name identity-auth/ui/admin-users-console's and subscription-manager's Gotchas already document as not matching the Labs envelope's error key (app_fastapi.py's create_error_response).

Application logic

  1. Mount: loading: true, connections all disconnected, campaigns: []; useEffect calls fetchData() once and again whenever currentWorkspace or showInactive changes (1330 to 1332). While loading, the page renders only the shell and a centered Spinner (1401 to 1409) - no header, no tabs, no toast.
  2. fetchData fires the four reads in Promise.all, each individually caught so one platform's failure never blocks the others' data from rendering (1250 to 1255); it groups the flat accounts list by platform (1258 to 1261), normalizes status.platforms from either an array or an object shape into one object keyed by platform (1270 to 1275), maps each raw campaign through mapCampaign (938 to 972: status normalized through a fixed map, platforms set to the single reported platform, ctr/cpc/cpa derived from raw spend/clicks/conversions), and rebuilds a 14-point chartData array with every value hardcoded to zero regardless of the campaigns just fetched (1311 to 1321, Gotchas).
  3. The four MetricCards and avgROAS/avgCPA are derived client-side from campaigns, not fetched separately (1335 to 1340); avgROAS divides by Math.max(1, …) of only the campaigns with roas > 0, so a workspace with campaigns but zero recorded ROAS shows 0.0x, not NaN.
  4. filtered applies, in order: a case-insensitive substring match on searchQuery, an exact statusFilter, an exact-membership platformFilter on platforms, then, when activeGroup !== 'all', a lookup of the group's four platform account ids against the campaign's own account_id - a campaign with no account_id at all always passes the group filter regardless of which client tab is active (1350 to 1351, a real gap: an unassigned campaign appears under every client).
  5. "New Campaign" opens CreateCampaignModal; its own 4-step flow (name/objective/budget, platform selection with auto-even budget split, audience/creative, a dry_run preview of POST /campaigns/setup) ends by calling handleCreateCampaign, which posts the same brief with dry_run: false and a fresh crypto.randomUUID() idempotency key, builds one toast from the per-platform results[] (all created: success; some: warning naming which failed and why; none: error), closes the modal and calls fetchData() (1203 to 1237).
  6. handleToggleCampaign posts to /campaigns/{id}/toggle with only platform: campaign.platforms[0], no action - the backend infers pause-vs-resume from the campaign's current state - then refetches on success and silently does nothing on failure (1388 to 1399).
  7. "Sync" and every filter/tab/group change are purely client-side except Sync itself, which re-runs fetchData().
  8. Connecting a platform opens a centered OAuth popup to the URL the connect endpoint returns; there is no listener for the popup closing or succeeding, so a newly connected platform only shows as connected after the next manual Sync or remount (1359 to 1386, a real gap).

Invariants

  • What the table shows is what the four reads returned, not a local computation layered on top: every write (toggle, campaign creation, group save) is followed by a refetch of the same four routes rather than a local patch (steps 5, 6).
  • No platform's failure is fatal to the page: each of the four reads is caught independently, so a Meta outage still renders Google and LinkedIn's connections and campaigns (step 2).
  • Every campaign this page can create is created paused: handleCreateCampaign never sets dry_run anywhere but false for the real submit and the wizard's own preview always sends dry_run: true first; the "everything is created PAUSED" copy in the wizard's review step is enforced server-side by cross-platform-campaign-creation, not by this page (Purpose).
  • A group filter is additive, never exclusionary by omission: a campaign with no account_id is never hidden by picking a client tab (step 4), so "all campaigns for this client" is not the same guarantee as "only this client's campaigns".
  • No mutation without an endpoint: search, status/platform filters, the client tabs, and expanding a Multiplatform setup row are all pure client-side state; the demo's local useState for each mirrors this exactly, nothing it renders posts anywhere.

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.

Campaigns tab, two platforms connected: connection cards, the spend chart, a filterable table of six campaigns default server rendered, hydrated in view

Ads

Create once, run everywhere. Manage campaigns across all platforms.

Platforms

2 of 4 connected

M

Meta

2 accounts

G

Google

1 account

in

LinkedIn

Not connected

M

Microsoft

Not connected

Campaigns
Multiplatform
Insights

Total Spend

CHF

4,966.5

Conversions

138

Avg. CPA

CHF

35.99

Avg. ROAS

1.3

x

Spend by Platform (14 days)

Meta

Google

LinkedIn

30. Aug

12. Sep

Alpin Feinkost — Frühling Launch

active
M

Leads

Bergkristall — Retargeting Q3

paused
M

Conversions

Nordlicht Search Brand

active
G

Traffic

Alpin Feinkost — Search Non-Brand

draft
G

Sales

Bergkristall — Awareness Reel

active
M
G

Awareness

Nordlicht Autumn Wrap-up

completed
G

Awareness

First fetch: the shell renders, the body is one centered spinner (line 1401 to 1409) loading server rendered, hydrated in view
Connected, campaigns exist, but the search text matches none: "No campaigns match your filters." empty server rendered, hydrated in view

Ads

Create once, run everywhere. Manage campaigns across all platforms.

Platforms

2 of 4 connected

M

Meta

2 accounts

G

Google

1 account

in

LinkedIn

Not connected

M

Microsoft

Not connected

Campaigns
Multiplatform
Insights

Total Spend

CHF

4,966.5

Conversions

138

Avg. CPA

CHF

35.99

Avg. ROAS

1.3

x

Spend by Platform (14 days)

Meta

Google

LinkedIn

30. Aug

12. Sep

No campaigns match your filters.

Fresh workspace: all four platforms show "Not connected", no clients, "No campaigns yet. Create your first one!" disconnected server rendered, hydrated in view

Ads

Create once, run everywhere. Manage campaigns across all platforms.

Platforms

0 of 4 connected

M

Meta

Not connected

G

Google

Not connected

in

LinkedIn

Not connected

M

Microsoft

Not connected

Campaigns
Multiplatform
Insights

Total Spend

CHF

0

Conversions

0

Avg. CPA

CHF

0.00

Avg. ROAS

0.0

x

Spend by Platform (14 days)

Meta

Google

LinkedIn

30. Aug

12. Sep

No campaigns yet. Create your first one!

A campaign-setup submission came back partial: the bottom toast is the only place the page surfaces an error (Gotchas) error server rendered, hydrated in view

Ads

Create once, run everywhere. Manage campaigns across all platforms.

Platforms

2 of 4 connected

M

Meta

2 accounts

G

Google

1 account

in

LinkedIn

Not connected

M

Microsoft

Not connected

Campaigns
Multiplatform
Insights

Total Spend

CHF

4,966.5

Conversions

138

Avg. CPA

CHF

35.99

Avg. ROAS

1.3

x

Spend by Platform (14 days)

Meta

Google

LinkedIn

30. Aug

12. Sep

Alpin Feinkost — Frühling Launch

active
M

Leads

Bergkristall — Retargeting Q3

paused
M

Conversions

Nordlicht Search Brand

active
G

Traffic

Alpin Feinkost — Search Non-Brand

draft
G

Sales

Bergkristall — Awareness Reel

active
M
G

Awareness

Nordlicht Autumn Wrap-up

completed
G

Awareness

Campaign setup failed: linkedin: Ad account not connected; google: budget exceeds daily cap

✕
Multiplatform tab: three setups, the partial one expanded into its per-platform rollup with "Retry failed platforms" multiplatform-tab server rendered, hydrated in view

Ads

Create once, run everywhere. Manage campaigns across all platforms.

Platforms

2 of 4 connected

M

Meta

2 accounts

G

Google

1 account

in

LinkedIn

Not connected

M

Microsoft

Not connected

Campaigns
Multiplatform
Insights

Multiplatform Campaigns

3 setups

Alpin Feinkost — Frühling Launch

created
metagoogle

Bergkristall — Retargeting Q3

partial
metalinkedin

Objective

conversions

Daily Budget

CHF 80

Countries

CH

Landing URL

https://example.com/retarget

M

meta

created

Campaign ID: demo-meta-campaign-772

in

linkedin

failed

Ad account not connected

Nordlicht Autumn Wrap-up

failed
google
Insights tab: Meta selected, campaign-level rows for the two connected ad accounts, totals strip and results table insights-tab server rendered, hydrated in view

Ads

Create once, run everywhere. Manage campaigns across all platforms.

Platforms

2 of 4 connected

M

Meta

2 accounts

G

Google

1 account

in

LinkedIn

Not connected

M

Microsoft

Not connected

Campaigns
Multiplatform
Insights

Insights

Spend

CHF 4'216.3

Impressions

793,000

Clicks

7,370

Conversions

117

NameSpendImpr.ClicksCTRCPCConv.
Bergkristall — Awareness ReelCHF 2'115.8512,3003,9800.78%0.5322
Alpin Feinkost — Frühling LaunchCHF 1'240.5184,2002,2101.20%0.5664
Bergkristall — Retargeting Q3CHF 86096,5001,1801.22%0.7331
ads/ui/ads-manager/demo.tsx
/**
 * Live demo for ads/ui/ads-manager.
 *
 * Labs' AdsManager.tsx (main, 62ba4fc, 1760 lines, mounted at `/ads`) with its
 * four reads (`GET /api/ads/status`, `/api/ads/accounts`, `/api/ads/campaigns`,
 * `/api/ads/groups`, fired in parallel from `fetchData`) and its writes
 * (campaign create/toggle, OAuth connect, group save) replaced by props: the
 * page's own state variables — `loading`, `activeTab`, `connections`,
 * `campaigns`, `chartData`, `adGroups`, `toast` — plus the filter/search
 * state and the two embedded tabs' own state, each seeded from a prop and
 * then owned locally the same way subscription-manager's voucher input is
 * (AUTHORING.md "The exports"). Rendered inside the shell demo the way every
 * private page is (`Navigation.tsx` is a thin delegator to `AppShell`), on
 * `/ads` (`nav.tsx`'s "campaigns" domain, `paths: ['/ads']`).
 *
 * What is kept, byte-faithful to structure and copy: the header and its four
 * actions (Retargeting / Clients / Sync / New Campaign — the last three
 * inert, Gotchas), the client tabs row, the platform-connections card (all
 * four `PLATFORMS`, `PlatformConnectionCard`'s connected/not-connected
 * branches), the three-tab strip (Campaigns / Multiplatform / Insights), the
 * campaigns tab's four `MetricCard`s, the spend-by-platform chart, the
 * search/status/platform filters and the "Show Older" toggle, the campaign
 * list header and `CampaignRow` (status dot, platform badges, metrics
 * columns, hover actions), both of `filtered.length === 0`'s messages, the
 * bottom toast, the Multiplatform tab's setup list (`MultiplatformSetups.tsx`
 * — status badges, per-platform chips, the expandable rollup with its brief
 * summary, per-platform outcome cards and the "Retry failed platforms"
 * button), and the Insights tab (`InsightsPanel.tsx` — the Meta/Google
 * toggle, the level/date-range/breakdown selects, the totals strip and the
 * results table). All copy is the source's, English (the one surface in Labs
 * that ships English while the rest is de-CH, reproduced as measured).
 *
 * What is different, and why:
 *  - `useTheme()`'s `isDark` branches every className in the source. The
 *    demo packages' alchemy-ui is, per its own package.json, "a light-locked
 *    theme shim" — there is no dark mode to branch into here, and every
 *    capture in this kit's verify harness runs `colorScheme: light`
 *    (AUTHORING.md "Rules") — so each `isDark ? a : b` ternary is collapsed
 *    to its light-mode `b` and the theme prop is dropped from every
 *    sub-component.
 *  - `recharts`'s `<AreaChart>` (the 14-day spend-by-platform card) is not a
 *    demo package (AUTHORING.md "The exports" names the four runtime
 *    packages a demo may import from; charting is not one of them, the same
 *    boundary that drops framer-motion from subscription-manager's gauge).
 *    `SpendByPlatformChart` below redraws the same three stacked areas with
 *    hand-built `<svg>` `<path>`s over the same `chartData` shape, gradient
 *    fills included; it is a substitute for the library, not a simplification
 *    of what it shows.
 *  - `useNavigate()` (View campaign, `/ads/retargeting`) and the OAuth
 *    popup (`window.open` in `handleConnect`) have nothing to navigate to or
 *    pop up from inside a demo frame: "Retargeting", "Connect" and a campaign
 *    row's click-to-view are rendered with their real label, icon and
 *    disabled state but call nothing, the same convention
 *    admin-users-console's row actions and subscription-manager's
 *    change-tier buttons use for a source-driven action the demo cannot
 *    perform. Pause/Resume and the "..." row menu are likewise inert.
 *  - `CreateCampaignModal` (the 4-step "New Campaign" wizard behind the
 *    header's primary button — name/objective/budget, platform + auto-split,
 *    audience + creative, then a `dry_run` preview of `POST
 *    /api/ads/campaigns/setup`) and `AdGroupsModal` ("Clients" behind the
 *    header's Settings button) are page-local components, 490 and 180 lines
 *    of their own step/field state that open over the page rather than being
 *    part of it. Neither is mounted: both buttons that open them render with
 *    their real label and icon and do nothing, matching how
 *    subscription-manager leaves its own change-tier modal unmounted for the
 *    same reason. The wizard's actual surface, `POST /api/ads/campaigns/setup`,
 *    is the `ads/cross-platform-campaign-creation` backend recipe; its result
 *    is what the Multiplatform tab below lists.
 *  - Each precomputed date in `chartData` and every setup's `created_at` is a
 *    literal ISO string, not `new Date()` at module load: the frame has to
 *    render the same way on every build, and `toLocaleDateString('de-CH', …)`
 *    (used for a setup's created date) is replaced with a fixed UTC-getter
 *    formatter for the same reason subscription-manager's does.
 *  - The Insights tab's platform toggle swaps between two precomputed row
 *    sets (`metaRows` / `googleRows`) instead of calling `getMetaInsights` /
 *    `runGoogleReport`; the level/date-range/breakdown selects are real,
 *    controlled UI state, they just have nothing to re-fetch in a demo with
 *    no network (Invariants).
 *
 * Two things the source itself does that are worth knowing before reading
 * this file as a model (both real, both dated, neither invented for this
 * recipe — see Gotchas): the page's top-level `error` state is set in two
 * catch blocks and never once read in the JSX (no dead branch was added
 * here to compensate: the toast, which IS rendered, is this recipe's stand-in
 * for a visible error surface, hence the `error` demo state below uses it),
 * and `MetricCard` supports a `change` prop ("+12.3% vs prev period") that no
 * call site in the page ever passes — both are reproduced by omission, not
 * fixed.
 */
import React, { useState } from 'react';
import {
  Plus,
  Search,
  TrendingUp as ArrowUpRight,
  TrendingDown as ArrowDownRight,
  Pause,
  Play,
  MoreVertical as MoreHorizontal,
  Zap,
  TrendingUp,
  DollarSign,
  Target,
  RefreshCw,
  Settings,
  ChevronDown,
  ChevronRight,
  AlertTriangle,
  CheckCircle,
  XCircle,
  Link2,
  BarChart3,
  Layers,
  Megaphone,
} from 'lucide-react';
import { defineStates } from '@prima-modules/demo';
import { Badge, Button, Card, Heading, Input, Spinner, 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 (AdsManager.tsx 69 to 191, adsService.ts 43 to 68 and 246 to 264) ------ */

type Platform = 'meta' | 'google' | 'linkedin' | 'microsoft';
type CampaignStatus = 'active' | 'paused' | 'draft' | 'completed' | 'error';
type Objective = 'awareness' | 'traffic' | 'leads' | 'conversions' | 'sales';

interface PlatformConfig {
  id: Platform;
  name: string;
  color: string;
}

interface AdAccount {
  id: string;
  platform: Platform;
  name: string;
  account_id: string;
  currency: string;
  is_default: boolean;
  status: 'active' | 'disconnected' | 'error';
}

interface CampaignMetrics {
  spend: number;
  impressions: number;
  clicks: number;
  conversions: number;
  ctr: number;
  cpc: number;
  cpa: number;
  roas: number;
}

interface Campaign {
  id: string;
  name: string;
  objective: Objective;
  status: CampaignStatus;
  platforms: Platform[];
  metrics: CampaignMetrics;
  account_id?: string;
}

interface PlatformConnection {
  platform: Platform;
  connected: boolean;
  accounts: AdAccount[];
}

interface AdGroup {
  name: string;
  meta_account_id?: string;
  google_account_id?: string;
  linkedin_account_id?: string;
  microsoft_account_id?: string;
}

interface ChartPoint {
  date: string;
  meta: number;
  google: number;
  linkedin: number;
}

interface Toast {
  kind: 'success' | 'warning' | 'error';
  text: string;
}

/** adsService.ts 43 to 68 — a `POST /api/ads/campaigns/setup` outcome, one row per platform. */
interface SetupPlatformResult {
  platform: string;
  status: 'created' | 'failed' | 'skipped' | 'preview' | string;
  campaign_id?: string | null;
  reason?: string | null;
  error?: string | null;
  warnings?: string[];
}

interface CampaignSetupRecord {
  id: string;
  name: string;
  objective: string;
  status: 'created' | 'partial' | 'failed' | 'creating' | string;
  platform_results: SetupPlatformResult[];
  total_daily_budget: number;
  currency: string;
  brief: { audience?: { geo?: string[] }; creative?: { landing_url?: string } };
  created_at?: string | null;
}

interface AccountOption {
  id: string;
  name: string;
}

/** InsightsPanel.tsx's flattened row shape (Meta and Google reports normalized to one table). */
interface FlatRow {
  name: string;
  spend: number;
  impressions: number;
  clicks: number;
  conversions: number;
  ctr: number;
  cpc: number;
}

export interface Props {
  viewer: ShellUser;
  workspace: ShellWorkspace;
  /** The guard at line 1401: true renders only the shell and a centered `Spinner`, nothing else. */
  loading: boolean;
  activeTab: 'campaigns' | 'multiplatform' | 'insights';
  connections: PlatformConnection[];
  campaigns: Campaign[];
  chartData: ChartPoint[];
  /** Client tabs above the connections card; hidden entirely when empty (line 1446). */
  adGroups: AdGroup[];
  toast: Toast | null;
  /** Filters own their live value locally once mounted (same convention as the voucher input); these seed it. */
  initialSearchQuery?: string;
  initialStatusFilter?: CampaignStatus | 'all';
  initialPlatformFilter?: Platform | 'all';
  initialShowInactive?: boolean;
  initialActiveGroup?: string;
  /** Multiplatform tab (MultiplatformSetups.tsx). */
  setups: CampaignSetupRecord[];
  initialExpandedSetupId?: string | null;
  /** Insights tab (InsightsPanel.tsx). */
  metaAccounts: AccountOption[];
  googleAccounts: AccountOption[];
  metaRows: FlatRow[];
  googleRows: FlatRow[];
}

/* ---- constants (AdsManager.tsx 129 to 174, adsService.ts 104 to 264) -------------- */

const PLATFORMS: Record<Platform, PlatformConfig> = {
  meta: { id: 'meta', name: 'Meta', color: '#1877F2' },
  google: { id: 'google', name: 'Google', color: '#4285F4' },
  linkedin: { id: 'linkedin', name: 'LinkedIn', color: '#0A66C2' },
  microsoft: { id: 'microsoft', name: 'Microsoft', color: '#00A4EF' },
};
const PLATFORM_ICON: Record<Platform, string> = { meta: 'M', google: 'G', linkedin: 'in', microsoft: 'M' };

const OBJECTIVES: Record<Objective, { label: string }> = {
  awareness: { label: 'Awareness' },
  traffic: { label: 'Traffic' },
  leads: { label: 'Leads' },
  conversions: { label: 'Conversions' },
  sales: { label: 'Sales' },
};

const META_DATE_PRESETS = ['today', 'yesterday', 'last_7d', 'last_14d', 'last_28d', 'last_30d', 'last_90d'] as const;
const META_BREAKDOWNS = ['age', 'gender', 'country', 'region', 'publisher_platform'] as const;
const GOOGLE_LEVELS = ['campaign', 'ad_group', 'keyword', 'search_term'] as const;
const GOOGLE_DATE_RANGES = ['TODAY', 'LAST_7_DAYS', 'LAST_14_DAYS', 'LAST_30_DAYS', 'LAST_MONTH'] as const;

/** Fixed UTC-getter formatter: `toLocaleDateString('de-CH', ...)` depends on the runtime's ICU data,
 * the same reason subscription-manager's `formatDate` replaces it (its header comment). */
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function formatDay(iso: string): string {
  const d = new Date(iso);
  return `${d.getUTCDate()}. ${MONTHS[d.getUTCMonth()]}`;
}

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

const viewer: ShellUser = { name: 'Nora Isler', email: '[email protected]', isAdmin: true };
const workspace: ShellWorkspace = { id: 'ws_2', name: 'Studio Nordlicht', slug: 'studio-nordlicht' };

const emptyMetrics: CampaignMetrics = { spend: 0, impressions: 0, clicks: 0, conversions: 0, ctr: 0, cpc: 0, cpa: 0, roas: 0 };

const connectedAccounts: Record<Platform, AdAccount[]> = {
  meta: [
    { id: 'demo-meta-9001', platform: 'meta', name: 'Nordlicht — Prospecting', account_id: 'demo-meta-9001', currency: 'CHF', is_default: true, status: 'active' },
    { id: 'demo-meta-9002', platform: 'meta', name: 'Bergkristall Uhren — Retarget', account_id: 'demo-meta-9002', currency: 'CHF', is_default: false, status: 'active' },
  ],
  google: [
    { id: 'demo-google-4001', platform: 'google', name: 'Nordlicht — Search', account_id: 'demo-google-4001', currency: 'CHF', is_default: true, status: 'active' },
  ],
  linkedin: [],
  microsoft: [],
};

const connectedConnections: PlatformConnection[] = [
  { platform: 'meta', connected: true, accounts: connectedAccounts.meta },
  { platform: 'google', connected: true, accounts: connectedAccounts.google },
  { platform: 'linkedin', connected: false, accounts: [] },
  { platform: 'microsoft', connected: false, accounts: [] },
];

const disconnectedConnections: PlatformConnection[] = [
  { platform: 'meta', connected: false, accounts: [] },
  { platform: 'google', connected: false, accounts: [] },
  { platform: 'linkedin', connected: false, accounts: [] },
  { platform: 'microsoft', connected: false, accounts: [] },
];

const sampleCampaigns: Campaign[] = [
  {
    id: 'camp-1', name: 'Alpin Feinkost — Frühling Launch', objective: 'leads', status: 'active',
    platforms: ['meta'], account_id: 'demo-meta-9002',
    metrics: { spend: 1240.5, impressions: 184200, clicks: 2210, conversions: 64, ctr: 1.2, cpc: 0.56, cpa: 19.38, roas: 2.4 },
  },
  {
    id: 'camp-2', name: 'Bergkristall — Retargeting Q3', objective: 'conversions', status: 'paused',
    platforms: ['meta'], account_id: 'demo-meta-9001',
    metrics: { spend: 860.0, impressions: 96500, clicks: 1180, conversions: 31, ctr: 1.22, cpc: 0.73, cpa: 27.74, roas: 1.6 },
  },
  {
    id: 'camp-3', name: 'Nordlicht Search Brand', objective: 'traffic', status: 'active',
    platforms: ['google'], account_id: 'demo-google-4001',
    metrics: { spend: 410.2, impressions: 38900, clicks: 1640, conversions: 12, ctr: 4.22, cpc: 0.25, cpa: 34.18, roas: 0.9 },
  },
  {
    id: 'camp-4', name: 'Alpin Feinkost — Search Non-Brand', objective: 'sales', status: 'draft',
    platforms: ['google'], account_id: 'demo-google-4001',
    metrics: emptyMetrics,
  },
  {
    id: 'camp-5', name: 'Bergkristall — Awareness Reel', objective: 'awareness', status: 'active',
    platforms: ['meta', 'google'], account_id: 'demo-meta-9001',
    metrics: { spend: 2115.8, impressions: 512300, clicks: 3980, conversions: 22, ctr: 0.78, cpc: 0.53, cpa: 96.17, roas: 0.7 },
  },
  {
    id: 'camp-6', name: 'Nordlicht Autumn Wrap-up', objective: 'awareness', status: 'completed',
    platforms: ['google'], account_id: 'demo-google-4001',
    metrics: { spend: 340.0, impressions: 61200, clicks: 890, conversions: 9, ctr: 1.45, cpc: 0.38, cpa: 37.78, roas: 1.1 },
  },
];

const sampleChart: ChartPoint[] = [
  { date: '2026-08-30', meta: 210, google: 90, linkedin: 0 },
  { date: '2026-08-31', meta: 195, google: 85, linkedin: 0 },
  { date: '2026-09-01', meta: 240, google: 110, linkedin: 0 },
  { date: '2026-09-02', meta: 260, google: 95, linkedin: 0 },
  { date: '2026-09-03', meta: 175, google: 130, linkedin: 0 },
  { date: '2026-09-04', meta: 205, google: 120, linkedin: 0 },
  { date: '2026-09-05', meta: 230, google: 100, linkedin: 0 },
  { date: '2026-09-06', meta: 250, google: 140, linkedin: 0 },
  { date: '2026-09-07', meta: 190, google: 115, linkedin: 0 },
  { date: '2026-09-08', meta: 220, google: 105, linkedin: 0 },
  { date: '2026-09-09', meta: 265, google: 150, linkedin: 0 },
  { date: '2026-09-10', meta: 245, google: 125, linkedin: 0 },
  { date: '2026-09-11', meta: 200, google: 95, linkedin: 0 },
  { date: '2026-09-12', meta: 235, google: 135, linkedin: 0 },
];

const sampleAdGroups: AdGroup[] = [
  { name: 'Alpin Feinkost', meta_account_id: 'demo-meta-9002', google_account_id: 'demo-google-4001' },
  { name: 'Bergkristall Uhren', meta_account_id: 'demo-meta-9001', google_account_id: 'demo-google-4001' },
];

const sampleSetups: CampaignSetupRecord[] = [
  {
    id: 'setup-1', name: 'Alpin Feinkost — Frühling Launch', objective: 'leads', status: 'created',
    platform_results: [
      { platform: 'meta', status: 'created', campaign_id: 'demo-meta-campaign-771' },
      { platform: 'google', status: 'created', campaign_id: 'demo-google-campaign-338' },
    ],
    total_daily_budget: 120, currency: 'CHF', created_at: '2026-09-08T09:12:00Z',
    brief: { audience: { geo: ['CH', 'DE'] }, creative: { landing_url: 'https://example.com/fruhling' } },
  },
  {
    id: 'setup-2', name: 'Bergkristall — Retargeting Q3', objective: 'conversions', status: 'partial',
    platform_results: [
      { platform: 'meta', status: 'created', campaign_id: 'demo-meta-campaign-772' },
      { platform: 'linkedin', status: 'failed', error: 'Ad account not connected', warnings: [] },
    ],
    total_daily_budget: 80, currency: 'CHF', created_at: '2026-09-05T14:40:00Z',
    brief: { audience: { geo: ['CH'] }, creative: { landing_url: 'https://example.com/retarget' } },
  },
  {
    id: 'setup-3', name: 'Nordlicht Autumn Wrap-up', objective: 'awareness', status: 'failed',
    platform_results: [
      { platform: 'google', status: 'failed', error: 'Daily budget below platform minimum', warnings: [] },
    ],
    total_daily_budget: 20, currency: 'CHF', created_at: '2026-09-01T08:00:00Z',
    brief: { audience: { geo: ['CH', 'AT'] }, creative: { landing_url: 'https://example.com/autumn' } },
  },
];

const metaAccountOptions: AccountOption[] = connectedAccounts.meta.map((a) => ({ id: a.account_id, name: a.name }));
const googleAccountOptions: AccountOption[] = connectedAccounts.google.map((a) => ({ id: a.account_id, name: a.name }));

const sampleMetaRows: FlatRow[] = [
  { name: 'Alpin Feinkost — Frühling Launch', spend: 1240.5, impressions: 184200, clicks: 2210, conversions: 64, ctr: 1.2, cpc: 0.56 },
  { name: 'Bergkristall — Retargeting Q3', spend: 860.0, impressions: 96500, clicks: 1180, conversions: 31, ctr: 1.22, cpc: 0.73 },
  { name: 'Bergkristall — Awareness Reel', spend: 2115.8, impressions: 512300, clicks: 3980, conversions: 22, ctr: 0.78, cpc: 0.53 },
];

const sampleGoogleRows: FlatRow[] = [
  { name: 'Nordlicht Search Brand', spend: 410.2, impressions: 38900, clicks: 1640, conversions: 12, ctr: 4.22, cpc: 0.25 },
  { name: 'Nordlicht Autumn Wrap-up', spend: 340.0, impressions: 61200, clicks: 890, conversions: 9, ctr: 1.45, cpc: 0.38 },
];

const base: Props = {
  viewer,
  workspace,
  loading: false,
  activeTab: 'campaigns',
  connections: connectedConnections,
  campaigns: sampleCampaigns,
  chartData: sampleChart,
  adGroups: sampleAdGroups,
  toast: null,
  initialSearchQuery: '',
  initialStatusFilter: 'all',
  initialPlatformFilter: 'all',
  initialShowInactive: false,
  initialActiveGroup: 'all',
  setups: sampleSetups,
  initialExpandedSetupId: null,
  metaAccounts: metaAccountOptions,
  googleAccounts: googleAccountOptions,
  metaRows: sampleMetaRows,
  googleRows: sampleGoogleRows,
};

export const states = defineStates<Props>({
  'default': {
    label: 'Campaigns tab, two platforms connected: connection cards, the spend chart, a filterable table of six campaigns',
    props: base, height: 1700, viewport: 1440,
  },
  'loading': {
    label: 'First fetch: the shell renders, the body is one centered spinner (line 1401 to 1409)',
    props: { ...base, loading: true, connections: disconnectedConnections, campaigns: [], adGroups: [] },
    height: 520, viewport: 1440,
  },
  'empty': {
    label: 'Connected, campaigns exist, but the search text matches none: "No campaigns match your filters."',
    props: { ...base, initialSearchQuery: 'holiday bundle' },
    height: 1000, viewport: 1440,
  },
  'disconnected': {
    label: 'Fresh workspace: all four platforms show "Not connected", no clients, "No campaigns yet. Create your first one!"',
    props: { ...base, connections: disconnectedConnections, campaigns: [], adGroups: [] },
    height: 900, viewport: 1440,
  },
  'error': {
    label: 'A campaign-setup submission came back partial: the bottom toast is the only place the page surfaces an error (Gotchas)',
    props: {
      ...base,
      toast: { kind: 'error', text: 'Campaign setup failed: linkedin: Ad account not connected; google: budget exceeds daily cap' },
    },
    height: 1700, viewport: 1440,
  },
  'multiplatform-tab': {
    label: 'Multiplatform tab: three setups, the partial one expanded into its per-platform rollup with "Retry failed platforms"',
    props: { ...base, activeTab: 'multiplatform', initialExpandedSetupId: 'setup-2' },
    height: 1000, viewport: 1440,
  },
  'insights-tab': {
    label: 'Insights tab: Meta selected, campaign-level rows for the two connected ad accounts, totals strip and results table',
    props: { ...base, activeTab: 'insights' },
    height: 900, viewport: 1440,
  },
});

/* ---- small components (AdsManager.tsx 197 to 435) --------------------------------- */

function PlatformBadge({ platform, size = 'sm' }: { platform: Platform; size?: 'sm' | 'md' }) {
  const config = PLATFORMS[platform];
  const sizeClasses = size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-7 h-7 text-xs';
  return (
    <div
      className={`${sizeClasses} rounded-md flex items-center justify-center font-bold text-white flex-shrink-0`}
      style={{ backgroundColor: config.color }}
      title={config.name}
    >
      {PLATFORM_ICON[platform]}
    </div>
  );
}

function MetricCard({
  label, value, prefix = '', suffix = '', icon: Icon,
}: { label: string; value: string | number; prefix?: string; suffix?: string; icon: React.ComponentType<any> }) {
  return (
    <Card padding="sm" className="bg-white border-stone-200">
      <div className="flex items-start justify-between">
        <div>
          <Text size="xs" className="text-stone-500">{label}</Text>
          <div className="mt-1 flex items-baseline gap-1">
            <Text size="xs" className="text-stone-500">{prefix}</Text>
            <Heading level={3} className="!text-2xl">{value}</Heading>
            <Text size="xs" className="text-stone-500">{suffix}</Text>
          </div>
        </div>
        <div className="p-2 rounded-lg bg-[#6844C4]/10">
          <Icon className="w-4 h-4 text-[#6844C4]" />
        </div>
      </div>
      {/* `change` (the +/-% row, ArrowUpRight/ArrowDownRight) is supported but never
          passed by any call site in the source (line 1533 to 1536) — reproduced by omission. */}
    </Card>
  );
}

function PlatformConnectionCard({ platform, connection }: { platform: Platform; connection?: PlatformConnection }) {
  const config = PLATFORMS[platform];
  const connected = connection?.connected ?? false;
  const accountCount = connection?.accounts?.length ?? 0;
  return (
    <div className={`flex items-center gap-3 px-3 py-2.5 rounded-xl ${connected ? 'bg-white border border-stone-200' : 'bg-stone-50 border border-stone-100'}`}>
      <PlatformBadge platform={platform} size="md" />
      <div className="flex-1 min-w-0">
        <Text size="sm" weight="medium">{config.name}</Text>
        <Text size="xs" className="text-stone-500">
          {connected ? `${accountCount} account${accountCount !== 1 ? 's' : ''}` : 'Not connected'}
        </Text>
      </div>
      {connected ? (
        <div className="flex items-center gap-1.5">
          <div className="w-2 h-2 rounded-full bg-emerald-500" />
          <button className="p-1.5 rounded-lg hover:bg-stone-100 text-stone-500">
            <Settings className="w-3.5 h-3.5" />
          </button>
        </div>
      ) : (
        <Button size="sm" variant="outline">
          <Link2 className="w-3.5 h-3.5" />
          Connect
        </Button>
      )}
    </div>
  );
}

const STATUS_DOT: Record<CampaignStatus, string> = {
  active: 'bg-emerald-500', paused: 'bg-amber-500', draft: 'bg-stone-400', completed: 'bg-blue-500', error: 'bg-red-500',
};
const STATUS_BADGE: Record<CampaignStatus, 'success' | 'warning' | 'default' | 'info' | 'error'> = {
  active: 'success', paused: 'warning', draft: 'default', completed: 'info', error: 'error',
};

function CampaignRow({ campaign }: { campaign: Campaign }) {
  return (
    <div className="group flex items-center gap-4 px-4 py-3 rounded-xl cursor-pointer border border-transparent hover:bg-stone-50 hover:border-stone-200">
      <div className="flex-1 min-w-0">
        <div className="flex items-center gap-2">
          <div className={`w-2 h-2 rounded-full flex-shrink-0 ${STATUS_DOT[campaign.status]}`} />
          <Text size="sm" weight="medium" className="truncate">{campaign.name}</Text>
          <Badge variant={STATUS_BADGE[campaign.status]} size="sm">{campaign.status}</Badge>
        </div>
        <div className="flex items-center gap-1.5 mt-1 ml-4">
          {campaign.platforms.map((p) => <PlatformBadge key={p} platform={p} />)}
          <Text size="xs" className="ml-1 text-stone-400">{OBJECTIVES[campaign.objective]?.label}</Text>
        </div>
      </div>
      <div className="hidden lg:flex items-center gap-6">
        <div className="text-right w-20">
          <Text size="xs" className="text-stone-400">Spend</Text>
          <Text size="sm" weight="medium">CHF {campaign.metrics.spend.toLocaleString()}</Text>
        </div>
        <div className="text-right w-20">
          <Text size="xs" className="text-stone-400">Clicks</Text>
          <Text size="sm" weight="medium">{campaign.metrics.clicks.toLocaleString()}</Text>
        </div>
        <div className="text-right w-16">
          <Text size="xs" className="text-stone-400">CTR</Text>
          <Text size="sm" weight="medium">{campaign.metrics.ctr.toFixed(2)}%</Text>
        </div>
        <div className="text-right w-16">
          <Text size="xs" className="text-stone-400">Conv.</Text>
          <Text size="sm" weight="medium">{campaign.metrics.conversions}</Text>
        </div>
        <div className="text-right w-20">
          <Text size="xs" className="text-stone-400">ROAS</Text>
          <Text size="sm" weight="medium" className={campaign.metrics.roas >= 2 ? 'text-emerald-500' : campaign.metrics.roas >= 1 ? 'text-amber-500' : 'text-red-400'}>
            {campaign.metrics.roas.toFixed(1)}x
          </Text>
        </div>
      </div>
      <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
        <button className="p-1.5 rounded-lg hover:bg-stone-200 text-stone-500" title={campaign.status === 'active' ? 'Pause' : 'Resume'}>
          {campaign.status === 'active' ? <Pause className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5" />}
        </button>
        <button className="p-1.5 rounded-lg hover:bg-stone-200 text-stone-500">
          <MoreHorizontal className="w-3.5 h-3.5" />
        </button>
      </div>
    </div>
  );
}

/** Substitute for recharts' `<AreaChart>` (header comment): the same three stacked,
 * gradient-filled areas over `chartData`, hand-built as one inline `<svg>`. */
function SpendByPlatformChart({ data }: { data: ChartPoint[] }) {
  const width = 100;
  const height = 40;
  const maxY = Math.max(1, ...data.map((d) => d.meta + d.google + d.linkedin));
  const stepX = data.length > 1 ? width / (data.length - 1) : width;

  const areaPath = (key: 'meta' | 'google' | 'linkedin', stackBelow: (d: ChartPoint) => number) => {
    const top = data.map((d, i) => {
      const y = height - ((stackBelow(d) + d[key]) / maxY) * height;
      return `${i === 0 ? 'M' : 'L'} ${i * stepX} ${y}`;
    });
    const bottom = [...data].reverse().map((d, i) => {
      const y = height - (stackBelow(d) / maxY) * height;
      const x = (data.length - 1 - i) * stepX;
      return `L ${x} ${y}`;
    });
    return `${top.join(' ')} ${bottom.join(' ')} Z`;
  };

  return (
    <div className="h-[200px]">
      <svg viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="none" className="w-full h-full" role="img" aria-label="Spend by platform, 14 days">
        <path d={areaPath('meta', () => 0)} fill={PLATFORMS.meta.color} fillOpacity={0.25} stroke={PLATFORMS.meta.color} strokeWidth={0.4} vectorEffect="non-scaling-stroke" />
        <path d={areaPath('google', (d) => d.meta)} fill={PLATFORMS.google.color} fillOpacity={0.25} stroke={PLATFORMS.google.color} strokeWidth={0.4} vectorEffect="non-scaling-stroke" />
        <path d={areaPath('linkedin', (d) => d.meta + d.google)} fill={PLATFORMS.linkedin.color} fillOpacity={0.25} stroke={PLATFORMS.linkedin.color} strokeWidth={0.4} vectorEffect="non-scaling-stroke" />
      </svg>
      <div className="flex justify-between mt-1">
        <Text size="xs" className="text-stone-400">{formatDay(data[0]?.date ?? '2026-01-01')}</Text>
        <Text size="xs" className="text-stone-400">{formatDay(data[data.length - 1]?.date ?? '2026-01-01')}</Text>
      </div>
    </div>
  );
}

/* ---- Multiplatform tab (MultiplatformSetups.tsx) ----------------------------------- */

function statusVariant(status: string): 'success' | 'warning' | 'error' | 'default' | 'info' {
  if (status === 'created') return 'success';
  if (status === 'partial') return 'warning';
  if (status === 'failed') return 'error';
  if (status === 'creating') return 'info';
  return 'default';
}

function PlatformChip({ result }: { result: SetupPlatformResult }) {
  const color = PLATFORMS[result.platform as Platform]?.color || '#78716c';
  const ok = result.status === 'created';
  const failed = result.status === 'failed';
  return (
    <span
      className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-lg text-xs border ${ok ? 'border-emerald-200 bg-emerald-50' : failed ? 'border-red-200 bg-red-50' : 'border-stone-200 bg-stone-50'}`}
      title={result.error || result.reason || result.status}
    >
      <span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
      <span className="capitalize">{result.platform}</span>
      {ok ? <CheckCircle className="w-3 h-3 text-emerald-500" /> : failed ? <XCircle className="w-3 h-3 text-red-500" /> : <AlertTriangle className="w-3 h-3 text-amber-500" />}
    </span>
  );
}

function SetupRollup({ setup, onRetried }: { setup: CampaignSetupRecord; onRetried: () => void }) {
  const [retrying, setRetrying] = useState(false);
  const canRetry = setup.status === 'partial' || setup.status === 'failed';
  const audience = setup.brief.audience || {};
  const creative = setup.brief.creative || {};
  return (
    <div className="space-y-3">
      <div className="p-3 rounded-xl grid grid-cols-2 md:grid-cols-4 gap-3 bg-stone-50">
        <div>
          <Text size="xs" className="text-stone-400">Objective</Text>
          <Text size="sm" weight="medium" className="capitalize">{setup.objective}</Text>
        </div>
        <div>
          <Text size="xs" className="text-stone-400">Daily Budget</Text>
          <Text size="sm" weight="medium">{setup.currency} {setup.total_daily_budget.toLocaleString()}</Text>
        </div>
        <div>
          <Text size="xs" className="text-stone-400">Countries</Text>
          <Text size="sm" weight="medium">{(audience.geo || []).join(', ') || '—'}</Text>
        </div>
        <div>
          <Text size="xs" className="text-stone-400">Landing URL</Text>
          <Text size="sm" weight="medium" className="truncate">{creative.landing_url || '—'}</Text>
        </div>
      </div>
      <div className="space-y-2">
        {setup.platform_results.map((r) => (
          <div key={r.platform} className="p-3 rounded-xl border border-stone-200 bg-white">
            <div className="flex items-center gap-2">
              <span className="w-5 h-5 rounded-md flex items-center justify-center text-[10px] font-bold text-white flex-shrink-0" style={{ backgroundColor: PLATFORMS[r.platform as Platform]?.color || '#78716c' }}>
                {r.platform === 'linkedin' ? 'in' : r.platform.charAt(0).toUpperCase()}
              </span>
              <Text size="sm" weight="medium" className="capitalize flex-1">{r.platform}</Text>
              <Badge variant={r.status === 'created' ? 'success' : r.status === 'failed' ? 'error' : 'warning'} size="sm">{r.status}</Badge>
            </div>
            {r.campaign_id && <Text size="xs" className="mt-1.5 block font-mono text-stone-500">Campaign ID: {r.campaign_id}</Text>}
            {(r.error || r.reason) && <Text size="xs" className="mt-1.5 block text-red-500">{r.error || r.reason}</Text>}
            {(r.warnings || []).map((w, i) => (
              <div key={i} className="mt-1.5 flex items-start gap-1.5">
                <AlertTriangle className="w-3 h-3 text-amber-500 flex-shrink-0 mt-0.5" />
                <Text size="xs" className="text-amber-700">{w}</Text>
              </div>
            ))}
          </div>
        ))}
      </div>
      {canRetry && (
        <div className="flex justify-end">
          <Button variant="outline" size="sm" loading={retrying} onClick={() => { setRetrying(true); setTimeout(() => { setRetrying(false); onRetried(); }, 400); }}>
            <RefreshCw className="w-3.5 h-3.5" />
            Retry failed platforms
          </Button>
        </div>
      )}
    </div>
  );
}

function MultiplatformTab({ setups, initialExpandedSetupId }: { setups: CampaignSetupRecord[]; initialExpandedSetupId?: string | null }) {
  const [expanded, setExpanded] = useState<string | null>(initialExpandedSetupId ?? null);
  return (
    <Card padding="sm" className="bg-white border-stone-200">
      <div className="flex items-center justify-between mb-4">
        <div className="flex items-center gap-2">
          <Layers className="w-4 h-4 text-[#6844C4]" />
          <Text size="sm" weight="medium">Multiplatform Campaigns</Text>
          <Text size="xs" className="text-stone-400">{setups.length} setup{setups.length !== 1 ? 's' : ''}</Text>
        </div>
        <Button variant="ghost" size="sm"><RefreshCw className="w-3.5 h-3.5" /></Button>
      </div>
      {setups.length === 0 ? (
        <div className="py-12 text-center">
          <Layers className="w-8 h-8 mx-auto mb-3 text-stone-300" />
          <Text size="sm" className="text-stone-400">No multiplatform setups yet. Use "New Campaign" to create one brief that runs everywhere.</Text>
        </div>
      ) : (
        <div className="space-y-1">
          {setups.map((setup) => {
            const isOpen = expanded === setup.id;
            return (
              <div key={setup.id}>
                <div
                  className={`group flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer border border-transparent hover:bg-stone-50 hover:border-stone-200 ${isOpen ? 'bg-stone-50' : ''}`}
                  onClick={() => setExpanded(isOpen ? null : setup.id)}
                >
                  {isOpen ? <ChevronDown className="w-4 h-4 flex-shrink-0 text-stone-500" /> : <ChevronRight className="w-4 h-4 flex-shrink-0 text-stone-500" />}
                  <div className="flex-1 min-w-0">
                    <div className="flex items-center gap-2">
                      <Text size="sm" weight="medium" className="truncate">{setup.name}</Text>
                      <Badge variant={statusVariant(setup.status)} size="sm">{setup.status}</Badge>
                    </div>
                    <div className="flex items-center gap-1.5 mt-1 flex-wrap">
                      {setup.platform_results.map((r) => <PlatformChip key={r.platform} result={r} />)}
                    </div>
                  </div>
                  <div className="hidden md:block text-right">
                    <Text size="xs" className="text-stone-400">Budget</Text>
                    <Text size="sm" weight="medium">{setup.currency} {setup.total_daily_budget.toLocaleString()}/day</Text>
                  </div>
                  <div className="hidden md:block text-right w-24">
                    <Text size="xs" className="text-stone-400">Created</Text>
                    <Text size="sm" weight="medium">{setup.created_at ? formatDay(setup.created_at) : '—'}</Text>
                  </div>
                </div>
                {isOpen && (
                  <div className="ml-11 mr-4 mb-3 mt-1">
                    <SetupRollup setup={setup} onRetried={() => {}} />
                  </div>
                )}
              </div>
            );
          })}
        </div>
      )}
    </Card>
  );
}

/* ---- Insights tab (InsightsPanel.tsx) ---------------------------------------------- */

const selectClass = 'px-3 py-2 rounded-xl text-sm border transition-colors bg-white border-stone-200 text-zinc-900';

function InsightsTab({ metaAccounts, googleAccounts, metaRows, googleRows }: { metaAccounts: AccountOption[]; googleAccounts: AccountOption[]; metaRows: FlatRow[]; googleRows: FlatRow[] }) {
  const metaConnected = metaAccounts.length > 0;
  const googleConnected = googleAccounts.length > 0;
  const [platform, setPlatform] = useState<'meta' | 'google'>(metaConnected || !googleConnected ? 'meta' : 'google');
  const [level, setLevel] = useState<string>('campaign');
  const [preset, setPreset] = useState<string>(META_DATE_PRESETS[5]);
  const [breakdown, setBreakdown] = useState('');
  const [googleLevel, setGoogleLevel] = useState<(typeof GOOGLE_LEVELS)[number]>('campaign');
  const [googleRange, setGoogleRange] = useState<(typeof GOOGLE_DATE_RANGES)[number]>('LAST_30_DAYS');

  const accounts = platform === 'meta' ? metaAccounts : googleAccounts;
  const rows = platform === 'meta' ? metaRows : googleRows;
  const sorted = [...rows].sort((a, b) => b.spend - a.spend);
  const totals = rows.reduce((acc, r) => ({ spend: acc.spend + r.spend, impressions: acc.impressions + r.impressions, clicks: acc.clicks + r.clicks, conversions: acc.conversions + r.conversions }), { spend: 0, impressions: 0, clicks: 0, conversions: 0 });

  if (!metaConnected && !googleConnected) {
    return (
      <Card padding="sm" className="bg-white border-stone-200">
        <div className="py-12 text-center">
          <BarChart3 className="w-8 h-8 mx-auto mb-3 text-stone-300" />
          <Text size="sm" className="text-stone-400">Connect Meta or Google above to run insight reports.</Text>
        </div>
      </Card>
    );
  }

  return (
    <Card padding="sm" className="bg-white border-stone-200">
      <div className="flex items-center justify-between mb-4">
        <div className="flex items-center gap-2">
          <BarChart3 className="w-4 h-4 text-[#6844C4]" />
          <Text size="sm" weight="medium">Insights</Text>
        </div>
        <Button variant="ghost" size="sm"><RefreshCw className="w-3.5 h-3.5" /></Button>
      </div>

      <div className="flex flex-wrap items-center gap-2 mb-4">
        <div className="flex rounded-xl border overflow-hidden border-stone-200">
          {(['meta', 'google'] as const).map((p) => {
            const disabled = p === 'meta' ? !metaConnected : !googleConnected;
            return (
              <button
                key={p}
                disabled={disabled}
                onClick={() => setPlatform(p)}
                className={`px-3 py-2 text-sm font-medium transition-colors ${platform === p ? 'bg-[#6844C4] text-white' : disabled ? 'text-stone-300' : 'text-stone-500 hover:bg-stone-50'}`}
              >
                {p === 'meta' ? 'Meta' : 'Google'}
              </button>
            );
          })}
        </div>
        <select value={accounts[0]?.id ?? ''} onChange={() => {}} className={selectClass}>
          {accounts.length === 0 && <option value="">No accounts</option>}
          {accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
        </select>
        {platform === 'meta' ? (
          <>
            <select value={level} onChange={(e) => setLevel(e.target.value)} className={selectClass}>
              <option value="campaign">By campaign</option>
              <option value="adset">By ad set</option>
              <option value="account">Account total</option>
            </select>
            <select value={preset} onChange={(e) => setPreset(e.target.value)} className={selectClass}>
              {META_DATE_PRESETS.map((p) => <option key={p} value={p}>{p.replace(/_/g, ' ')}</option>)}
            </select>
            <select value={breakdown} onChange={(e) => setBreakdown(e.target.value)} className={selectClass}>
              <option value="">No breakdown</option>
              {META_BREAKDOWNS.map((b) => <option key={b} value={b}>{b.replace(/_/g, ' ')}</option>)}
            </select>
          </>
        ) : (
          <>
            <select value={googleLevel} onChange={(e) => setGoogleLevel(e.target.value as any)} className={selectClass}>
              {GOOGLE_LEVELS.map((l) => <option key={l} value={l}>By {l.replace(/_/g, ' ')}</option>)}
            </select>
            <select value={googleRange} onChange={(e) => setGoogleRange(e.target.value as any)} className={selectClass}>
              {GOOGLE_DATE_RANGES.map((r) => <option key={r} value={r}>{r.replace(/_/g, ' ').toLowerCase()}</option>)}
            </select>
          </>
        )}
        <Button variant="primary" size="sm" disabled={!accounts[0]}>Run</Button>
      </div>

      {rows.length > 0 && (
        <div className="mb-4 p-3 rounded-xl grid grid-cols-2 md:grid-cols-4 gap-3 bg-stone-50">
          <div><Text size="xs" className="text-stone-400">Spend</Text><Text size="sm" weight="medium">CHF {totals.spend.toLocaleString('de-CH', { maximumFractionDigits: 2 })}</Text></div>
          <div><Text size="xs" className="text-stone-400">Impressions</Text><Text size="sm" weight="medium">{totals.impressions.toLocaleString()}</Text></div>
          <div><Text size="xs" className="text-stone-400">Clicks</Text><Text size="sm" weight="medium">{totals.clicks.toLocaleString()}</Text></div>
          <div><Text size="xs" className="text-stone-400">Conversions</Text><Text size="sm" weight="medium">{totals.conversions.toLocaleString('de-CH', { maximumFractionDigits: 1 })}</Text></div>
        </div>
      )}

      {sorted.length > 0 ? (
        <div className="overflow-x-auto">
          <table className="w-full text-sm">
            <thead>
              <tr className="text-stone-400">
                <th className="text-left font-medium px-3 py-2 text-xs">Name</th>
                <th className="text-right font-medium px-3 py-2 text-xs">Spend</th>
                <th className="text-right font-medium px-3 py-2 text-xs">Impr.</th>
                <th className="text-right font-medium px-3 py-2 text-xs">Clicks</th>
                <th className="text-right font-medium px-3 py-2 text-xs">CTR</th>
                <th className="text-right font-medium px-3 py-2 text-xs">CPC</th>
                <th className="text-right font-medium px-3 py-2 text-xs">Conv.</th>
              </tr>
            </thead>
            <tbody>
              {sorted.map((r, i) => (
                <tr key={i} className="border-t border-stone-100 hover:bg-stone-50">
                  <td className="px-3 py-2 max-w-[280px] truncate">{r.name}</td>
                  <td className="px-3 py-2 text-right whitespace-nowrap">CHF {r.spend.toLocaleString('de-CH', { maximumFractionDigits: 2 })}</td>
                  <td className="px-3 py-2 text-right">{r.impressions.toLocaleString()}</td>
                  <td className="px-3 py-2 text-right">{r.clicks.toLocaleString()}</td>
                  <td className="px-3 py-2 text-right">{r.ctr.toFixed(2)}%</td>
                  <td className="px-3 py-2 text-right">{r.cpc.toFixed(2)}</td>
                  <td className="px-3 py-2 text-right">{r.conversions.toLocaleString('de-CH', { maximumFractionDigits: 1 })}</td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      ) : (
        <div className="py-12 text-center"><Text size="sm" className="text-stone-400">No data for this selection.</Text></div>
      )}
    </Card>
  );
}

/* ---- the page (AdsManager.tsx 1168 to 1760) ---------------------------------------- */

function AdsHub({
  loading, activeTab, connections, campaigns, chartData, adGroups, toast,
  initialSearchQuery = '', initialStatusFilter = 'all', initialPlatformFilter = 'all', initialShowInactive = false, initialActiveGroup = 'all',
  setups, initialExpandedSetupId, metaAccounts, googleAccounts, metaRows, googleRows,
}: Omit<Props, 'viewer' | 'workspace'>) {
  const [searchQuery, setSearchQuery] = useState(initialSearchQuery);
  const [statusFilter, setStatusFilter] = useState(initialStatusFilter);
  const [platformFilter, setPlatformFilter] = useState(initialPlatformFilter);
  const [showInactive, setShowInactive] = useState(initialShowInactive);
  const [activeGroup, setActiveGroup] = useState(initialActiveGroup);

  if (loading) {
    return (
      <div className="flex items-center justify-center min-h-[60vh]">
        <Spinner size="lg" />
      </div>
    );
  }

  const totalSpend = campaigns.reduce((sum, c) => sum + c.metrics.spend, 0);
  const totalConversions = campaigns.reduce((sum, c) => sum + c.metrics.conversions, 0);
  const avgCPA = totalConversions > 0 ? totalSpend / totalConversions : 0;
  const roasCampaigns = campaigns.filter((c) => c.metrics.roas > 0);
  const avgROAS = roasCampaigns.reduce((sum, c) => sum + c.metrics.roas, 0) / Math.max(1, roasCampaigns.length);

  const filtered = campaigns.filter((c) => {
    if (searchQuery && !c.name.toLowerCase().includes(searchQuery.toLowerCase())) return false;
    if (statusFilter !== 'all' && c.status !== statusFilter) return false;
    if (platformFilter !== 'all' && !c.platforms.includes(platformFilter)) return false;
    if (activeGroup !== 'all') {
      const group = adGroups.find((g) => g.name === activeGroup);
      if (group) {
        if (!c.account_id) return true;
        const groupAccountIds = [group.meta_account_id, group.google_account_id, group.linkedin_account_id, group.microsoft_account_id].filter(Boolean);
        if (!groupAccountIds.includes(c.account_id)) return false;
      }
    }
    return true;
  });

  return (
    <div className="p-4 md:p-6 max-w-[1400px] mx-auto space-y-6">
      {/* Header */}
      <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
        <div>
          <Heading level={2} className="flex items-center gap-2">
            <Megaphone className="w-6 h-6 text-[#6844C4]" />
            Ads
          </Heading>
          <Text size="sm" className="text-stone-500">Create once, run everywhere. Manage campaigns across all platforms.</Text>
        </div>
        <div className="flex items-center gap-2">
          <Button variant="outline" size="sm"><Target className="w-3.5 h-3.5" />Retargeting</Button>
          <Button variant="outline" size="sm"><Settings className="w-3.5 h-3.5" />Clients</Button>
          <Button variant="outline" size="sm"><RefreshCw className="w-3.5 h-3.5" />Sync</Button>
          <Button variant="primary"><Plus className="w-4 h-4" />New Campaign</Button>
        </div>
      </div>

      {/* Client tabs */}
      {adGroups.length > 0 && (
        <div className="flex items-center gap-1 overflow-x-auto pb-1">
          <button
            onClick={() => setActiveGroup('all')}
            className={`px-4 py-2 rounded-2xl text-sm font-medium whitespace-nowrap transition-colors ${activeGroup === 'all' ? 'bg-[#6844C4] text-white' : 'bg-stone-100 text-stone-600 hover:bg-stone-200'}`}
          >
            All Clients
          </button>
          {adGroups.map((group) => (
            <button
              key={group.name}
              onClick={() => setActiveGroup(group.name)}
              className={`px-4 py-2 rounded-2xl text-sm font-medium whitespace-nowrap transition-colors ${activeGroup === group.name ? 'bg-[#6844C4] text-white' : 'bg-stone-100 text-stone-600 hover:bg-stone-200'}`}
            >
              {group.name}
            </button>
          ))}
        </div>
      )}

      {/* Platform connections */}
      <Card padding="sm" className="bg-white border-stone-200">
        <div className="flex items-center justify-between mb-3">
          <Text size="sm" weight="medium">Platforms</Text>
          <Text size="xs" className="text-stone-400">{connections.filter((c) => c.connected).length} of {connections.length} connected</Text>
        </div>
        <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
          {(Object.keys(PLATFORMS) as Platform[]).map((p) => (
            <PlatformConnectionCard key={p} platform={p} connection={connections.find((c) => c.platform === p)} />
          ))}
        </div>
      </Card>

      {/* View tabs */}
      <div className="flex items-center gap-1 border-b border-stone-200">
        {([{ id: 'campaigns', label: 'Campaigns', icon: Megaphone }, { id: 'multiplatform', label: 'Multiplatform', icon: Layers }, { id: 'insights', label: 'Insights', icon: BarChart3 }] as const).map((tab) => (
          <div
            key={tab.id}
            className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px ${activeTab === tab.id ? 'border-[#6844C4] text-[#6844C4]' : 'border-transparent text-stone-500'}`}
          >
            <tab.icon className="w-3.5 h-3.5" />
            {tab.label}
          </div>
        ))}
      </div>

      {activeTab === 'campaigns' && (
        <>
          <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
            <MetricCard label="Total Spend" value={totalSpend.toLocaleString()} prefix="CHF" icon={DollarSign} />
            <MetricCard label="Conversions" value={totalConversions} icon={Target} />
            <MetricCard label="Avg. CPA" value={avgCPA.toFixed(2)} prefix="CHF" icon={TrendingUp} />
            <MetricCard label="Avg. ROAS" value={avgROAS.toFixed(1)} suffix="x" icon={Zap} />
          </div>

          <Card padding="sm" className="bg-white border-stone-200">
            <div className="flex items-center justify-between mb-4">
              <Text size="sm" weight="medium">Spend by Platform (14 days)</Text>
              <div className="flex items-center gap-3">
                {(['meta', 'google', 'linkedin'] as Platform[]).map((p) => (
                  <div key={p} className="flex items-center gap-1.5">
                    <div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: PLATFORMS[p].color }} />
                    <Text size="xs" className="text-stone-500">{PLATFORMS[p].name}</Text>
                  </div>
                ))}
              </div>
            </div>
            <SpendByPlatformChart data={chartData} />
          </Card>

          <Card padding="sm" className="bg-white border-stone-200">
            <div className="flex flex-col md:flex-row md:items-center gap-3 mb-4">
              <div className="flex-1 relative">
                <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-stone-400" />
                <Input value={searchQuery} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)} placeholder="Search campaigns..." className="pl-10" />
              </div>
              <div className="flex items-center gap-2">
                <select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value as any)} className={selectClass}>
                  <option value="all">All Status</option>
                  <option value="active">Active</option>
                  <option value="paused">Paused</option>
                  <option value="draft">Draft</option>
                  <option value="completed">Completed</option>
                </select>
                <select value={platformFilter} onChange={(e) => setPlatformFilter(e.target.value as any)} className={selectClass}>
                  <option value="all">All Platforms</option>
                  {Object.entries(PLATFORMS).map(([key, config]) => <option key={key} value={key}>{config.name}</option>)}
                </select>
                <button
                  onClick={() => setShowInactive(!showInactive)}
                  className={`px-3 py-2 rounded-xl text-sm border transition-colors whitespace-nowrap ${showInactive ? 'bg-[#6844C4]/10 border-[#6844C4]/20 text-[#6844C4]' : 'bg-white border-stone-200 text-stone-500'}`}
                >
                  {showInactive ? 'Hide Older' : 'Show Older'}
                </button>
              </div>
            </div>

            <div className="hidden lg:flex items-center gap-4 px-4 py-2 mb-1 text-stone-400">
              <Text size="xs" weight="medium" className="flex-1">Campaign</Text>
              <Text size="xs" weight="medium" className="text-right w-20">Spend</Text>
              <Text size="xs" weight="medium" className="text-right w-20">Clicks</Text>
              <Text size="xs" weight="medium" className="text-right w-16">CTR</Text>
              <Text size="xs" weight="medium" className="text-right w-16">Conv.</Text>
              <Text size="xs" weight="medium" className="text-right w-20">ROAS</Text>
              <div className="w-16" />
            </div>

            <div className="space-y-1">
              {filtered.length === 0 ? (
                <div className="py-12 text-center">
                  <Megaphone className="w-8 h-8 mx-auto mb-3 text-stone-300" />
                  <Text size="sm" className="text-stone-400">
                    {searchQuery || statusFilter !== 'all' || platformFilter !== 'all' ? 'No campaigns match your filters.' : 'No campaigns yet. Create your first one!'}
                  </Text>
                </div>
              ) : (
                filtered.map((campaign) => <CampaignRow key={campaign.id} campaign={campaign} />)
              )}
            </div>
          </Card>
        </>
      )}

      {activeTab === 'multiplatform' && <MultiplatformTab setups={setups} initialExpandedSetupId={initialExpandedSetupId} />}

      {activeTab === 'insights' && (
        <InsightsTab metaAccounts={metaAccounts} googleAccounts={googleAccounts} metaRows={metaRows} googleRows={googleRows} />
      )}

      {toast && (
        <div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[60] max-w-xl w-[calc(100%-2rem)]">
          <div className={`px-4 py-3 rounded-xl shadow-2xl border flex items-start gap-2 bg-white ${toast.kind === 'success' ? 'bg-emerald-50 border-emerald-200' : toast.kind === 'warning' ? 'bg-amber-50 border-amber-200' : 'bg-red-50 border-red-200'}`}>
            {toast.kind === 'success' ? <CheckCircle className="w-4 h-4 text-emerald-500 flex-shrink-0 mt-0.5" /> : toast.kind === 'warning' ? <AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" /> : <XCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />}
            <Text size="xs" className="flex-1">{toast.text}</Text>
            <span className="opacity-50 text-xs">✕</span>
          </div>
        </div>
      )}
    </div>
  );
}

export default function Demo({ viewer: v, workspace: ws, galleryMode, ...page }: Props & { galleryMode?: boolean }) {
  return (
    <Shell domains={DOMAINS} user={v} workspaces={[ws]} currentWorkspaceId={ws.id} activePath="/ads" hasTour={false} hideChrome={galleryMode}>
      <AdsHub {...page} />
    </Shell>
  );
}
Component tree, from the recipe
<Route /ads> PrivateRoute (children)
  Navigation = AppShell
    AdsManager
      [loading]         div.min-h-[60vh] > Spinner lg                              (before any fetch resolves, line 1401 to 1409)
      Header            Heading "Ads" + subtitle, Retargeting/Clients/Sync/New Campaign buttons (line 1415 to 1443)
      [adGroups.length]  client tabs: "All Clients" + one pill per group.name       (line 1446 to 1472)
      Platforms card    Card > 4x PlatformConnectionCard (connected: dot + Settings gear | disconnected: Connect button) (1474 to 1494)
      View tabs         Campaigns | Multiplatform | Insights                        (1497 to 1518)
      [tab=campaigns]
        4x MetricCard   Total Spend, Conversions, Avg. CPA, Avg. ROAS               (1523 to 1537, `change` never passed, Gotchas)
        Spend chart     Card > recharts AreaChart, 3 platform gradients, 14 days    (1540 to 1586)
        Campaigns card  search Input + status/platform selects + Show Older toggle  (1591 to 1642)
                        list header (desktop only)                                  (1645 to 1653)
                        [filtered.length===0] empty message (two variants)          (1657 to 1665)
                        [else] filtered.map CampaignRow (status dot, Badge, platform badges, metrics cols, hover Pause/Resume + more) (1667 to 1682)
      [tab=multiplatform] MultiplatformSetups (own component; list of CampaignSetupRecord, expandable SetupRollup, PlatformChip row, retry button) (1688; MultiplatformSetups.tsx)
      [tab=insights]    InsightsPanel (own component; Meta/Google toggle, level/date-range/breakdown selects, totals strip, results table) (1690 to 1702; InsightsPanel.tsx)
      [toast]           fixed bottom banner, success | warning | error              (1705 to 1728)
      [showCreate]      CreateCampaignModal (4-step wizard: name/objective/budget -> platforms+auto-split -> audience/creative -> dry-run review) (1730 to 1738, 441 to 931; not in the packages a demo may import from, buttons inert instead)
      [showGroupSettings] AdGroupsModal (per-client ad-account grouping)             (1740 to 1757, 986 to 1165; same, not rendered)
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 ads-manager/demo.tsx, built from @prima-modules/alchemy-ui and @prima-modules/tokens and rendered by the docs host as one frame per state. The page's four reads and its toast are props (connections, campaigns, chartData, adGroups, toast); the tree is the frontmatter's. The hub is rendered inside the shell rebuild (ui-design-system/ui/appshell-navigation/demo.tsx, imported relatively the way subscription-manager's demo imports it), on /ads (nav.tsx's "campaigns" domain, paths: ['/ads']).

States (rendering.states, in order): default (two platforms connected, six campaigns across Meta/Google with one dual-platform row, the spend chart, all filters at rest); loading (the guard at line 1401, shell painted, one spinner, nothing else - matches the pre-fetch shape of every other prop too); empty (connected, campaigns exist, but initialSearchQuery matches none of them, so filtered.length === 0 renders "No campaigns match your filters.", the filtered branch of the line-1661 ternary, not the fresh-workspace branch); disconnected (all four platforms "Not connected", zero campaigns, zero clients - the real shape of fetchData's pre-fetch state variables and of a genuinely brand-new workspace's first successful fetch, both land here, "No campaigns yet. Create your first one!"); error (the bottom toast in its kind: 'error' branch, built the way handleCreateCampaign's partial/failed branches build it, line 1216 to 1227 - the page's page-level error state is never rendered anywhere in the source, so the toast, which is rendered, is this recipe's error surface, not an invented one, Gotchas); multiplatform-tab (three setups, the partial one expanded to show its per-platform rollup and the "Retry failed platforms" button canRetry gates on); insights-tab (Meta selected by default, campaign-level rows for both connected ad accounts, the totals strip and results table InsightsPanel renders once rows.length > 0).

Tokens and classes as the source uses them: Card with bg-white border-stone-200 for every top-level section (the source's isDark branch also carries a bg-white/[0.03] border-white/10 pair, dropped below); the brand purple #6844C4 for every active-tab underline, primary button and metric-card icon chip, not an S1 token; per-platform brand colors on PlatformBadge and the chart (#1877F2 Meta, #4285F4 Google, #0A66C2 LinkedIn, #00A4EF Microsoft); Badge variants success/warning/default/info/error keyed one-to-one off CampaignStatus. dark:-equivalent isDark ternaries exist on nearly every className in the source and are dropped in the demo, the same "dark: dead" trap subscription-manager's Gotchas names, since @prima-modules/alchemy-ui is a light-locked theme shim and every capture in this kit's verify harness runs colorScheme: light.

Responsive: the page body is max-w-[1400px]; the platform-connections grid is grid-cols-2 md:grid-cols-4; a CampaignRow's metrics columns (hidden lg:flex) disappear below lg, leaving only the name, status and platform badges; the filter row stacks the search box above the selects below md. Empty state: empty and disconnected above cover the two real branches (filtered-empty and no-campaigns-at-all); there is no separate loading skeleton for the table itself, only the page-level spinner. Error state: the toast, as above; neither tab renders a distinct page-level error card of its own in the source (Multiplatform and Insights each have their own internal error card for their own fetch, not demoed as a separate state here since both would be a nearly identical red Card already shown in principle by the top-level error state's toast).

What the demo deliberately does not render: CreateCampaignModal and AdGroupsModal (both page-local, not in the packages a demo may import from; their trigger buttons render with real label and icon and do nothing, same convention as subscription-manager's change-tier buttons); the OAuth popup window.open behind each "Connect" button; useNavigate()'s three destinations (/ads/retargeting, a campaign's detail route, none of which exist inside a demo frame); recharts' <AreaChart>, replaced by a hand-built inline-SVG stacked area chart over the same chartData shape (demo header comment); MultiplatformSetups.tsx's and InsightsPanel.tsx's own live network calls, replaced by the same props-in convention as the rest of this recipe. All are documented in the demo's own header comment.

Security & tenancy

What the browser may hold: S2's two tokens only, never a platform access token - every Meta/Google/LinkedIn credential this page's data ultimately comes from lives server-side in the workspace's tracking settings row (_get_tracking_settings, ads_router.py 232), never serialized to the client. import.meta.env.VITE_API_URL is the only baked value. Origin and CSRF: the api singleton's Bearer header, S2's own. Surface gating: ads_router and every router it composes with are mounted under LABS_SURFACES (app_fastapi.py 863, 867), the same gate subscription-manager's three routes sit behind.

Tenancy here is workspace-scoped throughout, unlike subscription-manager's per-user routes: every one of the eight calls above derives workspace_id from _require_workspace(request), a header the API client's interceptor attaches from the shell's current workspace, not from anything in the request body or URL - switching workspaces in the shell rail does change what this page shows, on the next fetch. Hidden by CSS versus enforced: nothing meaningful; every action button (New Campaign, Sync, Connect, a row's Pause/Resume) is visible to any signed-in member of the workspace regardless of role, and the server is the only thing that actually enforces a connected platform before a toggle or a setup call can do anything. Attacker-controlled input the page sends: the campaign brief (name, budget, audience, creative, landing URL) built entirely from CreateCampaignModal's form state, validated and normalized server-side by cross-platform-campaign-creation's per-platform adapters before anything reaches a real ad account, not by this page.

Tests

  • Colocated with the demo: ads-manager/demo.test.tsx (vitest, jsdom, @testing-library/react), afterEach(cleanup) per test-layout: default renders six CampaignRows and the correct avgROAS/avgCPA text; empty renders the filtered-empty sentence and not the fresh-workspace one; disconnected renders four "Not connected" cards and the fresh-workspace sentence; error renders exactly one toast with the red styling and no page-level error card; multiplatform-tab renders the setup-2 rollup expanded with a visible "Retry failed platforms" button and default/loading/disconnected/empty/insights-tab do not; insights-tab renders the totals strip only when metaRows.length > 0.
  • The kit's render smoke: node tooling/verify/render-smoke.mjs ads/ui/ads-manager renders all seven states with renderToString and fetch stubbed (Acceptance checks).
  • Labs at 62ba4fc has no unit test of AdsManager.tsx, MultiplatformSetups.tsx or InsightsPanel.tsx (grep -rl "AdsManager\|MultiplatformSetups\|InsightsPanel" frontend/src --include='*.test.*' is empty) and no Playwright spec for /ads. Backend coverage of the 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 page-level error state, once actually rendered somewhere, shows the server's real message; a test that the group filter change (step 4's gap) either excludes or clearly labels unassigned campaigns; a Playwright spec that connects a sandbox Meta account, runs the wizard's dry-run preview, submits for real, and asserts the new setup appears in the Multiplatform tab without a manual refresh.

Deploy & ops

Build target: the Labs Vite bundle (S2), a lazy chunk (App.tsx line 75, lazy(() => import('./pages/AdsManager'))). Env baked at build: VITE_API_URL, S2's own; no ads-specific PUBLIC_*/VITE_* variable is read by this page (platform credentials are entirely server-side, Security & tenancy). CSP and CORS: nothing of its own beyond the SPA's existing allowance; the OAuth popups navigate to each platform's own authorize URL, opened by window.open, not an iframe. Deploy path: S13's frontend rsync with the bundle; rollback is the bundle's. Nothing to migrate here directly; the schema the eight calls above read and write belongs to the five backend_modules recipes and to campaign_setup_router.py's own migration. Argus: a synthetic workspace session calling GET /api/ads/status returning 200 is this page's liveness; the page has no health signal of its own beyond an empty connections/campaigns render.

Gotchas

  • 2026-09-12 (measured at 62ba4fc): the page-level error state is set in fetchData's and handleConnect's catch blocks (1324, 1384) and read nowhere in the JSX - grep -n "{error}" frontend/src/pages/AdsManager.tsx matches nothing. A read failure the four .catch(() => null)s don't already absorb, or a failed OAuth connect, sets state the operator never sees. This recipe's error demo state uses the toast instead, the one error surface the page actually renders (Frontend).
  • 2026-09-12 (measured at 62ba4fc): MetricCard supports a change prop (the +/-% row with ArrowUpRight/ArrowDownRight) and no call site in the page ever passes one (line 1533 to 1536) - the same shape of dead prop subscription-manager's Gotchas documents for its own SubscriptionModal call sites, reproduced by omission here too.
  • 2026-09-12 (measured at 62ba4fc): chartData's three platform series are hardcoded to 0 for all 14 days regardless of the campaigns just fetched (line 1311 to 1321, the comment above the block explains this was a deliberate fix for a worse bug: the card used to show invented deltas next to a real spend of CHF 0). The spend chart currently always renders three flat lines at zero. A rebuild needs an actual per-day, per-platform spend series from the backend; none of the four reads this page makes currently returns one.
  • 2026-09-12 (measured at 62ba4fc): campaign.platform_metrics is initialized to four zeroed CampaignMetrics objects for every campaign and never populated from anything (mapCampaign, line 969) - a per-platform metrics breakdown the Campaign type supports but the page has no data source for yet.
  • 2026-09-12 (measured at 62ba4fc): Toggle and Divider are imported from alchemy-ui (line 61 to 62) and never rendered anywhere in the file - a dead import pair, harmless, left as measured.
  • 2026-09-12 (measured at 62ba4fc): handleToggleCampaign posts {platform: campaign.platforms[0]} with no action field, while the backend route's ToggleRequest schema and every other caller of it expects an explicit action: 'pause'|'resume'|'delete' (ads_router.py 1324 to 1340); the currently-deployed handler appears to infer the action from the campaign's stored status server-side, but nothing in this file's type (ToggleRequest is never imported or checked client-side) guards against a backend change that starts requiring the field, which would make every Pause/Resume click a 422 this page swallows silently (catch { /* Silently fail for now */ }, line 1396 to 1398).
  • 2026-09-12 (measured at 62ba4fc): handleConnect's connectMap has no microsoft entry (line 1363 to 1367); the fourth PlatformConnectionCard's "Connect" button is rendered, clickable, and does nothing (endpoint is undefined, the function returns early). Microsoft is fully wired into every type and every card in the file but has no connect flow anywhere in Labs at this commit.
  • 2026-09-12 (measured at 62ba4fc): connecting a platform opens a popup and never listens for it closing (handleConnect, 1371 to 1382); the connections list only reflects a fresh OAuth grant after a manual "Sync" click or a remount, a real gap an operator can easily read as "the connect flow is broken" when it in fact needs one more click.
  • 2026-09-12 (measured at 62ba4fc): GET /api/ads/status does not carry ad accounts (StatusResponse/PlatformStatus, ads_router.py 1051 to 1080) despite the type suggesting it might; the comment directly above fetchData's destructuring (1246 to 1249) documents a real regression this caused ("a correctly-connected platform render[ed] as '0 accounts'") before accounts were sourced from GET /api/ads/accounts instead - left here as the fix already applied, not a live bug, but a trap for a future edit that reads accounts off status again.

Reference implementation

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

  • frontend/src/pages/AdsManager.tsx (1760 lines): imports and types (1 to 191), PlatformBadge (197 to 210), MetricCard (216 to 265), PlatformConnectionCard (271 to 323), CampaignRow (329 to 435), CreateCampaignModal (441 to 931), mapCampaign (938 to 972), AdGroupsModal (986 to 1165), the page component's state (1168 to 1193), handleCreateCampaign (1203 to 1237), fetchData (1240 to 1328), aggregated metrics and filtered (1335 to 1357), handleConnect (1359 to 1386), handleToggleCampaign (1388 to 1399), the loading guard (1401 to 1409), header through the view tabs (1411 to 1518), the campaigns tab (1520 to 1686), the multiplatform and insights tab mounts (1688 to 1702), the toast (1705 to 1728), the two modal mounts (1730 to 1757).
  • frontend/src/components/ads/MultiplatformSetups.tsx (317 lines): PlatformChip (33 to 63), SetupRollup (65 to 202), the list component (204 to 317).
  • frontend/src/components/ads/InsightsPanel.tsx (383 lines): row flatteners (46 to 100), the panel (110 to 382).
  • frontend/src/components/Navigation.tsx (24 lines): the delegator to AppShell.
  • frontend/src/services/adsService.ts: SetupPlatformResult/CampaignSetupRecord (43 to 68), listCampaignSetups/getCampaignSetup/retryCampaignSetup (82 to 100), META_DATE_PRESETS/META_BREAKDOWNS (104 to 127), GoogleReportLevel/GOOGLE_DATE_RANGES (246 to 264), getMetaInsights/runGoogleReport (147, 289).
  • backend/api/routers/ads_router.py: _require_workspace (247 to 258), /status (1051 to 1080), /accounts (1084), /campaigns (1151), /campaigns/{id}/toggle (1324 to 1390), /groups (1909 to 1968).
  • backend/api/routers/campaign_setup_router.py and the rest of cross-platform-campaign-creation's reference implementation: the "New Campaign" wizard's actual write path.
  • backend/app_fastapi.py: 863, 867 (the two ads routers' LABS_SURFACES mounts).
  • frontend/src/App.tsx: the lazy import (75), the route (485 to 490).
  • The demo: modules/ads/ui/ads-manager/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 unread page-level error state; the never-passed change prop; the always-zero chart series; the never-populated platform_metrics; the dead Toggle/Divider imports; the toggle request missing an explicit action; Microsoft's missing connect endpoint; the OAuth popup's missing completion listener; the status-route accounts regression already fixed once.

Acceptance checks

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

  1. grep -n "{error}" src/pages/AdsManager.tsx prints nothing at 62ba4fc (the unread state, Gotchas) and at least one line after a fix that renders it.
  2. grep -n "api\.\(get\|post\|put\)(" src/pages/AdsManager.tsx | wc -l prints 8, matching the API surface table above.
  3. Browser walk-through, signed in with Meta and Google connected and at least one campaign on each: (a) open /ads, observe the shell paint, the spinner, then the connections card with two green dots and two "Not connected" cards; (b) type a search term matching nothing, observe "No campaigns match your filters." replace the table with no page reload; (c) switch to the Multiplatform tab, expand a partial setup, click "Retry failed platforms", observe the row re-fetch and the retried platform's chip update; (d) switch to Insights, toggle from Meta to Google, observe the account select and the results table both change to the Google account and its rows.
  4. Demo gate: node tooling/verify/render-smoke.mjs ads/ui/ads-manager renders all seven states with renderToString, fetch stubbed, 0 failures.
  5. Verifier: node tooling/verify/compare.mjs ads/ui/ads-manager --state default --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json (an owner session with sandbox Meta/Google connections and seeded campaigns) writes a report with page_errors: []; mismatch_ratio is read for the header, the connections card and the filter row only, since the app's real campaign names, metrics and the always-zero chart (Gotchas) differ from the demo's sample data by construction.

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.