@startsimpli/integrations 0.2.46 → 0.2.48
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__/announcement-composer.test.tsx +2 -1
- package/src/comms/__tests__/api.test.ts +17 -0
- package/src/comms/__tests__/audience-panel.test.tsx +155 -0
- package/src/comms/__tests__/scheduled-send.test.tsx +304 -0
- package/src/comms/api/_fetch.ts +1 -1
- package/src/comms/api/comms.ts +30 -4
- package/src/comms/components/AudiencePanel.tsx +221 -0
- package/src/comms/components/DraftReview.tsx +141 -28
- package/src/comms/components/SchedulePicker.tsx +53 -0
- package/src/comms/components/ScheduledSends.tsx +103 -0
- package/src/comms/components/SendLog.tsx +36 -1
- package/src/comms/components/_shared.tsx +35 -0
- package/src/comms/components/index.ts +3 -0
- package/src/comms/hooks/index.ts +76 -4
- package/src/comms/types/index.ts +128 -1
|
@@ -41,6 +41,41 @@ export function formatDate(iso: string | null | undefined): string {
|
|
|
41
41
|
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** "Nov 3, 2026, 10:00 AM EST" in `timeZone` (IANA), or the viewer's zone when absent or unknown. */
|
|
45
|
+
export function formatDateTime(iso: string | null | undefined, timeZone?: string | null): string {
|
|
46
|
+
if (!iso) return '';
|
|
47
|
+
const d = new Date(iso);
|
|
48
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
49
|
+
const opts: Intl.DateTimeFormatOptions = {
|
|
50
|
+
year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short',
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
return d.toLocaleString(undefined, timeZone ? { ...opts, timeZone } : opts);
|
|
54
|
+
} catch {
|
|
55
|
+
return d.toLocaleString(undefined, opts);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The viewer's IANA zone, or 'UTC' when the runtime cannot say. */
|
|
60
|
+
export function localTimeZone(): string {
|
|
61
|
+
try {
|
|
62
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
63
|
+
} catch {
|
|
64
|
+
return 'UTC';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** IANA zones for a picker: the runtime's list when it has one, always with the viewer's zone and UTC. */
|
|
69
|
+
export function timeZoneOptions(): string[] {
|
|
70
|
+
let zones: string[] = [];
|
|
71
|
+
try {
|
|
72
|
+
zones = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf?.('timeZone') ?? [];
|
|
73
|
+
} catch {
|
|
74
|
+
zones = [];
|
|
75
|
+
}
|
|
76
|
+
return Array.from(new Set([localTimeZone(), 'UTC', ...zones]));
|
|
77
|
+
}
|
|
78
|
+
|
|
44
79
|
/** A readable reason code: "not_allowlisted" -> "not allowlisted". */
|
|
45
80
|
export function humanize(code: string): string {
|
|
46
81
|
return code.replace(/_/g, ' ');
|
|
@@ -7,5 +7,8 @@ export * from './VoiceSettings';
|
|
|
7
7
|
export * from './SendingDomain';
|
|
8
8
|
export * from './ProductUpdates';
|
|
9
9
|
export * from './DraftReview';
|
|
10
|
+
export * from './SchedulePicker';
|
|
11
|
+
export * from './ScheduledSends';
|
|
10
12
|
export * from './ResultsPanel';
|
|
11
13
|
export * from './SendLog';
|
|
14
|
+
export * from './AudiencePanel';
|
package/src/comms/hooks/index.ts
CHANGED
|
@@ -20,12 +20,15 @@ import {
|
|
|
20
20
|
import type { CommsApi } from '../api/comms';
|
|
21
21
|
import type {
|
|
22
22
|
AssetsQuery,
|
|
23
|
+
AudienceSegments,
|
|
24
|
+
AudienceSpec,
|
|
23
25
|
CommsAsset,
|
|
24
26
|
CommsMetrics,
|
|
25
27
|
CommsProgram,
|
|
26
28
|
CommsSettings,
|
|
27
29
|
CommsSettingsPatch,
|
|
28
30
|
Draft,
|
|
31
|
+
DraftAudience,
|
|
29
32
|
DraftCreate,
|
|
30
33
|
DraftPatch,
|
|
31
34
|
DraftsQuery,
|
|
@@ -35,7 +38,8 @@ import type {
|
|
|
35
38
|
ProgramUpdateResult,
|
|
36
39
|
RenderedEmail,
|
|
37
40
|
SendNowResult,
|
|
38
|
-
|
|
41
|
+
ScheduleInput,
|
|
42
|
+
SendsPage,
|
|
39
43
|
SendsQuery,
|
|
40
44
|
StepGeneration,
|
|
41
45
|
TestSendInput,
|
|
@@ -55,6 +59,8 @@ export const commsKeys = {
|
|
|
55
59
|
draftsAll: (slug: string) => ['startsimpli', 'comms', slug, 'drafts'] as const,
|
|
56
60
|
drafts: (slug: string, q: DraftsQuery = {}) => ['startsimpli', 'comms', slug, 'drafts', q] as const,
|
|
57
61
|
draft: (slug: string, id: string) => ['startsimpli', 'comms', slug, 'drafts', 'one', id] as const,
|
|
62
|
+
audience: (slug: string, id: string) => ['startsimpli', 'comms', slug, 'audience', id] as const,
|
|
63
|
+
audienceSegments: (slug: string) => ['startsimpli', 'comms', slug, 'audience-segments'] as const,
|
|
58
64
|
draftPreview: (slug: string, id: string) => ['startsimpli', 'comms', slug, 'draft-preview', id] as const,
|
|
59
65
|
sends: (slug: string, q: SendsQuery = {}) => ['startsimpli', 'comms', slug, 'sends', q] as const,
|
|
60
66
|
metrics: (slug: string, q: MetricsQuery = {}) => ['startsimpli', 'comms', slug, 'metrics', q] as const,
|
|
@@ -210,6 +216,41 @@ export function useDraftPreview(api: CommsApi, id: string, enabled: boolean): Us
|
|
|
210
216
|
});
|
|
211
217
|
}
|
|
212
218
|
|
|
219
|
+
// per-send audience -----------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
export function useDraftAudience(api: CommsApi, id: string, enabled: boolean): UseQueryResult<DraftAudience> {
|
|
222
|
+
return useQuery({
|
|
223
|
+
queryKey: commsKeys.audience(api.slug, id),
|
|
224
|
+
queryFn: () => api.getDraftAudience(id),
|
|
225
|
+
enabled,
|
|
226
|
+
staleTime: 0,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function useAudienceSegments(api: CommsApi, enabled: boolean): UseQueryResult<AudienceSegments> {
|
|
231
|
+
return useQuery({
|
|
232
|
+
queryKey: commsKeys.audienceSegments(api.slug),
|
|
233
|
+
queryFn: () => api.listAudienceSegments(),
|
|
234
|
+
enabled,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Who a candidate audience would reach; nothing is saved. */
|
|
239
|
+
export function usePreviewAudience(api: CommsApi, id: string): UseMutationResult<DraftAudience, Error, AudienceSpec> {
|
|
240
|
+
return useMutation({ mutationFn: (spec: AudienceSpec) => api.previewDraftAudience(id, spec) });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function useSaveAudience(api: CommsApi, id: string): UseMutationResult<DraftAudience, Error, AudienceSpec> {
|
|
244
|
+
const qc = useQueryClient();
|
|
245
|
+
return useMutation({
|
|
246
|
+
mutationFn: (spec: AudienceSpec) => api.saveDraftAudience(id, spec),
|
|
247
|
+
onSuccess: (data) => {
|
|
248
|
+
qc.setQueryData(commsKeys.audience(api.slug, id), data);
|
|
249
|
+
return qc.invalidateQueries({ queryKey: commsKeys.draftsAll(api.slug) });
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
213
254
|
function useDraftsWrite<R, V>(api: CommsApi, fn: (v: V) => Promise<R>) {
|
|
214
255
|
const qc = useQueryClient();
|
|
215
256
|
return useMutation<R, Error, V>({
|
|
@@ -234,8 +275,39 @@ export function useUpdateDraft(api: CommsApi): UseMutationResult<Draft, Error, {
|
|
|
234
275
|
});
|
|
235
276
|
}
|
|
236
277
|
|
|
237
|
-
|
|
238
|
-
|
|
278
|
+
/** A draft id to send now, or {id, ...schedule} to schedule it. */
|
|
279
|
+
export type ApproveDraftVars = string | ({ id: string } & Partial<ScheduleInput>);
|
|
280
|
+
|
|
281
|
+
export function useApproveDraft(api: CommsApi): UseMutationResult<Draft, Error, ApproveDraftVars> {
|
|
282
|
+
return useScheduleWrite(api, (v: ApproveDraftVars) => {
|
|
283
|
+
if (typeof v === 'string') return api.approveDraft(v);
|
|
284
|
+
const { id, send_at, timezone } = v;
|
|
285
|
+
return api.approveDraft(id, send_at ? { send_at, timezone } : undefined);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Drafts and the sends report both change when a schedule does. */
|
|
290
|
+
function useScheduleWrite<R, V>(api: CommsApi, fn: (v: V) => Promise<R>) {
|
|
291
|
+
const qc = useQueryClient();
|
|
292
|
+
return useMutation<R, Error, V>({
|
|
293
|
+
mutationFn: fn,
|
|
294
|
+
onSuccess: () => Promise.all([
|
|
295
|
+
qc.invalidateQueries({ queryKey: commsKeys.draftsAll(api.slug) }),
|
|
296
|
+
qc.invalidateQueries({ queryKey: ['startsimpli', 'comms', api.slug, 'sends'] }),
|
|
297
|
+
]),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function useRescheduleDraft(api: CommsApi): UseMutationResult<Draft, Error, { id: string } & ScheduleInput> {
|
|
302
|
+
return useScheduleWrite(api, ({ id, ...schedule }: { id: string } & ScheduleInput) => api.rescheduleDraft(id, schedule));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function useRetryDraft(api: CommsApi): UseMutationResult<Draft, Error, string> {
|
|
306
|
+
return useScheduleWrite(api, (id: string) => api.retryDraft(id));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function useCancelDraft(api: CommsApi): UseMutationResult<Draft, Error, string> {
|
|
310
|
+
return useScheduleWrite(api, (id: string) => api.cancelDraft(id));
|
|
239
311
|
}
|
|
240
312
|
|
|
241
313
|
export function useDiscardDraft(api: CommsApi): UseMutationResult<Draft, Error, string> {
|
|
@@ -272,7 +344,7 @@ export function useDeleteAsset(api: CommsApi): UseMutationResult<void, Error, st
|
|
|
272
344
|
// results ------------------------------------------------------------------
|
|
273
345
|
|
|
274
346
|
/** Server-side pagination: page/page_size go to the server, never sliced here. */
|
|
275
|
-
export function useSends(api: CommsApi, q: SendsQuery): UseQueryResult<
|
|
347
|
+
export function useSends(api: CommsApi, q: SendsQuery): UseQueryResult<SendsPage> {
|
|
276
348
|
return useQuery({
|
|
277
349
|
queryKey: commsKeys.sends(api.slug, q),
|
|
278
350
|
queryFn: () => api.listSends(q),
|
package/src/comms/types/index.ts
CHANGED
|
@@ -194,7 +194,32 @@ export interface ChangesResponse {
|
|
|
194
194
|
unclassified: string[];
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
export type DraftApproval = 'pending' | 'approved' | 'discarded';
|
|
197
|
+
export type DraftApproval = 'pending' | 'scheduled' | 'expired' | 'failed' | 'approved' | 'discarded';
|
|
198
|
+
|
|
199
|
+
/** The named state of a draft/send (server `state`); every one has a reason and a next action. */
|
|
200
|
+
export type DraftState =
|
|
201
|
+
| 'generating' | 'writing_failed' | 'pending' | 'cancelled' | 'scheduled'
|
|
202
|
+
| 'expired' | 'failed' | 'sending' | 'sent' | 'discarded';
|
|
203
|
+
|
|
204
|
+
export type DraftAction =
|
|
205
|
+
| 'approve' | 'schedule' | 'edit' | 'discard' | 'reschedule' | 'send_now' | 'cancel' | 'retry' | 'wait';
|
|
206
|
+
|
|
207
|
+
/** Why a pending or expired draft needs a person (no recipients, paused at the scheduled time...). */
|
|
208
|
+
export interface DraftNotice {
|
|
209
|
+
code: string;
|
|
210
|
+
reason: string;
|
|
211
|
+
/** The original send time, on an expired draft. */
|
|
212
|
+
send_at?: string | null;
|
|
213
|
+
at?: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Why a draft failed, and where: dispatch (before any recipient was queued), queue, or send. */
|
|
217
|
+
export interface DraftFailure {
|
|
218
|
+
stage: 'dispatch' | 'queue' | 'send' | string;
|
|
219
|
+
code: string;
|
|
220
|
+
reason: string;
|
|
221
|
+
at?: string;
|
|
222
|
+
}
|
|
198
223
|
|
|
199
224
|
export interface Draft {
|
|
200
225
|
id: string;
|
|
@@ -211,6 +236,92 @@ export interface Draft {
|
|
|
211
236
|
error: string | null;
|
|
212
237
|
/** True while a worker is still writing the content: poll getDraft(id). */
|
|
213
238
|
generating: boolean;
|
|
239
|
+
/** This send's own audience; null/absent = the program default. */
|
|
240
|
+
audience?: AudienceSpec | null;
|
|
241
|
+
audience_is_default?: boolean;
|
|
242
|
+
/** Recipients queued, set at approve. */
|
|
243
|
+
recipient_count?: number | null;
|
|
244
|
+
/** UTC instant a scheduled draft goes out (approval 'scheduled'; kept after it starts). */
|
|
245
|
+
send_at?: string | null;
|
|
246
|
+
/** IANA zone the admin typed send_at in; display only, never changes when it sends. */
|
|
247
|
+
send_timezone?: string | null;
|
|
248
|
+
/** Set on a pending draft whose schedule was cancelled. */
|
|
249
|
+
cancelled_at?: string | null;
|
|
250
|
+
state?: DraftState;
|
|
251
|
+
/** What the admin can do next; absent on older servers (treated as the pending set). */
|
|
252
|
+
next_actions?: DraftAction[];
|
|
253
|
+
notice?: DraftNotice | null;
|
|
254
|
+
failure?: DraftFailure | null;
|
|
255
|
+
/** The requested send_at was already past, so it was sent right away. */
|
|
256
|
+
sent_immediately?: boolean;
|
|
257
|
+
requested_send_at?: string | null;
|
|
258
|
+
completed_at?: string | null;
|
|
259
|
+
/** A scheduled send whose audience lookup failed and is being retried. */
|
|
260
|
+
dispatch_attempts?: number;
|
|
261
|
+
dispatch_error?: string | null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A tenant-evaluated segment: the key (and params) the foundry's backend understands. */
|
|
265
|
+
export interface SegmentRef {
|
|
266
|
+
key: string;
|
|
267
|
+
params?: Record<string, unknown>;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Who one send goes to. Always narrows the foundry's customers; never widens. */
|
|
271
|
+
export interface AudienceSpec {
|
|
272
|
+
base?: 'all' | 'active' | 'inactive';
|
|
273
|
+
days?: number;
|
|
274
|
+
joined_after?: string;
|
|
275
|
+
joined_before?: string;
|
|
276
|
+
segment?: SegmentRef;
|
|
277
|
+
exclude?: { emails?: string[]; segment?: SegmentRef };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export type ExcludedReason =
|
|
281
|
+
| 'no_email'
|
|
282
|
+
| 'excluded'
|
|
283
|
+
| 'excluded_segment'
|
|
284
|
+
| 'undeliverable'
|
|
285
|
+
| 'unverified'
|
|
286
|
+
| 'opted_out'
|
|
287
|
+
| 'suppressed';
|
|
288
|
+
|
|
289
|
+
export interface AudiencePreview {
|
|
290
|
+
/** Who will receive it. */
|
|
291
|
+
count: number;
|
|
292
|
+
/** Matched the audience before mailability and exclusions. */
|
|
293
|
+
eligible: number;
|
|
294
|
+
excluded: Partial<Record<ExcludedReason, number>>;
|
|
295
|
+
excluded_total: number;
|
|
296
|
+
sample: string[];
|
|
297
|
+
excluded_sample: { email: string; reason: ExcludedReason }[];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface DraftAudience {
|
|
301
|
+
audience: AudienceSpec;
|
|
302
|
+
is_default: boolean;
|
|
303
|
+
preview: AudiencePreview;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export interface AudienceSegment {
|
|
307
|
+
key: string;
|
|
308
|
+
label: string;
|
|
309
|
+
description: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface AudienceSegments {
|
|
313
|
+
available: boolean;
|
|
314
|
+
segments: AudienceSegment[];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* When to send an approved draft. `send_at` is ISO 8601: with an offset or Z it
|
|
319
|
+
* is an exact instant; a local time ("2026-11-03T10:00") needs `timezone` (IANA).
|
|
320
|
+
* Stored and dispatched in UTC by the server.
|
|
321
|
+
*/
|
|
322
|
+
export interface ScheduleInput {
|
|
323
|
+
send_at: string;
|
|
324
|
+
timezone?: string;
|
|
214
325
|
}
|
|
215
326
|
|
|
216
327
|
/** POST drafts/: either the email as written (201) or notes to write it from (202, generating). */
|
|
@@ -282,6 +393,22 @@ export interface SendRow {
|
|
|
282
393
|
error: string | null;
|
|
283
394
|
}
|
|
284
395
|
|
|
396
|
+
/** A draft waiting for its send_at, or one whose schedule was cancelled (sends report). */
|
|
397
|
+
export interface ScheduledSend {
|
|
398
|
+
id: string;
|
|
399
|
+
program_key: string;
|
|
400
|
+
subject: string;
|
|
401
|
+
status: 'scheduled' | 'expired' | 'failed' | 'cancelled';
|
|
402
|
+
/** Why: the failure, the pause that expired it, or who cancelled it. */
|
|
403
|
+
reason?: string;
|
|
404
|
+
send_at: string | null;
|
|
405
|
+
send_timezone: string | null;
|
|
406
|
+
cancelled_at: string | null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** GET sends/: the recipient rows (paginated) plus the not-yet-sent drafts. */
|
|
410
|
+
export type SendsPage = Paginated<SendRow> & { scheduled?: ScheduledSend[] };
|
|
411
|
+
|
|
285
412
|
export interface SendsQuery extends PageParams {
|
|
286
413
|
program?: string;
|
|
287
414
|
status?: string;
|