@graph8/sdk 0.358.0 → 0.362.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/blocks/_foundation/README.md +108 -0
  2. package/blocks/_foundation/block.json +160 -12
  3. package/blocks/_foundation/components/api-error.tsx +62 -18
  4. package/blocks/_foundation/components/empty-state.tsx +39 -7
  5. package/blocks/_foundation/components/screen/bar-list.tsx +71 -0
  6. package/blocks/_foundation/components/screen/detail-panel.tsx +114 -0
  7. package/blocks/_foundation/components/screen/entity-avatar.tsx +56 -0
  8. package/blocks/_foundation/components/screen/form-sheet.tsx +73 -0
  9. package/blocks/_foundation/components/screen/index.ts +21 -0
  10. package/blocks/_foundation/components/screen/key-value-list.tsx +65 -0
  11. package/blocks/_foundation/components/screen/screen-layout.tsx +124 -0
  12. package/blocks/_foundation/components/screen/section-card.tsx +47 -0
  13. package/blocks/_foundation/components/screen/sparkline.tsx +77 -0
  14. package/blocks/_foundation/components/screen/split-view.tsx +125 -0
  15. package/blocks/_foundation/components/screen/stat-strip.tsx +121 -0
  16. package/blocks/_foundation/components/screen/status-pill.tsx +104 -0
  17. package/blocks/_foundation/components/screen/time-series-chart.tsx +114 -0
  18. package/blocks/_foundation/components/screen/toolbar.tsx +144 -0
  19. package/blocks/_foundation/hooks/use-url-state.ts +74 -0
  20. package/blocks/appointments/README.md +21 -1
  21. package/blocks/appointments/block.json +39 -2
  22. package/blocks/appointments/components/event-type-related.tsx +75 -0
  23. package/blocks/appointments/components/meeting-list.tsx +8 -4
  24. package/blocks/appointments/components/meetings-screen.tsx +154 -0
  25. package/blocks/appointments/components/scheduling-screen.tsx +121 -0
  26. package/blocks/appointments/hooks/use-event-types.ts +17 -0
  27. package/blocks/block.types.ts +9 -0
  28. package/blocks/deals/README.md +34 -3
  29. package/blocks/deals/block.json +39 -4
  30. package/blocks/deals/components/deal-detail.tsx +4 -2
  31. package/blocks/deals/components/deal-related.tsx +87 -0
  32. package/blocks/deals/components/deals-screen.tsx +293 -0
  33. package/blocks/deals/components/pipeline-board.tsx +4 -2
  34. package/blocks/deals/hooks/use-default-owner.ts +60 -0
  35. package/blocks/deals/lib/deals-types.ts +14 -0
  36. package/blocks/forms/README.md +12 -0
  37. package/blocks/forms/block.json +9 -1
  38. package/blocks/forms/components/forms-screen.tsx +350 -0
  39. package/blocks/forms/components/submissions-table.tsx +13 -5
  40. package/blocks/lifecycle/README.md +16 -0
  41. package/blocks/lifecycle/block.json +23 -1
  42. package/blocks/lifecycle/components/account-related.tsx +66 -0
  43. package/blocks/lifecycle/components/lifecycle-screen.tsx +147 -0
  44. package/blocks/newsletter/README.md +13 -0
  45. package/blocks/newsletter/block.json +9 -1
  46. package/blocks/newsletter/components/newsletter-screen.tsx +402 -0
  47. package/blocks/newsletter/components/subscriber-table.tsx +12 -6
  48. package/blocks/nurture/README.md +12 -0
  49. package/blocks/nurture/block.json +9 -1
  50. package/blocks/nurture/components/nurture-screen.tsx +279 -0
  51. package/blocks/pages/README.md +12 -0
  52. package/blocks/pages/block.json +9 -1
  53. package/blocks/pages/components/content-library.tsx +37 -7
  54. package/blocks/pages/components/page-list.tsx +4 -2
  55. package/blocks/pages/components/pages-screen.tsx +154 -0
  56. package/blocks/quotes/README.md +17 -0
  57. package/blocks/quotes/block.json +26 -2
  58. package/blocks/quotes/components/quote-list.tsx +7 -3
  59. package/blocks/quotes/components/quote-related.tsx +120 -0
  60. package/blocks/quotes/components/quotes-screen.tsx +169 -0
  61. package/dist/cli.js +197 -37
  62. package/dist/index.browser.js +1 -1
  63. package/dist/index.browser.js.map +1 -1
  64. package/dist/index.browser.mjs +1 -1
  65. package/dist/index.browser.mjs.map +1 -1
  66. package/dist/index.d.mts +93 -7
  67. package/dist/index.d.ts +93 -7
  68. package/dist/index.js +1 -1
  69. package/dist/index.js.map +1 -1
  70. package/dist/index.mjs +1 -1
  71. package/dist/index.mjs.map +1 -1
  72. package/dist/react.browser.js +1 -1
  73. package/dist/react.browser.js.map +1 -1
  74. package/dist/react.browser.mjs +1 -1
  75. package/dist/react.browser.mjs.map +1 -1
  76. package/dist/react.d.mts +90 -4
  77. package/dist/react.d.ts +90 -4
  78. package/dist/react.js +1 -1
  79. package/dist/react.js.map +1 -1
  80. package/dist/react.mjs +1 -1
  81. package/dist/react.mjs.map +1 -1
  82. package/dist/types/blocks/create.d.ts +15 -1
  83. package/dist/types/blocks/doctor.d.ts +2 -1
  84. package/dist/types/blocks/flows.d.ts +20 -0
  85. package/dist/types/blocks/registry.d.ts +15 -0
  86. package/dist/types/core.d.ts +3 -2
  87. package/dist/types/react.d.ts +2 -0
  88. package/dist/types/studio.d.ts +48 -0
  89. package/dist/types/version.d.ts +2 -2
  90. package/docs/api/adCampaigns.d.ts +4 -0
  91. package/docs/api/adCampaigns.md +4 -0
  92. package/docs/api/gtmCampaigns.d.ts +4 -0
  93. package/docs/api/gtmCampaigns.md +2 -0
  94. package/docs/api/gtmContext.d.ts +5 -1
  95. package/docs/api/gtmContext.md +15 -7
  96. package/docs/api/integrations.d.ts +29 -4
  97. package/docs/api/integrations.md +14 -0
  98. package/docs/sdk-clients.md +1 -1
  99. package/package.json +1 -1
  100. package/stacks/stack.schema.json +6 -1
  101. package/styles/styles.css +1 -1
  102. package/templates/next-app/README.md.tmpl +16 -0
  103. package/templates/next-app/app/globals.css.tmpl +105 -0
  104. package/templates/next-app/app/layout.tsx.tmpl +11 -2
  105. package/templates/next-app/components/app-shell.tsx.tmpl +238 -23
  106. package/templates/next-app/components/shell/connection-status.tsx.tmpl +109 -0
  107. package/templates/next-app/components/shell/nav-model.ts.tmpl +65 -0
  108. package/templates/next-app/components/shell/quick-jump.tsx.tmpl +131 -0
  109. package/templates/next-app/components/shell/theme-toggle.tsx.tmpl +40 -0
  110. package/templates/next-app/lib/nav.ts.tmpl +1 -1
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ /**
4
+ * useUrlState: one string in the URL query (`?id=123`, `?tab=activity`, `?q=ada`) as React state.
5
+ * The URL owns it, so the open record, the tab and the search survive reload, Back and sharing.
6
+ * Framework-agnostic: window.history.replaceState (pushState with `{ push: true }`) plus `popstate`,
7
+ * no next/navigation. SSR-safe: the server and the first client render see `defaultValue`.
8
+ *
9
+ * SDK operations: none.
10
+ */
11
+ import { useCallback, useSyncExternalStore } from "react";
12
+
13
+ const EVENT = "g8:urlstate";
14
+
15
+ type Listener = () => void;
16
+
17
+ function subscribe(listener: Listener): () => void {
18
+ if (typeof window !== "undefined") {
19
+ window.addEventListener("popstate", listener);
20
+ window.addEventListener(EVENT, listener);
21
+ }
22
+ return () => {
23
+ if (typeof window !== "undefined") {
24
+ window.removeEventListener("popstate", listener);
25
+ window.removeEventListener(EVENT, listener);
26
+ }
27
+ };
28
+ }
29
+
30
+ /** Current value of `key` in the page URL, or undefined. */
31
+ export function readUrlParam(key: string): string | undefined {
32
+ if (typeof window === "undefined") return undefined;
33
+ const v = new URLSearchParams(window.location.search).get(key);
34
+ return v == null ? undefined : v;
35
+ }
36
+
37
+ export interface SetUrlStateOptions {
38
+ /** Add a history entry (Back returns to the previous value) instead of replacing. */
39
+ push?: boolean;
40
+ }
41
+
42
+ /** Write `key` into the page URL (null, undefined or "" removes it) and notify every hook. */
43
+ export function writeUrlParam(key: string, value: string | null | undefined, options: SetUrlStateOptions = {}): void {
44
+ if (typeof window === "undefined") return;
45
+ const url = new URL(window.location.href);
46
+ const current = url.searchParams.get(key);
47
+ const next = value == null || value === "" ? null : String(value);
48
+ if (current === next) return;
49
+ if (next == null) url.searchParams.delete(key);
50
+ else url.searchParams.set(key, next);
51
+ const href = `${url.pathname}${url.search}${url.hash}`;
52
+ if (options.push) window.history.pushState(window.history.state, "", href);
53
+ else window.history.replaceState(window.history.state, "", href);
54
+ window.dispatchEvent(new Event(EVENT));
55
+ }
56
+
57
+ export type UrlStateSetter = (value: string | null | undefined, options?: SetUrlStateOptions) => void;
58
+
59
+ /**
60
+ * `const [id, setId] = useUrlState("id")`: `id` is the param or `defaultValue` ("" by default); `setId(null)`
61
+ * or `setId("")` removes it; `setId("42", { push: true })` adds a history entry.
62
+ */
63
+ export function useUrlState(key: string, defaultValue = ""): [string, UrlStateSetter] {
64
+ const value = useSyncExternalStore(
65
+ subscribe,
66
+ () => readUrlParam(key) ?? defaultValue,
67
+ () => defaultValue,
68
+ );
69
+ const set = useCallback<UrlStateSetter>(
70
+ (next, options) => writeUrlParam(key, next == null || next === defaultValue ? null : next, options),
71
+ [key, defaultValue],
72
+ );
73
+ return [value, set];
74
+ }
@@ -4,6 +4,25 @@ Event types, weekly working hours, a booking page, a list of booked and recorded
4
4
  rep takes on a booking (confirm, cancel, add a note). Install with `npx @graph8/sdk add appointments`
5
5
  (depends on `crm`).
6
6
 
7
+ ## Screens
8
+
9
+ - `MeetingsScreen` (`components/meetings-screen.tsx`): `<MeetingsScreen />`. Upcoming, Past, Cancelled and Recorded
10
+ meetings; headline numbers (upcoming, awaiting approval); open a booking (deep link `?sel=booking:<uid>`) to accept or
11
+ decline a pending one, add an internal note or cancel (all confirmed, they email attendees), or open a recorded
12
+ meeting for its summary, topics, action items and transcript. **Schedule a meeting** picks an event type, a day and a
13
+ time and books it for an attendee (external, confirmed). Calendar sign-in is only possible in the graph8 app, so the
14
+ screen shows a link-out card (`connectCalendarUrl`).
15
+ - `SchedulingScreen` (`components/scheduling-screen.tsx`): `<SchedulingScreen />`. Event types tab: the list (from
16
+ `GET /event-types`), create, open one (`?id=<event type id>`) to edit or delete it (confirmed) and to try its booking
17
+ page. Working hours tab: weekly hours, date overrides and connected calendars for a schedule.
18
+ - Slots for cross-block glue, all optional: `renderRowActions`, `renderDetailActions`, `extraDetailTabs`,
19
+ `toolbarActions`.
20
+
21
+ The older note that there is no list-event-types operation is out of date: `GET /event-types` (`useEventTypes`) lists
22
+ them, so ids no longer have to be stored. It needs the `campaigns:read` scope and `event-types` in the proxy
23
+ `allowedPrefixes`, next to `appointments` and `inbox`.
24
+
25
+
7
26
  ## The flow, in order
8
27
 
9
28
  1. **Hours** (`AvailabilityEditor`): `GET /appointments/schedules` -> pick `schedule.id` ->
@@ -30,7 +49,7 @@ booking), `booking.uid` (list -> detail -> confirm/cancel/notes), recorded `meet
30
49
  not in the API. `AvailabilityEditor` shows connection state and links out via `connectCalendarUrl`.
31
50
  - Use an API key minted by the host. Appointments act as a person; an organisation key can read and
32
51
  list slots, but anything personal (profile, slot reservation) requires a personal key.
33
- - Scopes: `meetings:read`, `meetings:write`, `meetings:run`, `meetings:delete` (cancel a booking, delete an event type).
52
+ - Scopes: `meetings:read`, `meetings:write`, `meetings:run`, `meetings:delete` (cancel a booking, delete an event type), plus `campaigns:read` for the event-type list.
34
53
 
35
54
  ## Defaults (the agent does not need to ask)
36
55
 
@@ -70,6 +89,7 @@ with the full API key.
70
89
  | `appointments.addBookingNote` | POST /appointments/bookings/{uid}/notes | external | MeetingDetail |
71
90
  | `meetings.listMeetings` | GET /inbox/meetings | read | MeetingList |
72
91
  | `meetings.getMeeting` | GET /inbox/meetings/{id} | read | MeetingDetail |
92
+ | `gtmLaunchHelpers.listEventTypes` | GET /event-types | read | useEventTypes (both screens) |
73
93
 
74
94
  ## Recipes (server side)
75
95
 
@@ -27,6 +27,7 @@
27
27
  "get_meeting_inbox_meetings__meeting_id__get",
28
28
  "list_bookings_appointments_bookings_get",
29
29
  "list_calendars_appointments_calendars_get",
30
+ "list_event_types_event_types_get",
30
31
  "list_meetings_inbox_meetings_get",
31
32
  "list_schedules_appointments_schedules_get",
32
33
  "update_availability_appointments_schedules__schedule_id__availability_patch",
@@ -107,6 +108,32 @@
107
108
  "external",
108
109
  "destructive"
109
110
  ]
111
+ },
112
+ {
113
+ "name": "MeetingsScreen",
114
+ "file": "components/meetings-screen.tsx",
115
+ "purpose": "Finished meetings product: upcoming, past, cancelled and recorded meetings with detail, approvals, notes and a schedule-a-meeting flow.",
116
+ "operations": [],
117
+ "tiers": []
118
+ },
119
+ {
120
+ "name": "SchedulingScreen",
121
+ "file": "components/scheduling-screen.tsx",
122
+ "purpose": "Finished scheduling product: event types with settings and booking-page preview, plus weekly working hours and calendar status.",
123
+ "operations": [],
124
+ "tiers": []
125
+ },
126
+ {
127
+ "name": "EventTypeOverview",
128
+ "file": "components/event-type-related.tsx",
129
+ "purpose": "Read views of one event type: every field (EventTypeOverview) and the bookings made through it (EventTypeBookings, with MeetingDetail in a sheet).",
130
+ "operations": [
131
+ "get_event_type_appointments_event_types__event_type_id__get",
132
+ "list_bookings_appointments_bookings_get"
133
+ ],
134
+ "tiers": [
135
+ "read"
136
+ ]
110
137
  }
111
138
  ],
112
139
  "hooks": [
@@ -125,6 +152,14 @@
125
152
  "operations": [
126
153
  "get_available_slots_appointments_slots_get"
127
154
  ]
155
+ },
156
+ {
157
+ "name": "useEventTypes",
158
+ "file": "hooks/use-event-types.ts",
159
+ "purpose": "The org's bookable event types (the list operation the editor needs).",
160
+ "operations": [
161
+ "list_event_types_event_types_get"
162
+ ]
128
163
  }
129
164
  ],
130
165
  "lib": [
@@ -174,12 +209,13 @@
174
209
  },
175
210
  {
176
211
  "id": "meetings-scopes",
177
- "description": "Key scopes meetings:read, meetings:write, meetings:run (booking, confirm, notes are tier external) and meetings:delete (cancel, delete event type).",
212
+ "description": "Key scopes meetings:read, meetings:write, meetings:run (booking, confirm, notes are tier external) and meetings:delete (cancel, delete event type), plus campaigns:read for the event-type list (GET /event-types).",
178
213
  "where": "api",
179
214
  "verify": "`g8.api.me.describeCurrentKey()` lists the meetings:* scopes."
180
215
  }
181
216
  ],
182
217
  "scopes": [
218
+ "campaigns:read",
183
219
  "meetings:delete",
184
220
  "meetings:read",
185
221
  "meetings:run",
@@ -213,6 +249,7 @@
213
249
  }
214
250
  ],
215
251
  "peerDependencies": {
216
- "react": ">=18"
252
+ "react": ">=18",
253
+ "lucide-react": ">=0.400.0"
217
254
  }
218
255
  }
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Read views of one event type, as tabs of its full detail: Overview (every field) and Bookings (who booked it).
5
+ *
6
+ * SDK operations, in order:
7
+ * EventTypeOverview: GET /appointments/event-types/{id} (appointments.getEventType) read
8
+ * EventTypeBookings: GET /appointments/bookings?event_type_ids=&skip=&take= (appointments.listBookings) read
9
+ * then MeetingDetail in a side sheet for the booking you open (confirm, cancel and notes
10
+ * are external or destructive there and go through ConfirmSpendDialog).
11
+ */
12
+ import { useState } from "react";
13
+ import { CalendarClock } from "lucide-react";
14
+ import { Button, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
15
+ import { ApiError } from "@/g8/components/api-error";
16
+ import { EmptyState } from "@/g8/components/empty-state";
17
+ import { KeyValueList } from "@/g8/components/screen/key-value-list";
18
+ import { SectionCard } from "@/g8/components/screen/section-card";
19
+ import { StatusPill, humanizeStatus, toneForStatus } from "@/g8/components/screen/status-pill";
20
+ import { g8Get } from "@/g8/lib/g8-client";
21
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
22
+ import { formatDateTime, listOf, unwrap, type Booking, type EventType } from "../lib/appointments-types";
23
+ import { MeetingDetail } from "./meeting-detail";
24
+
25
+ export function EventTypeOverview({ eventTypeId }: { eventTypeId: number }) {
26
+ const q = useG8Query(async (signal) => unwrap<EventType>(await g8Get(`/appointments/event-types/${eventTypeId}`, undefined, { signal })), [eventTypeId]);
27
+ if (q.error) return <ApiError error={q.error} title="Could not load the event type" onRetry={() => void q.refetch()} />;
28
+ if (!q.data) return <Skeleton className="h-32 w-full" aria-label="Loading event type" />;
29
+ const t = q.data;
30
+ return (
31
+ <SectionCard title="Details">
32
+ <KeyValueList columns={2} items={[
33
+ { label: "Title", value: t.title }, { label: "URL slug", value: t.slug ? `/${t.slug}` : null, mono: true }, { label: "Length", value: t.length ? `${t.length} minutes` : null },
34
+ { label: "Working hours", value: t.schedule?.name }, { label: "Minimum notice", value: t.minimum_booking_notice != null ? `${t.minimum_booking_notice} minutes` : null },
35
+ { label: "Host approval", value: t.requires_confirmation ? "Required" : "Not required" }, { label: "Visibility", value: t.hidden ? "Hidden" : "Bookable" },
36
+ { label: "Scheduling type", value: t.scheduling_type ?? "Single host" }, { label: "Description", value: t.description, wide: true },
37
+ ]} />
38
+ </SectionCard>
39
+ );
40
+ }
41
+
42
+ export function EventTypeBookings({ eventTypeId, pageSize = 20 }: { eventTypeId: number; pageSize?: number }) {
43
+ const [page, setPage] = useState(0);
44
+ const [open, setOpen] = useState<string | null>(null);
45
+ const q = useG8Query(async (signal) => listOf<Booking>(await g8Get("/appointments/bookings", { event_type_ids: String(eventTypeId), skip: page * pageSize, take: pageSize, sort_by: "start_time", sort_order: "desc" }, { signal }), "bookings"), [eventTypeId, page, pageSize]);
46
+ if (q.error) return <ApiError error={q.error} title="Could not load bookings" onRetry={() => void q.refetch()} />;
47
+ if (!q.data) return <Skeleton className="h-32 w-full" aria-label="Loading bookings" />;
48
+ if (q.data.length === 0 && page === 0) return <EmptyState icon={<CalendarClock className="size-8" />} title="No bookings yet" description="Bookings made through this event type appear here." />;
49
+ return (
50
+ <div className="space-y-3">
51
+ <Table>
52
+ <TableHeader><TableRow><TableHead>When</TableHead><TableHead>Attendees</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
53
+ <TableBody>
54
+ {q.data.map((b) => (
55
+ <TableRow key={b.uid} className="cursor-pointer" onClick={() => setOpen(b.uid)}>
56
+ <TableCell><button type="button" className="font-medium hover:underline" onClick={(e) => { e.stopPropagation(); setOpen(b.uid); }}>{formatDateTime(b.start_time)}</button></TableCell>
57
+ <TableCell className="max-w-64 truncate">{b.attendees.map((a) => a.name || a.email).join(", ") || "No attendees"}</TableCell>
58
+ <TableCell><StatusPill tone={toneForStatus(b.status)} dot>{humanizeStatus(b.status)}</StatusPill></TableCell>
59
+ </TableRow>
60
+ ))}
61
+ </TableBody>
62
+ </Table>
63
+ <div className="flex justify-end gap-2">
64
+ <Button size="sm" variant="outline" disabled={page === 0 || q.loading} onClick={() => setPage((p) => p - 1)}>Previous</Button>
65
+ <Button size="sm" variant="outline" disabled={q.data.length < pageSize || q.loading} onClick={() => setPage((p) => p + 1)}>Next</Button>
66
+ </div>
67
+ <Sheet open={open != null} onOpenChange={(o) => { if (!o) setOpen(null); }}>
68
+ <SheetContent className="w-full overflow-y-auto sm:max-w-xl">
69
+ <SheetHeader><SheetTitle>Booking</SheetTitle><SheetDescription>Accept, decline, note or cancel.</SheetDescription></SheetHeader>
70
+ {open ? <MeetingDetail selection={{ kind: "booking", id: open }} onChanged={() => void q.refetch()} /> : null}
71
+ </SheetContent>
72
+ </Sheet>
73
+ </div>
74
+ );
75
+ }
@@ -27,6 +27,10 @@ export interface MeetingListProps {
27
27
  onSelect?: (selection: MeetingSelection) => void;
28
28
  initialView?: MeetingView;
29
29
  pageSize?: number;
30
+ /** Change it to reload the current page (after a confirm, cancel or booking elsewhere on the screen). */
31
+ refreshKey?: number;
32
+ /** Mark this row as the open one. */
33
+ selectedId?: string | null;
30
34
  className?: string;
31
35
  }
32
36
 
@@ -54,7 +58,7 @@ function useDebouncedText(value: string, ms = 300): string {
54
58
  return v;
55
59
  }
56
60
 
57
- export function MeetingList({ onSelect, initialView = "upcoming", pageSize = 20, className }: MeetingListProps) {
61
+ export function MeetingList({ onSelect, initialView = "upcoming", pageSize = 20, refreshKey, selectedId, className }: MeetingListProps) {
58
62
  const [view, setView] = useState<MeetingView>(initialView);
59
63
  const [page, setPage] = useState(0);
60
64
  const [search, setSearch] = useState("");
@@ -71,7 +75,7 @@ export function MeetingList({ onSelect, initialView = "upcoming", pageSize = 20,
71
75
  const items = listOf<Booking>(env, "bookings");
72
76
  return { kind: "booking" as const, items, more: items.length === pageSize };
73
77
  },
74
- [view, page, query, pageSize, now],
78
+ [view, page, query, pageSize, now, refreshKey],
75
79
  );
76
80
 
77
81
  const switchView = (next: MeetingView) => {
@@ -116,7 +120,7 @@ export function MeetingList({ onSelect, initialView = "upcoming", pageSize = 20,
116
120
  <TableBody>
117
121
  {result.kind === "booking"
118
122
  ? (result.items as Booking[]).map((b) => (
119
- <TableRow key={b.uid} className="cursor-pointer" onClick={() => onSelect?.({ kind: "booking", id: b.uid })}>
123
+ <TableRow key={b.uid} data-state={selectedId === b.uid ? "selected" : undefined} className="cursor-pointer" onClick={() => onSelect?.({ kind: "booking", id: b.uid })}>
120
124
  <TableCell className="whitespace-nowrap">{formatDateTime(b.start_time)}</TableCell>
121
125
  <TableCell className="font-medium">{b.title || b.event_type_title || "Meeting"}</TableCell>
122
126
  <TableCell className="max-w-64 truncate">{attendeeNames(b)}</TableCell>
@@ -124,7 +128,7 @@ export function MeetingList({ onSelect, initialView = "upcoming", pageSize = 20,
124
128
  </TableRow>
125
129
  ))
126
130
  : (result.items as RecordedMeeting[]).map((m) => (
127
- <TableRow key={m.id} className="cursor-pointer" onClick={() => onSelect?.({ kind: "meeting", id: m.id })}>
131
+ <TableRow key={m.id} data-state={selectedId === m.id ? "selected" : undefined} className="cursor-pointer" onClick={() => onSelect?.({ kind: "meeting", id: m.id })}>
128
132
  <TableCell className="whitespace-nowrap">{formatDateTime(m.start_time)}</TableCell>
129
133
  <TableCell className="font-medium">{m.subject || "Untitled meeting"}</TableCell>
130
134
  <TableCell className="max-w-64 truncate">{m.transcript_summary || "No summary yet"}</TableCell>
@@ -0,0 +1,154 @@
1
+ "use client";
2
+
3
+ /**
4
+ * MeetingsScreen: the finished meetings product. Upcoming, past, cancelled and recorded meetings, a detail panel
5
+ * for the open one, and a "Schedule a meeting" flow. No props are required.
6
+ *
7
+ * It composes: MeetingList (views, search, paging), MeetingDetail (booking or recorded meeting) and BookingPage.
8
+ *
9
+ * Flows:
10
+ * - Headline numbers: meetings coming up and bookings waiting for your approval.
11
+ * - Open a booking (deep link ?sel=booking:<uid>, full page with &view=full) -> accept or decline a pending one, add an internal note,
12
+ * cancel (all confirmed because they email attendees). Open a recorded meeting -> summary, topics, action
13
+ * items and transcript.
14
+ * - Schedule a meeting: pick an event type, a day and time, enter the attendee, confirm, and the invite is
15
+ * emailed (external, confirmed inside BookingPage).
16
+ * - Calendar connection and working hours live in SchedulingScreen; Google or Microsoft sign-in is only possible
17
+ * in the graph8 app, so this screen links out instead of showing a dead button.
18
+ *
19
+ * SDK operations (through the composed parts): listBookings, getBooking, confirmBooking (external), cancelBooking
20
+ * (destructive), addBookingNote (external), listMeetings, getMeeting, listEventTypes, getEventType,
21
+ * getAvailableSlots, createBooking (external). Every confirmation uses ConfirmSpendDialog.
22
+ *
23
+ * Slots (all optional): renderDetailActions, extraDetailTabs, toolbarActions.
24
+ */
25
+ import { useMemo, useState, type ReactNode } from "react";
26
+ import { CalendarClock, CalendarPlus, ExternalLink } from "lucide-react";
27
+ import { Button, Skeleton } from "@graph8/sdk/ui";
28
+ import { ApiError } from "@/g8/components/api-error";
29
+ import { EmptyState } from "@/g8/components/empty-state";
30
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
31
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
32
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
33
+ import { SectionCard } from "@/g8/components/screen/section-card";
34
+ import { SplitView } from "@/g8/components/screen/split-view";
35
+ import { useUrlState } from "@/g8/hooks/use-url-state";
36
+ import { g8Get } from "@/g8/lib/g8-client";
37
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
38
+ import { useEventTypes } from "../hooks/use-event-types";
39
+ import { listOf, type Booking } from "../lib/appointments-types";
40
+ import { BookingPage } from "./booking-page";
41
+ import { MeetingDetail } from "./meeting-detail";
42
+ import { MeetingList, type MeetingSelection } from "./meeting-list";
43
+
44
+ export interface MeetingsScreenProps {
45
+ /** Where a person connects a calendar (graph8 app). */
46
+ connectCalendarUrl?: string;
47
+ /** Extra buttons in the detail header. */
48
+ renderDetailActions?: (selection: MeetingSelection) => ReactNode;
49
+ /** Extra tabs in the detail panel (for example the contact's CRM record). */
50
+ extraDetailTabs?: Array<{ id: string; label: string; render: (selection: MeetingSelection) => ReactNode }>;
51
+ /** Extra controls next to "Schedule a meeting". */
52
+ toolbarActions?: ReactNode;
53
+ }
54
+
55
+ const COUNT_CAP = 100;
56
+
57
+ function parseSelection(raw: string | null | undefined): MeetingSelection | null {
58
+ const [kind, ...rest] = (raw ?? "").split(":");
59
+ const id = rest.join(":");
60
+ return id && (kind === "booking" || kind === "meeting") ? { kind, id } : null;
61
+ }
62
+
63
+ function useBookingCounts(refreshKey: number) {
64
+ const now = useMemo(() => new Date().toISOString(), []);
65
+ const q = useG8Query(async (signal) => {
66
+ const [upcoming, pending] = await Promise.all([
67
+ g8Get("/appointments/bookings", { after_start: now, take: COUNT_CAP, skip: 0 }, { signal }),
68
+ g8Get("/appointments/bookings", { status: "pending", take: COUNT_CAP, skip: 0 }, { signal }),
69
+ ]);
70
+ return { upcoming: listOf<Booking>(upcoming, "bookings"), pending: listOf<Booking>(pending, "bookings") };
71
+ }, [now, refreshKey]);
72
+ return q;
73
+ }
74
+
75
+ function countLabel(list: Booking[]): string {
76
+ return list.length >= COUNT_CAP ? `${COUNT_CAP}+` : String(list.length);
77
+ }
78
+
79
+ function ScheduleSheet({ open, onOpenChange, onBooked }: { open: boolean; onOpenChange: (open: boolean) => void; onBooked: () => void }) {
80
+ const types = useEventTypes();
81
+ const [chosen, setChosen] = useState<number | null>(null);
82
+ const eventTypeId = chosen ?? types.eventTypes.find((t) => !t.hidden)?.id ?? types.eventTypes[0]?.id ?? null;
83
+ return (
84
+ <FormSheet open={open} onOpenChange={onOpenChange} title="Schedule a meeting" description="Pick a time that works for the host, then enter who is joining.">
85
+ {types.error ? <ApiError error={types.error} title="Could not load event types" onRetry={() => void types.refetch()} /> : types.loading && !types.data ? <Skeleton className="h-32 w-full" aria-label="Loading event types" /> : eventTypeId == null ? (
86
+ <EmptyState icon={<CalendarClock className="size-8" />} title="No event types yet" description="Create an event type in Scheduling first, then book meetings with it here." />
87
+ ) : (
88
+ <div className="space-y-4">
89
+ <div className="space-y-1">
90
+ <label htmlFor="schedule-event-type" className="text-sm font-medium">Event type</label>
91
+ <select id="schedule-event-type" className="h-9 w-full rounded-md border bg-background px-2 text-sm" value={eventTypeId} onChange={(e) => setChosen(Number(e.target.value))}>
92
+ {types.eventTypes.map((t) => <option key={t.id} value={t.id}>{t.title} ({t.length} min)</option>)}
93
+ </select>
94
+ </div>
95
+ <BookingPage eventTypeId={eventTypeId} onBooked={onBooked} />
96
+ </div>
97
+ )}
98
+ </FormSheet>
99
+ );
100
+ }
101
+
102
+ export function MeetingsScreen({ connectCalendarUrl = "https://app.graph8.com", renderDetailActions, extraDetailTabs, toolbarActions }: MeetingsScreenProps) {
103
+ const [raw, setRaw] = useUrlState("sel");
104
+ const [mode, setMode] = useUrlState("view");
105
+ const selection = parseSelection(raw);
106
+ const [version, setVersion] = useState(0);
107
+ const [scheduling, setScheduling] = useState(false);
108
+ const counts = useBookingCounts(version);
109
+ const changed = () => setVersion((v) => v + 1);
110
+ const close = () => { setRaw(null); setMode(null); };
111
+
112
+ const stats = [
113
+ { label: "Upcoming", value: counts.data ? countLabel(counts.data.upcoming) : "-", hint: "Meetings starting from now" },
114
+ { label: "Awaiting approval", value: counts.data ? countLabel(counts.data.pending) : "-", tone: counts.data && counts.data.pending.length > 0 ? ("warning" as const) : undefined, hint: "Pending bookings you can accept or decline" },
115
+ ];
116
+
117
+ const detail = selection ? (
118
+ <DetailPanel
119
+ title={selection.kind === "booking" ? "Booking" : "Recorded meeting"}
120
+ actions={renderDetailActions?.(selection)}
121
+ onClose={close}
122
+ onExpand={mode === "full" ? undefined : () => setMode("full")}
123
+ onCollapse={mode === "full" ? () => setMode(null) : undefined}
124
+ tabs={[
125
+ { id: "overview", label: "Overview", content: <MeetingDetail selection={selection} onChanged={changed} /> },
126
+ ...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(selection) })),
127
+ ]}
128
+ />
129
+ ) : null;
130
+
131
+ return (
132
+ <ScreenLayout
133
+ title="Meetings"
134
+ description="Everything booked and recorded, and the next meeting to prepare for."
135
+ icon={<CalendarClock className="size-5" />}
136
+ actions={<><Button onClick={() => setScheduling(true)}><CalendarPlus className="size-4" aria-hidden />Schedule a meeting</Button>{toolbarActions}</>}
137
+ stats={stats}
138
+ >
139
+ {counts.error ? <ApiError error={counts.error} title="Could not load meeting numbers" onRetry={() => void counts.refetch()} /> : null}
140
+ <SplitView
141
+ detailOpen={Boolean(selection)}
142
+ onCloseDetail={close}
143
+ expanded={mode === "full" && Boolean(selection)}
144
+ onExpandedChange={(full) => setMode(full ? "full" : null)}
145
+ detail={detail}
146
+ list={<MeetingList refreshKey={version} selectedId={selection?.id ?? null} onSelect={(s) => setRaw(`${s.kind}:${s.id}`)} />}
147
+ />
148
+ <SectionCard title="Calendar sync" description="Google and Microsoft sign-in happens in the graph8 app. Once connected, booked and recorded meetings appear here.">
149
+ <a className="inline-flex items-center gap-1 text-sm underline" href={connectCalendarUrl} target="_blank" rel="noreferrer">Connect a calendar in graph8<ExternalLink className="size-3.5" aria-hidden /></a>
150
+ </SectionCard>
151
+ <ScheduleSheet open={scheduling} onOpenChange={setScheduling} onBooked={changed} />
152
+ </ScreenLayout>
153
+ );
154
+ }
@@ -0,0 +1,121 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SchedulingScreen: the finished scheduling product. Your event types (meeting links), their settings, a live
5
+ * preview of each booking page, and your weekly working hours and calendar status. No props are required.
6
+ *
7
+ * It composes: EventTypeEditor (create, edit, delete), BookingPage (the preview), AvailabilityEditor (hours,
8
+ * date overrides, connected calendars) and the useEventTypes list.
9
+ *
10
+ * Flows:
11
+ * - Event types tab (full page with &view=full): see every event type with its length and status, create one, open one (deep link
12
+ * ?id=<event type id>) for Overview (every field), Bookings (who booked it, open one to accept, decline, note or
13
+ * cancel), Settings (edit, delete confirmed) and its Booking page preview. Booking from the preview is a real booking and emails the attendee (confirmed).
14
+ * - Working hours tab: edit the weekly hours and date overrides of a schedule. Connecting a Google or Microsoft
15
+ * calendar is only possible in the graph8 app, so the editor links out.
16
+ *
17
+ * SDK operations (through the composed parts): listEventTypes, getEventType, createEventType, updateEventType,
18
+ * deleteEventType (destructive, confirmed), listSchedules, getAvailability, updateAvailability, listCalendars,
19
+ * getAvailableSlots, createBooking (external, confirmed).
20
+ *
21
+ * Slots (all optional): renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions.
22
+ */
23
+ import { useState, type ReactNode } from "react";
24
+ import { CalendarClock, Plus } from "lucide-react";
25
+ import { Button, Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@graph8/sdk/ui";
26
+ import { ApiError } from "@/g8/components/api-error";
27
+ import { EmptyState } from "@/g8/components/empty-state";
28
+ import { DetailPanel } from "@/g8/components/screen/detail-panel";
29
+ import { FormSheet } from "@/g8/components/screen/form-sheet";
30
+ import { ScreenLayout } from "@/g8/components/screen/screen-layout";
31
+ import { SplitView } from "@/g8/components/screen/split-view";
32
+ import { StatusPill } from "@/g8/components/screen/status-pill";
33
+ import { useUrlState } from "@/g8/hooks/use-url-state";
34
+ import { useEventTypes } from "../hooks/use-event-types";
35
+ import type { EventType } from "../lib/appointments-types";
36
+ import { AvailabilityEditor } from "./availability-editor";
37
+ import { BookingPage } from "./booking-page";
38
+ import { EventTypeEditor } from "./event-type-editor";
39
+ import { EventTypeBookings, EventTypeOverview } from "./event-type-related";
40
+
41
+ export interface SchedulingScreenProps {
42
+ /** Where a person connects a calendar (graph8 app). */
43
+ connectCalendarUrl?: string;
44
+ renderRowActions?: (eventType: EventType) => ReactNode;
45
+ renderDetailActions?: (eventType: EventType) => ReactNode;
46
+ extraDetailTabs?: Array<{ id: string; label: string; render: (eventType: EventType) => ReactNode }>;
47
+ toolbarActions?: ReactNode;
48
+ }
49
+
50
+ const TABS = [{ id: "types", label: "Event types" }, { id: "hours", label: "Working hours" }];
51
+
52
+ export function SchedulingScreen({ connectCalendarUrl, renderRowActions, renderDetailActions, extraDetailTabs, toolbarActions }: SchedulingScreenProps) {
53
+ const [tabParam, setTab] = useUrlState("tab", "types");
54
+ const [idParam, setId] = useUrlState("id");
55
+ const [mode, setMode] = useUrlState("view");
56
+ const [creating, setCreating] = useState(false);
57
+ const types = useEventTypes();
58
+ const tab = tabParam === "hours" ? "hours" : "types";
59
+ const selected = types.eventTypes.find((t) => String(t.id) === idParam);
60
+ const selectedId = idParam && /^\d+$/.test(idParam) ? Number(idParam) : null;
61
+ const close = () => { setId(null); setMode(null); };
62
+
63
+ const detail = selectedId != null ? (
64
+ <DetailPanel
65
+ title={selected?.title ?? "Event type"}
66
+ subtitle={selected ? `${selected.length} minutes` : undefined}
67
+ badges={selected?.hidden ? <StatusPill tone="neutral">Hidden</StatusPill> : undefined}
68
+ actions={selected ? renderDetailActions?.(selected) : undefined}
69
+ onClose={close}
70
+ onExpand={mode === "full" ? undefined : () => setMode("full")}
71
+ onCollapse={mode === "full" ? () => setMode(null) : undefined}
72
+ tabs={[
73
+ { id: "overview", label: "Overview", content: <EventTypeOverview key={selectedId} eventTypeId={selectedId} /> },
74
+ { id: "bookings", label: "Bookings", content: <EventTypeBookings key={selectedId} eventTypeId={selectedId} /> },
75
+ { id: "settings", label: "Settings", content: <EventTypeEditor key={selectedId} eventTypeId={selectedId} onSaved={() => void types.refetch()} onDeleted={() => { close(); void types.refetch(); }} /> },
76
+ { id: "preview", label: "Booking page", content: <BookingPage key={selectedId} eventTypeId={selectedId} /> },
77
+ ...(extraDetailTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: selected ? t.render(selected) : null })),
78
+ ]}
79
+ />
80
+ ) : null;
81
+
82
+ const list = types.error ? <ApiError error={types.error} title="Could not load event types" onRetry={() => void types.refetch()} /> : types.loading && !types.data ? (
83
+ <div className="space-y-2" aria-busy="true" aria-label="Loading event types">{[0, 1, 2].map((i) => <Skeleton key={i} className="h-10 w-full" />)}</div>
84
+ ) : types.eventTypes.length === 0 ? (
85
+ <EmptyState icon={<CalendarClock className="size-8" />} title="No event types yet" description="An event type is a meeting link, like a 30 minute intro call. Create one and share its booking page." action={<Button onClick={() => setCreating(true)}>New event type</Button>} />
86
+ ) : (
87
+ <Table>
88
+ <TableHeader><TableRow><TableHead>Event type</TableHead><TableHead className="text-right">Length</TableHead><TableHead>Rules</TableHead>{renderRowActions ? <TableHead /> : null}</TableRow></TableHeader>
89
+ <TableBody>
90
+ {types.eventTypes.map((t) => (
91
+ <TableRow key={t.id} className="cursor-pointer" data-state={String(t.id) === idParam ? "selected" : undefined} onClick={() => setId(String(t.id))}>
92
+ <TableCell><button type="button" className="font-medium hover:underline" onClick={(e) => { e.stopPropagation(); setId(String(t.id)); }}>{t.title}</button><div className="text-xs text-muted-foreground">/{t.slug}</div></TableCell>
93
+ <TableCell className="text-right tabular-nums">{t.length} min</TableCell>
94
+ <TableCell className="space-x-1">
95
+ {t.hidden ? <StatusPill tone="neutral">Hidden</StatusPill> : <StatusPill tone="positive" dot>Bookable</StatusPill>}
96
+ {t.requires_confirmation ? <StatusPill tone="info">Needs approval</StatusPill> : null}
97
+ </TableCell>
98
+ {renderRowActions ? <TableCell onClick={(e) => e.stopPropagation()}>{renderRowActions(t)}</TableCell> : null}
99
+ </TableRow>
100
+ ))}
101
+ </TableBody>
102
+ </Table>
103
+ );
104
+
105
+ return (
106
+ <ScreenLayout
107
+ title="Scheduling"
108
+ description="Meeting links people can book, and the hours you are available."
109
+ icon={<CalendarClock className="size-5" />}
110
+ actions={<>{toolbarActions}<Button onClick={() => setCreating(true)}><Plus className="size-4" aria-hidden />New event type</Button></>}
111
+ tabs={TABS.map((t) => (t.id === "types" ? { ...t, count: types.data ? types.eventTypes.length : undefined } : t))}
112
+ activeTab={tab}
113
+ onTabChange={(id) => { setTab(id); close(); }}
114
+ >
115
+ {tab === "hours" ? <AvailabilityEditor connectCalendarUrl={connectCalendarUrl} /> : <SplitView detailOpen={selectedId != null} onCloseDetail={close} expanded={mode === "full" && selectedId != null} onExpandedChange={(full) => setMode(full ? "full" : null)} detail={detail} list={list} />}
116
+ <FormSheet open={creating} onOpenChange={setCreating} title="New event type" description="Name it, set its length and pick the working hours it uses.">
117
+ {creating ? <EventTypeEditor onSaved={(et) => { setCreating(false); void types.refetch(); setTab("types"); setId(String(et.id)); }} /> : null}
118
+ </FormSheet>
119
+ </ScreenLayout>
120
+ );
121
+ }
@@ -0,0 +1,17 @@
1
+ "use client";
2
+
3
+ /**
4
+ * useEventTypes: the org's bookable event types (meeting links), newest data first.
5
+ *
6
+ * SDK operations: GET /event-types?include_hidden= (gtmLaunchHelpers.listEventTypes), read tier, scope campaigns:read.
7
+ * This is the list operation the editor and booking screens were missing: ids no longer have to be remembered.
8
+ * Hidden event types are included so a rep can still edit them.
9
+ */
10
+ import { g8Get } from "@/g8/lib/g8-client";
11
+ import { useG8Query } from "@/g8/hooks/use-g8-query";
12
+ import { listOf, type EventType } from "../lib/appointments-types";
13
+
14
+ export function useEventTypes() {
15
+ const query = useG8Query(async (signal) => listOf<EventType>(await g8Get("/event-types", { include_hidden: true }, { signal }), "event_types", "items"), []);
16
+ return { ...query, eventTypes: query.data ?? [] };
17
+ }
@@ -94,6 +94,13 @@ export interface StackNavItem {
94
94
  component: string;
95
95
  /** JSON props passed to the component on its route (for a component that needs input to render anything). */
96
96
  props?: Record<string, unknown>;
97
+ /** lucide-react export name shown beside the label in the sidebar (for example `Users`). */
98
+ icon?: string;
99
+ /** Sidebar section label. */
100
+ group?: string;
101
+ description?: string;
102
+ /** Hand-written page template (`pages/*.tsx.tmpl`, relative to `stacks/<stack-id>/`) used instead of the generic route page. */
103
+ page?: string;
97
104
  }
98
105
 
99
106
  export interface StackManifest {
@@ -106,4 +113,6 @@ export interface StackManifest {
106
113
  /** Install order: each block's `dependsOn` come earlier. */
107
114
  blocks: string[];
108
115
  nav: StackNavItem[];
116
+ /** A nav path that `/` redirects to. */
117
+ home?: string;
109
118
  }