@startsimpli/integrations 0.2.46 → 0.2.47

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.2.46",
3
+ "version": "0.2.47",
4
4
  "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps, plus foundry customer email (subpath ./comms).",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -235,6 +235,23 @@ describe('product updates', () => {
235
235
  expect(r.html).toBe('<p>Hi Quinn</p>');
236
236
  });
237
237
 
238
+ it('per-send audience: GET/PUT drafts/{id}/audience/, POST .../preview/, GET audience/segments/', async () => {
239
+ const result = { audience: { base: 'all' }, is_default: true, preview: { count: 3, eligible: 3, excluded: {}, excluded_total: 0, sample: [], excluded_sample: [] } };
240
+ reply = () => HttpResponse.json(result);
241
+ await api().getDraftAudience('d-1');
242
+ expect([last().method, last().path]).toEqual(['GET', pathOf(`${P}/drafts/d-1/audience/`)]);
243
+ await api().saveDraftAudience('d-1', { base: 'inactive', days: 30 });
244
+ expect([last().method, last().path]).toEqual(['PUT', pathOf(`${P}/drafts/d-1/audience/`)]);
245
+ expect(last().body).toEqual({ audience: { base: 'inactive', days: 30 } });
246
+ await api().previewDraftAudience('d-1', { segment: { key: 'paying' } });
247
+ expect([last().method, last().path]).toEqual(['POST', pathOf(`${P}/drafts/d-1/audience/preview/`)]);
248
+ expect(last().body).toEqual({ audience: { segment: { key: 'paying' } } });
249
+ reply = () => HttpResponse.json({ available: false, segments: [] });
250
+ const segs = await api().listAudienceSegments();
251
+ expect(last().path).toBe(pathOf(`${P}/audience/segments/`));
252
+ expect(segs.available).toBe(false);
253
+ });
254
+
238
255
  // Shipped contract (updates lane): 202 with a placeholder draft still generating.
239
256
  it('POST programs/product_update/send-now/ with notes', async () => {
240
257
  reply = () => HttpResponse.json({ draft: draft({ generating: true, subject: '' }), reason: null }, { status: 202 });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Per-send audience on a pending draft (bd startsim-xkcnk): an "Audience"
3
+ * disclosure under each draft that shows who will receive it, lets the admin
4
+ * narrow it (activity, signup dates, a segment the foundry's backend defines,
5
+ * explicit exclusions), previews the count with an "excluded and why"
6
+ * breakdown, and saves it on the draft. Nothing is requested until it is opened.
7
+ */
8
+ import * as React from 'react';
9
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
10
+ import { render, screen, waitFor, within } from '@testing-library/react';
11
+ import userEvent from '@testing-library/user-event';
12
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
13
+ import { http, HttpResponse } from 'msw';
14
+ import { setupServer } from 'msw/node';
15
+
16
+ import { DraftReview } from '../components/DraftReview';
17
+ import { createCommsApi, setApiBase } from '../api/index';
18
+ import type { AudiencePreview, AudienceSpec, DraftAudience } from '../types/index';
19
+ import { BASE, P, SLUG, draft } from './fixtures';
20
+
21
+ interface Hit { method: string; path: string; body: any }
22
+ let hits: Hit[] = [];
23
+ let audience: DraftAudience;
24
+ let segmentsReply: () => Response;
25
+ let previewReply: (spec: AudienceSpec) => Response;
26
+
27
+ const rel = (p: string) => p.replace(new URL(P).pathname, '');
28
+ const preview = (over: Partial<AudiencePreview> = {}): AudiencePreview => ({
29
+ count: 42, eligible: 50, excluded: { opted_out: 5, suppressed: 3 }, excluded_total: 8,
30
+ sample: ['a@x.com', 'b@x.com'], excluded_sample: [{ email: 'gone@x.com', reason: 'opted_out' }], ...over,
31
+ });
32
+
33
+ const server = setupServer(
34
+ http.all(`${P}/*`, async ({ request }) => {
35
+ const url = new URL(request.url);
36
+ const text = request.method === 'GET' ? '' : await request.text();
37
+ const hit: Hit = { method: request.method, path: rel(url.pathname), body: text ? JSON.parse(text) : undefined };
38
+ hits.push(hit);
39
+ if (hit.path === '/drafts/') {
40
+ return HttpResponse.json({ count: 1, next: null, previous: null, results: [draft()] });
41
+ }
42
+ if (hit.path === '/audience/segments/') return segmentsReply();
43
+ if (hit.path === '/drafts/d-1/audience/' && hit.method === 'GET') return HttpResponse.json(audience);
44
+ if (hit.path === '/drafts/d-1/audience/' && hit.method === 'PUT') {
45
+ return HttpResponse.json({ audience: hit.body.audience, is_default: false, preview: preview() });
46
+ }
47
+ if (hit.path === '/drafts/d-1/audience/preview/') return previewReply(hit.body.audience);
48
+ return HttpResponse.json({});
49
+ }),
50
+ );
51
+
52
+ beforeAll(() => {
53
+ server.listen({ onUnhandledRequest: 'error' });
54
+ setApiBase(`${BASE}/api/v1`);
55
+ });
56
+ afterAll(() => server.close());
57
+ beforeEach(() => {
58
+ hits = [];
59
+ audience = { audience: { base: 'all' }, is_default: true, preview: preview() };
60
+ segmentsReply = () => HttpResponse.json({
61
+ available: true,
62
+ segments: [{ key: 'never_designed', label: 'Signed up, never made a design', description: '' },
63
+ { key: 'paying', label: 'Paying users', description: '' }],
64
+ });
65
+ previewReply = (spec) => HttpResponse.json({ audience: spec, is_default: false, preview: preview({ count: 7, excluded: {}, excluded_total: 0, excluded_sample: [] }) });
66
+ });
67
+ afterEach(() => server.resetHandlers());
68
+
69
+ async function openAudience() {
70
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
71
+ const user = userEvent.setup();
72
+ render(
73
+ <QueryClientProvider client={qc}>
74
+ <DraftReview api={createCommsApi(SLUG)} pollIntervalMs={20} />
75
+ </QueryClientProvider>,
76
+ );
77
+ const trigger = await screen.findByRole('button', { name: 'Audience' });
78
+ return { user, trigger };
79
+ }
80
+
81
+ const previews = () => hits.filter((h) => h.path === '/drafts/d-1/audience/preview/');
82
+
83
+ describe('Audience panel', () => {
84
+ it('asks for nothing until it is opened, then shows who receives it and why others are excluded', async () => {
85
+ const { user, trigger } = await openAudience();
86
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
87
+ expect(hits.some((h) => h.path.includes('audience'))).toBe(false);
88
+ await user.click(trigger);
89
+ const region = await screen.findByRole('region', { name: 'Audience' });
90
+ expect(await within(region).findByText(/42 people will receive this/)).toBeInTheDocument();
91
+ expect(within(region).getByText(/8 excluded/)).toBeInTheDocument();
92
+ expect(within(region).getByText(/5 unsubscribed/)).toBeInTheDocument();
93
+ expect(within(region).getByText(/3 suppressed/)).toBeInTheDocument();
94
+ expect(within(region).getByText('a@x.com')).toBeInTheDocument();
95
+ expect(within(region).getByText(/Using the program default/)).toBeInTheDocument();
96
+ });
97
+
98
+ it('previews a narrower activity audience without saving', async () => {
99
+ const { user, trigger } = await openAudience();
100
+ await user.click(trigger);
101
+ const region = await screen.findByRole('region', { name: 'Audience' });
102
+ await user.selectOptions(await within(region).findByLabelText('Who'), 'inactive');
103
+ await waitFor(() => expect(previews().at(-1)?.body.audience).toMatchObject({ base: 'inactive', days: 14 }));
104
+ expect(await within(region).findByText(/7 people will receive this/)).toBeInTheDocument();
105
+ expect(hits.some((h) => h.method === 'PUT')).toBe(false);
106
+ });
107
+
108
+ it('lists the foundry\'s segments and previews the chosen one', async () => {
109
+ const { user, trigger } = await openAudience();
110
+ await user.click(trigger);
111
+ const region = await screen.findByRole('region', { name: 'Audience' });
112
+ await user.selectOptions(await within(region).findByLabelText('Only people in segment'), 'never_designed');
113
+ await waitFor(() => expect(previews().at(-1)?.body.audience.segment).toEqual({ key: 'never_designed' }));
114
+ });
115
+
116
+ it('says segments are unavailable when the foundry has no backend', async () => {
117
+ segmentsReply = () => HttpResponse.json({ available: false, segments: [] });
118
+ const { user, trigger } = await openAudience();
119
+ await user.click(trigger);
120
+ const region = await screen.findByRole('region', { name: 'Audience' });
121
+ expect(await within(region).findByText(/Segments need the foundry's own backend/)).toBeInTheDocument();
122
+ expect(within(region).queryByLabelText('Only people in segment')).toBeNull();
123
+ });
124
+
125
+ it('takes signup dates and an exclusion list, one address per line', async () => {
126
+ const { user, trigger } = await openAudience();
127
+ await user.click(trigger);
128
+ const region = await screen.findByRole('region', { name: 'Audience' });
129
+ await user.type(await within(region).findByLabelText('Signed up after'), '2026-10-01');
130
+ await user.type(within(region).getByLabelText('Never send to'), 'A@x.com{enter}b@x.com');
131
+ await waitFor(() => expect(previews().at(-1)?.body.audience).toMatchObject({
132
+ joined_after: '2026-10-01', exclude: { emails: ['a@x.com', 'b@x.com'] },
133
+ }));
134
+ });
135
+
136
+ it('saves the audience on the draft', async () => {
137
+ const { user, trigger } = await openAudience();
138
+ await user.click(trigger);
139
+ const region = await screen.findByRole('region', { name: 'Audience' });
140
+ await user.selectOptions(await within(region).findByLabelText('Who'), 'active');
141
+ await user.click(within(region).getByRole('button', { name: 'Save audience' }));
142
+ await waitFor(() => expect(hits.find((h) => h.method === 'PUT')?.body).toEqual({
143
+ audience: { base: 'active', days: 14 },
144
+ }));
145
+ });
146
+
147
+ it('shows the server\'s reason when the audience cannot be resolved', async () => {
148
+ previewReply = () => HttpResponse.json({ detail: 'the foundry\'s backend refused the segment request (500)' }, { status: 400 });
149
+ const { user, trigger } = await openAudience();
150
+ await user.click(trigger);
151
+ const region = await screen.findByRole('region', { name: 'Audience' });
152
+ await user.selectOptions(await within(region).findByLabelText('Who'), 'inactive');
153
+ expect(await within(region).findByRole('alert')).toHaveTextContent(/refused the segment request/);
154
+ });
155
+ });
@@ -28,7 +28,7 @@ export function setAuthTokenProvider(
28
28
  }
29
29
 
30
30
  export interface RequestOptions {
31
- method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
31
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
32
32
  body?: unknown;
33
33
  query?: Record<string, string | number | boolean | undefined | null>;
34
34
  }
@@ -21,7 +21,10 @@ import type {
21
21
  CommsAsset,
22
22
  CommsSettings,
23
23
  CommsSettingsPatch,
24
+ AudienceSegments,
25
+ AudienceSpec,
24
26
  Draft,
27
+ DraftAudience,
25
28
  DraftCreate,
26
29
  DraftPatch,
27
30
  DraftsQuery,
@@ -76,6 +79,11 @@ export interface CommsApi {
76
79
  approveDraft(id: string): Promise<Draft>;
77
80
  discardDraft(id: string): Promise<Draft>;
78
81
  previewDraft(id: string): Promise<RenderedEmail>;
82
+ // per-send audience: the draft's own spec (or the program default) + who it reaches
83
+ getDraftAudience(id: string): Promise<DraftAudience>;
84
+ saveDraftAudience(id: string, audience: AudienceSpec): Promise<DraftAudience>;
85
+ previewDraftAudience(id: string, audience: AudienceSpec): Promise<DraftAudience>;
86
+ listAudienceSegments(): Promise<AudienceSegments>;
79
87
  sendUpdateNow(input?: { notes?: string }): Promise<SendNowResult>;
80
88
 
81
89
  // results
@@ -129,6 +137,12 @@ export function createCommsApi(slug: string): CommsApi {
129
137
  approveDraft: (id) => request(draftPath(id, 'approve'), { method: 'POST' }),
130
138
  discardDraft: (id) => request(draftPath(id, 'discard'), { method: 'POST' }),
131
139
  previewDraft: (id) => request(draftPath(id, 'preview')),
140
+ getDraftAudience: (id) => request(draftPath(id, 'audience')),
141
+ saveDraftAudience: (id, audience) =>
142
+ request(draftPath(id, 'audience'), { method: 'PUT', body: { audience } }),
143
+ previewDraftAudience: (id, audience) =>
144
+ request(draftPath(id, 'audience/preview'), { method: 'POST', body: { audience } }),
145
+ listAudienceSegments: () => request(`${P}/audience/segments/`),
132
146
  sendUpdateNow: (input = {}) =>
133
147
  request(`${P}/programs/product_update/send-now/`, { method: 'POST', body: input }),
134
148
 
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Per-send audience for one pending draft (bd startsim-xkcnk): who receives it,
3
+ * narrowed by activity, signup dates and a segment the foundry's own backend
4
+ * defines, minus explicit exclusions. Shows the recipient count, a sample and an
5
+ * "excluded and why" breakdown before anyone approves; "Save audience" stores it
6
+ * on the draft. A segment can only narrow the foundry's customers.
7
+ */
8
+ import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
9
+ import { Button, Input, Label, Textarea } from '@startsimpli/ui';
10
+
11
+ import type { CommsApi } from '../api/comms';
12
+ import { useAudienceSegments, useDraftAudience, usePreviewAudience, useSaveAudience } from '../hooks/index';
13
+ import type { AudiencePreview, AudienceSegment, AudienceSpec, ExcludedReason } from '../types/index';
14
+ import { ErrorNotice, NativeSelect, Note, useFieldId } from './_shared';
15
+
16
+ const DEFAULT_DAYS = 14;
17
+ const DEBOUNCE_MS = 250;
18
+
19
+ const REASON_LABEL: Record<ExcludedReason, string> = {
20
+ no_email: 'with no email address',
21
+ excluded: 'excluded by you',
22
+ excluded_segment: 'in the excluded segment',
23
+ undeliverable: 'with an undeliverable address',
24
+ unverified: 'with an unverified email',
25
+ opted_out: 'unsubscribed',
26
+ suppressed: 'suppressed (bounce or complaint)',
27
+ };
28
+
29
+ interface Form {
30
+ base: 'all' | 'active' | 'inactive';
31
+ days: string;
32
+ after: string;
33
+ before: string;
34
+ segment: string;
35
+ excludeSegment: string;
36
+ exclude: string;
37
+ }
38
+
39
+ function toForm(spec: AudienceSpec): Form {
40
+ return {
41
+ base: spec.base ?? 'all',
42
+ days: String(spec.days ?? DEFAULT_DAYS),
43
+ after: (spec.joined_after ?? '').slice(0, 10),
44
+ before: (spec.joined_before ?? '').slice(0, 10),
45
+ segment: spec.segment?.key ?? '',
46
+ excludeSegment: spec.exclude?.segment?.key ?? '',
47
+ exclude: (spec.exclude?.emails ?? []).join('\n'),
48
+ };
49
+ }
50
+
51
+ export function parseEmails(text: string): string[] {
52
+ return [...new Set(text.split(/[\s,;]+/).map((e) => e.trim().toLowerCase()).filter(Boolean))];
53
+ }
54
+
55
+ export function toSpec(f: Form): AudienceSpec {
56
+ const spec: AudienceSpec = { base: f.base };
57
+ if (f.base !== 'all') spec.days = Math.max(1, Math.floor(Number(f.days)) || DEFAULT_DAYS);
58
+ if (f.after) spec.joined_after = f.after;
59
+ if (f.before) spec.joined_before = f.before;
60
+ if (f.segment) spec.segment = { key: f.segment };
61
+ const emails = parseEmails(f.exclude);
62
+ if (emails.length || f.excludeSegment) {
63
+ spec.exclude = {};
64
+ if (emails.length) spec.exclude.emails = emails;
65
+ if (f.excludeSegment) spec.exclude.segment = { key: f.excludeSegment };
66
+ }
67
+ return spec;
68
+ }
69
+
70
+ function Summary({ preview }: { preview: AudiencePreview }) {
71
+ const reasons = Object.entries(preview.excluded).filter(([, n]) => n) as [ExcludedReason, number][];
72
+ return (
73
+ <div className="space-y-2 rounded-md bg-muted/40 p-3 text-sm" aria-live="polite">
74
+ <p className="font-medium">
75
+ {preview.count} {preview.count === 1 ? 'person' : 'people'} will receive this
76
+ </p>
77
+ {preview.sample.length ? (
78
+ <ul className="flex flex-wrap gap-x-3 text-xs text-muted-foreground">
79
+ {preview.sample.map((e) => (
80
+ <li key={e}>{e}</li>
81
+ ))}
82
+ </ul>
83
+ ) : null}
84
+ {preview.excluded_total ? (
85
+ <div>
86
+ <p>{preview.excluded_total} excluded</p>
87
+ <ul className="list-disc pl-5 text-muted-foreground">
88
+ {reasons.map(([reason, n]) => (
89
+ <li key={reason}>
90
+ {n} {REASON_LABEL[reason] ?? reason}
91
+ </li>
92
+ ))}
93
+ </ul>
94
+ </div>
95
+ ) : null}
96
+ </div>
97
+ );
98
+ }
99
+
100
+ function SegmentSelect({ id, label, value, onChange, segments, none }: {
101
+ id: string; label: string; value: string; onChange: (v: string) => void; segments: AudienceSegment[]; none: string;
102
+ }) {
103
+ return (
104
+ <div className="space-y-1">
105
+ <Label htmlFor={id}>{label}</Label>
106
+ <NativeSelect id={id} value={value} onChange={(e) => onChange(e.target.value)}>
107
+ <option value="">{none}</option>
108
+ {segments.map((s) => (
109
+ <option key={s.key} value={s.key}>{s.label}</option>
110
+ ))}
111
+ </NativeSelect>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ function AudienceForm({ api, draftId }: { api: CommsApi; draftId: string }) {
117
+ const prefix = useFieldId(`comms-audience-${draftId}`);
118
+ const loaded = useDraftAudience(api, draftId, true);
119
+ const segments = useAudienceSegments(api, true);
120
+ const previewMut = usePreviewAudience(api, draftId);
121
+ const save = useSaveAudience(api, draftId);
122
+ const [form, setForm] = useState<Form | null>(null);
123
+ const [shown, setShown] = useState<AudiencePreview | null>(null);
124
+ const initial = useRef<string | null>(null);
125
+
126
+ useEffect(() => {
127
+ if (loaded.data && form === null) {
128
+ setForm(toForm(loaded.data.audience));
129
+ setShown(loaded.data.preview);
130
+ initial.current = JSON.stringify(toSpec(toForm(loaded.data.audience)));
131
+ }
132
+ }, [loaded.data, form]);
133
+
134
+ const spec = useMemo(() => (form ? toSpec(form) : null), [form]);
135
+ const specKey = spec ? JSON.stringify(spec) : null;
136
+ const { mutate } = previewMut;
137
+ useEffect(() => {
138
+ if (!spec || specKey === initial.current) return;
139
+ const t = setTimeout(() => {
140
+ mutate(spec, {
141
+ onSuccess: (d) => { setShown(d.preview); },
142
+ onError: () => setShown(null),
143
+ });
144
+ }, DEBOUNCE_MS);
145
+ return () => clearTimeout(t);
146
+ // spec is derived from specKey
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
148
+ }, [specKey, mutate]);
149
+
150
+ if (loaded.error) return <ErrorNotice error={loaded.error} />;
151
+ if (!form || !spec) return <Note>Loading the audience…</Note>;
152
+ const set = (patch: Partial<Form>) => setForm({ ...form, ...patch });
153
+ const dirty = specKey !== initial.current;
154
+ const available = segments.data?.available ?? false;
155
+ const list = segments.data?.segments ?? [];
156
+
157
+ return (
158
+ <div className="space-y-3">
159
+ {loaded.data?.is_default && !dirty ? <Note>Using the program default. Change anything below to set this send's own audience.</Note> : null}
160
+ <div className="flex flex-wrap items-end gap-3">
161
+ <div className="space-y-1">
162
+ <Label htmlFor={`${prefix}-who`}>Who</Label>
163
+ <NativeSelect id={`${prefix}-who`} value={form.base} onChange={(e) => set({ base: e.target.value as Form['base'] })}>
164
+ <option value="all">Everyone</option>
165
+ <option value="active">Seen recently</option>
166
+ <option value="inactive">Not seen for a while</option>
167
+ </NativeSelect>
168
+ </div>
169
+ {form.base !== 'all' ? (
170
+ <div className="space-y-1">
171
+ <Label htmlFor={`${prefix}-days`}>Within days</Label>
172
+ <Input id={`${prefix}-days`} type="number" min={1} className="w-24" value={form.days} onChange={(e) => set({ days: e.target.value })} />
173
+ </div>
174
+ ) : null}
175
+ <div className="space-y-1">
176
+ <Label htmlFor={`${prefix}-after`}>Signed up after</Label>
177
+ <Input id={`${prefix}-after`} type="date" value={form.after} onChange={(e) => set({ after: e.target.value })} />
178
+ </div>
179
+ <div className="space-y-1">
180
+ <Label htmlFor={`${prefix}-before`}>Signed up before</Label>
181
+ <Input id={`${prefix}-before`} type="date" value={form.before} onChange={(e) => set({ before: e.target.value })} />
182
+ </div>
183
+ </div>
184
+ {available ? (
185
+ <div className="flex flex-wrap gap-3">
186
+ <SegmentSelect id={`${prefix}-seg`} label="Only people in segment" value={form.segment}
187
+ onChange={(v) => set({ segment: v })} segments={list} none="Everyone (no segment)" />
188
+ <SegmentSelect id={`${prefix}-xseg`} label="Never send to segment" value={form.excludeSegment}
189
+ onChange={(v) => set({ excludeSegment: v })} segments={list} none="None" />
190
+ </div>
191
+ ) : segments.data ? (
192
+ <Note>Segments need the foundry's own backend, which is not deployed yet. Activity and signup date still work.</Note>
193
+ ) : null}
194
+ <ErrorNotice error={segments.error} />
195
+ <div className="space-y-1">
196
+ <Label htmlFor={`${prefix}-exclude`}>Never send to</Label>
197
+ <Textarea id={`${prefix}-exclude`} rows={3} placeholder="One email address per line"
198
+ value={form.exclude} onChange={(e) => set({ exclude: e.target.value })} />
199
+ </div>
200
+ {shown ? <Summary preview={shown} /> : null}
201
+ {previewMut.isPending ? <Note>Counting…</Note> : null}
202
+ <ErrorNotice error={previewMut.error ?? save.error} />
203
+ <div className="flex items-center gap-2">
204
+ <Button type="button" size="sm" disabled={save.isPending || !dirty}
205
+ onClick={() => save.mutate(spec, { onSuccess: () => { initial.current = specKey; setForm({ ...form }); } })}>
206
+ Save audience
207
+ </Button>
208
+ {save.isSuccess && !dirty ? <span className="text-sm text-muted-foreground">Saved.</span> : null}
209
+ </div>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ /** The open panel; the draft row owns the "Audience" button that shows it. */
215
+ export function AudiencePanel({ api, draftId, id }: { api: CommsApi; draftId: string; id?: string }): ReactElement {
216
+ return (
217
+ <div id={id} role="region" aria-label="Audience" className="mt-2 space-y-3 rounded-md border p-3">
218
+ <AudienceForm api={api} draftId={draftId} />
219
+ </div>
220
+ );
221
+ }
@@ -20,6 +20,7 @@ import {
20
20
  useUpdateDraft,
21
21
  } from '../hooks/index';
22
22
  import type { Draft, DraftPatch } from '../types/index';
23
+ import { AudiencePanel } from './AudiencePanel';
23
24
  import { EmailPreview } from './EmailPreview';
24
25
  import { ErrorNotice, Note, formatDate, humanize, useFieldId } from './_shared';
25
26
 
@@ -81,6 +82,7 @@ function DraftEditor({ api, draft, onDone }: { api: CommsApi; draft: Draft; onDo
81
82
  function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
82
83
  const [editing, setEditing] = useState(false);
83
84
  const [previewing, setPreviewing] = useState(false);
85
+ const [auditing, setAuditing] = useState(false);
84
86
  const ready = !draft.generating && !draft.error;
85
87
  const preview = useDraftPreview(api, draft.id, previewing && ready);
86
88
  const sendTest = useSendTest(api);
@@ -99,6 +101,15 @@ function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
99
101
  <>
100
102
  <Button type="button" size="sm" variant="outline" onClick={() => setPreviewing((v) => !v)}>Preview</Button>
101
103
  <Button type="button" size="sm" variant="outline" onClick={() => setEditing((v) => !v)}>Edit</Button>
104
+ <Button
105
+ type="button"
106
+ size="sm"
107
+ variant="outline"
108
+ aria-expanded={auditing}
109
+ onClick={() => setAuditing((v) => !v)}
110
+ >
111
+ Audience
112
+ </Button>
102
113
  <Button
103
114
  type="button"
104
115
  size="sm"
@@ -127,6 +138,7 @@ function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
127
138
  ) : null}
128
139
  <ErrorNotice error={sendTest.error ?? approve.error ?? discard.error ?? preview.error} />
129
140
  {editing && ready ? <DraftEditor api={api} draft={draft} onDone={() => setEditing(false)} /> : null}
141
+ {auditing && ready ? <AudiencePanel api={api} draftId={draft.id} /> : null}
130
142
  {previewing && preview.data ? <EmailPreview email={preview.data} /> : null}
131
143
  </li>
132
144
  );
@@ -9,3 +9,4 @@ export * from './ProductUpdates';
9
9
  export * from './DraftReview';
10
10
  export * from './ResultsPanel';
11
11
  export * from './SendLog';
12
+ export * from './AudiencePanel';
@@ -20,12 +20,15 @@ import {
20
20
  import type { CommsApi } from '../api/comms';
21
21
  import type {
22
22
  AssetsQuery,
23
+ AudienceSegments,
24
+ AudienceSpec,
23
25
  CommsAsset,
24
26
  CommsMetrics,
25
27
  CommsProgram,
26
28
  CommsSettings,
27
29
  CommsSettingsPatch,
28
30
  Draft,
31
+ DraftAudience,
29
32
  DraftCreate,
30
33
  DraftPatch,
31
34
  DraftsQuery,
@@ -55,6 +58,8 @@ export const commsKeys = {
55
58
  draftsAll: (slug: string) => ['startsimpli', 'comms', slug, 'drafts'] as const,
56
59
  drafts: (slug: string, q: DraftsQuery = {}) => ['startsimpli', 'comms', slug, 'drafts', q] as const,
57
60
  draft: (slug: string, id: string) => ['startsimpli', 'comms', slug, 'drafts', 'one', id] as const,
61
+ audience: (slug: string, id: string) => ['startsimpli', 'comms', slug, 'audience', id] as const,
62
+ audienceSegments: (slug: string) => ['startsimpli', 'comms', slug, 'audience-segments'] as const,
58
63
  draftPreview: (slug: string, id: string) => ['startsimpli', 'comms', slug, 'draft-preview', id] as const,
59
64
  sends: (slug: string, q: SendsQuery = {}) => ['startsimpli', 'comms', slug, 'sends', q] as const,
60
65
  metrics: (slug: string, q: MetricsQuery = {}) => ['startsimpli', 'comms', slug, 'metrics', q] as const,
@@ -210,6 +215,41 @@ export function useDraftPreview(api: CommsApi, id: string, enabled: boolean): Us
210
215
  });
211
216
  }
212
217
 
218
+ // per-send audience -----------------------------------------------------------
219
+
220
+ export function useDraftAudience(api: CommsApi, id: string, enabled: boolean): UseQueryResult<DraftAudience> {
221
+ return useQuery({
222
+ queryKey: commsKeys.audience(api.slug, id),
223
+ queryFn: () => api.getDraftAudience(id),
224
+ enabled,
225
+ staleTime: 0,
226
+ });
227
+ }
228
+
229
+ export function useAudienceSegments(api: CommsApi, enabled: boolean): UseQueryResult<AudienceSegments> {
230
+ return useQuery({
231
+ queryKey: commsKeys.audienceSegments(api.slug),
232
+ queryFn: () => api.listAudienceSegments(),
233
+ enabled,
234
+ });
235
+ }
236
+
237
+ /** Who a candidate audience would reach; nothing is saved. */
238
+ export function usePreviewAudience(api: CommsApi, id: string): UseMutationResult<DraftAudience, Error, AudienceSpec> {
239
+ return useMutation({ mutationFn: (spec: AudienceSpec) => api.previewDraftAudience(id, spec) });
240
+ }
241
+
242
+ export function useSaveAudience(api: CommsApi, id: string): UseMutationResult<DraftAudience, Error, AudienceSpec> {
243
+ const qc = useQueryClient();
244
+ return useMutation({
245
+ mutationFn: (spec: AudienceSpec) => api.saveDraftAudience(id, spec),
246
+ onSuccess: (data) => {
247
+ qc.setQueryData(commsKeys.audience(api.slug, id), data);
248
+ return qc.invalidateQueries({ queryKey: commsKeys.draftsAll(api.slug) });
249
+ },
250
+ });
251
+ }
252
+
213
253
  function useDraftsWrite<R, V>(api: CommsApi, fn: (v: V) => Promise<R>) {
214
254
  const qc = useQueryClient();
215
255
  return useMutation<R, Error, V>({
@@ -211,6 +211,64 @@ export interface Draft {
211
211
  error: string | null;
212
212
  /** True while a worker is still writing the content: poll getDraft(id). */
213
213
  generating: boolean;
214
+ /** This send's own audience; null/absent = the program default. */
215
+ audience?: AudienceSpec | null;
216
+ audience_is_default?: boolean;
217
+ /** Recipients queued, set at approve. */
218
+ recipient_count?: number | null;
219
+ }
220
+
221
+ /** A tenant-evaluated segment: the key (and params) the foundry's backend understands. */
222
+ export interface SegmentRef {
223
+ key: string;
224
+ params?: Record<string, unknown>;
225
+ }
226
+
227
+ /** Who one send goes to. Always narrows the foundry's customers; never widens. */
228
+ export interface AudienceSpec {
229
+ base?: 'all' | 'active' | 'inactive';
230
+ days?: number;
231
+ joined_after?: string;
232
+ joined_before?: string;
233
+ segment?: SegmentRef;
234
+ exclude?: { emails?: string[]; segment?: SegmentRef };
235
+ }
236
+
237
+ export type ExcludedReason =
238
+ | 'no_email'
239
+ | 'excluded'
240
+ | 'excluded_segment'
241
+ | 'undeliverable'
242
+ | 'unverified'
243
+ | 'opted_out'
244
+ | 'suppressed';
245
+
246
+ export interface AudiencePreview {
247
+ /** Who will receive it. */
248
+ count: number;
249
+ /** Matched the audience before mailability and exclusions. */
250
+ eligible: number;
251
+ excluded: Partial<Record<ExcludedReason, number>>;
252
+ excluded_total: number;
253
+ sample: string[];
254
+ excluded_sample: { email: string; reason: ExcludedReason }[];
255
+ }
256
+
257
+ export interface DraftAudience {
258
+ audience: AudienceSpec;
259
+ is_default: boolean;
260
+ preview: AudiencePreview;
261
+ }
262
+
263
+ export interface AudienceSegment {
264
+ key: string;
265
+ label: string;
266
+ description: string;
267
+ }
268
+
269
+ export interface AudienceSegments {
270
+ available: boolean;
271
+ segments: AudienceSegment[];
214
272
  }
215
273
 
216
274
  /** POST drafts/: either the email as written (201) or notes to write it from (202, generating). */