@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.
- package/DESIGN.md +125 -50
- package/README.md +31 -7
- package/components/answer.js +1 -1
- package/components/assistant-ui/composer-harness.js +14 -10
- package/components/body-page-header.d.ts +9 -4
- package/components/body-page-header.js +10 -10
- package/components/breadcrumbs.js +15 -15
- package/components/button.d.ts +16 -1
- package/components/button.js +7 -4
- package/components/card.js +18 -18
- package/components/chart.d.ts +40 -4
- package/components/chart.js +824 -628
- package/components/chip.d.ts +2 -1
- package/components/chip.js +49 -46
- package/components/collection-header.js +4 -4
- package/components/connector-card.js +30 -30
- package/components/date-range-picker.js +1 -1
- package/components/detail-list.d.ts +28 -0
- package/components/detail-list.js +52 -0
- package/components/drawer.d.ts +38 -15
- package/components/drawer.js +150 -86
- package/components/empty-state.d.ts +3 -2
- package/components/empty-state.js +22 -22
- package/components/field.d.ts +16 -0
- package/components/field.js +63 -42
- package/components/file-dropzone.d.ts +15 -4
- package/components/file-dropzone.js +100 -64
- package/components/filter-bar.d.ts +44 -0
- package/components/filter-bar.js +89 -0
- package/components/filters.d.ts +37 -0
- package/components/filters.js +129 -0
- package/components/index.d.ts +4 -0
- package/components/index.js +526 -510
- package/components/input-field.d.ts +1 -1
- package/components/input-field.js +24 -22
- package/components/modal.d.ts +2 -1
- package/components/modal.js +120 -106
- package/components/panel.js +26 -27
- package/components/popover.d.ts +6 -1
- package/components/popover.js +74 -64
- package/components/sidebar.d.ts +15 -0
- package/components/sidebar.js +166 -129
- package/components/sparkline.d.ts +30 -0
- package/components/sparkline.js +69 -0
- package/components/table.d.ts +8 -0
- package/components/table.js +357 -350
- package/components/tabs.d.ts +6 -1
- package/components/tabs.js +28 -31
- package/demo/components/add-to-artifact.js +6 -2
- package/examples/README.md +5 -2
- package/examples/call-logs-screen.tsx +381 -0
- package/examples/campaign-analysis-screen.tsx +180 -0
- package/examples/campaigns-screen.tsx +497 -0
- package/examples/guardrails-settings-screen.tsx +123 -0
- package/fonts/geist-variable.woff2 +0 -0
- package/fonts.css +12 -17
- package/lib/icons.js +3 -3
- package/llms.txt +162 -30
- package/manifest.json +11604 -7878
- package/package.json +1 -1
- package/styles.css +102 -59
- package/theme.css +81 -37
- package/fonts/aribau-grotesk-medium.woff2 +0 -0
- 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
|
+
}
|