@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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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={
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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>
|