@odla-ai/chapter 0.25.6 → 0.26.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
@@ -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
@@ -316,19 +389,22 @@ Treat the network as websites connected by explicit delivery edges:
316
389
  - each `network.targets[]` entry is a directed edge from one leader to one
317
390
  follower: `{ id, name, url, secretName?, fields }`;
318
391
  - 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;
392
+ `{ version: 2, source: { siteId, recordId }, type, input }`, never an
393
+ identity-provider session, another site's pipeline/account/billing state, or
394
+ an ODLA admin key;
321
395
  - retries address the follower record by leader provenance, so the same edge
322
396
  updates rather than duplicates.
323
397
 
324
398
  The authorization chain has three separate hops. The leader's browser sends
325
399
  its own Clerk session only to the leader's admin route. The leader Worker
326
400
  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.
401
+ vault, and signs the exact method, path/query, sender id, timestamp, nonce, and
402
+ body digest. The follower validates that HMAC envelope against its vaulted
403
+ copy, requires the signed sender to match the v2 payload source, validates the
404
+ payload against its own CRM config, and writes with its own server-side
405
+ `ODLA_API_KEY`. The share secret itself is never sent. A follower Clerk token
406
+ is never needed, the leader never receives the follower's database key, and
407
+ sites do not share a Clerk application.
332
408
 
333
409
  Vault the same random value as `network_share_secret` in the follower and as
334
410
  `network_share_example_chapter` in the leader (or set a different
@@ -337,6 +413,25 @@ config. The standard collection drawer discovers the target and calls the
337
413
  admin-gated push route. A follower must declare the receiving CRM type/fields;
338
414
  otherwise it rejects the record cleanly instead of dropping fields.
339
415
 
416
+ The signed v2 protocol is the contract for new integrations. The follower
417
+ temporarily accepts the old bearer/v1 envelope so an existing edge can be
418
+ upgraded without downtime; new leaders must not use it.
419
+
420
+ Every received v2 record gets a `crm_record_origin` row keyed by the upstream
421
+ site and record id. Every push attempt gets a `crm_record_delivery` row keyed by
422
+ the local record and target, including attempt count, current status, payload
423
+ version, remote record id, and bounded latest error. Record detail responses
424
+ include both collections and the standard record drawer adds a Network tab
425
+ when either exists.
426
+
427
+ `GET /api/network/snapshot` is an HMAC-signed, aggregate-only follower endpoint.
428
+ It returns type totals and pipeline-stage counts, never record fields, people,
429
+ sessions, Clerk tokens, share secrets, or ODLA keys. The leader's admin-gated
430
+ `GET /api/admin/network/rollup` fans out to every configured target, verifies
431
+ that each returned site id equals the target id, and returns combined totals
432
+ plus explicit per-target failures. Consequently each target `id` must equal the
433
+ follower's `chapter.id`.
434
+
340
435
  #### Existing follower CRMs must opt into the shared graph
341
436
 
342
437
  When `crm` is omitted, chapter's default already contains compatible `person`
@@ -436,7 +531,8 @@ data.
436
531
 
437
532
  Use distinct development tenants and follower origins for the first delivery:
438
533
 
439
- 1. Vault one random value as `network_share_secret` in the follower and under
534
+ 1. Confirm every target `id` exactly equals the follower's `chapter.id`. Vault
535
+ one random value as `network_share_secret` in the follower and under
440
536
  the target's resolved `secretName` in the leader.
441
537
  2. Confirm `GET /api/admin/network/targets` lists the development follower with
442
538
  the expected compatible record types.
@@ -451,7 +547,11 @@ Use distinct development tenants and follower origins for the first delivery:
451
547
  without writing CRM data.
452
548
  7. Prove each browser JWT is accepted only by its own website. Inspect the
453
549
  network request and client bundle to confirm neither site's Clerk token nor
454
- either `ODLA_API_KEY` crosses the delivery edge.
550
+ either `ODLA_API_KEY` crosses the delivery edge, and confirm the share
551
+ secret itself is absent from the request.
552
+ 8. Load `GET /api/admin/network/rollup` through the leader session. Confirm
553
+ aggregate totals, then break one development edge and confirm that target is
554
+ reported unavailable without hiding healthy targets.
455
555
 
456
556
  Only after this contract passes against development origins should the leader
457
557
  target be changed to a production follower origin and the matching production
@@ -468,7 +568,7 @@ Legacy `?tab=` and path links still open, while `routing="query"` and
468
568
 
469
569
  ### Admin information architecture and migration
470
570
 
471
- The default chapter console deliberately has only three top-level workspaces:
571
+ The default follower console deliberately has only three top-level workspaces:
472
572
 
473
573
  - **Dashboard** — Overview and Billing page tabs.
474
574
  - **People** — configured CRM collections as page tabs, then a master/detail
@@ -476,6 +576,11 @@ The default chapter console deliberately has only three top-level workspaces:
476
576
  Connections, Access, and Sharing record tabs when those capabilities apply.
477
577
  - **Settings** — Calendar and Email page tabs.
478
578
 
579
+ A leader hub with configured targets adds **Portfolio** between People and
580
+ Settings and uses Dashboard Overview for the aggregate network rollup. A hub
581
+ without targets retains the minimal People-only default. These defaults compose
582
+ the same grammar; they do not introduce a second admin shell.
583
+
479
584
  The default `chrome="embedded"` assumes the host supplies the site header.
480
585
  Use `chrome="standalone"` for Chapter's packaged masthead or `renderHeader` for
481
586
  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";
@@ -177,6 +177,7 @@ function Gate(props) {
177
177
  }
178
178
 
179
179
  // src/ui/admin-route.ts
180
+ import { parseCrmDiscussionReference } from "@odla-ai/crm";
180
181
  var DEFAULT_ALIASES = {
181
182
  billing: { workspaceId: "dashboard", viewId: "billing" },
182
183
  meetings: { workspaceId: "dashboard" },
@@ -218,6 +219,14 @@ function adminRouteFromUrl(url, basePath, workspaceIds, aliases = {}) {
218
219
  const hashParts = url.hash.replace(/^#\/?/, "").split("/").filter(Boolean);
219
220
  const fromHash = resolve(fromParts(hashParts), workspaceIds, aliases);
220
221
  if (fromHash) return fromHash;
222
+ const reference = parseCrmDiscussionReference(url);
223
+ const fromReference = reference ? resolve({
224
+ workspaceId: "people",
225
+ viewId: reference.type,
226
+ recordId: reference.recordId,
227
+ detailTab: reference.kind === "crm:activity" ? "notes" : "profile"
228
+ }, workspaceIds, aliases) : null;
229
+ if (fromReference) return fromReference;
221
230
  const tab = url.searchParams.get("tab");
222
231
  const fromQuery = resolve(
223
232
  tab ? {
@@ -843,12 +852,15 @@ function NetworkShareActions(props) {
843
852
  // src/ui/admin-record-activity.tsx
844
853
  import { useCallback as useCallback3, useEffect as useEffect5, useState as useState8 } from "preact/hooks";
845
854
  import { ActivityFeed } from "@odla-ai/crm/ui";
855
+ import { parseCrmDiscussionReference as parseCrmDiscussionReference2 } from "@odla-ai/crm";
846
856
  import { jsx as jsx10 } from "preact/jsx-runtime";
847
857
  function RecordActivityPanel(props) {
848
858
  const { client, recordId } = props;
849
859
  const [activities, setActivities] = useState8([]);
850
860
  const [busy, setBusy] = useState8(false);
851
861
  const [error, setError] = useState8(null);
862
+ const reference = typeof window === "undefined" ? null : parseCrmDiscussionReference2(new URL(window.location.href));
863
+ const focusedActivityId = reference?.kind === "crm:activity" && reference.recordId === recordId ? reference.activityId : void 0;
852
864
  const load = useCallback3(async () => {
853
865
  try {
854
866
  const result = await client.listActivities(recordId, { limit: 100 });
@@ -873,6 +885,7 @@ function RecordActivityPanel(props) {
873
885
  ActivityFeed,
874
886
  {
875
887
  activities,
888
+ focusedActivityId,
876
889
  busy,
877
890
  onAddNote: (body) => void run(() => client.addActivity(recordId, { kind: "note", body })),
878
891
  onAddTask: (task) => void run(() => client.addActivity(recordId, { kind: "task", ...task })),
@@ -1242,8 +1255,46 @@ function peopleSection(options) {
1242
1255
  });
1243
1256
  }
1244
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
+
1245
1296
  // src/ui/admin-workspaces.tsx
1246
- import { jsx as jsx15 } from "preact/jsx-runtime";
1297
+ import { jsx as jsx16 } from "preact/jsx-runtime";
1247
1298
  var adminViewContext = (ctx, viewId, active) => {
1248
1299
  const route = {
1249
1300
  workspaceId: ctx.route.workspaceId,
@@ -1269,7 +1320,7 @@ function DashboardWorkspace({ chapter, ctx }) {
1269
1320
  const active = ctx.route.viewId === "billing" ? "billing" : "overview";
1270
1321
  const overview = dashboardSection({ id: "overview", label: copy.workspaces.overview });
1271
1322
  const billing = billingSection({ label: copy.workspaces.billing });
1272
- return /* @__PURE__ */ jsx15(
1323
+ return /* @__PURE__ */ jsx16(
1273
1324
  Tabs2,
1274
1325
  {
1275
1326
  ariaLabel: copy.workspaces.dashboardViewsLabel,
@@ -1297,7 +1348,7 @@ function SettingsWorkspace({ chapter, ctx }) {
1297
1348
  const active = ctx.route.viewId === "email" ? "email" : "calendar";
1298
1349
  const calendar = availabilitySection({ id: "calendar", label: copy.workspaces.calendar });
1299
1350
  const email = emailSection({ label: copy.workspaces.email });
1300
- return /* @__PURE__ */ jsx15(
1351
+ return /* @__PURE__ */ jsx16(
1301
1352
  Tabs2,
1302
1353
  {
1303
1354
  ariaLabel: copy.workspaces.settingsViewsLabel,
@@ -1321,11 +1372,11 @@ function SettingsWorkspace({ chapter, ctx }) {
1321
1372
  );
1322
1373
  }
1323
1374
  function PeopleWorkspace({ chapter, ctx }) {
1324
- const types = Object.entries(chapter.crm.config.types);
1375
+ const types = Object.entries(chapter.crm.config.types).filter(([, def]) => (def.workspace ?? "people") === "people");
1325
1376
  const fallback = types[0]?.[0] ?? "person";
1326
1377
  const active = types.some(([type]) => type === ctx.route.viewId) ? ctx.route.viewId : fallback;
1327
1378
  const networkSharing = chapter.network.targets.length > 0;
1328
- return /* @__PURE__ */ jsx15(
1379
+ return /* @__PURE__ */ jsx16(
1329
1380
  Tabs2,
1330
1381
  {
1331
1382
  ariaLabel: chapter.copy.admin.workspaces.collectionsLabel,
@@ -1350,38 +1401,124 @@ function PeopleWorkspace({ chapter, ctx }) {
1350
1401
  }
1351
1402
  );
1352
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
+ }
1353
1466
  function defaultAdminWorkspaces(chapter) {
1354
1467
  const copy = chapter.copy.admin;
1355
1468
  const people = {
1356
1469
  id: "people",
1357
1470
  label: copy.workspaces.people,
1358
1471
  defaultViewId: Object.keys(chapter.crm.config.types)[0] ?? "person",
1359
- render: (ctx) => /* @__PURE__ */ jsx15(PeopleWorkspace, { chapter, ctx })
1472
+ render: (ctx) => /* @__PURE__ */ jsx16(PeopleWorkspace, { chapter, ctx })
1360
1473
  };
1361
- 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
+ }
1362
1499
  return [
1363
1500
  {
1364
1501
  id: "dashboard",
1365
1502
  label: copy.workspaces.dashboard,
1366
1503
  defaultViewId: "overview",
1367
- render: (ctx) => /* @__PURE__ */ jsx15(DashboardWorkspace, { chapter, ctx })
1504
+ render: (ctx) => /* @__PURE__ */ jsx16(DashboardWorkspace, { chapter, ctx })
1368
1505
  },
1369
1506
  people,
1370
1507
  {
1371
1508
  id: "settings",
1372
1509
  label: copy.workspaces.settings,
1373
1510
  defaultViewId: "calendar",
1374
- render: (ctx) => /* @__PURE__ */ jsx15(SettingsWorkspace, { chapter, ctx })
1511
+ render: (ctx) => /* @__PURE__ */ jsx16(SettingsWorkspace, { chapter, ctx })
1375
1512
  }
1376
1513
  ];
1377
1514
  }
1378
1515
 
1379
1516
  // src/ui/admin-frame.tsx
1380
1517
  import { ThemeScope as ThemeScope2 } from "@odla-ai/ui/components";
1381
- import { jsx as jsx16, jsxs as jsxs12 } from "preact/jsx-runtime";
1518
+ import { jsx as jsx17, jsxs as jsxs13 } from "preact/jsx-runtime";
1382
1519
  function AdminTheme(props) {
1383
1520
  const brand = props.chapter?.brand;
1384
- return /* @__PURE__ */ jsx16(ChapterCopyProvider, { copy: props.chapter?.copy, children: /* @__PURE__ */ jsxs12(
1521
+ return /* @__PURE__ */ jsx17(ChapterCopyProvider, { copy: props.chapter?.copy, children: /* @__PURE__ */ jsxs13(
1385
1522
  ThemeScope2,
1386
1523
  {
1387
1524
  ref: props.scopeRef,
@@ -1390,7 +1527,7 @@ function AdminTheme(props) {
1390
1527
  accent: brand?.accent,
1391
1528
  colorScheme: brand?.colorScheme ?? "light",
1392
1529
  children: [
1393
- /* @__PURE__ */ jsx16(BrandStyle, { brand, selector: "[data-chapter-admin]" }),
1530
+ /* @__PURE__ */ jsx17(BrandStyle, { brand, selector: "[data-chapter-admin]" }),
1394
1531
  props.children
1395
1532
  ]
1396
1533
  }
@@ -1445,7 +1582,7 @@ async function loadChapterAdminUser(adapter, apiBase, getToken) {
1445
1582
  }
1446
1583
 
1447
1584
  // src/ui/admin.tsx
1448
- import { jsx as jsx17, jsxs as jsxs13 } from "preact/jsx-runtime";
1585
+ import { jsx as jsx18, jsxs as jsxs14 } from "preact/jsx-runtime";
1449
1586
  function Authed(props) {
1450
1587
  const copy = useChapterCopy().admin;
1451
1588
  const {
@@ -1492,17 +1629,17 @@ function Authed(props) {
1492
1629
  live = false;
1493
1630
  };
1494
1631
  }, [getToken, apiBase, auth]);
1495
- 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 }) });
1496
1633
  if (state.status === "denied") {
1497
- return /* @__PURE__ */ jsx17(Gate, { brand, children: /* @__PURE__ */ jsxs13("div", { className: "card", style: S.card, children: [
1498
- /* @__PURE__ */ jsx17("h2", { style: { marginTop: 0 }, children: copy.auth.notAuthorized }),
1499
- /* @__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, {
1500
1637
  account: state.email ?? copy.auth.thisAccount
1501
1638
  }) }),
1502
- /* @__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 })
1503
1640
  ] }) });
1504
1641
  }
1505
- return /* @__PURE__ */ jsx17(
1642
+ return /* @__PURE__ */ jsx18(
1506
1643
  AdminShell,
1507
1644
  {
1508
1645
  workspaces,
@@ -1555,23 +1692,23 @@ function ChapterAdmin(props) {
1555
1692
  }, [apiBase, auth]);
1556
1693
  let content;
1557
1694
  if (publishableKey === void 0 || publishableKey && !appearance) {
1558
- 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 }) });
1559
1696
  } else if (!publishableKey) {
1560
- content = /* @__PURE__ */ jsx17(Gate, { brand, children: /* @__PURE__ */ jsxs13("div", { className: "card", style: S.card, children: [
1561
- /* @__PURE__ */ jsx17("h2", { style: { marginTop: 0 }, children: copy.admin.auth.signInNotConfigured }),
1562
- /* @__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 })
1563
1700
  ] }) });
1564
1701
  } else if (workspaces.length === 0) {
1565
- 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 }) });
1566
1703
  } else {
1567
- content = /* @__PURE__ */ jsxs13(
1704
+ content = /* @__PURE__ */ jsxs14(
1568
1705
  ClerkGate,
1569
1706
  {
1570
1707
  publishableKey,
1571
1708
  appearance,
1572
1709
  afterSignOutUrl: "/",
1573
1710
  children: [
1574
- /* @__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(
1575
1712
  SignIn,
1576
1713
  {
1577
1714
  routing: "hash",
@@ -1579,7 +1716,7 @@ function ChapterAdmin(props) {
1579
1716
  signUpForceRedirectUrl: redirect
1580
1717
  }
1581
1718
  ) }) }),
1582
- /* @__PURE__ */ jsx17(SignedIn, { children: /* @__PURE__ */ jsx17(
1719
+ /* @__PURE__ */ jsx18(SignedIn, { children: /* @__PURE__ */ jsx18(
1583
1720
  Authed,
1584
1721
  {
1585
1722
  workspaces,
@@ -1599,7 +1736,7 @@ function ChapterAdmin(props) {
1599
1736
  }
1600
1737
  );
1601
1738
  }
1602
- return /* @__PURE__ */ jsx17(AdminTheme, { chapter: props.chapter, scopeRef, children: content });
1739
+ return /* @__PURE__ */ jsx18(AdminTheme, { chapter: props.chapter, scopeRef, children: content });
1603
1740
  }
1604
1741
 
1605
1742
  // src/ui/admin-defaults.ts
@@ -1610,15 +1747,15 @@ function defaultAdminSections(chapter) {
1610
1747
  // src/ui/admin-meetings.tsx
1611
1748
  import { useState as useState13 } from "preact/hooks";
1612
1749
  import { Button as Button6 } from "@odla-ai/ui/components";
1613
- 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";
1614
1751
  var when2 = (t) => new Date(t).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
1615
1752
  function MeetingsBody({ ctx }) {
1616
1753
  const copy = useChapterCopy().admin.meetings;
1617
1754
  const { data, loading, error, refresh } = useAdminResource(ctx.getToken, "/api/admin/meetings?all=1");
1618
1755
  const [busy, setBusy] = useState13(null);
1619
1756
  const [note, setNote] = useState13(null);
1620
- if (loading) return /* @__PURE__ */ jsx18(AdminNote, { children: copy.loading });
1621
- 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: [
1622
1759
  copy.loadFailed,
1623
1760
  error ? `: ${error}` : "",
1624
1761
  "."
@@ -1649,32 +1786,32 @@ function MeetingsBody({ ctx }) {
1649
1786
  }
1650
1787
  void run(id, () => adminFetch(ctx.getToken, `/api/admin/meetings/${id}/reschedule`, { method: "POST", body: JSON.stringify({ startAt }) }));
1651
1788
  };
1652
- 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: [
1653
1790
  copy.agenda,
1654
1791
  " ",
1655
- /* @__PURE__ */ jsxs14("span", { className: "muted", style: { fontWeight: 400 }, children: [
1792
+ /* @__PURE__ */ jsxs15("span", { className: "muted", style: { fontWeight: 400 }, children: [
1656
1793
  "\xB7 ",
1657
1794
  data.timezone
1658
1795
  ] })
1659
- ] }), 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: [
1660
- /* @__PURE__ */ jsx18("strong", { style: { minWidth: 170 }, children: when2(m.startAt) }),
1661
- /* @__PURE__ */ jsx18("span", { style: { flex: 1 }, children: m.applicant ? `${m.applicant.name} \xB7 ${m.applicant.email}` : copy.unknown }),
1662
- /* @__PURE__ */ jsx18("span", { className: "badge", children: m.status }),
1663
- 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: [
1664
1801
  copy.drift,
1665
1802
  ": ",
1666
1803
  m.drift
1667
1804
  ] }) : null,
1668
- m.meetUrl ? /* @__PURE__ */ jsx18("a", { href: m.meetUrl, target: "_blank", rel: "noreferrer", children: copy.meet }) : null,
1669
- m.status === "scheduled" ? /* @__PURE__ */ jsxs14(Fragment4, { children: [
1670
- /* @__PURE__ */ jsx18(Button6, { variant: "secondary", mini: true, disabled: busy === m.id, onClick: () => reschedule(m.id), children: copy.reschedule }),
1671
- /* @__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 })
1672
1809
  ] }) : null
1673
1810
  ] }, m.id)) }) }) });
1674
1811
  }
1675
1812
  function meetingsSection(options = {}) {
1676
1813
  const { id = "meetings", label = "Meetings" } = options;
1677
- return { id, label, render: (ctx) => /* @__PURE__ */ jsx18(MeetingsBody, { ctx }) };
1814
+ return { id, label, render: (ctx) => /* @__PURE__ */ jsx19(MeetingsBody, { ctx }) };
1678
1815
  }
1679
1816
 
1680
1817
  export {
@@ -1697,6 +1834,7 @@ export {
1697
1834
  applicationLifecycleAdapter,
1698
1835
  collectionSection,
1699
1836
  peopleSection,
1837
+ LeaderNetworkOverview,
1700
1838
  defaultAdminWorkspaces,
1701
1839
  loadChapterAdminConfig,
1702
1840
  loadChapterAdminUser,
@@ -1704,4 +1842,4 @@ export {
1704
1842
  defaultAdminSections,
1705
1843
  meetingsSection
1706
1844
  };
1707
- //# sourceMappingURL=chunk-5SWVMJZN.js.map
1845
+ //# sourceMappingURL=chunk-4QZEAWQL.js.map