@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.
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Comeback sequence settings (A2): inactivity threshold, the steps (day +
3
+ * content status), and per step: Edit, Preview, Send test to me, Write with AI,
4
+ * Approve.
5
+ *
6
+ * Config is REPLACED whole by the server (trigger + steps are validated
7
+ * together), so every save sends the full config with only the edited part
8
+ * changed. content_status is server-owned: an edited step goes back to draft.
9
+ *
10
+ * Write with AI is asynchronous: POST answers 202, then the step's generation
11
+ * status is polled until done (the programs list is refetched) or failed.
12
+ */
13
+ import { useState, type ReactElement } from 'react';
14
+ import { Button, Input, Label, Textarea } from '@startsimpli/ui';
15
+
16
+ import type { CommsApi } from '../api/comms';
17
+ import {
18
+ DEFAULT_POLL_MS,
19
+ useApproveStep,
20
+ useGenerateStep,
21
+ usePrograms,
22
+ useSendTest,
23
+ useStepGeneration,
24
+ useStepPreview,
25
+ useUpdateProgram,
26
+ } from '../hooks/index';
27
+ import {
28
+ COMEBACK_PROGRAM_KEY,
29
+ type ContentStatus,
30
+ type SequenceProgram,
31
+ type SequenceStep,
32
+ } from '../types/index';
33
+ import { EmailPreview } from './EmailPreview';
34
+ import { ErrorNotice, Note, humanize, useFieldId } from './_shared';
35
+
36
+ export interface SequenceSettingsProps {
37
+ api: CommsApi;
38
+ /** Program key; the comeback preset by default. */
39
+ programKey?: string;
40
+ /** How often to re-ask about a step being written. */
41
+ pollIntervalMs?: number;
42
+ }
43
+
44
+ const STATUS_LABEL: Record<ContentStatus, string> = {
45
+ approved: 'Approved',
46
+ draft: 'Draft',
47
+ empty: 'Not written',
48
+ };
49
+
50
+ function stepStatus(step: SequenceStep): ContentStatus {
51
+ if (!step.content) return 'empty';
52
+ return step.content_status === 'approved' ? 'approved' : 'draft';
53
+ }
54
+
55
+ function intentLabel(intent: string): string {
56
+ const s = humanize(intent || '');
57
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
58
+ }
59
+
60
+ function Threshold({ api, program }: { api: CommsApi; program: SequenceProgram }) {
61
+ const update = useUpdateProgram(api);
62
+ const id = useFieldId('comms-threshold');
63
+ const trigger = program.config.trigger;
64
+ const [days, setDays] = useState<string>(trigger?.type === 'inactive' ? String(trigger.days) : '');
65
+ const [saved, setSaved] = useState(false);
66
+ if (trigger?.type !== 'inactive') return null;
67
+
68
+ return (
69
+ <form
70
+ className="flex flex-wrap items-end gap-2"
71
+ onSubmit={(e) => {
72
+ e.preventDefault();
73
+ setSaved(false);
74
+ update.mutate(
75
+ { key: program.key, patch: { config: { ...program.config, trigger: { ...trigger, days: Number(days) } } } },
76
+ { onSuccess: () => setSaved(true) },
77
+ );
78
+ }}
79
+ >
80
+ <div className="space-y-1">
81
+ <Label htmlFor={id}>Days without logging in</Label>
82
+ <Input
83
+ id={id}
84
+ type="number"
85
+ min={1}
86
+ className="w-28"
87
+ value={days}
88
+ onChange={(e) => {
89
+ setSaved(false);
90
+ setDays(e.target.value);
91
+ }}
92
+ />
93
+ </div>
94
+ <Button type="submit" disabled={update.isPending || !(Number(days) >= 1)}>Save</Button>
95
+ {saved ? <Note>Saved.</Note> : null}
96
+ <div className="basis-full">
97
+ <ErrorNotice error={update.error} />
98
+ </div>
99
+ </form>
100
+ );
101
+ }
102
+
103
+ function StepEditor({
104
+ api,
105
+ program,
106
+ index,
107
+ onDone,
108
+ }: {
109
+ api: CommsApi;
110
+ program: SequenceProgram;
111
+ index: number;
112
+ onDone: () => void;
113
+ }) {
114
+ const update = useUpdateProgram(api);
115
+ const prefix = useFieldId(`comms-step-${index}`);
116
+ const step = program.config.steps[index];
117
+ const [day, setDay] = useState(String(step.offset_days));
118
+ const [c, setC] = useState(step.content ?? null);
119
+
120
+ const save = () => {
121
+ const steps = program.config.steps.map((s, i) =>
122
+ i === index ? { ...s, offset_days: Number(day), ...(c ? { content: c } : {}) } : s,
123
+ );
124
+ update.mutate({ key: program.key, patch: { config: { ...program.config, steps } } }, { onSuccess: onDone });
125
+ };
126
+
127
+ const field = (key: 'subject' | 'preheader', label: string) => (
128
+ <div className="space-y-1">
129
+ <Label htmlFor={`${prefix}-${key}`}>{label}</Label>
130
+ <Input id={`${prefix}-${key}`} value={c?.[key] ?? ''} onChange={(e) => c && setC({ ...c, [key]: e.target.value })} />
131
+ </div>
132
+ );
133
+ const area = (key: 'html' | 'text', label: string) => (
134
+ <div className="space-y-1">
135
+ <Label htmlFor={`${prefix}-${key}`}>{label}</Label>
136
+ <Textarea
137
+ id={`${prefix}-${key}`}
138
+ rows={key === 'html' ? 8 : 5}
139
+ className="font-mono text-xs"
140
+ value={c?.[key] ?? ''}
141
+ onChange={(e) => c && setC({ ...c, [key]: e.target.value })}
142
+ />
143
+ </div>
144
+ );
145
+
146
+ return (
147
+ <form
148
+ className="mt-2 space-y-3 rounded-md border p-3"
149
+ onSubmit={(e) => {
150
+ e.preventDefault();
151
+ save();
152
+ }}
153
+ >
154
+ <div className="space-y-1">
155
+ <Label htmlFor={`${prefix}-day`}>Day</Label>
156
+ <Input id={`${prefix}-day`} type="number" min={0} className="w-28" value={day} onChange={(e) => setDay(e.target.value)} />
157
+ </div>
158
+ {c ? (
159
+ <>
160
+ {field('subject', 'Subject')}
161
+ {field('preheader', 'Preheader')}
162
+ {area('html', 'Body (HTML)')}
163
+ {area('text', 'Plain text')}
164
+ </>
165
+ ) : (
166
+ <Note>This step has no copy yet. Use Write with AI to draft it.</Note>
167
+ )}
168
+ <div className="flex gap-2">
169
+ <Button type="submit" disabled={update.isPending}>Save step</Button>
170
+ <Button type="button" variant="ghost" onClick={onDone}>Cancel</Button>
171
+ </div>
172
+ <ErrorNotice error={update.error} />
173
+ </form>
174
+ );
175
+ }
176
+
177
+ function StepRow({
178
+ api,
179
+ program,
180
+ index,
181
+ pollIntervalMs,
182
+ }: {
183
+ api: CommsApi;
184
+ program: SequenceProgram;
185
+ index: number;
186
+ pollIntervalMs: number;
187
+ }) {
188
+ const step = program.config.steps[index];
189
+ const status = stepStatus(step);
190
+ const [editing, setEditing] = useState(false);
191
+ const [previewing, setPreviewing] = useState(false);
192
+ const [watching, setWatching] = useState(false);
193
+ const preview = useStepPreview(api, program.key, index, previewing && status !== 'empty');
194
+ const sendTest = useSendTest(api);
195
+ const generate = useGenerateStep(api);
196
+ const generation = useStepGeneration(api, program.key, index, watching, pollIntervalMs);
197
+ const approve = useApproveStep(api);
198
+
199
+ const writing = generate.isPending || (watching && generation.data?.status === 'pending');
200
+ const failed = watching && generation.data?.status === 'failed' ? generation.data.error || 'Writing this email failed.' : null;
201
+
202
+ return (
203
+ <li className="py-3">
204
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
205
+ <span className="w-16 font-medium">Day {step.offset_days}</span>
206
+ <span className="min-w-[10rem] flex-1">
207
+ {step.content?.subject || intentLabel(step.intent)}
208
+ </span>
209
+ <span className="text-sm text-muted-foreground">{writing ? 'Writing…' : STATUS_LABEL[status]}</span>
210
+ <div className="flex flex-wrap gap-2">
211
+ <Button type="button" size="sm" variant="outline" onClick={() => setEditing((v) => !v)}>Edit</Button>
212
+ {status !== 'empty' ? (
213
+ <>
214
+ <Button type="button" size="sm" variant="outline" onClick={() => setPreviewing((v) => !v)}>Preview</Button>
215
+ <Button
216
+ type="button"
217
+ size="sm"
218
+ variant="outline"
219
+ disabled={sendTest.isPending}
220
+ onClick={() => sendTest.mutate({ program_key: program.key, step_index: index })}
221
+ >
222
+ Send test to me
223
+ </Button>
224
+ </>
225
+ ) : null}
226
+ <Button
227
+ type="button"
228
+ size="sm"
229
+ variant="outline"
230
+ disabled={writing}
231
+ onClick={() => {
232
+ setWatching(false);
233
+ generate.mutate({ key: program.key, step: index }, { onSuccess: () => setWatching(true) });
234
+ }}
235
+ >
236
+ Write with AI
237
+ </Button>
238
+ {status === 'draft' && !writing ? (
239
+ <Button
240
+ type="button"
241
+ size="sm"
242
+ disabled={approve.isPending}
243
+ onClick={() => approve.mutate({ key: program.key, step: index })}
244
+ >
245
+ Approve
246
+ </Button>
247
+ ) : null}
248
+ </div>
249
+ </div>
250
+ {sendTest.data?.sent ? <Note>Test sent to you.</Note> : null}
251
+ {sendTest.data && !sendTest.data.sent ? (
252
+ <ErrorNotice error={new Error(`Test not sent: ${humanize(sendTest.data.reason || 'refused')}`)} />
253
+ ) : null}
254
+ <ErrorNotice error={sendTest.error ?? generate.error ?? approve.error ?? preview.error ?? (failed ? new Error(failed) : null)} />
255
+ {editing ? <StepEditor api={api} program={program} index={index} onDone={() => setEditing(false)} /> : null}
256
+ {previewing && preview.data ? <EmailPreview email={preview.data} /> : null}
257
+ </li>
258
+ );
259
+ }
260
+
261
+ export function SequenceSettings({
262
+ api,
263
+ programKey = COMEBACK_PROGRAM_KEY,
264
+ pollIntervalMs = DEFAULT_POLL_MS,
265
+ }: SequenceSettingsProps): ReactElement | null {
266
+ const { data: programs } = usePrograms(api);
267
+ const program = programs?.find((p) => p.key === programKey);
268
+ if (!program || program.kind !== 'sequence') return null;
269
+ const steps = program.config.steps ?? [];
270
+ return (
271
+ <div className="space-y-4">
272
+ <Threshold api={api} program={program} />
273
+ <ol className="divide-y">
274
+ {steps.map((_, i) => (
275
+ <StepRow key={i} api={api} program={program} index={i} pollIntervalMs={pollIntervalMs} />
276
+ ))}
277
+ </ol>
278
+ </div>
279
+ );
280
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The foundry voice the AI copy follows (A5): product one-liner, tone,
3
+ * audience, CTA link, words to avoid. `voice` is replaced whole server-side, so
4
+ * Save sends every field (keys this form does not know are kept).
5
+ */
6
+ import { useState, type ReactElement } from 'react';
7
+ import { Button, Input, Label } from '@startsimpli/ui';
8
+
9
+ import type { CommsApi } from '../api/comms';
10
+ import { useCommsSettings, useUpdateCommsSettings } from '../hooks/index';
11
+ import type { CommsVoice } from '../types/index';
12
+ import { ErrorNotice, Note, useFieldId } from './_shared';
13
+
14
+ export interface VoiceSettingsProps {
15
+ api: CommsApi;
16
+ }
17
+
18
+ type Fields = Omit<CommsVoice, 'avoid_words'> & { avoid_words: string };
19
+
20
+ const FIELDS: { key: keyof Fields; label: string; placeholder: string }[] = [
21
+ { key: 'product', label: 'Product', placeholder: 'What the product does, in one line' },
22
+ { key: 'tone', label: 'Tone', placeholder: 'e.g. warm, plain' },
23
+ { key: 'audience', label: 'Audience', placeholder: 'Who the customers are' },
24
+ { key: 'cta_url', label: 'Call-to-action link', placeholder: 'https://' },
25
+ { key: 'avoid_words', label: 'Words to avoid', placeholder: 'Comma separated' },
26
+ ];
27
+
28
+ function VoiceForm({ api, voice }: { api: CommsApi; voice: Partial<CommsVoice> }) {
29
+ const update = useUpdateCommsSettings(api);
30
+ const prefix = useFieldId('comms-voice');
31
+ const [saved, setSaved] = useState(false);
32
+ const [f, setF] = useState<Fields>({
33
+ product: voice.product ?? '',
34
+ tone: voice.tone ?? '',
35
+ audience: voice.audience ?? '',
36
+ cta_url: voice.cta_url ?? '',
37
+ avoid_words: (voice.avoid_words ?? []).join(', '),
38
+ });
39
+
40
+ const save = () => {
41
+ setSaved(false);
42
+ const avoid_words = f.avoid_words.split(',').map((w) => w.trim()).filter(Boolean);
43
+ update.mutate(
44
+ { voice: { ...(voice as CommsVoice), ...f, avoid_words } },
45
+ { onSuccess: () => setSaved(true) },
46
+ );
47
+ };
48
+
49
+ return (
50
+ <form
51
+ className="space-y-3"
52
+ onSubmit={(e) => {
53
+ e.preventDefault();
54
+ save();
55
+ }}
56
+ >
57
+ {FIELDS.map(({ key, label, placeholder }) => (
58
+ <div key={key} className="space-y-1">
59
+ <Label htmlFor={`${prefix}-${key}`}>{label}</Label>
60
+ <Input
61
+ id={`${prefix}-${key}`}
62
+ value={f[key]}
63
+ placeholder={placeholder}
64
+ onChange={(e) => {
65
+ setSaved(false);
66
+ setF({ ...f, [key]: e.target.value });
67
+ }}
68
+ />
69
+ </div>
70
+ ))}
71
+ <div className="flex items-center gap-3">
72
+ <Button type="submit" disabled={update.isPending}>Save voice</Button>
73
+ {saved ? <Note>Saved.</Note> : null}
74
+ </div>
75
+ <ErrorNotice error={update.error} />
76
+ </form>
77
+ );
78
+ }
79
+
80
+ export function VoiceSettings({ api }: VoiceSettingsProps): ReactElement | null {
81
+ const { data: settings } = useCommsSettings(api);
82
+ if (!settings) return null;
83
+ return <VoiceForm api={api} voice={settings.voice ?? {}} />;
84
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Small pieces every comms fold uses. Package-private (not exported).
3
+ */
4
+ import * as React from 'react';
5
+ import { Alert } from '@startsimpli/ui';
6
+
7
+ /** A failed request as a role=alert carrying the server's own reason. */
8
+ export function ErrorNotice({ error }: { error: unknown }) {
9
+ if (!error) return null;
10
+ const message = error instanceof Error ? error.message : String(error);
11
+ return (
12
+ <Alert variant="destructive" className="py-2 text-sm">
13
+ {message}
14
+ </Alert>
15
+ );
16
+ }
17
+
18
+ /** A quiet one-line status message (not an alert). */
19
+ export function Note({ children }: { children: React.ReactNode }) {
20
+ return <p className="text-sm text-muted-foreground">{children}</p>;
21
+ }
22
+
23
+ /** Native <select>, styled like the package inputs. */
24
+ export function NativeSelect(props: React.SelectHTMLAttributes<HTMLSelectElement>) {
25
+ const { className, ...rest } = props;
26
+ return (
27
+ <select
28
+ {...rest}
29
+ className={
30
+ 'h-9 rounded-md border border-input bg-background px-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ' +
31
+ (className ?? '')
32
+ }
33
+ />
34
+ );
35
+ }
36
+
37
+ export function formatDate(iso: string | null | undefined): string {
38
+ if (!iso) return '';
39
+ const d = new Date(iso);
40
+ if (Number.isNaN(d.getTime())) return '';
41
+ return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
42
+ }
43
+
44
+ /** A readable reason code: "not_allowlisted" -> "not allowlisted". */
45
+ export function humanize(code: string): string {
46
+ return code.replace(/_/g, ' ');
47
+ }
48
+
49
+ let seq = 0;
50
+ /** Stable unique id for label/control pairs (React 18+ useId, with a fallback). */
51
+ export function useFieldId(prefix: string): string {
52
+ const id = (React as { useId?: () => string }).useId?.();
53
+ const [fallback] = React.useState(() => `${prefix}-${++seq}`);
54
+ return id ? `${prefix}-${id.replace(/:/g, '')}` : fallback;
55
+ }
@@ -0,0 +1,11 @@
1
+ export * from './pages/CustomerEmailsPage';
2
+ export * from './ProgramToggles';
3
+ export * from './SequenceSettings';
4
+ export * from './EmailPreview';
5
+ export * from './ApprovalModeSetting';
6
+ export * from './VoiceSettings';
7
+ export * from './SendingDomain';
8
+ export * from './ProductUpdates';
9
+ export * from './DraftReview';
10
+ export * from './ResultsPanel';
11
+ export * from './SendLog';
@@ -0,0 +1,112 @@
1
+ /**
2
+ * CUSTOMER EMAILS: the whole per-foundry comms screen, as one composer
3
+ * (lane startsim-r7hws; persona A screens A1-A8 in startsim-lzfsd).
4
+ *
5
+ * `<CustomerEmailsPage api={createCommsApi(slug)} foundrySlug={slug} />` is the
6
+ * entire integration; apps/foundry owns only the mount and the token wiring.
7
+ *
8
+ * LAYOUT (Quinn): a normal app. ONE title ("Customer emails"), the two switches
9
+ * directly under it, then folded sections in one column. No side rail, no card
10
+ * stack, no second header. Folds: Comeback sequence settings, Product updates,
11
+ * Approval, Voice, Sending domain, Results, Send log. Fold bodies mount only
12
+ * when opened, so nothing below the switches is fetched until asked for.
13
+ *
14
+ * Admin-only: the comms routes answer 403 to anyone without view_comms; the page
15
+ * renders that as a plain notice and no controls.
16
+ */
17
+ import type { ReactElement, ReactNode } from 'react';
18
+ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@startsimpli/ui';
19
+
20
+ import type { CommsApi } from '../../api/comms';
21
+ import { DEFAULT_POLL_MS, useCommsSettings, usePrograms } from '../../hooks/index';
22
+ import { CommsApiError } from '../../types/index';
23
+ import { ApprovalModeSetting } from '../ApprovalModeSetting';
24
+ import { ProductUpdates } from '../ProductUpdates';
25
+ import { ProgramToggles } from '../ProgramToggles';
26
+ import { ResultsPanel } from '../ResultsPanel';
27
+ import { SendLog } from '../SendLog';
28
+ import { SendingDomain } from '../SendingDomain';
29
+ import { SequenceSettings } from '../SequenceSettings';
30
+ import { VoiceSettings } from '../VoiceSettings';
31
+ import { ErrorNotice, Note } from '../_shared';
32
+
33
+ export interface CustomerEmailsPageProps {
34
+ api: CommsApi;
35
+ foundrySlug: string;
36
+ /** Rows per page in the send log (server-side). Default 25. */
37
+ sendLogPageSize?: number;
38
+ /** How often background work (AI writing) is re-checked. Default 2s. */
39
+ pollIntervalMs?: number;
40
+ }
41
+
42
+ function Fold({ value, title, children }: { value: string; title: string; children: ReactNode }) {
43
+ return (
44
+ <AccordionItem value={value}>
45
+ <AccordionTrigger className="text-base">{title}</AccordionTrigger>
46
+ <AccordionContent>{children}</AccordionContent>
47
+ </AccordionItem>
48
+ );
49
+ }
50
+
51
+ export function CustomerEmailsPage({
52
+ api,
53
+ foundrySlug,
54
+ sendLogPageSize = 25,
55
+ pollIntervalMs = DEFAULT_POLL_MS,
56
+ }: CustomerEmailsPageProps): ReactElement {
57
+ const settings = useCommsSettings(api);
58
+ const programs = usePrograms(api);
59
+ const error = settings.error ?? programs.error;
60
+ const status = error instanceof CommsApiError ? error.status : 0;
61
+
62
+ let body: ReactNode;
63
+ if (status === 403) {
64
+ body = <ErrorNotice error={new Error('Only foundry admins can manage customer emails.')} />;
65
+ } else if (status === 401) {
66
+ // Not a permissions answer: the request carried no valid bearer, which is
67
+ // the host's token wiring (see apps/foundry customer-emails.tsx), not the user.
68
+ body = <ErrorNotice error={new Error('Your sign-in was not accepted for this page. Reload, or sign in again.')} />;
69
+ } else if (error) {
70
+ body = <ErrorNotice error={error} />;
71
+ } else if (!settings.data || !programs.data) {
72
+ body = <Note>Loading…</Note>;
73
+ } else {
74
+ const sender = settings.data.sender_name || foundrySlug;
75
+ body = (
76
+ <>
77
+ <Note>Emails your customers get from {sender}, written by AI from your voice settings.</Note>
78
+ <ProgramToggles api={api} />
79
+ <Accordion type="multiple" className="w-full">
80
+ <Fold value="sequence" title="Comeback sequence settings">
81
+ <SequenceSettings api={api} pollIntervalMs={pollIntervalMs} />
82
+ </Fold>
83
+ <Fold value="updates" title="Product updates">
84
+ <ProductUpdates api={api} pollIntervalMs={pollIntervalMs} />
85
+ </Fold>
86
+ <Fold value="approval" title="Approval">
87
+ <ApprovalModeSetting api={api} />
88
+ </Fold>
89
+ <Fold value="voice" title="Voice">
90
+ <VoiceSettings api={api} />
91
+ </Fold>
92
+ <Fold value="domain" title="Sending domain">
93
+ <SendingDomain api={api} />
94
+ </Fold>
95
+ <Fold value="results" title="Results">
96
+ <ResultsPanel api={api} />
97
+ </Fold>
98
+ <Fold value="log" title="Send log">
99
+ <SendLog api={api} pageSize={sendLogPageSize} />
100
+ </Fold>
101
+ </Accordion>
102
+ </>
103
+ );
104
+ }
105
+
106
+ return (
107
+ <section className="mx-auto w-full max-w-4xl space-y-4">
108
+ <h2 className="text-2xl font-semibold tracking-tight">Customer emails</h2>
109
+ {body}
110
+ </section>
111
+ );
112
+ }