@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.
@@ -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';
@@ -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
- SendRow,
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
- export function useApproveDraft(api: CommsApi): UseMutationResult<Draft, Error, string> {
238
- return useDraftsWrite(api, (id: string) => api.approveDraft(id));
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<Paginated<SendRow>> {
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),
@@ -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;