@odla-ai/chapter 0.54.0 → 0.54.2

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
@@ -1520,7 +1520,7 @@ These bite silently — a smoke test won't catch them:
1520
1520
  | `submitConfirmation` | `POST /api/applications` (submit) — a free tier always; a paid tier only when `paymentConfirmation` is disabled, so a paid applicant is confirmed once | template `enabled` flag |
1521
1521
  | `prepEmail` | `POST /api/schedule/book` — once per application; a reschedule sends no second one | template `enabled` flag |
1522
1522
  | `paymentConfirmation` | Stripe webhook, first successful invoice | template `enabled` flag |
1523
- | `seatAccepted` | `POST /api/named-seat/accept` — to the seat's purchaser, once per seat, naming the recipient and tier | template `enabled` flag; the seeded default copy applies until the group row carries its own `emailTemplates.seatAccepted` |
1523
+ | `seatAccepted` | `POST /api/named-seat/accept` — to the seat's purchaser, once per seat, naming the recipient and tier | explicit `enabled: true` on a stored `emailTemplates.seatAccepted`; missing copy blocks delivery |
1524
1524
 
1525
1525
  ```ts
1526
1526
  defineChapter({ /* … */ sends: { adminNotification: "payment" } });
@@ -1528,8 +1528,19 @@ These bite silently — a smoke test won't catch them:
1528
1528
 
1529
1529
  **Template variables.** Every value comes from stored data — the frozen
1530
1530
  checkout the applicant consented to, the tier catalog, the booked meeting,
1531
- the request origin — never from a price or a name written into code. An
1532
- unknown placeholder renders empty, so copy can use any subset.
1531
+ the request origin — never from a price or a name written into code. Sends
1532
+ reject unknown placeholders, missing required values, internal draft notes,
1533
+ incomplete copy, and unresolved tier display names. Optional blanks must be
1534
+ declared by the event (for example, a free tier’s payment amount suffix).
1535
+
1536
+ **Readiness on page open.** The Email page checks every built-in and host event
1537
+ against saved templates and current tier names. Missing templates and invalid
1538
+ values appear with the event’s trigger and recipient. Explicitly disabled
1539
+ emails stay off. `emails.events` declares custom reminder contracts; pass the
1540
+ same event to `sendTemplated`. Gift invitations and acceptance notices require
1541
+ stored copy; delivery never supplies a hidden fallback. Hub mode uses the
1542
+ site’s actual CRM template registry and CRM settings. All automatic sends
1543
+ require explicit `enabled: true`; validation never enables a template.
1533
1544
 
1534
1545
  | Variable | Templates | Value |
1535
1546
  |---|---|---|
@@ -1779,7 +1790,8 @@ These bite silently — a smoke test won't catch them:
1779
1790
  included, and the offer reports `purchaseWindowed: false`. Memberships frozen
1780
1791
  under an earlier windowed offering keep their window until the network owner
1781
1792
  lifts it once per chapter with `POST /api/admin/membership-seats/lift-purchase-window`
1782
- (super admin; only ever widens).
1793
+ (super admin; only ever widens), or from a script with `liftSeatPurchaseWindow`
1794
+ (exported from `@odla-ai/chapter/worker` beside `lowerSeatPrice`).
1783
1795
 
1784
1796
  On the payment step, a difference between the standard and due amounts is
1785
1797
  labelled a founding discount only when the quote carries `founding` from the
@@ -2627,7 +2627,7 @@ function EmailDeliveryResolution({ row, ctx, onResolved }) {
2627
2627
  }
2628
2628
 
2629
2629
  // src/ui/admin-email.tsx
2630
- import { jsx as jsx29, jsxs as jsxs27 } from "preact/jsx-runtime";
2630
+ import { Fragment as Fragment12, jsx as jsx29, jsxs as jsxs27 } from "preact/jsx-runtime";
2631
2631
  var logColumns = (copy, ctx, refresh) => [
2632
2632
  { key: "sentAt", header: copy.sentColumn, cell: (r) => new Date(r.sentAt).toLocaleString(), sortAs: "number", sortValue: (r) => r.sentAt },
2633
2633
  { key: "template", header: copy.templateColumn, cell: (r) => r.template },
@@ -2652,7 +2652,7 @@ function EmailBody({ ctx }) {
2652
2652
  "."
2653
2653
  ] });
2654
2654
  const edit = (patch) => setDraft({ ...data, ...patch });
2655
- const editTemplate = (key, patch) => edit({ emailTemplates: { ...data.emailTemplates, [key]: { ...data.emailTemplates[key], ...patch } } });
2655
+ const editTemplate = (key, patch) => edit({ emailTemplates: { ...data.emailTemplates, [key]: { subject: "", text: "", enabled: false, ...data.emailTemplates[key], ...patch } } });
2656
2656
  const run = async (key, fn) => {
2657
2657
  setBusy(key);
2658
2658
  setNote(null);
@@ -2680,6 +2680,24 @@ function EmailBody({ ctx }) {
2680
2680
  log.refresh();
2681
2681
  });
2682
2682
  return /* @__PURE__ */ jsxs27(AdminPage, { children: [
2683
+ /* @__PURE__ */ jsxs27(Panel, { title: "Email readiness", actions: /* @__PURE__ */ jsx29(Button16, { variant: "secondary", mini: true, disabled: !!draft, onClick: () => cfg.refresh(), children: "Check again" }), children: [
2684
+ /* @__PURE__ */ jsx29("p", { children: "Each event is checked against its saved email and current tier names when this page opens. Missing copy or required values blocks delivery." }),
2685
+ data.validation ? /* @__PURE__ */ jsxs27(Fragment12, { children: [
2686
+ /* @__PURE__ */ jsxs27("p", { children: [
2687
+ data.validation.events.filter((event) => event.status === "blocked").length,
2688
+ " blocked \xB7 ",
2689
+ data.validation.events.filter((event) => event.status === "disabled").length,
2690
+ " switched off \xB7 ",
2691
+ data.validation.events.filter((event) => event.status === "ready").length,
2692
+ " ready"
2693
+ ] }),
2694
+ data.validation.issues.length ? /* @__PURE__ */ jsx29("ul", { role: "alert", children: data.validation.issues.map((issue, i) => /* @__PURE__ */ jsx29("li", { children: issue.message }, i)) }) : null,
2695
+ draft ? /* @__PURE__ */ jsx29("p", { role: "status", children: "Save your changes to refresh these checks." }) : /* @__PURE__ */ jsxs27("p", { className: "muted", children: [
2696
+ "Checked ",
2697
+ new Date(data.validation.checkedAt).toLocaleString()
2698
+ ] })
2699
+ ] }) : /* @__PURE__ */ jsx29("p", { role: "alert", children: "Email validation is unavailable. Refresh after the server has been updated." })
2700
+ ] }),
2683
2701
  /* @__PURE__ */ jsxs27(
2684
2702
  Panel,
2685
2703
  {
@@ -2693,16 +2711,31 @@ function EmailBody({ ctx }) {
2693
2711
  children: [
2694
2712
  /* @__PURE__ */ jsx29(Field13, { label: copy.notificationAddress, children: /* @__PURE__ */ jsx29(Input6, { value: data.notificationEmail, onInput: (e) => edit({ notificationEmail: e.target.value }) }) }),
2695
2713
  /* @__PURE__ */ jsx29(Field13, { label: copy.replyTo, children: /* @__PURE__ */ jsx29(Input6, { value: data.replyTo, onInput: (e) => edit({ replyTo: e.target.value }) }) }),
2696
- /* @__PURE__ */ jsx29(Field13, { label: copy.fromName, hint: copy.fromNameHint, children: /* @__PURE__ */ jsx29(Input6, { value: data.fromName ?? "", onInput: (e) => edit({ fromName: e.target.value }) }) }),
2714
+ data.testAvailable ? /* @__PURE__ */ jsx29(Field13, { label: copy.fromName, hint: copy.fromNameHint, children: /* @__PURE__ */ jsx29(Input6, { value: data.fromName ?? "", onInput: (e) => edit({ fromName: e.target.value }) }) }) : null,
2697
2715
  /* @__PURE__ */ jsx29(Field13, { label: copy.debugInbox, hint: copy.debugInboxHint, children: /* @__PURE__ */ jsx29(Input6, { value: data.debugEmail, onInput: (e) => edit({ debugEmail: e.target.value }) }) })
2698
2716
  ]
2699
2717
  }
2700
2718
  ),
2701
- Object.entries(data.emailTemplates).map(([key, t]) => /* @__PURE__ */ jsxs27(Panel, { title: key, actions: /* @__PURE__ */ jsx29(Button16, { variant: "secondary", mini: true, disabled: busy === `test:${key}`, onClick: () => void test(key), children: copy.sendTest }), children: [
2702
- /* @__PURE__ */ jsx29(Checkbox2, { label: copy.enabled, checked: t.enabled, onChange: (e) => editTemplate(key, { enabled: e.target.checked }) }),
2703
- /* @__PURE__ */ jsx29(Field13, { label: copy.subject, children: /* @__PURE__ */ jsx29(Input6, { value: t.subject, onInput: (e) => editTemplate(key, { subject: e.target.value }) }) }),
2704
- /* @__PURE__ */ jsx29(Field13, { label: copy.body, children: /* @__PURE__ */ jsx29(Textarea5, { prose: true, rows: 5, value: t.text, onInput: (e) => editTemplate(key, { text: e.target.value }) }) })
2705
- ] }, key)),
2719
+ (data.validation?.events ?? []).map((event) => {
2720
+ const key = event.key, t = data.emailTemplates[key] ?? { subject: "", text: "", enabled: false };
2721
+ return /* @__PURE__ */ jsxs27(Panel, { title: event.label, actions: /* @__PURE__ */ jsx29("span", { children: event.status === "ready" ? "Ready" : event.status === "disabled" ? "Switched off" : "Blocked" }), children: [
2722
+ /* @__PURE__ */ jsxs27("p", { children: [
2723
+ event.trigger,
2724
+ ". Recipient: ",
2725
+ event.audience,
2726
+ "."
2727
+ ] }),
2728
+ event.issues.length ? /* @__PURE__ */ jsx29("ul", { children: event.issues.map((issue, i) => /* @__PURE__ */ jsx29("li", { children: issue.message }, i)) }) : null,
2729
+ /* @__PURE__ */ jsx29(Checkbox2, { label: copy.enabled, checked: t.enabled === true, onChange: (e) => editTemplate(key, { enabled: e.target.checked }) }),
2730
+ /* @__PURE__ */ jsx29(Field13, { label: copy.subject, children: /* @__PURE__ */ jsx29(Input6, { value: t.subject, onInput: (e) => editTemplate(key, { subject: e.target.value }) }) }),
2731
+ /* @__PURE__ */ jsx29(Field13, { label: copy.body, children: /* @__PURE__ */ jsx29(Textarea5, { prose: true, rows: 5, value: t.text, onInput: (e) => editTemplate(key, { text: e.target.value }) }) }),
2732
+ /* @__PURE__ */ jsxs27("p", { className: "muted", children: [
2733
+ "Available values: ",
2734
+ event.variables.map((name) => `{{${name}}}`).join(", ")
2735
+ ] }),
2736
+ data.testAvailable ? /* @__PURE__ */ jsx29(Button16, { variant: "secondary", mini: true, disabled: !!draft || event.issues.length > 0 || busy === `test:${key}`, onClick: () => void test(key), children: copy.sendTest }) : null
2737
+ ] }, key);
2738
+ }),
2706
2739
  /* @__PURE__ */ jsxs27("div", { style: { display: "flex", gap: 12, alignItems: "center" }, children: [
2707
2740
  /* @__PURE__ */ jsx29(Button16, { disabled: !draft || busy === "save", onClick: () => void save(), children: copy.save }),
2708
2741
  note ? /* @__PURE__ */ jsx29("span", { className: "muted", children: note }) : null
@@ -3298,7 +3331,7 @@ function defaultAdminSections(chapter) {
3298
3331
  // src/ui/admin-meetings.tsx
3299
3332
  import { useState as useState28 } from "preact/hooks";
3300
3333
  import { Button as Button17 } from "@odla-ai/ui/components";
3301
- import { Fragment as Fragment12, jsx as jsx35, jsxs as jsxs31 } from "preact/jsx-runtime";
3334
+ import { Fragment as Fragment13, jsx as jsx35, jsxs as jsxs31 } from "preact/jsx-runtime";
3302
3335
  var when2 = (t) => new Date(t).toLocaleString([], { dateStyle: "medium", timeStyle: "short" });
3303
3336
  function MeetingsBody({ ctx }) {
3304
3337
  const copy = useChapterCopy().admin.meetings;
@@ -3337,7 +3370,7 @@ function MeetingsBody({ ctx }) {
3337
3370
  }
3338
3371
  void run(id, () => adminFetch(ctx.getToken, `/api/admin/meetings/${id}/reschedule`, { method: "POST", body: JSON.stringify({ startAt }) }));
3339
3372
  };
3340
- return /* @__PURE__ */ jsx35(AdminPage, { children: /* @__PURE__ */ jsx35(Panel, { title: /* @__PURE__ */ jsxs31(Fragment12, { children: [
3373
+ return /* @__PURE__ */ jsx35(AdminPage, { children: /* @__PURE__ */ jsx35(Panel, { title: /* @__PURE__ */ jsxs31(Fragment13, { children: [
3341
3374
  copy.agenda,
3342
3375
  " ",
3343
3376
  /* @__PURE__ */ jsxs31("span", { className: "muted", style: { fontWeight: 400 }, children: [
@@ -3354,7 +3387,7 @@ function MeetingsBody({ ctx }) {
3354
3387
  m.drift
3355
3388
  ] }) : null,
3356
3389
  m.meetUrl ? /* @__PURE__ */ jsx35("a", { href: m.meetUrl, target: "_blank", rel: "noreferrer", children: copy.meet }) : null,
3357
- m.status === "scheduled" ? /* @__PURE__ */ jsxs31(Fragment12, { children: [
3390
+ m.status === "scheduled" ? /* @__PURE__ */ jsxs31(Fragment13, { children: [
3358
3391
  /* @__PURE__ */ jsx35(Button17, { variant: "secondary", mini: true, disabled: busy === m.id, onClick: () => reschedule(m.id), children: copy.reschedule }),
3359
3392
  /* @__PURE__ */ jsx35(Button17, { variant: "danger", mini: true, disabled: busy === m.id, onClick: () => cancel(m.id), children: copy.cancel })
3360
3393
  ] }) : null
@@ -3368,7 +3401,7 @@ function meetingsSection(options = {}) {
3368
3401
  // src/ui/admin-network-record-editor.tsx
3369
3402
  import { Button as Button18, Field as Field14, Select as Select10 } from "@odla-ai/ui/components";
3370
3403
  import { useRef as useRef4, useState as useState29 } from "preact/hooks";
3371
- import { Fragment as Fragment13, jsx as jsx36, jsxs as jsxs32 } from "preact/jsx-runtime";
3404
+ import { Fragment as Fragment14, jsx as jsx36, jsxs as jsxs32 } from "preact/jsx-runtime";
3372
3405
  function NetworkRecordEditor(props) {
3373
3406
  const { ctx, record } = props;
3374
3407
  const [stage, setStage] = useState29("");
@@ -3407,7 +3440,7 @@ function NetworkRecordEditor(props) {
3407
3440
  }
3408
3441
  };
3409
3442
  return /* @__PURE__ */ jsxs32("div", { style: { display: "grid", gap: 24 }, children: [
3410
- superAdmin && record.edit ? /* @__PURE__ */ jsxs32(Fragment13, { children: [
3443
+ superAdmin && record.edit ? /* @__PURE__ */ jsxs32(Fragment14, { children: [
3411
3444
  /* @__PURE__ */ jsxs32("section", { children: [
3412
3445
  /* @__PURE__ */ jsx36("h3", { children: "Chapter profile" }),
3413
3446
  /* @__PURE__ */ jsx36(
@@ -3508,7 +3541,7 @@ function NetworkSeatBilling(props) {
3508
3541
  // src/ui/membership-policy-versions.tsx
3509
3542
  import { Button as Button20, DataTable as DataTable5 } from "@odla-ai/ui/components";
3510
3543
  import { useState as useState31 } from "preact/hooks";
3511
- import { Fragment as Fragment14, jsx as jsx38, jsxs as jsxs34 } from "preact/jsx-runtime";
3544
+ import { Fragment as Fragment15, jsx as jsx38, jsxs as jsxs34 } from "preact/jsx-runtime";
3512
3545
  var PATH = "/api/admin/membership-policy-versions";
3513
3546
  var RETERM = "/api/admin/membership-discount/reterm";
3514
3547
  var day = (ms) => new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
@@ -3524,17 +3557,17 @@ var State = ({ row }) => /* @__PURE__ */ jsx38("span", { className: row.state ==
3524
3557
  var superseded = (row) => row.supersededAt === void 0 ? "\u2014" : day(row.supersededAt);
3525
3558
  var Source = ({ row }) => /* @__PURE__ */ jsxs34("span", { children: [
3526
3559
  row.source ?? /* @__PURE__ */ jsx38("span", { className: "muted", children: "Unrecorded" }),
3527
- row.actor ? /* @__PURE__ */ jsxs34(Fragment14, { children: [
3560
+ row.actor ? /* @__PURE__ */ jsxs34(Fragment15, { children: [
3528
3561
  " \xB7 ",
3529
3562
  /* @__PURE__ */ jsx38("code", { children: row.actor })
3530
3563
  ] }) : null,
3531
- row.recordedAt !== void 0 ? /* @__PURE__ */ jsxs34(Fragment14, { children: [
3564
+ row.recordedAt !== void 0 ? /* @__PURE__ */ jsxs34(Fragment15, { children: [
3532
3565
  " \xB7 ",
3533
3566
  day(row.recordedAt)
3534
3567
  ] }) : null
3535
3568
  ] });
3536
3569
  var offeringColumns = [
3537
- { key: "version", header: "Version", cell: (row) => /* @__PURE__ */ jsxs34(Fragment14, { children: [
3570
+ { key: "version", header: "Version", cell: (row) => /* @__PURE__ */ jsxs34(Fragment15, { children: [
3538
3571
  /* @__PURE__ */ jsxs34("code", { children: [
3539
3572
  "v",
3540
3573
  row.version
@@ -3552,7 +3585,7 @@ var offeringColumns = [
3552
3585
  { key: "source", header: "Source", cell: (row) => /* @__PURE__ */ jsx38(Source, { row }) }
3553
3586
  ];
3554
3587
  var policyColumns = (getToken) => [
3555
- { key: "version", header: "Version", cell: (row) => /* @__PURE__ */ jsxs34(Fragment14, { children: [
3588
+ { key: "version", header: "Version", cell: (row) => /* @__PURE__ */ jsxs34(Fragment15, { children: [
3556
3589
  /* @__PURE__ */ jsxs34("code", { children: [
3557
3590
  "v",
3558
3591
  row.version
@@ -3560,7 +3593,7 @@ var policyColumns = (getToken) => [
3560
3593
  " ",
3561
3594
  /* @__PURE__ */ jsx38(State, { row })
3562
3595
  ] }), sortAs: "number", sortValue: (row) => row.version },
3563
- { key: "cohort", header: "Cohort", cell: (row) => /* @__PURE__ */ jsxs34(Fragment14, { children: [
3596
+ { key: "cohort", header: "Cohort", cell: (row) => /* @__PURE__ */ jsxs34(Fragment15, { children: [
3564
3597
  row.cohortLabel,
3565
3598
  " ",
3566
3599
  /* @__PURE__ */ jsxs34("span", { className: "muted", children: [
@@ -3617,7 +3650,7 @@ function MembershipPolicyVersions(props) {
3617
3650
  "Offering versions could not be loaded: ",
3618
3651
  error ?? "empty response"
3619
3652
  ] }) });
3620
- return /* @__PURE__ */ jsxs34(Fragment14, { children: [
3653
+ return /* @__PURE__ */ jsxs34(Fragment15, { children: [
3621
3654
  /* @__PURE__ */ jsxs34(Panel, { title: "Membership offering versions", actions: data.truncated ? /* @__PURE__ */ jsx38("span", { className: "badge", children: "Truncated" }) : void 0, children: [
3622
3655
  /* @__PURE__ */ jsxs34("p", { className: "muted", children: [
3623
3656
  "The list price a card shows comes from the active offering; the payable price applies the active discount policy. State is judged as of ",
@@ -3704,4 +3737,4 @@ export {
3704
3737
  NetworkSeatBilling,
3705
3738
  MembershipPolicyVersions
3706
3739
  };
3707
- //# sourceMappingURL=chunk-NDZCR3OW.js.map
3740
+ //# sourceMappingURL=chunk-ZAE24YKY.js.map