@startsimpli/integrations 0.2.35 → 0.2.37
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/README.md +2 -0
- package/package.json +23 -6
- package/src/__tests__/ingest.test.ts +127 -0
- package/src/__tests__/safe-fetch-pinning.test.ts +198 -0
- package/src/__tests__/safe-fetch.test.ts +186 -0
- package/src/comms/__tests__/api.test.ts +327 -0
- package/src/comms/__tests__/customer-emails-page.test.tsx +678 -0
- package/src/comms/__tests__/fixtures.ts +133 -0
- package/src/comms/api/_fetch.ts +103 -0
- package/src/comms/api/comms.ts +120 -0
- package/src/comms/api/index.ts +2 -0
- package/src/comms/components/ApprovalModeSetting.tsx +49 -0
- package/src/comms/components/DraftReview.tsx +162 -0
- package/src/comms/components/EmailPreview.tsx +28 -0
- package/src/comms/components/ProductUpdates.tsx +178 -0
- package/src/comms/components/ProgramToggles.tsx +93 -0
- package/src/comms/components/ResultsPanel.tsx +50 -0
- package/src/comms/components/SendLog.tsx +118 -0
- package/src/comms/components/SendingDomain.tsx +120 -0
- package/src/comms/components/SequenceSettings.tsx +280 -0
- package/src/comms/components/VoiceSettings.tsx +84 -0
- package/src/comms/components/_shared.tsx +55 -0
- package/src/comms/components/index.ts +11 -0
- package/src/comms/components/pages/CustomerEmailsPage.tsx +112 -0
- package/src/comms/hooks/index.ts +253 -0
- package/src/comms/index.ts +14 -0
- package/src/comms/types/index.ts +308 -0
- package/src/ingest/index.ts +126 -0
- package/src/safe-fetch/index.ts +387 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire fixtures shaped exactly like ADR startsim-bzcrj section 2. Shared by the
|
|
3
|
+
* client and page suites so both pin the same contract.
|
|
4
|
+
*/
|
|
5
|
+
import type {
|
|
6
|
+
StepGeneration,
|
|
7
|
+
CommsMetrics,
|
|
8
|
+
CommsSettings,
|
|
9
|
+
Draft,
|
|
10
|
+
BroadcastProgram,
|
|
11
|
+
SendRow,
|
|
12
|
+
SequenceProgram,
|
|
13
|
+
} from '../types/index';
|
|
14
|
+
|
|
15
|
+
export const BASE = 'https://api.test';
|
|
16
|
+
export const SLUG = 'crochets';
|
|
17
|
+
export const P = `${BASE}/api/v1/foundry/tenants/${SLUG}/comms`;
|
|
18
|
+
|
|
19
|
+
export const settings = (over: Partial<CommsSettings> = {}): CommsSettings => ({
|
|
20
|
+
approval_mode: 'approve',
|
|
21
|
+
voice: {
|
|
22
|
+
product: 'AI crochet patterns from a photo',
|
|
23
|
+
tone: 'warm, plain',
|
|
24
|
+
audience: 'hobby crocheters',
|
|
25
|
+
cta_url: 'https://crochets.site/app',
|
|
26
|
+
avoid_words: ['synergy'],
|
|
27
|
+
},
|
|
28
|
+
sender_name: 'Crochets',
|
|
29
|
+
reply_to: '',
|
|
30
|
+
postal_address: '',
|
|
31
|
+
sending_domain: '',
|
|
32
|
+
domain_status: 'none',
|
|
33
|
+
domain_records: [],
|
|
34
|
+
domain_checked_at: null,
|
|
35
|
+
paused: false,
|
|
36
|
+
source_repos: [],
|
|
37
|
+
from_address: 'Crochets <crochets@mail.startsimpli.com>',
|
|
38
|
+
...over,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const comeback = (over: Partial<SequenceProgram> = {}): SequenceProgram => ({
|
|
42
|
+
key: 'comeback',
|
|
43
|
+
kind: 'sequence',
|
|
44
|
+
category: 'lifecycle',
|
|
45
|
+
enabled: true,
|
|
46
|
+
version: 3,
|
|
47
|
+
config: {
|
|
48
|
+
trigger: { type: 'inactive', days: 14 },
|
|
49
|
+
steps: [
|
|
50
|
+
{
|
|
51
|
+
offset_days: 0,
|
|
52
|
+
intent: 'nudge',
|
|
53
|
+
content: { subject: 'Your hook misses you', preheader: 'New patterns', html: '<p>Hi {{first_name}}</p>', text: 'Hi {{first_name}}' },
|
|
54
|
+
content_status: 'approved',
|
|
55
|
+
generated_at: '2026-10-10T00:00:00Z',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
offset_days: 5,
|
|
59
|
+
intent: 'value_reminder',
|
|
60
|
+
content: { subject: 'Three patterns to try', preheader: '', html: '<p>Try these</p>', text: 'Try these' },
|
|
61
|
+
content_status: 'draft',
|
|
62
|
+
generated_at: '2026-10-10T00:00:00Z',
|
|
63
|
+
},
|
|
64
|
+
{ offset_days: 10, intent: 'last_call', content: null, content_status: 'empty', generated_at: null },
|
|
65
|
+
],
|
|
66
|
+
exit: ['active', 'unsubscribed'],
|
|
67
|
+
cooldown_days: 30,
|
|
68
|
+
},
|
|
69
|
+
...over,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const productUpdate = (over: Partial<BroadcastProgram> = {}): BroadcastProgram => ({
|
|
73
|
+
key: 'product_update',
|
|
74
|
+
kind: 'broadcast',
|
|
75
|
+
category: 'product_updates',
|
|
76
|
+
enabled: false,
|
|
77
|
+
version: 1,
|
|
78
|
+
config: { cadence: 'on_deploy', audience: 'all', active_days: 14 },
|
|
79
|
+
...over,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const draft = (over: Partial<Draft> = {}): Draft => ({
|
|
83
|
+
id: 'd-1',
|
|
84
|
+
program_key: 'product_update',
|
|
85
|
+
subject: 'New: stitch counter',
|
|
86
|
+
preheader: 'Count rows hands-free',
|
|
87
|
+
html: '<h1>Stitch counter</h1>',
|
|
88
|
+
text: 'Stitch counter',
|
|
89
|
+
approval: 'pending',
|
|
90
|
+
item_keys: ['gh:pr:qosha1/crochet#412'],
|
|
91
|
+
window: ['2026-10-01T00:00:00Z', '2026-10-15T00:00:00Z'],
|
|
92
|
+
created_at: '2026-10-15T09:00:00Z',
|
|
93
|
+
error: null,
|
|
94
|
+
generating: false,
|
|
95
|
+
...over,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
export const metrics = (over: Partial<CommsMetrics> = {}): CommsMetrics => ({
|
|
99
|
+
sent: 120,
|
|
100
|
+
delivered: 117,
|
|
101
|
+
opened: 61,
|
|
102
|
+
clicked: 19,
|
|
103
|
+
bounced: 3,
|
|
104
|
+
complained: 0,
|
|
105
|
+
unsubscribed: 2,
|
|
106
|
+
came_back: 11,
|
|
107
|
+
...over,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export function sendRows(n: number, offset = 0): SendRow[] {
|
|
111
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
112
|
+
id: `s-${offset + i + 1}`,
|
|
113
|
+
email: `customer${offset + i + 1}@example.com`,
|
|
114
|
+
program_key: 'comeback',
|
|
115
|
+
step_index: 0,
|
|
116
|
+
subject: 'Your hook misses you',
|
|
117
|
+
status: 'delivered',
|
|
118
|
+
sent_at: '2026-10-14T10:00:00Z',
|
|
119
|
+
opened_at: null,
|
|
120
|
+
clicked_at: null,
|
|
121
|
+
error: null,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const generation = (over: Partial<StepGeneration> = {}): StepGeneration => ({
|
|
126
|
+
step_index: 2,
|
|
127
|
+
status: 'pending',
|
|
128
|
+
error: '',
|
|
129
|
+
requested_at: '2026-10-16T10:00:00Z',
|
|
130
|
+
finished_at: null,
|
|
131
|
+
step: null,
|
|
132
|
+
...over,
|
|
133
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one HTTP door for @startsimpli/integrations/comms (ADR startsim-bzcrj
|
|
3
|
+
* section 4, L8).
|
|
4
|
+
*
|
|
5
|
+
* Same shape as the package root's `api/_fetch.ts`, but its own module state: base '/api/v1'
|
|
6
|
+
* (overridable via setApiBase), a module-level bearer provider the mounting app
|
|
7
|
+
* wires once (setAuthTokenProvider), JSON in/out, and NO key transform, so the
|
|
8
|
+
* snake_case wire types in ../types hold. Non-2xx becomes CommsApiError carrying
|
|
9
|
+
* the HTTP status, so the composer can tell "not an admin" (403) from an outage.
|
|
10
|
+
*/
|
|
11
|
+
import { CommsApiError, type CommsErrorPayload } from '../types/index';
|
|
12
|
+
|
|
13
|
+
let _apiBase = '/api/v1';
|
|
14
|
+
let _tokenProvider: (() => string | null | Promise<string | null>) | null = null;
|
|
15
|
+
|
|
16
|
+
export function setApiBase(base: string): void {
|
|
17
|
+
_apiBase = base.replace(/\/$/, '');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getApiBase(): string {
|
|
21
|
+
return _apiBase;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function setAuthTokenProvider(
|
|
25
|
+
provider: (() => string | null | Promise<string | null>) | null,
|
|
26
|
+
): void {
|
|
27
|
+
_tokenProvider = provider;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RequestOptions {
|
|
31
|
+
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
|
32
|
+
body?: unknown;
|
|
33
|
+
query?: Record<string, string | number | boolean | undefined | null>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
|
37
|
+
const { method = 'GET', body, query } = opts;
|
|
38
|
+
let url = `${_apiBase}${path.startsWith('/') ? path : '/' + path}`;
|
|
39
|
+
if (query) {
|
|
40
|
+
const params = new URLSearchParams();
|
|
41
|
+
for (const [k, v] of Object.entries(query)) {
|
|
42
|
+
if (v === undefined || v === null || v === '') continue;
|
|
43
|
+
params.set(k, String(v));
|
|
44
|
+
}
|
|
45
|
+
const qs = params.toString();
|
|
46
|
+
if (qs) url += `?${qs}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
50
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
51
|
+
if (_tokenProvider) {
|
|
52
|
+
try {
|
|
53
|
+
const token = await _tokenProvider();
|
|
54
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
55
|
+
} catch {
|
|
56
|
+
// Provider failure surfaces as Django's 401 below.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const resp = await fetch(url, {
|
|
61
|
+
method,
|
|
62
|
+
headers,
|
|
63
|
+
credentials: 'include',
|
|
64
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const contentType = resp.headers.get('Content-Type') || '';
|
|
68
|
+
let payload: unknown = null;
|
|
69
|
+
try {
|
|
70
|
+
payload = contentType.includes('application/json') ? await resp.json() : await resp.text();
|
|
71
|
+
} catch {
|
|
72
|
+
payload = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!resp.ok) {
|
|
76
|
+
const p: CommsErrorPayload = payload && typeof payload === 'object' ? (payload as CommsErrorPayload) : {};
|
|
77
|
+
throw new CommsApiError(errorMessage(p) || `HTTP ${resp.status}`, resp.status, p);
|
|
78
|
+
}
|
|
79
|
+
return payload as T;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* One readable line from a DRF error body: `error` / `detail` / `reason`, else
|
|
84
|
+
* the field errors (`{"domain": ["..."]}`, `{"config": {"steps[1].offset_days": "..."}}`)
|
|
85
|
+
* flattened to "domain: ...; steps[1].offset_days: ...".
|
|
86
|
+
*/
|
|
87
|
+
export function errorMessage(payload: unknown): string {
|
|
88
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
89
|
+
const p = payload as Record<string, unknown>;
|
|
90
|
+
for (const k of ['error', 'detail', 'reason']) {
|
|
91
|
+
if (typeof p[k] === 'string' && p[k]) return p[k] as string;
|
|
92
|
+
}
|
|
93
|
+
const parts: string[] = [];
|
|
94
|
+
const walk = (key: string, v: unknown) => {
|
|
95
|
+
if (typeof v === 'string') parts.push(key ? `${key}: ${v}` : v);
|
|
96
|
+
else if (Array.isArray(v)) v.forEach((x) => walk(key, x));
|
|
97
|
+
else if (v && typeof v === 'object') {
|
|
98
|
+
for (const [k, x] of Object.entries(v)) walk(key === 'config' || !key ? k : `${key}.${k}`, x);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
walk('', p);
|
|
102
|
+
return parts.join('; ');
|
|
103
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommsApi: the foundry comms admin routes (ADR startsim-bzcrj section 2),
|
|
3
|
+
* bound to ONE foundry by `createCommsApi(slug)`.
|
|
4
|
+
*
|
|
5
|
+
* P = /foundry/tenants/{slug}/comms (under the /api/v1 base)
|
|
6
|
+
*
|
|
7
|
+
* The composer takes a CommsApi as a prop, so tests and other hosts can hand
|
|
8
|
+
* it a stub; the real one goes through ./_fetch `request()`.
|
|
9
|
+
*
|
|
10
|
+
* Two routes are asynchronous on the server (model calls outlast a web worker):
|
|
11
|
+
* - steps/{i}/generate/: POST answers 202 with a StepGeneration; poll
|
|
12
|
+
* getStepGeneration until status is done or failed.
|
|
13
|
+
* - programs/product_update/send-now/: 202 with a draft whose `generating` is
|
|
14
|
+
* true; poll getDraft(id) until it is false.
|
|
15
|
+
*/
|
|
16
|
+
import type {
|
|
17
|
+
ChangesResponse,
|
|
18
|
+
CommsMetrics,
|
|
19
|
+
CommsProgram,
|
|
20
|
+
CommsSettings,
|
|
21
|
+
CommsSettingsPatch,
|
|
22
|
+
Draft,
|
|
23
|
+
DraftPatch,
|
|
24
|
+
DraftsQuery,
|
|
25
|
+
MetricsQuery,
|
|
26
|
+
Paginated,
|
|
27
|
+
ProgramPatch,
|
|
28
|
+
ProgramUpdateResult,
|
|
29
|
+
RenderedEmail,
|
|
30
|
+
SendNowResult,
|
|
31
|
+
SendRow,
|
|
32
|
+
SendsQuery,
|
|
33
|
+
StepGeneration,
|
|
34
|
+
TestSendInput,
|
|
35
|
+
TestSendResult,
|
|
36
|
+
} from '../types/index';
|
|
37
|
+
import { request } from './_fetch';
|
|
38
|
+
|
|
39
|
+
export interface CommsApi {
|
|
40
|
+
/** The foundry this client is bound to. */
|
|
41
|
+
readonly slug: string;
|
|
42
|
+
|
|
43
|
+
// settings
|
|
44
|
+
getSettings(): Promise<CommsSettings>;
|
|
45
|
+
updateSettings(patch: CommsSettingsPatch): Promise<CommsSettings>;
|
|
46
|
+
connectDomain(domain: string): Promise<CommsSettings>;
|
|
47
|
+
checkDomain(): Promise<CommsSettings>;
|
|
48
|
+
|
|
49
|
+
// programs
|
|
50
|
+
listPrograms(): Promise<CommsProgram[]>;
|
|
51
|
+
getProgram(key: string): Promise<CommsProgram>;
|
|
52
|
+
updateProgram(key: string, patch: ProgramPatch): Promise<ProgramUpdateResult>;
|
|
53
|
+
/** Queues the model call (202). Poll getStepGeneration for the outcome. */
|
|
54
|
+
generateStep(key: string, stepIndex: number, input?: { instructions?: string }): Promise<StepGeneration>;
|
|
55
|
+
getStepGeneration(key: string, stepIndex: number): Promise<StepGeneration>;
|
|
56
|
+
approveStep(key: string, stepIndex: number): Promise<CommsProgram>;
|
|
57
|
+
previewStep(key: string, stepIndex: number): Promise<RenderedEmail>;
|
|
58
|
+
|
|
59
|
+
// test send (always and only to the requesting admin)
|
|
60
|
+
sendTest(input: TestSendInput): Promise<TestSendResult>;
|
|
61
|
+
|
|
62
|
+
// product updates
|
|
63
|
+
listChanges(query?: { since?: string; until?: string }): Promise<ChangesResponse>;
|
|
64
|
+
listDrafts(query?: DraftsQuery): Promise<Paginated<Draft>>;
|
|
65
|
+
getDraft(id: string): Promise<Draft>;
|
|
66
|
+
updateDraft(id: string, patch: DraftPatch): Promise<Draft>;
|
|
67
|
+
approveDraft(id: string): Promise<Draft>;
|
|
68
|
+
discardDraft(id: string): Promise<Draft>;
|
|
69
|
+
previewDraft(id: string): Promise<RenderedEmail>;
|
|
70
|
+
sendUpdateNow(input?: { notes?: string }): Promise<SendNowResult>;
|
|
71
|
+
|
|
72
|
+
// results
|
|
73
|
+
listSends(query?: SendsQuery): Promise<Paginated<SendRow>>;
|
|
74
|
+
getMetrics(query?: MetricsQuery): Promise<CommsMetrics>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The path prefix for one foundry's comms routes, relative to the api base. */
|
|
78
|
+
export function commsBasePath(slug: string): string {
|
|
79
|
+
return `/foundry/tenants/${encodeURIComponent(slug)}/comms`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const seg = encodeURIComponent;
|
|
83
|
+
|
|
84
|
+
export function createCommsApi(slug: string): CommsApi {
|
|
85
|
+
const P = commsBasePath(slug);
|
|
86
|
+
const step = (key: string, i: number, action: string) =>
|
|
87
|
+
`${P}/programs/${seg(key)}/steps/${i}/${action}/`;
|
|
88
|
+
const draftPath = (id: string, action = '') => `${P}/drafts/${seg(id)}/${action ? `${action}/` : ''}`;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
slug,
|
|
92
|
+
getSettings: () => request(`${P}/settings/`),
|
|
93
|
+
updateSettings: (patch) => request(`${P}/settings/`, { method: 'PATCH', body: patch }),
|
|
94
|
+
connectDomain: (domain) => request(`${P}/settings/domain/`, { method: 'POST', body: { domain } }),
|
|
95
|
+
checkDomain: () => request(`${P}/settings/domain/check/`, { method: 'POST' }),
|
|
96
|
+
|
|
97
|
+
listPrograms: () => request(`${P}/programs/`),
|
|
98
|
+
getProgram: (key) => request(`${P}/programs/${seg(key)}/`),
|
|
99
|
+
updateProgram: (key, patch) => request(`${P}/programs/${seg(key)}/`, { method: 'PATCH', body: patch }),
|
|
100
|
+
generateStep: (key, i, input = {}) => request(step(key, i, 'generate'), { method: 'POST', body: input }),
|
|
101
|
+
getStepGeneration: (key, i) => request(step(key, i, 'generate')),
|
|
102
|
+
approveStep: (key, i) => request(step(key, i, 'approve'), { method: 'POST' }),
|
|
103
|
+
previewStep: (key, i) => request(step(key, i, 'preview')),
|
|
104
|
+
|
|
105
|
+
sendTest: (input) => request(`${P}/test-send/`, { method: 'POST', body: input }),
|
|
106
|
+
|
|
107
|
+
listChanges: (query = {}) => request(`${P}/changes/`, { query: { ...query } }),
|
|
108
|
+
listDrafts: (query = {}) => request(`${P}/drafts/`, { query: { ...query } }),
|
|
109
|
+
getDraft: (id) => request(draftPath(id)),
|
|
110
|
+
updateDraft: (id, patch) => request(draftPath(id), { method: 'PATCH', body: patch }),
|
|
111
|
+
approveDraft: (id) => request(draftPath(id, 'approve'), { method: 'POST' }),
|
|
112
|
+
discardDraft: (id) => request(draftPath(id, 'discard'), { method: 'POST' }),
|
|
113
|
+
previewDraft: (id) => request(draftPath(id, 'preview')),
|
|
114
|
+
sendUpdateNow: (input = {}) =>
|
|
115
|
+
request(`${P}/programs/product_update/send-now/`, { method: 'POST', body: input }),
|
|
116
|
+
|
|
117
|
+
listSends: (query = {}) => request(`${P}/sends/`, { query: { ...query } }),
|
|
118
|
+
getMetrics: (query = {}) => request(`${P}/metrics/`, { query: { ...query } }),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-foundry "send automatically" vs "I approve each email" (A4, decision
|
|
3
|
+
* 13o60=c). PATCH settings.approval_mode.
|
|
4
|
+
*/
|
|
5
|
+
import type { ReactElement } from 'react';
|
|
6
|
+
|
|
7
|
+
import type { CommsApi } from '../api/comms';
|
|
8
|
+
import { useCommsSettings, useUpdateCommsSettings } from '../hooks/index';
|
|
9
|
+
import type { ApprovalMode } from '../types/index';
|
|
10
|
+
import { ErrorNotice, useFieldId } from './_shared';
|
|
11
|
+
|
|
12
|
+
export interface ApprovalModeSettingProps {
|
|
13
|
+
api: CommsApi;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const OPTIONS: { value: ApprovalMode; label: string; hint: string }[] = [
|
|
17
|
+
{ value: 'auto', label: 'Send automatically', hint: 'AI-written emails go out without waiting for you.' },
|
|
18
|
+
{ value: 'approve', label: 'I approve each email', hint: 'Nothing new goes out until you approve it.' },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function ApprovalModeSetting({ api }: ApprovalModeSettingProps): ReactElement | null {
|
|
22
|
+
const { data: settings } = useCommsSettings(api);
|
|
23
|
+
const update = useUpdateCommsSettings(api);
|
|
24
|
+
const name = useFieldId('comms-approval');
|
|
25
|
+
if (!settings) return null;
|
|
26
|
+
return (
|
|
27
|
+
<fieldset className="space-y-2">
|
|
28
|
+
<legend className="sr-only">Approval</legend>
|
|
29
|
+
{OPTIONS.map((o) => (
|
|
30
|
+
<label key={o.value} className="flex items-start gap-2 text-sm">
|
|
31
|
+
<input
|
|
32
|
+
type="radio"
|
|
33
|
+
name={name}
|
|
34
|
+
value={o.value}
|
|
35
|
+
checked={settings.approval_mode === o.value}
|
|
36
|
+
disabled={update.isPending}
|
|
37
|
+
onChange={() => update.mutate({ approval_mode: o.value })}
|
|
38
|
+
className="mt-1"
|
|
39
|
+
/>
|
|
40
|
+
<span>
|
|
41
|
+
<span className="font-medium">{o.label}</span>
|
|
42
|
+
<span className="block text-muted-foreground">{o.hint}</span>
|
|
43
|
+
</span>
|
|
44
|
+
</label>
|
|
45
|
+
))}
|
|
46
|
+
<ErrorNotice error={update.error} />
|
|
47
|
+
</fieldset>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending product-update drafts (A4): review, edit subject/preheader/body,
|
|
3
|
+
* preview (server-rendered, G2), send a test, approve or discard.
|
|
4
|
+
*
|
|
5
|
+
* A draft still being written shows only its state (the server refuses edits,
|
|
6
|
+
* previews and approval until it is). A draft whose generation failed can only
|
|
7
|
+
* be discarded; send-now again retries the same window.
|
|
8
|
+
*/
|
|
9
|
+
import { useState, type ReactElement } from 'react';
|
|
10
|
+
import { Button, Input, Label, Textarea } from '@startsimpli/ui';
|
|
11
|
+
|
|
12
|
+
import type { CommsApi } from '../api/comms';
|
|
13
|
+
import {
|
|
14
|
+
DEFAULT_POLL_MS,
|
|
15
|
+
useApproveDraft,
|
|
16
|
+
useDiscardDraft,
|
|
17
|
+
useDraftPreview,
|
|
18
|
+
useDrafts,
|
|
19
|
+
useSendTest,
|
|
20
|
+
useUpdateDraft,
|
|
21
|
+
} from '../hooks/index';
|
|
22
|
+
import type { Draft, DraftPatch } from '../types/index';
|
|
23
|
+
import { EmailPreview } from './EmailPreview';
|
|
24
|
+
import { ErrorNotice, Note, formatDate, humanize, useFieldId } from './_shared';
|
|
25
|
+
|
|
26
|
+
export interface DraftReviewProps {
|
|
27
|
+
api: CommsApi;
|
|
28
|
+
pollIntervalMs?: number;
|
|
29
|
+
/** Drafts per server page. Default 10. */
|
|
30
|
+
pageSize?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function DraftEditor({ api, draft, onDone }: { api: CommsApi; draft: Draft; onDone: () => void }) {
|
|
34
|
+
const update = useUpdateDraft(api);
|
|
35
|
+
const prefix = useFieldId(`comms-draft-${draft.id}`);
|
|
36
|
+
const [f, setF] = useState<Required<DraftPatch>>({
|
|
37
|
+
subject: draft.subject,
|
|
38
|
+
preheader: draft.preheader,
|
|
39
|
+
html: draft.html,
|
|
40
|
+
text: draft.text,
|
|
41
|
+
});
|
|
42
|
+
const changed = (Object.keys(f) as (keyof DraftPatch)[]).filter((k) => f[k] !== draft[k]);
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<form
|
|
46
|
+
className="mt-2 space-y-3 rounded-md border p-3"
|
|
47
|
+
onSubmit={(e) => {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
if (!changed.length) return onDone();
|
|
50
|
+
const patch = Object.fromEntries(changed.map((k) => [k, f[k]])) as DraftPatch;
|
|
51
|
+
update.mutate({ id: draft.id, patch }, { onSuccess: onDone });
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{(['subject', 'preheader'] as const).map((k) => (
|
|
55
|
+
<div key={k} className="space-y-1">
|
|
56
|
+
<Label htmlFor={`${prefix}-${k}`}>{k === 'subject' ? 'Subject' : 'Preheader'}</Label>
|
|
57
|
+
<Input id={`${prefix}-${k}`} value={f[k]} onChange={(e) => setF({ ...f, [k]: e.target.value })} />
|
|
58
|
+
</div>
|
|
59
|
+
))}
|
|
60
|
+
{(['html', 'text'] as const).map((k) => (
|
|
61
|
+
<div key={k} className="space-y-1">
|
|
62
|
+
<Label htmlFor={`${prefix}-${k}`}>{k === 'html' ? 'Body (HTML)' : 'Plain text'}</Label>
|
|
63
|
+
<Textarea
|
|
64
|
+
id={`${prefix}-${k}`}
|
|
65
|
+
rows={k === 'html' ? 10 : 6}
|
|
66
|
+
className="font-mono text-xs"
|
|
67
|
+
value={f[k]}
|
|
68
|
+
onChange={(e) => setF({ ...f, [k]: e.target.value })}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
))}
|
|
72
|
+
<div className="flex gap-2">
|
|
73
|
+
<Button type="submit" disabled={update.isPending}>Save draft</Button>
|
|
74
|
+
<Button type="button" variant="ghost" onClick={onDone}>Cancel</Button>
|
|
75
|
+
</div>
|
|
76
|
+
<ErrorNotice error={update.error} />
|
|
77
|
+
</form>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
|
|
82
|
+
const [editing, setEditing] = useState(false);
|
|
83
|
+
const [previewing, setPreviewing] = useState(false);
|
|
84
|
+
const ready = !draft.generating && !draft.error;
|
|
85
|
+
const preview = useDraftPreview(api, draft.id, previewing && ready);
|
|
86
|
+
const sendTest = useSendTest(api);
|
|
87
|
+
const approve = useApproveDraft(api);
|
|
88
|
+
const discard = useDiscardDraft(api);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<li className="py-3">
|
|
92
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
|
93
|
+
<span className="min-w-[10rem] flex-1 font-medium">
|
|
94
|
+
{draft.generating ? 'Writing this update…' : draft.subject || '(no subject)'}
|
|
95
|
+
</span>
|
|
96
|
+
<span className="text-sm text-muted-foreground">{formatDate(draft.created_at)}</span>
|
|
97
|
+
<div className="flex flex-wrap gap-2">
|
|
98
|
+
{ready ? (
|
|
99
|
+
<>
|
|
100
|
+
<Button type="button" size="sm" variant="outline" onClick={() => setPreviewing((v) => !v)}>Preview</Button>
|
|
101
|
+
<Button type="button" size="sm" variant="outline" onClick={() => setEditing((v) => !v)}>Edit</Button>
|
|
102
|
+
<Button
|
|
103
|
+
type="button"
|
|
104
|
+
size="sm"
|
|
105
|
+
variant="outline"
|
|
106
|
+
disabled={sendTest.isPending}
|
|
107
|
+
onClick={() => sendTest.mutate({ draft_id: draft.id })}
|
|
108
|
+
>
|
|
109
|
+
Send test to me
|
|
110
|
+
</Button>
|
|
111
|
+
<Button type="button" size="sm" disabled={approve.isPending} onClick={() => approve.mutate(draft.id)}>
|
|
112
|
+
Approve and send
|
|
113
|
+
</Button>
|
|
114
|
+
</>
|
|
115
|
+
) : null}
|
|
116
|
+
{!draft.generating ? (
|
|
117
|
+
<Button type="button" size="sm" variant="ghost" disabled={discard.isPending} onClick={() => discard.mutate(draft.id)}>
|
|
118
|
+
Discard
|
|
119
|
+
</Button>
|
|
120
|
+
) : null}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
{draft.error ? <ErrorNotice error={new Error(`Writing this update failed: ${humanize(draft.error)}`)} /> : null}
|
|
124
|
+
{sendTest.data?.sent ? <Note>Test sent to you.</Note> : null}
|
|
125
|
+
{sendTest.data && !sendTest.data.sent ? (
|
|
126
|
+
<ErrorNotice error={new Error(`Test not sent: ${humanize(sendTest.data.reason || 'refused')}`)} />
|
|
127
|
+
) : null}
|
|
128
|
+
<ErrorNotice error={sendTest.error ?? approve.error ?? discard.error ?? preview.error} />
|
|
129
|
+
{editing && ready ? <DraftEditor api={api} draft={draft} onDone={() => setEditing(false)} /> : null}
|
|
130
|
+
{previewing && preview.data ? <EmailPreview email={preview.data} /> : null}
|
|
131
|
+
</li>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function DraftReview({ api, pollIntervalMs = DEFAULT_POLL_MS, pageSize = 10 }: DraftReviewProps): ReactElement | null {
|
|
136
|
+
const [page, setPage] = useState(1);
|
|
137
|
+
const drafts = useDrafts(api, { status: 'pending', page, page_size: pageSize }, pollIntervalMs);
|
|
138
|
+
if (drafts.error) return <ErrorNotice error={drafts.error} />;
|
|
139
|
+
if (!drafts.data) return null;
|
|
140
|
+
const { results, count } = drafts.data;
|
|
141
|
+
if (!results.length) return <Note>No updates are waiting for approval.</Note>;
|
|
142
|
+
return (
|
|
143
|
+
<div className="space-y-1">
|
|
144
|
+
<h3 className="text-sm font-medium">Waiting for approval</h3>
|
|
145
|
+
<ul className="divide-y">
|
|
146
|
+
{results.map((d) => (
|
|
147
|
+
<DraftItem key={d.id} api={api} draft={d} />
|
|
148
|
+
))}
|
|
149
|
+
</ul>
|
|
150
|
+
{count > pageSize ? (
|
|
151
|
+
<div className="flex items-center gap-2 text-sm">
|
|
152
|
+
<Button type="button" size="sm" variant="outline" disabled={!drafts.data.previous} onClick={() => setPage(page - 1)}>
|
|
153
|
+
Previous
|
|
154
|
+
</Button>
|
|
155
|
+
<Button type="button" size="sm" variant="outline" disabled={!drafts.data.next} onClick={() => setPage(page + 1)}>
|
|
156
|
+
Next
|
|
157
|
+
</Button>
|
|
158
|
+
</div>
|
|
159
|
+
) : null}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders a server-rendered email (subject, preheader, html) as the customer
|
|
3
|
+
* would see it: html inside a sandboxed <iframe srcDoc>, never injected into
|
|
4
|
+
* the admin page (email CSS would leak, and DOMPurify would strip it).
|
|
5
|
+
*/
|
|
6
|
+
import type { ReactElement } from 'react';
|
|
7
|
+
import type { RenderedEmail } from '../types/index';
|
|
8
|
+
|
|
9
|
+
export interface EmailPreviewProps {
|
|
10
|
+
email: RenderedEmail;
|
|
11
|
+
title?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function EmailPreview({ email, title = 'Email preview' }: EmailPreviewProps): ReactElement {
|
|
15
|
+
return (
|
|
16
|
+
<div className="mt-2 rounded-md border">
|
|
17
|
+
<div className="border-b px-3 py-2 text-sm">
|
|
18
|
+
<div>
|
|
19
|
+
<span className="text-muted-foreground">Subject: </span>
|
|
20
|
+
<span className="font-medium">{email.subject}</span>
|
|
21
|
+
</div>
|
|
22
|
+
{email.preheader ? <div className="text-muted-foreground">{email.preheader}</div> : null}
|
|
23
|
+
</div>
|
|
24
|
+
{/* sandbox="" : no scripts, no same-origin, no navigation of the admin page. */}
|
|
25
|
+
<iframe title={title} sandbox="" srcDoc={email.html} className="h-[480px] w-full bg-white" />
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|