@odla-ai/chapter 0.18.0 → 0.20.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/README.md CHANGED
@@ -13,10 +13,9 @@ npm i @odla-ai/chapter
13
13
  > **Agentic experiment.** Built and maintained by AI agents from bounded runbooks
14
14
  > with human review. Review the documented guarantees before relying on it.
15
15
 
16
- > **Pre-1.0.** The member surface and the operational worker (auth, join, Stripe,
17
- > booking, email, CRM projection, admin operational routes) ship. The admin *UI*
18
- > is a growing section catalog: `peopleSection` and the availability editor today,
19
- > more sections landing per release.
16
+ > **Pre-1.0.** The member surface, operational worker, standard admin console,
17
+ > and leader follower record delivery ship. APIs may still tighten before 1.0;
18
+ > pin the package and run schema/route contract tests in every site.
20
19
 
21
20
  ## The shape
22
21
 
@@ -24,9 +23,12 @@ npm i @odla-ai/chapter
24
23
  a resolved engine. `mode: "chapter"` is the full public member site; `mode:
25
24
  "hub"` is admin-only and CRM-focused. The mode gates the member/join/payment
26
25
  route surface; everything else (auth, CRM, chrome, provisioning) is shared.
26
+ `chapter.brand` and `chapter.network` are resolved from this same config, so
27
+ the browser UI does not need a second identity or follower registry.
27
28
  - **The worker is the package.** `chapterWorker({ chapter })` is the whole
28
29
  Cloudflare `ExportedHandler`. It serves `/api/health`, `/api/config`,
29
- `/api/me`, `/api/crm/*`, `/api/network/shared`; chapter mode adds the public
30
+ `/api/me`, `/api/crm/*`, `/api/network/shared`; configured leaders also get
31
+ `/api/admin/network/{targets,push}`. Chapter mode adds the public
30
32
  member surface (`/api/join-config`, `/api/applications`,
31
33
  `/api/schedule/{slots,book}`, `/api/payments/subscription`,
32
34
  `/api/webhooks/stripe`) and the admin operational routes (`/api/admin/*`), then
@@ -55,6 +57,14 @@ npm i @odla-ai/chapter
55
57
  the crm namespaces + the chapter namespaces (`applications`, `groups`,
56
58
  `meetings`, `emailLog`, plus the auth tables) + a guarded group-row seed. Drop
57
59
  it in `odla.config.mjs` `integrations: [...]`.
60
+ - **Network movement is explicit.** A leader lists follower origins and
61
+ per-target field allowlists in `network.targets`; secret *names* live in
62
+ config and secret *values* stay in the tenant vault. The standard record panel
63
+ then shows “Share with …” actions for compatible people, businesses, deals,
64
+ or other CRM types. The receiver validates the payload against its own CRM
65
+ config and stores a provenance tag, so retries update the same local record.
66
+ Shared identity/business fields move; each site's pipeline, account, and
67
+ billing state remain authoritative locally.
58
68
  - **The UI kit, adoptable in pieces.** Three entries, split on the dependency
59
69
  boundary so you never pay for what you don't use:
60
70
  - `@odla-ai/chapter/ui/member` — **react-only**: `SlotPicker`, the date/timezone
@@ -64,12 +74,34 @@ npm i @odla-ai/chapter
64
74
  presentational component without the Clerk browser SDK. (A bundle-graph test
65
75
  enforces this.)
66
76
  - `@odla-ai/chapter/ui/admin` — the Clerk-gated console `ChapterAdmin` + the
67
- section catalog (`peopleSection`). This one does need auth-clerk + crm/ui.
77
+ section catalog. `<ChapterAdmin chapter={chapter}/>` derives the brand and
78
+ standard sections automatically: every configured CRM collection plus the
79
+ chapter operational surfaces. Pass `sections` only for a deliberate custom
80
+ console. This one needs auth-clerk + crm/ui.
68
81
  - `@odla-ai/chapter/ui` — the full barrel, for back-compat.
69
82
 
70
83
  Authored against React, rendered as Preact via `preact/compat` in the reference
71
84
  sites. Brand tokens (`brandTokens`/`<BrandStyle>`) re-skin all of it from
72
- `brand`.
85
+ `brand` (now light **and** dark, via `brand.palette` + `brand.paletteDark`).
86
+
87
+ ### Theme tokens (or the console renders broken)
88
+
89
+ The UI reads its colors from `--ui-*` design tokens whose **values come from a
90
+ theme layer, not `@odla-ai/ui/index.css`.** Use the one-import application sheet;
91
+ it expands to the token layer, Salt's familiar editorial component treatment,
92
+ then the shared component sheet in the required order:
93
+
94
+ ```ts
95
+ import "@odla-ai/ui/themes/salt/app.css";
96
+ ```
97
+
98
+ `ChapterAdmin` guards against this at runtime: if a `.panel` has no background
99
+ (the theme layer is missing), it renders a **loud red banner** at the top of the
100
+ console instead of failing silently. To brand it, pass `brand.palette` /
101
+ `brand.paletteDark` — `brandTokens(brand)` emits both a `:root` block and a
102
+ `[data-theme="dark"]` / `prefers-color-scheme: dark` block, so your accent/colors
103
+ override the shared visual grammar in both modes. When the `chapter` prop is
104
+ present, `ChapterAdmin` installs those brand tokens itself.
73
105
 
74
106
  ## Quick start
75
107
 
@@ -81,7 +113,13 @@ export const chapter = defineChapter({
81
113
  id: "example-chapter",
82
114
  name: "Example Chapter",
83
115
  mode: "chapter",
84
- brand: { palette: { "--ui-accent": "#2f6f4f" }, fonts: { display: "GT Sectra" } },
116
+ brand: {
117
+ badge: "EX",
118
+ wordmark: "Example Chapter",
119
+ tagline: "Capital and craft for durable local businesses.",
120
+ palette: { "--ui-accent": "#2f6f4f" },
121
+ fonts: { display: "GT Sectra" },
122
+ },
85
123
  prices: { standardCents: 100000, foundingDiscountCents: 10000 },
86
124
  emails: { notificationEmail: "hello@example.com" },
87
125
  });
@@ -94,6 +132,16 @@ import { chapter } from "./chapter.config.mjs";
94
132
  export default chapterWorker({ chapter });
95
133
  ```
96
134
 
135
+ ```tsx
136
+ // src/app/admin.tsx — brand + familiar sections come from the same config.
137
+ import "@odla-ai/ui/themes/salt/app.css";
138
+ import { render } from "preact";
139
+ import { ChapterAdmin } from "@odla-ai/chapter/ui/admin";
140
+ import { chapter } from "../chapter.config.mjs";
141
+
142
+ render(<ChapterAdmin chapter={chapter} />, document.getElementById("admin-root"));
143
+ ```
144
+
97
145
  ```ts
98
146
  // odla.config.mjs
99
147
  import { createChapterIntegration } from "@odla-ai/chapter";
@@ -105,6 +153,48 @@ export default {
105
153
  };
106
154
  ```
107
155
 
156
+ That is the reusable application shell. The follower's public pages remain
157
+ site-owned: start from the Silver & Salt content rhythm and `@odla-ai/ui`
158
+ marketing components, then change the wordmark, palette, copy, imagery, and
159
+ investment thesis. Do not fork auth, admin routing, CRM, payment, booking, or
160
+ account logic to achieve a different brand.
161
+
162
+ ### Leader → follower delivery
163
+
164
+ The leader declares where records may go and exactly which fields each follower
165
+ receives:
166
+
167
+ ```ts
168
+ export const chapter = defineChapter({
169
+ id: "example-hub",
170
+ name: "Example Hub",
171
+ mode: "hub",
172
+ network: {
173
+ targets: [{
174
+ id: "example-chapter",
175
+ name: "Example Chapter",
176
+ url: "https://chapter.example.com",
177
+ fields: {
178
+ person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
179
+ company: ["name", "domain", "industry", "location", "linkedin"],
180
+ },
181
+ }],
182
+ },
183
+ });
184
+ ```
185
+
186
+ Vault the same random value as `network_share_secret` in the follower and as
187
+ `network_share_example_chapter` in the leader (or set a different
188
+ `secretName`). No secret is placed in source, browser data, or provisioning
189
+ config. The standard collection drawer discovers the target and calls the
190
+ admin-gated push route. A follower must declare the receiving CRM type/fields;
191
+ otherwise it rejects the record cleanly instead of dropping fields.
192
+
193
+ Section navigation defaults to `/admin/?tab=people`, not
194
+ `/admin/people`. Query routing deliberately works in both leader-style SPAs and
195
+ Silver & Salt-style static subdirectories. Legacy path links still open, and
196
+ `routing="path"` remains available only for hosts with an explicit SPA fallback.
197
+
108
198
  ## Adopting into an existing site
109
199
 
110
200
  A real conversion (the site this was extracted from) went from a 2,094-line
@@ -227,9 +317,9 @@ These bite silently — a smoke test won't catch them:
227
317
  `@odla-ai/auth-clerk/invitations` when you want to send your own branded
228
318
  invitation mail. Importing the full `@odla-ai/chapter/ui` barrel pulls the admin
229
319
  half, so prefer the narrower entry.
230
- - **Known-good version set** (installs clean, no flags): `@odla-ai/chapter` 0.10.0,
231
- `@odla-ai/ui` 0.9.0, `@odla-ai/crm` 0.1.4, `@odla-ai/db` 0.6.6,
232
- `@odla-ai/calendar` 0.2.0, `@odla-ai/email` 0.3.1, CLI 0.16.7, plus `jose` and
320
+ - **Known-good version set** (installs clean, no flags): `@odla-ai/chapter` 0.20.0,
321
+ `@odla-ai/ui` 0.10.0, `@odla-ai/crm` 0.1.4, `@odla-ai/db` 0.6.6,
322
+ `@odla-ai/calendar` 0.2.0, `@odla-ai/email` 0.3.1, CLI 0.17.1, plus `jose` and
233
323
  react (or `preact` + `preact/compat`). chapter is the package that pulls the
234
324
  others together, so this is the line to check when an install complains.
235
325
  - **`--legacy-peer-deps` is a diagnostic, not a setting.** It suppresses exactly
@@ -487,37 +487,6 @@ function JoinIsland(props) {
487
487
  ] });
488
488
  }
489
489
 
490
- // src/brand.ts
491
- function paletteVar(key) {
492
- return key.startsWith("--") ? key : `--${key}`;
493
- }
494
- function cleanValue(value) {
495
- return value.replace(/[<>{};]/g, "").trim();
496
- }
497
- function brandTokens(brand) {
498
- if (!brand) return "";
499
- const decls = [];
500
- for (const [key, value] of Object.entries(brand.palette ?? {})) {
501
- if (typeof value === "string" && value.trim()) decls.push(`${paletteVar(key)}: ${cleanValue(value)};`);
502
- }
503
- const fonts = brand.fonts;
504
- if (fonts?.display) decls.push(`--ui-font-display: ${cleanValue(fonts.display)};`);
505
- if (fonts?.body) decls.push(`--ui-font-sans: ${cleanValue(fonts.body)};`);
506
- if (fonts?.numeral) decls.push(`--ui-font-numeral: ${cleanValue(fonts.numeral)};`);
507
- return decls.length ? `:root {
508
- ${decls.join("\n ")}
509
- }
510
- ` : "";
511
- }
512
-
513
- // src/ui/brand-style.tsx
514
- import { jsx as jsx6 } from "react/jsx-runtime";
515
- function BrandStyle(props) {
516
- const css = brandTokens(props.brand);
517
- if (!css) return null;
518
- return /* @__PURE__ */ jsx6("style", { children: css });
519
- }
520
-
521
490
  export {
522
491
  tzShort,
523
492
  dayKey,
@@ -531,7 +500,6 @@ export {
531
500
  Rescheduler,
532
501
  MembersArea,
533
502
  PaymentStep,
534
- JoinIsland,
535
- BrandStyle
503
+ JoinIsland
536
504
  };
537
- //# sourceMappingURL=chunk-ZCZK6QLC.js.map
505
+ //# sourceMappingURL=chunk-LME2IHY5.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/ui/slot-picker.tsx","../src/ui/datetime.ts","../src/ui/members.tsx","../src/ui/reschedule.tsx","../src/ui/join.tsx","../src/ui/payment-step.tsx"],"sourcesContent":["// SlotPicker — a pure presentational day-chips + time-grid picker, shared by the\n// join booking step and the member-area reschedule. Ported from the proven\n// Silver & Salt Preact island to React; behaviour and DOM shape are unchanged.\n//\n// It holds only the active-day UI state; slot data, selection, and booking are\n// the caller's (the picker is `onPick`-driven). `classes` lets the composing\n// island supply its own CSS contract (join styles `slot-*`, reschedule\n// `msched-*`); the defaults work standalone against those class names.\nimport { useMemo, useState } from \"react\";\nimport { dayLabel, groupSlotsByDay, timeLabel } from \"./datetime.js\";\nimport type { Slot } from \"./datetime.js\";\n\n/** CSS class names for the four picker parts — overridable so one component\n * serves several differently-styled surfaces. */\nexport interface SlotPickerClasses {\n days: string;\n day: string;\n times: string;\n time: string;\n}\n\nconst DEFAULT_CLASSES: SlotPickerClasses = {\n days: \"slot-days\",\n day: \"slot-day\",\n times: \"slot-grid\",\n time: \"slot-time\",\n};\n\n/** Props for {@link SlotPicker}. Generic over the slot type so a caller's richer\n * slot object survives through `onPick`. */\nexport interface SlotPickerProps<T extends Slot = Slot> {\n /** Bookable slots, ideally already chronological. */\n slots: readonly T[];\n /** The group's scheduling timezone; all labels render in it. */\n timezone: string;\n /** The currently-selected slot's start, for the pressed state. */\n selectedStartAt?: number;\n /** Called with the full slot object when a time is chosen. */\n onPick: (slot: T) => void;\n /** Called when the active day changes (callers clear their selection). */\n onDayChange?: () => void;\n classes?: SlotPickerClasses;\n}\n\n/** A day-chips + time-grid slot picker. Presentational and `onPick`-driven: it\n * owns only the active-day selection; the caller owns slot data, the chosen\n * slot, and booking. Shared by the join booking step and the member reschedule. */\nexport function SlotPicker<T extends Slot = Slot>(props: SlotPickerProps<T>) {\n const { slots, timezone, selectedStartAt, onPick, onDayChange, classes = DEFAULT_CLASSES } = props;\n const byDay = useMemo(() => groupSlotsByDay(slots, timezone), [slots, timezone]);\n const dayKeys = [...byDay.keys()];\n const [activeDay, setActiveDay] = useState<string | undefined>(dayKeys[0]);\n\n // Keep the active day valid if the slot set changed under us (e.g. a reload\n // after a 409): fall back to the first available day.\n const day = activeDay !== undefined && byDay.has(activeDay) ? activeDay : dayKeys[0];\n const times = (day !== undefined ? byDay.get(day) : undefined) ?? [];\n\n return (\n <>\n <div className={classes.days}>\n {dayKeys.map((key) => {\n const first = byDay.get(key)?.[0];\n return (\n <button\n key={key}\n type=\"button\"\n className={classes.day}\n aria-pressed={key === day}\n onClick={() => {\n setActiveDay(key);\n onDayChange?.();\n }}\n >\n {first ? dayLabel(first.startAt, timezone) : key}\n </button>\n );\n })}\n </div>\n <div className={classes.times}>\n {times.map((s) => (\n <button\n key={s.startAt}\n type=\"button\"\n className={classes.time}\n aria-pressed={selectedStartAt === s.startAt}\n onClick={() => onPick(s)}\n >\n {timeLabel(s.startAt, timezone)}\n </button>\n ))}\n </div>\n </>\n );\n}\n","// Date/timezone formatting for the chapter member islands (join booking, member\n// area). Ported from the proven Silver & Salt helpers. Every time renders in the\n// group's SCHEDULING timezone with an explicit abbreviation — never the viewer's\n// unlabeled local zone — so an applicant always sees the time the chapter meant.\n// Pure and framework-free, so it is unit-testable without a DOM.\n\n/** A bookable slot: a start instant in epoch milliseconds. */\nexport interface Slot {\n startAt: number;\n}\n\n/** The timezone's short abbreviation (e.g. \"PST\"); falls back to the id if the\n * runtime can't resolve it. */\nexport function tzShort(tz: string): string {\n try {\n const parts = new Intl.DateTimeFormat(undefined, { timeZone: tz, timeZoneName: \"short\" }).formatToParts(\n new Date(),\n );\n return parts.find((p) => p.type === \"timeZoneName\")?.value ?? tz;\n } catch {\n return tz;\n }\n}\n\n/** DST-safe day bucket key (ISO date in the target zone). Groups slots by the\n * calendar day a viewer in `tz` would see, not by UTC midnight. */\nexport function dayKey(ms: number, tz: string): string {\n return new Date(ms).toLocaleDateString(\"en-CA\", { timeZone: tz });\n}\n\n/** Short day label for a day chip, e.g. \"Mon, Jun 3\". */\nexport function dayLabel(ms: number, tz: string): string {\n return new Date(ms).toLocaleDateString(undefined, { timeZone: tz, weekday: \"short\", month: \"short\", day: \"numeric\" });\n}\n\n/** Time-of-day label for a slot button, e.g. \"2:30 PM\". */\nexport function timeLabel(ms: number, tz: string): string {\n return new Date(ms).toLocaleTimeString(undefined, { timeZone: tz, hour: \"numeric\", minute: \"2-digit\" });\n}\n\n/** Full, human confirmation label, e.g. \"Monday, June 3, 2:30 PM PST\". */\nexport function fullLabel(ms: number, tz: string): string {\n return new Date(ms).toLocaleString(undefined, {\n timeZone: tz,\n weekday: \"long\",\n month: \"long\",\n day: \"numeric\",\n hour: \"numeric\",\n minute: \"2-digit\",\n timeZoneName: \"short\",\n });\n}\n\n/** Currency label from integer cents, e.g. 100000 → \"$1,000\". */\nexport function fmtMoney(cents: number): string {\n return \"$\" + Math.round(cents / 100).toLocaleString();\n}\n\n/** Short date label, e.g. \"Jun 3, 2026\" (used for renewal/refund copy). */\nexport function fmtDate(ms: number): string {\n return new Date(ms).toLocaleDateString(undefined, { month: \"short\", day: \"numeric\", year: \"numeric\" });\n}\n\n/** Bucket slots into an insertion-ordered `Map<dayKey, Slot[]>` for the picker.\n * Input order is preserved within each day, so a server that returns slots\n * chronologically yields chronological chips + times. */\nexport function groupSlotsByDay<T extends Slot>(slots: readonly T[], tz: string): Map<string, T[]> {\n const byDay = new Map<string, T[]>();\n for (const s of slots) {\n const k = dayKey(s.startAt, tz);\n const bucket = byDay.get(k);\n if (bucket) bucket.push(s);\n else byDay.set(k, [s]);\n }\n return byDay;\n}\n","// The member area island. Reads GET /api/me (role + the member's own\n// application, folded server-side) and renders the account header plus, for a\n// provisional member, their application card with book/reschedule; a full member\n// sees the caller-supplied `memberContent`. Ported from the S&S members island to\n// React, driven by the injected `api` (no Clerk SDK dependency).\nimport { useEffect, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { Rescheduler } from \"./reschedule.js\";\nimport { fmtDate, fullLabel } from \"./datetime.js\";\nimport type { ApiFn } from \"./api.js\";\nimport type { MemberApplication } from \"../session.js\";\n\ninterface Me {\n email: string | null;\n role: string;\n application: MemberApplication | null;\n}\n\n/** Props for {@link MembersArea}. */\nexport interface MembersAreaProps {\n api: ApiFn;\n /** Sign the member out (the host owns Clerk's signOut). */\n signOut: () => void;\n /** Where the \"Admin console\" link points for admins. Default \"/admin/\". */\n adminHref?: string;\n /** Where an accountless member goes to apply. Default \"/join.html\". */\n applyHref?: string;\n /** What a full (non-provisional) member sees below the account header. */\n memberContent?: ReactNode;\n}\n\nfunction card(kicker: string, body: ReactNode): ReactNode {\n return (\n <div className=\"card\">\n <div className=\"card-label\">Your Application</div>\n <div className=\"meeting-block\">\n <div className=\"meeting-kicker\">{kicker}</div>\n {body}\n </div>\n </div>\n );\n}\n\nfunction ProvisionalCard(props: {\n api: ApiFn;\n application: MemberApplication | null;\n applyHref: string;\n onReschedule: () => void | Promise<void>;\n}): ReactNode {\n const { api, application, applyHref, onReschedule } = props;\n if (!application) {\n return card(\n \"One step remains\",\n <>\n <div className=\"meeting-note\">Your account is ready, and the application that completes it takes a few minutes.</div>\n <a className=\"apply-link\" href={applyHref}>\n Apply for membership\n </a>\n </>,\n );\n }\n if (application.status === \"refunded\") {\n return card(\"Membership refunded\", <div className=\"meeting-note\">Your fee has been refunded in full and your membership is canceled.</div>);\n }\n const membership = application.paid ? (\n <div className=\"meeting-note\">Your membership is active{application.renewalAt ? ` and renews ${fmtDate(application.renewalAt)}` : \"\"}.</div>\n ) : null;\n if (application.meetingAt) {\n return card(\n \"Your introduction call\",\n <>\n <div className=\"meeting-date\">{fullLabel(application.meetingAt, application.timezone)}</div>\n <div className=\"meeting-note\">A calendar invitation with the video call link is in your email.</div>\n {application.meetUrl ? (\n <div className=\"meeting-note\">\n <a href={application.meetUrl} target=\"_blank\" rel=\"noopener\">\n Join the video call\n </a>\n </div>\n ) : null}\n <Rescheduler api={api} applicationId={application.id} timezone={application.timezone} onRescheduled={onReschedule} />\n {membership}\n </>,\n );\n }\n return card(\n \"Book your introduction call\",\n <>\n <div className=\"meeting-note\">Your application is in. Choose a time below, and a calendar invitation will reach your email.</div>\n <Rescheduler api={api} applicationId={application.id} timezone={application.timezone} onRescheduled={onReschedule} label=\"Choose a time\" />\n {membership}\n </>,\n );\n}\n\n/** The signed-in member area. Loads /api/me on mount and renders the account\n * header plus the provisional application card or the full-member content. */\nexport function MembersArea(props: MembersAreaProps) {\n const { api, signOut, adminHref = \"/admin/\", applyHref = \"/join.html\", memberContent } = props;\n const [me, setMe] = useState<Me | null>(null);\n const [error, setError] = useState(false);\n\n const reload = async () => {\n try {\n setMe(await api<Me>(\"/api/me\"));\n } catch {\n setError(true);\n }\n };\n useEffect(() => {\n void reload();\n }, []);\n\n if (error) return <p className=\"meeting-note\">Sign in is briefly unavailable. Please refresh.</p>;\n if (!me) return <p className=\"meeting-note\">Loading…</p>;\n const role = me.role || \"provisional\";\n\n return (\n <>\n <div className=\"card\">\n <div className=\"member-row\">\n <span className=\"member-email\">{me.email}</span>\n <span className={\"role-badge \" + role}>{role}</span>\n </div>\n <div className=\"account-actions\">\n <button className=\"btn secondary mini\" onClick={signOut}>\n Sign out\n </button>\n {role === \"admin\" ? (\n <a className=\"admin-console-link\" href={adminHref}>\n Admin console\n </a>\n ) : null}\n </div>\n </div>\n {role === \"provisional\" ? (\n <ProvisionalCard api={api} application={me.application} applyHref={applyHref} onReschedule={reload} />\n ) : (\n (memberContent ?? (\n <div className=\"card\">\n <div className=\"card-label\">Welcome back.</div>\n </div>\n ))\n )}\n </>\n );\n}\n","// In-place reschedule: open a SlotPicker, rebook via /api/schedule/book (the same\n// capability the join flow uses; the application id is the credential). Ported\n// from the S&S member-area Rescheduler to React, driven by the injected `api`.\nimport { useState } from \"react\";\nimport { SlotPicker } from \"./slot-picker.js\";\nimport type { Slot } from \"./datetime.js\";\nimport type { ApiFn } from \"./api.js\";\n\ninterface SlotsResponse {\n schedulingReady?: boolean;\n slots?: Slot[];\n timezone?: string;\n}\n\n/** Props for {@link Rescheduler}. */\nexport interface RescheduleProps {\n api: ApiFn;\n applicationId: string;\n timezone: string;\n /** Called after a successful (re)booking so the caller can refresh /api/me. */\n onRescheduled: () => void | Promise<void>;\n /** Link text to open the picker (e.g. \"Choose a time\" for a first booking). */\n label?: string;\n}\n\n/** A collapsed \"change your time\" link that expands into a {@link SlotPicker} and\n * rebooks on pick. Degrades to a message when scheduling is unavailable. */\nexport function Rescheduler(props: RescheduleProps) {\n const { api, applicationId, timezone, onRescheduled, label = \"Change your time\" } = props;\n const [open, setOpen] = useState(false);\n const [slots, setSlots] = useState<Slot[] | null>(null);\n const [tz, setTz] = useState(timezone);\n const [busy, setBusy] = useState(false);\n const [msg, setMsg] = useState<string | null>(null);\n\n const start = async () => {\n setOpen(true);\n setSlots(null);\n setMsg(null);\n try {\n const r = await api<SlotsResponse>(\"/api/schedule/slots\");\n if (r.schedulingReady === false) {\n setSlots([]);\n setMsg(\"Scheduling is briefly unavailable. We'll reach out by email to arrange your call.\");\n return;\n }\n setSlots(r.slots ?? []);\n if (r.timezone) setTz(r.timezone);\n } catch {\n setSlots([]);\n setMsg(\"Times are briefly unavailable. Please try again.\");\n }\n };\n\n const pick = async (slot: Slot) => {\n setBusy(true);\n setMsg(null);\n try {\n await api(\"/api/schedule/book\", { method: \"POST\", body: JSON.stringify({ applicationId, startAt: slot.startAt }) });\n setOpen(false);\n await onRescheduled();\n } catch (e) {\n setMsg(e instanceof Error ? e.message : \"That time is no longer available. Please pick another.\");\n } finally {\n setBusy(false);\n }\n };\n\n if (!open) {\n return (\n <p className=\"meeting-note\">\n <a\n href=\"#\"\n onClick={(e) => {\n e.preventDefault();\n void start();\n }}\n >\n {label}\n </a>\n </p>\n );\n }\n return (\n <div className=\"msched\">\n {slots === null ? (\n <p className=\"meeting-note\">Loading available times…</p>\n ) : slots.length === 0 ? (\n <p className=\"meeting-note\">{msg ?? \"No open times right now. Please check back soon.\"}</p>\n ) : (\n <SlotPicker\n slots={slots}\n timezone={tz}\n classes={{ days: \"msched-days\", day: \"msched-day\", times: \"msched-times\", time: \"msched-time\" }}\n onPick={(s) => void pick(s)}\n />\n )}\n {busy ? <p className=\"meeting-note\">Rescheduling…</p> : null}\n {msg && slots && slots.length > 0 ? <p className=\"meeting-note error\">{msg}</p> : null}\n <p className=\"meeting-note\">\n <a\n href=\"#\"\n onClick={(e) => {\n e.preventDefault();\n setOpen(false);\n }}\n >\n Keep my current time\n </a>\n </p>\n </div>\n );\n}\n","// The join island — the signup flow orchestrator. The SITE provides the form\n// fields (as children); this owns the flow: submit → /api/applications →\n// (paymentsReady) the Stripe PaymentStep → (schedulingReady) the SlotPicker\n// booking → confirmation. Faithful to how S&S wires join.html, but the flow +\n// payment + booking are packaged instead of hand-rolled per site.\nimport { useEffect, useState } from \"react\";\nimport type { FormEvent, ReactNode } from \"react\";\nimport { SlotPicker } from \"./slot-picker.js\";\nimport { PaymentStep } from \"./payment-step.js\";\nimport { fullLabel } from \"./datetime.js\";\nimport type { Slot } from \"./datetime.js\";\n\n/** The public join config (the shape `GET /api/join-config` returns). */\nexport interface JoinConfig {\n id: string;\n name: string;\n paymentsReady: boolean;\n refundPolicyText?: string;\n}\n\n/** Props for {@link JoinIsland}. */\nexport interface JoinIslandProps {\n config: JoinConfig;\n /** The site's application form fields — inputs with `name` attributes; their\n * values are collected via FormData and posted to /api/applications. */\n children: ReactNode;\n /** Where the confirmation links after booking. Default \"/members/\". */\n membersHref?: string;\n}\n\ntype Step = \"form\" | \"pay\" | \"book\" | \"done\";\n\ninterface SlotsResponse {\n schedulingReady?: boolean;\n slots?: Slot[];\n timezone?: string;\n}\n\nfunction JoinBooking(props: { applicationId: string; onBooked: (b: { startAt: number; timezone: string }) => void }): ReactNode {\n const { applicationId, onBooked } = props;\n const [state, setState] = useState<{ slots: Slot[]; timezone: string } | null>(null);\n const [msg, setMsg] = useState<string | null>(null);\n const [busy, setBusy] = useState(false);\n const [selected, setSelected] = useState<Slot | null>(null);\n\n const load = async () => {\n setMsg(null);\n try {\n const res = await fetch(\"/api/schedule/slots\");\n const data = (await res.json()) as SlotsResponse;\n if (data.schedulingReady === false || !data.slots?.length) {\n setState(null);\n setMsg(\"Scheduling is briefly unavailable — we'll reach out by email to arrange your call.\");\n return;\n }\n setState({ slots: data.slots, timezone: data.timezone ?? \"UTC\" });\n } catch {\n setMsg(\"Times are briefly unavailable. Please try again.\");\n }\n };\n useEffect(() => {\n void load();\n }, []);\n\n const book = async () => {\n if (!selected) return;\n setBusy(true);\n setMsg(null);\n try {\n const res = await fetch(\"/api/schedule/book\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ applicationId, startAt: selected.startAt }),\n });\n const data = (await res.json()) as { startAt?: number; error?: string; code?: string };\n if (res.status === 409 && data.code === \"calendar_slot_unavailable\") {\n setSelected(null);\n setMsg(\"That time was just taken. Here are the current openings.\");\n await load();\n return;\n }\n if (!res.ok) throw new Error(data.error ?? \"The booking could not be completed.\");\n onBooked({ startAt: data.startAt ?? selected.startAt, timezone: state?.timezone ?? \"UTC\" });\n } catch (e) {\n setMsg(e instanceof Error ? e.message : \"The booking could not be completed.\");\n } finally {\n setBusy(false);\n }\n };\n\n if (!state) return <p className=\"slots-status\">{msg ?? \"Loading available times…\"}</p>;\n return (\n <div className=\"join-book\">\n <SlotPicker\n slots={state.slots}\n timezone={state.timezone}\n selectedStartAt={selected?.startAt}\n onPick={setSelected}\n onDayChange={() => setSelected(null)}\n />\n <div className=\"slot-confirm\" hidden={!selected}>\n <button className=\"submit-btn\" disabled={busy} onClick={() => void book()}>\n {busy ? \"Booking…\" : \"Book this time\"}\n </button>\n {msg ? <p className=\"step2-note\">{msg}</p> : null}\n </div>\n </div>\n );\n}\n\n/** The signup island. Renders the site's form, then drives payment (when the\n * chapter charges) and booking (when a calendar is connected) to confirmation. */\nexport function JoinIsland(props: JoinIslandProps) {\n const { config, children, membersHref = \"/members/\" } = props;\n const [step, setStep] = useState<Step>(\"form\");\n const [applicationId, setApplicationId] = useState<string | null>(null);\n const [error, setError] = useState<string | null>(null);\n const [submitting, setSubmitting] = useState(false);\n const [booked, setBooked] = useState<{ startAt: number; timezone: string } | null>(null);\n\n const submit = async (e: FormEvent<HTMLFormElement>) => {\n e.preventDefault();\n setSubmitting(true);\n setError(null);\n try {\n const fields: Record<string, unknown> = {};\n for (const [k, v] of new FormData(e.currentTarget).entries()) fields[k] = v;\n fields.submissionId = crypto.randomUUID();\n const res = await fetch(\"/api/applications\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(fields),\n });\n const data = (await res.json()) as { id?: string; error?: string };\n if (!res.ok || !data.id) throw new Error(data.error ?? \"Your application could not be submitted.\");\n setApplicationId(data.id);\n setStep(config.paymentsReady ? \"pay\" : \"book\");\n } catch (err) {\n setError(err instanceof Error ? err.message : \"Something went wrong. Please try again.\");\n } finally {\n setSubmitting(false);\n }\n };\n\n if (step === \"done\" && booked) {\n return (\n <div className=\"join-done card\">\n <div className=\"card-label\">You're booked</div>\n <p className=\"meeting-date\">{fullLabel(booked.startAt, booked.timezone)}</p>\n <p className=\"meeting-note\">A calendar invitation with the video call link is on its way to your email.</p>\n <a className=\"apply-link\" href={membersHref}>\n Go to your member area\n </a>\n </div>\n );\n }\n if (step === \"book\" && applicationId) {\n return (\n <JoinBooking\n applicationId={applicationId}\n onBooked={(b) => {\n setBooked(b);\n setStep(\"done\");\n }}\n />\n );\n }\n if (step === \"pay\" && applicationId) {\n return <PaymentStep applicationId={applicationId} refundPolicyText={config.refundPolicyText ?? \"\"} onPaid={() => setStep(\"book\")} />;\n }\n return (\n <form className=\"join-form\" onSubmit={(e) => void submit(e)}>\n {children}\n {error ? <p className=\"join-error\">{error}</p> : null}\n <button className=\"submit-btn\" type=\"submit\" disabled={submitting}>\n {submitting ? \"Submitting…\" : \"Submit application\"}\n </button>\n </form>\n );\n}\n","// The join flow's payment step. Ports the S&S vanilla card entry into the island:\n// dynamically loads js.stripe.com (NO @stripe/react-stripe-js dep), creates a\n// subscription server-side, and mounts a Stripe Payment Element. The refund-policy\n// checkbox is the gate — checking it starts the (money-creating) subscription\n// call, exactly as S&S does. Client success is advisory only; the webhook is the\n// authoritative writer of paid state (see payments.ts).\nimport { useRef, useState } from \"react\";\n\n// Minimal shapes for the dynamically-loaded js.stripe.com global.\ninterface StripeElementsApi {\n create(type: string): { mount(target: HTMLElement): void };\n}\ninterface StripeApi {\n elements(opts: { clientSecret: string }): StripeElementsApi;\n confirmPayment(opts: {\n elements: StripeElementsApi;\n confirmParams?: { return_url?: string };\n redirect?: \"if_required\";\n }): Promise<{ error?: { message?: string }; paymentIntent?: { status?: string } }>;\n}\ntype StripeFactory = (publishableKey: string) => StripeApi;\n\nlet loader: Promise<StripeFactory> | null = null;\n\n/** Load js.stripe.com once and resolve the `Stripe` global (browser only). */\nfunction loadStripe(): Promise<StripeFactory> {\n const existing = (globalThis as { Stripe?: StripeFactory }).Stripe;\n if (existing) return Promise.resolve(existing);\n if (!loader) {\n loader = new Promise<StripeFactory>((resolve, reject) => {\n const s = document.createElement(\"script\");\n s.src = \"https://js.stripe.com/v3/\";\n s.onload = () => {\n const fn = (globalThis as { Stripe?: StripeFactory }).Stripe;\n if (fn) resolve(fn);\n else reject(new Error(\"stripe.js unavailable\"));\n };\n s.onerror = () => reject(new Error(\"stripe.js failed to load\"));\n document.head.appendChild(s);\n });\n }\n return loader;\n}\n\n/** Props for {@link PaymentStep}. */\nexport interface PaymentStepProps {\n /** The application to attach the subscription to (the capability). */\n applicationId: string;\n /** Refund-policy copy the applicant must acknowledge to start payment. */\n refundPolicyText: string;\n /** Called once the payment succeeds (or is processing) — advance to booking. */\n onPaid: () => void;\n}\n\ninterface SubResponse {\n clientSecret?: string;\n publishableKey?: string | null;\n}\n\n/** The card-entry step: acknowledge the refund policy → create the subscription\n * → mount Stripe Elements → confirm. */\nexport function PaymentStep(props: PaymentStepProps) {\n const { applicationId, refundPolicyText, onPaid } = props;\n const [status, setStatus] = useState<\"idle\" | \"loading\" | \"ready\" | \"confirming\">(\"idle\");\n const [error, setError] = useState<string | null>(null);\n const mountRef = useRef<HTMLDivElement | null>(null);\n const stripeRef = useRef<StripeApi | null>(null);\n const elementsRef = useRef<StripeElementsApi | null>(null);\n\n const begin = async () => {\n setStatus(\"loading\");\n setError(null);\n try {\n const res = await fetch(\"/api/payments/subscription\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ applicationId, refundPolicyAck: true }),\n });\n const data = (await res.json()) as SubResponse;\n if (!res.ok || !data.clientSecret || !data.publishableKey) throw new Error(\"Payment could not be set up. Please try again.\");\n const stripe = (await loadStripe())(data.publishableKey);\n const elements = stripe.elements({ clientSecret: data.clientSecret });\n const element = elements.create(\"payment\");\n if (mountRef.current) element.mount(mountRef.current);\n stripeRef.current = stripe;\n elementsRef.current = elements;\n setStatus(\"ready\");\n } catch (e) {\n setStatus(\"idle\");\n setError(e instanceof Error ? e.message : \"Payment could not be set up. Please try again.\");\n }\n };\n\n const pay = async () => {\n const stripe = stripeRef.current;\n const elements = elementsRef.current;\n if (!stripe || !elements) return;\n setStatus(\"confirming\");\n setError(null);\n const returnUrl =\n typeof window !== \"undefined\" ? `${window.location.origin}${window.location.pathname}?redirect_status=succeeded` : undefined;\n const result = await stripe.confirmPayment({ elements, confirmParams: { return_url: returnUrl }, redirect: \"if_required\" });\n if (result.error) {\n setError(result.error.message ?? \"The payment could not be completed.\");\n setStatus(\"ready\");\n return;\n }\n const paid = result.paymentIntent?.status;\n if (paid === \"succeeded\" || paid === \"processing\") onPaid();\n else {\n setError(\"The payment did not complete. Please try again.\");\n setStatus(\"ready\");\n }\n };\n\n return (\n <div className=\"join-pay\">\n <label className=\"compliance-box\">\n <input\n type=\"checkbox\"\n disabled={status !== \"idle\"}\n onChange={(e) => {\n if (e.currentTarget.checked) void begin();\n }}\n />\n <span>{refundPolicyText}</span>\n </label>\n {status === \"loading\" ? <p className=\"pay-status\">Preparing secure payment…</p> : null}\n <div ref={mountRef} hidden={status === \"idle\" || status === \"loading\"} />\n {status === \"ready\" || status === \"confirming\" ? (\n <button className=\"submit-btn\" disabled={status === \"confirming\"} onClick={() => void pay()}>\n {status === \"confirming\" ? \"Processing…\" : \"Pay and continue\"}\n </button>\n ) : null}\n {error ? <p className=\"pay-error\">{error}</p> : null}\n </div>\n );\n}\n"],"mappings":"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","names":["useState","useState","jsx","jsxs","useState","Fragment","jsx","jsxs","useState","useEffect","useState","useState","jsx","jsxs","jsx","jsxs","useState","useEffect"]}
@@ -0,0 +1,51 @@
1
+ // src/brand.ts
2
+ function paletteVar(key) {
3
+ return key.startsWith("--") ? key : `--${key}`;
4
+ }
5
+ function cleanValue(value) {
6
+ return value.replace(/[<>{};]/g, "").trim();
7
+ }
8
+ function paletteDecls(palette) {
9
+ const decls = [];
10
+ for (const [key, value] of Object.entries(palette ?? {})) {
11
+ if (typeof value === "string" && value.trim()) decls.push(`${paletteVar(key)}: ${cleanValue(value)};`);
12
+ }
13
+ return decls;
14
+ }
15
+ function brandTokens(brand) {
16
+ if (!brand) return "";
17
+ const light = paletteDecls(brand.palette);
18
+ const fonts = brand.fonts;
19
+ if (fonts?.display) light.push(`--ui-font-display: ${cleanValue(fonts.display)};`);
20
+ if (fonts?.body) light.push(`--ui-font-sans: ${cleanValue(fonts.body)};`);
21
+ if (fonts?.numeral) light.push(`--ui-font-numeral: ${cleanValue(fonts.numeral)};`);
22
+ const dark = paletteDecls(brand.paletteDark);
23
+ let css = light.length ? `:root {
24
+ ${light.join("\n ")}
25
+ }
26
+ ` : "";
27
+ if (dark.length) {
28
+ const block = `{
29
+ ${dark.join("\n ")}
30
+ }`;
31
+ css += `:root[data-theme="dark"] ${block}
32
+ @media (prefers-color-scheme: dark) {
33
+ :root:not([data-theme="light"]) ${block}
34
+ }
35
+ `;
36
+ }
37
+ return css;
38
+ }
39
+
40
+ // src/ui/brand-style.tsx
41
+ import { jsx } from "react/jsx-runtime";
42
+ function BrandStyle(props) {
43
+ const css = brandTokens(props.brand);
44
+ if (!css) return null;
45
+ return /* @__PURE__ */ jsx("style", { children: css });
46
+ }
47
+
48
+ export {
49
+ BrandStyle
50
+ };
51
+ //# sourceMappingURL=chunk-MKXHZMAP.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/brand.ts","../src/ui/brand-style.tsx"],"sourcesContent":["// Brand tokens (H4). defineChapter accepts a `brand` block; this turns it into a\n// `:root { --…: … }` CSS block, so a chapter re-skins the WHOLE UI — the\n// @odla-ai/ui components, the admin shell, and the member islands, which all read\n// --ui-* design tokens — from one config instead of hand-writing inline CSS.\n//\n// Pure string generation, so it is unit-testable and can be emitted at\n// build/SSR time into the page <head> (no flash of unstyled content), or via the\n// <BrandStyle> component from @odla-ai/chapter/ui.\nimport type { ChapterBrand } from \"./types\";\n\n// A palette entry is either a direct custom property (already `--…`, e.g.\n// `--ui-accent` to retheme components) or a bare name we expose as `--<name>`\n// (e.g. `moss` → `--moss`, for a site to reference in its own CSS).\nfunction paletteVar(key: string): string {\n return key.startsWith(\"--\") ? key : `--${key}`;\n}\n\n// Strip characters that could break out of a `--var: value;` declaration or the\n// surrounding <style>. Brand config is trusted author input, so this is a\n// belt-and-suspenders guard, not a security boundary.\nfunction cleanValue(value: string): string {\n return value.replace(/[<>{};]/g, \"\").trim();\n}\n\nfunction paletteDecls(palette: Record<string, string> | undefined): string[] {\n const decls: string[] = [];\n for (const [key, value] of Object.entries(palette ?? {})) {\n if (typeof value === \"string\" && value.trim()) decls.push(`${paletteVar(key)}: ${cleanValue(value)};`);\n }\n return decls;\n}\n\n/**\n * Build the CSS that maps a chapter's brand onto the design tokens the UI reads:\n * each `palette` entry becomes a custom property (light, and dark unless\n * `paletteDark` overrides), and `fonts` (display/body/numeral) map to\n * `--ui-font-display` / `--ui-font-sans` / `--ui-font-numeral`. The dark block is\n * emitted under both `:root[data-theme=\"dark\"]` (the odla-ui theme toggle) and\n * `@media (prefers-color-scheme: dark)`. Returns \"\" when there is nothing to\n * theme. These are brand OVERRIDES on top of a base theme — they do not replace\n * the theme layer the components need (see {@link brandTokens} usage in the docs).\n */\nexport function brandTokens(brand: ChapterBrand | undefined): string {\n if (!brand) return \"\";\n const light = paletteDecls(brand.palette);\n const fonts = brand.fonts;\n if (fonts?.display) light.push(`--ui-font-display: ${cleanValue(fonts.display)};`);\n if (fonts?.body) light.push(`--ui-font-sans: ${cleanValue(fonts.body)};`);\n if (fonts?.numeral) light.push(`--ui-font-numeral: ${cleanValue(fonts.numeral)};`);\n const dark = paletteDecls(brand.paletteDark);\n\n let css = light.length ? `:root {\\n ${light.join(\"\\n \")}\\n}\\n` : \"\";\n if (dark.length) {\n const block = `{\\n ${dark.join(\"\\n \")}\\n}`;\n css += `:root[data-theme=\"dark\"] ${block}\\n@media (prefers-color-scheme: dark) {\\n :root:not([data-theme=\"light\"]) ${block}\\n}\\n`;\n }\n return css;\n}\n","// Client-side convenience for brand tokens: render the chapter's brand as a\n// <style> tag. Prefer emitting brandTokens() into the page <head> at build/SSR\n// time (no flash); use this when that isn't available (e.g. a pure SPA mount).\nimport { brandTokens } from \"../brand.js\";\nimport type { ChapterBrand } from \"../types\";\n\n/** Props for {@link BrandStyle}. */\nexport interface BrandStyleProps {\n brand: ChapterBrand | undefined;\n}\n\n/** Render a chapter's brand tokens as an inline <style> block (or nothing when\n * there is no brand to theme). */\nexport function BrandStyle(props: BrandStyleProps) {\n const css = brandTokens(props.brand);\n if (!css) return null;\n return <style>{css}</style>;\n}\n"],"mappings":";AAaA,SAAS,WAAW,KAAqB;AACvC,SAAO,IAAI,WAAW,IAAI,IAAI,MAAM,KAAK,GAAG;AAC9C;AAKA,SAAS,WAAW,OAAuB;AACzC,SAAO,MAAM,QAAQ,YAAY,EAAE,EAAE,KAAK;AAC5C;AAEA,SAAS,aAAa,SAAuD;AAC3E,QAAM,QAAkB,CAAC;AACzB,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,WAAW,CAAC,CAAC,GAAG;AACxD,QAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAG,OAAM,KAAK,GAAG,WAAW,GAAG,CAAC,KAAK,WAAW,KAAK,CAAC,GAAG;AAAA,EACvG;AACA,SAAO;AACT;AAYO,SAAS,YAAY,OAAyC;AACnE,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,QAAQ,aAAa,MAAM,OAAO;AACxC,QAAM,QAAQ,MAAM;AACpB,MAAI,OAAO,QAAS,OAAM,KAAK,sBAAsB,WAAW,MAAM,OAAO,CAAC,GAAG;AACjF,MAAI,OAAO,KAAM,OAAM,KAAK,mBAAmB,WAAW,MAAM,IAAI,CAAC,GAAG;AACxE,MAAI,OAAO,QAAS,OAAM,KAAK,sBAAsB,WAAW,MAAM,OAAO,CAAC,GAAG;AACjF,QAAM,OAAO,aAAa,MAAM,WAAW;AAE3C,MAAI,MAAM,MAAM,SAAS;AAAA,IAAc,MAAM,KAAK,MAAM,CAAC;AAAA;AAAA,IAAU;AACnE,MAAI,KAAK,QAAQ;AACf,UAAM,QAAQ;AAAA,IAAQ,KAAK,KAAK,MAAM,CAAC;AAAA;AACvC,WAAO,4BAA4B,KAAK;AAAA;AAAA,oCAA8E,KAAK;AAAA;AAAA;AAAA,EAC7H;AACA,SAAO;AACT;;;ACzCS;AAHF,SAAS,WAAW,OAAwB;AACjD,QAAM,MAAM,YAAY,MAAM,KAAK;AACnC,MAAI,CAAC,IAAK,QAAO;AACjB,SAAO,oBAAC,WAAO,eAAI;AACrB;","names":[]}