@graph8/sdk 0.358.0 → 0.362.0

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.
Files changed (110) hide show
  1. package/blocks/_foundation/README.md +108 -0
  2. package/blocks/_foundation/block.json +160 -12
  3. package/blocks/_foundation/components/api-error.tsx +62 -18
  4. package/blocks/_foundation/components/empty-state.tsx +39 -7
  5. package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
  6. package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
  7. package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
  8. package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
  9. package/blocks/_foundation/components/screen/index.ts +21 -0
  10. package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
  11. package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
  12. package/blocks/_foundation/components/screen/section-card.tsx +47 -0
  13. package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
  14. package/blocks/_foundation/components/screen/split-view.tsx +125 -0
  15. package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
  16. package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
  17. package/blocks/_foundation/components/screen/time-series-chart.tsx +114 -0
  18. package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
  19. package/blocks/_foundation/hooks/use-url-state.ts +74 -0
  20. package/blocks/appointments/README.md +21 -1
  21. package/blocks/appointments/block.json +39 -2
  22. package/blocks/appointments/components/event-type-related.tsx +75 -0
  23. package/blocks/appointments/components/meeting-list.tsx +8 -4
  24. package/blocks/appointments/components/meetings-screen.tsx +154 -0
  25. package/blocks/appointments/components/scheduling-screen.tsx +121 -0
  26. package/blocks/appointments/hooks/use-event-types.ts +17 -0
  27. package/blocks/block.types.ts +9 -0
  28. package/blocks/deals/README.md +34 -3
  29. package/blocks/deals/block.json +39 -4
  30. package/blocks/deals/components/deal-detail.tsx +4 -2
  31. package/blocks/deals/components/deal-related.tsx +87 -0
  32. package/blocks/deals/components/deals-screen.tsx +293 -0
  33. package/blocks/deals/components/pipeline-board.tsx +4 -2
  34. package/blocks/deals/hooks/use-default-owner.ts +60 -0
  35. package/blocks/deals/lib/deals-types.ts +14 -0
  36. package/blocks/forms/README.md +12 -0
  37. package/blocks/forms/block.json +9 -1
  38. package/blocks/forms/components/forms-screen.tsx +350 -0
  39. package/blocks/forms/components/submissions-table.tsx +13 -5
  40. package/blocks/lifecycle/README.md +16 -0
  41. package/blocks/lifecycle/block.json +23 -1
  42. package/blocks/lifecycle/components/account-related.tsx +66 -0
  43. package/blocks/lifecycle/components/lifecycle-screen.tsx +147 -0
  44. package/blocks/newsletter/README.md +13 -0
  45. package/blocks/newsletter/block.json +9 -1
  46. package/blocks/newsletter/components/newsletter-screen.tsx +402 -0
  47. package/blocks/newsletter/components/subscriber-table.tsx +12 -6
  48. package/blocks/nurture/README.md +12 -0
  49. package/blocks/nurture/block.json +9 -1
  50. package/blocks/nurture/components/nurture-screen.tsx +279 -0
  51. package/blocks/pages/README.md +12 -0
  52. package/blocks/pages/block.json +9 -1
  53. package/blocks/pages/components/content-library.tsx +37 -7
  54. package/blocks/pages/components/page-list.tsx +4 -2
  55. package/blocks/pages/components/pages-screen.tsx +154 -0
  56. package/blocks/quotes/README.md +17 -0
  57. package/blocks/quotes/block.json +26 -2
  58. package/blocks/quotes/components/quote-list.tsx +7 -3
  59. package/blocks/quotes/components/quote-related.tsx +120 -0
  60. package/blocks/quotes/components/quotes-screen.tsx +169 -0
  61. package/dist/cli.js +197 -37
  62. package/dist/index.browser.js +1 -1
  63. package/dist/index.browser.js.map +1 -1
  64. package/dist/index.browser.mjs +1 -1
  65. package/dist/index.browser.mjs.map +1 -1
  66. package/dist/index.d.mts +93 -7
  67. package/dist/index.d.ts +93 -7
  68. package/dist/index.js +1 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/index.mjs +1 -1
  71. package/dist/index.mjs.map +1 -1
  72. package/dist/react.browser.js +1 -1
  73. package/dist/react.browser.js.map +1 -1
  74. package/dist/react.browser.mjs +1 -1
  75. package/dist/react.browser.mjs.map +1 -1
  76. package/dist/react.d.mts +90 -4
  77. package/dist/react.d.ts +90 -4
  78. package/dist/react.js +1 -1
  79. package/dist/react.js.map +1 -1
  80. package/dist/react.mjs +1 -1
  81. package/dist/react.mjs.map +1 -1
  82. package/dist/types/blocks/create.d.ts +15 -1
  83. package/dist/types/blocks/doctor.d.ts +2 -1
  84. package/dist/types/blocks/flows.d.ts +20 -0
  85. package/dist/types/blocks/registry.d.ts +15 -0
  86. package/dist/types/core.d.ts +3 -2
  87. package/dist/types/react.d.ts +2 -0
  88. package/dist/types/studio.d.ts +48 -0
  89. package/dist/types/version.d.ts +2 -2
  90. package/docs/api/adCampaigns.d.ts +4 -0
  91. package/docs/api/adCampaigns.md +4 -0
  92. package/docs/api/gtmCampaigns.d.ts +4 -0
  93. package/docs/api/gtmCampaigns.md +2 -0
  94. package/docs/api/gtmContext.d.ts +5 -1
  95. package/docs/api/gtmContext.md +15 -7
  96. package/docs/api/integrations.d.ts +29 -4
  97. package/docs/api/integrations.md +14 -0
  98. package/docs/sdk-clients.md +1 -1
  99. package/package.json +1 -1
  100. package/stacks/stack.schema.json +6 -1
  101. package/styles/styles.css +1 -1
  102. package/templates/next-app/README.md.tmpl +16 -0
  103. package/templates/next-app/app/globals.css.tmpl +105 -0
  104. package/templates/next-app/app/layout.tsx.tmpl +11 -2
  105. package/templates/next-app/components/app-shell.tsx.tmpl +238 -23
  106. package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
  107. package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
  108. package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
  109. package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
  110. package/templates/next-app/lib/nav.ts.tmpl +1 -1
@@ -0,0 +1,402 @@
1
+ "use client";
2
+
3
+ /**
4
+ * NewsletterScreen: the finished newsletter workspace. No props needed.
5
+ *
6
+ * Composes: ScreenLayout (title, tabs, stats), Toolbar, SplitView + DetailPanel (selected issue, deep link
7
+ * `?id=`), FormSheet (new newsletter), IssueForm (write, AI, test, delete), IssueScheduler (send, schedule,
8
+ * pause, resume, cancel), SubscriberTable (add, import a list, verify, unsubscribe, erase), NewsletterStats.
9
+ *
10
+ * Every record has a full detail view: `?tab=` picks Issues, Subscribers, Performance or Details (the newsletter itself);
11
+ * `?id=` opens an issue (Write, Send, Results, Details) or a subscriber (all fields, unsubscribe, erase);
12
+ * `&view=full` shows the open record as a full page.
13
+ *
14
+ * User flows: pick or create a newsletter (`?newsletter=`), activate or pause it, read the headline numbers,
15
+ * search and filter issues, write a new issue, edit a draft, generate it with AI, send yourself a test, send now
16
+ * or schedule it, follow and pause/resume/cancel the send, read per-issue results, manage subscribers, and
17
+ * compare every sent issue. Needs a verified sending domain in the graph8 app before issues can be delivered.
18
+ *
19
+ * SDK operations: listNewsletters, createNewsletter (external, confirmed), listIssues, activateNewsletter
20
+ * (external, confirmed), pauseNewsletter, listSubscribers (count only), getIssueAnalyticsKpis; everything else
21
+ * lives in the composed parts, which confirm every send, import, verify, unsubscribe and erase.
22
+ */
23
+
24
+ import { useEffect, useState } from "react";
25
+ import type { ReactNode } from "react";
26
+ import { Mail, Pause, Play, Plus, Send, UserMinus, Trash2 } from "lucide-react";
27
+ import { Button, Input, Label, Skeleton } from "@graph8/sdk/ui";
28
+ import { ApiError } from "@/g8/components/api-error";
29
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
30
+ import { EmptyState } from "@/g8/components/empty-state";
31
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
32
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
33
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
34
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
35
+ import { SplitView } from "@/g8/components/screen/split-view";
36
+ import { StatusPill } from "@/g8/components/screen/status-pill";
37
+ import type { Tone } from "@/g8/components/screen/status-pill";
38
+ import { Toolbar } from "@/g8/components/screen/toolbar";
39
+ import { useUrlState } from "@/g8/hooks/use-url-state";
40
+ import { g8Delete, g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
41
+ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
42
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
43
+ import { isDraft, issueTitle, percent } from "../lib/newsletter-types";
44
+ import type { Issue, IssueKpis, Newsletter, Subscriber } from "../lib/newsletter-types";
45
+ import { IssueForm } from "./issue-form";
46
+ import { IssueScheduler } from "./issue-scheduler";
47
+ import { NewsletterStats } from "./newsletter-stats";
48
+ import { SubscriberTable } from "./subscriber-table";
49
+
50
+ export interface NewsletterScreenProps {
51
+ /** Open this newsletter first when the URL does not name one. */
52
+ initialNewsletterId?: string;
53
+ renderRowActions?: (issue: Issue) => ReactNode;
54
+ renderDetailActions?: (issue: Issue) => ReactNode;
55
+ extraDetailTabs?: { id: string; label: string; render: (issue: Issue) => ReactNode }[];
56
+ toolbarActions?: ReactNode;
57
+ className?: string;
58
+ }
59
+
60
+ type Tab = "issues" | "subscribers" | "performance" | "details";
61
+ const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
62
+
63
+ function tone(status: string | undefined): Tone {
64
+ if (status === "sent") return "positive";
65
+ if (status === "sending" || status === "scheduled") return "info";
66
+ if (status === "failed") return "negative";
67
+ return "neutral";
68
+ }
69
+
70
+ function IssueResults({ newsletterId, issue }: { newsletterId: string; issue: Issue }) {
71
+ const res = useG8Query(
72
+ (signal) => g8Get<{ stale?: boolean; kpis?: IssueKpis; last_updated_at?: string | null }>(`/newsletters/${encodeURIComponent(newsletterId)}/issues/${issue.id}/analytics/kpis`, {}, { signal }).then((r) => r.data),
73
+ [newsletterId, issue.id],
74
+ { enabled: !isDraft(issue) },
75
+ );
76
+ if (isDraft(issue)) return <EmptyState title="No results yet" description="Numbers appear after this issue is sent." />;
77
+ if (res.error) return <ApiError error={res.error} title="Could not load the numbers" onRetry={() => void res.refetch()} />;
78
+ if (res.loading && !res.data) return <Skeleton className="h-24 w-full" aria-busy="true" />;
79
+ if (res.data?.stale) return <p role="alert" className="flex items-center gap-2 text-sm"><StatusPill tone="warning">Stale</StatusPill>The numbers could not be read just now. This is not zero engagement: try again in a minute.</p>;
80
+ const k = res.data?.kpis;
81
+ return (
82
+ <KeyValueList
83
+ columns={2}
84
+ items={[
85
+ { label: "Delivered", value: `${(k?.delivered ?? 0).toLocaleString()} of ${(k?.total ?? 0).toLocaleString()}` },
86
+ { label: "Opened", value: `${(k?.opened ?? 0).toLocaleString()} (${percent(k?.opened, k?.delivered)})` },
87
+ { label: "Clicked", value: `${(k?.clicked ?? 0).toLocaleString()} (${percent(k?.clicked, k?.delivered)})` },
88
+ { label: "Bounced", value: `${(k?.bounced ?? 0).toLocaleString()} (${percent(k?.bounced, k?.total)})` },
89
+ { label: "Unsubscribed", value: (k?.unsubscribed_attributed ?? 0).toLocaleString() },
90
+ { label: "Complaints", value: (k?.complained ?? 0).toLocaleString() },
91
+ ]}
92
+ />
93
+ );
94
+ }
95
+
96
+ const when = (v: string | null | undefined): string => (v ? new Date(v).toLocaleString() : "");
97
+
98
+ function IssueDetails({ issue }: { issue: Issue }) {
99
+ const k = issue.kpis;
100
+ return (
101
+ <KeyValueList
102
+ columns={2}
103
+ items={[
104
+ { label: "Issue id", value: String(issue.id) },
105
+ { label: "Status", value: issue.status ?? "draft" },
106
+ { label: "Subject", value: issue.subject || "Not set" },
107
+ { label: "Preview text", value: issue.preview_text || "Not set" },
108
+ { label: "Sender name", value: issue.sender_name || "Newsletter default" },
109
+ { label: "Sender email", value: issue.sender_email || "Newsletter default" },
110
+ { label: "Reply to", value: issue.reply_to || "Not set" },
111
+ { label: "Scheduled", value: when(issue.scheduled_at) || "Not scheduled" },
112
+ { label: "Sent", value: when(issue.sent_at) || "Not sent" },
113
+ { label: "Delivered", value: k?.delivered == null ? "Not sent" : k.delivered.toLocaleString() },
114
+ { label: "Opened", value: k?.opened == null ? "Not sent" : `${k.opened.toLocaleString()} (${percent(k.opened, k.delivered)})` },
115
+ { label: "Clicked", value: k?.clicked == null ? "Not sent" : `${k.clicked.toLocaleString()} (${percent(k.clicked, k.delivered)})` },
116
+ ]}
117
+ />
118
+ );
119
+ }
120
+
121
+ function SubscriberDetail({ newsletterId, subscriber, onChanged, onClose, expandProps }: { newsletterId: string; subscriber: Subscriber; onChanged: () => void; onClose: () => void; expandProps: { onExpand: () => void; onCollapse?: () => void } }) {
122
+ const [confirm, setConfirm] = useState<"unsubscribe" | "erase" | null>(null);
123
+ const base = `/newsletters/${encodeURIComponent(newsletterId)}/subscribers/${subscriber.id}`;
124
+ const run = useG8Mutation((kind: "unsubscribe" | "erase") => (kind === "unsubscribe" ? g8Patch(`${base}/unsubscribe`, {}) : g8Delete(base)), {
125
+ onSuccess: (_r, kind) => { setConfirm(null); onChanged(); if (kind === "erase") onClose(); },
126
+ });
127
+ const unsubscribed = subscriber.status === "unsubscribed";
128
+ const known = new Set(["id", "email", "first_name", "last_name", "status", "verification_status", "created_at"]);
129
+ const extra = Object.entries(subscriber).filter(([key, v]) => !known.has(key) && v != null && typeof v !== "object");
130
+ return (
131
+ <DetailPanel
132
+ title={subscriber.email}
133
+ subtitle={[subscriber.first_name, subscriber.last_name].filter(Boolean).join(" ") || undefined}
134
+ badges={<StatusPill tone={unsubscribed ? "neutral" : subscriber.status === "subscribed" ? "positive" : "warning"}>{subscriber.status ?? "unknown"}</StatusPill>}
135
+ actions={(
136
+ <>
137
+ {!unsubscribed ? <Button size="sm" variant="outline" onClick={() => setConfirm("unsubscribe")}><UserMinus className="size-4" />Unsubscribe</Button> : null}
138
+ <Button size="sm" variant="ghost" onClick={() => setConfirm("erase")}><Trash2 className="size-4" />Erase</Button>
139
+ </>
140
+ )}
141
+ onClose={onClose}
142
+ {...expandProps}
143
+ >
144
+ <KeyValueList
145
+ columns={2}
146
+ items={[
147
+ { label: "Email", value: subscriber.email },
148
+ { label: "First name", value: subscriber.first_name || "Not set" },
149
+ { label: "Last name", value: subscriber.last_name || "Not set" },
150
+ { label: "Status", value: subscriber.status ?? "unknown" },
151
+ { label: "Address check", value: subscriber.verification_status || "Not checked" },
152
+ { label: "Subscribed", value: when(subscriber.created_at) || "Not set" },
153
+ { label: "Subscriber id", value: String(subscriber.id) },
154
+ ...extra.map(([key, v]) => ({ label: key.replace(/_/g, " "), value: String(v) })),
155
+ ]}
156
+ />
157
+ <ConfirmSpendDialog
158
+ open={confirm !== null}
159
+ tier={confirm === "erase" ? "destructive" : "external"}
160
+ title={confirm === "erase" ? `Erase ${subscriber.email}?` : `Unsubscribe ${subscriber.email}?`}
161
+ affected={confirm === "erase" ? 1 : undefined}
162
+ description={<><p>{confirm === "erase" ? "This removes the person and the record that they opted out. Use only for an erasure request." : "They stay in the list as unsubscribed and will not be mailed."}</p>{run.error ? <ApiError error={run.error} title="That did not work" /> : null}</>}
163
+ confirmLabel={confirm === "erase" ? "Erase" : "Unsubscribe"}
164
+ confirming={run.loading}
165
+ onCancel={() => setConfirm(null)}
166
+ onConfirm={() => { if (confirm) void run.mutate(confirm); }}
167
+ />
168
+ </DetailPanel>
169
+ );
170
+ }
171
+
172
+ export function NewsletterScreen({ initialNewsletterId, renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, className }: NewsletterScreenProps) {
173
+ const [urlNewsletter, setUrlNewsletter] = useUrlState("newsletter", initialNewsletterId ?? "");
174
+ const [issueParam, setIssueParam] = useUrlState("id", "");
175
+ const [view, setView] = useUrlState("view", "");
176
+ const expandProps = { onExpand: () => setView("full"), onCollapse: view === "full" ? () => setView(null) : undefined };
177
+ const [tabParam, setTabParam] = useUrlState("tab", "issues");
178
+ const tab = (["issues", "subscribers", "performance", "details"].includes(tabParam) ? tabParam : "issues") as Tab;
179
+ const closeDetail = (): void => { setIssueParam(null); setView(null); };
180
+ const [subRefresh, setSubRefresh] = useState(0);
181
+ const [openedSubscriber, setOpenedSubscriber] = useState<Subscriber | null>(null);
182
+ const [search, setSearch] = useState("");
183
+ const [statusFilter, setStatusFilter] = useState("");
184
+ const [sheet, setSheet] = useState(false);
185
+ const [name, setName] = useState("");
186
+ const [goal, setGoal] = useState("");
187
+ const [pending, setPending] = useState<"create" | "activate" | null>(null);
188
+
189
+ const newsletters = useG8Query((signal) => g8Get<Newsletter[]>("/newsletters", {}, { signal }).then((r) => r.data ?? []), []);
190
+ const list = newsletters.data ?? [];
191
+ const newsletterId = urlNewsletter || list[0]?.id || "";
192
+ const current = list.find((n) => n.id === newsletterId);
193
+ const base = `/newsletters/${encodeURIComponent(newsletterId)}`;
194
+ const issues = useG8Query((signal) => g8Get<Issue[]>(`${base}/issues`, {}, { signal }).then((r) => r.data ?? []), [base], { enabled: Boolean(newsletterId) });
195
+ const audience = useG8Query((signal) => g8Get<{ total?: number }>(`${base}/subscribers`, { status: "subscribed", page_size: 1 }, { signal }).then((r) => r.data?.total ?? 0), [base], { enabled: Boolean(newsletterId) });
196
+
197
+ // Everything open belongs to one newsletter: switching clears the selected issue and the filters.
198
+ useEffect(() => {
199
+ setSearch("");
200
+ setStatusFilter("");
201
+ }, [newsletterId]);
202
+
203
+ const create = useG8Mutation(async () => (await g8Post<Newsletter>("/newsletters", { name: name.trim(), goal: goal.trim() || undefined })).data, {
204
+ onSuccess: (created) => { setPending(null); setSheet(false); setName(""); setGoal(""); setIssueParam(null); setUrlNewsletter(created.id); void newsletters.refetch(); },
205
+ });
206
+ const activate = useG8Mutation(() => g8Post(`${base}/activate`), { onSuccess: () => { setPending(null); void newsletters.refetch(); } });
207
+ const pause = useG8Mutation(() => g8Post(`${base}/pause`), { onSuccess: () => void newsletters.refetch() });
208
+
209
+ const subscriberId = tab === "subscribers" && issueParam ? Number(issueParam) : undefined;
210
+ const lookup = useG8Query(
211
+ (signal) => g8Get<{ items?: Subscriber[] }>(`${base}/subscribers`, { page: 1, page_size: 200 }, { signal }).then((r) => r.data?.items ?? []),
212
+ [base, subRefresh],
213
+ { enabled: subscriberId !== undefined && openedSubscriber?.id !== subscriberId },
214
+ );
215
+ const openSubscriber = subscriberId === undefined ? null : openedSubscriber?.id === subscriberId ? openedSubscriber : ((lookup.data ?? []).find((x) => x.id === subscriberId) ?? null);
216
+ const all = issues.data ?? [];
217
+ const sent = all.filter((i) => i.status === "sent" || i.sent_at);
218
+ const totals = sent.reduce((a, i) => ({ delivered: a.delivered + (i.kpis?.delivered ?? 0), opened: a.opened + (i.kpis?.opened ?? 0), clicked: a.clicked + (i.kpis?.clicked ?? 0) }), { delivered: 0, opened: 0, clicked: 0 });
219
+ const q = search.trim().toLowerCase();
220
+ const shown = all.filter((i) => (!statusFilter || (i.status ?? "draft") === statusFilter) && (!q || issueTitle(i).toLowerCase().includes(q)));
221
+ const selected = issueParam === "new" ? undefined : all.find((i) => String(i.id) === issueParam);
222
+ const detailOpen = issueParam === "new" || Boolean(selected);
223
+ const live = current?.status === "active";
224
+
225
+ const pickNewsletter = (id: string): void => { setIssueParam(null); setUrlNewsletter(id); };
226
+ const detailTabs = selected
227
+ ? [
228
+ { id: "write", label: isDraft(selected) ? "Write" : "Content", content: <IssueForm key={`f${selected.id}`} newsletterId={newsletterId} issueId={selected.id} onSaved={() => void issues.refetch()} onDeleted={() => { setIssueParam(null); void issues.refetch(); }} /> },
229
+ { id: "send", label: "Send", content: <IssueScheduler key={`s${selected.id}`} newsletterId={newsletterId} issueId={selected.id} onSent={() => void issues.refetch()} /> },
230
+ { id: "results", label: "Results", content: <IssueResults newsletterId={newsletterId} issue={selected} /> },
231
+ { id: "details", label: "Issue details", content: <IssueDetails issue={selected} /> },
232
+ ...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selected) })),
233
+ ]
234
+ : undefined;
235
+
236
+ const detail = issueParam === "new" ? (
237
+ <DetailPanel title="New issue" subtitle={current?.name} onClose={closeDetail} {...expandProps}>
238
+ <IssueForm key="new" newsletterId={newsletterId} onSaved={(saved) => { void issues.refetch(); setIssueParam(String(saved.id)); }} />
239
+ </DetailPanel>
240
+ ) : selected ? (
241
+ <DetailPanel
242
+ title={issueTitle(selected)}
243
+ subtitle={selected.sent_at ? `Sent ${new Date(selected.sent_at).toLocaleString()}` : selected.scheduled_at ? `Scheduled ${new Date(selected.scheduled_at).toLocaleString()}` : "Draft"}
244
+ badges={<StatusPill tone={tone(selected.status)}>{selected.status ?? "draft"}</StatusPill>}
245
+ actions={renderDetailActions?.(selected)}
246
+ tabs={detailTabs}
247
+ onClose={closeDetail}
248
+ {...expandProps}
249
+ />
250
+ ) : null;
251
+
252
+ const issueList = issues.error ? <ApiError error={issues.error} title="Could not load issues" onRetry={() => void issues.refetch()} /> : issues.loading && !issues.data ? (
253
+ <div aria-busy="true" className="grid gap-2"><Skeleton className="h-14 w-full" /><Skeleton className="h-14 w-full" /><Skeleton className="h-14 w-full" /></div>
254
+ ) : shown.length === 0 ? (
255
+ <EmptyState icon={<Mail className="size-8" />} title={all.length === 0 ? "No issues yet" : "No issues match"} description={all.length === 0 ? "Write the first issue, send yourself a test, then send it to your subscribers." : "Try another search or status."} action={all.length === 0 ? <Button onClick={() => setIssueParam("new")}>Write the first issue</Button> : undefined} />
256
+ ) : (
257
+ <ul className="grid gap-2">
258
+ {shown.map((i) => (
259
+ <li key={i.id} className={`flex items-center justify-between gap-3 rounded-md border p-3 ${String(i.id) === issueParam ? "bg-muted" : ""}`}>
260
+ <button type="button" className="min-w-0 flex-1 text-left" onClick={() => setIssueParam(String(i.id))}>
261
+ <span className="block truncate font-medium">{issueTitle(i)}</span>
262
+ <span className="text-muted-foreground block text-xs">
263
+ {i.sent_at ? `Sent ${new Date(i.sent_at).toLocaleDateString()}` : i.scheduled_at ? `Scheduled ${new Date(i.scheduled_at).toLocaleString()}` : "Not sent"}
264
+ {i.kpis?.delivered ? ` · ${percent(i.kpis.opened, i.kpis.delivered)} opened · ${percent(i.kpis.clicked, i.kpis.delivered)} clicked` : ""}
265
+ </span>
266
+ </button>
267
+ <StatusPill tone={tone(i.status)}>{i.status ?? "draft"}</StatusPill>
268
+ {renderRowActions?.(i)}
269
+ </li>
270
+ ))}
271
+ </ul>
272
+ );
273
+
274
+ const body = !current ? (
275
+ newsletters.error ? <ApiError error={newsletters.error} title="Could not load newsletters" onRetry={() => void newsletters.refetch()} /> :
276
+ newsletters.loading && !newsletters.data ? <Skeleton className="h-40 w-full" aria-busy="true" /> : (
277
+ <EmptyState icon={<Mail className="size-8" />} title="Create your first newsletter" description="A newsletter holds your issues and subscribers. Nothing is sent until you activate it and send an issue." action={<Button onClick={() => setSheet(true)}>New newsletter</Button>} />
278
+ )
279
+ ) : tab === "subscribers" ? (
280
+ <SplitView
281
+ detailOpen={subscriberId !== undefined}
282
+ onCloseDetail={closeDetail}
283
+ expanded={view === "full"}
284
+ onExpandedChange={(e) => setView(e ? "full" : null)}
285
+ detailWidth="lg"
286
+ list={<SubscriberTable key={newsletterId} newsletterId={newsletterId} refreshKey={subRefresh} selectedId={subscriberId} onOpen={(sub) => { setOpenedSubscriber(sub); setIssueParam(String(sub.id)); }} />}
287
+ detail={openSubscriber ? (
288
+ <SubscriberDetail key={openSubscriber.id} newsletterId={newsletterId} subscriber={openSubscriber} onChanged={() => { setSubRefresh((n) => n + 1); setOpenedSubscriber(null); }} onClose={() => { closeDetail(); setOpenedSubscriber(null); }} expandProps={expandProps} />
289
+ ) : lookup.loading ? <Skeleton className="h-40 w-full" aria-busy="true" /> : (
290
+ <EmptyState title="Subscriber not found" description="Open a subscriber from the list. Direct links reach the first 200 subscribers." />
291
+ )}
292
+ />
293
+ )
294
+ : tab === "details" ? (
295
+ <DetailPanel
296
+ title={current.name}
297
+ subtitle={current.goal ?? undefined}
298
+ badges={<StatusPill tone={live ? "positive" : "neutral"}>{current.status ?? "draft"}</StatusPill>}
299
+ actions={live
300
+ ? <Button variant="outline" size="sm" loading={pause.loading} onClick={() => void pause.mutate(undefined)}><Pause className="size-4" />Pause newsletter</Button>
301
+ : <Button variant="outline" size="sm" onClick={() => setPending("activate")}><Play className="size-4" />Activate newsletter</Button>}
302
+ >
303
+ <KeyValueList
304
+ columns={2}
305
+ items={[
306
+ { label: "Name", value: current.name },
307
+ { label: "Status", value: current.status ?? "draft" },
308
+ { label: "Goal", value: current.goal || "Not set" },
309
+ { label: "Slug", value: current.slug || "Not set" },
310
+ { label: "Audience list", value: current.audience_list_id == null ? "None" : String(current.audience_list_id) },
311
+ { label: "Newsletter id", value: current.id },
312
+ { label: "Issues", value: all.length.toLocaleString() },
313
+ { label: "Sent issues", value: sent.length.toLocaleString() },
314
+ { label: "Drafts", value: all.filter((i) => isDraft(i)).length.toLocaleString() },
315
+ { label: "Subscribed people", value: (audience.data ?? 0).toLocaleString() },
316
+ ]}
317
+ />
318
+ </DetailPanel>
319
+ )
320
+ : tab === "performance" ? <NewsletterStats key={newsletterId} newsletterId={newsletterId} />
321
+ : (
322
+ <SplitView
323
+ detailOpen={detailOpen}
324
+ onCloseDetail={closeDetail}
325
+ expanded={view === "full"}
326
+ onExpandedChange={(e) => setView(e ? "full" : null)}
327
+ detailWidth="xl"
328
+ list={(
329
+ <div className="grid gap-3">
330
+ <Toolbar
331
+ search={{ value: search, onChange: setSearch, placeholder: "Search issues" }}
332
+ filters={(
333
+ <select aria-label="Issue status" className={SELECT} value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
334
+ <option value="">All statuses</option><option value="draft">Drafts</option><option value="scheduled">Scheduled</option><option value="sending">Sending</option><option value="sent">Sent</option><option value="failed">Failed</option>
335
+ </select>
336
+ )}
337
+ actions={<>{toolbarActions}<Button size="sm" onClick={() => setIssueParam("new")}><Plus className="size-4" />New issue</Button></>}
338
+ />
339
+ {issueList}
340
+ </div>
341
+ )}
342
+ detail={detail}
343
+ />
344
+ );
345
+
346
+ return (
347
+ <ScreenLayout
348
+ className={className}
349
+ title="Newsletter"
350
+ description="Write, send and measure issues for the people who subscribed."
351
+ icon={<Mail className="size-5" />}
352
+ actions={(
353
+ <>
354
+ {list.length > 0 ? (
355
+ <select aria-label="Newsletter" className={SELECT} value={newsletterId} onChange={(e) => pickNewsletter(e.target.value)}>
356
+ {list.map((n) => <option key={n.id} value={n.id}>{n.name}</option>)}
357
+ </select>
358
+ ) : null}
359
+ {current ? (live
360
+ ? <Button variant="outline" size="sm" loading={pause.loading} onClick={() => void pause.mutate(undefined)}><Pause className="size-4" />Pause newsletter</Button>
361
+ : <Button variant="outline" size="sm" onClick={() => setPending("activate")}><Play className="size-4" />Activate newsletter</Button>) : null}
362
+ <Button size="sm" onClick={() => setSheet(true)}><Plus className="size-4" />New newsletter</Button>
363
+ </>
364
+ )}
365
+ tabs={current ? [{ id: "issues", label: "Issues", count: all.length }, { id: "subscribers", label: "Subscribers", count: audience.data }, { id: "performance", label: "Performance" }, { id: "details", label: "Details" }] : undefined}
366
+ activeTab={tab}
367
+ onTabChange={(id) => { setTabParam(id === "issues" ? null : id); closeDetail(); setOpenedSubscriber(null); }}
368
+ stats={current ? [
369
+ { label: "Status", value: current.status ?? "draft", tone: live ? "positive" : "neutral" },
370
+ { label: "Subscribers", value: audience.loading ? "..." : (audience.data ?? 0).toLocaleString(), hint: "subscribed" },
371
+ { label: "Issues sent", value: sent.length.toLocaleString() },
372
+ { label: "Open rate", value: sent.length ? percent(totals.opened, totals.delivered) : "-", hint: "of delivered" },
373
+ { label: "Click rate", value: sent.length ? percent(totals.clicked, totals.delivered) : "-", hint: "of delivered" },
374
+ ] : undefined}
375
+ >
376
+ {pause.error ? <ApiError error={pause.error} title="Could not pause" /> : null}
377
+ {body}
378
+ <FormSheet
379
+ open={sheet}
380
+ onOpenChange={(o) => { setSheet(o); if (!o) setPending(null); }}
381
+ title="New newsletter"
382
+ description="Creates the container for your issues. Nothing is sent until you activate it."
383
+ footer={<Button disabled={!name.trim()} onClick={() => setPending("create")}><Send className="size-4" />Create newsletter</Button>}
384
+ >
385
+ <div className="grid gap-3">
386
+ <div><Label htmlFor="g8-nls-name" className="mb-1">Name</Label><Input id="g8-nls-name" value={name} onChange={(e) => setName(e.target.value)} /></div>
387
+ <div><Label htmlFor="g8-nls-goal" className="mb-1">Goal (optional)</Label><Input id="g8-nls-goal" value={goal} onChange={(e) => setGoal(e.target.value)} /></div>
388
+ </div>
389
+ </FormSheet>
390
+ <ConfirmSpendDialog
391
+ open={pending !== null}
392
+ tier="external"
393
+ title={pending === "activate" ? `Activate ${current?.name ?? "this newsletter"}?` : `Create the newsletter "${name.trim()}"?`}
394
+ description={<><p>{pending === "activate" ? "Once active, the scheduler sends any issue whose scheduled time has passed to real subscribers. A verified sending domain is required (set it up in graph8)." : "This creates the newsletter container. Nothing is sent until you activate it and send an issue."}</p>{(create.error ?? activate.error) ? <ApiError error={create.error ?? activate.error} title="That did not work" /> : null}</>}
395
+ confirmLabel={pending === "activate" ? "Activate" : "Create"}
396
+ confirming={create.loading || activate.loading}
397
+ onCancel={() => setPending(null)}
398
+ onConfirm={() => void (pending === "activate" ? activate.mutate(undefined) : create.mutate(undefined))}
399
+ />
400
+ </ScreenLayout>
401
+ );
402
+ }
@@ -28,6 +28,12 @@ export interface SubscriberTableProps {
28
28
  /** The newsletter to show. Omit it (for example as a stack nav page) and a newsletter picker is shown instead. */
29
29
  newsletterId?: string;
30
30
  pageSize?: number;
31
+ /** When set, the email of a row is a button that opens the full record (see NewsletterScreen). */
32
+ onOpen?: (subscriber: Subscriber) => void;
33
+ /** The subscriber whose full record is open; its row is highlighted. */
34
+ selectedId?: number;
35
+ /** Bump to re-read the table after the record was changed elsewhere. */
36
+ refreshKey?: number;
31
37
  }
32
38
 
33
39
  type Action =
@@ -46,7 +52,7 @@ function creditsOf(preview: unknown): number | undefined {
46
52
  return typeof value === "number" ? value : undefined;
47
53
  }
48
54
 
49
- export function SubscriberTable({ newsletterId: given, pageSize = 25 }: SubscriberTableProps) {
55
+ export function SubscriberTable({ newsletterId: given, pageSize = 25, onOpen, selectedId, refreshKey }: SubscriberTableProps) {
50
56
  const [picked, setPicked] = useState("");
51
57
  const all = useG8Query((signal) => g8Get<Array<{ id: string; name: string }>>("/newsletters", {}, { signal }).then((r) => r.data ?? []), [], { enabled: !given });
52
58
  const newsletterId = given ?? picked;
@@ -61,10 +67,10 @@ export function SubscriberTable({ newsletterId: given, pageSize = 25 }: Subscrib
61
67
  </select>
62
68
  );
63
69
  }
64
- return <SubscribersOf newsletterId={newsletterId} pageSize={pageSize} />;
70
+ return <SubscribersOf newsletterId={newsletterId} pageSize={pageSize} onOpen={onOpen} selectedId={selectedId} refreshKey={refreshKey} />;
65
71
  }
66
72
 
67
- function SubscribersOf({ newsletterId, pageSize }: { newsletterId: string; pageSize: number }) {
73
+ function SubscribersOf({ newsletterId, pageSize, onOpen, selectedId, refreshKey }: { newsletterId: string; pageSize: number; onOpen?: (s: Subscriber) => void; selectedId?: number; refreshKey?: number }) {
68
74
  const base = `/newsletters/${encodeURIComponent(newsletterId)}/subscribers`;
69
75
  const [page, setPage] = useState(1);
70
76
  const [search, setSearch] = useState("");
@@ -75,7 +81,7 @@ function SubscribersOf({ newsletterId, pageSize }: { newsletterId: string; pageS
75
81
  const [action, setAction] = useState<Action | null>(null);
76
82
  const subscribers = useG8Query<Page>(
77
83
  (signal) => g8Get<Page>(base, { page, page_size: pageSize, search: query || undefined, status: status || undefined }, { signal }).then((r) => ({ items: r.data?.items ?? [], total: r.data?.total ?? 0 })),
78
- [base, page, pageSize, query, status],
84
+ [base, page, pageSize, query, status, refreshKey],
79
85
  );
80
86
  const lists = useG8Query((signal) => g8Get<ListOption[]>("/lists", { limit: 200 }, { signal }).then((r) => r.data ?? []), []);
81
87
  const preview = useG8Mutation(async () => (await g8Post(`${base}/verify/preview`, {})).data);
@@ -134,8 +140,8 @@ function SubscribersOf({ newsletterId, pageSize }: { newsletterId: string; pageS
134
140
  <TableHeader><TableRow><TableHead>Email</TableHead><TableHead>Name</TableHead><TableHead>Status</TableHead><TableHead>Verification</TableHead><TableHead /></TableRow></TableHeader>
135
141
  <TableBody>
136
142
  {items.map((s) => (
137
- <TableRow key={s.id}>
138
- <TableCell>{s.email}</TableCell>
143
+ <TableRow key={s.id} data-state={selectedId === s.id ? "selected" : undefined}>
144
+ <TableCell>{onOpen ? <button type="button" className="text-left hover:underline" aria-label={`Open ${s.email}`} onClick={() => onOpen(s)}>{s.email}</button> : s.email}</TableCell>
139
145
  <TableCell>{[s.first_name, s.last_name].filter(Boolean).join(" ")}</TableCell>
140
146
  <TableCell>{s.status}</TableCell>
141
147
  <TableCell>{s.verification_status ?? ""}</TableCell>
@@ -5,6 +5,17 @@ is a sequence of kind `nurture`: a few emails sent from **one pinned mailbox** t
5
5
  contact when they reply. It is not cold outreach (use the `sequencer` block for that). Going live reaches real people,
6
6
  so launch is tier `external` and is confirmed with the audience size.
7
7
 
8
+ ## Screens
9
+
10
+ `NurtureScreen` (`components/nurture-screen.tsx`) is the finished page: list, create, results, audience, launch and run controls.
11
+
12
+ ```tsx
13
+ import { NurtureScreen } from "@/g8/nurture/components/nurture-screen";
14
+ export default function Page() { return <NurtureScreen />; }
15
+ ```
16
+
17
+ Flows: see every nurture with its state, search and filter, create one (sender mailbox, audience, email steps), open it (deep link `?id=`) to read results, choose the audience and add selected contacts, tick consent and go live (confirmed with the audience size), pause, resume (confirmed), duplicate, edit settings, archive (confirmed). With no connected mailbox the create sheet links out to graph8 (Google and Microsoft sign-in only exists in the app; override with `connectMailboxUrl`). Optional props: `contactIds`, `renderRowActions`, `renderDetailActions`, `extraDetailTabs`, `toolbarActions`.
18
+
8
19
  ## Install
9
20
 
10
21
  ```
@@ -84,6 +95,7 @@ export default function Page() {
84
95
 
85
96
  | Component | File | What it is |
86
97
  |---|---|---|
98
+ | `NurtureScreen` | `components/nurture-screen.tsx` | The finished page: list, create, results, audience and launch, run controls. |
87
99
  | `NurtureBuilder` | `components/nurture-builder.tsx` | List your nurtures; create, edit, pause, resume, duplicate and archive them. |
88
100
  | `NurtureForm` | `components/nurture-form.tsx` | Create a nurture (name, sender mailbox, audience, email steps) or edit its settings. |
89
101
  | `NurtureEnroll` | `components/nurture-enroll.tsx` | Choose the audience list, attest consent, optionally add selected contacts, and launch with the audience size confirmed. |
@@ -29,6 +29,13 @@
29
29
  "get_sequence_performance_by_id_analytics_outbound_sequence_performance__sequence_id__get"
30
30
  ],
31
31
  "components": [
32
+ {
33
+ "name": "NurtureScreen",
34
+ "file": "components/nurture-screen.tsx",
35
+ "purpose": "Finished nurture workspace: list with state and row actions, create sheet, detail with results, audience and launch, and settings.",
36
+ "operations": [],
37
+ "tiers": []
38
+ },
32
39
  {
33
40
  "name": "NurtureBuilder",
34
41
  "file": "components/nurture-builder.tsx",
@@ -186,6 +193,7 @@
186
193
  }
187
194
  ],
188
195
  "peerDependencies": {
189
- "react": ">=18"
196
+ "react": ">=18",
197
+ "lucide-react": ">=0.400.0"
190
198
  }
191
199
  }