@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,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useDefaultOwner: who owns a deal created in the UI when nobody picked an owner.
|
|
5
|
+
*
|
|
6
|
+
* graph8 requires an owner on every deal. The rule (documented in the README defaults, so no agent has to ask):
|
|
7
|
+
* 1. the API key's own user: GET /team-members/me (roster record of the person who minted a PERSONAL key);
|
|
8
|
+
* 2. otherwise the first active team member of GET /team-members (an organisation key has no person, and
|
|
9
|
+
* /team-members/me answers 403 for it), which the screen shows in an owner picker the person can change.
|
|
10
|
+
*
|
|
11
|
+
* SDK operations, in order:
|
|
12
|
+
* 1. GET /team-members/me (teamMembers.getMyTeamMember) read - may be refused for an organisation key
|
|
13
|
+
* 2. GET /team-members (teamMembers.listTeamMembers) read - the picker options
|
|
14
|
+
*
|
|
15
|
+
* `owner_id` accepts a user id or an email; this hook prefers the identity-provider user id and falls back to the email.
|
|
16
|
+
*/
|
|
17
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
18
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
19
|
+
import { dataOf } from "../lib/deals-types";
|
|
20
|
+
|
|
21
|
+
interface TeamMember {
|
|
22
|
+
id?: string;
|
|
23
|
+
name?: string | null;
|
|
24
|
+
email?: string | null;
|
|
25
|
+
propelauth_user_id?: string | null;
|
|
26
|
+
status?: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OwnerOption {
|
|
30
|
+
/** The value to send as `owner_id`. */
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
email?: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type OwnerSource = "key" | "first-member" | "none";
|
|
37
|
+
|
|
38
|
+
function toOption(m: TeamMember | null | undefined): OwnerOption | null {
|
|
39
|
+
const id = m?.propelauth_user_id || m?.email || null;
|
|
40
|
+
return m && id ? { id, name: m.name || m.email || id, email: m.email } : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function membersOf(env: unknown): TeamMember[] {
|
|
44
|
+
const inner = dataOf<unknown>(env);
|
|
45
|
+
if (Array.isArray(inner)) return inner as TeamMember[];
|
|
46
|
+
const o = inner as { items?: unknown; team_members?: unknown } | null;
|
|
47
|
+
const list = o?.items ?? o?.team_members;
|
|
48
|
+
return Array.isArray(list) ? (list as TeamMember[]) : [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function useDefaultOwner() {
|
|
52
|
+
const me = useG8Query(async (signal) => toOption(dataOf<TeamMember>(await g8Get("/team-members/me", undefined, { signal }))), []);
|
|
53
|
+
const team = useG8Query(async (signal) => membersOf(await g8Get("/team-members", { limit: 200 }, { signal })).filter((m) => !m.status || m.status === "active").map(toOption).filter((o): o is OwnerOption => o !== null), []);
|
|
54
|
+
const mine = me.data ?? null; // a refused /team-members/me (organisation key) is expected, not an error to show
|
|
55
|
+
const owners = team.data ?? [];
|
|
56
|
+
const all = mine && !owners.some((o) => o.id === mine.id) ? [mine, ...owners] : owners;
|
|
57
|
+
const defaultOwner = mine ?? all[0] ?? null;
|
|
58
|
+
const source: OwnerSource = mine ? "key" : defaultOwner ? "first-member" : "none";
|
|
59
|
+
return { owners: all, defaultOwner, source, loading: me.loading || (team.loading && !defaultOwner), error: all.length === 0 ? team.error : undefined, reload: team.refetch };
|
|
60
|
+
}
|
|
@@ -80,6 +80,20 @@ export function sumAmounts(deals: ReadonlyArray<Pick<Deal, "amount">>): number {
|
|
|
80
80
|
return deals.reduce((total, d) => total + (d.amount ?? 0), 0);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Money for a group of deals that may span currencies: one formatted total per currency, joined with " + "
|
|
85
|
+
* ("$1,200 + €300"). A single currency formats exactly like formatMoney. `valueOf` defaults to the amount sum.
|
|
86
|
+
*/
|
|
87
|
+
export function sumMoney(deals: ReadonlyArray<Deal>, valueOf: (group: ReadonlyArray<Deal>) => number = sumAmounts, fallbackCurrency?: string | null): string {
|
|
88
|
+
const groups = new Map<string, Deal[]>();
|
|
89
|
+
for (const d of deals) {
|
|
90
|
+
const key = d.currency || "USD";
|
|
91
|
+
groups.set(key, [...(groups.get(key) ?? []), d]);
|
|
92
|
+
}
|
|
93
|
+
if (groups.size <= 1) return formatMoney(valueOf(deals), deals[0]?.currency ?? fallbackCurrency);
|
|
94
|
+
return [...groups].map(([currency, group]) => formatMoney(valueOf(group), currency)).join(" + ");
|
|
95
|
+
}
|
|
96
|
+
|
|
83
97
|
/**
|
|
84
98
|
* Probability-weighted value of open deals. This is an ESTIMATE computed here: the analytics endpoint refuses to
|
|
85
99
|
* return a weighted total because a stage's probability defaults to 0 when nobody configured it, which would
|
package/blocks/forms/README.md
CHANGED
|
@@ -5,6 +5,17 @@ snippet, lists them, and routes every submission to a list with a field mapping.
|
|
|
5
5
|
that: install the code (`FormEmbed`), set where leads go and how fields map (`FormBuilder`), see what arrived and
|
|
6
6
|
retry failures (`SubmissionsTable`), and find forms that are not captured (`SiteAuditPanel`).
|
|
7
7
|
|
|
8
|
+
## Screens
|
|
9
|
+
|
|
10
|
+
`FormsScreen` (`components/forms-screen.tsx`) is the finished page: install code, captured forms, routing and submissions in one place.
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { FormsScreen } from "@/g8/forms/components/forms-screen";
|
|
14
|
+
export default function Page() { return <FormsScreen />; }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Flows: copy the snippet and ready-made lead form (Install tab, `?tab=install`), see every captured form with health and counts (search is debounced; the first 100 are listed), open one (deep link `?id=<source_id>/<form_key>`; a submission opens in a sheet at `&submission=<id>` with every field, its delivery and trace) to rename it, pause or resume delivery (confirmed), choose the destination list and map fields (suggest, preview, save, test), read submissions, trace a lead and retry a failed delivery (confirmed), and find forms that are not captured (Site audit tab, `?tab=audit`). A `&submission=` link opened cold (reload, shared link) is looked up in the form's latest 100 submissions; an older one shows its trace and points to the Submissions tab. Optional props: `renderRowActions`, `renderDetailActions`, `extraDetailTabs`, `toolbarActions`.
|
|
18
|
+
|
|
8
19
|
## Install
|
|
9
20
|
|
|
10
21
|
```
|
|
@@ -78,6 +89,7 @@ export default function FormsPage() {
|
|
|
78
89
|
|
|
79
90
|
| Component | File | What it is |
|
|
80
91
|
|---|---|---|
|
|
92
|
+
| `FormsScreen` | `components/forms-screen.tsx` | The finished page: install code, captured forms, routing, submissions and site audit. |
|
|
81
93
|
| `FormBuilder` | `components/form-builder.tsx` | Forms workspace: list captured forms, rename, pause/resume delivery, set destination and mapping, view submissions. |
|
|
82
94
|
| `FormMappingEditor` | `components/form-mapping-editor.tsx` | Pick the destination list and map submitted fields to contact fields, with suggest, preview, save and test. |
|
|
83
95
|
| `FormEmbed` | `components/form-embed.tsx` | Copyable tracking snippet (script tag, React) and a ready-made lead-capture form with live preview. |
|
package/blocks/forms/block.json
CHANGED
|
@@ -37,6 +37,13 @@
|
|
|
37
37
|
"update_site_audit_finding_forms_site_audit_findings__finding_id__patch"
|
|
38
38
|
],
|
|
39
39
|
"components": [
|
|
40
|
+
{
|
|
41
|
+
"name": "FormsScreen",
|
|
42
|
+
"file": "components/forms-screen.tsx",
|
|
43
|
+
"purpose": "Finished forms workspace: captured forms with health, detail (overview, destination and fields, submissions), install code and site audit tabs.",
|
|
44
|
+
"operations": [],
|
|
45
|
+
"tiers": []
|
|
46
|
+
},
|
|
40
47
|
{
|
|
41
48
|
"name": "FormBuilder",
|
|
42
49
|
"file": "components/form-builder.tsx",
|
|
@@ -195,6 +202,7 @@
|
|
|
195
202
|
}
|
|
196
203
|
],
|
|
197
204
|
"peerDependencies": {
|
|
198
|
-
"react": ">=18"
|
|
205
|
+
"react": ">=18",
|
|
206
|
+
"lucide-react": ">=0.400.0"
|
|
199
207
|
}
|
|
200
208
|
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FormsScreen: the finished forms workspace. No props needed.
|
|
5
|
+
*
|
|
6
|
+
* graph8 captures the lead forms already on your site (it does not host a form designer), so this screen is
|
|
7
|
+
* about getting the code on the site, routing every captured form to a list, and watching what arrives.
|
|
8
|
+
*
|
|
9
|
+
* Composes: ScreenLayout (title, tabs, stats), Toolbar (debounced search, health filter), SplitView +
|
|
10
|
+
* DetailPanel (selected form, deep link `?id=<source_id>/<form_key>`, one submission `&submission=<id>`), FormEmbed
|
|
11
|
+
* (Install tab, `?tab=install`), SiteAuditPanel (Site audit tab, `?tab=audit`), FormMappingEditor (Setup tab),
|
|
12
|
+
* SubmissionsTable (Submissions tab).
|
|
13
|
+
*
|
|
14
|
+
* User flows: install the snippet and copy the ready-made lead form, see every captured form with its health and
|
|
15
|
+
* counts, search and filter, open one to read capture and delivery numbers, rename it, pause or resume delivery
|
|
16
|
+
* (confirmed), pick the destination list and map fields (suggest, preview, save, test), read submissions, trace
|
|
17
|
+
* where a lead went and retry a failed delivery (confirmed), and find forms graph8 is not capturing.
|
|
18
|
+
*
|
|
19
|
+
* SDK operations: listForms, getFormOverview, updateFormRegistry, pauseFormDelivery / resumeFormDelivery
|
|
20
|
+
* (external, confirmed), searchFormSubmissions (read, to resolve a `&submission=` deep link that was not clicked
|
|
21
|
+
* in this session); the rest live in the composed parts.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { useEffect, useState } from "react";
|
|
25
|
+
import type { ReactNode } from "react";
|
|
26
|
+
import { ClipboardList, Pause, Play } from "lucide-react";
|
|
27
|
+
import { Button, Input, Skeleton } from "@graph8/sdk/ui";
|
|
28
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
29
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
30
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
31
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
32
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
33
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
34
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
35
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
36
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
37
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
38
|
+
import type { Tone } from "@/g8/components/screen/status-pill";
|
|
39
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
40
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
41
|
+
import { g8Get, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
42
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
43
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
44
|
+
import { STATUS_LABEL, cellText, formLabel, formStatus, pageUrlText, pickItems, submissionFailed, submissionState } from "../lib/form-types";
|
|
45
|
+
import type { FormListItem, FormOverview, FormRef, FormSubmission } from "../lib/form-types";
|
|
46
|
+
import { FormEmbed } from "./form-embed";
|
|
47
|
+
import { FormMappingEditor } from "./form-mapping-editor";
|
|
48
|
+
import { SiteAuditPanel } from "./site-audit-panel";
|
|
49
|
+
import { SubmissionsTable } from "./submissions-table";
|
|
50
|
+
|
|
51
|
+
export interface FormsScreenProps {
|
|
52
|
+
renderRowActions?: (form: FormListItem) => ReactNode;
|
|
53
|
+
renderDetailActions?: (form: FormListItem) => ReactNode;
|
|
54
|
+
extraDetailTabs?: { id: string; label: string; render: (form: FormListItem) => ReactNode }[];
|
|
55
|
+
toolbarActions?: ReactNode;
|
|
56
|
+
className?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type Tab = "forms" | "install" | "audit";
|
|
60
|
+
const LIMIT = 100;
|
|
61
|
+
/** How many of a form's latest submissions a deep link is looked up in. */
|
|
62
|
+
const SUBMISSION_LOOKUP = 100;
|
|
63
|
+
const SELECT = "border-input bg-background h-9 rounded-md border px-2 text-sm";
|
|
64
|
+
|
|
65
|
+
const TONE: Record<ReturnType<typeof formStatus>, Tone> = { failing: "negative", paused: "warning", live: "positive", needs_setup: "info", no_data: "neutral" };
|
|
66
|
+
|
|
67
|
+
const refKey = (f: FormRef): string => `${encodeURIComponent(f.source_id)}/${encodeURIComponent(f.form_key)}`;
|
|
68
|
+
|
|
69
|
+
function parseRef(value: string): FormRef | undefined {
|
|
70
|
+
const [source, key] = value.split("/");
|
|
71
|
+
return source && key ? { source_id: decodeURIComponent(source), form_key: decodeURIComponent(key) } : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function FormOverviewTab({ form, onChanged }: { form: FormListItem; onChanged: () => void }) {
|
|
75
|
+
const base = `/forms/${encodeURIComponent(form.form_key)}`;
|
|
76
|
+
const overview = useG8Query((signal) => g8Get<FormOverview>(`${base}/overview`, { source_id: form.source_id }, { signal }).then((r) => r.data), [base, form.source_id]);
|
|
77
|
+
const [name, setName] = useState(form.display_name ?? "");
|
|
78
|
+
const [confirm, setConfirm] = useState<"pause" | "resume" | null>(null);
|
|
79
|
+
const rename = useG8Mutation(() => g8Patch(base, { source_id: form.source_id, display_name: name.trim() }), { onSuccess: onChanged });
|
|
80
|
+
const toggle = useG8Mutation((action: "pause" | "resume") => g8Post(`${base}/delivery/${action}`, { source_id: form.source_id }), {
|
|
81
|
+
onSuccess: () => { setConfirm(null); void overview.refetch(); onChanged(); },
|
|
82
|
+
});
|
|
83
|
+
if (overview.error) return <ApiError error={overview.error} title="Could not load the form overview" onRetry={() => void overview.refetch()} />;
|
|
84
|
+
if (overview.loading && !overview.data) return <Skeleton className="h-32 w-full" aria-busy="true" />;
|
|
85
|
+
const o = overview.data;
|
|
86
|
+
const paused = o?.delivery_health === "paused";
|
|
87
|
+
const m = o?.metrics;
|
|
88
|
+
return (
|
|
89
|
+
<div className="grid gap-4">
|
|
90
|
+
<form className="flex items-end gap-2" onSubmit={(e) => { e.preventDefault(); if (name.trim()) void rename.mutate(undefined); }}>
|
|
91
|
+
<div className="max-w-sm flex-1">
|
|
92
|
+
<label htmlFor="g8-forms-name" className="text-muted-foreground text-xs">Form name</label>
|
|
93
|
+
<Input id="g8-forms-name" value={name} placeholder={form.form_key} onChange={(e) => setName(e.target.value)} />
|
|
94
|
+
</div>
|
|
95
|
+
<Button type="submit" size="sm" variant="outline" loading={rename.loading} disabled={!name.trim() || name.trim() === form.display_name}>Rename</Button>
|
|
96
|
+
<Button type="button" size="sm" variant="outline" disabled={!o} onClick={() => setConfirm(paused ? "resume" : "pause")}>
|
|
97
|
+
{paused ? <Play className="size-4" /> : <Pause className="size-4" />}{paused ? "Resume delivery" : "Pause delivery"}
|
|
98
|
+
</Button>
|
|
99
|
+
</form>
|
|
100
|
+
{rename.error ? <ApiError error={rename.error} title="Could not rename the form" /> : null}
|
|
101
|
+
{o?.delivery_warning ? <p role="alert" className="flex items-center gap-2 text-sm"><StatusPill tone="warning">Check delivery</StatusPill>{o.delivery_warning}</p> : null}
|
|
102
|
+
<KeyValueList
|
|
103
|
+
columns={2}
|
|
104
|
+
items={[
|
|
105
|
+
{ label: "Captured", value: (m?.captured ?? 0).toLocaleString() },
|
|
106
|
+
{ label: "Processed", value: (m?.processed ?? 0).toLocaleString() },
|
|
107
|
+
{ label: "In destination", value: (m?.destination_verified ?? 0).toLocaleString() },
|
|
108
|
+
{ label: "Failed", value: (m?.failed ?? 0).toLocaleString() },
|
|
109
|
+
{ label: "Destination list", value: o?.destination?.list_name ?? "None yet" },
|
|
110
|
+
{ label: "Capture health", value: o?.capture_health ?? "-" },
|
|
111
|
+
{ label: "Page", value: pageUrlText(form.page_url) || "-" },
|
|
112
|
+
{ label: "Next step", value: o?.recommended_action ?? "-" },
|
|
113
|
+
]}
|
|
114
|
+
/>
|
|
115
|
+
<ConfirmSpendDialog
|
|
116
|
+
open={confirm !== null}
|
|
117
|
+
tier="external"
|
|
118
|
+
title={confirm === "pause" ? "Pause delivery for this form?" : "Resume delivery for this form?"}
|
|
119
|
+
confirmLabel={confirm === "pause" ? "Pause" : "Resume"}
|
|
120
|
+
description={<><p>{confirm === "pause" ? "New submissions are still captured but queue instead of reaching your list." : "Queued and new submissions are delivered to the destination list, creating or updating contacts."}</p>{toggle.error ? <ApiError error={toggle.error} title="Could not change delivery" /> : null}</>}
|
|
121
|
+
confirming={toggle.loading}
|
|
122
|
+
onCancel={() => setConfirm(null)}
|
|
123
|
+
onConfirm={() => { if (confirm) void toggle.mutate(confirm); }}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A linked submission that is not among the form's latest ones: say so, and still show its real trace. */
|
|
130
|
+
function SubmissionNotFound({ form, submissionId }: { form: FormRef; submissionId: string }) {
|
|
131
|
+
const trace = useG8Query(
|
|
132
|
+
(signal) => g8Get<{ capture_state?: string; receipts?: unknown[] }>(`/forms/${encodeURIComponent(form.form_key)}/submissions/${encodeURIComponent(submissionId)}/trace`, { source_id: form.source_id }, { signal }).then((r) => r.data),
|
|
133
|
+
[form.form_key, form.source_id, submissionId],
|
|
134
|
+
);
|
|
135
|
+
const receipts = pickItems<Record<string, unknown>>(trace.data, "receipts");
|
|
136
|
+
return (
|
|
137
|
+
<div className="grid gap-4">
|
|
138
|
+
<EmptyState
|
|
139
|
+
title="This submission is not in the latest 100"
|
|
140
|
+
description={`Submission ${submissionId} is older than the form's latest ${SUBMISSION_LOOKUP} submissions, so its fields and delivery are not shown here. Open it from the Submissions tab to see them and to retry a failed delivery.`}
|
|
141
|
+
/>
|
|
142
|
+
<SectionCard title="Where did this lead go?" description={trace.data?.capture_state ? `Capture: ${trace.data.capture_state}` : undefined}>
|
|
143
|
+
{trace.error ? <ApiError error={trace.error} title="No trace for this submission" /> : trace.loading ? <Skeleton className="h-16 w-full" /> : receipts.length === 0 ? <p className="text-muted-foreground text-sm">No delivery steps recorded yet.</p> : (
|
|
144
|
+
receipts.map((r, i) => <pre key={i} className="overflow-x-auto rounded border p-2 text-xs">{JSON.stringify(r, null, 2)}</pre>)
|
|
145
|
+
)}
|
|
146
|
+
</SectionCard>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** One submission in full: every submitted field, where it came from, its delivery, and the trace. */
|
|
152
|
+
function SubmissionDetail({ form, submission, onChanged }: { form: FormRef; submission: FormSubmission; onChanged: () => void }) {
|
|
153
|
+
const [retrying, setRetrying] = useState(false);
|
|
154
|
+
const trace = useG8Query(
|
|
155
|
+
(signal) => g8Get<{ capture_state?: string; receipts?: unknown[] }>(`/forms/${encodeURIComponent(form.form_key)}/submissions/${encodeURIComponent(submission.id)}/trace`, { source_id: form.source_id }, { signal }).then((r) => r.data),
|
|
156
|
+
[form.form_key, form.source_id, submission.id],
|
|
157
|
+
);
|
|
158
|
+
const retry = useG8Mutation(() => g8Post(`/forms/deliveries/${encodeURIComponent(submission.delivery?.receipt_id ?? "")}/retry`), { onSuccess: () => { setRetrying(false); void trace.refetch(); onChanged(); } });
|
|
159
|
+
const fields = Object.entries(submission.form_data ?? {});
|
|
160
|
+
const receipts = pickItems<Record<string, unknown>>(trace.data, "receipts");
|
|
161
|
+
const failed = submissionFailed(submission);
|
|
162
|
+
return (
|
|
163
|
+
<div className="grid gap-4">
|
|
164
|
+
<div className="flex items-center gap-2">
|
|
165
|
+
<StatusPill tone={failed ? "negative" : submissionState(submission) === "delivered" ? "positive" : "neutral"}>{submissionState(submission)}</StatusPill>
|
|
166
|
+
{submission.is_test ? <StatusPill tone="info">Test</StatusPill> : null}
|
|
167
|
+
{failed && submission.delivery?.receipt_id ? <Button size="sm" variant="outline" onClick={() => { retry.reset(); setRetrying(true); }}>Retry delivery</Button> : null}
|
|
168
|
+
</div>
|
|
169
|
+
<SectionCard title="Submitted fields">
|
|
170
|
+
{fields.length === 0 ? <p className="text-muted-foreground text-sm">No fields were captured. Identifying fields may be redacted for your key.</p> : (
|
|
171
|
+
<KeyValueList columns={1} items={fields.map(([k, v]) => ({ label: k, value: cellText(v) || "Empty" }))} />
|
|
172
|
+
)}
|
|
173
|
+
</SectionCard>
|
|
174
|
+
<SectionCard title="Where it came from">
|
|
175
|
+
<KeyValueList
|
|
176
|
+
columns={2}
|
|
177
|
+
items={[
|
|
178
|
+
{ label: "Received", value: submission.received_at ? new Date(submission.received_at).toLocaleString() : "Not set" },
|
|
179
|
+
{ label: "Page", value: submission.page?.url ?? submission.page?.path ?? "Not set" },
|
|
180
|
+
{ label: "Delivery", value: submissionState(submission) },
|
|
181
|
+
{ label: "Reason", value: submission.delivery?.reason ?? "None" },
|
|
182
|
+
{ label: "Receipt", value: submission.delivery?.receipt_id ?? "None" },
|
|
183
|
+
{ label: "Submission id", value: submission.id },
|
|
184
|
+
]}
|
|
185
|
+
/>
|
|
186
|
+
</SectionCard>
|
|
187
|
+
<SectionCard title="Where did this lead go?" description={trace.data?.capture_state ? `Capture: ${trace.data.capture_state}` : undefined}>
|
|
188
|
+
{trace.error ? <ApiError error={trace.error} title="No trace for this submission" /> : trace.loading ? <Skeleton className="h-16 w-full" /> : receipts.length === 0 ? <p className="text-muted-foreground text-sm">No delivery steps recorded yet.</p> : (
|
|
189
|
+
receipts.map((r, i) => <pre key={i} className="overflow-x-auto rounded border p-2 text-xs">{JSON.stringify(r, null, 2)}</pre>)
|
|
190
|
+
)}
|
|
191
|
+
</SectionCard>
|
|
192
|
+
<ConfirmSpendDialog
|
|
193
|
+
open={retrying}
|
|
194
|
+
tier="external"
|
|
195
|
+
title="Retry delivering this lead?"
|
|
196
|
+
description={<><p>The lead is sent to the form's destination again, creating or updating a contact.</p>{retry.error ? <ApiError error={retry.error} title="Could not retry" /> : null}</>}
|
|
197
|
+
confirmLabel="Retry delivery"
|
|
198
|
+
confirming={retry.loading}
|
|
199
|
+
onCancel={() => { retry.reset(); setRetrying(false); }}
|
|
200
|
+
onConfirm={() => void retry.mutate(undefined)}
|
|
201
|
+
/>
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function FormsScreen({ renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions, className }: FormsScreenProps) {
|
|
207
|
+
const [tabParam, setTabParam] = useUrlState("tab", "forms");
|
|
208
|
+
const tab: Tab = tabParam === "install" || tabParam === "audit" ? tabParam : "forms";
|
|
209
|
+
const setTab = (next: Tab): void => setTabParam(next === "forms" ? null : next);
|
|
210
|
+
const [selectedKey, setSelectedKey] = useUrlState("id", "");
|
|
211
|
+
const [view, setView] = useUrlState("view", "");
|
|
212
|
+
const expandProps = { onExpand: () => setView("full"), onCollapse: view === "full" ? () => setView(null) : undefined };
|
|
213
|
+
const [text, setText] = useState("");
|
|
214
|
+
const [search, setSearch] = useState("");
|
|
215
|
+
const [health, setHealth] = useState("");
|
|
216
|
+
const [submissionId, setSubmissionId] = useUrlState("submission", "");
|
|
217
|
+
const [openedSubmission, setOpenedSubmission] = useState<FormSubmission | null>(null);
|
|
218
|
+
const [subsRefresh, setSubsRefresh] = useState(0);
|
|
219
|
+
|
|
220
|
+
// Debounce the search so each keystroke is not a request.
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
const t = setTimeout(() => setSearch(text.trim()), 300);
|
|
223
|
+
return () => clearTimeout(t);
|
|
224
|
+
}, [text]);
|
|
225
|
+
|
|
226
|
+
const closeForm = (): void => { setView(null); setSelectedKey(null); setSubmissionId(null); setOpenedSubmission(null); };
|
|
227
|
+
const forms = useG8Query(
|
|
228
|
+
(signal) => g8Get<{ items?: FormListItem[]; total?: number }>("/forms", { page: 1, per_page: LIMIT, search: search || undefined }, { signal }).then((r) => ({ items: pickItems<FormListItem>(r.data, "items"), total: (r.data as { total?: number } | undefined)?.total ?? 0 })),
|
|
229
|
+
[search],
|
|
230
|
+
);
|
|
231
|
+
const items = forms.data?.items ?? [];
|
|
232
|
+
const total = forms.data?.total ?? items.length;
|
|
233
|
+
const shown = items.filter((f) => !health || formStatus(f) === health);
|
|
234
|
+
const ref = parseRef(selectedKey);
|
|
235
|
+
const selected: FormListItem | undefined = ref ? (items.find((f) => f.form_key === ref.form_key && f.source_id === ref.source_id) ?? { id: `${ref.source_id}:${ref.form_key}`, ...ref }) : undefined;
|
|
236
|
+
const sum = (pick: (f: FormListItem) => number | null | undefined): number => items.reduce((a, f) => a + (pick(f) ?? 0), 0);
|
|
237
|
+
// A `&submission=` link that was not clicked in this session (reload, shared link): find the real record in the
|
|
238
|
+
// form's latest submissions instead of rendering an empty stub as if it were data.
|
|
239
|
+
const needsLookup = Boolean(ref && submissionId) && openedSubmission?.id !== submissionId;
|
|
240
|
+
const lookup = useG8Query(
|
|
241
|
+
(signal) => g8Post<unknown>(`/forms/${encodeURIComponent(ref?.form_key ?? "")}/submissions`, { page: 1, per_page: SUBMISSION_LOOKUP, source_id: ref?.source_id }, { signal }).then((r) => {
|
|
242
|
+
const data = r.data as { submissions?: { items?: FormSubmission[] }; items?: FormSubmission[] } | undefined;
|
|
243
|
+
return (data?.submissions ?? data)?.items ?? [];
|
|
244
|
+
}),
|
|
245
|
+
[ref?.form_key, ref?.source_id, submissionId],
|
|
246
|
+
{ enabled: needsLookup },
|
|
247
|
+
);
|
|
248
|
+
const linkedSubmission: FormSubmission | null = !submissionId ? null : openedSubmission?.id === submissionId ? openedSubmission : ((lookup.data ?? []).find((x) => x.id === submissionId) ?? null);
|
|
249
|
+
|
|
250
|
+
const rows = forms.error ? <ApiError error={forms.error} title="Could not load forms" onRetry={() => void forms.refetch()} /> : forms.loading && !forms.data ? (
|
|
251
|
+
<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>
|
|
252
|
+
) : shown.length === 0 ? (
|
|
253
|
+
<EmptyState
|
|
254
|
+
icon={<ClipboardList className="size-8" />}
|
|
255
|
+
title={search || health ? "No forms match" : "No forms captured yet"}
|
|
256
|
+
description={search || health ? "Try another name or health." : "Install the graph8 snippet on your site. Forms appear here after their first submission."}
|
|
257
|
+
action={!search && !health ? <Button onClick={() => setTab("install")}>Get the install code</Button> : undefined}
|
|
258
|
+
/>
|
|
259
|
+
) : (
|
|
260
|
+
<ul className="grid gap-2">
|
|
261
|
+
{shown.map((f) => {
|
|
262
|
+
const status = formStatus(f);
|
|
263
|
+
const key = refKey(f);
|
|
264
|
+
return (
|
|
265
|
+
<li key={key} className={`flex items-center justify-between gap-3 rounded-md border p-3 ${key === selectedKey ? "bg-muted" : ""}`}>
|
|
266
|
+
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setSelectedKey(key)}>
|
|
267
|
+
<span className="block truncate font-medium">{formLabel(f)}</span>
|
|
268
|
+
<span className="text-muted-foreground block truncate text-xs">{pageUrlText(f.page_url) || f.form_key}</span>
|
|
269
|
+
<span className="text-muted-foreground block text-xs">
|
|
270
|
+
{(f.total_submissions ?? 0).toLocaleString()} {(f.total_submissions ?? 0) === 1 ? "submission" : "submissions"}{f.failed_submissions ? ` · ${f.failed_submissions} failed` : ""}{f.target_list_name ? ` · to ${f.target_list_name}` : ""}
|
|
271
|
+
</span>
|
|
272
|
+
</button>
|
|
273
|
+
<StatusPill tone={TONE[status]}>{STATUS_LABEL[status]}</StatusPill>
|
|
274
|
+
{renderRowActions?.(f)}
|
|
275
|
+
</li>
|
|
276
|
+
);
|
|
277
|
+
})}
|
|
278
|
+
{total > items.length ? <li className="text-muted-foreground text-xs">Showing the first {items.length} of {total.toLocaleString()} forms. Search to narrow down.</li> : null}
|
|
279
|
+
</ul>
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const detail = selected ? (
|
|
283
|
+
<DetailPanel
|
|
284
|
+
title={formLabel(selected)}
|
|
285
|
+
subtitle={pageUrlText(selected.page_url) || selected.form_key}
|
|
286
|
+
badges={<StatusPill tone={TONE[formStatus(selected)]}>{STATUS_LABEL[formStatus(selected)]}</StatusPill>}
|
|
287
|
+
actions={renderDetailActions?.(selected)}
|
|
288
|
+
onClose={() => closeForm()}
|
|
289
|
+
{...expandProps}
|
|
290
|
+
tabs={[
|
|
291
|
+
{ id: "overview", label: "Overview", content: <FormOverviewTab key={refKey(selected)} form={selected} onChanged={() => void forms.refetch()} /> },
|
|
292
|
+
{ id: "setup", label: "Destination and fields", content: <FormMappingEditor key={refKey(selected)} form={selected} onSaved={() => void forms.refetch()} /> },
|
|
293
|
+
{ id: "submissions", label: "Submissions", content: <SubmissionsTable key={`${refKey(selected)}:${subsRefresh}`} form={selected} selectedId={submissionId || undefined} onOpen={(sub) => { setOpenedSubmission(sub); setSubmissionId(sub.id); }} /> },
|
|
294
|
+
...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selected) })),
|
|
295
|
+
]}
|
|
296
|
+
/>
|
|
297
|
+
) : null;
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<ScreenLayout
|
|
301
|
+
className={className}
|
|
302
|
+
title="Forms"
|
|
303
|
+
description="Capture the forms already on your site, route every lead to a list, and watch what arrives."
|
|
304
|
+
icon={<ClipboardList className="size-5" />}
|
|
305
|
+
tabs={[{ id: "forms", label: "Forms", count: total }, { id: "install", label: "Install" }, { id: "audit", label: "Site audit" }]}
|
|
306
|
+
activeTab={tab}
|
|
307
|
+
onTabChange={(id) => { setTab(id as Tab); closeForm(); }}
|
|
308
|
+
stats={tab === "forms" ? [
|
|
309
|
+
{ label: "Forms", value: total.toLocaleString() },
|
|
310
|
+
{ label: "Submissions", value: sum((f) => f.total_submissions).toLocaleString() },
|
|
311
|
+
{ label: "Failed", value: sum((f) => f.failed_submissions).toLocaleString(), tone: sum((f) => f.failed_submissions) > 0 ? "negative" : "neutral" },
|
|
312
|
+
{ label: "Need setup", value: items.filter((f) => formStatus(f) === "needs_setup").length.toLocaleString(), tone: "info" },
|
|
313
|
+
] : undefined}
|
|
314
|
+
toolbar={tab === "forms" ? (
|
|
315
|
+
<Toolbar
|
|
316
|
+
search={{ value: text, onChange: setText, placeholder: "Search forms" }}
|
|
317
|
+
filters={(
|
|
318
|
+
<select aria-label="Form health" className={SELECT} value={health} onChange={(e) => setHealth(e.target.value)}>
|
|
319
|
+
<option value="">All health</option>
|
|
320
|
+
{Object.entries(STATUS_LABEL).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
|
321
|
+
</select>
|
|
322
|
+
)}
|
|
323
|
+
actions={toolbarActions}
|
|
324
|
+
/>
|
|
325
|
+
) : undefined}
|
|
326
|
+
>
|
|
327
|
+
<FormSheet
|
|
328
|
+
open={Boolean(submissionId) && Boolean(selected)}
|
|
329
|
+
onOpenChange={(o) => { if (!o) { setSubmissionId(null); setOpenedSubmission(null); } }}
|
|
330
|
+
title="Submission"
|
|
331
|
+
description={selected ? formLabel(selected) : undefined}
|
|
332
|
+
>
|
|
333
|
+
{selected && submissionId ? (
|
|
334
|
+
linkedSubmission ? (
|
|
335
|
+
<SubmissionDetail key={submissionId} form={selected} submission={linkedSubmission} onChanged={() => setSubsRefresh((n) => n + 1)} />
|
|
336
|
+
) : lookup.error ? (
|
|
337
|
+
<ApiError error={lookup.error} title="Could not load this submission" onRetry={() => void lookup.refetch()} />
|
|
338
|
+
) : lookup.loading || !lookup.data ? (
|
|
339
|
+
<div aria-busy="true" className="grid gap-2"><Skeleton className="h-8 w-40" /><Skeleton className="h-32 w-full" /></div>
|
|
340
|
+
) : (
|
|
341
|
+
<SubmissionNotFound form={selected} submissionId={submissionId} />
|
|
342
|
+
)
|
|
343
|
+
) : null}
|
|
344
|
+
</FormSheet>
|
|
345
|
+
{tab === "install" ? <FormEmbed /> : tab === "audit" ? <SiteAuditPanel /> : (
|
|
346
|
+
<SplitView list={rows} detail={detail} detailOpen={Boolean(selected)} onCloseDetail={() => closeForm()} expanded={view === "full"} onExpandedChange={(e) => setView(e ? "full" : null)} detailWidth="xl" />
|
|
347
|
+
)}
|
|
348
|
+
</ScreenLayout>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
@@ -29,6 +29,10 @@ export interface SubmissionsTableProps {
|
|
|
29
29
|
pageSize?: number;
|
|
30
30
|
/** Max submitted-field columns. Default 4. */
|
|
31
31
|
maxFieldColumns?: number;
|
|
32
|
+
/** When set, the received time of a row is a button that opens the full record (see FormsScreen). */
|
|
33
|
+
onOpen?: (submission: FormSubmission) => void;
|
|
34
|
+
/** The submission whose full record is open; its row is highlighted. */
|
|
35
|
+
selectedId?: string;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
interface Page { items: FormSubmission[]; total: number }
|
|
@@ -59,7 +63,7 @@ function SubmissionTrace({ form, submission, onClose }: { form: FormRef; submiss
|
|
|
59
63
|
);
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
export function SubmissionsTable({ form: given, pageSize = 20, maxFieldColumns = 4 }: SubmissionsTableProps) {
|
|
66
|
+
export function SubmissionsTable({ form: given, pageSize = 20, maxFieldColumns = 4, onOpen, selectedId }: SubmissionsTableProps) {
|
|
63
67
|
const [picked, setPicked] = useState<FormRef | undefined>(undefined);
|
|
64
68
|
const forms = useG8Query((signal) => g8Get<unknown>("/forms", { per_page: 100 }, { signal }).then((r) => pickItems<FormRef & { display_name?: string | null }>(r.data, "items")), [], { enabled: !given });
|
|
65
69
|
const form = given ?? picked;
|
|
@@ -75,10 +79,10 @@ export function SubmissionsTable({ form: given, pageSize = 20, maxFieldColumns =
|
|
|
75
79
|
);
|
|
76
80
|
}
|
|
77
81
|
// Keyed on the form: page, trace and retry state belong to one form.
|
|
78
|
-
return <SubmissionsFor key={`${form.source_id}:${form.form_key}`} form={form} pageSize={pageSize} maxFieldColumns={maxFieldColumns} />;
|
|
82
|
+
return <SubmissionsFor key={`${form.source_id}:${form.form_key}`} form={form} pageSize={pageSize} maxFieldColumns={maxFieldColumns} onOpen={onOpen} selectedId={selectedId} />;
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
function SubmissionsFor({ form, pageSize, maxFieldColumns }: { form: FormRef; pageSize: number; maxFieldColumns: number }) {
|
|
85
|
+
function SubmissionsFor({ form, pageSize, maxFieldColumns, onOpen, selectedId }: { form: FormRef; pageSize: number; maxFieldColumns: number; onOpen?: (s: FormSubmission) => void; selectedId?: string }) {
|
|
82
86
|
const [page, setPage] = useState(1);
|
|
83
87
|
const [traceOf, setTraceOf] = useState<FormSubmission | null>(null);
|
|
84
88
|
const [retrying, setRetrying] = useState<FormSubmission | null>(null);
|
|
@@ -123,8 +127,12 @@ function SubmissionsFor({ form, pageSize, maxFieldColumns }: { form: FormRef; pa
|
|
|
123
127
|
</TableHeader>
|
|
124
128
|
<TableBody>
|
|
125
129
|
{items.map((s) => (
|
|
126
|
-
<TableRow key={s.id}>
|
|
127
|
-
<TableCell>
|
|
130
|
+
<TableRow key={s.id} data-state={selectedId === s.id ? "selected" : undefined}>
|
|
131
|
+
<TableCell>
|
|
132
|
+
{onOpen ? (
|
|
133
|
+
<button type="button" className="text-left hover:underline" aria-label={`Open submission ${s.id}`} onClick={() => onOpen(s)}>{s.received_at ? new Date(s.received_at).toLocaleString() : s.id}{s.is_test ? " (test)" : ""}</button>
|
|
134
|
+
) : <>{s.received_at ? new Date(s.received_at).toLocaleString() : ""}{s.is_test ? " (test)" : ""}</>}
|
|
135
|
+
</TableCell>
|
|
128
136
|
{columns.map((c) => <TableCell key={c}>{cellText(s.form_data?.[c])}</TableCell>)}
|
|
129
137
|
<TableCell className={submissionFailed(s) ? "text-red-700" : undefined}>{submissionState(s)}</TableCell>
|
|
130
138
|
<TableCell className="text-right">
|
|
@@ -13,6 +13,22 @@ These five are ONE parametrised view keyed by stage, not five features: the stag
|
|
|
13
13
|
| `ChurnList` | Churned companies, last MRR, on-demand activity check, win-back to Leads. |
|
|
14
14
|
| `useLifecycleStages` (hook) | Stages and their counts. |
|
|
15
15
|
|
|
16
|
+
## Screens
|
|
17
|
+
|
|
18
|
+
`LifecycleScreen` (`components/lifecycle-screen.tsx`): `<LifecycleScreen />`.
|
|
19
|
+
|
|
20
|
+
- A count per stage as headline numbers, then a Board tab and one tab per stage (custom stages included).
|
|
21
|
+
- Board: columns of companies with Move (a dry run shows refusals first, for example Customers without an active
|
|
22
|
+
subscription). Customers tab: the book of business with MRR, headline counts and the account brief (rewriting it
|
|
23
|
+
uses credits, confirmed). Churned tab: lost revenue, an activity check and a win-back to Leads. Every other stage is
|
|
24
|
+
a contact queue with search; locked emails are unlocked with credits (confirmed) and contacts can be moved.
|
|
25
|
+
- Open a company or contact (deep link `?rec=company:<id>` or `?rec=contact:<id>`) to get the crm block's full record
|
|
26
|
+
(activity, notes, tasks, edit) with a **Move stage** action.
|
|
27
|
+
- Slots, all optional: `renderDetailActions(record)`, `extraDetailTabs`, `toolbarActions`.
|
|
28
|
+
|
|
29
|
+
Proxy `allowedPrefixes` must also include the crm block's prefixes for the record panel.
|
|
30
|
+
|
|
31
|
+
|
|
16
32
|
## Flow, in order (and the ids passed between steps)
|
|
17
33
|
|
|
18
34
|
1. `GET /lifecycle-stages` -> each stage has `id` (entry id) and `slug` (`suspects`, `leads`, `trials`, `customers`, `churned`).
|
|
@@ -98,6 +98,27 @@
|
|
|
98
98
|
"read",
|
|
99
99
|
"write"
|
|
100
100
|
]
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"name": "LifecycleScreen",
|
|
104
|
+
"file": "components/lifecycle-screen.tsx",
|
|
105
|
+
"purpose": "Finished lifecycle product: board, one tab per stage (contact queue, customers, churned) and a CRM record panel with Move stage.",
|
|
106
|
+
"operations": [],
|
|
107
|
+
"tiers": []
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"name": "AccountBriefTab",
|
|
111
|
+
"file": "components/account-related.tsx",
|
|
112
|
+
"purpose": "Account views for the record panel: the customer-success brief (rewrite is billable and confirmed) and AccountSignalsTab, recent intent signals.",
|
|
113
|
+
"operations": [
|
|
114
|
+
"get_brief_customer_success_companies__company_id__brief_get",
|
|
115
|
+
"synthesize_brief_customer_success_companies__company_id__brief_synthesize_post",
|
|
116
|
+
"get_account_intent_signals_accounts__account_id__intent_signals_get"
|
|
117
|
+
],
|
|
118
|
+
"tiers": [
|
|
119
|
+
"read",
|
|
120
|
+
"billable"
|
|
121
|
+
]
|
|
101
122
|
}
|
|
102
123
|
],
|
|
103
124
|
"hooks": [
|
|
@@ -193,6 +214,7 @@
|
|
|
193
214
|
}
|
|
194
215
|
],
|
|
195
216
|
"peerDependencies": {
|
|
196
|
-
"react": ">=18"
|
|
217
|
+
"react": ">=18",
|
|
218
|
+
"lucide-react": ">=0.400.0"
|
|
197
219
|
}
|
|
198
220
|
}
|