@startsimpli/integrations 0.2.45 → 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 +1 -1
- package/src/comms/__tests__/api.test.ts +17 -0
- package/src/comms/__tests__/audience-panel.test.tsx +155 -0
- package/src/comms/__tests__/image-library.test.tsx +230 -0
- package/src/comms/api/_fetch.ts +5 -3
- package/src/comms/api/comms.ts +29 -0
- package/src/comms/components/AnnouncementComposer.tsx +29 -3
- package/src/comms/components/AudiencePanel.tsx +221 -0
- package/src/comms/components/DraftReview.tsx +12 -0
- package/src/comms/components/ImageLibrary.tsx +115 -0
- package/src/comms/components/index.ts +1 -0
- package/src/comms/hooks/index.ts +66 -0
- package/src/comms/types/index.ts +75 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
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
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image library inside the Announcements fold (bd startsim-ktuhz, picker lane).
|
|
3
|
+
*
|
|
4
|
+
* "Images" is a small section of the composer: upload a picture with its alt
|
|
5
|
+
* text (alt is required), see the foundry's library as thumbnails, insert one
|
|
6
|
+
* into the message, delete one. Uploads are multipart POSTs to comms/assets/.
|
|
7
|
+
*
|
|
8
|
+
* Inserting writes `` on its own paragraph; saving the draft turns
|
|
9
|
+
* that into an <img> (escaped, max-width 100%) in the html and `[Image: alt]` in
|
|
10
|
+
* the plain-text part. The server sanitiser has the last word on which hosts
|
|
11
|
+
* survive.
|
|
12
|
+
*
|
|
13
|
+
* TEST ENVIRONMENT NOTE: jsdom's FormData is not recognised by Node's fetch (it is
|
|
14
|
+
* sent as "[object FormData]"), so the tests wrap fetch: a FormData body is read
|
|
15
|
+
* field by field and forwarded as JSON with an X-Test-Form header. That keeps
|
|
16
|
+
* "the request carries file and alt" under test; the multipart encoding itself
|
|
17
|
+
* is the browser's. The helper test below checks the real fetch call directly.
|
|
18
|
+
*/
|
|
19
|
+
import * as React from 'react';
|
|
20
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
21
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
22
|
+
import userEvent from '@testing-library/user-event';
|
|
23
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
24
|
+
import { http, HttpResponse } from 'msw';
|
|
25
|
+
import { setupServer } from 'msw/node';
|
|
26
|
+
|
|
27
|
+
import { CustomerEmailsPage } from '../components/pages/CustomerEmailsPage';
|
|
28
|
+
import { textToHtml, textToPlain } from '../components/AnnouncementComposer';
|
|
29
|
+
import { createCommsApi, setApiBase } from '../api/index';
|
|
30
|
+
import type { CommsAsset } from '../types/index';
|
|
31
|
+
import { BASE, P, SLUG, comeback, draft, productUpdate, settings } from './fixtures';
|
|
32
|
+
|
|
33
|
+
const asset = (over: Partial<CommsAsset> = {}): CommsAsset => ({
|
|
34
|
+
id: 'img-1',
|
|
35
|
+
key: 'crochets/img-1.png',
|
|
36
|
+
url: 'https://cdn.test/crochets/img-1.png',
|
|
37
|
+
filename: 'cat.png',
|
|
38
|
+
content_type: 'image/png',
|
|
39
|
+
bytes: 1234,
|
|
40
|
+
width: 60,
|
|
41
|
+
height: 40,
|
|
42
|
+
alt: 'A cat',
|
|
43
|
+
created_at: '2026-10-31T10:00:00Z',
|
|
44
|
+
...over,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
interface Hit { method: string; path: string; body: any; form?: Record<string, string> }
|
|
48
|
+
let hits: Hit[] = [];
|
|
49
|
+
let library: CommsAsset[] = [];
|
|
50
|
+
let uploadReply: () => Response;
|
|
51
|
+
|
|
52
|
+
const rel = (p: string) => p.replace(new URL(P).pathname, '');
|
|
53
|
+
|
|
54
|
+
const server = setupServer(
|
|
55
|
+
http.all(`${P}/*`, async ({ request }) => {
|
|
56
|
+
const url = new URL(request.url);
|
|
57
|
+
const isForm = request.headers.get('X-Test-Form') === '1';
|
|
58
|
+
let body: any;
|
|
59
|
+
let form: Hit['form'];
|
|
60
|
+
if (request.method !== 'GET') {
|
|
61
|
+
const t = await request.text();
|
|
62
|
+
const parsed = t ? JSON.parse(t) : undefined;
|
|
63
|
+
if (isForm) form = parsed;
|
|
64
|
+
else body = parsed;
|
|
65
|
+
}
|
|
66
|
+
const hit: Hit = { method: request.method, path: rel(url.pathname), body, form };
|
|
67
|
+
hits.push(hit);
|
|
68
|
+
if (hit.path === '/settings/') return HttpResponse.json(settings());
|
|
69
|
+
if (hit.path === '/programs/') return HttpResponse.json([comeback(), productUpdate()]);
|
|
70
|
+
if (hit.path === '/drafts/' && hit.method === 'POST') return HttpResponse.json(draft({ id: 'a-1' }), { status: 201 });
|
|
71
|
+
if (hit.path === '/drafts/') return HttpResponse.json({ count: 0, next: null, previous: null, results: [] });
|
|
72
|
+
if (hit.path === '/assets/' && hit.method === 'POST') return uploadReply();
|
|
73
|
+
if (hit.path === '/assets/') return HttpResponse.json({ count: library.length, next: null, previous: null, results: library });
|
|
74
|
+
const m = hit.path.match(/^\/assets\/([^/]+)\/$/);
|
|
75
|
+
if (m && hit.method === 'DELETE') {
|
|
76
|
+
library = library.filter((a) => a.id !== m[1]);
|
|
77
|
+
return new HttpResponse(null, { status: 204 });
|
|
78
|
+
}
|
|
79
|
+
return HttpResponse.json({});
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
let realFetch: typeof fetch;
|
|
84
|
+
let lastInit: RequestInit | undefined;
|
|
85
|
+
|
|
86
|
+
beforeAll(() => {
|
|
87
|
+
server.listen({ onUnhandledRequest: 'error' });
|
|
88
|
+
realFetch = globalThis.fetch; // msw's patched fetch, captured after listen()
|
|
89
|
+
vi.stubGlobal('fetch', (input: RequestInfo | URL, init?: RequestInit) => {
|
|
90
|
+
if (init?.body instanceof FormData) {
|
|
91
|
+
lastInit = init; // the upload call itself (later refetches must not overwrite it)
|
|
92
|
+
const entries = Object.fromEntries(init.body.entries());
|
|
93
|
+
const headers = { ...(init.headers as Record<string, string>), 'X-Test-Form': '1', 'Content-Type': 'application/json' };
|
|
94
|
+
return realFetch.call(globalThis, input, {
|
|
95
|
+
...init,
|
|
96
|
+
headers,
|
|
97
|
+
body: JSON.stringify({ file: (entries.file as File).name, alt: String(entries.alt) }),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return realFetch.call(globalThis, input, init);
|
|
101
|
+
});
|
|
102
|
+
setApiBase(`${BASE}/api/v1`);
|
|
103
|
+
});
|
|
104
|
+
afterAll(() => {
|
|
105
|
+
server.close();
|
|
106
|
+
vi.unstubAllGlobals();
|
|
107
|
+
});
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
hits = [];
|
|
110
|
+
library = [];
|
|
111
|
+
uploadReply = () => HttpResponse.json({}, { status: 500 });
|
|
112
|
+
});
|
|
113
|
+
afterEach(() => server.resetHandlers());
|
|
114
|
+
|
|
115
|
+
async function openImages() {
|
|
116
|
+
const user = userEvent.setup();
|
|
117
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
118
|
+
render(
|
|
119
|
+
<QueryClientProvider client={qc}>
|
|
120
|
+
<CustomerEmailsPage api={createCommsApi(SLUG)} foundrySlug={SLUG} pollIntervalMs={20} />
|
|
121
|
+
</QueryClientProvider>,
|
|
122
|
+
);
|
|
123
|
+
await user.click(await screen.findByRole('button', { name: 'Announcements' }));
|
|
124
|
+
const region = await screen.findByRole('region', { name: 'Announcements' });
|
|
125
|
+
return { user, region };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const png = () => new File([new Uint8Array([137, 80, 78, 71])], 'cat.png', { type: 'image/png' });
|
|
129
|
+
const uploads = () => hits.filter((h) => h.method === 'POST' && h.path === '/assets/');
|
|
130
|
+
|
|
131
|
+
describe('textToHtml / textToPlain with images', () => {
|
|
132
|
+
it('turns a paragraph that is only an image into an escaped, responsive <img>', () => {
|
|
133
|
+
expect(textToHtml('Hello\n\n')).toBe(
|
|
134
|
+
'<p>Hello</p><p><img src="https://cdn.test/a.png" alt="A "cat"" style="max-width:100%;height:auto;border:0"></p>',
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
it('handles an image inline and escapes markup around it', () => {
|
|
138
|
+
expect(textToHtml('See  <b>now</b>')).toBe(
|
|
139
|
+
'<p>See <img src="https://cdn.test/a.png" alt="x" style="max-width:100%;height:auto;border:0"> <b>now</b></p>',
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
it('never turns a non-http(s) target into an image', () => {
|
|
143
|
+
expect(textToHtml(')')).toBe('<p>)</p>');
|
|
144
|
+
});
|
|
145
|
+
it('writes [Image: alt] in the plain-text part', () => {
|
|
146
|
+
expect(textToPlain('Hi\n\n')).toBe('Hi\n\n[Image: A cat]');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('Images section', () => {
|
|
151
|
+
it('lists the foundry library as thumbnails with their alt text', async () => {
|
|
152
|
+
library = [asset(), asset({ id: 'img-2', alt: 'A dog', url: 'https://cdn.test/crochets/img-2.png' })];
|
|
153
|
+
const { region } = await openImages();
|
|
154
|
+
const imgs = await within(region).findAllByRole('img', { name: 'A cat' });
|
|
155
|
+
expect(imgs[0]).toHaveAttribute('src', 'https://cdn.test/crochets/img-1.png');
|
|
156
|
+
expect(within(region).getByRole('img', { name: 'A dog' })).toBeInTheDocument();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('needs a file AND alt text before it will upload', async () => {
|
|
160
|
+
const { user, region } = await openImages();
|
|
161
|
+
const button = await within(region).findByRole('button', { name: 'Upload image' });
|
|
162
|
+
expect(button).toBeDisabled();
|
|
163
|
+
await user.upload(within(region).getByLabelText('Image file'), png());
|
|
164
|
+
expect(button).toBeDisabled();
|
|
165
|
+
await user.type(within(region).getByLabelText('Describe the image'), 'A cat');
|
|
166
|
+
expect(button).toBeEnabled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('uploads multipart with the file and alt, then shows it in the library', async () => {
|
|
170
|
+
uploadReply = () => {
|
|
171
|
+
library = [asset()];
|
|
172
|
+
return HttpResponse.json(asset(), { status: 201 });
|
|
173
|
+
};
|
|
174
|
+
const { user, region } = await openImages();
|
|
175
|
+
await user.upload(await within(region).findByLabelText('Image file'), png());
|
|
176
|
+
await user.type(within(region).getByLabelText('Describe the image'), 'A cat');
|
|
177
|
+
await user.click(within(region).getByRole('button', { name: 'Upload image' }));
|
|
178
|
+
await waitFor(() => expect(uploads()).toHaveLength(1));
|
|
179
|
+
const form = uploads()[0].form!;
|
|
180
|
+
expect(form.file).toBe('cat.png');
|
|
181
|
+
expect(form.alt).toBe('A cat');
|
|
182
|
+
// what the helper really sent: a FormData body and NO forced JSON content type
|
|
183
|
+
expect(lastInit?.body).toBeInstanceOf(FormData);
|
|
184
|
+
expect((lastInit?.headers as Record<string, string>)['Content-Type']).toBeUndefined();
|
|
185
|
+
expect(await within(region).findByRole('img', { name: 'A cat' })).toBeInTheDocument();
|
|
186
|
+
expect(within(region).getByLabelText('Describe the image')).toHaveValue('');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("shows the server's reason when an upload is refused", async () => {
|
|
190
|
+
uploadReply = () => HttpResponse.json({ detail: 'that is not a PNG, JPEG, GIF or WebP image' }, { status: 400 });
|
|
191
|
+
const { user, region } = await openImages();
|
|
192
|
+
await user.upload(await within(region).findByLabelText('Image file'), png());
|
|
193
|
+
await user.type(within(region).getByLabelText('Describe the image'), 'x');
|
|
194
|
+
await user.click(within(region).getByRole('button', { name: 'Upload image' }));
|
|
195
|
+
expect(await within(region).findByRole('alert')).toHaveTextContent('not a PNG, JPEG, GIF or WebP');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('inserts an image into the message as its own paragraph', async () => {
|
|
199
|
+
library = [asset()];
|
|
200
|
+
const { user, region } = await openImages();
|
|
201
|
+
await user.type(within(region).getByLabelText('Message'), 'Hello there');
|
|
202
|
+
await user.click(await within(region).findByRole('button', { name: 'Insert A cat' }));
|
|
203
|
+
expect(within(region).getByLabelText('Message')).toHaveValue(
|
|
204
|
+
'Hello there\n\n',
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('saves a draft whose html has the <img> and whose text part says [Image: alt]', async () => {
|
|
209
|
+
library = [asset()];
|
|
210
|
+
const { user, region } = await openImages();
|
|
211
|
+
await user.type(within(region).getByLabelText('Subject'), 'Pics');
|
|
212
|
+
await user.type(within(region).getByLabelText('Message'), 'Look');
|
|
213
|
+
await user.click(await within(region).findByRole('button', { name: 'Insert A cat' }));
|
|
214
|
+
await user.click(within(region).getByRole('button', { name: 'Save as draft' }));
|
|
215
|
+
await waitFor(() => expect(hits.some((h) => h.method === 'POST' && h.path === '/drafts/')).toBe(true));
|
|
216
|
+
const body = hits.find((h) => h.method === 'POST' && h.path === '/drafts/')!.body;
|
|
217
|
+
expect(body.html).toBe(
|
|
218
|
+
'<p>Look</p><p><img src="https://cdn.test/crochets/img-1.png" alt="A cat" style="max-width:100%;height:auto;border:0"></p>',
|
|
219
|
+
);
|
|
220
|
+
expect(body.text).toBe('Look\n\n[Image: A cat]');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('deletes an image from the library', async () => {
|
|
224
|
+
library = [asset()];
|
|
225
|
+
const { user, region } = await openImages();
|
|
226
|
+
await user.click(await within(region).findByRole('button', { name: 'Delete A cat' }));
|
|
227
|
+
await waitFor(() => expect(hits.some((h) => h.method === 'DELETE' && h.path === '/assets/img-1/')).toBe(true));
|
|
228
|
+
await waitFor(() => expect(within(region).queryByRole('img', { name: 'A cat' })).not.toBeInTheDocument());
|
|
229
|
+
});
|
|
230
|
+
});
|
package/src/comms/api/_fetch.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -47,7 +47,9 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
50
|
-
|
|
50
|
+
const isForm = typeof FormData !== 'undefined' && body instanceof FormData;
|
|
51
|
+
// A FormData body carries its own multipart boundary: the browser sets the header.
|
|
52
|
+
if (body !== undefined && !isForm) headers['Content-Type'] = 'application/json';
|
|
51
53
|
if (_tokenProvider) {
|
|
52
54
|
try {
|
|
53
55
|
const token = await _tokenProvider();
|
|
@@ -61,7 +63,7 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
|
|
|
61
63
|
method,
|
|
62
64
|
headers,
|
|
63
65
|
credentials: 'include',
|
|
64
|
-
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
66
|
+
...(body !== undefined ? { body: isForm ? (body as FormData) : JSON.stringify(body) } : {}),
|
|
65
67
|
});
|
|
66
68
|
|
|
67
69
|
const contentType = resp.headers.get('Content-Type') || '';
|
package/src/comms/api/comms.ts
CHANGED
|
@@ -17,9 +17,14 @@ import type {
|
|
|
17
17
|
ChangesResponse,
|
|
18
18
|
CommsMetrics,
|
|
19
19
|
CommsProgram,
|
|
20
|
+
AssetsQuery,
|
|
21
|
+
CommsAsset,
|
|
20
22
|
CommsSettings,
|
|
21
23
|
CommsSettingsPatch,
|
|
24
|
+
AudienceSegments,
|
|
25
|
+
AudienceSpec,
|
|
22
26
|
Draft,
|
|
27
|
+
DraftAudience,
|
|
23
28
|
DraftCreate,
|
|
24
29
|
DraftPatch,
|
|
25
30
|
DraftsQuery,
|
|
@@ -65,10 +70,20 @@ export interface CommsApi {
|
|
|
65
70
|
listDrafts(query?: DraftsQuery): Promise<Paginated<Draft>>;
|
|
66
71
|
getDraft(id: string): Promise<Draft>;
|
|
67
72
|
createDraft(input: DraftCreate): Promise<Draft>;
|
|
73
|
+
|
|
74
|
+
// image library
|
|
75
|
+
listAssets(query?: AssetsQuery): Promise<Paginated<CommsAsset>>;
|
|
76
|
+
uploadAsset(file: File, alt: string): Promise<CommsAsset>;
|
|
77
|
+
deleteAsset(id: string): Promise<void>;
|
|
68
78
|
updateDraft(id: string, patch: DraftPatch): Promise<Draft>;
|
|
69
79
|
approveDraft(id: string): Promise<Draft>;
|
|
70
80
|
discardDraft(id: string): Promise<Draft>;
|
|
71
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>;
|
|
72
87
|
sendUpdateNow(input?: { notes?: string }): Promise<SendNowResult>;
|
|
73
88
|
|
|
74
89
|
// results
|
|
@@ -110,10 +125,24 @@ export function createCommsApi(slug: string): CommsApi {
|
|
|
110
125
|
listDrafts: (query = {}) => request(`${P}/drafts/`, { query: { ...query } }),
|
|
111
126
|
getDraft: (id) => request(draftPath(id)),
|
|
112
127
|
createDraft: (input) => request(`${P}/drafts/`, { method: 'POST', body: input }),
|
|
128
|
+
listAssets: (query = {}) => request(`${P}/assets/`, { query: { ...query } }),
|
|
129
|
+
uploadAsset: (file, alt) => {
|
|
130
|
+
const form = new FormData();
|
|
131
|
+
form.append('file', file);
|
|
132
|
+
form.append('alt', alt);
|
|
133
|
+
return request(`${P}/assets/`, { method: 'POST', body: form });
|
|
134
|
+
},
|
|
135
|
+
deleteAsset: (id) => request(`${P}/assets/${seg(id)}/`, { method: 'DELETE' }),
|
|
113
136
|
updateDraft: (id, patch) => request(draftPath(id), { method: 'PATCH', body: patch }),
|
|
114
137
|
approveDraft: (id) => request(draftPath(id, 'approve'), { method: 'POST' }),
|
|
115
138
|
discardDraft: (id) => request(draftPath(id, 'discard'), { method: 'POST' }),
|
|
116
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/`),
|
|
117
146
|
sendUpdateNow: (input = {}) =>
|
|
118
147
|
request(`${P}/programs/product_update/send-now/`, { method: 'POST', body: input }),
|
|
119
148
|
|
|
@@ -12,7 +12,9 @@ import { Button, Input, Label, Textarea } from '@startsimpli/ui';
|
|
|
12
12
|
|
|
13
13
|
import type { CommsApi } from '../api/comms';
|
|
14
14
|
import { DEFAULT_POLL_MS, useCreateDraft, useDraft } from '../hooks/index';
|
|
15
|
+
import type { CommsAsset } from '../types/index';
|
|
15
16
|
import { DraftReview } from './DraftReview';
|
|
17
|
+
import { ImageLibrary } from './ImageLibrary';
|
|
16
18
|
import { ErrorNotice, Note, humanize, useFieldId } from './_shared';
|
|
17
19
|
|
|
18
20
|
export interface AnnouncementComposerProps {
|
|
@@ -24,15 +26,35 @@ type Mode = 'write' | 'notes';
|
|
|
24
26
|
|
|
25
27
|
const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
const IMAGE = /!\[([^\]]*)\]\((https?:\/\/[^\s)]+)\)/g;
|
|
30
|
+
const escapeAttr = (v: string) => escapeHtml(v).replace(/"/g, '"');
|
|
31
|
+
|
|
32
|
+
/** One paragraph's text, with `` turned into an <img> and everything else escaped. */
|
|
33
|
+
function inlineHtml(text: string): string {
|
|
34
|
+
let out = '';
|
|
35
|
+
let last = 0;
|
|
36
|
+
for (const m of text.matchAll(IMAGE)) {
|
|
37
|
+
out += escapeHtml(text.slice(last, m.index)).replace(/\n/g, '<br>');
|
|
38
|
+
out += `<img src="${escapeAttr(m[2])}" alt="${escapeAttr(m[1])}" style="max-width:100%;height:auto;border:0">`;
|
|
39
|
+
last = (m.index ?? 0) + m[0].length;
|
|
40
|
+
}
|
|
41
|
+
return out + escapeHtml(text.slice(last)).replace(/\n/g, '<br>');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Blank-line-separated paragraphs; a single newline is a line break; an image is ``. */
|
|
28
45
|
export function textToHtml(text: string): string {
|
|
29
46
|
return text
|
|
30
47
|
.trim()
|
|
31
48
|
.split(/\n{2,}/)
|
|
32
|
-
.map((p) => `<p>${
|
|
49
|
+
.map((p) => `<p>${inlineHtml(p.trim())}</p>`)
|
|
33
50
|
.join('');
|
|
34
51
|
}
|
|
35
52
|
|
|
53
|
+
/** The plain-text part: each image reads `[Image: alt]`. */
|
|
54
|
+
export function textToPlain(text: string): string {
|
|
55
|
+
return text.trim().replace(IMAGE, (_m, alt: string) => `[Image: ${alt}]`);
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
/** Follows one notes draft until the worker has finished writing it. */
|
|
37
59
|
function NotesProgress({
|
|
38
60
|
api,
|
|
@@ -72,6 +94,9 @@ export function AnnouncementComposer({ api, pollIntervalMs = DEFAULT_POLL_MS }:
|
|
|
72
94
|
else setStatus('Your draft is ready. Review it below.');
|
|
73
95
|
}, []);
|
|
74
96
|
|
|
97
|
+
const insertImage = (a: CommsAsset) =>
|
|
98
|
+
setMessage((m) => `${m.trimEnd()}${m.trim() ? '\n\n' : ''}`);
|
|
99
|
+
|
|
75
100
|
const canSave = mode === 'write' ? !!subject.trim() && !!message.trim() : !!notes.trim();
|
|
76
101
|
|
|
77
102
|
const submit = () => {
|
|
@@ -83,7 +108,7 @@ export function AnnouncementComposer({ api, pollIntervalMs = DEFAULT_POLL_MS }:
|
|
|
83
108
|
subject: subject.trim(),
|
|
84
109
|
...(preheader.trim() ? { preheader: preheader.trim() } : {}),
|
|
85
110
|
html: textToHtml(message),
|
|
86
|
-
text: message
|
|
111
|
+
text: textToPlain(message),
|
|
87
112
|
}
|
|
88
113
|
: { notes: notes.trim() };
|
|
89
114
|
create.mutate(input, {
|
|
@@ -140,6 +165,7 @@ export function AnnouncementComposer({ api, pollIntervalMs = DEFAULT_POLL_MS }:
|
|
|
140
165
|
<Textarea id={`${ids}-msg`} rows={8} value={message} onChange={(e) => setMessage(e.target.value)} />
|
|
141
166
|
<Note>Plain text: leave a blank line between paragraphs, and anything in angle brackets is shown as typed. {'{{first_name}}'} and {'{{app_url}}'} are filled in for each customer.</Note>
|
|
142
167
|
</div>
|
|
168
|
+
<ImageLibrary api={api} onInsert={insertImage} />
|
|
143
169
|
</>
|
|
144
170
|
) : (
|
|
145
171
|
<div className="space-y-1">
|
|
@@ -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
|
);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The foundry's email image library, inside the announcement composer (bd
|
|
3
|
+
* startsim-ktuhz): upload a picture with its alt text (required), browse the
|
|
4
|
+
* library as thumbnails (server-paged), insert one into the message, delete one.
|
|
5
|
+
* The server decides what an image is (type sniffed, EXIF dropped) and which hosts
|
|
6
|
+
* survive the email sanitiser; this only collects, lists and hands back a url.
|
|
7
|
+
*/
|
|
8
|
+
import { useState, type ReactElement } from 'react';
|
|
9
|
+
import { Button, Input, Label } from '@startsimpli/ui';
|
|
10
|
+
|
|
11
|
+
import type { CommsApi } from '../api/comms';
|
|
12
|
+
import { useAssets, useDeleteAsset, useUploadAsset } from '../hooks/index';
|
|
13
|
+
import type { CommsAsset } from '../types/index';
|
|
14
|
+
import { ErrorNotice, Note, useFieldId } from './_shared';
|
|
15
|
+
|
|
16
|
+
export interface ImageLibraryProps {
|
|
17
|
+
api: CommsApi;
|
|
18
|
+
/** Called with the chosen image; the composer puts it in the message. */
|
|
19
|
+
onInsert: (asset: CommsAsset) => void;
|
|
20
|
+
pageSize?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ImageLibrary({ api, onInsert, pageSize = 12 }: ImageLibraryProps): ReactElement {
|
|
24
|
+
const ids = useFieldId('comms-images');
|
|
25
|
+
const [page, setPage] = useState(1);
|
|
26
|
+
const assets = useAssets(api, { page, page_size: pageSize });
|
|
27
|
+
const upload = useUploadAsset(api);
|
|
28
|
+
const remove = useDeleteAsset(api);
|
|
29
|
+
const [file, setFile] = useState<File | null>(null);
|
|
30
|
+
const [alt, setAlt] = useState('');
|
|
31
|
+
const [inputKey, setInputKey] = useState(0);
|
|
32
|
+
|
|
33
|
+
const results = assets.data?.results ?? [];
|
|
34
|
+
|
|
35
|
+
const submit = () => {
|
|
36
|
+
if (!file || !alt.trim()) return;
|
|
37
|
+
upload.mutate(
|
|
38
|
+
{ file, alt: alt.trim() },
|
|
39
|
+
{
|
|
40
|
+
onSuccess: () => {
|
|
41
|
+
setFile(null);
|
|
42
|
+
setAlt('');
|
|
43
|
+
setInputKey((k) => k + 1);
|
|
44
|
+
setPage(1);
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="space-y-3 rounded-md border p-3">
|
|
52
|
+
<p className="text-sm font-medium">Images</p>
|
|
53
|
+
<div className="flex flex-wrap items-end gap-3">
|
|
54
|
+
<div className="space-y-1">
|
|
55
|
+
<Label htmlFor={`${ids}-file`}>Image file</Label>
|
|
56
|
+
<input
|
|
57
|
+
key={inputKey}
|
|
58
|
+
id={`${ids}-file`}
|
|
59
|
+
type="file"
|
|
60
|
+
accept="image/png,image/jpeg,image/gif,image/webp"
|
|
61
|
+
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
|
62
|
+
className="block text-sm"
|
|
63
|
+
/>
|
|
64
|
+
</div>
|
|
65
|
+
<div className="min-w-48 flex-1 space-y-1">
|
|
66
|
+
<Label htmlFor={`${ids}-alt`}>Describe the image</Label>
|
|
67
|
+
<Input id={`${ids}-alt`} value={alt} onChange={(e) => setAlt(e.target.value)} />
|
|
68
|
+
</div>
|
|
69
|
+
<Button type="button" disabled={!file || !alt.trim() || upload.isPending} onClick={submit}>
|
|
70
|
+
Upload image
|
|
71
|
+
</Button>
|
|
72
|
+
</div>
|
|
73
|
+
<Note>PNG, JPEG, GIF or WebP. The description is read aloud by screen readers and shown when images are blocked.</Note>
|
|
74
|
+
<ErrorNotice error={upload.error ?? remove.error ?? assets.error} />
|
|
75
|
+
|
|
76
|
+
{results.length ? (
|
|
77
|
+
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
78
|
+
{results.map((a) => (
|
|
79
|
+
<li key={a.id} className="space-y-1">
|
|
80
|
+
<img src={a.url} alt={a.alt} className="h-20 w-full rounded border object-cover" />
|
|
81
|
+
<div className="flex gap-1">
|
|
82
|
+
<Button type="button" size="sm" variant="outline" aria-label={`Insert ${a.alt}`} onClick={() => onInsert(a)}>
|
|
83
|
+
Insert
|
|
84
|
+
</Button>
|
|
85
|
+
<Button
|
|
86
|
+
type="button"
|
|
87
|
+
size="sm"
|
|
88
|
+
variant="ghost"
|
|
89
|
+
aria-label={`Delete ${a.alt}`}
|
|
90
|
+
disabled={remove.isPending}
|
|
91
|
+
onClick={() => remove.mutate(a.id)}
|
|
92
|
+
>
|
|
93
|
+
Delete
|
|
94
|
+
</Button>
|
|
95
|
+
</div>
|
|
96
|
+
</li>
|
|
97
|
+
))}
|
|
98
|
+
</ul>
|
|
99
|
+
) : (
|
|
100
|
+
<Note>{assets.isLoading ? 'Loading images…' : 'No images yet.'}</Note>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{assets.data && (assets.data.previous || assets.data.next) ? (
|
|
104
|
+
<div className="flex items-center gap-2">
|
|
105
|
+
<Button type="button" size="sm" variant="outline" disabled={!assets.data.previous} onClick={() => setPage((p) => p - 1)}>
|
|
106
|
+
Previous
|
|
107
|
+
</Button>
|
|
108
|
+
<Button type="button" size="sm" variant="outline" disabled={!assets.data.next} onClick={() => setPage((p) => p + 1)}>
|
|
109
|
+
Next
|
|
110
|
+
</Button>
|
|
111
|
+
</div>
|
|
112
|
+
) : null}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
package/src/comms/hooks/index.ts
CHANGED
|
@@ -19,11 +19,16 @@ import {
|
|
|
19
19
|
|
|
20
20
|
import type { CommsApi } from '../api/comms';
|
|
21
21
|
import type {
|
|
22
|
+
AssetsQuery,
|
|
23
|
+
AudienceSegments,
|
|
24
|
+
AudienceSpec,
|
|
25
|
+
CommsAsset,
|
|
22
26
|
CommsMetrics,
|
|
23
27
|
CommsProgram,
|
|
24
28
|
CommsSettings,
|
|
25
29
|
CommsSettingsPatch,
|
|
26
30
|
Draft,
|
|
31
|
+
DraftAudience,
|
|
27
32
|
DraftCreate,
|
|
28
33
|
DraftPatch,
|
|
29
34
|
DraftsQuery,
|
|
@@ -48,9 +53,13 @@ export const commsKeys = {
|
|
|
48
53
|
['startsimpli', 'comms', slug, 'generation', key, step] as const,
|
|
49
54
|
preview: (slug: string, key: string, step: number) =>
|
|
50
55
|
['startsimpli', 'comms', slug, 'preview', key, step] as const,
|
|
56
|
+
assetsAll: (slug: string) => ['startsimpli', 'comms', slug, 'assets'] as const,
|
|
57
|
+
assets: (slug: string, q: AssetsQuery = {}) => ['startsimpli', 'comms', slug, 'assets', q] as const,
|
|
51
58
|
draftsAll: (slug: string) => ['startsimpli', 'comms', slug, 'drafts'] as const,
|
|
52
59
|
drafts: (slug: string, q: DraftsQuery = {}) => ['startsimpli', 'comms', slug, 'drafts', q] as const,
|
|
53
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,
|
|
54
63
|
draftPreview: (slug: string, id: string) => ['startsimpli', 'comms', slug, 'draft-preview', id] as const,
|
|
55
64
|
sends: (slug: string, q: SendsQuery = {}) => ['startsimpli', 'comms', slug, 'sends', q] as const,
|
|
56
65
|
metrics: (slug: string, q: MetricsQuery = {}) => ['startsimpli', 'comms', slug, 'metrics', q] as const,
|
|
@@ -206,6 +215,41 @@ export function useDraftPreview(api: CommsApi, id: string, enabled: boolean): Us
|
|
|
206
215
|
});
|
|
207
216
|
}
|
|
208
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
|
+
|
|
209
253
|
function useDraftsWrite<R, V>(api: CommsApi, fn: (v: V) => Promise<R>) {
|
|
210
254
|
const qc = useQueryClient();
|
|
211
255
|
return useMutation<R, Error, V>({
|
|
@@ -243,6 +287,28 @@ export function useSendUpdateNow(api: CommsApi): UseMutationResult<SendNowResult
|
|
|
243
287
|
return useDraftsWrite(api, (input: { notes?: string }) => api.sendUpdateNow(input));
|
|
244
288
|
}
|
|
245
289
|
|
|
290
|
+
// image library ---------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
export function useAssets(api: CommsApi, q: AssetsQuery): UseQueryResult<Paginated<CommsAsset>> {
|
|
293
|
+
return useQuery({ queryKey: commsKeys.assets(api.slug, q), queryFn: () => api.listAssets(q) });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function useAssetsWrite<R, V>(api: CommsApi, fn: (v: V) => Promise<R>) {
|
|
297
|
+
const qc = useQueryClient();
|
|
298
|
+
return useMutation<R, Error, V>({
|
|
299
|
+
mutationFn: fn,
|
|
300
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: commsKeys.assetsAll(api.slug) }),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function useUploadAsset(api: CommsApi): UseMutationResult<CommsAsset, Error, { file: File; alt: string }> {
|
|
305
|
+
return useAssetsWrite(api, ({ file, alt }: { file: File; alt: string }) => api.uploadAsset(file, alt));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function useDeleteAsset(api: CommsApi): UseMutationResult<void, Error, string> {
|
|
309
|
+
return useAssetsWrite(api, (id: string) => api.deleteAsset(id));
|
|
310
|
+
}
|
|
311
|
+
|
|
246
312
|
// results ------------------------------------------------------------------
|
|
247
313
|
|
|
248
314
|
/** Server-side pagination: page/page_size go to the server, never sliced here. */
|
package/src/comms/types/index.ts
CHANGED
|
@@ -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). */
|
|
@@ -218,6 +276,23 @@ export type DraftCreate =
|
|
|
218
276
|
| { subject: string; html: string; preheader?: string; text?: string }
|
|
219
277
|
| { notes: string };
|
|
220
278
|
|
|
279
|
+
/** One image in the foundry's email image library (comms/assets/). */
|
|
280
|
+
export interface CommsAsset {
|
|
281
|
+
id: string;
|
|
282
|
+
key: string;
|
|
283
|
+
/** Public CDN url; the only host the server's email sanitiser keeps. */
|
|
284
|
+
url: string;
|
|
285
|
+
filename: string;
|
|
286
|
+
content_type: string;
|
|
287
|
+
bytes: number;
|
|
288
|
+
width: number;
|
|
289
|
+
height: number;
|
|
290
|
+
alt: string;
|
|
291
|
+
created_at: string;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export type AssetsQuery = PageParams;
|
|
295
|
+
|
|
221
296
|
export type DraftPatch = Partial<Pick<Draft, 'subject' | 'preheader' | 'html' | 'text'>>;
|
|
222
297
|
|
|
223
298
|
export const NOTHING_CUSTOMER_FACING = 'nothing_customer_facing';
|