@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.
- package/blocks/_foundation/README.md +108 -0
- package/blocks/_foundation/block.json +160 -12
- package/blocks/_foundation/components/api-error.tsx +62 -18
- package/blocks/_foundation/components/empty-state.tsx +39 -7
- package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
- package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
- package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
- package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
- package/blocks/_foundation/components/screen/index.ts +21 -0
- package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
- package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
- package/blocks/_foundation/components/screen/section-card.tsx +47 -0
- package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
- package/blocks/_foundation/components/screen/split-view.tsx +125 -0
- package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
- package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
- package/blocks/_foundation/components/screen/time-series-chart.tsx +114 -0
- package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
- package/blocks/_foundation/hooks/use-url-state.ts +74 -0
- package/blocks/appointments/README.md +21 -1
- package/blocks/appointments/block.json +39 -2
- package/blocks/appointments/components/event-type-related.tsx +75 -0
- package/blocks/appointments/components/meeting-list.tsx +8 -4
- package/blocks/appointments/components/meetings-screen.tsx +154 -0
- package/blocks/appointments/components/scheduling-screen.tsx +121 -0
- package/blocks/appointments/hooks/use-event-types.ts +17 -0
- package/blocks/block.types.ts +9 -0
- package/blocks/deals/README.md +34 -3
- package/blocks/deals/block.json +39 -4
- package/blocks/deals/components/deal-detail.tsx +4 -2
- package/blocks/deals/components/deal-related.tsx +87 -0
- package/blocks/deals/components/deals-screen.tsx +293 -0
- package/blocks/deals/components/pipeline-board.tsx +4 -2
- package/blocks/deals/hooks/use-default-owner.ts +60 -0
- package/blocks/deals/lib/deals-types.ts +14 -0
- package/blocks/forms/README.md +12 -0
- package/blocks/forms/block.json +9 -1
- package/blocks/forms/components/forms-screen.tsx +350 -0
- package/blocks/forms/components/submissions-table.tsx +13 -5
- package/blocks/lifecycle/README.md +16 -0
- package/blocks/lifecycle/block.json +23 -1
- package/blocks/lifecycle/components/account-related.tsx +66 -0
- package/blocks/lifecycle/components/lifecycle-screen.tsx +147 -0
- package/blocks/newsletter/README.md +13 -0
- package/blocks/newsletter/block.json +9 -1
- package/blocks/newsletter/components/newsletter-screen.tsx +402 -0
- package/blocks/newsletter/components/subscriber-table.tsx +12 -6
- package/blocks/nurture/README.md +12 -0
- package/blocks/nurture/block.json +9 -1
- package/blocks/nurture/components/nurture-screen.tsx +279 -0
- package/blocks/pages/README.md +12 -0
- package/blocks/pages/block.json +9 -1
- package/blocks/pages/components/content-library.tsx +37 -7
- package/blocks/pages/components/page-list.tsx +4 -2
- package/blocks/pages/components/pages-screen.tsx +154 -0
- package/blocks/quotes/README.md +17 -0
- package/blocks/quotes/block.json +26 -2
- package/blocks/quotes/components/quote-list.tsx +7 -3
- package/blocks/quotes/components/quote-related.tsx +120 -0
- package/blocks/quotes/components/quotes-screen.tsx +169 -0
- package/dist/cli.js +197 -37
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +1 -1
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +93 -7
- package/dist/index.d.ts +93 -7
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +1 -1
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +1 -1
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +90 -4
- package/dist/react.d.ts +90 -4
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/create.d.ts +15 -1
- package/dist/types/blocks/doctor.d.ts +2 -1
- package/dist/types/blocks/flows.d.ts +20 -0
- package/dist/types/blocks/registry.d.ts +15 -0
- package/dist/types/core.d.ts +3 -2
- package/dist/types/react.d.ts +2 -0
- package/dist/types/studio.d.ts +48 -0
- package/dist/types/version.d.ts +2 -2
- package/docs/api/adCampaigns.d.ts +4 -0
- package/docs/api/adCampaigns.md +4 -0
- package/docs/api/gtmCampaigns.d.ts +4 -0
- package/docs/api/gtmCampaigns.md +2 -0
- package/docs/api/gtmContext.d.ts +5 -1
- package/docs/api/gtmContext.md +15 -7
- package/docs/api/integrations.d.ts +29 -4
- package/docs/api/integrations.md +14 -0
- package/docs/sdk-clients.md +1 -1
- package/package.json +1 -1
- package/stacks/stack.schema.json +6 -1
- package/styles/styles.css +1 -1
- package/templates/next-app/README.md.tmpl +16 -0
- package/templates/next-app/app/globals.css.tmpl +105 -0
- package/templates/next-app/app/layout.tsx.tmpl +11 -2
- package/templates/next-app/components/app-shell.tsx.tmpl +238 -23
- package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
- package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
- package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
- package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
- package/templates/next-app/lib/nav.ts.tmpl +1 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Account views for the lifecycle record panel: the customer-success brief and recent intent signals.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations, in order:
|
|
7
|
+
* AccountBriefTab: GET /customer-success/companies/{id}/brief (lifecycle.getBrief) read, free
|
|
8
|
+
* POST /customer-success/companies/{id}/brief/synthesize (lifecycle.synthesizeBrief) BILLABLE -> ConfirmSpendDialog
|
|
9
|
+
* AccountSignalsTab: GET /accounts/{id}/intent-signals?limit (lifecycle.getAccountIntentSignals) read
|
|
10
|
+
* The company id is the account id on these revenue routes.
|
|
11
|
+
*/
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { Radar } from "lucide-react";
|
|
14
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
15
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
16
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
17
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
18
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
19
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
20
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
21
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
22
|
+
import { asRec, body, text } from "../lib/lifecycle";
|
|
23
|
+
|
|
24
|
+
export function AccountBriefTab({ companyId, name }: { companyId: number; name?: string | null }) {
|
|
25
|
+
const base = `/customer-success/companies/${companyId}/brief`;
|
|
26
|
+
const brief = useG8Query((signal) => g8Get(base, undefined, { signal }), [companyId]);
|
|
27
|
+
const [confirm, setConfirm] = useState(false);
|
|
28
|
+
const write = useG8Mutation(() => g8Post(`${base}/synthesize`), { onSuccess: () => { setConfirm(false); void brief.refetch(); } });
|
|
29
|
+
const b = asRec(body(brief.data));
|
|
30
|
+
if (brief.error) return <ApiError error={brief.error} title="Could not load the brief" onRetry={() => void brief.refetch()} />;
|
|
31
|
+
if (brief.loading && !brief.data) return <Skeleton className="h-24 w-full" aria-label="Loading brief" />;
|
|
32
|
+
return (
|
|
33
|
+
<SectionCard title="Account brief" description="What the team knows about this customer, in one place." actions={<Button size="sm" variant="outline" onClick={() => setConfirm(true)}>{b.summary ? "Rewrite brief" : "Write brief"}</Button>}>
|
|
34
|
+
{b.summary ? <p className="whitespace-pre-wrap text-sm" data-testid="brief-summary">{text(b.summary)}</p> : <p className="text-sm text-muted-foreground">No brief written yet.</p>}
|
|
35
|
+
{b.synthesized_at ? <p className="mt-2 text-xs text-muted-foreground">Written <time dateTime={text(b.synthesized_at)}>{new Date(text(b.synthesized_at)).toLocaleDateString()}</time></p> : null}
|
|
36
|
+
{write.error ? <ApiError error={write.error} title="Could not write the brief" /> : null}
|
|
37
|
+
{confirm ? (
|
|
38
|
+
<ConfirmSpendDialog open tier="billable" title={`Write a brief for ${name ?? "this customer"}`} confirmLabel="Write brief" confirming={write.loading}
|
|
39
|
+
description="Reads everything known about the customer and writes a new brief with an AI model. This spends credits." onConfirm={() => void write.mutate(undefined)} onCancel={() => setConfirm(false)} />
|
|
40
|
+
) : null}
|
|
41
|
+
</SectionCard>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function AccountSignalsTab({ companyId }: { companyId: number }) {
|
|
46
|
+
const q = useG8Query((signal) => g8Get(`/accounts/${companyId}/intent-signals`, { limit: 20 }, { signal }), [companyId]);
|
|
47
|
+
if (q.error) return <ApiError error={q.error} title="Could not load signals" onRetry={() => void q.refetch()} />;
|
|
48
|
+
if (q.loading && !q.data) return <Skeleton className="h-24 w-full" aria-label="Loading signals" />;
|
|
49
|
+
const payload = body(q.data);
|
|
50
|
+
const raw = Array.isArray(payload) ? payload : Array.isArray(asRec(payload).signals) ? (asRec(payload).signals as unknown[]) : [];
|
|
51
|
+
const signals = raw.map(asRec);
|
|
52
|
+
if (signals.length === 0) return <EmptyState icon={<Radar className="size-8" />} title="Quiet" description="No recent buying or activity signals for this account." />;
|
|
53
|
+
return (
|
|
54
|
+
<ul className="divide-y rounded-md border" aria-label="Signals">
|
|
55
|
+
{signals.map((s, i) => {
|
|
56
|
+
const when = text(s.detected_at ?? s.created_at ?? s.occurred_at);
|
|
57
|
+
return (
|
|
58
|
+
<li key={text(s.id, String(i))} className="flex items-center justify-between gap-3 p-3 text-sm">
|
|
59
|
+
<span className="min-w-0 truncate">{text(s.title ?? s.signal ?? s.signal_type ?? s.type ?? s.name, "Signal")}</span>
|
|
60
|
+
{when ? <time className="shrink-0 text-xs text-muted-foreground" dateTime={when} title={new Date(when).toLocaleString()}>{new Date(when).toLocaleDateString()}</time> : null}
|
|
61
|
+
</li>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</ul>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LifecycleScreen: the finished revenue-lifecycle product. One screen for Suspects, Leads, Trial, Customers and
|
|
5
|
+
* Churned (and any custom stage): the board, each stage's working list, and a record panel. No props are required.
|
|
6
|
+
*
|
|
7
|
+
* It composes: LifecycleBoard (columns with counts and Move), LeadQueue (contacts of a stage: search, billable
|
|
8
|
+
* unlock, move), CustomerTable (MRR, headline counts, AI account brief), ChurnList (last MRR, activity check,
|
|
9
|
+
* win-back), StageMoveDialog, and the crm block's CompanyDetail and ContactDetail for the open record.
|
|
10
|
+
*
|
|
11
|
+
* Flows:
|
|
12
|
+
* - Headline numbers: how many companies sit in every stage.
|
|
13
|
+
* - Board tab: move a company between stages (a dry run shows refusals first, for example Customers without an
|
|
14
|
+
* active subscription). One tab per stage after it: Customers shows the book of business with an account brief
|
|
15
|
+
* (rewriting it uses credits, confirmed); Churned shows lost revenue and a win-back to Leads; every other stage
|
|
16
|
+
* is a contact queue where locked emails can be unlocked (credits, confirmed).
|
|
17
|
+
* - Open a company or contact (deep link ?rec=company:<id> or ?rec=contact:<id>, full page with &view=full) -> the
|
|
18
|
+
* full CRM record (activity, notes, tasks, edit) with a Move stage action; a company also gets Brief (rewrite
|
|
19
|
+
* uses credits, confirmed) and Signals tabs. Leads are the contacts of a stage, customers and churned are companies.
|
|
20
|
+
*
|
|
21
|
+
* SDK operations (through the composed parts): listLifecycleStages, getLifecycleStageCounts,
|
|
22
|
+
* listLifecycleStageRecords, moveCompanyLifecycleStage (dry run first), moveContactLifecycleStage,
|
|
23
|
+
* listLifecycleContacts, unlockLifecycleContacts (billable), getRevenueSummary, getBrief, synthesizeBrief
|
|
24
|
+
* (billable), getAccountIntentSignals, plus the crm block's getCompany / getContact.
|
|
25
|
+
*
|
|
26
|
+
* Slots (all optional): renderDetailActions, extraDetailTabs, toolbarActions.
|
|
27
|
+
*/
|
|
28
|
+
import { useState, type ReactNode } from "react";
|
|
29
|
+
import { GitBranch } from "lucide-react";
|
|
30
|
+
import { Button } from "@graph8/sdk/ui";
|
|
31
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
32
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
33
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
34
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
35
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
36
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
37
|
+
import { CompanyDetail } from "@/g8/crm/components/company-detail";
|
|
38
|
+
import { ContactDetail } from "@/g8/crm/components/contact-detail";
|
|
39
|
+
import type { CompanyRecord as CrmCompany, ContactRecord as CrmContact } from "@/g8/crm/lib/crm-types";
|
|
40
|
+
import { useLifecycleStages } from "../hooks/use-lifecycle-stages";
|
|
41
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
42
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
43
|
+
import type { LifecycleStage } from "../lib/lifecycle";
|
|
44
|
+
import { AccountBriefTab, AccountSignalsTab } from "./account-related";
|
|
45
|
+
import { ChurnList } from "./churn-list";
|
|
46
|
+
import { CustomerTable } from "./customer-table";
|
|
47
|
+
import { LeadQueue } from "./lead-queue";
|
|
48
|
+
import { LifecycleBoard } from "./lifecycle-board";
|
|
49
|
+
import { StageMoveDialog } from "./stage-move-dialog";
|
|
50
|
+
|
|
51
|
+
export interface LifecycleScreenProps {
|
|
52
|
+
/** Buttons next to "Move stage" in the record panel. */
|
|
53
|
+
renderDetailActions?: (record: { kind: "company" | "contact"; id: number }) => ReactNode;
|
|
54
|
+
/** Extra tabs in the record panel (for example Deals or Quotes for a company). */
|
|
55
|
+
extraDetailTabs?: Array<{ id: string; label: string; render: (record: { kind: "company" | "contact"; id: number }) => ReactNode }>;
|
|
56
|
+
toolbarActions?: ReactNode;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type OpenRecord = { kind: "company" | "contact"; id: number };
|
|
60
|
+
|
|
61
|
+
function parseRecord(raw: string | null | undefined): OpenRecord | null {
|
|
62
|
+
const [kind, id] = (raw ?? "").split(":");
|
|
63
|
+
const n = Number(id);
|
|
64
|
+
return (kind === "company" || kind === "contact") && Number.isInteger(n) && n > 0 ? { kind, id: n } : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The brief tab, named after the company so the billable confirm says which customer it writes for. */
|
|
68
|
+
function CompanyBrief({ companyId }: { companyId: number }) {
|
|
69
|
+
const company = useG8Query((signal) => g8Get<CrmCompany>(`/companies/${companyId}`, undefined, { signal }), [companyId]);
|
|
70
|
+
const name = company.data?.data?.name ?? null;
|
|
71
|
+
return <AccountBriefTab companyId={companyId} name={name} />;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function StageTab({ stage, version, onOpenCompany, onOpenContact }: { stage: LifecycleStage; version: number; onOpenCompany: (id: number) => void; onOpenContact: (id: number) => void }) {
|
|
75
|
+
if (stage.slug === "customers") return <CustomerTable key={version} stageSlug={stage.slug} onOpenCompany={onOpenCompany} />;
|
|
76
|
+
if (stage.slug === "churned") return <ChurnList key={version} stageSlug={stage.slug} onOpenCompany={onOpenCompany} />;
|
|
77
|
+
return <LeadQueue key={`${stage.slug}-${version}`} stageSlug={stage.slug} onOpenContact={onOpenContact} />;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function LifecycleScreen({ renderDetailActions, extraDetailTabs, toolbarActions }: LifecycleScreenProps) {
|
|
81
|
+
const { stages, countOf, loading, error, refetch } = useLifecycleStages();
|
|
82
|
+
const [tabParam, setTab] = useUrlState("tab", "board");
|
|
83
|
+
const [raw, setRaw] = useUrlState("rec");
|
|
84
|
+
const [mode, setMode] = useUrlState("view");
|
|
85
|
+
const [version, setVersion] = useState(0);
|
|
86
|
+
const [moving, setMoving] = useState<{ record: OpenRecord; name: string } | null>(null);
|
|
87
|
+
const record = parseRecord(raw);
|
|
88
|
+
const tab = tabParam === "board" || stages.some((s) => s.slug === tabParam) ? (tabParam as string) : "board";
|
|
89
|
+
const activeStage = stages.find((s) => s.slug === tab);
|
|
90
|
+
const close = () => { setRaw(null); setMode(null); };
|
|
91
|
+
const openCompany = (id: number) => setRaw(`company:${id}`);
|
|
92
|
+
const openContact = (id: number) => setRaw(`contact:${id}`);
|
|
93
|
+
const refresh = () => { setVersion((v) => v + 1); void refetch(); };
|
|
94
|
+
|
|
95
|
+
const moveButton = (name: string | null | undefined) => record ? (
|
|
96
|
+
<>
|
|
97
|
+
<Button variant="outline" size="sm" onClick={() => setMoving({ record, name: name || `${record.kind} ${record.id}` })}>Move stage</Button>
|
|
98
|
+
{renderDetailActions?.(record)}
|
|
99
|
+
</>
|
|
100
|
+
) : null;
|
|
101
|
+
|
|
102
|
+
const detail = record ? (
|
|
103
|
+
<DetailPanel
|
|
104
|
+
title={record.kind === "company" ? "Company" : "Contact"}
|
|
105
|
+
onClose={close}
|
|
106
|
+
onExpand={mode === "full" ? undefined : () => setMode("full")}
|
|
107
|
+
onCollapse={mode === "full" ? () => setMode(null) : undefined}
|
|
108
|
+
tabs={[
|
|
109
|
+
{
|
|
110
|
+
id: "record",
|
|
111
|
+
label: "Record",
|
|
112
|
+
content: record.kind === "company"
|
|
113
|
+
? <CompanyDetail key={record.id} companyId={record.id} onOpenContact={(c: CrmContact) => openContact(c.id)} renderActions={(c: CrmCompany) => moveButton(c.name)} extraTabs={(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, render: () => t.render(record) }))} />
|
|
114
|
+
: <ContactDetail key={record.id} contactId={record.id} onOpenCompany={openCompany} renderActions={(c: CrmContact) => moveButton(c.full_name || [c.first_name, c.last_name].filter(Boolean).join(" "))} extraTabs={(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, render: () => t.render(record) }))} />,
|
|
115
|
+
},
|
|
116
|
+
...(record.kind === "company" ? [
|
|
117
|
+
{ id: "brief", label: "Brief", content: <CompanyBrief key={record.id} companyId={record.id} /> },
|
|
118
|
+
{ id: "signals", label: "Signals", content: <AccountSignalsTab key={record.id} companyId={record.id} /> },
|
|
119
|
+
] : []),
|
|
120
|
+
]}
|
|
121
|
+
/>
|
|
122
|
+
) : null;
|
|
123
|
+
|
|
124
|
+
let body: ReactNode;
|
|
125
|
+
if (error) body = <ApiError error={error} title="Could not load lifecycle stages" onRetry={() => void refetch()} />;
|
|
126
|
+
else if (!loading && stages.length === 0) body = <EmptyState title="No lifecycle stages" description="Stages are configured in graph8 under Revenue settings." />;
|
|
127
|
+
else if (activeStage) body = <StageTab stage={activeStage} version={version} onOpenCompany={openCompany} onOpenContact={openContact} />;
|
|
128
|
+
else body = <LifecycleBoard key={version} onOpenCompany={openCompany} />;
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<ScreenLayout
|
|
132
|
+
title="Lifecycle"
|
|
133
|
+
description="Follow every company from suspect to customer, and win back the ones that left."
|
|
134
|
+
icon={<GitBranch className="size-5" />}
|
|
135
|
+
actions={toolbarActions}
|
|
136
|
+
tabs={[{ id: "board", label: "Board" }, ...stages.map((s) => ({ id: s.slug, label: s.name }))]}
|
|
137
|
+
activeTab={tab}
|
|
138
|
+
onTabChange={(id) => { setTab(id); close(); }}
|
|
139
|
+
stats={stages.map((s) => ({ label: s.name, value: countOf(s) == null ? "-" : (countOf(s) as number).toLocaleString() }))}
|
|
140
|
+
>
|
|
141
|
+
<SplitView detailOpen={Boolean(record)} onCloseDetail={close} expanded={mode === "full" && Boolean(record)} onExpandedChange={(full) => setMode(full ? "full" : null)} detailWidth="xl" detail={detail} list={body} />
|
|
142
|
+
{moving ? (
|
|
143
|
+
<StageMoveDialog kind={moving.record.kind} recordId={moving.record.id} recordName={moving.name} stages={stages} onClose={() => setMoving(null)} onMoved={() => { setMoving(null); refresh(); }} />
|
|
144
|
+
) : null}
|
|
145
|
+
</ScreenLayout>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -4,6 +4,18 @@ Write, test, schedule and send newsletter issues, manage subscribers, and read o
|
|
|
4
4
|
**Sending reaches real inboxes and cannot be recalled**, so every call that can mail a person is tier `external`
|
|
5
5
|
and goes through `ConfirmSpendDialog` with the recipient count.
|
|
6
6
|
|
|
7
|
+
## Screens
|
|
8
|
+
|
|
9
|
+
`NewsletterScreen` (`components/newsletter-screen.tsx`) is the finished page: mount it and a person can run a newsletter end to end, no extra code.
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { NewsletterScreen } from "@/g8/newsletter/components/newsletter-screen";
|
|
13
|
+
export default function Page() { return <NewsletterScreen />; }
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Flows: pick or create a newsletter (deep link `?newsletter=`), activate or pause it, read subscribers and open and click rates, search and filter issues, write an issue (deep link `?id=`), generate it with AI, send a test, send now or schedule, follow the send and pause, resume or cancel it, read per-issue results, manage subscribers (add, import a list, verify, unsubscribe, erase), compare sent issues. Every send, import, verify, unsubscribe and erase is confirmed.
|
|
17
|
+
Prerequisites are the ones below (verified sending domain, consent, `campaigns:launch`). Optional props: `renderRowActions`, `renderDetailActions`, `extraDetailTabs`, `toolbarActions`.
|
|
18
|
+
|
|
7
19
|
## Install
|
|
8
20
|
|
|
9
21
|
```
|
|
@@ -82,6 +94,7 @@ export default function Page() {
|
|
|
82
94
|
|
|
83
95
|
| Component | File | What it is |
|
|
84
96
|
|---|---|---|
|
|
97
|
+
| `NewsletterScreen` | `components/newsletter-screen.tsx` | The finished page: issues with write, send and results, subscribers and performance. |
|
|
85
98
|
| `NewsletterEditor` | `components/newsletter-editor.tsx` | Pick or create a newsletter, list its issues, write issues and activate or pause the newsletter. |
|
|
86
99
|
| `IssueForm` | `components/issue-form.tsx` | Draft one issue: subject, preview text, HTML, AI generation, test send and delete of drafts. |
|
|
87
100
|
| `IssueScheduler` | `components/issue-scheduler.tsx` | Send an issue now or schedule it, confirmed with the subscriber count, then follow, pause, resume or cancel the send. |
|
|
@@ -43,6 +43,13 @@
|
|
|
43
43
|
"get_issue_analytics_kpis_newsletters__newsletter_id__issues__issue_id__analytics_kpis_get"
|
|
44
44
|
],
|
|
45
45
|
"components": [
|
|
46
|
+
{
|
|
47
|
+
"name": "NewsletterScreen",
|
|
48
|
+
"file": "components/newsletter-screen.tsx",
|
|
49
|
+
"purpose": "Finished newsletter workspace: pick or create a newsletter, issues list with detail (write, send, results), subscribers and performance tabs.",
|
|
50
|
+
"operations": [],
|
|
51
|
+
"tiers": []
|
|
52
|
+
},
|
|
46
53
|
{
|
|
47
54
|
"name": "NewsletterEditor",
|
|
48
55
|
"file": "components/newsletter-editor.tsx",
|
|
@@ -219,6 +226,7 @@
|
|
|
219
226
|
}
|
|
220
227
|
],
|
|
221
228
|
"peerDependencies": {
|
|
222
|
-
"react": ">=18"
|
|
229
|
+
"react": ">=18",
|
|
230
|
+
"lucide-react": ">=0.400.0"
|
|
223
231
|
}
|
|
224
232
|
}
|