@startsimpli/integrations 0.2.44 → 0.2.46
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 +3 -3
- package/src/comms/__tests__/announcement-composer.test.tsx +185 -0
- package/src/comms/__tests__/image-library.test.tsx +230 -0
- package/src/comms/api/_fetch.ts +4 -2
- package/src/comms/api/comms.ts +18 -0
- package/src/comms/components/AnnouncementComposer.tsx +196 -0
- package/src/comms/components/ImageLibrary.tsx +115 -0
- package/src/comms/components/pages/CustomerEmailsPage.tsx +5 -1
- package/src/comms/hooks/index.ts +32 -0
- package/src/comms/types/index.ts +23 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/integrations",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.46",
|
|
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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"react": ">=18.0.0",
|
|
36
36
|
"@tanstack/react-query": ">=5.0.0",
|
|
37
37
|
"@startsimpli/llm": ">=0.4.0",
|
|
38
|
-
"@startsimpli/ui": "^0.4.
|
|
38
|
+
"@startsimpli/ui": "^0.4.162"
|
|
39
39
|
},
|
|
40
40
|
"peerDependenciesMeta": {
|
|
41
41
|
"react": {
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"typescript": "^6.0.0",
|
|
66
66
|
"vitest": "^3.0.0",
|
|
67
67
|
"@startsimpli/llm": "0.4.25",
|
|
68
|
-
"@startsimpli/ui": "0.4.
|
|
68
|
+
"@startsimpli/ui": "0.4.162"
|
|
69
69
|
},
|
|
70
70
|
"scripts": {
|
|
71
71
|
"type-check": "tsc --noEmit",
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Announcement composer on the Customer emails page (bd startsim-k0uew, UI lane).
|
|
3
|
+
*
|
|
4
|
+
* An "Announcements" fold: write an email yourself, or write from notes. Both go
|
|
5
|
+
* through POST comms/drafts/ (201 with the content, 202 with a draft the worker
|
|
6
|
+
* is still writing, polled with GET drafts/{id}/). The draft then lands in the
|
|
7
|
+
* normal review list; creating never approves or sends.
|
|
8
|
+
*
|
|
9
|
+
* (user-event reads `{{` as an escaped `{`, so typed tokens are written `{{{{x}}`.)
|
|
10
|
+
* Plain text in, safe HTML out: blank lines are paragraphs, markup is escaped,
|
|
11
|
+
* {{tokens}} pass through untouched (the server validates them).
|
|
12
|
+
*/
|
|
13
|
+
import * as React from 'react';
|
|
14
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
15
|
+
import { render, screen, waitFor, within } from '@testing-library/react';
|
|
16
|
+
import userEvent from '@testing-library/user-event';
|
|
17
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
18
|
+
import { http, HttpResponse } from 'msw';
|
|
19
|
+
import { setupServer } from 'msw/node';
|
|
20
|
+
|
|
21
|
+
import { CustomerEmailsPage } from '../components/pages/CustomerEmailsPage';
|
|
22
|
+
import { createCommsApi, setApiBase } from '../api/index';
|
|
23
|
+
import type { Draft } from '../types/index';
|
|
24
|
+
import { BASE, P, SLUG, comeback, draft, productUpdate, settings } from './fixtures';
|
|
25
|
+
|
|
26
|
+
interface Hit { method: string; path: string; body: any }
|
|
27
|
+
let hits: Hit[] = [];
|
|
28
|
+
let drafts: Draft[] = [];
|
|
29
|
+
let createReply: () => Response;
|
|
30
|
+
let pollsLeft = 0;
|
|
31
|
+
|
|
32
|
+
const rel = (p: string) => p.replace(new URL(P).pathname, '');
|
|
33
|
+
|
|
34
|
+
const server = setupServer(
|
|
35
|
+
http.all(`${P}/*`, async ({ request }) => {
|
|
36
|
+
const url = new URL(request.url);
|
|
37
|
+
const text = request.method === 'GET' ? '' : await request.text();
|
|
38
|
+
const hit: Hit = { method: request.method, path: rel(url.pathname), body: text ? JSON.parse(text) : undefined };
|
|
39
|
+
hits.push(hit);
|
|
40
|
+
if (hit.path === '/settings/') return HttpResponse.json(settings());
|
|
41
|
+
if (hit.path === '/programs/') return HttpResponse.json([comeback(), productUpdate()]);
|
|
42
|
+
if (hit.path === '/drafts/' && hit.method === 'POST') return createReply();
|
|
43
|
+
if (hit.path === '/drafts/') {
|
|
44
|
+
const results = drafts.filter((d) => d.approval === 'pending');
|
|
45
|
+
return HttpResponse.json({ count: results.length, next: null, previous: null, results });
|
|
46
|
+
}
|
|
47
|
+
const m = hit.path.match(/^\/drafts\/([^/]+)\/$/);
|
|
48
|
+
if (m && hit.method === 'GET') {
|
|
49
|
+
const d = drafts.find((x) => x.id === m[1])!;
|
|
50
|
+
if (d.generating && --pollsLeft <= 0) {
|
|
51
|
+
Object.assign(d, { generating: false, subject: 'Written from your notes', html: '<p>Done</p>', text: 'Done' });
|
|
52
|
+
}
|
|
53
|
+
return HttpResponse.json(d);
|
|
54
|
+
}
|
|
55
|
+
return HttpResponse.json({});
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
beforeAll(() => {
|
|
60
|
+
server.listen({ onUnhandledRequest: 'error' });
|
|
61
|
+
setApiBase(`${BASE}/api/v1`);
|
|
62
|
+
});
|
|
63
|
+
afterAll(() => server.close());
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
hits = [];
|
|
66
|
+
drafts = [];
|
|
67
|
+
pollsLeft = 1;
|
|
68
|
+
createReply = () => HttpResponse.json({}, { status: 500 });
|
|
69
|
+
});
|
|
70
|
+
afterEach(() => server.resetHandlers());
|
|
71
|
+
|
|
72
|
+
function mount() {
|
|
73
|
+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
74
|
+
return render(
|
|
75
|
+
<QueryClientProvider client={qc}>
|
|
76
|
+
<CustomerEmailsPage api={createCommsApi(SLUG)} foundrySlug={SLUG} pollIntervalMs={20} />
|
|
77
|
+
</QueryClientProvider>,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function openFold() {
|
|
82
|
+
const user = userEvent.setup();
|
|
83
|
+
mount();
|
|
84
|
+
const trigger = await screen.findByRole('button', { name: 'Announcements' });
|
|
85
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
86
|
+
await user.click(trigger);
|
|
87
|
+
return { user, region: await screen.findByRole('region', { name: 'Announcements' }) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const posts = () => hits.filter((h) => h.method === 'POST' && h.path === '/drafts/');
|
|
91
|
+
|
|
92
|
+
describe('Announcements fold', () => {
|
|
93
|
+
it('is a collapsed fold with a write mode by default and a submit that waits for content', async () => {
|
|
94
|
+
const { user, region } = await openFold();
|
|
95
|
+
const send = within(region).getByRole('button', { name: 'Save as draft' });
|
|
96
|
+
expect(send).toBeDisabled();
|
|
97
|
+
await user.type(within(region).getByLabelText('Subject'), 'Hello');
|
|
98
|
+
expect(send).toBeDisabled();
|
|
99
|
+
await user.type(within(region).getByLabelText('Message'), 'Body');
|
|
100
|
+
expect(send).toBeEnabled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('writes an authored draft: plain text becomes escaped paragraphs, tokens untouched', async () => {
|
|
104
|
+
createReply = () => {
|
|
105
|
+
const d = draft({ id: 'a-1', program_key: 'announcement', subject: 'New engine', window: [] as any, item_keys: [] });
|
|
106
|
+
drafts.push(d);
|
|
107
|
+
return HttpResponse.json(d, { status: 201 });
|
|
108
|
+
};
|
|
109
|
+
const { user, region } = await openFold();
|
|
110
|
+
await user.type(within(region).getByLabelText('Subject'), 'New engine');
|
|
111
|
+
await user.type(within(region).getByLabelText('Preview text (optional)'), 'Hi {{{{first_name}}');
|
|
112
|
+
await user.type(within(region).getByLabelText('Message'), 'Hi {{{{first_name}}, <b>we</b> rebuilt it.{Enter}{Enter}Try it at {{{{app_url}}.');
|
|
113
|
+
await user.click(within(region).getByRole('button', { name: 'Save as draft' }));
|
|
114
|
+
|
|
115
|
+
await waitFor(() => expect(posts()).toHaveLength(1));
|
|
116
|
+
expect(posts()[0].body).toEqual({
|
|
117
|
+
subject: 'New engine',
|
|
118
|
+
preheader: 'Hi {{first_name}}',
|
|
119
|
+
html: '<p>Hi {{first_name}}, <b>we</b> rebuilt it.</p><p>Try it at {{app_url}}.</p>',
|
|
120
|
+
text: 'Hi {{first_name}}, <b>we</b> rebuilt it.\n\nTry it at {{app_url}}.',
|
|
121
|
+
});
|
|
122
|
+
expect(await within(region).findByText(/saved as a draft/i)).toBeInTheDocument();
|
|
123
|
+
expect(within(region).getByLabelText('Subject')).toHaveValue('');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('never approves or sends on create', async () => {
|
|
127
|
+
createReply = () => HttpResponse.json(draft({ id: 'a-1', program_key: 'announcement' }), { status: 201 });
|
|
128
|
+
const { user, region } = await openFold();
|
|
129
|
+
await user.type(within(region).getByLabelText('Subject'), 'S');
|
|
130
|
+
await user.type(within(region).getByLabelText('Message'), 'B');
|
|
131
|
+
await user.click(within(region).getByRole('button', { name: 'Save as draft' }));
|
|
132
|
+
await waitFor(() => expect(posts()).toHaveLength(1));
|
|
133
|
+
expect(hits.some((h) => /approve|send-now|test-send/.test(h.path))).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('writes from notes: posts only the notes, follows the 202 draft until it is written', async () => {
|
|
137
|
+
createReply = () => {
|
|
138
|
+
const d = draft({ id: 'n-1', program_key: 'announcement', subject: '(generating)', generating: true });
|
|
139
|
+
drafts.push(d);
|
|
140
|
+
return HttpResponse.json(d, { status: 202 });
|
|
141
|
+
};
|
|
142
|
+
const { user, region } = await openFold();
|
|
143
|
+
await user.click(within(region).getByRole('radio', { name: 'Write it from my notes' }));
|
|
144
|
+
expect(within(region).queryByLabelText('Subject')).not.toBeInTheDocument();
|
|
145
|
+
await user.type(within(region).getByLabelText('Notes'), 'We rebuilt the design engine.');
|
|
146
|
+
await user.click(within(region).getByRole('button', { name: 'Write a draft from my notes' }));
|
|
147
|
+
|
|
148
|
+
await waitFor(() => expect(posts()).toHaveLength(1));
|
|
149
|
+
expect(posts()[0].body).toEqual({ notes: 'We rebuilt the design engine.' });
|
|
150
|
+
expect(await within(region).findByText(/writing/i)).toBeInTheDocument();
|
|
151
|
+
expect(await within(region).findByText(/draft is ready/i)).toBeInTheDocument();
|
|
152
|
+
expect(hits.some((h) => h.method === 'GET' && h.path === '/drafts/n-1/')).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('shows a failed generation as an alert', async () => {
|
|
156
|
+
createReply = () => {
|
|
157
|
+
const d = draft({ id: 'n-2', program_key: 'announcement', generating: true });
|
|
158
|
+
drafts.push(d);
|
|
159
|
+
return HttpResponse.json(d, { status: 202 });
|
|
160
|
+
};
|
|
161
|
+
const { user, region } = await openFold();
|
|
162
|
+
await user.click(within(region).getByRole('radio', { name: 'Write it from my notes' }));
|
|
163
|
+
await user.type(within(region).getByLabelText('Notes'), 'n');
|
|
164
|
+
await user.click(within(region).getByRole('button', { name: 'Write a draft from my notes' }));
|
|
165
|
+
await waitFor(() => expect(drafts).toHaveLength(1));
|
|
166
|
+
Object.assign(drafts[0], { generating: false, error: 'model down', subject: '(generation failed)' });
|
|
167
|
+
expect(await within(region).findByRole('alert')).toHaveTextContent(/model down/i);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("shows the server's reason for a rejected draft (an unknown token) and keeps what was typed", async () => {
|
|
171
|
+
createReply = () => HttpResponse.json({ detail: 'unknown token {{plan_name}} in comms content' }, { status: 400 });
|
|
172
|
+
const { user, region } = await openFold();
|
|
173
|
+
await user.type(within(region).getByLabelText('Subject'), 'Keep me');
|
|
174
|
+
await user.type(within(region).getByLabelText('Message'), 'Hi {{{{plan_name}}');
|
|
175
|
+
await user.click(within(region).getByRole('button', { name: 'Save as draft' }));
|
|
176
|
+
expect(await within(region).findByRole('alert')).toHaveTextContent('unknown token {{plan_name}}');
|
|
177
|
+
expect(within(region).getByLabelText('Subject')).toHaveValue('Keep me');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('lists the new draft for review in the same fold', async () => {
|
|
181
|
+
drafts.push(draft({ id: 'a-9', program_key: 'announcement', subject: 'Waiting for you' }));
|
|
182
|
+
const { region } = await openFold();
|
|
183
|
+
expect(await within(region).findByText('Waiting for you')).toBeInTheDocument();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -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
|
@@ -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,12 @@ import type {
|
|
|
17
17
|
ChangesResponse,
|
|
18
18
|
CommsMetrics,
|
|
19
19
|
CommsProgram,
|
|
20
|
+
AssetsQuery,
|
|
21
|
+
CommsAsset,
|
|
20
22
|
CommsSettings,
|
|
21
23
|
CommsSettingsPatch,
|
|
22
24
|
Draft,
|
|
25
|
+
DraftCreate,
|
|
23
26
|
DraftPatch,
|
|
24
27
|
DraftsQuery,
|
|
25
28
|
MetricsQuery,
|
|
@@ -63,6 +66,12 @@ export interface CommsApi {
|
|
|
63
66
|
listChanges(query?: { since?: string; until?: string }): Promise<ChangesResponse>;
|
|
64
67
|
listDrafts(query?: DraftsQuery): Promise<Paginated<Draft>>;
|
|
65
68
|
getDraft(id: string): Promise<Draft>;
|
|
69
|
+
createDraft(input: DraftCreate): Promise<Draft>;
|
|
70
|
+
|
|
71
|
+
// image library
|
|
72
|
+
listAssets(query?: AssetsQuery): Promise<Paginated<CommsAsset>>;
|
|
73
|
+
uploadAsset(file: File, alt: string): Promise<CommsAsset>;
|
|
74
|
+
deleteAsset(id: string): Promise<void>;
|
|
66
75
|
updateDraft(id: string, patch: DraftPatch): Promise<Draft>;
|
|
67
76
|
approveDraft(id: string): Promise<Draft>;
|
|
68
77
|
discardDraft(id: string): Promise<Draft>;
|
|
@@ -107,6 +116,15 @@ export function createCommsApi(slug: string): CommsApi {
|
|
|
107
116
|
listChanges: (query = {}) => request(`${P}/changes/`, { query: { ...query } }),
|
|
108
117
|
listDrafts: (query = {}) => request(`${P}/drafts/`, { query: { ...query } }),
|
|
109
118
|
getDraft: (id) => request(draftPath(id)),
|
|
119
|
+
createDraft: (input) => request(`${P}/drafts/`, { method: 'POST', body: input }),
|
|
120
|
+
listAssets: (query = {}) => request(`${P}/assets/`, { query: { ...query } }),
|
|
121
|
+
uploadAsset: (file, alt) => {
|
|
122
|
+
const form = new FormData();
|
|
123
|
+
form.append('file', file);
|
|
124
|
+
form.append('alt', alt);
|
|
125
|
+
return request(`${P}/assets/`, { method: 'POST', body: form });
|
|
126
|
+
},
|
|
127
|
+
deleteAsset: (id) => request(`${P}/assets/${seg(id)}/`, { method: 'DELETE' }),
|
|
110
128
|
updateDraft: (id, patch) => request(draftPath(id), { method: 'PATCH', body: patch }),
|
|
111
129
|
approveDraft: (id) => request(draftPath(id, 'approve'), { method: 'POST' }),
|
|
112
130
|
discardDraft: (id) => request(draftPath(id, 'discard'), { method: 'POST' }),
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Announcements (bd startsim-k0uew): a one-off email to the customers, written
|
|
3
|
+
* by hand or by AI from notes. Both go through POST drafts/. The draft lands in
|
|
4
|
+
* the normal review list below the form (preview, test send, edit, approve);
|
|
5
|
+
* creating one never approves or sends it.
|
|
6
|
+
*
|
|
7
|
+
* Plain text in, safe HTML out: blank lines make paragraphs, markup is escaped,
|
|
8
|
+
* {{tokens}} are left for the server to validate and fill in.
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useEffect, useState, type ReactElement } from 'react';
|
|
11
|
+
import { Button, Input, Label, Textarea } from '@startsimpli/ui';
|
|
12
|
+
|
|
13
|
+
import type { CommsApi } from '../api/comms';
|
|
14
|
+
import { DEFAULT_POLL_MS, useCreateDraft, useDraft } from '../hooks/index';
|
|
15
|
+
import type { CommsAsset } from '../types/index';
|
|
16
|
+
import { DraftReview } from './DraftReview';
|
|
17
|
+
import { ImageLibrary } from './ImageLibrary';
|
|
18
|
+
import { ErrorNotice, Note, humanize, useFieldId } from './_shared';
|
|
19
|
+
|
|
20
|
+
export interface AnnouncementComposerProps {
|
|
21
|
+
api: CommsApi;
|
|
22
|
+
pollIntervalMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Mode = 'write' | 'notes';
|
|
26
|
+
|
|
27
|
+
const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
28
|
+
|
|
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 ``. */
|
|
45
|
+
export function textToHtml(text: string): string {
|
|
46
|
+
return text
|
|
47
|
+
.trim()
|
|
48
|
+
.split(/\n{2,}/)
|
|
49
|
+
.map((p) => `<p>${inlineHtml(p.trim())}</p>`)
|
|
50
|
+
.join('');
|
|
51
|
+
}
|
|
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
|
+
|
|
58
|
+
/** Follows one notes draft until the worker has finished writing it. */
|
|
59
|
+
function NotesProgress({
|
|
60
|
+
api,
|
|
61
|
+
id,
|
|
62
|
+
pollIntervalMs,
|
|
63
|
+
onFinished,
|
|
64
|
+
}: {
|
|
65
|
+
api: CommsApi;
|
|
66
|
+
id: string;
|
|
67
|
+
pollIntervalMs: number;
|
|
68
|
+
onFinished: (error: string | null) => void;
|
|
69
|
+
}) {
|
|
70
|
+
const draft = useDraft(api, id, pollIntervalMs);
|
|
71
|
+
const d = draft.data;
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (draft.error) onFinished(draft.error.message);
|
|
74
|
+
else if (d && !d.generating) onFinished(d.error ? `Writing this draft failed: ${humanize(d.error)}` : null);
|
|
75
|
+
}, [d, draft.error, onFinished]);
|
|
76
|
+
return <Note>Writing the draft… this can take a minute.</Note>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function AnnouncementComposer({ api, pollIntervalMs = DEFAULT_POLL_MS }: AnnouncementComposerProps): ReactElement {
|
|
80
|
+
const create = useCreateDraft(api);
|
|
81
|
+
const ids = useFieldId('comms-announce');
|
|
82
|
+
const [mode, setMode] = useState<Mode>('write');
|
|
83
|
+
const [subject, setSubject] = useState('');
|
|
84
|
+
const [preheader, setPreheader] = useState('');
|
|
85
|
+
const [message, setMessage] = useState('');
|
|
86
|
+
const [notes, setNotes] = useState('');
|
|
87
|
+
const [following, setFollowing] = useState<string | null>(null);
|
|
88
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
89
|
+
const [failure, setFailure] = useState<string | null>(null);
|
|
90
|
+
|
|
91
|
+
const onFinished = useCallback((error: string | null) => {
|
|
92
|
+
setFollowing(null);
|
|
93
|
+
if (error) setFailure(error);
|
|
94
|
+
else setStatus('Your draft is ready. Review it below.');
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const insertImage = (a: CommsAsset) =>
|
|
98
|
+
setMessage((m) => `${m.trimEnd()}${m.trim() ? '\n\n' : ''}`);
|
|
99
|
+
|
|
100
|
+
const canSave = mode === 'write' ? !!subject.trim() && !!message.trim() : !!notes.trim();
|
|
101
|
+
|
|
102
|
+
const submit = () => {
|
|
103
|
+
setStatus(null);
|
|
104
|
+
setFailure(null);
|
|
105
|
+
const input =
|
|
106
|
+
mode === 'write'
|
|
107
|
+
? {
|
|
108
|
+
subject: subject.trim(),
|
|
109
|
+
...(preheader.trim() ? { preheader: preheader.trim() } : {}),
|
|
110
|
+
html: textToHtml(message),
|
|
111
|
+
text: textToPlain(message),
|
|
112
|
+
}
|
|
113
|
+
: { notes: notes.trim() };
|
|
114
|
+
create.mutate(input, {
|
|
115
|
+
onSuccess: (d) => {
|
|
116
|
+
if (mode === 'write') {
|
|
117
|
+
setSubject('');
|
|
118
|
+
setPreheader('');
|
|
119
|
+
setMessage('');
|
|
120
|
+
setStatus('Saved as a draft. Review it below; nothing is sent until you approve it.');
|
|
121
|
+
} else {
|
|
122
|
+
setNotes('');
|
|
123
|
+
if (d.generating) setFollowing(d.id);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className="space-y-4">
|
|
131
|
+
<form
|
|
132
|
+
className="space-y-3"
|
|
133
|
+
onSubmit={(e) => {
|
|
134
|
+
e.preventDefault();
|
|
135
|
+
if (canSave) submit();
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
<fieldset className="flex flex-wrap gap-4">
|
|
139
|
+
<legend className="sr-only">How to write it</legend>
|
|
140
|
+
{(
|
|
141
|
+
[
|
|
142
|
+
['write', 'Write it myself'],
|
|
143
|
+
['notes', 'Write it from my notes'],
|
|
144
|
+
] as const
|
|
145
|
+
).map(([value, label]) => (
|
|
146
|
+
<label key={value} className="flex items-center gap-2 text-sm">
|
|
147
|
+
<input type="radio" name={`${ids}-mode`} checked={mode === value} onChange={() => setMode(value)} />
|
|
148
|
+
{label}
|
|
149
|
+
</label>
|
|
150
|
+
))}
|
|
151
|
+
</fieldset>
|
|
152
|
+
|
|
153
|
+
{mode === 'write' ? (
|
|
154
|
+
<>
|
|
155
|
+
<div className="space-y-1">
|
|
156
|
+
<Label htmlFor={`${ids}-subject`}>Subject</Label>
|
|
157
|
+
<Input id={`${ids}-subject`} value={subject} onChange={(e) => setSubject(e.target.value)} />
|
|
158
|
+
</div>
|
|
159
|
+
<div className="space-y-1">
|
|
160
|
+
<Label htmlFor={`${ids}-pre`}>Preview text (optional)</Label>
|
|
161
|
+
<Input id={`${ids}-pre`} value={preheader} onChange={(e) => setPreheader(e.target.value)} />
|
|
162
|
+
</div>
|
|
163
|
+
<div className="space-y-1">
|
|
164
|
+
<Label htmlFor={`${ids}-msg`}>Message</Label>
|
|
165
|
+
<Textarea id={`${ids}-msg`} rows={8} value={message} onChange={(e) => setMessage(e.target.value)} />
|
|
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>
|
|
167
|
+
</div>
|
|
168
|
+
<ImageLibrary api={api} onInsert={insertImage} />
|
|
169
|
+
</>
|
|
170
|
+
) : (
|
|
171
|
+
<div className="space-y-1">
|
|
172
|
+
<Label htmlFor={`${ids}-notes`}>Notes</Label>
|
|
173
|
+
<Textarea
|
|
174
|
+
id={`${ids}-notes`}
|
|
175
|
+
rows={6}
|
|
176
|
+
value={notes}
|
|
177
|
+
placeholder="What to tell customers, in your own words. The AI writes the email from this alone."
|
|
178
|
+
onChange={(e) => setNotes(e.target.value)}
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
<Button type="submit" disabled={!canSave || create.isPending || !!following}>
|
|
184
|
+
{mode === 'write' ? 'Save as draft' : 'Write a draft from my notes'}
|
|
185
|
+
</Button>
|
|
186
|
+
</form>
|
|
187
|
+
|
|
188
|
+
{following ? (
|
|
189
|
+
<NotesProgress api={api} id={following} pollIntervalMs={pollIntervalMs} onFinished={onFinished} />
|
|
190
|
+
) : null}
|
|
191
|
+
{status ? <Note>{status}</Note> : null}
|
|
192
|
+
<ErrorNotice error={create.error ?? (failure ? new Error(failure) : null)} />
|
|
193
|
+
<DraftReview api={api} pollIntervalMs={pollIntervalMs} />
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* LAYOUT (Quinn): a normal app. ONE title ("Customer emails"), the two switches
|
|
9
9
|
* directly under it, then folded sections in one column. No side rail, no card
|
|
10
10
|
* stack, no second header. Folds: Comeback sequence settings, Product updates,
|
|
11
|
-
* Approval, Voice, Sending domain, Results, Send log. Fold bodies mount only
|
|
11
|
+
* Announcements, Approval, Voice, Sending domain, Results, Send log. Fold bodies mount only
|
|
12
12
|
* when opened, so nothing below the switches is fetched until asked for.
|
|
13
13
|
*
|
|
14
14
|
* Admin-only: the comms routes answer 403 to anyone without view_comms; the page
|
|
@@ -20,6 +20,7 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@s
|
|
|
20
20
|
import type { CommsApi } from '../../api/comms';
|
|
21
21
|
import { DEFAULT_POLL_MS, useCommsSettings, usePrograms } from '../../hooks/index';
|
|
22
22
|
import { CommsApiError } from '../../types/index';
|
|
23
|
+
import { AnnouncementComposer } from '../AnnouncementComposer';
|
|
23
24
|
import { ApprovalModeSetting } from '../ApprovalModeSetting';
|
|
24
25
|
import { ProductUpdates } from '../ProductUpdates';
|
|
25
26
|
import { ProgramToggles } from '../ProgramToggles';
|
|
@@ -83,6 +84,9 @@ export function CustomerEmailsPage({
|
|
|
83
84
|
<Fold value="updates" title="Product updates">
|
|
84
85
|
<ProductUpdates api={api} pollIntervalMs={pollIntervalMs} />
|
|
85
86
|
</Fold>
|
|
87
|
+
<Fold value="announcements" title="Announcements">
|
|
88
|
+
<AnnouncementComposer api={api} pollIntervalMs={pollIntervalMs} />
|
|
89
|
+
</Fold>
|
|
86
90
|
<Fold value="approval" title="Approval">
|
|
87
91
|
<ApprovalModeSetting api={api} />
|
|
88
92
|
</Fold>
|
package/src/comms/hooks/index.ts
CHANGED
|
@@ -19,11 +19,14 @@ import {
|
|
|
19
19
|
|
|
20
20
|
import type { CommsApi } from '../api/comms';
|
|
21
21
|
import type {
|
|
22
|
+
AssetsQuery,
|
|
23
|
+
CommsAsset,
|
|
22
24
|
CommsMetrics,
|
|
23
25
|
CommsProgram,
|
|
24
26
|
CommsSettings,
|
|
25
27
|
CommsSettingsPatch,
|
|
26
28
|
Draft,
|
|
29
|
+
DraftCreate,
|
|
27
30
|
DraftPatch,
|
|
28
31
|
DraftsQuery,
|
|
29
32
|
MetricsQuery,
|
|
@@ -47,6 +50,8 @@ export const commsKeys = {
|
|
|
47
50
|
['startsimpli', 'comms', slug, 'generation', key, step] as const,
|
|
48
51
|
preview: (slug: string, key: string, step: number) =>
|
|
49
52
|
['startsimpli', 'comms', slug, 'preview', key, step] as const,
|
|
53
|
+
assetsAll: (slug: string) => ['startsimpli', 'comms', slug, 'assets'] as const,
|
|
54
|
+
assets: (slug: string, q: AssetsQuery = {}) => ['startsimpli', 'comms', slug, 'assets', q] as const,
|
|
50
55
|
draftsAll: (slug: string) => ['startsimpli', 'comms', slug, 'drafts'] as const,
|
|
51
56
|
drafts: (slug: string, q: DraftsQuery = {}) => ['startsimpli', 'comms', slug, 'drafts', q] as const,
|
|
52
57
|
draft: (slug: string, id: string) => ['startsimpli', 'comms', slug, 'drafts', 'one', id] as const,
|
|
@@ -213,6 +218,11 @@ function useDraftsWrite<R, V>(api: CommsApi, fn: (v: V) => Promise<R>) {
|
|
|
213
218
|
});
|
|
214
219
|
}
|
|
215
220
|
|
|
221
|
+
/** A one-off announcement: the email as written (201) or notes to write it from (202, still generating: follow with useDraft). */
|
|
222
|
+
export function useCreateDraft(api: CommsApi): UseMutationResult<Draft, Error, DraftCreate> {
|
|
223
|
+
return useDraftsWrite(api, (input: DraftCreate) => api.createDraft(input));
|
|
224
|
+
}
|
|
225
|
+
|
|
216
226
|
export function useUpdateDraft(api: CommsApi): UseMutationResult<Draft, Error, { id: string; patch: DraftPatch }> {
|
|
217
227
|
const qc = useQueryClient();
|
|
218
228
|
return useMutation({
|
|
@@ -237,6 +247,28 @@ export function useSendUpdateNow(api: CommsApi): UseMutationResult<SendNowResult
|
|
|
237
247
|
return useDraftsWrite(api, (input: { notes?: string }) => api.sendUpdateNow(input));
|
|
238
248
|
}
|
|
239
249
|
|
|
250
|
+
// image library ---------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
export function useAssets(api: CommsApi, q: AssetsQuery): UseQueryResult<Paginated<CommsAsset>> {
|
|
253
|
+
return useQuery({ queryKey: commsKeys.assets(api.slug, q), queryFn: () => api.listAssets(q) });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function useAssetsWrite<R, V>(api: CommsApi, fn: (v: V) => Promise<R>) {
|
|
257
|
+
const qc = useQueryClient();
|
|
258
|
+
return useMutation<R, Error, V>({
|
|
259
|
+
mutationFn: fn,
|
|
260
|
+
onSuccess: () => qc.invalidateQueries({ queryKey: commsKeys.assetsAll(api.slug) }),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function useUploadAsset(api: CommsApi): UseMutationResult<CommsAsset, Error, { file: File; alt: string }> {
|
|
265
|
+
return useAssetsWrite(api, ({ file, alt }: { file: File; alt: string }) => api.uploadAsset(file, alt));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function useDeleteAsset(api: CommsApi): UseMutationResult<void, Error, string> {
|
|
269
|
+
return useAssetsWrite(api, (id: string) => api.deleteAsset(id));
|
|
270
|
+
}
|
|
271
|
+
|
|
240
272
|
// results ------------------------------------------------------------------
|
|
241
273
|
|
|
242
274
|
/** Server-side pagination: page/page_size go to the server, never sliced here. */
|
package/src/comms/types/index.ts
CHANGED
|
@@ -157,6 +157,7 @@ export interface StepGeneration {
|
|
|
157
157
|
|
|
158
158
|
/** The two switches persona A sees (A1). Presets are data server-side. */
|
|
159
159
|
export const COMEBACK_PROGRAM_KEY = 'comeback';
|
|
160
|
+
export const ANNOUNCEMENT_PROGRAM_KEY = 'announcement';
|
|
160
161
|
export const PRODUCT_UPDATE_PROGRAM_KEY = 'product_update';
|
|
161
162
|
|
|
162
163
|
// ---- preview / test send ----------------------------------------------------
|
|
@@ -212,6 +213,28 @@ export interface Draft {
|
|
|
212
213
|
generating: boolean;
|
|
213
214
|
}
|
|
214
215
|
|
|
216
|
+
/** POST drafts/: either the email as written (201) or notes to write it from (202, generating). */
|
|
217
|
+
export type DraftCreate =
|
|
218
|
+
| { subject: string; html: string; preheader?: string; text?: string }
|
|
219
|
+
| { notes: string };
|
|
220
|
+
|
|
221
|
+
/** One image in the foundry's email image library (comms/assets/). */
|
|
222
|
+
export interface CommsAsset {
|
|
223
|
+
id: string;
|
|
224
|
+
key: string;
|
|
225
|
+
/** Public CDN url; the only host the server's email sanitiser keeps. */
|
|
226
|
+
url: string;
|
|
227
|
+
filename: string;
|
|
228
|
+
content_type: string;
|
|
229
|
+
bytes: number;
|
|
230
|
+
width: number;
|
|
231
|
+
height: number;
|
|
232
|
+
alt: string;
|
|
233
|
+
created_at: string;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export type AssetsQuery = PageParams;
|
|
237
|
+
|
|
215
238
|
export type DraftPatch = Partial<Pick<Draft, 'subject' | 'preheader' | 'html' | 'text'>>;
|
|
216
239
|
|
|
217
240
|
export const NOTHING_CUSTOMER_FACING = 'nothing_customer_facing';
|