@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,279 @@
1
+ "use client";
2
+
3
+ /**
4
+ * NurtureScreen: the finished nurture workspace. No props needed.
5
+ *
6
+ * Composes: ScreenLayout (title, stats), Toolbar (search, status filter), SplitView + DetailPanel (selected
7
+ * nurture, deep link `?id=`), FormSheet (new nurture via NurtureForm), NurtureStats (overview and results),
8
+ * NurtureEnroll (audience list, consent, Go live), NurtureForm in edit mode (settings).
9
+ *
10
+ * The detail panel (`?id=`) has Overview, Details (every field and each email), Audience and launch, and Settings;
11
+ * `&view=full` shows it as a full page.
12
+ *
13
+ * User flows: see every nurture with its state, search and filter, create a nurture (sender mailbox, audience,
14
+ * email steps), open one to read its results, choose the audience and add selected contacts, tick consent and go
15
+ * live (confirmed with the audience size), pause, resume (confirmed), duplicate, edit settings and archive
16
+ * (confirmed). Connecting a mailbox needs Google or Microsoft sign-in, which only exists in the graph8 app, so
17
+ * the create sheet links out when no mailbox is connected.
18
+ *
19
+ * SDK operations: listNurtures, listMyMailboxes (only to detect "no mailbox"), pauseNurture, resumeNurture
20
+ * (external-style confirm), duplicateNurture, deleteNurture (destructive, confirmed); the rest live in the parts.
21
+ */
22
+
23
+ import { useState } from "react";
24
+ import type { ReactNode } from "react";
25
+ import { Copy, ExternalLink, HeartHandshake, Pause, Play, Plus, Trash2 } from "lucide-react";
26
+ import { Button, Skeleton } from "@graph8/sdk/ui";
27
+ import { ApiError } from "@/g8/components/api-error";
28
+ import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
29
+ import { EmptyState } from "@/g8/components/empty-state";
30
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
31
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
32
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
33
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
34
+ import { SectionCard } from "@/g8/components/screen/section-card";
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, 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 { isDrafted, isFinished, isLiveLike, isNurtureOff, isPaused, statusOf } from "../lib/nurture-types";
44
+ import type { ListOption, MailboxOption, Nurture } from "../lib/nurture-types";
45
+ import { NurtureEnroll } from "./nurture-enroll";
46
+ import { NurtureForm } from "./nurture-form";
47
+ import { NurtureStats } from "./nurture-stats";
48
+
49
+ export interface NurtureScreenProps {
50
+ /** Where a person connects a mailbox (Google and Microsoft sign-in only works in the graph8 app). */
51
+ connectMailboxUrl?: string;
52
+ /** Contact ids (for example selected elsewhere) offered as "add to the audience" in the launch tab. */
53
+ contactIds?: readonly number[];
54
+ renderRowActions?: (nurture: Nurture) => ReactNode;
55
+ renderDetailActions?: (nurture: Nurture) => ReactNode;
56
+ /** Shown in the launch tab for adding people to the audience (for example a contact picker from your CRM page). */
57
+ renderAudienceTools?: (nurture: Nurture) => ReactNode;
58
+ extraDetailTabs?: { id: string; label: string; render: (nurture: Nurture) => ReactNode }[];
59
+ toolbarActions?: ReactNode;
60
+ className?: string;
61
+ }
62
+
63
+ const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
64
+
65
+ function toneOf(n: Nurture): Tone {
66
+ if (isLiveLike(n)) return "positive";
67
+ if (isPaused(n)) return "warning";
68
+ if (isFinished(n)) return "neutral";
69
+ return "info";
70
+ }
71
+
72
+ function matchesFilter(n: Nurture, f: string): boolean {
73
+ if (!f) return true;
74
+ if (f === "drafted") return isDrafted(n);
75
+ if (f === "live") return isLiveLike(n);
76
+ if (f === "paused") return isPaused(n);
77
+ return isFinished(n);
78
+ }
79
+
80
+ interface StepRow { step_order?: number; time_interval?: number; step_data?: { subject?: string; body?: string } }
81
+
82
+ const when = (v: string | null | undefined): string => (v ? new Date(v).toLocaleString() : "");
83
+ const yesNo = (v: boolean | undefined, fallback: boolean): string => ((v ?? fallback) ? "Yes" : "No");
84
+
85
+ /** Every field of one nurture, its audience and sender by name, and each email of the series. */
86
+ function NurtureDetails({ nurtureId }: { nurtureId: string }) {
87
+ const one = useG8Query((signal) => g8Get<Nurture>(`/nurtures/${encodeURIComponent(nurtureId)}`, {}, { signal }).then((r) => r.data), [nurtureId]);
88
+ const lists = useG8Query((signal) => g8Get<ListOption[]>("/lists", { limit: 200 }, { signal }).then((r) => r.data ?? []), []);
89
+ const boxes = useG8Query((signal) => g8Get<{ mailboxes?: MailboxOption[] }>("/mailboxes/my", {}, { signal }).then((r) => r.data?.mailboxes ?? []), []);
90
+ if (one.error) return <ApiError error={one.error} title="Could not load the nurture" onRetry={() => void one.refetch()} />;
91
+ if (!one.data) return <Skeleton className="h-40 w-full" aria-busy="true" />;
92
+ const n = one.data;
93
+ const list = (lists.data ?? []).find((l) => l.id === n.associated_list_id);
94
+ const box = (boxes.data ?? []).find((m) => m.id === n.pinned_mailbox_id);
95
+ const steps = Array.isArray(n.steps) ? (n.steps as StepRow[]) : [];
96
+ return (
97
+ <div className="grid gap-4">
98
+ <KeyValueList
99
+ columns={2}
100
+ items={[
101
+ { label: "Name", value: n.name },
102
+ { label: "Status", value: statusOf(n) },
103
+ { label: "Description", value: n.description || "Not set" },
104
+ { label: "Audience list", value: list ? `${list.title} (${(list.contact_count ?? 0).toLocaleString()})` : n.associated_list_id ? `List ${n.associated_list_id}` : "None yet" },
105
+ { label: "Sends from", value: box?.email ?? (n.pinned_mailbox_id ? `Mailbox ${n.pinned_mailbox_id}` : "Not set") },
106
+ { label: "Stops on reply", value: yesNo(n.finish_on_reply, true) },
107
+ { label: "One thread", value: yesNo(n.send_in_same_thread, true) },
108
+ { label: "Opt-out line", value: yesNo(n.include_unsubscribe_cta, true) },
109
+ { label: "Shared", value: yesNo(n.is_shared, false) },
110
+ { label: "Created", value: when(n.created_at) || "Not set" },
111
+ { label: "Updated", value: when(n.updated_at) || "Not set" },
112
+ { label: "Nurture id", value: n.id },
113
+ ]}
114
+ />
115
+ <SectionCard title="Emails" description={steps.length ? "In the order they are sent." : "The series has no emails to show."}>
116
+ <ol className="grid gap-2">
117
+ {steps.map((st, i) => (
118
+ <li key={st.step_order ?? i} className="rounded-md border p-3 text-sm">
119
+ <p className="font-medium">{i + 1}. {st.step_data?.subject || "No subject"}</p>
120
+ <p className="text-muted-foreground text-xs">{i === 0 ? "Sent first, when you go live" : waitText(Math.round((st.time_interval ?? 0) / 86400))}</p>
121
+ {st.step_data?.body ? <p className="mt-1 whitespace-pre-wrap">{st.step_data.body}</p> : null}
122
+ </li>
123
+ ))}
124
+ </ol>
125
+ </SectionCard>
126
+ </div>
127
+ );
128
+ }
129
+
130
+ /** "Wait 1 day" / "Wait 3 days" after the previous email (`time_interval` is seconds). */
131
+ function waitText(days: number): string {
132
+ return `Wait ${days} ${days === 1 ? "day" : "days"} after the previous email`;
133
+ }
134
+
135
+ export function NurtureScreen({ connectMailboxUrl = "https://app.graph8.com", contactIds, renderRowActions, renderDetailActions, renderAudienceTools, extraDetailTabs, toolbarActions, className }: NurtureScreenProps) {
136
+ const [id, setId] = useUrlState("id", "");
137
+ const [view, setView] = useUrlState("view", "");
138
+ const expandProps = { onExpand: () => setView("full"), onCollapse: view === "full" ? () => setView(null) : undefined };
139
+ const [search, setSearch] = useState("");
140
+ const [filter, setFilter] = useState("");
141
+ const [creating, setCreating] = useState(false);
142
+ const [toResume, setToResume] = useState<Nurture | null>(null);
143
+ const [toArchive, setToArchive] = useState<Nurture | null>(null);
144
+ const [busy, setBusy] = useState<string | null>(null);
145
+
146
+ const list = useG8Query((signal) => g8Get<{ nurtures?: Nurture[] }>("/nurtures", { page: 1, limit: 100 }, { signal }).then((r) => r.data?.nurtures ?? []), []);
147
+ const mailboxes = useG8Query((signal) => g8Get<{ mailboxes?: MailboxOption[] }>("/mailboxes/my", {}, { signal }).then((r) => (r.data?.mailboxes ?? []).filter((m) => !m.is_archived)), [], { enabled: creating });
148
+
149
+ const act = useG8Mutation((a: { kind: "pause" | "duplicate"; nurture: Nurture }) => g8Post(`/nurtures/${encodeURIComponent(a.nurture.id)}/${a.kind}`, a.kind === "duplicate" ? {} : undefined), { onSuccess: () => void list.refetch() });
150
+ const run = async (kind: "pause" | "duplicate", nurture: Nurture): Promise<void> => {
151
+ setBusy(`${kind}:${nurture.id}`);
152
+ await act.mutate({ kind, nurture });
153
+ setBusy(null);
154
+ };
155
+ const resume = useG8Mutation((n: Nurture) => g8Post(`/nurtures/${encodeURIComponent(n.id)}/resume`), { onSuccess: () => { setToResume(null); void list.refetch(); } });
156
+ const archive = useG8Mutation((n: Nurture) => g8Delete(`/nurtures/${encodeURIComponent(n.id)}`), {
157
+ onSuccess: (_r, n) => { setToArchive(null); if (n.id === id) setId(null); void list.refetch(); },
158
+ });
159
+
160
+ if (isNurtureOff(list.error)) {
161
+ return <ScreenLayout title="Nurture" description="Lifecycle email series for people who agreed to hear from you." icon={<HeartHandshake className="size-5" />}><EmptyState title="Nurture is not enabled" description="Nurture is switched off for this workspace. Ask graph8 to turn it on, then reload." /></ScreenLayout>;
162
+ }
163
+
164
+ const all = list.data ?? [];
165
+ const q = search.trim().toLowerCase();
166
+ const shown = all.filter((n) => matchesFilter(n, filter) && (!q || n.name.toLowerCase().includes(q)));
167
+ const selected = all.find((n) => n.id === id);
168
+ const noMailbox = creating && !mailboxes.loading && (mailboxes.data ?? []).length === 0 && !mailboxes.error;
169
+
170
+ const rows = list.error ? <ApiError error={list.error} title="Could not load nurtures" onRetry={() => void list.refetch()} /> : list.loading && !list.data ? (
171
+ <div aria-busy="true" className="grid gap-2"><Skeleton className="h-16 w-full" /><Skeleton className="h-16 w-full" /><Skeleton className="h-16 w-full" /></div>
172
+ ) : shown.length === 0 ? (
173
+ <EmptyState icon={<HeartHandshake className="size-8" />} title={all.length === 0 ? "No nurtures yet" : "No nurtures match"} description={all.length === 0 ? "A nurture sends a short series of emails to people who agreed to hear from you, and stops for each person when they reply." : "Try another search or status."} action={all.length === 0 ? <Button onClick={() => setCreating(true)}>New nurture</Button> : undefined} />
174
+ ) : (
175
+ <ul className="grid gap-2">
176
+ {shown.map((n) => (
177
+ <li key={n.id} className={`flex flex-wrap items-center justify-between gap-2 rounded-md border p-3 ${n.id === id ? "bg-muted" : ""}`}>
178
+ <button type="button" className="min-w-0 flex-1 text-left" onClick={() => setId(n.id)}>
179
+ <span className="block truncate font-medium">{n.name}</span>
180
+ <span className="text-muted-foreground block text-xs">{Array.isArray(n.steps) && n.steps.length > 0 ? `${n.steps.length} ${n.steps.length === 1 ? "email" : "emails"}` : "Series"}{n.is_shared ? " · shared" : ""}</span>
181
+ </button>
182
+ <StatusPill tone={toneOf(n)}>{statusOf(n)}</StatusPill>
183
+ <div className="flex gap-1">
184
+ {isLiveLike(n) ? <Button size="sm" variant="ghost" aria-label={`Pause ${n.name}`} loading={busy === `pause:${n.id}`} disabled={busy !== null} onClick={() => void run("pause", n)}><Pause className="size-4" /></Button> : null}
185
+ {isPaused(n) ? <Button size="sm" variant="ghost" aria-label={`Resume ${n.name}`} onClick={() => setToResume(n)}><Play className="size-4" /></Button> : null}
186
+ <Button size="sm" variant="ghost" aria-label={`Duplicate ${n.name}`} loading={busy === `duplicate:${n.id}`} disabled={busy !== null} onClick={() => void run("duplicate", n)}><Copy className="size-4" /></Button>
187
+ {!isFinished(n) ? <Button size="sm" variant="ghost" aria-label={`Archive ${n.name}`} onClick={() => setToArchive(n)}><Trash2 className="size-4" /></Button> : null}
188
+ {renderRowActions?.(n)}
189
+ </div>
190
+ </li>
191
+ ))}
192
+ </ul>
193
+ );
194
+
195
+ const detail = selected ? (
196
+ <DetailPanel
197
+ title={selected.name}
198
+ subtitle={selected.description ?? undefined}
199
+ badges={<StatusPill tone={toneOf(selected)}>{statusOf(selected)}</StatusPill>}
200
+ actions={renderDetailActions?.(selected)}
201
+ onClose={() => { setId(null); setView(null); }}
202
+ {...expandProps}
203
+ tabs={[
204
+ { id: "overview", label: "Overview", content: <NurtureStats key={selected.id} nurtureId={selected.id} /> },
205
+ { id: "details", label: "Details", content: <NurtureDetails key={selected.id} nurtureId={selected.id} /> },
206
+ { id: "launch", label: "Audience and launch", content: (
207
+ <div className="grid gap-4">
208
+ <NurtureEnroll key={selected.id} nurtureId={selected.id} contactIds={contactIds} onLaunched={() => void list.refetch()} />
209
+ <SectionCard title="Add people to the audience" description="Enrolling means putting a contact in the audience list. Add people to that list from your contacts, or import a list, before you go live.">
210
+ {renderAudienceTools?.(selected)}
211
+ </SectionCard>
212
+ </div>
213
+ ) },
214
+ ...(!isFinished(selected) ? [{ id: "settings", label: "Settings", content: <NurtureForm key={selected.id} nurture={selected} onSaved={() => void list.refetch()} /> }] : []),
215
+ ...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selected) })),
216
+ ]}
217
+ />
218
+ ) : null;
219
+
220
+ return (
221
+ <ScreenLayout
222
+ className={className}
223
+ title="Nurture"
224
+ description="Lifecycle email series for people who agreed to hear from you."
225
+ icon={<HeartHandshake className="size-5" />}
226
+ actions={<Button size="sm" onClick={() => setCreating(true)}><Plus className="size-4" />New nurture</Button>}
227
+ stats={[
228
+ { label: "Nurtures", value: all.length.toLocaleString() },
229
+ { label: "Live", value: all.filter(isLiveLike).length.toLocaleString(), tone: "positive" },
230
+ { label: "Drafted", value: all.filter(isDrafted).length.toLocaleString() },
231
+ { label: "Paused", value: all.filter(isPaused).length.toLocaleString(), tone: "warning" },
232
+ ]}
233
+ toolbar={(
234
+ <Toolbar
235
+ search={{ value: search, onChange: setSearch, placeholder: "Search nurtures" }}
236
+ filters={(
237
+ <select aria-label="Nurture status" className={SELECT} value={filter} onChange={(e) => setFilter(e.target.value)}>
238
+ <option value="">All statuses</option><option value="drafted">Drafted</option><option value="live">Live</option><option value="paused">Paused</option><option value="finished">Finished</option>
239
+ </select>
240
+ )}
241
+ actions={toolbarActions}
242
+ />
243
+ )}
244
+ >
245
+ {act.error ? <ApiError error={act.error} title="That did not work" /> : null}
246
+ <SplitView list={rows} detail={detail} detailOpen={Boolean(selected)} onCloseDetail={() => { setId(null); setView(null); }} expanded={view === "full"} onExpandedChange={(e) => setView(e ? "full" : null)} detailWidth="xl" />
247
+ <FormSheet open={creating} onOpenChange={setCreating} title="New nurture" description="Pick the sender and the audience, then write the emails. Nothing is sent until you go live.">
248
+ <div className="grid gap-4">
249
+ {noMailbox ? (
250
+ <SectionCard title="Connect a mailbox first" description="A nurture sends from one connected mailbox. Google and Microsoft sign-in only works inside graph8.">
251
+ <a className="inline-flex items-center gap-1 text-sm underline" href={connectMailboxUrl} target="_blank" rel="noreferrer">Connect in graph8 <ExternalLink className="size-3.5" /></a>
252
+ </SectionCard>
253
+ ) : null}
254
+ {creating ? <NurtureForm onCancel={() => setCreating(false)} onSaved={(created) => { setCreating(false); void list.refetch(); setId(created.id); }} /> : null}
255
+ </div>
256
+ </FormSheet>
257
+ <ConfirmSpendDialog
258
+ open={toResume !== null}
259
+ tier="external"
260
+ title={`Resume ${toResume?.name ?? "this nurture"}?`}
261
+ description={<><p>Emails start going out again to the contacts who have not finished it.</p>{resume.error ? <ApiError error={resume.error} title="Could not resume" /> : null}</>}
262
+ confirmLabel="Resume"
263
+ confirming={resume.loading}
264
+ onCancel={() => setToResume(null)}
265
+ onConfirm={() => { if (toResume) void resume.mutate(toResume); }}
266
+ />
267
+ <ConfirmSpendDialog
268
+ open={toArchive !== null}
269
+ tier="destructive"
270
+ title={`Archive ${toArchive?.name ?? "this nurture"}?`}
271
+ description={<><p>{toArchive && isLiveLike(toArchive) ? "It is paused and wound down first, then archived. Contacts stop receiving its emails." : "It is archived and no longer runs."}</p>{archive.error ? <ApiError error={archive.error} title="Could not archive" /> : null}</>}
272
+ confirmLabel="Archive"
273
+ confirming={archive.loading}
274
+ onCancel={() => setToArchive(null)}
275
+ onConfirm={() => { if (toArchive) void archive.mutate(toArchive); }}
276
+ />
277
+ </ScreenLayout>
278
+ );
279
+ }
@@ -3,8 +3,20 @@
3
3
  Editable React screens for the marketing-site side of a product: landing pages (list, section editor, publish),
4
4
  a content library, the brand kit and SEO audits. Install with `npx @graph8/sdk add pages`.
5
5
 
6
+ ## Screens
7
+
8
+ `PagesScreen` (`components/pages-screen.tsx`) is the finished page: landing pages with the editor beside the list, plus content, brand kit and SEO tabs.
9
+
10
+ ```tsx
11
+ import { PagesScreen } from "@/g8/pages/components/pages-screen";
12
+ export default function Page() { return <PagesScreen />; }
13
+ ```
14
+
15
+ Flows: list and filter landing pages, create blank (free) or with AI (confirmed spend), open one (deep link `?id=`) to edit sections, preview, restore a version, publish or unpublish (confirmed) and delete (confirmed); manage the content library; re-scan the brand kit; run SEO audits. Needs the proxy prefixes listed under Prerequisites. Optional props: `defaultDomain`, `renderDetailActions`, `extraDetailTabs`, `toolbarActions`.
16
+
6
17
  | Component | What it is |
7
18
  |---|---|
19
+ | `PagesScreen` | The finished page: landing pages with the editor beside them, content, brand kit and SEO tabs. |
8
20
  | `PageList` | Landing pages with a status filter; create blank or with AI; publish, unpublish, delete. |
9
21
  | `PageEditor` | Lite editor: page details, one card per section (its text fields), preview, version restore, publish. Not a WYSIWYG canvas. |
10
22
  | `ContentLibrary` | Content inventory (blogs, website pages): filters, add by URL, edit sheet, SEO enrichment, delete. |
@@ -45,6 +45,13 @@
45
45
  "remove_seo_tracked_keywords_seo_rank_tracking_keywords_remove_post"
46
46
  ],
47
47
  "components": [
48
+ {
49
+ "name": "PagesScreen",
50
+ "file": "components/pages-screen.tsx",
51
+ "purpose": "Finished marketing-site workspace: landing pages with beside-the-list editor, content library, brand kit and SEO tabs.",
52
+ "operations": [],
53
+ "tiers": []
54
+ },
48
55
  {
49
56
  "name": "PageList",
50
57
  "file": "components/page-list.tsx",
@@ -218,6 +225,7 @@
218
225
  }
219
226
  ],
220
227
  "peerDependencies": {
221
- "react": ">=18"
228
+ "react": ">=18",
229
+ "lucide-react": ">=0.400.0"
222
230
  }
223
231
  }
@@ -23,12 +23,15 @@ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
23
23
  import { ApiError } from "@/g8/components/api-error";
24
24
  import { EmptyState } from "@/g8/components/empty-state";
25
25
  import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
26
- import { asRec, num, rowsOf, text } from "../lib/studio";
27
- import type { ContentPageRow } from "../lib/studio";
26
+ import { asRec, num, rowsOf, shortDate, text } from "../lib/studio";
27
+ import type { ContentPageRow, Rec } from "../lib/studio";
28
28
 
29
29
  export interface ContentLibraryProps {
30
30
  pageSize?: number;
31
31
  className?: string;
32
+ /** Controlled: the content item whose full record is open (for a `?id=` deep link). */
33
+ selectedId?: string | null;
34
+ onSelectedChange?: (id: string | null) => void;
32
35
  }
33
36
 
34
37
  const TYPES = ["", "website", "blog", "linkedin", "newsletter"];
@@ -45,7 +48,22 @@ function Select({ id, value, options, onChange }: { id: string; value: string; o
45
48
  );
46
49
  }
47
50
 
48
- function EditSheet({ pageId, onClose, onSaved }: { pageId: string; onClose: () => void; onSaved: () => void }) {
51
+ /** Every read-only field the content record carries (labels and keys come from the Studio content response). */
52
+ function contentFacts(d: Rec): Array<[string, string]> {
53
+ const n = (k: string): string => (d[k] == null ? "" : text(d[k]));
54
+ const yes = (k: string): string => (d[k] == null ? "" : d[k] ? "Yes" : "No");
55
+ return [
56
+ ["Type", label(text(d.content_type, "website"))], ["Source", n("source")], ["Domain", n("domain")], ["Owned", yes("is_owned")],
57
+ ["H1", n("h1")], ["Meta description", n("meta_description")], ["Words", n("word_count")], ["Crawl status", n("crawl_status")],
58
+ ["SEO score", n("seo_score")], ["AEO score", n("aeo_score")], ["Freshness", n("freshness_score")], ["Priority", n("priority_score")],
59
+ ["Authority", n("authority_score")], ["Backlinks", n("backlinks_total")], ["Referring domains", n("referring_domains_count")], ["Organic keywords", n("organic_keywords_count")],
60
+ ["Keyword position", n("primary_keyword_position")], ["Keyword volume", n("primary_keyword_volume")], ["LLM grade", n("llm_grade")], ["Cited by LLMs", yes("llm_cited")],
61
+ ["AI overview", yes("ai_overview_present")], ["Clicks", n("gsc_clicks")], ["Impressions", n("gsc_impressions")], ["CTR", n("gsc_ctr")], ["Search position", n("gsc_position")],
62
+ ["Published", shortDate(d.published_at)], ["Last crawled", shortDate(d.last_crawled_at)], ["Created", shortDate(d.created_at)], ["Last synced", shortDate(d.semrush_last_synced)],
63
+ ];
64
+ }
65
+
66
+ function EditSheet({ pageId, onClose, onSaved, onAction }: { pageId: string; onClose: () => void; onSaved: () => void; onAction: (kind: "enrich" | "delete", page: ContentPageRow) => void }) {
49
67
  const detail = useG8Query((signal) => g8Get(`/content/pages/${encodeURIComponent(pageId)}`, undefined, { signal }), [pageId]);
50
68
  const [form, setForm] = useState({ title: "", primary_keyword: "", content_status: "idea", body_content: "" });
51
69
  const d = asRec(detail.data?.data);
@@ -64,6 +82,15 @@ function EditSheet({ pageId, onClose, onSaved }: { pageId: string; onClose: () =
64
82
  {detail.error ? <ApiError error={detail.error} onRetry={() => void detail.refetch()} /> : null}
65
83
  {detail.loading && !detail.data ? <Skeleton className="h-32 w-full" /> : (
66
84
  <>
85
+ <dl className="grid grid-cols-2 gap-2 text-sm">
86
+ {contentFacts(d).map(([k, v]) => (
87
+ <div key={k}><dt className="text-muted-foreground text-xs">{k}</dt><dd className="truncate" title={v}>{v || "Not set"}</dd></div>
88
+ ))}
89
+ </dl>
90
+ <div className="flex gap-2">
91
+ <Button size="sm" variant="outline" onClick={() => onAction("enrich", { id: pageId, url: text(d.url), title: text(d.title) })}>Enrich SEO</Button>
92
+ <Button size="sm" variant="ghost" onClick={() => onAction("delete", { id: pageId, url: text(d.url), title: text(d.title) })}>Delete page</Button>
93
+ </div>
67
94
  <div className="space-y-1"><Label htmlFor="cl-title">Title</Label><Input id="cl-title" value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} /></div>
68
95
  <div className="space-y-1"><Label htmlFor="cl-kw">Primary keyword</Label><Input id="cl-kw" value={form.primary_keyword} onChange={(e) => setForm({ ...form, primary_keyword: e.target.value })} /></div>
69
96
  <div className="space-y-1"><Label htmlFor="cl-status">Status</Label><Select id="cl-status" value={form.content_status} options={STATUSES.slice(1)} onChange={(v) => setForm({ ...form, content_status: v })} /></div>
@@ -78,12 +105,15 @@ function EditSheet({ pageId, onClose, onSaved }: { pageId: string; onClose: () =
78
105
  );
79
106
  }
80
107
 
81
- export function ContentLibrary({ pageSize = 25, className }: ContentLibraryProps) {
108
+ export function ContentLibrary({ pageSize = 25, className, selectedId, onSelectedChange }: ContentLibraryProps) {
82
109
  const [type, setType] = useState("");
83
110
  const [status, setStatus] = useState("");
84
111
  const [offset, setOffset] = useState(0);
85
112
  const [url, setUrl] = useState("");
86
- const [editing, setEditing] = useState<string | null>(null);
113
+ const [localEditing, setLocalEditing] = useState<string | null>(null);
114
+ const controlled = onSelectedChange !== undefined;
115
+ const editing = controlled ? (selectedId ?? null) : localEditing;
116
+ const setEditing = (id: string | null): void => (controlled ? onSelectedChange?.(id) : setLocalEditing(id));
87
117
  const [action, setAction] = useState<Action | null>(null);
88
118
 
89
119
  const list = useG8Query(
@@ -93,7 +123,7 @@ export function ContentLibrary({ pageSize = 25, className }: ContentLibraryProps
93
123
  const add = useG8Mutation((u: string) => g8Post("/content/pages", { url: u, content_type: type || "website" }), { onSuccess: () => { setUrl(""); void list.refetch(); } });
94
124
  const run = useG8Mutation(
95
125
  (a: Action) => (a.kind === "enrich" ? g8Post(`/content/pages/${encodeURIComponent(a.page.id)}/enrich-seo`) : g8Delete(`/content/pages/${encodeURIComponent(a.page.id)}`)),
96
- { onSuccess: () => { setAction(null); void list.refetch(); } },
126
+ { onSuccess: (_r, a) => { setAction(null); if (a.kind === "delete" && a.page.id === editing) setEditing(null); void list.refetch(); } },
97
127
  );
98
128
 
99
129
  const data = asRec(list.data?.data);
@@ -150,7 +180,7 @@ export function ContentLibrary({ pageSize = 25, className }: ContentLibraryProps
150
180
  </div>
151
181
  </div>
152
182
 
153
- {editing ? <EditSheet pageId={editing} onClose={() => setEditing(null)} onSaved={() => void list.refetch()} /> : null}
183
+ {editing ? <EditSheet pageId={editing} onClose={() => setEditing(null)} onSaved={() => void list.refetch()} onAction={(kind, page) => setAction({ kind, page })} /> : null}
154
184
  {action ? (
155
185
  <ConfirmSpendDialog open tier={action.kind === "enrich" ? "billable" : "destructive"}
156
186
  title={action.kind === "enrich" ? `Enrich SEO for "${name(action.page)}"` : `Delete "${name(action.page)}"`}
@@ -29,6 +29,8 @@ import type { LandingPageRow } from "../lib/studio";
29
29
  export interface PageListProps {
30
30
  /** Called with the page id when a row is opened. Wire it to your PageEditor route. */
31
31
  onOpen?: (pageId: string) => void;
32
+ /** Called with the page id after a page is deleted (for example to close it if it is open beside the list). */
33
+ onDeleted?: (pageId: string) => void;
32
34
  pageSize?: number;
33
35
  className?: string;
34
36
  }
@@ -56,7 +58,7 @@ function confirmCopy(p: Pending): { tier: ConfirmTier; title: string; descriptio
56
58
  return { tier: "destructive", title: `Delete "${p.page.name}"`, description: "The page and its version history are removed.", label: "Delete", affected: 1 };
57
59
  }
58
60
 
59
- export function PageList({ onOpen, pageSize = 25, className }: PageListProps) {
61
+ export function PageList({ onOpen, onDeleted, pageSize = 25, className }: PageListProps) {
60
62
  const [status, setStatus] = useState("");
61
63
  const [offset, setOffset] = useState(0);
62
64
  const [name, setName] = useState("");
@@ -72,7 +74,7 @@ export function PageList({ onOpen, pageSize = 25, className }: PageListProps) {
72
74
  if (p.kind === "publish") return g8Post(`${base}/publish`);
73
75
  if (p.kind === "unpublish") return g8Post(`${base}/unpublish`);
74
76
  return g8Delete(base);
75
- }, { onSuccess: () => { setPending(null); void list.refetch(); } });
77
+ }, { onSuccess: (_res, p) => { setPending(null); void list.refetch(); if (p.kind === "delete") onDeleted?.(p.page.id); } });
76
78
  const blank = useG8Mutation((title: string) => g8Post("/landing-pages", { name: title || undefined, generate: false }), {
77
79
  onSuccess: (res) => {
78
80
  setName("");
@@ -0,0 +1,154 @@
1
+ "use client";
2
+
3
+ /**
4
+ * PagesScreen: the finished marketing-site workspace. No props needed.
5
+ *
6
+ * Composes: ScreenLayout (title, tabs, stats), SplitView + DetailPanel (landing page list and the selected
7
+ * page's editor, deep link `?id=`), PageList (list, filter, blank or AI create, publish, unpublish, delete),
8
+ * PageEditor (details, sections, preview, versions, publish), ContentLibrary, BrandKitEditor, SeoReport.
9
+ *
10
+ * Every record is deep-linkable: `?id=` opens a landing page (Edit and Details tabs) or, on the Content tab
11
+ * (`?tab=content`), a content item with all of its fields and actions; the Brand kit tab is the one brand record.
12
+ * `&view=full` shows the open record as a full page.
13
+ *
14
+ * User flows: see every landing page with its state, create one (blank is free, AI is a confirmed spend), open it
15
+ * beside the list, edit its sections, preview, restore a version, publish or unpublish (confirmed), delete
16
+ * (confirmed); manage the content inventory (add by URL, edit, SEO enrichment, delete); re-scan the brand kit and
17
+ * choose page layouts; run an SEO audit and track keywords. The editor changes text fields of sections-mode pages;
18
+ * freeform HTML pages can be previewed, published and versioned, and their HTML is edited in the graph8 app.
19
+ *
20
+ * SDK operations: listLandingPages (for the headline numbers only); everything else lives in the composed parts,
21
+ * which confirm every publish, unpublish, delete, AI generation and paid audit.
22
+ */
23
+
24
+ import { useState } from "react";
25
+ import type { ReactNode } from "react";
26
+ import { Globe } from "lucide-react";
27
+ import { Skeleton } from "@graph8/sdk/ui";
28
+ import { ApiError } from "@/g8/components/api-error";
29
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
30
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
31
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
32
+ import { SplitView } from "@/g8/components/screen/split-view";
33
+ import { useUrlState } from "@/g8/hooks/use-url-state";
34
+ import { g8Get } from "@/g8/lib/g8-client";
35
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
36
+ import { StatusPill } from "@/g8/components/screen/status-pill";
37
+ import { asRec, num, rowsOf, shortDate, text } from "../lib/studio";
38
+ import type { LandingPageRow } from "../lib/studio";
39
+ import { BrandKitEditor } from "./brand-kit-editor";
40
+ import { ContentLibrary } from "./content-library";
41
+ import { PageEditor } from "./page-editor";
42
+ import { PageList } from "./page-list";
43
+ import { SeoReport } from "./seo-report";
44
+
45
+ export interface PagesScreenProps {
46
+ /** Prefill the SEO audit domain. */
47
+ defaultDomain?: string;
48
+ renderDetailActions?: (pageId: string) => ReactNode;
49
+ extraDetailTabs?: { id: string; label: string; render: (pageId: string) => ReactNode }[];
50
+ toolbarActions?: ReactNode;
51
+ className?: string;
52
+ }
53
+
54
+ type Tab = "pages" | "content" | "brand" | "seo";
55
+
56
+ /** Every field of one landing page (name and slug are edited in the Edit tab). */
57
+ function PageDetails({ pageId }: { pageId: string }) {
58
+ const res = useG8Query((signal) => g8Get(`/landing-pages/${encodeURIComponent(pageId)}`, undefined, { signal }), [pageId]);
59
+ if (res.error) return <ApiError error={res.error} title="Could not load the page" onRetry={() => void res.refetch()} />;
60
+ if (!res.data) return <Skeleton className="h-32 w-full" aria-busy="true" />;
61
+ const d = asRec(res.data.data);
62
+ const n = (k: string): string => (d[k] == null || d[k] === "" ? "Not set" : text(d[k]));
63
+ const sections = Array.isArray(d.sections) ? d.sections.length : null;
64
+ return (
65
+ <KeyValueList
66
+ columns={2}
67
+ items={[
68
+ { label: "Name", value: n("name") },
69
+ { label: "Status", value: n("status") },
70
+ { label: "Slug", value: n("slug") },
71
+ { label: "Mode", value: d.generation_mode === "freeform" ? "Freeform HTML" : "Sections" },
72
+ { label: "Sections", value: sections == null ? "None (freeform)" : String(sections) },
73
+ { label: "Live URL", value: n("published_url") },
74
+ { label: "Description", value: n("description") },
75
+ { label: "Created", value: shortDate(d.created_at) || "Not set" },
76
+ { label: "Updated", value: shortDate(d.updated_at) || "Not set" },
77
+ { label: "Page id", value: n("id") },
78
+ ...(d.error_message ? [{ label: "Last error", value: text(d.error_message) }] : []),
79
+ ]}
80
+ />
81
+ );
82
+ }
83
+
84
+ export function PagesScreen({ defaultDomain, renderDetailActions, extraDetailTabs, toolbarActions, className }: PagesScreenProps) {
85
+ const [tabParam, setTabParam] = useUrlState("tab", "pages");
86
+ const tab = (["pages", "content", "brand", "seo"].includes(tabParam) ? tabParam : "pages") as Tab;
87
+ const [pageId, setPageId] = useUrlState("id", "");
88
+ const [view, setView] = useUrlState("view", "");
89
+ const expandProps = { onExpand: () => setView("full"), onCollapse: view === "full" ? () => setView(null) : undefined };
90
+ // The list owns its own fetch; bumping this remounts it after the editor changed something (publish, rename).
91
+ const [rev, setRev] = useState(0);
92
+ const pages = useG8Query((signal) => g8Get("/landing-pages", { limit: 100 }, { signal }), [rev]);
93
+ const data = asRec(pages.data?.data);
94
+ const rows = rowsOf<LandingPageRow>(data, "landing_pages");
95
+ const total = num(data.total) ?? rows.length;
96
+ const published = rows.filter((p) => p.status === "published").length;
97
+ const current = tab === "pages" ? rows.find((p) => p.id === pageId) : undefined;
98
+
99
+ const detail = tab === "pages" && pageId ? (
100
+ <DetailPanel
101
+ title={current?.name || "Landing page"}
102
+ subtitle={current?.published_url ?? undefined}
103
+ badges={current ? <StatusPill tone={current.status === "published" ? "positive" : "neutral"}>{current.status}</StatusPill> : undefined}
104
+ actions={renderDetailActions?.(pageId)}
105
+ onClose={() => { setPageId(null); setView(null); }}
106
+ {...expandProps}
107
+ tabs={[
108
+ { id: "edit", label: "Edit", content: <PageEditor key={pageId} pageId={pageId} onSaved={() => setRev((r) => r + 1)} /> },
109
+ { id: "details", label: "Details", content: <PageDetails key={`${pageId}:${rev}`} pageId={pageId} /> },
110
+ ...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(pageId) })),
111
+ ]}
112
+ />
113
+ ) : null;
114
+
115
+ return (
116
+ <ScreenLayout
117
+ className={className}
118
+ title="Pages"
119
+ description="Landing pages, your content library, the brand kit and SEO in one place."
120
+ icon={<Globe className="size-5" />}
121
+ actions={toolbarActions}
122
+ tabs={[
123
+ { id: "pages", label: "Landing pages", count: total },
124
+ { id: "content", label: "Content" },
125
+ { id: "brand", label: "Brand kit" },
126
+ { id: "seo", label: "SEO" },
127
+ ]}
128
+ activeTab={tab}
129
+ onTabChange={(id) => { setTabParam(id === "pages" ? null : id); setPageId(null); setView(null); }}
130
+ stats={tab === "pages" ? [
131
+ { label: "Landing pages", value: total.toLocaleString() },
132
+ { label: "Published", value: published.toLocaleString(), tone: "positive" },
133
+ { label: "Drafts", value: Math.max(0, rows.length - published).toLocaleString() },
134
+ ] : undefined}
135
+ >
136
+ {tab === "pages" ? (
137
+ <>
138
+ {pages.error ? <ApiError error={pages.error} title="Could not load landing pages" onRetry={() => void pages.refetch()} /> : null}
139
+ <SplitView
140
+ detailOpen={Boolean(pageId)}
141
+ onCloseDetail={() => { setPageId(null); setView(null); }}
142
+ expanded={view === "full"}
143
+ onExpandedChange={(e) => setView(e ? "full" : null)}
144
+ detailWidth="xl"
145
+ list={<PageList key={rev} onOpen={(id) => setPageId(id)} onDeleted={(id) => { if (id === pageId) { setPageId(null); setView(null); } setRev((r) => r + 1); }} />}
146
+ detail={detail}
147
+ />
148
+ </>
149
+ ) : tab === "content" ? <ContentLibrary selectedId={pageId || null} onSelectedChange={(id) => setPageId(id)} />
150
+ : tab === "brand" ? <BrandKitEditor />
151
+ : <SeoReport defaultDomain={defaultDomain} />}
152
+ </ScreenLayout>
153
+ );
154
+ }
@@ -11,6 +11,22 @@ Install with `npx @graph8/sdk add quotes`.
11
11
  | `QuoteViewer` | One quote with totals and status-based actions: send/resend, edit as draft, duplicate, delete. |
12
12
  | `SignatureStatus` | Envelope state, signers, activity, reminder. |
13
13
 
14
+ ## Screens
15
+
16
+ `QuotesScreen` (`components/quotes-screen.tsx`): `<QuotesScreen />`, optionally `dealId` or `companyId` to scope it.
17
+
18
+ - Headline numbers by status (drafts, out for signature, accepted, declined) and the quote list with its status filter.
19
+ - Open a quote (deep link `?id=<quote id>`): line items, totals, signer, signature progress, with the actions its status
20
+ allows. Send, resend, signature reminder and edit-as-draft are external and confirmed; delete is destructive and
21
+ confirmed; duplicate and saving a draft email nobody.
22
+ - **New quote** asks for the deal first (search by name, skipped when `dealId` is given because graph8 attaches every
23
+ quote to a deal), then opens the builder (details, signer, line items from the quotable products, live totals).
24
+ - Edit a draft from the viewer in a side sheet.
25
+ - Slots, all optional: `renderDetailActions(quoteId)`, `extraDetailTabs`, `toolbarActions`.
26
+
27
+ The deal search calls `GET /deals`, so the key also needs `deals:read` and the proxy `allowedPrefixes` need `deals`.
28
+
29
+
14
30
  ## Flow, in order (and the ids passed between steps)
15
31
 
16
32
  1. `GET /quotable-products` -> items with `prices[]`. A line item needs `stripe_product_id` + `stripe_price_id`, or `manual_product_id`
@@ -71,6 +87,7 @@ and **delete** (destructive). From code, never call `sendQuote` without the pers
71
87
  | `duplicateQuote` | POST `/api/v1/quotes/{quote_id}/duplicate` | external | `quotes:run` |
72
88
  | `deleteQuote` | DELETE `/api/v1/quotes/{quote_id}` | destructive | `quotes:delete` |
73
89
  | `resendEnvelope` | POST `/api/v1/envelopes/{envelope_id}/resend` | external | `quotes:run` |
90
+ | `listDeals` | GET `/api/v1/deals` | read | `deals:read` (the deal picker in QuotesScreen) |
74
91
 
75
92
  ## Recipes
76
93