@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.
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Per-send audience for one pending draft (bd startsim-xkcnk): who receives it,
3
+ * narrowed by activity, signup dates and a segment the foundry's own backend
4
+ * defines, minus explicit exclusions. Shows the recipient count, a sample and an
5
+ * "excluded and why" breakdown before anyone approves; "Save audience" stores it
6
+ * on the draft. A segment can only narrow the foundry's customers.
7
+ */
8
+ import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
9
+ import { Button, Input, Label, Textarea } from '@startsimpli/ui';
10
+
11
+ import type { CommsApi } from '../api/comms';
12
+ import { useAudienceSegments, useDraftAudience, usePreviewAudience, useSaveAudience } from '../hooks/index';
13
+ import type { AudiencePreview, AudienceSegment, AudienceSpec, ExcludedReason } from '../types/index';
14
+ import { ErrorNotice, NativeSelect, Note, useFieldId } from './_shared';
15
+
16
+ const DEFAULT_DAYS = 14;
17
+ const DEBOUNCE_MS = 250;
18
+
19
+ const REASON_LABEL: Record<ExcludedReason, string> = {
20
+ no_email: 'with no email address',
21
+ excluded: 'excluded by you',
22
+ excluded_segment: 'in the excluded segment',
23
+ undeliverable: 'with an undeliverable address',
24
+ unverified: 'with an unverified email',
25
+ opted_out: 'unsubscribed',
26
+ suppressed: 'suppressed (bounce or complaint)',
27
+ };
28
+
29
+ interface Form {
30
+ base: 'all' | 'active' | 'inactive';
31
+ days: string;
32
+ after: string;
33
+ before: string;
34
+ segment: string;
35
+ excludeSegment: string;
36
+ exclude: string;
37
+ }
38
+
39
+ function toForm(spec: AudienceSpec): Form {
40
+ return {
41
+ base: spec.base ?? 'all',
42
+ days: String(spec.days ?? DEFAULT_DAYS),
43
+ after: (spec.joined_after ?? '').slice(0, 10),
44
+ before: (spec.joined_before ?? '').slice(0, 10),
45
+ segment: spec.segment?.key ?? '',
46
+ excludeSegment: spec.exclude?.segment?.key ?? '',
47
+ exclude: (spec.exclude?.emails ?? []).join('\n'),
48
+ };
49
+ }
50
+
51
+ export function parseEmails(text: string): string[] {
52
+ return [...new Set(text.split(/[\s,;]+/).map((e) => e.trim().toLowerCase()).filter(Boolean))];
53
+ }
54
+
55
+ export function toSpec(f: Form): AudienceSpec {
56
+ const spec: AudienceSpec = { base: f.base };
57
+ if (f.base !== 'all') spec.days = Math.max(1, Math.floor(Number(f.days)) || DEFAULT_DAYS);
58
+ if (f.after) spec.joined_after = f.after;
59
+ if (f.before) spec.joined_before = f.before;
60
+ if (f.segment) spec.segment = { key: f.segment };
61
+ const emails = parseEmails(f.exclude);
62
+ if (emails.length || f.excludeSegment) {
63
+ spec.exclude = {};
64
+ if (emails.length) spec.exclude.emails = emails;
65
+ if (f.excludeSegment) spec.exclude.segment = { key: f.excludeSegment };
66
+ }
67
+ return spec;
68
+ }
69
+
70
+ function Summary({ preview }: { preview: AudiencePreview }) {
71
+ const reasons = Object.entries(preview.excluded).filter(([, n]) => n) as [ExcludedReason, number][];
72
+ return (
73
+ <div className="space-y-2 rounded-md bg-muted/40 p-3 text-sm" aria-live="polite">
74
+ <p className="font-medium">
75
+ {preview.count} {preview.count === 1 ? 'person' : 'people'} will receive this
76
+ </p>
77
+ {preview.sample.length ? (
78
+ <ul className="flex flex-wrap gap-x-3 text-xs text-muted-foreground">
79
+ {preview.sample.map((e) => (
80
+ <li key={e}>{e}</li>
81
+ ))}
82
+ </ul>
83
+ ) : null}
84
+ {preview.excluded_total ? (
85
+ <div>
86
+ <p>{preview.excluded_total} excluded</p>
87
+ <ul className="list-disc pl-5 text-muted-foreground">
88
+ {reasons.map(([reason, n]) => (
89
+ <li key={reason}>
90
+ {n} {REASON_LABEL[reason] ?? reason}
91
+ </li>
92
+ ))}
93
+ </ul>
94
+ </div>
95
+ ) : null}
96
+ </div>
97
+ );
98
+ }
99
+
100
+ function SegmentSelect({ id, label, value, onChange, segments, none }: {
101
+ id: string; label: string; value: string; onChange: (v: string) => void; segments: AudienceSegment[]; none: string;
102
+ }) {
103
+ return (
104
+ <div className="space-y-1">
105
+ <Label htmlFor={id}>{label}</Label>
106
+ <NativeSelect id={id} value={value} onChange={(e) => onChange(e.target.value)}>
107
+ <option value="">{none}</option>
108
+ {segments.map((s) => (
109
+ <option key={s.key} value={s.key}>{s.label}</option>
110
+ ))}
111
+ </NativeSelect>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ function AudienceForm({ api, draftId }: { api: CommsApi; draftId: string }) {
117
+ const prefix = useFieldId(`comms-audience-${draftId}`);
118
+ const loaded = useDraftAudience(api, draftId, true);
119
+ const segments = useAudienceSegments(api, true);
120
+ const previewMut = usePreviewAudience(api, draftId);
121
+ const save = useSaveAudience(api, draftId);
122
+ const [form, setForm] = useState<Form | null>(null);
123
+ const [shown, setShown] = useState<AudiencePreview | null>(null);
124
+ const initial = useRef<string | null>(null);
125
+
126
+ useEffect(() => {
127
+ if (loaded.data && form === null) {
128
+ setForm(toForm(loaded.data.audience));
129
+ setShown(loaded.data.preview);
130
+ initial.current = JSON.stringify(toSpec(toForm(loaded.data.audience)));
131
+ }
132
+ }, [loaded.data, form]);
133
+
134
+ const spec = useMemo(() => (form ? toSpec(form) : null), [form]);
135
+ const specKey = spec ? JSON.stringify(spec) : null;
136
+ const { mutate } = previewMut;
137
+ useEffect(() => {
138
+ if (!spec || specKey === initial.current) return;
139
+ const t = setTimeout(() => {
140
+ mutate(spec, {
141
+ onSuccess: (d) => { setShown(d.preview); },
142
+ onError: () => setShown(null),
143
+ });
144
+ }, DEBOUNCE_MS);
145
+ return () => clearTimeout(t);
146
+ // spec is derived from specKey
147
+ // eslint-disable-next-line react-hooks/exhaustive-deps
148
+ }, [specKey, mutate]);
149
+
150
+ if (loaded.error) return <ErrorNotice error={loaded.error} />;
151
+ if (!form || !spec) return <Note>Loading the audience…</Note>;
152
+ const set = (patch: Partial<Form>) => setForm({ ...form, ...patch });
153
+ const dirty = specKey !== initial.current;
154
+ const available = segments.data?.available ?? false;
155
+ const list = segments.data?.segments ?? [];
156
+
157
+ return (
158
+ <div className="space-y-3">
159
+ {loaded.data?.is_default && !dirty ? <Note>Using the program default. Change anything below to set this send's own audience.</Note> : null}
160
+ <div className="flex flex-wrap items-end gap-3">
161
+ <div className="space-y-1">
162
+ <Label htmlFor={`${prefix}-who`}>Who</Label>
163
+ <NativeSelect id={`${prefix}-who`} value={form.base} onChange={(e) => set({ base: e.target.value as Form['base'] })}>
164
+ <option value="all">Everyone</option>
165
+ <option value="active">Seen recently</option>
166
+ <option value="inactive">Not seen for a while</option>
167
+ </NativeSelect>
168
+ </div>
169
+ {form.base !== 'all' ? (
170
+ <div className="space-y-1">
171
+ <Label htmlFor={`${prefix}-days`}>Within days</Label>
172
+ <Input id={`${prefix}-days`} type="number" min={1} className="w-24" value={form.days} onChange={(e) => set({ days: e.target.value })} />
173
+ </div>
174
+ ) : null}
175
+ <div className="space-y-1">
176
+ <Label htmlFor={`${prefix}-after`}>Signed up after</Label>
177
+ <Input id={`${prefix}-after`} type="date" value={form.after} onChange={(e) => set({ after: e.target.value })} />
178
+ </div>
179
+ <div className="space-y-1">
180
+ <Label htmlFor={`${prefix}-before`}>Signed up before</Label>
181
+ <Input id={`${prefix}-before`} type="date" value={form.before} onChange={(e) => set({ before: e.target.value })} />
182
+ </div>
183
+ </div>
184
+ {available ? (
185
+ <div className="flex flex-wrap gap-3">
186
+ <SegmentSelect id={`${prefix}-seg`} label="Only people in segment" value={form.segment}
187
+ onChange={(v) => set({ segment: v })} segments={list} none="Everyone (no segment)" />
188
+ <SegmentSelect id={`${prefix}-xseg`} label="Never send to segment" value={form.excludeSegment}
189
+ onChange={(v) => set({ excludeSegment: v })} segments={list} none="None" />
190
+ </div>
191
+ ) : segments.data ? (
192
+ <Note>Segments need the foundry's own backend, which is not deployed yet. Activity and signup date still work.</Note>
193
+ ) : null}
194
+ <ErrorNotice error={segments.error} />
195
+ <div className="space-y-1">
196
+ <Label htmlFor={`${prefix}-exclude`}>Never send to</Label>
197
+ <Textarea id={`${prefix}-exclude`} rows={3} placeholder="One email address per line"
198
+ value={form.exclude} onChange={(e) => set({ exclude: e.target.value })} />
199
+ </div>
200
+ {shown ? <Summary preview={shown} /> : null}
201
+ {previewMut.isPending ? <Note>Counting…</Note> : null}
202
+ <ErrorNotice error={previewMut.error ?? save.error} />
203
+ <div className="flex items-center gap-2">
204
+ <Button type="button" size="sm" disabled={save.isPending || !dirty}
205
+ onClick={() => save.mutate(spec, { onSuccess: () => { initial.current = specKey; setForm({ ...form }); } })}>
206
+ Save audience
207
+ </Button>
208
+ {save.isSuccess && !dirty ? <span className="text-sm text-muted-foreground">Saved.</span> : null}
209
+ </div>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ /** The open panel; the draft row owns the "Audience" button that shows it. */
215
+ export function AudiencePanel({ api, draftId, id }: { api: CommsApi; draftId: string; id?: string }): ReactElement {
216
+ return (
217
+ <div id={id} role="region" aria-label="Audience" className="mt-2 space-y-3 rounded-md border p-3">
218
+ <AudienceForm api={api} draftId={draftId} />
219
+ </div>
220
+ );
221
+ }
@@ -14,14 +14,18 @@ import {
14
14
  DEFAULT_POLL_MS,
15
15
  useApproveDraft,
16
16
  useDiscardDraft,
17
+ useRetryDraft,
17
18
  useDraftPreview,
18
19
  useDrafts,
19
20
  useSendTest,
20
21
  useUpdateDraft,
21
22
  } from '../hooks/index';
22
- import type { Draft, DraftPatch } from '../types/index';
23
+ import type { Draft, DraftAction, DraftPatch } from '../types/index';
24
+ import { AudiencePanel } from './AudiencePanel';
23
25
  import { EmailPreview } from './EmailPreview';
24
- import { ErrorNotice, Note, formatDate, humanize, useFieldId } from './_shared';
26
+ import { SchedulePicker } from './SchedulePicker';
27
+ import { ScheduledSends } from './ScheduledSends';
28
+ import { ErrorNotice, Note, formatDate, formatDateTime, humanize, useFieldId } from './_shared';
25
29
 
26
30
  export interface DraftReviewProps {
27
31
  api: CommsApi;
@@ -78,14 +82,36 @@ function DraftEditor({ api, draft, onDone }: { api: CommsApi; draft: Draft; onDo
78
82
  );
79
83
  }
80
84
 
81
- function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
85
+ /** The actions the server says are open; older servers send none, which means the pending set. */
86
+ function allowed(draft: Draft, action: DraftAction): boolean {
87
+ if (!draft.next_actions) return action !== 'retry';
88
+ return draft.next_actions.includes(action);
89
+ }
90
+
91
+ export function DraftItem({
92
+ api,
93
+ draft,
94
+ onSentImmediately,
95
+ }: {
96
+ api: CommsApi;
97
+ draft: Draft;
98
+ onSentImmediately?: (draft: Draft) => void;
99
+ }) {
82
100
  const [editing, setEditing] = useState(false);
83
101
  const [previewing, setPreviewing] = useState(false);
102
+ const [auditing, setAuditing] = useState(false);
103
+ const [scheduling, setScheduling] = useState(false);
84
104
  const ready = !draft.generating && !draft.error;
85
105
  const preview = useDraftPreview(api, draft.id, previewing && ready);
86
106
  const sendTest = useSendTest(api);
87
107
  const approve = useApproveDraft(api);
88
108
  const discard = useDiscardDraft(api);
109
+ const retry = useRetryDraft(api);
110
+ const approveWith = (vars: Parameters<typeof approve.mutateAsync>[0], done?: () => void) =>
111
+ approve.mutateAsync(vars).then((d) => {
112
+ if (d.sent_immediately) onSentImmediately?.(d);
113
+ done?.();
114
+ }, () => undefined);
89
115
 
90
116
  return (
91
117
  <li className="py-3">
@@ -98,7 +124,20 @@ function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
98
124
  {ready ? (
99
125
  <>
100
126
  <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>
127
+ {allowed(draft, 'edit') ? (
128
+ <>
129
+ <Button type="button" size="sm" variant="outline" onClick={() => setEditing((v) => !v)}>Edit</Button>
130
+ <Button
131
+ type="button"
132
+ size="sm"
133
+ variant="outline"
134
+ aria-expanded={auditing}
135
+ onClick={() => setAuditing((v) => !v)}
136
+ >
137
+ Audience
138
+ </Button>
139
+ </>
140
+ ) : null}
102
141
  <Button
103
142
  type="button"
104
143
  size="sm"
@@ -108,12 +147,24 @@ function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
108
147
  >
109
148
  Send test to me
110
149
  </Button>
111
- <Button type="button" size="sm" disabled={approve.isPending} onClick={() => approve.mutate(draft.id)}>
112
- Approve and send
113
- </Button>
150
+ {allowed(draft, 'approve') ? (
151
+ <Button type="button" size="sm" disabled={approve.isPending} onClick={() => approveWith(draft.id)}>
152
+ Approve and send
153
+ </Button>
154
+ ) : null}
155
+ {allowed(draft, 'schedule') ? (
156
+ <Button type="button" size="sm" variant="outline" onClick={() => setScheduling((v) => !v)}>
157
+ Schedule…
158
+ </Button>
159
+ ) : null}
160
+ {allowed(draft, 'retry') ? (
161
+ <Button type="button" size="sm" disabled={retry.isPending} onClick={() => retry.mutate(draft.id)}>
162
+ Retry
163
+ </Button>
164
+ ) : null}
114
165
  </>
115
166
  ) : null}
116
- {!draft.generating ? (
167
+ {!draft.generating && allowed(draft, 'discard') ? (
117
168
  <Button type="button" size="sm" variant="ghost" disabled={discard.isPending} onClick={() => discard.mutate(draft.id)}>
118
169
  Discard
119
170
  </Button>
@@ -125,38 +176,100 @@ function DraftItem({ api, draft }: { api: CommsApi; draft: Draft }) {
125
176
  {sendTest.data && !sendTest.data.sent ? (
126
177
  <ErrorNotice error={new Error(`Test not sent: ${humanize(sendTest.data.reason || 'refused')}`)} />
127
178
  ) : null}
128
- <ErrorNotice error={sendTest.error ?? approve.error ?? discard.error ?? preview.error} />
179
+ {draft.failure ? <ErrorNotice error={new Error(`Send failed: ${draft.failure.reason}`)} /> : null}
180
+ {draft.notice ? (
181
+ <Note>
182
+ {draft.notice.code === 'expired' && draft.notice.send_at
183
+ ? `${draft.notice.reason}. It was scheduled for ${formatDateTime(draft.notice.send_at)}.`
184
+ : draft.notice.reason}
185
+ </Note>
186
+ ) : null}
187
+ <ErrorNotice error={sendTest.error ?? approve.error ?? discard.error ?? retry.error ?? preview.error} />
129
188
  {editing && ready ? <DraftEditor api={api} draft={draft} onDone={() => setEditing(false)} /> : null}
189
+ {auditing && ready ? <AudiencePanel api={api} draftId={draft.id} /> : null}
130
190
  {previewing && preview.data ? <EmailPreview email={preview.data} /> : null}
191
+ {scheduling && ready ? (
192
+ <SchedulePicker
193
+ submitLabel="Approve and schedule"
194
+ pending={approve.isPending}
195
+ onCancel={() => setScheduling(false)}
196
+ onSubmit={(s) => approveWith({ id: draft.id, ...s }, () => setScheduling(false))}
197
+ />
198
+ ) : null}
199
+ {draft.cancelled_at ? <Note>{`Schedule cancelled ${formatDateTime(draft.cancelled_at)}.`}</Note> : null}
131
200
  </li>
132
201
  );
133
202
  }
134
203
 
204
+ /** Expired and failed drafts: each states why and offers its next action. */
205
+ function NeedsAttention({ api, pollIntervalMs, onSentImmediately }: {
206
+ api: CommsApi;
207
+ pollIntervalMs: number;
208
+ onSentImmediately: (draft: Draft) => void;
209
+ }): ReactElement | null {
210
+ const expired = useDrafts(api, { status: 'expired', page: 1, page_size: 50 }, pollIntervalMs);
211
+ const failed = useDrafts(api, { status: 'failed', page: 1, page_size: 50 }, pollIntervalMs);
212
+ const rows = [...(failed.data?.results ?? []), ...(expired.data?.results ?? [])];
213
+ if (!rows.length) return null;
214
+ return (
215
+ <div className="space-y-1">
216
+ <h3 className="text-sm font-medium">Needs attention</h3>
217
+ <ul className="divide-y">
218
+ {rows.map((d) => (
219
+ <DraftItem key={d.id} api={api} draft={d} onSentImmediately={onSentImmediately} />
220
+ ))}
221
+ </ul>
222
+ </div>
223
+ );
224
+ }
225
+
135
226
  export function DraftReview({ api, pollIntervalMs = DEFAULT_POLL_MS, pageSize = 10 }: DraftReviewProps): ReactElement | null {
136
227
  const [page, setPage] = useState(1);
228
+ const [justSent, setJustSent] = useState<Draft | null>(null);
137
229
  const drafts = useDrafts(api, { status: 'pending', page, page_size: pageSize }, pollIntervalMs);
138
230
  if (drafts.error) return <ErrorNotice error={drafts.error} />;
139
231
  if (!drafts.data) return null;
140
232
  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>
233
+ const extras = (
234
+ <>
235
+ {justSent ? (
236
+ <Note>
237
+ {`"${justSent.subject}" was sent immediately: the requested time (${formatDateTime(justSent.requested_send_at)}) had already passed.`}
238
+ </Note>
159
239
  ) : null}
160
- </div>
240
+ <NeedsAttention api={api} pollIntervalMs={pollIntervalMs} onSentImmediately={setJustSent} />
241
+ <ScheduledSends api={api} pollIntervalMs={pollIntervalMs} />
242
+ </>
243
+ );
244
+ if (!results.length) {
245
+ return (
246
+ <>
247
+ <Note>No updates are waiting for approval.</Note>
248
+ {extras}
249
+ </>
250
+ );
251
+ }
252
+ return (
253
+ <>
254
+ <div className="space-y-1">
255
+ <h3 className="text-sm font-medium">Waiting for approval</h3>
256
+ <ul className="divide-y">
257
+ {results.map((d) => (
258
+ <DraftItem key={d.id} api={api} draft={d} onSentImmediately={setJustSent} />
259
+ ))}
260
+ </ul>
261
+ {count > pageSize ? (
262
+ <div className="flex items-center gap-2 text-sm">
263
+ <Button type="button" size="sm" variant="outline" disabled={!drafts.data.previous} onClick={() => setPage(page - 1)}>
264
+ Previous
265
+ </Button>
266
+ <Button type="button" size="sm" variant="outline" disabled={!drafts.data.next} onClick={() => setPage(page + 1)}>
267
+ Next
268
+ </Button>
269
+ </div>
270
+ ) : null}
271
+ </div>
272
+ {extras}
273
+ </>
161
274
  );
162
275
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Pick when an approved draft goes out: a local date and time plus the time
3
+ * zone it is meant in (default: the viewer's). The server stores the instant in
4
+ * UTC; this form only collects the wall-clock time and the zone name.
5
+ */
6
+ import { useState, type ReactElement } from 'react';
7
+ import { Button, Input, Label } from '@startsimpli/ui';
8
+
9
+ import type { ScheduleInput } from '../types/index';
10
+ import { NativeSelect, localTimeZone, timeZoneOptions, useFieldId } from './_shared';
11
+
12
+ export interface SchedulePickerProps {
13
+ submitLabel: string;
14
+ pending?: boolean;
15
+ onSubmit: (schedule: ScheduleInput) => void;
16
+ onCancel: () => void;
17
+ }
18
+
19
+ export function SchedulePicker({ submitLabel, pending, onSubmit, onCancel }: SchedulePickerProps): ReactElement {
20
+ const [when, setWhen] = useState('');
21
+ const [zone, setZone] = useState(localTimeZone);
22
+ const whenId = useFieldId('comms-send-at');
23
+ const zoneId = useFieldId('comms-send-zone');
24
+ return (
25
+ <form
26
+ className="mt-2 flex flex-wrap items-end gap-3 rounded-md border p-3"
27
+ onSubmit={(e) => {
28
+ e.preventDefault();
29
+ if (when) onSubmit({ send_at: when, timezone: zone });
30
+ }}
31
+ >
32
+ <div className="space-y-1">
33
+ <Label htmlFor={whenId}>Send at</Label>
34
+ <Input id={whenId} type="datetime-local" required value={when} onChange={(e) => setWhen(e.target.value)} />
35
+ </div>
36
+ <div className="space-y-1">
37
+ <Label htmlFor={zoneId}>Time zone</Label>
38
+ <div>
39
+ <NativeSelect id={zoneId} value={zone} onChange={(e) => setZone(e.target.value)}>
40
+ {timeZoneOptions().map((z) => (
41
+ <option key={z} value={z}>{z}</option>
42
+ ))}
43
+ </NativeSelect>
44
+ </div>
45
+ </div>
46
+ <p className="basis-full text-xs text-muted-foreground">
47
+ A time that has already passed sends immediately.
48
+ </p>
49
+ <Button type="submit" size="sm" disabled={pending || !when}>{submitLabel}</Button>
50
+ <Button type="button" size="sm" variant="ghost" onClick={onCancel}>Close</Button>
51
+ </form>
52
+ );
53
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Approved drafts waiting for their send time: when they go out, and the three
3
+ * things you can still do before then (reschedule, send now, cancel). Once the
4
+ * send starts the server refuses cancel and reschedule (409), shown as-is.
5
+ */
6
+ import { useState, type ReactElement } from 'react';
7
+ import { Button } from '@startsimpli/ui';
8
+
9
+ import type { CommsApi } from '../api/comms';
10
+ import {
11
+ DEFAULT_POLL_MS,
12
+ useApproveDraft,
13
+ useCancelDraft,
14
+ useDraftAudience,
15
+ useDrafts,
16
+ useRescheduleDraft,
17
+ } from '../hooks/index';
18
+ import type { Draft } from '../types/index';
19
+ import { SchedulePicker } from './SchedulePicker';
20
+ import { ErrorNotice, formatDateTime } from './_shared';
21
+
22
+ export interface ScheduledSendsProps {
23
+ api: CommsApi;
24
+ pollIntervalMs?: number;
25
+ /** Drafts per server page. Default 10. */
26
+ pageSize?: number;
27
+ }
28
+
29
+ function ScheduledItem({ api, draft }: { api: CommsApi; draft: Draft }) {
30
+ const [moving, setMoving] = useState(false);
31
+ const reschedule = useRescheduleDraft(api);
32
+ const cancel = useCancelDraft(api);
33
+ const sendNow = useApproveDraft(api);
34
+ const zone = draft.send_timezone || undefined;
35
+ const live = useDraftAudience(api, draft.id, true).data?.preview.count;
36
+ const frozen = draft.recipient_count;
37
+ const count =
38
+ live === undefined
39
+ ? null
40
+ : frozen != null && frozen !== live
41
+ ? `${frozen} recipients when scheduled, ${live} now`
42
+ : `${live} recipients`;
43
+ return (
44
+ <li className="py-3">
45
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
46
+ <span className="min-w-[10rem] flex-1 font-medium">{draft.subject || '(no subject)'}</span>
47
+ <span className="text-sm text-muted-foreground">
48
+ {`Sends ${formatDateTime(draft.send_at, zone)}`}
49
+ {zone && zone !== 'UTC' ? ` (${formatDateTime(draft.send_at, 'UTC')})` : ''}
50
+ </span>
51
+ <div className="flex flex-wrap gap-2">
52
+ <Button type="button" size="sm" variant="outline" onClick={() => setMoving((v) => !v)}>Reschedule</Button>
53
+ <Button type="button" size="sm" variant="outline" disabled={sendNow.isPending} onClick={() => sendNow.mutate(draft.id)}>
54
+ Send now
55
+ </Button>
56
+ <Button type="button" size="sm" variant="ghost" disabled={cancel.isPending} onClick={() => cancel.mutate(draft.id)}>
57
+ Cancel schedule
58
+ </Button>
59
+ </div>
60
+ </div>
61
+ {count ? <p className="text-sm text-muted-foreground">{count}</p> : null}
62
+ {draft.dispatch_error ? (
63
+ <p className="text-sm text-muted-foreground">{`Trying again: ${draft.dispatch_error}`}</p>
64
+ ) : null}
65
+ {moving ? (
66
+ <SchedulePicker
67
+ submitLabel="Move send"
68
+ pending={reschedule.isPending}
69
+ onCancel={() => setMoving(false)}
70
+ onSubmit={(s) => reschedule.mutate({ id: draft.id, ...s }, { onSuccess: () => setMoving(false) })}
71
+ />
72
+ ) : null}
73
+ <ErrorNotice error={reschedule.error ?? cancel.error ?? sendNow.error} />
74
+ </li>
75
+ );
76
+ }
77
+
78
+ export function ScheduledSends({ api, pollIntervalMs = DEFAULT_POLL_MS, pageSize = 10 }: ScheduledSendsProps): ReactElement | null {
79
+ const [page, setPage] = useState(1);
80
+ const drafts = useDrafts(api, { status: 'scheduled', page, page_size: pageSize }, pollIntervalMs);
81
+ if (drafts.error) return <ErrorNotice error={drafts.error} />;
82
+ if (!drafts.data?.results.length) return null;
83
+ return (
84
+ <div className="space-y-1">
85
+ <h3 className="text-sm font-medium">Scheduled to send</h3>
86
+ <ul className="divide-y">
87
+ {drafts.data.results.map((d) => (
88
+ <ScheduledItem key={d.id} api={api} draft={d} />
89
+ ))}
90
+ </ul>
91
+ {drafts.data.count > pageSize ? (
92
+ <div className="flex items-center gap-2 text-sm">
93
+ <Button type="button" size="sm" variant="outline" disabled={!drafts.data.previous} onClick={() => setPage(page - 1)}>
94
+ Previous
95
+ </Button>
96
+ <Button type="button" size="sm" variant="outline" disabled={!drafts.data.next} onClick={() => setPage(page + 1)}>
97
+ Next
98
+ </Button>
99
+ </div>
100
+ ) : null}
101
+ </div>
102
+ );
103
+ }
@@ -18,7 +18,7 @@ import {
18
18
  import type { CommsApi } from '../api/comms';
19
19
  import { useSends } from '../hooks/index';
20
20
  import { COMEBACK_PROGRAM_KEY, PRODUCT_UPDATE_PROGRAM_KEY } from '../types/index';
21
- import { ErrorNotice, NativeSelect, formatDate, useFieldId } from './_shared';
21
+ import { ErrorNotice, NativeSelect, formatDate, formatDateTime, useFieldId } from './_shared';
22
22
 
23
23
  export interface SendLogProps {
24
24
  api: CommsApi;
@@ -73,6 +73,41 @@ export function SendLog({ api, pageSize = 25 }: SendLogProps): ReactElement {
73
73
  </div>
74
74
  </div>
75
75
  <ErrorNotice error={sends.error} />
76
+ {data?.scheduled?.length ? (
77
+ <div className="space-y-1">
78
+ <h3 className="text-sm font-medium">Scheduled and cancelled</h3>
79
+ <Table>
80
+ <TableHeader>
81
+ <TableRow>
82
+ <TableHead>Subject</TableHead>
83
+ <TableHead>Program</TableHead>
84
+ <TableHead>Status</TableHead>
85
+ <TableHead>Goes out</TableHead>
86
+ <TableHead>Reason</TableHead>
87
+ </TableRow>
88
+ </TableHeader>
89
+ <TableBody>
90
+ {data.scheduled.map((s) => (
91
+ <TableRow key={s.id}>
92
+ <TableCell>{s.subject}</TableCell>
93
+ <TableCell>{programLabel(s.program_key)}</TableCell>
94
+ <TableCell>{s.status}</TableCell>
95
+ <TableCell>
96
+ {s.status === 'scheduled'
97
+ ? formatDateTime(s.send_at, s.send_timezone)
98
+ : s.status === 'cancelled'
99
+ ? `Cancelled ${formatDateTime(s.cancelled_at)}`
100
+ : s.send_at
101
+ ? `Was ${formatDateTime(s.send_at, s.send_timezone)}`
102
+ : ''}
103
+ </TableCell>
104
+ <TableCell>{s.reason}</TableCell>
105
+ </TableRow>
106
+ ))}
107
+ </TableBody>
108
+ </Table>
109
+ </div>
110
+ ) : null}
76
111
  <Table>
77
112
  <TableHeader>
78
113
  <TableRow>