@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
|
@@ -4,19 +4,25 @@
|
|
|
4
4
|
* Follows principles `collection-screens`, `record-detail-panels`,
|
|
5
5
|
* `status-language` and `tabs-search-opposed` (see `get_principles`):
|
|
6
6
|
*
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
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
|
|
9
10
|
* icon triggers trailing — then the Table.
|
|
10
11
|
* - The rows fill the panel: the page size is derived from the height that is
|
|
11
12
|
* left, so Pagination always sits on the panel's bottom edge.
|
|
12
13
|
* - Row checkboxes appear on hover (the header checkbox is always visible).
|
|
13
14
|
* - Clicking a row opens its detail panel beside the list and marks the row
|
|
14
|
-
* with `isActive` — no radio, no modal.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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.
|
|
20
26
|
*
|
|
21
27
|
* In a full app this screen sits inside the shell, next to the Sidebar
|
|
22
28
|
* (put the customer's logo in its brand slot with SidebarWorkspace).
|
|
@@ -24,16 +30,19 @@
|
|
|
24
30
|
*/
|
|
25
31
|
import * as React from "react";
|
|
26
32
|
import {
|
|
27
|
-
Avatar,
|
|
28
33
|
BodyPageHeader,
|
|
29
34
|
Button,
|
|
30
35
|
Checkbox,
|
|
31
36
|
Chip,
|
|
32
37
|
Drawer,
|
|
38
|
+
Field,
|
|
33
39
|
DrawerBody,
|
|
34
40
|
DrawerFooter,
|
|
35
41
|
DrawerHeader,
|
|
42
|
+
DrawerSection,
|
|
36
43
|
DrawerTitle,
|
|
44
|
+
DetailItem,
|
|
45
|
+
DetailList,
|
|
37
46
|
FileDropzone,
|
|
38
47
|
Filters,
|
|
39
48
|
InputField,
|
|
@@ -44,8 +53,10 @@ import {
|
|
|
44
53
|
ModalHeader,
|
|
45
54
|
ModalTitle,
|
|
46
55
|
Pagination,
|
|
56
|
+
Progress,
|
|
47
57
|
SearchDisclosure,
|
|
48
58
|
SegmentedControl,
|
|
59
|
+
Sparkline,
|
|
49
60
|
Tab,
|
|
50
61
|
TabList,
|
|
51
62
|
Table,
|
|
@@ -168,6 +179,10 @@ function findCampaign(id: string | null): CampaignRecord | undefined {
|
|
|
168
179
|
}
|
|
169
180
|
|
|
170
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);
|
|
171
186
|
return (
|
|
172
187
|
<>
|
|
173
188
|
<TableCell>{campaign.name}</TableCell>
|
|
@@ -175,12 +190,30 @@ function CampaignCells({ campaign }: { campaign: CampaignRecord }) {
|
|
|
175
190
|
<Chip color={STATUS_COLOR[campaign.status]}>{campaign.status}</Chip>
|
|
176
191
|
</TableCell>
|
|
177
192
|
<TableCell align="right" className="tabular-nums">{campaign.leads.toLocaleString("en-IN")}</TableCell>
|
|
178
|
-
<TableCell>
|
|
179
|
-
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
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
|
+
)}
|
|
183
215
|
</TableCell>
|
|
216
|
+
<TableCell>{campaign.owner}</TableCell>
|
|
184
217
|
</>
|
|
185
218
|
);
|
|
186
219
|
}
|
|
@@ -188,34 +221,34 @@ function CampaignCells({ campaign }: { campaign: CampaignRecord }) {
|
|
|
188
221
|
/** Selection-driven detail panel (principle `record-detail-panels`). */
|
|
189
222
|
export function CampaignDetail({ campaign, onClose }: { campaign: CampaignRecord | undefined; onClose: () => void }) {
|
|
190
223
|
return (
|
|
191
|
-
<Drawer
|
|
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
|
+
>
|
|
192
231
|
{campaign && (
|
|
193
232
|
<>
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
<DrawerTitle>{campaign.name}</DrawerTitle>
|
|
197
|
-
{/* Status lives with the title, so it never floats on its own. */}
|
|
198
|
-
<div className="mt-1 flex items-center gap-2">
|
|
199
|
-
<Chip color={STATUS_COLOR[campaign.status]}>{campaign.status}</Chip>
|
|
200
|
-
<span className="text-s text-secondary">{campaign.context}</span>
|
|
201
|
-
</div>
|
|
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>
|
|
202
236
|
</DrawerHeader>
|
|
203
|
-
<DrawerBody className="flex flex-col gap-8
|
|
204
|
-
<
|
|
205
|
-
<h3 className="mb-1 text-m font-medium text-foreground">Performance</h3>
|
|
237
|
+
<DrawerBody className="flex flex-col gap-8">
|
|
238
|
+
<DrawerSection title="Performance">
|
|
206
239
|
<dl className="grid grid-cols-3 gap-2">
|
|
207
240
|
{[
|
|
208
241
|
{ label: "Leads", value: campaign.leads.toLocaleString("en-IN") },
|
|
209
242
|
{ label: "Connect rate", value: campaign.connectRate },
|
|
210
243
|
{ label: "Booked", value: campaign.booked.toLocaleString("en-IN") },
|
|
211
244
|
].map((stat) => (
|
|
212
|
-
<div key={stat.label} className="min-w-0 rounded-
|
|
245
|
+
<div key={stat.label} className="min-w-0 rounded-card border border-border bg-wg1 p-3">
|
|
213
246
|
<dt className="truncate text-s text-secondary">{stat.label}</dt>
|
|
214
247
|
<dd className="mt-0.5 text-xl tabular-nums text-foreground">{stat.value}</dd>
|
|
215
248
|
</div>
|
|
216
249
|
))}
|
|
217
250
|
</dl>
|
|
218
|
-
<div className="rounded-
|
|
251
|
+
<div className="rounded-card border border-border p-4">
|
|
219
252
|
<LineChart
|
|
220
253
|
title={<span className="text-s font-normal text-secondary">Connected calls · last 7 days</span>}
|
|
221
254
|
data={campaign.week.map((calls, index) => ({ day: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][index], calls }))}
|
|
@@ -227,22 +260,22 @@ export function CampaignDetail({ campaign, onClose }: { campaign: CampaignRecord
|
|
|
227
260
|
height={160}
|
|
228
261
|
/>
|
|
229
262
|
</div>
|
|
230
|
-
</
|
|
231
|
-
<
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
</
|
|
245
|
-
</
|
|
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>
|
|
246
279
|
</DrawerBody>
|
|
247
280
|
<DrawerFooter>
|
|
248
281
|
<Button variant="outline">Duplicate</Button>
|
|
@@ -255,59 +288,79 @@ export function CampaignDetail({ campaign, onClose }: { campaign: CampaignRecord
|
|
|
255
288
|
}
|
|
256
289
|
|
|
257
290
|
const SAVED_AUDIENCES = [
|
|
258
|
-
{ id: "0019b431", name: "CSV audience 0019b431", size: 6 },
|
|
259
|
-
{ id: "b190b191", name: "CSV audience b190b191", size: 868 },
|
|
260
|
-
{ id: "5ee46ae6", name: "CSV audience 5ee46ae6", size: 162 },
|
|
261
|
-
{ id: "9dc3d0b1", name: "CSV audience 9dc3d0b1", size: 188 },
|
|
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 },
|
|
262
295
|
];
|
|
263
296
|
|
|
264
|
-
/** Create task launched from the collection: a Modal over the list (principle `
|
|
297
|
+
/** Create task launched from the collection: a fixed-height Modal over the list (principle `create-modals`). */
|
|
265
298
|
export function NewCampaignModal({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
|
266
299
|
const [name, setName] = React.useState("");
|
|
267
300
|
const [useNumber, setUseNumber] = React.useState(false);
|
|
268
301
|
const [audience, setAudience] = React.useState("csv");
|
|
269
302
|
const [query, setQuery] = React.useState("");
|
|
303
|
+
const [picked, setPicked] = React.useState<Set<string>>(new Set(["b190b191"]));
|
|
270
304
|
const audiences = SAVED_AUDIENCES.filter((item) => item.name.toLowerCase().includes(query.trim().toLowerCase()));
|
|
271
305
|
|
|
272
306
|
return (
|
|
273
|
-
<Modal open={open} onOpenChange={onOpenChange}
|
|
307
|
+
<Modal open={open} onOpenChange={onOpenChange}>
|
|
274
308
|
<ModalHeader>
|
|
275
309
|
<ModalTitle>New campaign</ModalTitle>
|
|
276
310
|
</ModalHeader>
|
|
277
311
|
<ModalBody className="flex flex-col gap-6">
|
|
278
312
|
<InputField label="Campaign name" value={name} onChange={(event) => setName(event.target.value)} placeholder="First-EMI onboarding" />
|
|
279
|
-
|
|
280
|
-
|
|
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
|
+
>
|
|
281
319
|
<div className="flex min-h-12 items-center gap-3 rounded-m border border-input px-3">
|
|
282
320
|
<Checkbox aria-label="+91 22 6987 0900" checked={useNumber} onChange={(event) => setUseNumber(event.target.checked)} />
|
|
283
321
|
<span className="flex-1 text-m tabular-nums text-foreground">+91 22 6987 0900</span>
|
|
284
322
|
<Chip>Plivo</Chip>
|
|
285
323
|
</div>
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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>
|
|
311
364
|
</ModalBody>
|
|
312
365
|
<ModalFooter>
|
|
313
366
|
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
|
|
@@ -346,94 +399,99 @@ export function CampaignsScreen() {
|
|
|
346
399
|
value === "all" ? CAMPAIGNS.length : CAMPAIGNS.filter((c) => c.status === value).length;
|
|
347
400
|
|
|
348
401
|
return (
|
|
349
|
-
<div className="flex h-screen
|
|
350
|
-
|
|
351
|
-
|
|
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>} />
|
|
352
407
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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>
|
|
378
434
|
</div>
|
|
379
|
-
</div>
|
|
380
435
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
<TableBody>
|
|
407
|
-
{pageRows.map((campaign) => (
|
|
408
|
-
<TableRow
|
|
409
|
-
key={campaign.id}
|
|
410
|
-
rowKey={campaign.id}
|
|
411
|
-
aria-label={`Open ${campaign.name}`}
|
|
412
|
-
isActive={openId === campaign.id}
|
|
413
|
-
onClick={() => setOpenId((id) => (id === campaign.id ? null : campaign.id))}
|
|
414
|
-
childRows={campaign.cohorts?.map((cohort) => (
|
|
415
|
-
<TableRow
|
|
416
|
-
key={cohort.id}
|
|
417
|
-
rowKey={cohort.id}
|
|
418
|
-
aria-label={`Open ${cohort.name}`}
|
|
419
|
-
isActive={openId === cohort.id}
|
|
420
|
-
onClick={() => setOpenId((id) => (id === cohort.id ? null : cohort.id))}
|
|
421
|
-
>
|
|
422
|
-
<CampaignCells campaign={cohort} />
|
|
423
|
-
</TableRow>
|
|
424
|
-
))}
|
|
425
|
-
defaultExpanded={Boolean(campaign.cohorts)}
|
|
426
|
-
>
|
|
427
|
-
<CampaignCells campaign={campaign} />
|
|
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>
|
|
428
461
|
</TableRow>
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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>
|
|
434
491
|
|
|
435
|
-
|
|
436
|
-
|
|
492
|
+
<CampaignDetail campaign={findCampaign(openId)} onClose={() => setOpenId(null)} />
|
|
493
|
+
<NewCampaignModal open={creating} onOpenChange={setCreating} />
|
|
494
|
+
</div>
|
|
437
495
|
</div>
|
|
438
496
|
);
|
|
439
497
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guardrails settings — the ADS settings-screen recipe, built only from ADS
|
|
3
|
+
* exports.
|
|
4
|
+
*
|
|
5
|
+
* Follows principle `settings-screens` (see `get_principles`):
|
|
6
|
+
*
|
|
7
|
+
* - One white page in the app window: BodyPageHeader (a 64px bar with the
|
|
8
|
+
* title, the one save action and a full-width hairline), then the setting
|
|
9
|
+
* groups 32px apart.
|
|
10
|
+
* - Every group FILLS the panel's width between its 24px side insets — never a
|
|
11
|
+
* fixed reading-width column — so each row's control lines up on the panel's
|
|
12
|
+
* right edge under the save action.
|
|
13
|
+
* - A group is a 16px Medium title over a SettingList; each SettingRow reads
|
|
14
|
+
* bare glyph → label + one-line description → the control that changes it
|
|
15
|
+
* (Switch, a Button showing the current value, or a Badge).
|
|
16
|
+
*
|
|
17
|
+
* Copy it and replace GROUPS with your own settings.
|
|
18
|
+
*/
|
|
19
|
+
import * as React from "react";
|
|
20
|
+
import {
|
|
21
|
+
Badge,
|
|
22
|
+
BodyPageHeader,
|
|
23
|
+
Button,
|
|
24
|
+
SettingList,
|
|
25
|
+
SettingRow,
|
|
26
|
+
Switch,
|
|
27
|
+
} from "@actioneer/ads";
|
|
28
|
+
import { Call, CheckCircle, FileText, Info, Refresh } from "@actioneer/ads/lib/icons";
|
|
29
|
+
|
|
30
|
+
type Control =
|
|
31
|
+
| { kind: "switch"; on: boolean }
|
|
32
|
+
| { kind: "button"; label: string }
|
|
33
|
+
| { kind: "badge"; label: string };
|
|
34
|
+
|
|
35
|
+
interface Setting {
|
|
36
|
+
id: string;
|
|
37
|
+
icon: React.ReactNode;
|
|
38
|
+
label: string;
|
|
39
|
+
description: string;
|
|
40
|
+
control: Control;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const GROUPS: { title: string; settings: Setting[] }[] = [
|
|
44
|
+
{
|
|
45
|
+
title: "Calling window",
|
|
46
|
+
settings: [
|
|
47
|
+
{ id: "hours", icon: <Call />, label: "Calling hours", description: "Leads are dialled only inside this window", control: { kind: "button", label: "10:00 – 19:00" } },
|
|
48
|
+
{ id: "retry", icon: <Refresh />, label: "Retry unanswered calls", description: "Up to 3 attempts, 2 hours apart", control: { kind: "switch", on: true } },
|
|
49
|
+
{ id: "dnd", icon: <CheckCircle />, label: "Respect the DND registry", description: "Skip numbers on the national do-not-call list", control: { kind: "switch", on: true } },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
title: "Compliance",
|
|
54
|
+
settings: [
|
|
55
|
+
{ id: "rates", icon: <Info />, label: "Block rate quotes", description: "The agent never quotes a rate outside the approved sheet", control: { kind: "switch", on: true } },
|
|
56
|
+
{ id: "disclosure", icon: <FileText />, label: "Disclosure script", description: "Read at the start of every call", control: { kind: "button", label: "Edit" } },
|
|
57
|
+
{ id: "talk-over", icon: <Info />, label: "Talk-over limit", description: "Flag calls where the agent talks over the lead for 2s or more", control: { kind: "badge", label: "2s" } },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
title: "Escalation",
|
|
62
|
+
settings: [
|
|
63
|
+
{ id: "handoff", icon: <Call />, label: "Hand off to a person", description: "When the lead asks for a person twice", control: { kind: "switch", on: false } },
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export function GuardrailsSettingsScreen() {
|
|
69
|
+
const [switches, setSwitches] = React.useState<Record<string, boolean>>(() =>
|
|
70
|
+
Object.fromEntries(
|
|
71
|
+
GROUPS.flatMap((group) => group.settings)
|
|
72
|
+
.filter((setting) => setting.control.kind === "switch")
|
|
73
|
+
.map((setting) => [setting.id, (setting.control as { on: boolean }).on]),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const control = (setting: Setting) => {
|
|
78
|
+
switch (setting.control.kind) {
|
|
79
|
+
case "switch":
|
|
80
|
+
return (
|
|
81
|
+
<Switch
|
|
82
|
+
aria-label={setting.label}
|
|
83
|
+
checked={switches[setting.id]}
|
|
84
|
+
onCheckedChange={(on) => setSwitches((current) => ({ ...current, [setting.id]: on }))}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
case "button":
|
|
88
|
+
return <Button variant="outline">{setting.control.label}</Button>;
|
|
89
|
+
case "badge":
|
|
90
|
+
return <Badge color="success">{setting.control.label}</Badge>;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
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
|
+
<section aria-label="Guardrails" className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
99
|
+
<BodyPageHeader title="Guardrails" actions={<Button>Save changes</Button>} />
|
|
100
|
+
<div className="flex min-h-0 flex-1 flex-col gap-8 overflow-y-auto px-6 pb-6">
|
|
101
|
+
{GROUPS.map((group) => (
|
|
102
|
+
// w-full: the group fills the panel — no max-width column.
|
|
103
|
+
<section key={group.title} aria-label={group.title} className="flex w-full flex-col gap-2">
|
|
104
|
+
<h2 className="m-0 text-l font-medium text-on-white-primary">{group.title}</h2>
|
|
105
|
+
<SettingList>
|
|
106
|
+
{group.settings.map((setting) => (
|
|
107
|
+
<SettingRow
|
|
108
|
+
key={setting.id}
|
|
109
|
+
icon={setting.icon}
|
|
110
|
+
label={setting.label}
|
|
111
|
+
description={setting.description}
|
|
112
|
+
action={control(setting)}
|
|
113
|
+
/>
|
|
114
|
+
))}
|
|
115
|
+
</SettingList>
|
|
116
|
+
</section>
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
</section>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
Binary file
|