@actioneer/ads 1.0.9 → 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 +98 -57
- package/README.md +19 -7
- package/components/answer.js +1 -1
- package/components/assistant-ui/composer-harness.js +13 -10
- package/components/body-page-header.d.ts +9 -4
- package/components/body-page-header.js +10 -10
- package/components/breadcrumbs.js +22 -22
- package/components/card.js +18 -18
- package/components/chart.d.ts +9 -0
- package/components/chart.js +738 -717
- package/components/chip.js +12 -12
- 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/index.d.ts +3 -0
- package/components/index.js +526 -513
- package/components/input-field.d.ts +1 -1
- package/components/input-field.js +24 -22
- package/components/modal.js +56 -53
- package/components/panel.js +26 -27
- package/components/sidebar.js +54 -54
- package/components/sparkline.d.ts +30 -0
- package/components/sparkline.js +69 -0
- package/demo/components/add-to-artifact.js +5 -2
- package/examples/README.md +4 -2
- package/examples/call-logs-screen.tsx +381 -0
- package/examples/campaign-analysis-screen.tsx +114 -72
- package/examples/campaigns-screen.tsx +218 -160
- 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 +107 -39
- package/manifest.json +3100 -892
- 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,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Call logs screen — the ADS record-call-logs recipe, built only from ADS
|
|
3
|
+
* exports.
|
|
4
|
+
*
|
|
5
|
+
* Follows principles `record-detail-panels`, `collection-screens`,
|
|
6
|
+
* `leading-back-navigation` and `status-language` (see `get_principles`):
|
|
7
|
+
*
|
|
8
|
+
* - Call logs is a TAB of its campaign, not a page of its own: the 16px
|
|
9
|
+
* Breadcrumbs trail (Voice campaigns / campaign) is the title, the campaign
|
|
10
|
+
* status Chip sits beside it, and the campaign actions (Test call, Launch)
|
|
11
|
+
* sit on the right of the same row — a 64px header bar closed by a
|
|
12
|
+
* full-width hairline.
|
|
13
|
+
* - The list is a Table — Number · Status · Outcome · Duration — whose rows
|
|
14
|
+
* fill the panel with Pagination at the bottom. Status is a plain Chip
|
|
15
|
+
* (square dot + 14px text: Completed green, No answer amber, Failed red);
|
|
16
|
+
* the outcome exists only once a call connected, otherwise an em dash.
|
|
17
|
+
* - Opening a call marks its row `isActive` and mounts the call panel beside
|
|
18
|
+
* the list, attached to the page by one full-height hairline.
|
|
19
|
+
* - The call panel: DrawerHeader (kicker, number, status + time · length),
|
|
20
|
+
* then DrawerSections 32px apart — Summary, Recording, Transcript · N turns,
|
|
21
|
+
* Follow-ups, CRM write-back, Captured fields · X of N — and Call metadata
|
|
22
|
+
* collapsed last. Each fact appears once. No footer: there is no dedicated
|
|
23
|
+
* call page, so there is nothing to "open".
|
|
24
|
+
*
|
|
25
|
+
* Copy it and replace CALLS with your own records.
|
|
26
|
+
*/
|
|
27
|
+
import * as React from "react";
|
|
28
|
+
import {
|
|
29
|
+
AudioWaveform,
|
|
30
|
+
BreadcrumbItem,
|
|
31
|
+
Breadcrumbs,
|
|
32
|
+
Button,
|
|
33
|
+
Chip,
|
|
34
|
+
Collapsible,
|
|
35
|
+
CollapsibleContent,
|
|
36
|
+
CollapsibleTrigger,
|
|
37
|
+
DetailItem,
|
|
38
|
+
DetailList,
|
|
39
|
+
Drawer,
|
|
40
|
+
DrawerBody,
|
|
41
|
+
DrawerHeader,
|
|
42
|
+
DrawerKicker,
|
|
43
|
+
DrawerMeta,
|
|
44
|
+
DrawerSection,
|
|
45
|
+
DrawerTitle,
|
|
46
|
+
Filters,
|
|
47
|
+
Pagination,
|
|
48
|
+
SearchDisclosure,
|
|
49
|
+
Tab,
|
|
50
|
+
TabList,
|
|
51
|
+
Table,
|
|
52
|
+
TableBody,
|
|
53
|
+
TableCell,
|
|
54
|
+
TableColumn,
|
|
55
|
+
TableHeader,
|
|
56
|
+
TableRow,
|
|
57
|
+
Tabs,
|
|
58
|
+
Transcript,
|
|
59
|
+
type FilterGroup,
|
|
60
|
+
type TranscriptTurn,
|
|
61
|
+
} from "@actioneer/ads";
|
|
62
|
+
import { ChevronDown } from "@actioneer/ads/lib/icons";
|
|
63
|
+
|
|
64
|
+
/** Call statuses, one tone per meaning (principle `status-language`). */
|
|
65
|
+
export type CallStatus = "Completed" | "No answer" | "Busy" | "Failed";
|
|
66
|
+
|
|
67
|
+
export const CALL_STATUS_COLOR: Record<CallStatus, "success" | "warning" | "danger"> = {
|
|
68
|
+
Completed: "success",
|
|
69
|
+
"No answer": "warning",
|
|
70
|
+
Busy: "warning",
|
|
71
|
+
Failed: "danger",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export interface CallRecord {
|
|
75
|
+
id: string;
|
|
76
|
+
number: string;
|
|
77
|
+
status: CallStatus;
|
|
78
|
+
/** Disposition — only for calls that connected. */
|
|
79
|
+
outcome?: string;
|
|
80
|
+
seconds: number;
|
|
81
|
+
startedAt: string;
|
|
82
|
+
summary?: string;
|
|
83
|
+
cost: string;
|
|
84
|
+
pickedUp: boolean;
|
|
85
|
+
transcript: TranscriptTurn[];
|
|
86
|
+
followUps: string[];
|
|
87
|
+
crm: { nextAction?: string; reason?: string };
|
|
88
|
+
/** Field key → captured value (undefined = not stated). */
|
|
89
|
+
captured: Record<string, string | undefined>;
|
|
90
|
+
metadata: { callId: string; agent: string; callerId: string; provider: string };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Sentence-case labels for captured field keys — never show the raw key. */
|
|
94
|
+
export const CAPTURED_FIELD_LABELS: Record<string, string> = {
|
|
95
|
+
customer_name: "Customer name",
|
|
96
|
+
type_of_loan: "Type of loan",
|
|
97
|
+
loan_amount: "Loan amount",
|
|
98
|
+
city: "City",
|
|
99
|
+
pincode: "Pincode",
|
|
100
|
+
type_of_employment: "Type of employment",
|
|
101
|
+
approximate_monthly_income: "Monthly income",
|
|
102
|
+
property_status: "Property status",
|
|
103
|
+
property_amount: "Property amount",
|
|
104
|
+
callback_slot: "Callback slot",
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const EMPTY_CAPTURE = Object.fromEntries(Object.keys(CAPTURED_FIELD_LABELS).map((key) => [key, undefined]));
|
|
108
|
+
|
|
109
|
+
const call = (number: string, status: CallStatus, seconds: number, extra: Partial<CallRecord> = {}): CallRecord => ({
|
|
110
|
+
id: number.replace(/\D/g, ""),
|
|
111
|
+
number,
|
|
112
|
+
status,
|
|
113
|
+
seconds,
|
|
114
|
+
startedAt: "12 Sep, 3:41 PM",
|
|
115
|
+
cost: "₹0.00",
|
|
116
|
+
pickedUp: status === "Completed",
|
|
117
|
+
transcript: [],
|
|
118
|
+
followUps: [],
|
|
119
|
+
crm: {},
|
|
120
|
+
captured: EMPTY_CAPTURE,
|
|
121
|
+
metadata: { callId: "d8138618-0042", agent: "Asha · Hindi", callerId: "+91 22 6987 0900", provider: "Plivo" },
|
|
122
|
+
...extra,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
export const CALLS: CallRecord[] = [
|
|
126
|
+
call("+91 98393 81298", "No answer", 0),
|
|
127
|
+
call("+91 87222 96094", "No answer", 0),
|
|
128
|
+
call("+91 98857 55120", "No answer", 0),
|
|
129
|
+
call("+91 90053 11157", "No answer", 0),
|
|
130
|
+
call("+91 86686 86494", "Completed", 4, {
|
|
131
|
+
outcome: "Customer did not speak",
|
|
132
|
+
summary: "An unknown customer answered but did not respond. No next step was established.",
|
|
133
|
+
transcript: [{ id: "t1", role: "agent", timeLabel: "0:01", text: "Hi, this is Asha calling from Liquide about your loan enquiry. Is this a good time to talk?" }],
|
|
134
|
+
crm: { reason: "The customer did not provide any voice response." },
|
|
135
|
+
}),
|
|
136
|
+
call("+91 73853 99695", "No answer", 0),
|
|
137
|
+
call("+91 94880 36418", "Completed", 10, { outcome: "Customer did not speak", summary: "The customer picked up and hung up after the greeting." }),
|
|
138
|
+
call("+91 74477 96759", "No answer", 0),
|
|
139
|
+
call("+91 75682 95332", "Completed", 7, { outcome: "Customer did not speak", summary: "The customer picked up and stayed silent." }),
|
|
140
|
+
call("+91 78876 09135", "Busy", 0),
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
const TABS = ["Script", "Workflow", "Analysis", "Call logs", "Reports", "Guardrails"];
|
|
144
|
+
|
|
145
|
+
const INITIAL_FILTERS: FilterGroup[] = [
|
|
146
|
+
{
|
|
147
|
+
id: "status",
|
|
148
|
+
label: "Status",
|
|
149
|
+
value: "all",
|
|
150
|
+
options: [
|
|
151
|
+
{ value: "all", label: "All statuses" },
|
|
152
|
+
{ value: "Completed", label: "Completed" },
|
|
153
|
+
{ value: "No answer", label: "No answer" },
|
|
154
|
+
{ value: "Busy", label: "Busy" },
|
|
155
|
+
{ value: "Failed", label: "Failed" },
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const ROW_HEIGHT = 64;
|
|
161
|
+
const TABLE_CHROME = 90;
|
|
162
|
+
|
|
163
|
+
function useRowsThatFit(ref: React.RefObject<HTMLDivElement | null>) {
|
|
164
|
+
const [rows, setRows] = React.useState(8);
|
|
165
|
+
React.useLayoutEffect(() => {
|
|
166
|
+
const node = ref.current;
|
|
167
|
+
if (!node) return;
|
|
168
|
+
const update = () => setRows(Math.max(3, Math.floor((node.clientHeight - TABLE_CHROME) / ROW_HEIGHT)));
|
|
169
|
+
update();
|
|
170
|
+
const observer = new ResizeObserver(update);
|
|
171
|
+
observer.observe(node);
|
|
172
|
+
return () => observer.disconnect();
|
|
173
|
+
}, [ref]);
|
|
174
|
+
return rows;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const seconds = (value: number) => `${value}s`;
|
|
178
|
+
|
|
179
|
+
/** The call panel: header owns status, time and length; the body never repeats them. */
|
|
180
|
+
export function CallPanel({ call, onClose }: { call: CallRecord | undefined; onClose: () => void }) {
|
|
181
|
+
const capturedKeys = Object.keys(CAPTURED_FIELD_LABELS);
|
|
182
|
+
const filled = call ? capturedKeys.filter((key) => call.captured[key]).length : 0;
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<Drawer open={Boolean(call)} onOpenChange={(open) => !open && onClose()} aria-label="Call details">
|
|
186
|
+
{call && (
|
|
187
|
+
<>
|
|
188
|
+
<DrawerHeader>
|
|
189
|
+
<DrawerKicker>Call</DrawerKicker>
|
|
190
|
+
<DrawerTitle className="tabular-nums">{call.number}</DrawerTitle>
|
|
191
|
+
<DrawerMeta>
|
|
192
|
+
<Chip color={CALL_STATUS_COLOR[call.status]}>{call.status}</Chip>
|
|
193
|
+
<span>
|
|
194
|
+
{call.startedAt} · {seconds(call.seconds)}
|
|
195
|
+
</span>
|
|
196
|
+
</DrawerMeta>
|
|
197
|
+
</DrawerHeader>
|
|
198
|
+
<DrawerBody className="flex flex-col gap-8">
|
|
199
|
+
<DrawerSection title="Summary">
|
|
200
|
+
{call.outcome && (
|
|
201
|
+
<div className="flex">
|
|
202
|
+
<Chip variant="outline">{call.outcome}</Chip>
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
<p className="m-0 text-m text-on-white-primary">{call.summary ?? "The call did not connect."}</p>
|
|
206
|
+
<dl className="m-0 mt-1 grid grid-cols-2 gap-2">
|
|
207
|
+
{[
|
|
208
|
+
{ label: "Cost", value: call.cost },
|
|
209
|
+
{ label: "Picked up", value: call.pickedUp ? "Yes" : "No" },
|
|
210
|
+
].map((stat) => (
|
|
211
|
+
<div key={stat.label} className="min-w-0 rounded-card border border-border bg-wg1 p-3">
|
|
212
|
+
<dt className="text-s text-on-white-secondary">{stat.label}</dt>
|
|
213
|
+
<dd className="m-0 mt-0.5 text-xl tabular-nums text-on-white-primary">{stat.value}</dd>
|
|
214
|
+
</div>
|
|
215
|
+
))}
|
|
216
|
+
</dl>
|
|
217
|
+
</DrawerSection>
|
|
218
|
+
|
|
219
|
+
{call.pickedUp && (
|
|
220
|
+
<DrawerSection title="Recording">
|
|
221
|
+
<AudioWaveform src="/samples/conversation-24s.wav" durationSeconds={call.seconds} channels={2} downloadName={`call-${call.id}.wav`} />
|
|
222
|
+
</DrawerSection>
|
|
223
|
+
)}
|
|
224
|
+
|
|
225
|
+
<DrawerSection title="Transcript" count={`${call.transcript.length} ${call.transcript.length === 1 ? "turn" : "turns"}`}>
|
|
226
|
+
<Transcript turns={call.transcript} emptyMessage="No one spoke on this call." />
|
|
227
|
+
</DrawerSection>
|
|
228
|
+
|
|
229
|
+
<DrawerSection title="Follow-ups">
|
|
230
|
+
{call.followUps.length ? (
|
|
231
|
+
<ul className="m-0 list-none p-0 text-m text-on-white-primary">
|
|
232
|
+
{call.followUps.map((item) => <li key={item}>{item}</li>)}
|
|
233
|
+
</ul>
|
|
234
|
+
) : (
|
|
235
|
+
<p className="m-0 text-m text-on-white-secondary">No follow-up recorded</p>
|
|
236
|
+
)}
|
|
237
|
+
</DrawerSection>
|
|
238
|
+
|
|
239
|
+
<DrawerSection title="CRM write-back">
|
|
240
|
+
<DetailList>
|
|
241
|
+
<DetailItem label="Next action" emptyLabel="None">{call.crm.nextAction}</DetailItem>
|
|
242
|
+
<DetailItem label="Reason">{call.crm.reason}</DetailItem>
|
|
243
|
+
</DetailList>
|
|
244
|
+
</DrawerSection>
|
|
245
|
+
|
|
246
|
+
<DrawerSection title="Captured fields" count={`${filled} of ${capturedKeys.length}`}>
|
|
247
|
+
<DetailList>
|
|
248
|
+
{capturedKeys.map((key) => (
|
|
249
|
+
<DetailItem key={key} label={CAPTURED_FIELD_LABELS[key]}>{call.captured[key]}</DetailItem>
|
|
250
|
+
))}
|
|
251
|
+
</DetailList>
|
|
252
|
+
</DrawerSection>
|
|
253
|
+
|
|
254
|
+
{/* Rarely needed, so it starts collapsed and closes the panel. */}
|
|
255
|
+
<Collapsible>
|
|
256
|
+
<CollapsibleTrigger className="group flex min-h-10 w-full items-center justify-between border-t border-tertiary font-medium text-on-white-primary">
|
|
257
|
+
Call metadata
|
|
258
|
+
<ChevronDown aria-hidden className="size-4 text-on-white-secondary transition-transform duration-150 group-data-[state=open]:rotate-180 motion-reduce:transition-none" />
|
|
259
|
+
</CollapsibleTrigger>
|
|
260
|
+
<CollapsibleContent>
|
|
261
|
+
<DetailList>
|
|
262
|
+
<DetailItem label="Call ID">{call.metadata.callId}</DetailItem>
|
|
263
|
+
<DetailItem label="Agent">{call.metadata.agent}</DetailItem>
|
|
264
|
+
<DetailItem label="Caller ID">{call.metadata.callerId}</DetailItem>
|
|
265
|
+
<DetailItem label="Provider">{call.metadata.provider}</DetailItem>
|
|
266
|
+
</DetailList>
|
|
267
|
+
</CollapsibleContent>
|
|
268
|
+
</Collapsible>
|
|
269
|
+
</DrawerBody>
|
|
270
|
+
</>
|
|
271
|
+
)}
|
|
272
|
+
</Drawer>
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function CallLogsScreen() {
|
|
277
|
+
const [tab, setTab] = React.useState("Call logs");
|
|
278
|
+
const [search, setSearch] = React.useState("");
|
|
279
|
+
const [searchOpen, setSearchOpen] = React.useState(false);
|
|
280
|
+
const [filters, setFilters] = React.useState(INITIAL_FILTERS);
|
|
281
|
+
const [openId, setOpenId] = React.useState<string | null>(null);
|
|
282
|
+
const [page, setPage] = React.useState(1);
|
|
283
|
+
const rowsRef = React.useRef<HTMLDivElement>(null);
|
|
284
|
+
const pageSize = useRowsThatFit(rowsRef);
|
|
285
|
+
|
|
286
|
+
const status = filters.find((group) => group.id === "status")?.value ?? "all";
|
|
287
|
+
const query = search.replace(/\s/g, "");
|
|
288
|
+
const visible = CALLS.filter((c) => (status === "all" || c.status === status) && (!query || c.number.replace(/\s/g, "").includes(query)));
|
|
289
|
+
const pages = Math.max(1, Math.ceil(visible.length / pageSize));
|
|
290
|
+
const current = Math.min(page, pages);
|
|
291
|
+
const pageRows = visible.slice((current - 1) * pageSize, current * pageSize);
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<div className="flex h-screen bg-surface p-3 text-foreground">
|
|
295
|
+
{/* The app window: one rounded surface on the WG3 backdrop. */}
|
|
296
|
+
<div className="flex min-w-0 flex-1 overflow-hidden rounded-window border border-border bg-card shadow-window">
|
|
297
|
+
<section aria-label="12-09-2026" className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
298
|
+
{/* The breadcrumb is the title; status sits beside it, campaign actions on the right. */}
|
|
299
|
+
<div className="flex h-16 shrink-0 items-center border-b border-border px-6">
|
|
300
|
+
<Breadcrumbs className="flex-1"
|
|
301
|
+
meta={<Chip color="success">Completed</Chip>}
|
|
302
|
+
actions={
|
|
303
|
+
<>
|
|
304
|
+
<Button variant="outline">Test call</Button>
|
|
305
|
+
<Button>Launch</Button>
|
|
306
|
+
</>
|
|
307
|
+
}
|
|
308
|
+
>
|
|
309
|
+
<BreadcrumbItem href="#campaigns">Voice campaigns</BreadcrumbItem>
|
|
310
|
+
<BreadcrumbItem isCurrent>12-09-2026</BreadcrumbItem>
|
|
311
|
+
</Breadcrumbs>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
<div className="flex items-center justify-between gap-4 px-6 pt-5">
|
|
315
|
+
<Tabs className="min-w-0 flex-1" value={tab} onValueChange={setTab}>
|
|
316
|
+
<TabList aria-label="Campaign sections">
|
|
317
|
+
{TABS.map((item) => (
|
|
318
|
+
<Tab key={item} value={item} count={item === "Call logs" ? CALLS.length : undefined}>
|
|
319
|
+
{item}
|
|
320
|
+
</Tab>
|
|
321
|
+
))}
|
|
322
|
+
</TabList>
|
|
323
|
+
</Tabs>
|
|
324
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
325
|
+
<SearchDisclosure value={search} onValueChange={(value) => { setSearch(value); setPage(1); }} open={searchOpen} onOpenChange={setSearchOpen} placeholder="Search numbers" triggerLabel="Search calls" />
|
|
326
|
+
<Filters
|
|
327
|
+
triggerLabel="Filter calls"
|
|
328
|
+
groups={filters}
|
|
329
|
+
onChange={(id, value) => { setFilters((groups) => groups.map((group) => (group.id === id ? { ...group, value } : group))); setPage(1); }}
|
|
330
|
+
/>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
<div ref={rowsRef} className="min-h-0 flex-1 px-6 pt-4 pb-6">
|
|
335
|
+
<Table
|
|
336
|
+
aria-label="Call logs"
|
|
337
|
+
footer={
|
|
338
|
+
<Pagination
|
|
339
|
+
className="w-full"
|
|
340
|
+
total={pages}
|
|
341
|
+
page={current}
|
|
342
|
+
onChange={setPage}
|
|
343
|
+
summary={`${visible.length === 0 ? 0 : (current - 1) * pageSize + 1}–${Math.min(current * pageSize, visible.length)} of ${visible.length} calls`}
|
|
344
|
+
/>
|
|
345
|
+
}
|
|
346
|
+
>
|
|
347
|
+
<TableHeader>
|
|
348
|
+
<TableRow>
|
|
349
|
+
<TableColumn>Number</TableColumn>
|
|
350
|
+
<TableColumn>Status</TableColumn>
|
|
351
|
+
<TableColumn>Outcome</TableColumn>
|
|
352
|
+
<TableColumn align="right">Duration</TableColumn>
|
|
353
|
+
</TableRow>
|
|
354
|
+
</TableHeader>
|
|
355
|
+
<TableBody>
|
|
356
|
+
{pageRows.map((row) => (
|
|
357
|
+
<TableRow
|
|
358
|
+
key={row.id}
|
|
359
|
+
rowKey={row.id}
|
|
360
|
+
aria-label={`Open call with ${row.number}`}
|
|
361
|
+
isActive={openId === row.id}
|
|
362
|
+
onClick={() => setOpenId((id) => (id === row.id ? null : row.id))}
|
|
363
|
+
>
|
|
364
|
+
<TableCell className="tabular-nums">{row.number}</TableCell>
|
|
365
|
+
<TableCell>
|
|
366
|
+
<Chip variant="plain" color={CALL_STATUS_COLOR[row.status]}>{row.status}</Chip>
|
|
367
|
+
</TableCell>
|
|
368
|
+
<TableCell className={row.outcome ? undefined : "text-on-white-secondary"}>{row.outcome ?? "—"}</TableCell>
|
|
369
|
+
<TableCell align="right" className="tabular-nums">{seconds(row.seconds)}</TableCell>
|
|
370
|
+
</TableRow>
|
|
371
|
+
))}
|
|
372
|
+
</TableBody>
|
|
373
|
+
</Table>
|
|
374
|
+
</div>
|
|
375
|
+
</section>
|
|
376
|
+
|
|
377
|
+
<CallPanel call={CALLS.find((c) => c.id === openId)} onClose={() => setOpenId(null)} />
|
|
378
|
+
</div>
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
@@ -5,13 +5,17 @@
|
|
|
5
5
|
* Follows principles `leading-back-navigation`, `analytics-surfaces` and
|
|
6
6
|
* `status-language` (see `get_principles`):
|
|
7
7
|
*
|
|
8
|
-
* - The
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
15
19
|
* - A chart card puts its title + scope subtitle on the left and the legend at
|
|
16
20
|
* the right of the same row (`legendPlacement="header"`), and prints its
|
|
17
21
|
* values on the chart — never hover-only.
|
|
@@ -19,23 +23,28 @@
|
|
|
19
23
|
* Copy it and replace the sample STATS / SEGMENT / REACH data with your own.
|
|
20
24
|
*/
|
|
21
25
|
import * as React from "react";
|
|
26
|
+
import { Calendar } from "@actioneer/ads/lib/icons";
|
|
22
27
|
import {
|
|
23
28
|
BarChart,
|
|
24
29
|
BreadcrumbItem,
|
|
25
30
|
Breadcrumbs,
|
|
26
31
|
Button,
|
|
27
32
|
LineChart,
|
|
33
|
+
FilterBar,
|
|
34
|
+
FilterBarSelect,
|
|
35
|
+
FilterBarValue,
|
|
28
36
|
Progress,
|
|
37
|
+
Sparkline,
|
|
29
38
|
Tab,
|
|
30
39
|
TabList,
|
|
31
40
|
Tabs,
|
|
32
41
|
} from "@actioneer/ads";
|
|
33
42
|
|
|
34
43
|
export const STATS = [
|
|
35
|
-
{ label: "Calls analysed", value: 3930, of: "of 4,800 calls", percent: 82, color: "primary" as const },
|
|
36
|
-
{ label: "Recipients reached", value: 864, of: "of 4,800 people", percent: 18, color: "primary" as const },
|
|
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] },
|
|
37
46
|
// The one number that needs attention is the only amber one.
|
|
38
|
-
{ label: "Asked for a callback", value: 144, of: "of 864 calls", percent: 17, color: "warning" as const },
|
|
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] },
|
|
39
48
|
];
|
|
40
49
|
|
|
41
50
|
export const SEGMENT = [
|
|
@@ -56,10 +65,22 @@ export const REACH = [
|
|
|
56
65
|
|
|
57
66
|
const TABS = ["Script", "Workflow", "Analysis", "Call logs", "Guardrails"];
|
|
58
67
|
|
|
59
|
-
|
|
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`). */
|
|
60
81
|
function DataCard({ children, label }: { children: React.ReactNode; label: string }) {
|
|
61
82
|
return (
|
|
62
|
-
<section aria-label={label} className="flex min-w-0 flex-col rounded-
|
|
83
|
+
<section aria-label={label} className="flex min-w-0 flex-col rounded-card border border-border bg-card p-5 shadow-card">
|
|
63
84
|
{children}
|
|
64
85
|
</section>
|
|
65
86
|
);
|
|
@@ -67,72 +88,93 @@ function DataCard({ children, label }: { children: React.ReactNode; label: strin
|
|
|
67
88
|
|
|
68
89
|
export function CampaignAnalysisScreen() {
|
|
69
90
|
const [tab, setTab] = React.useState("Analysis");
|
|
91
|
+
const [period, setPeriod] = React.useState("campaign");
|
|
92
|
+
const [cohort, setCohort] = React.useState("all");
|
|
70
93
|
|
|
71
94
|
return (
|
|
72
|
-
<div className="flex h-screen bg-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<Tabs value={tab} onValueChange={setTab}>
|
|
83
|
-
<TabList aria-label="Campaign sections">
|
|
84
|
-
{TABS.map((item) => (
|
|
85
|
-
<Tab key={item} value={item}>{item}</Tab>
|
|
86
|
-
))}
|
|
87
|
-
</TabList>
|
|
88
|
-
</Tabs>
|
|
89
|
-
</div>
|
|
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>
|
|
90
105
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
<span className="text-m text-secondary">{stat.of}</span>
|
|
100
|
-
</p>
|
|
101
|
-
<Progress className="mt-3" value={stat.percent} color={stat.color} label={stat.label} />
|
|
102
|
-
</DataCard>
|
|
103
|
-
</div>
|
|
104
|
-
))}
|
|
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>
|
|
105
114
|
</div>
|
|
106
115
|
|
|
107
|
-
<div className="
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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>
|
|
133
175
|
</div>
|
|
134
|
-
</
|
|
135
|
-
</
|
|
176
|
+
</main>
|
|
177
|
+
</div>
|
|
136
178
|
</div>
|
|
137
179
|
);
|
|
138
180
|
}
|