@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
|
@@ -16,6 +16,8 @@ Shared base every other block depends on. Install it first (the CLI does it for
|
|
|
16
16
|
| `components/confirm-spend-dialog.tsx` | `ConfirmSpendDialog`: shows tier, estimated credits, recipients, before a billable/external/destructive call. |
|
|
17
17
|
| `components/api-error.tsx` | `ApiError`: shows `detail`, 422 field errors, request id, retry. |
|
|
18
18
|
| `components/empty-state.tsx` | `EmptyState`: empty list with a first step. |
|
|
19
|
+
| `components/screen/*.tsx` | The **screen kit** (below): `ScreenLayout`, `StatStrip`, `SplitView`, `DetailPanel`, `Toolbar`, `StatusPill`, `EntityAvatar`, `SectionCard`, `FormSheet`, `KeyValueList`, `Sparkline`, `BarList`, `TimeSeriesChart`. Barrel: `@/g8/components/screen`. |
|
|
20
|
+
| `hooks/use-url-state.ts` | `useUrlState(key, default = "")` -> `[value, set]`. One string in the URL query as state (replaceState + popstate, SSR-safe). |
|
|
19
21
|
|
|
20
22
|
## Flow of every call
|
|
21
23
|
|
|
@@ -67,6 +69,112 @@ redirects (an upstream 3xx becomes a 502); caps the request body at 5 MiB (413),
|
|
|
67
69
|
an oversized upload is never held whole in memory; returns 500 with a clear message if `G8_API_KEY` is unset
|
|
68
70
|
and a generic 502 if graph8 is unreachable. The key is never logged, echoed or included in any response.
|
|
69
71
|
|
|
72
|
+
## Screen kit
|
|
73
|
+
|
|
74
|
+
Finished screens (`<Name>Screen` in every block) are composed from these parts, so an Apollo clone and a
|
|
75
|
+
HubSpot clone look like one product. Import each part from `@/g8/components/screen/<file>` (or everything
|
|
76
|
+
from `@/g8/components/screen`). The look follows graph8's rules: ink on white, calm density, colour only
|
|
77
|
+
for meaning, no entrance animations, light and dark through the tokens.
|
|
78
|
+
|
|
79
|
+
| Part | File | Use it for |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `ScreenLayout` | `screen-layout` | The frame: sticky header (icon, title, description, actions, underline `tabs`), `stats`, `toolbar`, content. One per route. |
|
|
82
|
+
| `StatStrip` | `stat-strip` | KPI tiles. `StatItem = { label, value, delta?, hint?, tone?, sparkline?, footer?, onClick? }`. Numbers are ink; a delta takes colour only through `tone`. |
|
|
83
|
+
| `SplitView` | `split-view` | `list` beside the selected record (`detail`), `detailWidth` md, lg, xl. Under 1024px the detail is a right sheet. `expanded` + `onExpandedChange` make it a full-page record screen with a Back affordance. |
|
|
84
|
+
| `DetailPanel` | `detail-panel` | The record: sticky header (avatar, title, subtitle, badges, actions, close) + `tabs: { id, label, content, count? }[]` or children. `onExpand` shows "Open full page"; `onCollapse` shows "Back to split view". |
|
|
85
|
+
| `Toolbar` | `toolbar` | Debounced `search` (Enter flushes, Escape clears), `filters`, right-aligned `actions`, `status` (polite result count), a chips row as children. |
|
|
86
|
+
| `StatusPill` | `status-pill` | `tone`: neutral, info, positive, warning, negative. `toneForStatus(raw)` maps API states; unknown is warning, never positive. `humanizeStatus`. |
|
|
87
|
+
| `EntityAvatar` | `entity-avatar` | `kind` person (circle, initials) or company (rounded square, letter); `size` sm, md, lg; `imageUrl`. |
|
|
88
|
+
| `SectionCard` | `section-card` | A flat, hairlined section: title, description, actions, body (`flush` for tables), footer. |
|
|
89
|
+
| `FormSheet` | `form-sheet` | Create/edit sheet: `title`, `description`, fields as children, `footer` (Cancel, then the primary), `onSubmit` wraps the body in a form. |
|
|
90
|
+
| `KeyValueList` | `key-value-list` | Facts about a record, `columns` 1 or 2, `inline` for dense rows. A missing value is a faint dash. |
|
|
91
|
+
| `Sparkline` | `sparkline` | Tiny SVG trend for tiles and cells. `data: number[]`, optional `tone`. |
|
|
92
|
+
| `BarList` | `bar-list` | Ranked rows with a proportional ink bar; `href` makes a row a link. |
|
|
93
|
+
| `TimeSeriesChart` | `time-series-chart` | `data`, `xKey`, `series: { key, label?, tone? }[]`, `kind` line, area, bar. Needs the `recharts` peer. `tone: "prior"` is the dashed comparison. |
|
|
94
|
+
| `useUrlState` | `hooks/use-url-state` | `const [id, setId] = useUrlState("id")`; `setId(null)` removes it; `{ push: true }` adds a history entry. |
|
|
95
|
+
|
|
96
|
+
A finished list screen, start to end:
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
"use client";
|
|
100
|
+
import { Users, Plus } from "lucide-react";
|
|
101
|
+
import { Button } from "@graph8/sdk/ui";
|
|
102
|
+
import { ScreenLayout } from "@/g8/components/screen/screen-layout";
|
|
103
|
+
import { Toolbar } from "@/g8/components/screen/toolbar";
|
|
104
|
+
import { SplitView } from "@/g8/components/screen/split-view";
|
|
105
|
+
import { DetailPanel } from "@/g8/components/screen/detail-panel";
|
|
106
|
+
import { EntityAvatar } from "@/g8/components/screen/entity-avatar";
|
|
107
|
+
import { StatusPill, toneForStatus, humanizeStatus } from "@/g8/components/screen/status-pill";
|
|
108
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
109
|
+
import { SectionCard } from "@/g8/components/screen/section-card";
|
|
110
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
111
|
+
import { useUrlState } from "@/g8/hooks/use-url-state";
|
|
112
|
+
|
|
113
|
+
export function ContactsScreen() {
|
|
114
|
+
const [q, setQ] = useUrlState("q");
|
|
115
|
+
const [id, setId] = useUrlState("id");
|
|
116
|
+
const [creating, setCreating] = useUrlState("new");
|
|
117
|
+
const [view, setView] = useUrlState("view"); // "full" = the record as a full page (?id=X&view=full)
|
|
118
|
+
const contacts = useContacts(q); // your block's hook; { data, loading, error, refetch }
|
|
119
|
+
const open = contacts.data?.find((c) => c.id === id);
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<ScreenLayout
|
|
123
|
+
title="Contacts"
|
|
124
|
+
description="Everyone your team is working"
|
|
125
|
+
icon={<Users />}
|
|
126
|
+
stats={[{ label: "Contacts", value: contacts.total ?? "-" }, { label: "Replied this week", value: 12, delta: 8, tone: "positive" }]}
|
|
127
|
+
actions={<Button onClick={() => setCreating("1")}><Plus />Add contact</Button>}
|
|
128
|
+
toolbar={<Toolbar search={{ value: q, onChange: setQ, placeholder: "Search contacts" }} status={contacts.total != null ? `${contacts.total} contacts` : undefined} />}
|
|
129
|
+
fill
|
|
130
|
+
>
|
|
131
|
+
<SplitView
|
|
132
|
+
detailOpen={!!open}
|
|
133
|
+
onCloseDetail={() => { setId(null); setView(null); }}
|
|
134
|
+
expanded={view === "full"}
|
|
135
|
+
onExpandedChange={(full) => setView(full ? "full" : null)}
|
|
136
|
+
list={<ContactTable rows={contacts.data} selectedId={id} onOpen={(c) => setId(c.id)} />}
|
|
137
|
+
detail={open ? (
|
|
138
|
+
<DetailPanel
|
|
139
|
+
title={open.name}
|
|
140
|
+
subtitle={open.title}
|
|
141
|
+
avatar={<EntityAvatar name={open.name} imageUrl={open.avatar_url} size="lg" />}
|
|
142
|
+
badges={<StatusPill tone={toneForStatus(open.status)} dot>{humanizeStatus(open.status)}</StatusPill>}
|
|
143
|
+
onClose={() => { setId(null); setView(null); }}
|
|
144
|
+
onExpand={view === "full" ? undefined : () => setView("full")}
|
|
145
|
+
onCollapse={view === "full" ? () => setView(null) : undefined}
|
|
146
|
+
tabs={[
|
|
147
|
+
{ id: "overview", label: "Overview", content: (
|
|
148
|
+
<SectionCard title="Details">
|
|
149
|
+
<KeyValueList columns={2} items={[{ label: "Email", value: open.email }, { label: "Phone", value: open.phone }]} />
|
|
150
|
+
</SectionCard>
|
|
151
|
+
) },
|
|
152
|
+
{ id: "activity", label: "Activity", content: <ActivityFeed contactId={open.id} /> },
|
|
153
|
+
]}
|
|
154
|
+
/>
|
|
155
|
+
) : null}
|
|
156
|
+
/>
|
|
157
|
+
<FormSheet open={!!creating} onOpenChange={(o) => setCreating(o ? "1" : null)} title="Add contact" onSubmit={submit}
|
|
158
|
+
footer={<><Button variant="outline" type="button" onClick={() => setCreating(null)}>Cancel</Button><Button type="submit">Add contact</Button></>}>
|
|
159
|
+
{/* fields */}
|
|
160
|
+
</FormSheet>
|
|
161
|
+
</ScreenLayout>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Rules the kit encodes, so screens stay consistent:
|
|
167
|
+
|
|
168
|
+
- The URL owns the open record, the tab and the search (`useUrlState`): reload and Back work, links are shareable.
|
|
169
|
+
- Every record has a full-page view, not only a side panel: `?id=X&view=full` opens it (SplitView `expanded`), and
|
|
170
|
+
the panel's "Open full page" button gets there. Back returns to the split view with the list.
|
|
171
|
+
- One primary action per screen, last on the right. Bulk actions appear on selection. Edits autosave where possible.
|
|
172
|
+
- Colour means good or bad for the user: `tone` on deltas, pills and series; everything else is ink.
|
|
173
|
+
- An unknown status is a warning pill with the humanized value, never a green "Active".
|
|
174
|
+
- A failed load is `ApiError` with Retry, never an empty list; an empty list is `EmptyState` with the first step.
|
|
175
|
+
- Loading is visible and distinct from empty: `StatStrip loading`, `Skeleton` rows.
|
|
176
|
+
- Dates show short text; the full timestamp is the `title` of a `<time dateTime>`.
|
|
177
|
+
|
|
70
178
|
## Prerequisites
|
|
71
179
|
|
|
72
180
|
- `G8_API_KEY` in the server env; verify with `recipes/verify-proxy-key.ts`.
|
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
"id": "foundation",
|
|
4
4
|
"title": "Foundation",
|
|
5
5
|
"summary": "Shared base for every block: browser client, data hooks, Next.js proxy route, confirm/error/empty components.",
|
|
6
|
-
"features": [
|
|
6
|
+
"features": [
|
|
7
|
+
"browser-safe API calls via a server proxy",
|
|
8
|
+
"query, mutation, pagination and job-polling hooks",
|
|
9
|
+
"spend/send/delete confirmation dialog",
|
|
10
|
+
"screen kit: layout, stats, split view, detail panel, toolbar, status pills, avatars, cards, form sheet, charts"
|
|
11
|
+
],
|
|
7
12
|
"dependsOn": [],
|
|
8
13
|
"operations": [],
|
|
9
14
|
"components": [
|
|
@@ -27,20 +32,147 @@
|
|
|
27
32
|
"purpose": "Empty-list placeholder with a title, hint and an optional first-step action.",
|
|
28
33
|
"operations": [],
|
|
29
34
|
"tiers": []
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"name": "ScreenLayout",
|
|
38
|
+
"file": "components/screen/screen-layout.tsx",
|
|
39
|
+
"purpose": "Frame of a finished screen: sticky header (icon, title, description, actions, underline tabs), StatStrip, toolbar, content.",
|
|
40
|
+
"operations": [],
|
|
41
|
+
"tiers": []
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"name": "StatStrip",
|
|
45
|
+
"file": "components/screen/stat-strip.tsx",
|
|
46
|
+
"purpose": "Row of KPI tiles: label, value, delta (coloured only through tone), hint tooltip, sparkline, skeleton while loading.",
|
|
47
|
+
"operations": [],
|
|
48
|
+
"tiers": []
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"name": "SplitView",
|
|
52
|
+
"file": "components/screen/split-view.tsx",
|
|
53
|
+
"purpose": "List beside the selected record; the detail pane becomes a right Sheet under 1024px.",
|
|
54
|
+
"operations": [],
|
|
55
|
+
"tiers": []
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"name": "DetailPanel",
|
|
59
|
+
"file": "components/screen/detail-panel.tsx",
|
|
60
|
+
"purpose": "The selected record: sticky header (avatar, title, subtitle, badges, actions, close) and underline tabs or plain children.",
|
|
61
|
+
"operations": [],
|
|
62
|
+
"tiers": []
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "Toolbar",
|
|
66
|
+
"file": "components/screen/toolbar.tsx",
|
|
67
|
+
"purpose": "The row above a list: debounced search (Enter flushes, Escape clears), filters, right-aligned actions, an optional chips row.",
|
|
68
|
+
"operations": [],
|
|
69
|
+
"tiers": []
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"name": "StatusPill",
|
|
73
|
+
"file": "components/screen/status-pill.tsx",
|
|
74
|
+
"purpose": "State badge with a tone (neutral, info, positive, warning, negative); toneForStatus maps raw API states, unknown ones to warning.",
|
|
75
|
+
"operations": [],
|
|
76
|
+
"tiers": []
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "EntityAvatar",
|
|
80
|
+
"file": "components/screen/entity-avatar.tsx",
|
|
81
|
+
"purpose": "Person (circle, initials) or company (rounded square, first letter) avatar in three sizes, with an image when there is one.",
|
|
82
|
+
"operations": [],
|
|
83
|
+
"tiers": []
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"name": "SectionCard",
|
|
87
|
+
"file": "components/screen/section-card.tsx",
|
|
88
|
+
"purpose": "Flat hairlined section with a title row (title, description, actions) and a body; cards cast no shadow.",
|
|
89
|
+
"operations": [],
|
|
90
|
+
"tiers": []
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"name": "FormSheet",
|
|
94
|
+
"file": "components/screen/form-sheet.tsx",
|
|
95
|
+
"purpose": "Create/edit side sheet: header, scrolling fields, sticky footer; optional onSubmit wraps the body in a form.",
|
|
96
|
+
"operations": [],
|
|
97
|
+
"tiers": []
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"name": "KeyValueList",
|
|
101
|
+
"file": "components/screen/key-value-list.tsx",
|
|
102
|
+
"purpose": "Labelled facts about a record in one or two columns; a missing value is a faint dash.",
|
|
103
|
+
"operations": [],
|
|
104
|
+
"tiers": []
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"name": "Sparkline",
|
|
108
|
+
"file": "components/screen/sparkline.tsx",
|
|
109
|
+
"purpose": "Tiny inline SVG trend (ink line, 6% area) for stat tiles and cells; no chart library.",
|
|
110
|
+
"operations": [],
|
|
111
|
+
"tiers": []
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"name": "BarList",
|
|
115
|
+
"file": "components/screen/bar-list.tsx",
|
|
116
|
+
"purpose": "Ranked rows with a proportional ink bar and a right-aligned value; rows can be links.",
|
|
117
|
+
"operations": [],
|
|
118
|
+
"tiers": []
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"name": "TimeSeriesChart",
|
|
122
|
+
"file": "components/screen/time-series-chart.tsx",
|
|
123
|
+
"purpose": "Line, area or bar chart over time on recharts: ink series, tones only for meaning, dashed prior period, hairline grid.",
|
|
124
|
+
"operations": [],
|
|
125
|
+
"tiers": []
|
|
30
126
|
}
|
|
31
127
|
],
|
|
32
128
|
"hooks": [
|
|
33
|
-
{
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
129
|
+
{
|
|
130
|
+
"name": "useG8Query",
|
|
131
|
+
"file": "hooks/use-g8-query.ts",
|
|
132
|
+
"purpose": "Load data with loading/error/data, an out-of-order guard and refetch."
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"name": "useG8Mutation",
|
|
136
|
+
"file": "hooks/use-g8-mutation.ts",
|
|
137
|
+
"purpose": "Run a write with loading/error state and success/error callbacks."
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"name": "usePaginated",
|
|
141
|
+
"file": "hooks/use-paginated.ts",
|
|
142
|
+
"purpose": "Cursor pagination over the API envelope (pagination.next_cursor) with load-more and reload."
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
"name": "useJobPoll",
|
|
146
|
+
"file": "hooks/use-job-poll.ts",
|
|
147
|
+
"purpose": "Poll a job-status fetcher with backoff until a terminal state (the API has no streams)."
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"name": "useUrlState",
|
|
151
|
+
"file": "hooks/use-url-state.ts",
|
|
152
|
+
"purpose": "One string in the URL query as state (replaceState + popstate, SSR-safe, no next/navigation): the open record, tab or search survives reload and Back."
|
|
153
|
+
}
|
|
37
154
|
],
|
|
38
155
|
"lib": [
|
|
39
|
-
{
|
|
156
|
+
{
|
|
157
|
+
"file": "lib/g8-client.ts",
|
|
158
|
+
"purpose": "Browser caller for the app's own /api/g8 proxy. Holds no key, returns the SDK envelope, throws G8Error."
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
"file": "components/screen/index.ts",
|
|
162
|
+
"purpose": "Barrel for the screen kit: import every part from @/g8/components/screen."
|
|
163
|
+
}
|
|
40
164
|
],
|
|
41
165
|
"server": [
|
|
42
|
-
{
|
|
43
|
-
|
|
166
|
+
{
|
|
167
|
+
"file": "server/proxy.ts",
|
|
168
|
+
"purpose": "createG8Proxy(): forwards browser calls to the graph8 API with the server-side key, a required path allowlist and authorize hook (it refuses to run without them) and a server-side org hook.",
|
|
169
|
+
"target": "g8/server/proxy.ts"
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"file": "server/route.ts",
|
|
173
|
+
"purpose": "The Next.js App Router catch-all route that mounts the proxy.",
|
|
174
|
+
"target": "app/api/g8/[...path]/route.ts"
|
|
175
|
+
}
|
|
44
176
|
],
|
|
45
177
|
"recipes": [
|
|
46
178
|
{
|
|
@@ -71,9 +203,25 @@
|
|
|
71
203
|
],
|
|
72
204
|
"scopes": [],
|
|
73
205
|
"defaults": [
|
|
74
|
-
{
|
|
75
|
-
|
|
76
|
-
|
|
206
|
+
{
|
|
207
|
+
"decision": "Which API paths the browser may call through the proxy",
|
|
208
|
+
"default": "Only the areas the installed blocks use (allowedPrefixes, required), not all of /api/v1",
|
|
209
|
+
"askHuman": false
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
"decision": "How the proxy knows the caller is a signed-in user of the app",
|
|
213
|
+
"default": "None: the route refuses every request until `authorize` is set",
|
|
214
|
+
"askHuman": true
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"decision": "Styling",
|
|
218
|
+
"default": "Tailwind utility classes on plain elements; overlays (ConfirmSpendDialog) and later blocks use @graph8/sdk/ui, so import its CSS",
|
|
219
|
+
"askHuman": false
|
|
220
|
+
}
|
|
77
221
|
],
|
|
78
|
-
"peerDependencies": {
|
|
222
|
+
"peerDependencies": {
|
|
223
|
+
"react": ">=18",
|
|
224
|
+
"lucide-react": ">=0.400.0",
|
|
225
|
+
"recharts": "^2.15.0 || ^3.0.0"
|
|
226
|
+
}
|
|
79
227
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { G8Error } from "@graph8/sdk";
|
|
4
|
+
import { CircleAlert } from "lucide-react";
|
|
5
|
+
import { Button, cn } from "@graph8/sdk/ui";
|
|
4
6
|
|
|
5
7
|
export interface ApiErrorProps {
|
|
6
8
|
error: unknown;
|
|
@@ -8,6 +10,11 @@ export interface ApiErrorProps {
|
|
|
8
10
|
/** Shows a Retry button. */
|
|
9
11
|
onRetry?: () => void;
|
|
10
12
|
className?: string;
|
|
13
|
+
/** One line with the reason and Retry inline, for toolbars and cards. */
|
|
14
|
+
compact?: boolean;
|
|
15
|
+
retryLabel?: string;
|
|
16
|
+
/** Extra actions beside Retry (open settings, contact support). */
|
|
17
|
+
actions?: React.ReactNode;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
interface FieldError {
|
|
@@ -36,27 +43,64 @@ function explain(error: G8Error): string {
|
|
|
36
43
|
return error.message;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Inline error panel for a failed graph8 call. Always shows `detail`, not just `message`. A failed load
|
|
48
|
+
* is an error with Retry, never an empty list. Ember is reserved for this: errors and alerts.
|
|
49
|
+
*/
|
|
50
|
+
export function ApiError({ error, title = "Something went wrong", onRetry, className, compact, retryLabel = "Retry", actions }: ApiErrorProps) {
|
|
41
51
|
if (error == null) return null;
|
|
42
52
|
const info = describeError(error);
|
|
53
|
+
|
|
54
|
+
if (compact) {
|
|
55
|
+
return (
|
|
56
|
+
<div role="alert" data-testid="api-error" className={cn("flex flex-wrap items-center gap-x-3 gap-y-1 rounded-md border border-error-foreground/20 bg-error px-3 py-2 text-sm text-error-foreground", className)}>
|
|
57
|
+
<CircleAlert className="size-4 shrink-0" strokeWidth={1.5} aria-hidden />
|
|
58
|
+
<span className="min-w-0 flex-1 truncate">
|
|
59
|
+
<span className="font-medium">{title}.</span> {info.message}
|
|
60
|
+
</span>
|
|
61
|
+
{actions}
|
|
62
|
+
{onRetry ? (
|
|
63
|
+
<Button type="button" size="sm" variant="outline" onClick={onRetry} className="h-7 border-error-foreground/30 bg-background text-error-foreground hover:bg-background/70 hover:text-error-foreground">
|
|
64
|
+
{retryLabel}
|
|
65
|
+
</Button>
|
|
66
|
+
) : null}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
43
71
|
return (
|
|
44
|
-
<div role="alert" className={
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
72
|
+
<div role="alert" data-testid="api-error" className={cn("flex gap-3 rounded-lg border border-error-foreground/20 bg-error p-4 text-sm text-error-foreground", className)}>
|
|
73
|
+
<div aria-hidden className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-error-foreground/20 bg-background/60">
|
|
74
|
+
<CircleAlert className="size-4" strokeWidth={1.5} />
|
|
75
|
+
</div>
|
|
76
|
+
<div className="min-w-0 flex-1">
|
|
77
|
+
<p className="font-medium leading-5">{title}</p>
|
|
78
|
+
<p className="mt-1 leading-5 text-error-foreground/90">{info.message}</p>
|
|
79
|
+
{info.fields.length > 0 ? (
|
|
80
|
+
<ul className="mt-2 list-disc space-y-0.5 pl-5 text-error-foreground/90">
|
|
81
|
+
{info.fields.map((line) => (
|
|
82
|
+
<li key={line}>{line}</li>
|
|
83
|
+
))}
|
|
84
|
+
</ul>
|
|
85
|
+
) : null}
|
|
86
|
+
{info.status != null || info.requestId ? (
|
|
87
|
+
<p className="mt-2 text-xs text-error-foreground/70 tabular-nums">
|
|
88
|
+
{info.status != null ? <span>HTTP {info.status}</span> : null}
|
|
89
|
+
{info.status != null && info.requestId ? <span aria-hidden> · </span> : null}
|
|
90
|
+
{info.requestId ? <span>Request id: {info.requestId}</span> : null}
|
|
91
|
+
</p>
|
|
92
|
+
) : null}
|
|
93
|
+
{onRetry || actions ? (
|
|
94
|
+
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
95
|
+
{onRetry ? (
|
|
96
|
+
<Button type="button" size="sm" variant="outline" onClick={onRetry} className="border-error-foreground/30 bg-background text-error-foreground hover:bg-background/70 hover:text-error-foreground">
|
|
97
|
+
{retryLabel}
|
|
98
|
+
</Button>
|
|
99
|
+
) : null}
|
|
100
|
+
{actions}
|
|
101
|
+
</div>
|
|
102
|
+
) : null}
|
|
103
|
+
</div>
|
|
60
104
|
</div>
|
|
61
105
|
);
|
|
62
106
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
|
+
import { cn } from "@graph8/sdk/ui";
|
|
4
5
|
|
|
5
6
|
export interface EmptyStateProps {
|
|
6
7
|
title: string;
|
|
@@ -9,16 +10,47 @@ export interface EmptyStateProps {
|
|
|
9
10
|
action?: ReactNode;
|
|
10
11
|
icon?: ReactNode;
|
|
11
12
|
className?: string;
|
|
13
|
+
/** A second, quieter action (import, learn more). */
|
|
14
|
+
secondaryAction?: ReactNode;
|
|
15
|
+
/** Less vertical space, for panels and cards. */
|
|
16
|
+
compact?: boolean;
|
|
17
|
+
/** Draw a dashed boundary, so the empty area reads as a place where rows will appear. */
|
|
18
|
+
bordered?: boolean;
|
|
12
19
|
}
|
|
13
20
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Shown when a list is empty. Say what will appear here and offer the first step. First-run copy
|
|
23
|
+
* ("No contacts yet. Import a CSV or add one.") differs from filtered-empty copy ("No contacts match
|
|
24
|
+
* these filters.", with a Clear filters action). An ink icon in a quiet well, one sentence, one action.
|
|
25
|
+
*/
|
|
26
|
+
export function EmptyState({ title, description, action, icon, className, secondaryAction, compact, bordered }: EmptyStateProps) {
|
|
16
27
|
return (
|
|
17
|
-
<div
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
28
|
+
<div
|
|
29
|
+
role="status"
|
|
30
|
+
data-testid="empty-state"
|
|
31
|
+
className={cn(
|
|
32
|
+
"flex flex-col items-center justify-center gap-2 px-6 text-center",
|
|
33
|
+
compact ? "py-8" : "py-14",
|
|
34
|
+
bordered && "rounded-lg border border-dashed border-border",
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
{icon ? (
|
|
39
|
+
<div
|
|
40
|
+
aria-hidden
|
|
41
|
+
className="mb-1 flex size-10 items-center justify-center rounded-lg border border-border bg-muted text-foreground [&_svg]:size-5 [&_svg]:stroke-[1.5]"
|
|
42
|
+
>
|
|
43
|
+
{icon}
|
|
44
|
+
</div>
|
|
45
|
+
) : null}
|
|
46
|
+
<h3 className="text-sm font-medium text-foreground">{title}</h3>
|
|
47
|
+
{description ? <p className="max-w-sm text-sm leading-5 text-text-color-muted">{description}</p> : null}
|
|
48
|
+
{action || secondaryAction ? (
|
|
49
|
+
<div className="mt-2 flex flex-wrap items-center justify-center gap-2">
|
|
50
|
+
{secondaryAction}
|
|
51
|
+
{action}
|
|
52
|
+
</div>
|
|
53
|
+
) : null}
|
|
22
54
|
</div>
|
|
23
55
|
);
|
|
24
56
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BarList: ranked rows (top sources, top campaigns, stages) with a proportional ink bar behind each
|
|
5
|
+
* label and the value right-aligned. Rows with `href` are links. The first row carries the story in
|
|
6
|
+
* ink; the rest are an ink tint.
|
|
7
|
+
*
|
|
8
|
+
* SDK operations: none.
|
|
9
|
+
*/
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
import { cn } from "@graph8/sdk/ui";
|
|
12
|
+
import { TONE_CLASSES, type Tone } from "./status-pill";
|
|
13
|
+
|
|
14
|
+
export interface BarListItem {
|
|
15
|
+
label: ReactNode;
|
|
16
|
+
value: number;
|
|
17
|
+
href?: string;
|
|
18
|
+
onClick?: () => void;
|
|
19
|
+
/** Colours this row's bar; only when the row means good or bad. */
|
|
20
|
+
tone?: Tone;
|
|
21
|
+
/** Text shown instead of the raw number ("42%", "1.2k"). */
|
|
22
|
+
display?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BarListProps {
|
|
26
|
+
items: BarListItem[];
|
|
27
|
+
/** Highest value to scale against; defaults to the largest item. */
|
|
28
|
+
max?: number;
|
|
29
|
+
/** Format a value for display when `display` is absent. */
|
|
30
|
+
formatValue?: (value: number) => string;
|
|
31
|
+
/** Emphasise the first row in full ink. Default true. */
|
|
32
|
+
emphasizeFirst?: boolean;
|
|
33
|
+
className?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const defaultFormat = (v: number) => v.toLocaleString();
|
|
37
|
+
|
|
38
|
+
export function BarList({ items, max, formatValue = defaultFormat, emphasizeFirst = true, className }: BarListProps) {
|
|
39
|
+
const top = max ?? Math.max(0, ...items.map((i) => i.value));
|
|
40
|
+
return (
|
|
41
|
+
<ol className={cn("flex flex-col gap-1.5", className)} data-testid="bar-list">
|
|
42
|
+
{items.map((item, i) => {
|
|
43
|
+
const pct = top > 0 ? Math.max(0, Math.min(100, (item.value / top) * 100)) : 0;
|
|
44
|
+
const bar = item.tone ? TONE_CLASSES[item.tone].bar + " opacity-30" : emphasizeFirst && i === 0 ? "bg-foreground/15" : "bg-foreground/[0.07]";
|
|
45
|
+
const inner = (
|
|
46
|
+
<>
|
|
47
|
+
<span aria-hidden className={cn("absolute inset-y-0 left-0 rounded-sm transition-[width]", bar)} style={{ width: `${pct}%` }} />
|
|
48
|
+
<span className="relative min-w-0 flex-1 truncate text-sm text-foreground">{item.label}</span>
|
|
49
|
+
<span className="relative shrink-0 pl-3 text-sm tabular-nums text-foreground">{item.display ?? formatValue(item.value)}</span>
|
|
50
|
+
</>
|
|
51
|
+
);
|
|
52
|
+
const cls = "relative flex h-7 w-full items-center overflow-hidden rounded-sm px-2 text-left";
|
|
53
|
+
return (
|
|
54
|
+
<li key={i} className="min-w-0">
|
|
55
|
+
{item.href ? (
|
|
56
|
+
<a href={item.href} className={cn(cls, "hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring")}>
|
|
57
|
+
{inner}
|
|
58
|
+
</a>
|
|
59
|
+
) : item.onClick ? (
|
|
60
|
+
<button type="button" onClick={item.onClick} className={cn(cls, "hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring")}>
|
|
61
|
+
{inner}
|
|
62
|
+
</button>
|
|
63
|
+
) : (
|
|
64
|
+
<div className={cls}>{inner}</div>
|
|
65
|
+
)}
|
|
66
|
+
</li>
|
|
67
|
+
);
|
|
68
|
+
})}
|
|
69
|
+
</ol>
|
|
70
|
+
);
|
|
71
|
+
}
|