@actioneer/ads 1.0.8 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/DESIGN.md +125 -50
  2. package/README.md +31 -7
  3. package/components/answer.js +1 -1
  4. package/components/assistant-ui/composer-harness.js +14 -10
  5. package/components/body-page-header.d.ts +9 -4
  6. package/components/body-page-header.js +10 -10
  7. package/components/breadcrumbs.js +15 -15
  8. package/components/button.d.ts +16 -1
  9. package/components/button.js +7 -4
  10. package/components/card.js +18 -18
  11. package/components/chart.d.ts +40 -4
  12. package/components/chart.js +824 -628
  13. package/components/chip.d.ts +2 -1
  14. package/components/chip.js +49 -46
  15. package/components/collection-header.js +4 -4
  16. package/components/connector-card.js +30 -30
  17. package/components/date-range-picker.js +1 -1
  18. package/components/detail-list.d.ts +28 -0
  19. package/components/detail-list.js +52 -0
  20. package/components/drawer.d.ts +38 -15
  21. package/components/drawer.js +150 -86
  22. package/components/empty-state.d.ts +3 -2
  23. package/components/empty-state.js +22 -22
  24. package/components/field.d.ts +16 -0
  25. package/components/field.js +63 -42
  26. package/components/file-dropzone.d.ts +15 -4
  27. package/components/file-dropzone.js +100 -64
  28. package/components/filter-bar.d.ts +44 -0
  29. package/components/filter-bar.js +89 -0
  30. package/components/filters.d.ts +37 -0
  31. package/components/filters.js +129 -0
  32. package/components/index.d.ts +4 -0
  33. package/components/index.js +526 -510
  34. package/components/input-field.d.ts +1 -1
  35. package/components/input-field.js +24 -22
  36. package/components/modal.d.ts +2 -1
  37. package/components/modal.js +120 -106
  38. package/components/panel.js +26 -27
  39. package/components/popover.d.ts +6 -1
  40. package/components/popover.js +74 -64
  41. package/components/sidebar.d.ts +15 -0
  42. package/components/sidebar.js +166 -129
  43. package/components/sparkline.d.ts +30 -0
  44. package/components/sparkline.js +69 -0
  45. package/components/table.d.ts +8 -0
  46. package/components/table.js +357 -350
  47. package/components/tabs.d.ts +6 -1
  48. package/components/tabs.js +28 -31
  49. package/demo/components/add-to-artifact.js +6 -2
  50. package/examples/README.md +5 -2
  51. package/examples/call-logs-screen.tsx +381 -0
  52. package/examples/campaign-analysis-screen.tsx +180 -0
  53. package/examples/campaigns-screen.tsx +497 -0
  54. package/examples/guardrails-settings-screen.tsx +123 -0
  55. package/fonts/geist-variable.woff2 +0 -0
  56. package/fonts.css +12 -17
  57. package/lib/icons.js +3 -3
  58. package/llms.txt +162 -30
  59. package/manifest.json +11604 -7878
  60. package/package.json +1 -1
  61. package/styles.css +102 -59
  62. package/theme.css +81 -37
  63. package/fonts/aribau-grotesk-medium.woff2 +0 -0
  64. package/fonts/aribau-grotesk-regular.woff2 +0 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Campaign analysis screen — the ADS record/analytics-page recipe, built only
3
+ * from ADS exports.
4
+ *
5
+ * Follows principles `leading-back-navigation`, `analytics-surfaces` and
6
+ * `status-language` (see `get_principles`):
7
+ *
8
+ * - The 18px Breadcrumbs trail (parent / record) IS the page title, set in a
9
+ * 64px header bar closed by a full-width WG4 hairline; the one primary
10
+ * action sits at the right end of the same bar. No back arrow and no
11
+ * repeated H1.
12
+ * - A FilterBar spells out the scope under the tabs: the period preset and the
13
+ * dates it resolves to on the left, the cohort on the right.
14
+ * - Every stat and every chart sits in its own resting card (1px WG4 border,
15
+ * radius-card 14px, shadow-card, 20px padding), 16px apart.
16
+ * - A stat card stacks label → number + scope 4px apart with a Sparkline of
17
+ * its 7-day trend beside the number (right ~40% of the card), then a
18
+ * Progress bar 12px below that fills the card.
19
+ * - A chart card puts its title + scope subtitle on the left and the legend at
20
+ * the right of the same row (`legendPlacement="header"`), and prints its
21
+ * values on the chart — never hover-only.
22
+ *
23
+ * Copy it and replace the sample STATS / SEGMENT / REACH data with your own.
24
+ */
25
+ import * as React from "react";
26
+ import { Calendar } from "@actioneer/ads/lib/icons";
27
+ import {
28
+ BarChart,
29
+ BreadcrumbItem,
30
+ Breadcrumbs,
31
+ Button,
32
+ LineChart,
33
+ FilterBar,
34
+ FilterBarSelect,
35
+ FilterBarValue,
36
+ Progress,
37
+ Sparkline,
38
+ Tab,
39
+ TabList,
40
+ Tabs,
41
+ } from "@actioneer/ads";
42
+
43
+ export const STATS = [
44
+ { label: "Calls analysed", value: 3930, of: "of 4,800 calls", percent: 82, color: "primary" as const, trend: [400, 1100, 1700, 2500, 3100, 3600, 3930] },
45
+ { label: "Recipients reached", value: 864, of: "of 4,800 people", percent: 18, color: "primary" as const, trend: [120, 260, 410, 560, 690, 790, 864] },
46
+ // The one number that needs attention is the only amber one.
47
+ { label: "Asked for a callback", value: 144, of: "of 864 calls", percent: 17, color: "warning" as const, trend: [20, 40, 58, 80, 104, 126, 144] },
48
+ ];
49
+
50
+ export const SEGMENT = [
51
+ { day: "Oct 19", netNew: 3200, already: 1900 },
52
+ { day: "Oct 20", netNew: 420, already: 3080 },
53
+ { day: "Oct 21", netNew: 360, already: 3240 },
54
+ { day: "Oct 22", netNew: 440, already: 3460 },
55
+ { day: "Oct 23", netNew: 380, already: 3520 },
56
+ ];
57
+
58
+ export const REACH = [
59
+ { day: "Oct 19", reached: 12, booked: 2 },
60
+ { day: "Oct 20", reached: 14, booked: 3 },
61
+ { day: "Oct 21", reached: 15, booked: 4 },
62
+ { day: "Oct 22", reached: 17, booked: 4 },
63
+ { day: "Oct 23", reached: 18, booked: 5 },
64
+ ];
65
+
66
+ const TABS = ["Script", "Workflow", "Analysis", "Call logs", "Guardrails"];
67
+
68
+ const PERIODS = [
69
+ { value: "campaign", label: "This campaign" },
70
+ { value: "7d", label: "Last 7 days" },
71
+ { value: "30d", label: "Last 30 days" },
72
+ ];
73
+ const PERIOD_DATES: Record<string, string> = { campaign: "Oct 19 – Oct 23", "7d": "Oct 17 – Oct 23", "30d": "Sep 24 – Oct 23" };
74
+ const COHORTS = [
75
+ { value: "all", label: "All cohorts" },
76
+ { value: "blr", label: "Bengaluru" },
77
+ { value: "mum", label: "Mumbai" },
78
+ ];
79
+
80
+ /** Resting card for one stat or one chart (principle `analytics-surfaces`). */
81
+ function DataCard({ children, label }: { children: React.ReactNode; label: string }) {
82
+ return (
83
+ <section aria-label={label} className="flex min-w-0 flex-col rounded-card border border-border bg-card p-5 shadow-card">
84
+ {children}
85
+ </section>
86
+ );
87
+ }
88
+
89
+ export function CampaignAnalysisScreen() {
90
+ const [tab, setTab] = React.useState("Analysis");
91
+ const [period, setPeriod] = React.useState("campaign");
92
+ const [cohort, setCohort] = React.useState("all");
93
+
94
+ return (
95
+ <div className="flex h-screen bg-surface p-3 text-foreground">
96
+ {/* The app window: one rounded surface on the WG3 backdrop. */}
97
+ <div className="flex min-w-0 flex-1 overflow-hidden rounded-window border border-border bg-card shadow-window">
98
+ <main aria-label="Q4 renewal reminders" className="flex min-w-0 flex-1 flex-col overflow-hidden">
99
+ <div className="flex h-16 shrink-0 items-center border-b border-border px-6">
100
+ <Breadcrumbs className="flex-1" actions={<Button>Pause campaign</Button>}>
101
+ <BreadcrumbItem href="#campaigns">Voice campaigns</BreadcrumbItem>
102
+ <BreadcrumbItem isCurrent>Q4 renewal reminders</BreadcrumbItem>
103
+ </Breadcrumbs>
104
+ </div>
105
+
106
+ <div className="px-6 pt-5">
107
+ <Tabs value={tab} onValueChange={setTab}>
108
+ <TabList aria-label="Campaign sections">
109
+ {TABS.map((item) => (
110
+ <Tab key={item} value={item}>{item}</Tab>
111
+ ))}
112
+ </TabList>
113
+ </Tabs>
114
+ </div>
115
+
116
+ <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 pt-6 pb-6">
117
+ {/* The scope the numbers cover, spelled out: preset + resolved dates leading, cohort trailing. */}
118
+ <div className="flex items-center justify-between gap-4">
119
+ <FilterBar aria-label="Report period">
120
+ <FilterBarSelect label="Period" value={period} options={PERIODS} onValueChange={setPeriod} />
121
+ <FilterBarValue icon={<Calendar />}>{PERIOD_DATES[period]}</FilterBarValue>
122
+ </FilterBar>
123
+ <FilterBar aria-label="Cohort">
124
+ <FilterBarSelect label="Cohort" value={cohort} options={COHORTS} onValueChange={setCohort} />
125
+ </FilterBar>
126
+ </div>
127
+ <div role="list" aria-label="Campaign progress" className="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-4">
128
+ {STATS.map((stat) => (
129
+ <div role="listitem" key={stat.label} className="contents">
130
+ <DataCard label={stat.label}>
131
+ {/* Number on the left, its 7-day trend on the right. */}
132
+ <div className="flex items-end gap-4">
133
+ <div className="min-w-0 flex-1">
134
+ <p className="text-m text-secondary">{stat.label}</p>
135
+ <p className="mt-1 flex items-baseline gap-2">
136
+ <span className="text-4xl tabular-nums text-foreground">{stat.value.toLocaleString("en-IN")}</span>
137
+ <span className="whitespace-nowrap text-s text-secondary">{stat.of}</span>
138
+ </p>
139
+ </div>
140
+ <Sparkline className="w-1/3 shrink-0" data={stat.trend} label={`${stat.label}, past 7 days`} />
141
+ </div>
142
+ <Progress className="mt-3" value={stat.percent} color={stat.color} label={stat.label} />
143
+ </DataCard>
144
+ </div>
145
+ ))}
146
+ </div>
147
+
148
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(24rem,1fr))] gap-4">
149
+ <DataCard label="Who was in the segment">
150
+ <BarChart
151
+ title="Who was in the segment"
152
+ subtitle="4,800 net new numbers · Oct 19 – 23"
153
+ legendPlacement="header"
154
+ data={SEGMENT}
155
+ categoryKey="day"
156
+ series={["netNew", "already"]}
157
+ seriesLabels={{ netNew: "Net new", already: "Already in campaign" }}
158
+ stacked
159
+ height={360}
160
+ />
161
+ </DataCard>
162
+ <DataCard label="Did we reach them?">
163
+ <LineChart
164
+ title="Did we reach them?"
165
+ subtitle="Share of 4,800 people · by day"
166
+ legendPlacement="header"
167
+ data={REACH}
168
+ categoryKey="day"
169
+ series={["reached", "booked"]}
170
+ seriesLabels={{ reached: "Reached", booked: "Booked" }}
171
+ height={360}
172
+ />
173
+ </DataCard>
174
+ </div>
175
+ </div>
176
+ </main>
177
+ </div>
178
+ </div>
179
+ );
180
+ }
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Campaigns screen — the ADS collection-screen recipe, built only from ADS exports.
3
+ *
4
+ * Follows principles `collection-screens`, `record-detail-panels`,
5
+ * `status-language` and `tabs-search-opposed` (see `get_principles`):
6
+ *
7
+ * - The app window (rounded-window, WG4 hairline, shadow-window on the WG3
8
+ * backdrop) holds one white page: BodyPageHeader (a 64px bar with the title,
9
+ * one primary action and a full-width hairline), then the control row — Tabs with counts leading, SearchDisclosure then Filters as
10
+ * icon triggers trailing — then the Table.
11
+ * - The rows fill the panel: the page size is derived from the height that is
12
+ * left, so Pagination always sits on the panel's bottom edge.
13
+ * - Row checkboxes appear on hover (the header checkbox is always visible).
14
+ * - Clicking a row opens its detail panel beside the list and marks the row
15
+ * with `isActive` — no radio, no modal. The panel is attached to the page:
16
+ * flush beside the list, split off by one full-height hairline, no gap.
17
+ * - The detail panel: a title-only 64px header (title and close centred on
18
+ * one row), then Performance (stat cards + chart card) and Details
19
+ * (label–value rows that start with the status Chip and the finish date).
20
+ * - New campaign opens as a fixed-height Modal over the list (never a separate
21
+ * screen): one scrolling form — Campaign name, Caller numbers, Audience —
22
+ * with Cancel and Create campaign together in the footer (principle
23
+ * `create-modals`). Optional guidance lives in a Field `info` icon, the
24
+ * source switch sits in the Field `actions` slot, uploads use the inline
25
+ * FileDropzone, and picking one saved record is a single-select Table.
26
+ *
27
+ * In a full app this screen sits inside the shell, next to the Sidebar
28
+ * (put the customer's logo in its brand slot with SidebarWorkspace).
29
+ * Copy it and replace CAMPAIGNS with your own records.
30
+ */
31
+ import * as React from "react";
32
+ import {
33
+ BodyPageHeader,
34
+ Button,
35
+ Checkbox,
36
+ Chip,
37
+ Drawer,
38
+ Field,
39
+ DrawerBody,
40
+ DrawerFooter,
41
+ DrawerHeader,
42
+ DrawerSection,
43
+ DrawerTitle,
44
+ DetailItem,
45
+ DetailList,
46
+ FileDropzone,
47
+ Filters,
48
+ InputField,
49
+ LineChart,
50
+ Modal,
51
+ ModalBody,
52
+ ModalFooter,
53
+ ModalHeader,
54
+ ModalTitle,
55
+ Pagination,
56
+ Progress,
57
+ SearchDisclosure,
58
+ SegmentedControl,
59
+ Sparkline,
60
+ Tab,
61
+ TabList,
62
+ Table,
63
+ TableBody,
64
+ TableCell,
65
+ TableColumn,
66
+ TableHeader,
67
+ TableRow,
68
+ Tabs,
69
+ type FilterGroup,
70
+ } from "@actioneer/ads";
71
+
72
+ /** Business-language statuses, one tone per meaning (principle `status-language`). */
73
+ export type CampaignStatus = "Active" | "Scheduled" | "Paused" | "Completed" | "Draft" | "Failed";
74
+
75
+ export const STATUS_COLOR: Record<CampaignStatus, "info" | "warning" | "success" | "default" | "danger"> = {
76
+ Active: "info",
77
+ Scheduled: "info",
78
+ Paused: "warning",
79
+ Completed: "success",
80
+ Draft: "default",
81
+ Failed: "danger",
82
+ };
83
+
84
+ export interface CampaignRecord {
85
+ id: string;
86
+ name: string;
87
+ status: CampaignStatus;
88
+ leads: number;
89
+ connectRate: string;
90
+ booked: number;
91
+ owner: string;
92
+ started: string;
93
+ context: string;
94
+ callingWindows: number;
95
+ /** Connected calls for the last seven days. */
96
+ week: number[];
97
+ cohorts?: CampaignRecord[];
98
+ }
99
+
100
+ const week = (...values: number[]) => values;
101
+
102
+ export const CAMPAIGNS: CampaignRecord[] = [
103
+ {
104
+ id: "q4", name: "Q4 renewal reminders", status: "Active", leads: 2400, connectRate: "44%", booked: 286, owner: "Divya Shetty", started: "Oct 22", context: "Running since Oct 22", callingWindows: 2, week: week(210, 260, 240, 310, 290, 180, 150),
105
+ cohorts: [
106
+ { id: "q4-blr", name: "Bengaluru cohort", status: "Active", leads: 1500, connectRate: "47%", booked: 188, owner: "Divya Shetty", started: "Oct 22", context: "Running since Oct 22", callingWindows: 2, week: week(140, 170, 160, 210, 190, 120, 100) },
107
+ { id: "q4-bom", name: "Mumbai cohort", status: "Paused", leads: 900, connectRate: "39%", booked: 98, owner: "Kiran Menon", started: "Oct 22", context: "Paused today at 11:40", callingWindows: 2, week: week(70, 90, 80, 100, 100, 60, 0) },
108
+ ],
109
+ },
110
+ { id: "emi", name: "Loan EMI nudges", status: "Completed", leads: 5120, connectRate: "41%", booked: 612, owner: "Vikram Nair", started: "Oct 14", context: "Finished Oct 21", callingWindows: 3, week: week(420, 510, 480, 620, 580, 310, 260) },
111
+ { id: "kyc", name: "KYC re-verification", status: "Active", leads: 3260, connectRate: "38%", booked: 402, owner: "Rohan Iyer", started: "Oct 20", context: "Running since Oct 20", callingWindows: 2, week: week(260, 300, 320, 340, 330, 280, 310) },
112
+ { id: "lapse", name: "Policy lapse win-back", status: "Draft", leads: 780, connectRate: "—", booked: 0, owner: "Asha Rao", started: "—", context: "Not launched", callingWindows: 1, week: week(0, 0, 0, 0, 0, 0, 0) },
113
+ { id: "card", name: "Card activation follow-up", status: "Scheduled", leads: 1840, connectRate: "—", booked: 0, owner: "Meera Pillai", started: "Oct 28", context: "Starts Oct 28 at 10:00", callingWindows: 2, week: week(0, 0, 0, 0, 0, 0, 0) },
114
+ { id: "payment", name: "Payment failure outreach", status: "Completed", leads: 1120, connectRate: "45%", booked: 151, owner: "Arjun Das", started: "Oct 12", context: "Finished Oct 19", callingWindows: 2, week: week(160, 190, 170, 210, 200, 70, 34) },
115
+ { id: "festive", name: "Festive offer callbacks", status: "Paused", leads: 640, connectRate: "36%", booked: 52, owner: "Neha Kapoor", started: "Oct 18", context: "Paused yesterday at 18:10", callingWindows: 1, week: week(60, 80, 70, 90, 88, 0, 0) },
116
+ { id: "abandoned", name: "Abandoned application recovery", status: "Active", leads: 4050, connectRate: "40%", booked: 377, owner: "Sanjay Rao", started: "Oct 18", context: "Running since Oct 18", callingWindows: 3, week: week(340, 380, 400, 420, 410, 330, 310) },
117
+ { id: "renewal-check", name: "Insurance renewal check-in", status: "Failed", leads: 2210, connectRate: "—", booked: 0, owner: "Kiran Menon", started: "Oct 16", context: "CRM writeback failed on Oct 16", callingWindows: 2, week: week(0, 0, 0, 0, 0, 0, 0) },
118
+ { id: "credit-limit", name: "Credit limit upgrade offer", status: "Completed", leads: 3480, connectRate: "43%", booked: 298, owner: "Divya Shetty", started: "Oct 08", context: "Finished Oct 15", callingWindows: 3, week: week(300, 320, 310, 360, 340, 120, 80) },
119
+ { id: "survey", name: "Home loan top-up survey", status: "Active", leads: 2730, connectRate: "35%", booked: 120, owner: "Asha Rao", started: "Oct 24", context: "Running since Oct 24", callingWindows: 1, week: week(0, 0, 120, 180, 210, 190, 160) },
120
+ { id: "welcome", name: "New account welcome calls", status: "Scheduled", leads: 960, connectRate: "—", booked: 0, owner: "Meera Pillai", started: "Nov 02", context: "Starts Nov 2 at 09:30", callingWindows: 1, week: week(0, 0, 0, 0, 0, 0, 0) },
121
+ ];
122
+
123
+ /** Status tabs use the same words as the status chips. */
124
+ const TABS: Array<{ value: "all" | CampaignStatus; label: string }> = [
125
+ { value: "all", label: "All" },
126
+ { value: "Draft", label: "Draft" },
127
+ { value: "Active", label: "Active" },
128
+ { value: "Paused", label: "Paused" },
129
+ { value: "Completed", label: "Completed" },
130
+ ];
131
+
132
+ const INITIAL_FILTERS: FilterGroup[] = [
133
+ {
134
+ id: "owner",
135
+ label: "Owner",
136
+ value: "all",
137
+ options: [{ value: "all", label: "All owners" }, ...Array.from(new Set(CAMPAIGNS.map((c) => c.owner))).map((owner) => ({ value: owner, label: owner }))],
138
+ },
139
+ {
140
+ id: "windows",
141
+ label: "Calling windows",
142
+ value: "all",
143
+ options: [
144
+ { value: "all", label: "Any number" },
145
+ { value: "1", label: "1 window" },
146
+ { value: "2", label: "2 windows" },
147
+ { value: "3", label: "3 windows" },
148
+ ],
149
+ },
150
+ ];
151
+
152
+ const ROW_HEIGHT = 64;
153
+ /** Header row + footer (pagination) heights inside the table surface. */
154
+ const TABLE_CHROME = 37 + 53;
155
+
156
+ /** Derive the page size from the height left in the panel, so rows fill it. */
157
+ function useRowsThatFit(ref: React.RefObject<HTMLDivElement | null>) {
158
+ const [rows, setRows] = React.useState(8);
159
+ React.useLayoutEffect(() => {
160
+ const node = ref.current;
161
+ if (!node) return;
162
+ const update = () => setRows(Math.max(3, Math.floor((node.clientHeight - TABLE_CHROME) / ROW_HEIGHT)));
163
+ update();
164
+ const observer = new ResizeObserver(update);
165
+ observer.observe(node);
166
+ return () => observer.disconnect();
167
+ }, [ref]);
168
+ return rows;
169
+ }
170
+
171
+ function findCampaign(id: string | null): CampaignRecord | undefined {
172
+ if (!id) return undefined;
173
+ for (const campaign of CAMPAIGNS) {
174
+ if (campaign.id === id) return campaign;
175
+ const cohort = campaign.cohorts?.find((child) => child.id === id);
176
+ if (cohort) return cohort;
177
+ }
178
+ return undefined;
179
+ }
180
+
181
+ function CampaignCells({ campaign }: { campaign: CampaignRecord }) {
182
+ // Metric cells (principle `collection-screens`): a rate gets a small meter
183
+ // before the number; the 7-day trend is a dotless Sparkline.
184
+ const rate = campaign.connectRate === "—" ? null : Number.parseFloat(campaign.connectRate);
185
+ const hasCalls = campaign.week.some((value) => value > 0);
186
+ return (
187
+ <>
188
+ <TableCell>{campaign.name}</TableCell>
189
+ <TableCell>
190
+ <Chip color={STATUS_COLOR[campaign.status]}>{campaign.status}</Chip>
191
+ </TableCell>
192
+ <TableCell align="right" className="tabular-nums">{campaign.leads.toLocaleString("en-IN")}</TableCell>
193
+ <TableCell align="right">
194
+ {rate === null ? (
195
+ <span className="text-on-white-secondary">—</span>
196
+ ) : (
197
+ <span className="inline-flex items-center justify-end gap-2.5">
198
+ <Progress value={rate} size="sm" label={`${campaign.name} connect rate`} className="w-12" />
199
+ <span className="tabular-nums">{campaign.connectRate}</span>
200
+ </span>
201
+ )}
202
+ </TableCell>
203
+ <TableCell align="right">
204
+ {hasCalls ? (
205
+ <Sparkline
206
+ data={campaign.week}
207
+ height={28}
208
+ dot={false}
209
+ label={`${campaign.name}: connected calls, last 7 days`}
210
+ className="ml-auto w-24"
211
+ />
212
+ ) : (
213
+ <span className="text-on-white-secondary">No calls yet</span>
214
+ )}
215
+ </TableCell>
216
+ <TableCell>{campaign.owner}</TableCell>
217
+ </>
218
+ );
219
+ }
220
+
221
+ /** Selection-driven detail panel (principle `record-detail-panels`). */
222
+ export function CampaignDetail({ campaign, onClose }: { campaign: CampaignRecord | undefined; onClose: () => void }) {
223
+ return (
224
+ <Drawer
225
+ open={Boolean(campaign)}
226
+ onOpenChange={(open) => !open && onClose()}
227
+ aria-label="Campaign details"
228
+ // Centre the built-in close button on the 64px title row.
229
+ className="[&>button[aria-label=Close]]:top-4"
230
+ >
231
+ {campaign && (
232
+ <>
233
+ {/* Title-only header bar: the title and the close button share one centred row. */}
234
+ <DrawerHeader className="h-16 justify-center py-0">
235
+ <DrawerTitle className="truncate">{campaign.name}</DrawerTitle>
236
+ </DrawerHeader>
237
+ <DrawerBody className="flex flex-col gap-8">
238
+ <DrawerSection title="Performance">
239
+ <dl className="grid grid-cols-3 gap-2">
240
+ {[
241
+ { label: "Leads", value: campaign.leads.toLocaleString("en-IN") },
242
+ { label: "Connect rate", value: campaign.connectRate },
243
+ { label: "Booked", value: campaign.booked.toLocaleString("en-IN") },
244
+ ].map((stat) => (
245
+ <div key={stat.label} className="min-w-0 rounded-card border border-border bg-wg1 p-3">
246
+ <dt className="truncate text-s text-secondary">{stat.label}</dt>
247
+ <dd className="mt-0.5 text-xl tabular-nums text-foreground">{stat.value}</dd>
248
+ </div>
249
+ ))}
250
+ </dl>
251
+ <div className="rounded-card border border-border p-4">
252
+ <LineChart
253
+ title={<span className="text-s font-normal text-secondary">Connected calls · last 7 days</span>}
254
+ data={campaign.week.map((calls, index) => ({ day: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][index], calls }))}
255
+ categoryKey="day"
256
+ series={["calls"]}
257
+ seriesLabels={{ calls: "Connected" }}
258
+ legend={false}
259
+ showYAxis={false}
260
+ height={160}
261
+ />
262
+ </div>
263
+ </DrawerSection>
264
+ <DrawerSection title="Details">
265
+ <DetailList>
266
+ <DetailItem label="Status">
267
+ <Chip color={STATUS_COLOR[campaign.status]}>{campaign.status}</Chip>
268
+ </DetailItem>
269
+ <DetailItem label="Owner">{campaign.owner}</DetailItem>
270
+ <DetailItem label="Started">{campaign.started}</DetailItem>
271
+ {campaign.status === "Completed" ? (
272
+ <DetailItem label="Finished">{campaign.context.replace(/^Finished /, "")}</DetailItem>
273
+ ) : (
274
+ <DetailItem label="Activity">{campaign.context}</DetailItem>
275
+ )}
276
+ <DetailItem label="Calling windows">{campaign.callingWindows}</DetailItem>
277
+ </DetailList>
278
+ </DrawerSection>
279
+ </DrawerBody>
280
+ <DrawerFooter>
281
+ <Button variant="outline">Duplicate</Button>
282
+ <Button>Open full view</Button>
283
+ </DrawerFooter>
284
+ </>
285
+ )}
286
+ </Drawer>
287
+ );
288
+ }
289
+
290
+ const SAVED_AUDIENCES = [
291
+ { id: "0019b431", name: "CSV audience 0019b431", uploaded: "Oct 24", size: 6 },
292
+ { id: "b190b191", name: "CSV audience b190b191", uploaded: "Oct 21", size: 868 },
293
+ { id: "5ee46ae6", name: "CSV audience 5ee46ae6", uploaded: "Oct 18", size: 162 },
294
+ { id: "9dc3d0b1", name: "CSV audience 9dc3d0b1", uploaded: "Oct 12", size: 188 },
295
+ ];
296
+
297
+ /** Create task launched from the collection: a fixed-height Modal over the list (principle `create-modals`). */
298
+ export function NewCampaignModal({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
299
+ const [name, setName] = React.useState("");
300
+ const [useNumber, setUseNumber] = React.useState(false);
301
+ const [audience, setAudience] = React.useState("csv");
302
+ const [query, setQuery] = React.useState("");
303
+ const [picked, setPicked] = React.useState<Set<string>>(new Set(["b190b191"]));
304
+ const audiences = SAVED_AUDIENCES.filter((item) => item.name.toLowerCase().includes(query.trim().toLowerCase()));
305
+
306
+ return (
307
+ <Modal open={open} onOpenChange={onOpenChange}>
308
+ <ModalHeader>
309
+ <ModalTitle>New campaign</ModalTitle>
310
+ </ModalHeader>
311
+ <ModalBody className="flex flex-col gap-6">
312
+ <InputField label="Campaign name" value={name} onChange={(event) => setName(event.target.value)} placeholder="First-EMI onboarding" />
313
+
314
+ {/* Optional guidance goes in the label's info icon, not a helper line. */}
315
+ <Field
316
+ label="Caller numbers"
317
+ info="Optional. Tick none to call from any enabled number. You can change this later, even while the campaign is calling."
318
+ >
319
+ <div className="flex min-h-12 items-center gap-3 rounded-m border border-input px-3">
320
+ <Checkbox aria-label="+91 22 6987 0900" checked={useNumber} onChange={(event) => setUseNumber(event.target.checked)} />
321
+ <span className="flex-1 text-m tabular-nums text-foreground">+91 22 6987 0900</span>
322
+ <Chip>Plivo</Chip>
323
+ </div>
324
+ </Field>
325
+
326
+ {/* The source switch sits on the label row; the body changes with it. */}
327
+ <Field
328
+ label="Audience"
329
+ actions={
330
+ <SegmentedControl
331
+ size="sm"
332
+ aria-label="Audience source"
333
+ options={[{ value: "csv", label: "CSV upload" }, { value: "empty", label: "Empty audience" }]}
334
+ value={audience}
335
+ onChange={setAudience}
336
+ />
337
+ }
338
+ >
339
+ {audience === "csv" ? (
340
+ <div className="flex flex-col gap-4">
341
+ <FileDropzone layout="inline" accept=".csv,.xlsx" label="Upload a new list" hint="Drop a CSV or .xlsx with a phone column" />
342
+ <InputField aria-label="Search saved audiences" type="search" placeholder="Search saved audiences" value={query} onChange={(event) => setQuery(event.target.value)} />
343
+ <Table aria-label="Saved audiences" selectionMode="single" selectedKeys={picked} onSelectionChange={setPicked}>
344
+ <TableHeader>
345
+ <TableRow>
346
+ <TableColumn>Name</TableColumn>
347
+ <TableColumn>Uploaded</TableColumn>
348
+ <TableColumn align="right">Contacts</TableColumn>
349
+ </TableRow>
350
+ </TableHeader>
351
+ <TableBody>
352
+ {audiences.map((item) => (
353
+ <TableRow key={item.id} rowKey={item.id} aria-label={`Use ${item.name}`}>
354
+ <TableCell>{item.name}</TableCell>
355
+ <TableCell className="text-secondary">{item.uploaded}</TableCell>
356
+ <TableCell align="right" className="tabular-nums">{item.size.toLocaleString("en-IN")}</TableCell>
357
+ </TableRow>
358
+ ))}
359
+ </TableBody>
360
+ </Table>
361
+ </div>
362
+ ) : null}
363
+ </Field>
364
+ </ModalBody>
365
+ <ModalFooter>
366
+ <Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
367
+ <Button disabled={!name.trim()} onClick={() => onOpenChange(false)}>Create campaign</Button>
368
+ </ModalFooter>
369
+ </Modal>
370
+ );
371
+ }
372
+
373
+ export function CampaignsScreen() {
374
+ const [tab, setTab] = React.useState<"all" | CampaignStatus>("all");
375
+ const [search, setSearch] = React.useState("");
376
+ const [searchOpen, setSearchOpen] = React.useState(false);
377
+ const [filters, setFilters] = React.useState(INITIAL_FILTERS);
378
+ const [selectedKeys, setSelectedKeys] = React.useState<Set<string>>(new Set());
379
+ const [openId, setOpenId] = React.useState<string | null>(null);
380
+ const [page, setPage] = React.useState(1);
381
+ const [creating, setCreating] = React.useState(false);
382
+ const rowsRef = React.useRef<HTMLDivElement>(null);
383
+ const pageSize = useRowsThatFit(rowsRef);
384
+
385
+ const owner = filters.find((group) => group.id === "owner")?.value ?? "all";
386
+ const windows = filters.find((group) => group.id === "windows")?.value ?? "all";
387
+ const query = search.trim().toLowerCase();
388
+ const visible = CAMPAIGNS.filter(
389
+ (c) =>
390
+ (tab === "all" || c.status === tab) &&
391
+ (owner === "all" || c.owner === owner) &&
392
+ (windows === "all" || String(c.callingWindows) === windows) &&
393
+ (!query || c.name.toLowerCase().includes(query)),
394
+ );
395
+ const pages = Math.max(1, Math.ceil(visible.length / pageSize));
396
+ const current = Math.min(page, pages);
397
+ const pageRows = visible.slice((current - 1) * pageSize, current * pageSize);
398
+ const countFor = (value: "all" | CampaignStatus) =>
399
+ value === "all" ? CAMPAIGNS.length : CAMPAIGNS.filter((c) => c.status === value).length;
400
+
401
+ return (
402
+ <div className="flex h-screen bg-surface p-3 text-foreground">
403
+ {/* The app window: one rounded surface on the WG3 backdrop. */}
404
+ <div className="flex min-w-0 flex-1 overflow-hidden rounded-window border border-border bg-card shadow-window">
405
+ <section aria-label="Voice campaigns" className="flex min-w-0 flex-1 flex-col overflow-hidden">
406
+ <BodyPageHeader title="Voice campaigns" actions={<Button onClick={() => setCreating(true)}>New campaign</Button>} />
407
+
408
+ {/* Tabs lead; search and filters trail as icon triggers. */}
409
+ <div className="flex items-center justify-between gap-4 px-6">
410
+ <Tabs className="min-w-0 flex-1" value={tab} onValueChange={(value) => { setTab(value as typeof tab); setPage(1); }}>
411
+ <TabList aria-label="Filter campaigns by status">
412
+ {TABS.map((item) => (
413
+ <Tab key={item.value} value={item.value} count={countFor(item.value)}>
414
+ {item.label}
415
+ </Tab>
416
+ ))}
417
+ </TabList>
418
+ </Tabs>
419
+ <div className="flex shrink-0 items-center gap-2">
420
+ <SearchDisclosure
421
+ value={search}
422
+ onValueChange={(value) => { setSearch(value); setPage(1); }}
423
+ open={searchOpen}
424
+ onOpenChange={setSearchOpen}
425
+ placeholder="Search campaigns"
426
+ triggerLabel="Search campaigns"
427
+ />
428
+ <Filters
429
+ triggerLabel="Filter campaigns"
430
+ groups={filters}
431
+ onChange={(id, value) => { setFilters((groups) => groups.map((group) => (group.id === id ? { ...group, value } : group))); setPage(1); }}
432
+ />
433
+ </div>
434
+ </div>
435
+
436
+ {/* The rows fill what is left of the panel; pagination sits at the bottom. */}
437
+ <div ref={rowsRef} className="min-h-0 flex-1 px-6 pt-4 pb-6">
438
+ <Table
439
+ aria-label="Voice campaigns"
440
+ selectionMode="multiple"
441
+ selectedKeys={selectedKeys}
442
+ onSelectionChange={setSelectedKeys}
443
+ footer={
444
+ <Pagination
445
+ className="w-full"
446
+ total={pages}
447
+ page={current}
448
+ onChange={setPage}
449
+ summary={`${visible.length === 0 ? 0 : (current - 1) * pageSize + 1}–${Math.min(current * pageSize, visible.length)} of ${visible.length} campaigns`}
450
+ />
451
+ }
452
+ >
453
+ <TableHeader>
454
+ <TableRow>
455
+ <TableColumn>Campaign</TableColumn>
456
+ <TableColumn>Status</TableColumn>
457
+ <TableColumn align="right">Leads</TableColumn>
458
+ <TableColumn align="right">Connect rate</TableColumn>
459
+ <TableColumn align="right">Last 7 days</TableColumn>
460
+ <TableColumn>Owner</TableColumn>
461
+ </TableRow>
462
+ </TableHeader>
463
+ <TableBody>
464
+ {pageRows.map((campaign) => (
465
+ <TableRow
466
+ key={campaign.id}
467
+ rowKey={campaign.id}
468
+ aria-label={`Open ${campaign.name}`}
469
+ isActive={openId === campaign.id}
470
+ onClick={() => setOpenId((id) => (id === campaign.id ? null : campaign.id))}
471
+ childRows={campaign.cohorts?.map((cohort) => (
472
+ <TableRow
473
+ key={cohort.id}
474
+ rowKey={cohort.id}
475
+ aria-label={`Open ${cohort.name}`}
476
+ isActive={openId === cohort.id}
477
+ onClick={() => setOpenId((id) => (id === cohort.id ? null : cohort.id))}
478
+ >
479
+ <CampaignCells campaign={cohort} />
480
+ </TableRow>
481
+ ))}
482
+ defaultExpanded={Boolean(campaign.cohorts)}
483
+ >
484
+ <CampaignCells campaign={campaign} />
485
+ </TableRow>
486
+ ))}
487
+ </TableBody>
488
+ </Table>
489
+ </div>
490
+ </section>
491
+
492
+ <CampaignDetail campaign={findCampaign(openId)} onClose={() => setOpenId(null)} />
493
+ <NewCampaignModal open={creating} onOpenChange={setCreating} />
494
+ </div>
495
+ </div>
496
+ );
497
+ }