@odla-ai/chapter 0.25.8 → 0.26.1

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
@@ -61,8 +61,10 @@ Chapter-owned application, admin, CRM, payment, and scheduling behavior.
61
61
  the browser UI does not need a second identity or follower registry.
62
62
  - **The worker is the package.** `chapterWorker({ chapter })` is the whole
63
63
  Cloudflare `ExportedHandler`. It serves `/api/health`, `/api/config`,
64
- `/api/me`, `/api/crm/*`, `/api/network/shared`; configured leaders also get
65
- `/api/admin/network/{targets,push}`. Chapter mode adds the public
64
+ `/api/me`, `/api/crm/*`, `/api/network/{shared,snapshot}`; configured leaders
65
+ also get `/api/admin/network/{targets,push,rollup}`. An enabled leader
66
+ formation intake adds `/api/formation/{config,applications}`. Chapter mode
67
+ adds the public
66
68
  member surface (`/api/join-config`, `/api/join/resume`, `/api/applications`,
67
69
  `/api/schedule/{slots,book}`, `/api/payments/subscription`,
68
70
  `/api/webhooks/stripe`). The `/api/admin/*` handlers are registered in both
@@ -104,7 +106,9 @@ Chapter-owned application, admin, CRM, payment, and scheduling behavior.
104
106
  config and secret *values* stay in the tenant vault. The standard record panel
105
107
  then shows “Share with …” actions for compatible people, businesses, deals,
106
108
  or other CRM types. The receiver validates the payload against its own CRM
107
- config and stores a provenance tag, so retries update the same local record.
109
+ config and stores a structured origin row, so retries update the same local
110
+ record. Delivery state is structured too; display tags remain a migration and
111
+ cohort affordance rather than the machine identity contract.
108
112
  Shared identity/business fields move; each site's pipeline, account, and
109
113
  billing state remain authoritative locally.
110
114
  - **The UI kit, adoptable in pieces.** Three entries, split on the dependency
@@ -285,6 +289,75 @@ wrap or replace the provisional card just as admin workspaces can be composed.
285
289
  Nested admin/page/record tabs use fragment anchors by default, not query-string
286
290
  state.
287
291
 
292
+ ### Leader portfolio and formation
293
+
294
+ `leaderCrmConfig()` composes generic portfolio records into an existing CRM
295
+ config without replacing its people, businesses, pipelines, templates, or
296
+ relations. Duplicate ids fail at startup:
297
+
298
+ ```ts
299
+ import { defineChapter, leaderCrmConfig } from "@odla-ai/chapter";
300
+
301
+ const existingCrmConfig = {
302
+ types: {
303
+ person: {
304
+ label: "Person",
305
+ labelPlural: "People",
306
+ nameField: "name",
307
+ emailField: "email",
308
+ fields: {
309
+ name: { type: "string", required: true },
310
+ email: { type: "email" },
311
+ },
312
+ },
313
+ },
314
+ };
315
+
316
+ export const chapter = defineChapter({
317
+ id: "example-hub",
318
+ name: "Example Hub",
319
+ mode: "hub",
320
+ crm: leaderCrmConfig({ base: existingCrmConfig }),
321
+ formation: {},
322
+ network: {
323
+ targets: [{
324
+ id: "example-chapter",
325
+ name: "Example Chapter",
326
+ url: "https://chapter.example.com",
327
+ fields: { person: ["name", "email"] },
328
+ }],
329
+ },
330
+ });
331
+ ```
332
+
333
+ The preset adds `chapter`, `chapter_application`, and `deal` record types in
334
+ the `portfolio` workspace, with formation, operating, and deal-flow pipelines.
335
+ It also composes relations to base `person` and `company` types when present.
336
+ These are ordinary CRM records: the host may add fields, types, render slots,
337
+ and relations by composing its own config before passing it to Chapter.
338
+
339
+ `formation: {}` opts into a host-rendered public application:
340
+
341
+ - `GET /api/formation/config` returns only configured public field metadata.
342
+ - `POST /api/formation/applications` accepts only those fields and creates a
343
+ normal `chapter_application` CRM record.
344
+ - A stable base64url `submissionId` makes ambiguous retries resolve to the same
345
+ record. Hosts must generate one per form journey and retain it until the
346
+ response is known.
347
+ - Request size, string length, required fields, and public field names are
348
+ bounded by `formation`; internal fields such as notes are not accepted.
349
+
350
+ Formation is off unless configured. Chapter intentionally does not ship a
351
+ public form design: the site renders the fields in its own voice and brand.
352
+
353
+ For a hub with network targets, the standard admin catalog is Dashboard,
354
+ People, Portfolio, and Settings. Dashboard Overview renders aggregate network
355
+ health; People contains CRM types whose `workspace` is absent or `"people"`;
356
+ Portfolio contains types marked `workspace: "portfolio"`. Operational details
357
+ remain nested page tabs and record operations remain record tabs. A
358
+ `workspaces` transform can preserve or extend this catalog just like the
359
+ standard follower console.
360
+
288
361
  ### Leader → follower delivery
289
362
 
290
363
  The leader declares where records may go and exactly which fields each follower
@@ -300,6 +373,7 @@ export const chapter = defineChapter({
300
373
  id: "example-chapter",
301
374
  name: "Example Chapter",
302
375
  url: "https://chapter.example.com",
376
+ binding: "EXAMPLE_CHAPTER",
303
377
  fields: {
304
378
  person: ["name", "email", "firstName", "lastName", "phone", "linkedin"],
305
379
  company: ["name", "domain", "industry", "location", "linkedin"],
@@ -314,21 +388,50 @@ Treat the network as websites connected by explicit delivery edges:
314
388
  - each website is a node with its own `appId`, ODLA tenants/keys, CRM config,
315
389
  Clerk application, publishable keys, issuer, roles, and local users;
316
390
  - each `network.targets[]` entry is a directed edge from one leader to one
317
- follower: `{ id, name, url, secretName?, fields }`;
391
+ follower: `{ id, name, url, binding?, secretName?, fields }`;
318
392
  - the payload is a versioned, allowlisted business record
319
- `{ version, type, hubRecordId, input }`, never an identity-provider session,
320
- another site's pipeline/account/billing state, or an ODLA admin key;
393
+ `{ version: 2, source: { siteId, recordId }, type, input }`, never an
394
+ identity-provider session, another site's pipeline/account/billing state, or
395
+ an ODLA admin key;
321
396
  - retries address the follower record by leader provenance, so the same edge
322
397
  updates rather than duplicates.
323
398
 
324
399
  The authorization chain has three separate hops. The leader's browser sends
325
400
  its own Clerk session only to the leader's admin route. The leader Worker
326
401
  authorizes that local operator, reads the edge's share secret from its own
327
- vault, and sends that secret to the follower. The follower validates the share
328
- secret, validates the payload against its own CRM config, and writes with its
329
- own server-side `ODLA_API_KEY`. A follower Clerk token is never needed, the
330
- leader never receives the follower's database key, and sites do not share a
331
- Clerk application.
402
+ vault, and signs the exact method, path/query, sender id, timestamp, nonce, and
403
+ body digest. The follower validates that HMAC envelope against its vaulted
404
+ copy, requires the signed sender to match the v2 payload source, validates the
405
+ payload against its own CRM config, and writes with its own server-side
406
+ `ODLA_API_KEY`. The share secret itself is never sent. A follower Clerk token
407
+ is never needed, the leader never receives the follower's database key, and
408
+ sites do not share a Clerk application.
409
+
410
+ When both sites are Cloudflare Workers, configure `binding` and add the matching
411
+ service binding to the leader's Wrangler environment. Cloudflare does not
412
+ reliably dispatch same-account `workers.dev` subrequests into the target
413
+ Worker; Chapter therefore signs against `url` but sends through the bound
414
+ Worker:
415
+
416
+ ```jsonc
417
+ {
418
+ "env": {
419
+ "dev": {
420
+ "services": [{
421
+ "binding": "EXAMPLE_CHAPTER",
422
+ "service": "example-chapter-dev"
423
+ }]
424
+ }
425
+ }
426
+ }
427
+ ```
428
+
429
+ The example assumes the follower sets its development Worker `name` explicitly
430
+ to `example-chapter-dev`. If it instead uses Cloudflare's named-environment
431
+ addressing, declare the base `service` plus `"environment": "dev"`. The binding
432
+ name is public configuration, not a credential. If a configured binding is
433
+ missing at runtime, the target fails explicitly instead of falling back to a
434
+ potentially misrouted public fetch.
332
435
 
333
436
  Vault the same random value as `network_share_secret` in the follower and as
334
437
  `network_share_example_chapter` in the leader (or set a different
@@ -337,6 +440,25 @@ config. The standard collection drawer discovers the target and calls the
337
440
  admin-gated push route. A follower must declare the receiving CRM type/fields;
338
441
  otherwise it rejects the record cleanly instead of dropping fields.
339
442
 
443
+ The signed v2 protocol is the contract for new integrations. The follower
444
+ temporarily accepts the old bearer/v1 envelope so an existing edge can be
445
+ upgraded without downtime; new leaders must not use it.
446
+
447
+ Every received v2 record gets a `crm_record_origin` row keyed by the upstream
448
+ site and record id. Every push attempt gets a `crm_record_delivery` row keyed by
449
+ the local record and target, including attempt count, current status, payload
450
+ version, remote record id, and bounded latest error. Record detail responses
451
+ include both collections and the standard record drawer adds a Network tab
452
+ when either exists.
453
+
454
+ `GET /api/network/snapshot` is an HMAC-signed, aggregate-only follower endpoint.
455
+ It returns type totals and pipeline-stage counts, never record fields, people,
456
+ sessions, Clerk tokens, share secrets, or ODLA keys. The leader's admin-gated
457
+ `GET /api/admin/network/rollup` fans out to every configured target, verifies
458
+ that each returned site id equals the target id, and returns combined totals
459
+ plus explicit per-target failures. Consequently each target `id` must equal the
460
+ follower's `chapter.id`.
461
+
340
462
  #### Existing follower CRMs must opt into the shared graph
341
463
 
342
464
  When `crm` is omitted, chapter's default already contains compatible `person`
@@ -436,8 +558,10 @@ data.
436
558
 
437
559
  Use distinct development tenants and follower origins for the first delivery:
438
560
 
439
- 1. Vault one random value as `network_share_secret` in the follower and under
440
- the target's resolved `secretName` in the leader.
561
+ 1. Confirm every target `id` exactly equals the follower's `chapter.id`. For
562
+ Cloudflare Workers, confirm its `binding` exists in the leader's development
563
+ Wrangler environment. Vault one random value as `network_share_secret` in
564
+ the follower and under the target's resolved `secretName` in the leader.
441
565
  2. Confirm `GET /api/admin/network/targets` lists the development follower with
442
566
  the expected compatible record types.
443
567
  3. Share one test person and one test business from the leader's record drawer.
@@ -451,7 +575,11 @@ Use distinct development tenants and follower origins for the first delivery:
451
575
  without writing CRM data.
452
576
  7. Prove each browser JWT is accepted only by its own website. Inspect the
453
577
  network request and client bundle to confirm neither site's Clerk token nor
454
- either `ODLA_API_KEY` crosses the delivery edge.
578
+ either `ODLA_API_KEY` crosses the delivery edge, and confirm the share
579
+ secret itself is absent from the request.
580
+ 8. Load `GET /api/admin/network/rollup` through the leader session. Confirm
581
+ aggregate totals, then break one development edge and confirm that target is
582
+ reported unavailable without hiding healthy targets.
455
583
 
456
584
  Only after this contract passes against development origins should the leader
457
585
  target be changed to a production follower origin and the matching production
@@ -468,7 +596,7 @@ Legacy `?tab=` and path links still open, while `routing="query"` and
468
596
 
469
597
  ### Admin information architecture and migration
470
598
 
471
- The default chapter console deliberately has only three top-level workspaces:
599
+ The default follower console deliberately has only three top-level workspaces:
472
600
 
473
601
  - **Dashboard** — Overview and Billing page tabs.
474
602
  - **People** — configured CRM collections as page tabs, then a master/detail
@@ -476,6 +604,11 @@ The default chapter console deliberately has only three top-level workspaces:
476
604
  Connections, Access, and Sharing record tabs when those capabilities apply.
477
605
  - **Settings** — Calendar and Email page tabs.
478
606
 
607
+ A leader hub with configured targets adds **Portfolio** between People and
608
+ Settings and uses Dashboard Overview for the aggregate network rollup. A hub
609
+ without targets retains the minimal People-only default. These defaults compose
610
+ the same grammar; they do not introduce a second admin shell.
611
+
479
612
  The default `chrome="embedded"` assumes the host supplies the site header.
480
613
  Use `chrome="standalone"` for Chapter's packaged masthead or `renderHeader` for
481
614
  a custom header rendered inside the Chapter theme boundary. `editorial`,
@@ -4,7 +4,7 @@ import {
4
4
  DEFAULT_CHAPTER_COPY,
5
5
  formatChapterCopy,
6
6
  useChapterCopy
7
- } from "./chunk-WGQO4WDJ.js";
7
+ } from "./chunk-OIWCKF2G.js";
8
8
 
9
9
  // src/ui/admin.tsx
10
10
  import { useEffect as useEffect8, useMemo, useRef, useState as useState12 } from "preact/hooks";
@@ -1255,8 +1255,46 @@ function peopleSection(options) {
1255
1255
  });
1256
1256
  }
1257
1257
 
1258
+ // src/ui/admin-portfolio.tsx
1259
+ import { StatBand as StatBand2, StepPipeline as StepPipeline2 } from "@odla-ai/ui/components";
1260
+ import { jsx as jsx15, jsxs as jsxs12 } from "preact/jsx-runtime";
1261
+ function LeaderNetworkOverview({ ctx }) {
1262
+ const copy = useChapterCopy().admin.network;
1263
+ const { data, loading, error } = useAdminResource(
1264
+ ctx.getToken,
1265
+ "/api/admin/network/rollup"
1266
+ );
1267
+ if (loading) return /* @__PURE__ */ jsx15(AdminNote, { children: copy.loadingRollup });
1268
+ if (error || !data) return /* @__PURE__ */ jsxs12(AdminNote, { children: [
1269
+ copy.loadRollupFailed,
1270
+ error ? `: ${error}` : "",
1271
+ "."
1272
+ ] });
1273
+ if (data.configured === 0) return /* @__PURE__ */ jsx15(AdminPage, { children: /* @__PURE__ */ jsx15(Panel, { title: copy.overview, children: /* @__PURE__ */ jsx15("p", { className: "muted", children: copy.noFollowers }) }) });
1274
+ const stats = [
1275
+ { value: data.configured.toLocaleString(), label: copy.configuredFollowers },
1276
+ { value: data.available.toLocaleString(), label: copy.reachableFollowers },
1277
+ { value: data.totalRecords.toLocaleString(), label: copy.recordsAcrossFollowers }
1278
+ ];
1279
+ const steps = data.types.map((type) => ({
1280
+ icon: /* @__PURE__ */ jsx15("span", { style: { fontSize: 22, fontWeight: 700 }, children: type.total }),
1281
+ title: type.type.replaceAll("_", " "),
1282
+ description: Object.entries(type.stages).filter(([, count]) => count > 0).map(([stage, count]) => `${stage.replaceAll("_", " ")} ${count}`).join(" \xB7 ") || copy.noRecordTotals
1283
+ }));
1284
+ return /* @__PURE__ */ jsxs12(AdminPage, { children: [
1285
+ /* @__PURE__ */ jsx15(StatBand2, { stats }),
1286
+ /* @__PURE__ */ jsx15(Panel, { title: copy.followers, children: /* @__PURE__ */ jsx15("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 10 }, children: data.targets.map((target) => /* @__PURE__ */ jsxs12("li", { style: { display: "flex", gap: 12, alignItems: "baseline", flexWrap: "wrap" }, children: [
1287
+ /* @__PURE__ */ jsx15("strong", { style: { minWidth: 180 }, children: target.name }),
1288
+ /* @__PURE__ */ jsx15("span", { className: "badge", children: target.available ? copy.available : copy.unavailable }),
1289
+ target.error ? /* @__PURE__ */ jsx15("span", { className: "muted", children: target.error }) : null,
1290
+ /* @__PURE__ */ jsx15("a", { href: target.url, target: "_blank", rel: "noreferrer", style: { marginLeft: "auto" }, children: target.url })
1291
+ ] }, target.id)) }) }),
1292
+ /* @__PURE__ */ jsx15(Panel, { title: copy.recordTotals, children: steps.length ? /* @__PURE__ */ jsx15(StepPipeline2, { ariaLabel: copy.recordTotals, steps }) : /* @__PURE__ */ jsx15("p", { className: "muted", children: copy.noRecordTotals }) })
1293
+ ] });
1294
+ }
1295
+
1258
1296
  // src/ui/admin-workspaces.tsx
1259
- import { jsx as jsx15 } from "preact/jsx-runtime";
1297
+ import { jsx as jsx16 } from "preact/jsx-runtime";
1260
1298
  var adminViewContext = (ctx, viewId, active) => {
1261
1299
  const route = {
1262
1300
  workspaceId: ctx.route.workspaceId,
@@ -1282,7 +1320,7 @@ function DashboardWorkspace({ chapter, ctx }) {
1282
1320
  const active = ctx.route.viewId === "billing" ? "billing" : "overview";
1283
1321
  const overview = dashboardSection({ id: "overview", label: copy.workspaces.overview });
1284
1322
  const billing = billingSection({ label: copy.workspaces.billing });
1285
- return /* @__PURE__ */ jsx15(
1323
+ return /* @__PURE__ */ jsx16(
1286
1324
  Tabs2,
1287
1325
  {
1288
1326
  ariaLabel: copy.workspaces.dashboardViewsLabel,
@@ -1310,7 +1348,7 @@ function SettingsWorkspace({ chapter, ctx }) {
1310
1348
  const active = ctx.route.viewId === "email" ? "email" : "calendar";
1311
1349
  const calendar = availabilitySection({ id: "calendar", label: copy.workspaces.calendar });
1312
1350
  const email = emailSection({ label: copy.workspaces.email });
1313
- return /* @__PURE__ */ jsx15(
1351
+ return /* @__PURE__ */ jsx16(
1314
1352
  Tabs2,
1315
1353
  {
1316
1354
  ariaLabel: copy.workspaces.settingsViewsLabel,
@@ -1334,11 +1372,11 @@ function SettingsWorkspace({ chapter, ctx }) {
1334
1372
  );
1335
1373
  }
1336
1374
  function PeopleWorkspace({ chapter, ctx }) {
1337
- const types = Object.entries(chapter.crm.config.types);
1375
+ const types = Object.entries(chapter.crm.config.types).filter(([, def]) => (def.workspace ?? "people") === "people");
1338
1376
  const fallback = types[0]?.[0] ?? "person";
1339
1377
  const active = types.some(([type]) => type === ctx.route.viewId) ? ctx.route.viewId : fallback;
1340
1378
  const networkSharing = chapter.network.targets.length > 0;
1341
- return /* @__PURE__ */ jsx15(
1379
+ return /* @__PURE__ */ jsx16(
1342
1380
  Tabs2,
1343
1381
  {
1344
1382
  ariaLabel: chapter.copy.admin.workspaces.collectionsLabel,
@@ -1363,38 +1401,124 @@ function PeopleWorkspace({ chapter, ctx }) {
1363
1401
  }
1364
1402
  );
1365
1403
  }
1404
+ function PortfolioWorkspace({ chapter, ctx }) {
1405
+ const copy = chapter.copy.admin;
1406
+ const types = Object.entries(chapter.crm.config.types).filter(([, def]) => def.workspace === "portfolio");
1407
+ const fallback = types[0]?.[0] ?? "";
1408
+ const active = types.some(([type]) => type === ctx.route.viewId) ? ctx.route.viewId : fallback;
1409
+ if (!active) return /* @__PURE__ */ jsx16(LeaderNetworkOverview, { ctx });
1410
+ return /* @__PURE__ */ jsx16(
1411
+ Tabs2,
1412
+ {
1413
+ ariaLabel: copy.workspaces.portfolioViewsLabel,
1414
+ value: active,
1415
+ variant: "pill",
1416
+ mount: "active",
1417
+ items: types.map(([type, def]) => {
1418
+ const section = collectionSection({
1419
+ crm: chapter.crm,
1420
+ type,
1421
+ networkSharing: chapter.network.targets.length > 0
1422
+ });
1423
+ return {
1424
+ value: type,
1425
+ label: def.labelPlural ?? def.label,
1426
+ href: ctx.href({ viewId: type, recordId: null, detailTab: null }),
1427
+ panel: section.render(adminViewContext(ctx, type, active === type))
1428
+ };
1429
+ })
1430
+ }
1431
+ );
1432
+ }
1433
+ function LeaderDashboardWorkspace({ chapter, ctx }) {
1434
+ return /* @__PURE__ */ jsx16(
1435
+ Tabs2,
1436
+ {
1437
+ ariaLabel: chapter.copy.admin.workspaces.dashboardViewsLabel,
1438
+ value: "overview",
1439
+ mount: "active",
1440
+ items: [{
1441
+ value: "overview",
1442
+ label: chapter.copy.admin.workspaces.overview,
1443
+ href: ctx.href({ viewId: "overview", recordId: null, detailTab: null }),
1444
+ panel: /* @__PURE__ */ jsx16(LeaderNetworkOverview, { ctx: adminViewContext(ctx, "overview", true) })
1445
+ }]
1446
+ }
1447
+ );
1448
+ }
1449
+ function LeaderSettingsWorkspace({ chapter, ctx }) {
1450
+ const email = emailSection({ label: chapter.copy.admin.workspaces.email });
1451
+ return /* @__PURE__ */ jsx16(
1452
+ Tabs2,
1453
+ {
1454
+ ariaLabel: chapter.copy.admin.workspaces.settingsViewsLabel,
1455
+ value: "email",
1456
+ mount: "active",
1457
+ items: [{
1458
+ value: "email",
1459
+ label: chapter.copy.admin.workspaces.email,
1460
+ href: ctx.href({ viewId: "email", recordId: null, detailTab: null }),
1461
+ panel: email.render(adminViewContext(ctx, "email", true))
1462
+ }]
1463
+ }
1464
+ );
1465
+ }
1366
1466
  function defaultAdminWorkspaces(chapter) {
1367
1467
  const copy = chapter.copy.admin;
1368
1468
  const people = {
1369
1469
  id: "people",
1370
1470
  label: copy.workspaces.people,
1371
1471
  defaultViewId: Object.keys(chapter.crm.config.types)[0] ?? "person",
1372
- render: (ctx) => /* @__PURE__ */ jsx15(PeopleWorkspace, { chapter, ctx })
1472
+ render: (ctx) => /* @__PURE__ */ jsx16(PeopleWorkspace, { chapter, ctx })
1373
1473
  };
1374
- if (chapter.mode === "hub") return [people];
1474
+ if (chapter.mode === "hub" && chapter.network.targets.length === 0) return [people];
1475
+ if (chapter.mode === "hub") {
1476
+ const portfolioTypes = Object.entries(chapter.crm.config.types).filter(([, def]) => def.workspace === "portfolio");
1477
+ return [
1478
+ {
1479
+ id: "dashboard",
1480
+ label: copy.workspaces.dashboard,
1481
+ defaultViewId: "overview",
1482
+ render: (ctx) => /* @__PURE__ */ jsx16(LeaderDashboardWorkspace, { chapter, ctx })
1483
+ },
1484
+ people,
1485
+ {
1486
+ id: "portfolio",
1487
+ label: copy.workspaces.portfolio,
1488
+ defaultViewId: portfolioTypes[0]?.[0],
1489
+ render: (ctx) => /* @__PURE__ */ jsx16(PortfolioWorkspace, { chapter, ctx })
1490
+ },
1491
+ {
1492
+ id: "settings",
1493
+ label: copy.workspaces.settings,
1494
+ defaultViewId: "email",
1495
+ render: (ctx) => /* @__PURE__ */ jsx16(LeaderSettingsWorkspace, { chapter, ctx })
1496
+ }
1497
+ ];
1498
+ }
1375
1499
  return [
1376
1500
  {
1377
1501
  id: "dashboard",
1378
1502
  label: copy.workspaces.dashboard,
1379
1503
  defaultViewId: "overview",
1380
- render: (ctx) => /* @__PURE__ */ jsx15(DashboardWorkspace, { chapter, ctx })
1504
+ render: (ctx) => /* @__PURE__ */ jsx16(DashboardWorkspace, { chapter, ctx })
1381
1505
  },
1382
1506
  people,
1383
1507
  {
1384
1508
  id: "settings",
1385
1509
  label: copy.workspaces.settings,
1386
1510
  defaultViewId: "calendar",
1387
- render: (ctx) => /* @__PURE__ */ jsx15(SettingsWorkspace, { chapter, ctx })
1511
+ render: (ctx) => /* @__PURE__ */ jsx16(SettingsWorkspace, { chapter, ctx })
1388
1512
  }
1389
1513
  ];
1390
1514
  }
1391
1515
 
1392
1516
  // src/ui/admin-frame.tsx
1393
1517
  import { ThemeScope as ThemeScope2 } from "@odla-ai/ui/components";
1394
- import { jsx as jsx16, jsxs as jsxs12 } from "preact/jsx-runtime";
1518
+ import { jsx as jsx17, jsxs as jsxs13 } from "preact/jsx-runtime";
1395
1519
  function AdminTheme(props) {
1396
1520
  const brand = props.chapter?.brand;
1397
- return /* @__PURE__ */ jsx16(ChapterCopyProvider, { copy: props.chapter?.copy, children: /* @__PURE__ */ jsxs12(
1521
+ return /* @__PURE__ */ jsx17(ChapterCopyProvider, { copy: props.chapter?.copy, children: /* @__PURE__ */ jsxs13(
1398
1522
  ThemeScope2,
1399
1523
  {
1400
1524
  ref: props.scopeRef,
@@ -1403,7 +1527,7 @@ function AdminTheme(props) {
1403
1527
  accent: brand?.accent,
1404
1528
  colorScheme: brand?.colorScheme ?? "light",
1405
1529
  children: [
1406
- /* @__PURE__ */ jsx16(BrandStyle, { brand, selector: "[data-chapter-admin]" }),
1530
+ /* @__PURE__ */ jsx17(BrandStyle, { brand, selector: "[data-chapter-admin]" }),
1407
1531
  props.children
1408
1532
  ]
1409
1533
  }
@@ -1458,7 +1582,7 @@ async function loadChapterAdminUser(adapter, apiBase, getToken) {
1458
1582
  }
1459
1583
 
1460
1584
  // src/ui/admin.tsx
1461
- import { jsx as jsx17, jsxs as jsxs13 } from "preact/jsx-runtime";
1585
+ import { jsx as jsx18, jsxs as jsxs14 } from "preact/jsx-runtime";
1462
1586
  function Authed(props) {
1463
1587
  const copy = useChapterCopy().admin;
1464
1588
  const {
@@ -1505,17 +1629,17 @@ function Authed(props) {
1505
1629
  live = false;
1506
1630
  };
1507
1631
  }, [getToken, apiBase, auth]);
1508
- if (state.status === "checking") return /* @__PURE__ */ jsx17(Gate, { brand, children: /* @__PURE__ */ jsx17("p", { style: S.muted, children: copy.auth.checking }) });
1632
+ if (state.status === "checking") return /* @__PURE__ */ jsx18(Gate, { brand, children: /* @__PURE__ */ jsx18("p", { style: S.muted, children: copy.auth.checking }) });
1509
1633
  if (state.status === "denied") {
1510
- return /* @__PURE__ */ jsx17(Gate, { brand, children: /* @__PURE__ */ jsxs13("div", { className: "card", style: S.card, children: [
1511
- /* @__PURE__ */ jsx17("h2", { style: { marginTop: 0 }, children: copy.auth.notAuthorized }),
1512
- /* @__PURE__ */ jsx17("p", { style: S.muted, children: formatChapterCopy(copy.auth.accountNotAuthorized, {
1634
+ return /* @__PURE__ */ jsx18(Gate, { brand, children: /* @__PURE__ */ jsxs14("div", { className: "card", style: S.card, children: [
1635
+ /* @__PURE__ */ jsx18("h2", { style: { marginTop: 0 }, children: copy.auth.notAuthorized }),
1636
+ /* @__PURE__ */ jsx18("p", { style: S.muted, children: formatChapterCopy(copy.auth.accountNotAuthorized, {
1513
1637
  account: state.email ?? copy.auth.thisAccount
1514
1638
  }) }),
1515
- /* @__PURE__ */ jsx17("button", { className: "btn secondary", onClick: () => signOut(), children: copy.auth.signOut })
1639
+ /* @__PURE__ */ jsx18("button", { className: "btn secondary", onClick: () => signOut(), children: copy.auth.signOut })
1516
1640
  ] }) });
1517
1641
  }
1518
- return /* @__PURE__ */ jsx17(
1642
+ return /* @__PURE__ */ jsx18(
1519
1643
  AdminShell,
1520
1644
  {
1521
1645
  workspaces,
@@ -1568,23 +1692,23 @@ function ChapterAdmin(props) {
1568
1692
  }, [apiBase, auth]);
1569
1693
  let content;
1570
1694
  if (publishableKey === void 0 || publishableKey && !appearance) {
1571
- content = /* @__PURE__ */ jsx17(Gate, { brand, children: /* @__PURE__ */ jsx17("p", { style: S.muted, children: copy.admin.auth.loading }) });
1695
+ content = /* @__PURE__ */ jsx18(Gate, { brand, children: /* @__PURE__ */ jsx18("p", { style: S.muted, children: copy.admin.auth.loading }) });
1572
1696
  } else if (!publishableKey) {
1573
- content = /* @__PURE__ */ jsx17(Gate, { brand, children: /* @__PURE__ */ jsxs13("div", { className: "card", style: S.card, children: [
1574
- /* @__PURE__ */ jsx17("h2", { style: { marginTop: 0 }, children: copy.admin.auth.signInNotConfigured }),
1575
- /* @__PURE__ */ jsx17("p", { style: S.muted, children: copy.admin.auth.missingPublishableKey })
1697
+ content = /* @__PURE__ */ jsx18(Gate, { brand, children: /* @__PURE__ */ jsxs14("div", { className: "card", style: S.card, children: [
1698
+ /* @__PURE__ */ jsx18("h2", { style: { marginTop: 0 }, children: copy.admin.auth.signInNotConfigured }),
1699
+ /* @__PURE__ */ jsx18("p", { style: S.muted, children: copy.admin.auth.missingPublishableKey })
1576
1700
  ] }) });
1577
1701
  } else if (workspaces.length === 0) {
1578
- content = /* @__PURE__ */ jsx17(Gate, { brand, children: /* @__PURE__ */ jsx17("p", { style: S.muted, children: copy.admin.auth.noWorkspaces }) });
1702
+ content = /* @__PURE__ */ jsx18(Gate, { brand, children: /* @__PURE__ */ jsx18("p", { style: S.muted, children: copy.admin.auth.noWorkspaces }) });
1579
1703
  } else {
1580
- content = /* @__PURE__ */ jsxs13(
1704
+ content = /* @__PURE__ */ jsxs14(
1581
1705
  ClerkGate,
1582
1706
  {
1583
1707
  publishableKey,
1584
1708
  appearance,
1585
1709
  afterSignOutUrl: "/",
1586
1710
  children: [
1587
- /* @__PURE__ */ jsx17(SignedOut, { children: /* @__PURE__ */ jsx17(Gate, { brand, tagline: copy.admin.auth.signInTagline, children: /* @__PURE__ */ jsx17(
1711
+ /* @__PURE__ */ jsx18(SignedOut, { children: /* @__PURE__ */ jsx18(Gate, { brand, tagline: copy.admin.auth.signInTagline, children: /* @__PURE__ */ jsx18(
1588
1712
  SignIn,
1589
1713
  {
1590
1714
  routing: "hash",
@@ -1592,7 +1716,7 @@ function ChapterAdmin(props) {
1592
1716
  signUpForceRedirectUrl: redirect
1593
1717
  }
1594
1718
  ) }) }),
1595
- /* @__PURE__ */ jsx17(SignedIn, { children: /* @__PURE__ */ jsx17(
1719
+ /* @__PURE__ */ jsx18(SignedIn, { children: /* @__PURE__ */ jsx18(
1596
1720
  Authed,
1597
1721
  {
1598
1722
  workspaces,
@@ -1612,7 +1736,7 @@ function ChapterAdmin(props) {
1612
1736
  }
1613
1737
  );
1614
1738
  }
1615
- return /* @__PURE__ */ jsx17(AdminTheme, { chapter: props.chapter, scopeRef, children: content });
1739
+ return /* @__PURE__ */ jsx18(AdminTheme, { chapter: props.chapter, scopeRef, children: content });
1616
1740
  }
1617
1741
 
1618
1742
  // src/ui/admin-defaults.ts
@@ -1623,15 +1747,15 @@ function defaultAdminSections(chapter) {
1623
1747
  // src/ui/admin-meetings.tsx
1624
1748
  import { useState as useState13 } from "preact/hooks";
1625
1749
  import { Button as Button6 } from "@odla-ai/ui/components";
1626
- import { Fragment as Fragment4, jsx as jsx18, jsxs as jsxs14 } from "preact/jsx-runtime";
1750
+ import { Fragment as Fragment4, jsx as jsx19, jsxs as jsxs15 } from "preact/jsx-runtime";
1627
1751
  var when2 = (t) => new Date(t).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
1628
1752
  function MeetingsBody({ ctx }) {
1629
1753
  const copy = useChapterCopy().admin.meetings;
1630
1754
  const { data, loading, error, refresh } = useAdminResource(ctx.getToken, "/api/admin/meetings?all=1");
1631
1755
  const [busy, setBusy] = useState13(null);
1632
1756
  const [note, setNote] = useState13(null);
1633
- if (loading) return /* @__PURE__ */ jsx18(AdminNote, { children: copy.loading });
1634
- if (error || !data) return /* @__PURE__ */ jsxs14(AdminNote, { children: [
1757
+ if (loading) return /* @__PURE__ */ jsx19(AdminNote, { children: copy.loading });
1758
+ if (error || !data) return /* @__PURE__ */ jsxs15(AdminNote, { children: [
1635
1759
  copy.loadFailed,
1636
1760
  error ? `: ${error}` : "",
1637
1761
  "."
@@ -1662,32 +1786,32 @@ function MeetingsBody({ ctx }) {
1662
1786
  }
1663
1787
  void run(id, () => adminFetch(ctx.getToken, `/api/admin/meetings/${id}/reschedule`, { method: "POST", body: JSON.stringify({ startAt }) }));
1664
1788
  };
1665
- return /* @__PURE__ */ jsx18(AdminPage, { children: /* @__PURE__ */ jsx18(Panel, { title: /* @__PURE__ */ jsxs14(Fragment4, { children: [
1789
+ return /* @__PURE__ */ jsx19(AdminPage, { children: /* @__PURE__ */ jsx19(Panel, { title: /* @__PURE__ */ jsxs15(Fragment4, { children: [
1666
1790
  copy.agenda,
1667
1791
  " ",
1668
- /* @__PURE__ */ jsxs14("span", { className: "muted", style: { fontWeight: 400 }, children: [
1792
+ /* @__PURE__ */ jsxs15("span", { className: "muted", style: { fontWeight: 400 }, children: [
1669
1793
  "\xB7 ",
1670
1794
  data.timezone
1671
1795
  ] })
1672
- ] }), actions: note ? /* @__PURE__ */ jsx18("span", { className: "muted", children: note }) : void 0, children: data.meetings.length === 0 ? /* @__PURE__ */ jsx18("p", { className: "muted", style: { margin: 0 }, children: copy.empty }) : /* @__PURE__ */ jsx18("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 12 }, children: data.meetings.map((m) => /* @__PURE__ */ jsxs14("li", { style: { display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", opacity: m.status === "cancelled" ? 0.55 : 1 }, children: [
1673
- /* @__PURE__ */ jsx18("strong", { style: { minWidth: 170 }, children: when2(m.startAt) }),
1674
- /* @__PURE__ */ jsx18("span", { style: { flex: 1 }, children: m.applicant ? `${m.applicant.name} \xB7 ${m.applicant.email}` : copy.unknown }),
1675
- /* @__PURE__ */ jsx18("span", { className: "badge", children: m.status }),
1676
- m.drift && m.drift !== "none" ? /* @__PURE__ */ jsxs14("span", { className: "badge", children: [
1796
+ ] }), actions: note ? /* @__PURE__ */ jsx19("span", { className: "muted", children: note }) : void 0, children: data.meetings.length === 0 ? /* @__PURE__ */ jsx19("p", { className: "muted", style: { margin: 0 }, children: copy.empty }) : /* @__PURE__ */ jsx19("ul", { style: { listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 12 }, children: data.meetings.map((m) => /* @__PURE__ */ jsxs15("li", { style: { display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", opacity: m.status === "cancelled" ? 0.55 : 1 }, children: [
1797
+ /* @__PURE__ */ jsx19("strong", { style: { minWidth: 170 }, children: when2(m.startAt) }),
1798
+ /* @__PURE__ */ jsx19("span", { style: { flex: 1 }, children: m.applicant ? `${m.applicant.name} \xB7 ${m.applicant.email}` : copy.unknown }),
1799
+ /* @__PURE__ */ jsx19("span", { className: "badge", children: m.status }),
1800
+ m.drift && m.drift !== "none" ? /* @__PURE__ */ jsxs15("span", { className: "badge", children: [
1677
1801
  copy.drift,
1678
1802
  ": ",
1679
1803
  m.drift
1680
1804
  ] }) : null,
1681
- m.meetUrl ? /* @__PURE__ */ jsx18("a", { href: m.meetUrl, target: "_blank", rel: "noreferrer", children: copy.meet }) : null,
1682
- m.status === "scheduled" ? /* @__PURE__ */ jsxs14(Fragment4, { children: [
1683
- /* @__PURE__ */ jsx18(Button6, { variant: "secondary", mini: true, disabled: busy === m.id, onClick: () => reschedule(m.id), children: copy.reschedule }),
1684
- /* @__PURE__ */ jsx18(Button6, { variant: "danger", mini: true, disabled: busy === m.id, onClick: () => cancel(m.id), children: copy.cancel })
1805
+ m.meetUrl ? /* @__PURE__ */ jsx19("a", { href: m.meetUrl, target: "_blank", rel: "noreferrer", children: copy.meet }) : null,
1806
+ m.status === "scheduled" ? /* @__PURE__ */ jsxs15(Fragment4, { children: [
1807
+ /* @__PURE__ */ jsx19(Button6, { variant: "secondary", mini: true, disabled: busy === m.id, onClick: () => reschedule(m.id), children: copy.reschedule }),
1808
+ /* @__PURE__ */ jsx19(Button6, { variant: "danger", mini: true, disabled: busy === m.id, onClick: () => cancel(m.id), children: copy.cancel })
1685
1809
  ] }) : null
1686
1810
  ] }, m.id)) }) }) });
1687
1811
  }
1688
1812
  function meetingsSection(options = {}) {
1689
1813
  const { id = "meetings", label = "Meetings" } = options;
1690
- return { id, label, render: (ctx) => /* @__PURE__ */ jsx18(MeetingsBody, { ctx }) };
1814
+ return { id, label, render: (ctx) => /* @__PURE__ */ jsx19(MeetingsBody, { ctx }) };
1691
1815
  }
1692
1816
 
1693
1817
  export {
@@ -1710,6 +1834,7 @@ export {
1710
1834
  applicationLifecycleAdapter,
1711
1835
  collectionSection,
1712
1836
  peopleSection,
1837
+ LeaderNetworkOverview,
1713
1838
  defaultAdminWorkspaces,
1714
1839
  loadChapterAdminConfig,
1715
1840
  loadChapterAdminUser,
@@ -1717,4 +1842,4 @@ export {
1717
1842
  defaultAdminSections,
1718
1843
  meetingsSection
1719
1844
  };
1720
- //# sourceMappingURL=chunk-4FW35E3P.js.map
1845
+ //# sourceMappingURL=chunk-4QZEAWQL.js.map