@odla-ai/chapter 0.31.3 → 0.32.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
@@ -4,8 +4,9 @@ A foundation for **membership sites**. One `defineChapter({...})` config resolve
4
4
  the shared application engine — join/apply → Stripe membership → Google booking
5
5
  → member area — plus an admin console and CRM, or an **admin-only hub**, on
6
6
  odla-db + Clerk + [@odla-ai/crm](https://odla.ai/docs/packages/crm) + calendar +
7
- email. The host still builds the public pages, routing, and brand presentation;
8
- Chapter supplies their application mechanics.
7
+ email. The host still builds the public pages and routing. Chapter supplies the
8
+ application mechanics plus an optional, versioned membership-card renderer so
9
+ a hub preview and follower site can render the same reviewed content exactly.
9
10
 
10
11
  ```sh
11
12
  npm install @odla-ai/chapter
@@ -255,6 +256,95 @@ CRM, payment, booking, or account logic to achieve a different brand.
255
256
 
256
257
  ### Join and member composition
257
258
 
259
+ For a centrally managed membership page, validate a bounded presentation
260
+ document against the live, server-authoritative tiers, then render it with
261
+ `MembershipCards`:
262
+
263
+ ```tsx
264
+ import {
265
+ MEMBERSHIP_PAGE_RENDERER,
266
+ membershipPageDocument,
267
+ } from "@odla-ai/chapter";
268
+ import { MembershipCards } from "@odla-ai/chapter/ui/member";
269
+ import { ThemeScope } from "@odla-ai/ui/components";
270
+
271
+ const membershipPage = membershipPageDocument({
272
+ schemaVersion: 1,
273
+ renderer: MEMBERSHIP_PAGE_RENDERER,
274
+ tiers: [
275
+ {
276
+ tierId: "steward",
277
+ variant: "supporting",
278
+ badge: "Sustains the collective",
279
+ benefits: ["Everything in **Founding Member**", "The annual retreat"],
280
+ callout: {
281
+ label: "A word of thanks",
282
+ bodyMarkdown: "*You are the reason this collective can exist.*",
283
+ },
284
+ ctaLabel: "Fund the movement",
285
+ },
286
+ {
287
+ tierId: "founding",
288
+ variant: "featured",
289
+ badge: "Most members start here",
290
+ compareAtPriceCents: 100_000,
291
+ billingLabel: "a year",
292
+ capacity: { total: 100, label: "only" },
293
+ priceNoteMarkdown: "Your founding rate is **held for as long as you stay**.",
294
+ benefits: ["Live pitch nights", "A seat in diligence"],
295
+ ctaLabel: "Join the collective",
296
+ },
297
+ {
298
+ tierId: "associate",
299
+ summaryMarkdown: "Open to everyone, anywhere",
300
+ benefits: ["Founder pitch recordings", "The monthly member letter"],
301
+ ctaLabel: "Join for free",
302
+ },
303
+ ],
304
+ }, { tiers: joinConfig.tiers });
305
+
306
+ <ThemeScope tokens={approvedChapterTokens}>
307
+ <MembershipCards
308
+ tiers={joinConfig.tiers}
309
+ document={membershipPage}
310
+ applicationPath="/apply"
311
+ />
312
+ </ThemeScope>
313
+ ```
314
+
315
+ The document controls tier order and bounded copy slots. It cannot author
316
+ prices, free/paid state, tier identity, links, CSS, or HTML; those remain live
317
+ Chapter authority. Unknown fields, missing/duplicate tiers, oversized content,
318
+ and compare-at prices that do not exceed the charged price fail validation.
319
+ Markdown is rendered by the safe renderer from `@odla-ai/ui`.
320
+
321
+ `MembershipCards` is token-native, including `--ui-font-sans`,
322
+ `--ui-font-display`, and `--ui-font-numeral` as well as the semantic color,
323
+ surface, border, radius, spacing, and shadow tokens. Its responsive layout and
324
+ component CSS are versioned with the renderer. A CMS preview uses
325
+ `mode="preview"`; the public page uses the default link mode. Both therefore
326
+ share the same component, document, tier authority, and token scope.
327
+
328
+ The same renderer can also replace the placeholder chooser inside
329
+ `JoinIsland` without reimplementing selection:
330
+
331
+ ```tsx
332
+ <JoinIsland
333
+ config={joinConfig}
334
+ renderTiers={({ tiers, selectedTierId, selectTier }) => (
335
+ <MembershipCards
336
+ tiers={tiers}
337
+ document={membershipPage}
338
+ mode="select"
339
+ selectedTierId={selectedTierId}
340
+ onSelectTier={selectTier}
341
+ />
342
+ )}
343
+ >
344
+ <ApplicationFields />
345
+ </JoinIsland>
346
+ ```
347
+
258
348
  `JoinIsland` keeps application mechanics while exposing presentation seams:
259
349
 
260
350
  ```tsx
@@ -841,6 +931,14 @@ worker to 6 lines and deleted ~2,500 lines. The order that worked:
841
931
 
842
932
  These bite silently — a smoke test won't catch them:
843
933
 
934
+ - **Application fields generate the application schema.** The resolved
935
+ `application.required` and `application.optional` lists now control both
936
+ submit validation and the `applications` entity emitted by
937
+ `defineChapter()`. Built-in fields retain their types and indexes;
938
+ site-defined fields become string attrs. A field removed from the reference
939
+ form no longer remains accidentally required at `db.transact`, and a field
940
+ listed in both arrays is rejected at definition time. Keep package-owned
941
+ operational attrs such as `status` out of both lists.
844
942
  - **Per-field caps.** `application.defaultMaxLen` is 2000. If your form accepts
845
943
  longer input, pass `maxLen` explicitly or the default starts rejecting it.
846
944
  - **`services` default** is `["db","calendar","o11y"]`; `smoke` compares config
@@ -720,6 +720,284 @@ function JoinIsland(props) {
720
720
  ] });
721
721
  }
722
722
 
723
+ // src/ui/membership-cards.tsx
724
+ import { Markdown } from "@odla-ai/ui/components";
725
+
726
+ // src/membership-page.ts
727
+ var MEMBERSHIP_PAGE_RENDERER = "membership-cards-v1";
728
+ var TIER_ID = /^[a-z][a-z0-9-]{0,79}$/;
729
+ var VARIANTS = /* @__PURE__ */ new Set(["standard", "featured", "supporting"]);
730
+ var ROOT_KEYS = /* @__PURE__ */ new Set(["schemaVersion", "renderer", "tiers", "footnoteMarkdown"]);
731
+ var TIER_KEYS = /* @__PURE__ */ new Set([
732
+ "tierId",
733
+ "variant",
734
+ "badge",
735
+ "summaryMarkdown",
736
+ "priceEyebrow",
737
+ "compareAtPriceCents",
738
+ "billingLabel",
739
+ "priceNoteMarkdown",
740
+ "capacity",
741
+ "benefits",
742
+ "callout",
743
+ "ctaLabel"
744
+ ]);
745
+ var CALLOUT_KEYS = /* @__PURE__ */ new Set(["label", "bodyMarkdown"]);
746
+ var CAPACITY_KEYS = /* @__PURE__ */ new Set(["total", "label"]);
747
+ var MAX_TIERS = 20;
748
+ var MAX_BENEFITS = 24;
749
+ var MAX_MARKDOWN = 65536;
750
+ var MAX_LABEL = 160;
751
+ var objectRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
752
+ function rejectUnknownKeys(value, allowed, path, errors) {
753
+ for (const key of Object.keys(value)) {
754
+ if (!allowed.has(key)) errors.push(`${path}.${key} is not supported`);
755
+ }
756
+ }
757
+ function boundedString(value, path, errors, max, required = false) {
758
+ if (value === void 0) {
759
+ if (required) errors.push(`${path} is required`);
760
+ return void 0;
761
+ }
762
+ if (typeof value !== "string") {
763
+ errors.push(`${path} must be a string`);
764
+ return void 0;
765
+ }
766
+ const text = value.trim();
767
+ if (required && !text) errors.push(`${path} is required`);
768
+ if (text.length > max) errors.push(`${path} must be at most ${max.toLocaleString("en-US")} characters`);
769
+ return text;
770
+ }
771
+ function validateMembershipPageDocument(input, options = {}) {
772
+ const errors = [];
773
+ const root = objectRecord(input);
774
+ if (!root) return { ok: false, errors: ["document must be an object"] };
775
+ rejectUnknownKeys(root, ROOT_KEYS, "document", errors);
776
+ if (root.schemaVersion !== 1) errors.push("document.schemaVersion must be 1");
777
+ if (root.renderer !== MEMBERSHIP_PAGE_RENDERER) {
778
+ errors.push(`document.renderer must be "${MEMBERSHIP_PAGE_RENDERER}"`);
779
+ }
780
+ if (!Array.isArray(root.tiers)) errors.push("document.tiers must be an array");
781
+ const rawTiers = Array.isArray(root.tiers) ? root.tiers : [];
782
+ if (Array.isArray(root.tiers) && rawTiers.length === 0) errors.push("document.tiers must include at least one tier");
783
+ if (rawTiers.length > MAX_TIERS) errors.push(`document.tiers supports at most ${MAX_TIERS} entries`);
784
+ const authority = new Map((options.tiers ?? []).map((tier) => [tier.id, tier]));
785
+ const seen = /* @__PURE__ */ new Set();
786
+ const tiers = [];
787
+ for (const [index, raw] of rawTiers.slice(0, MAX_TIERS).entries()) {
788
+ const path = `document.tiers[${index}]`;
789
+ const item = objectRecord(raw);
790
+ if (!item) {
791
+ errors.push(`${path} must be an object`);
792
+ continue;
793
+ }
794
+ rejectUnknownKeys(item, TIER_KEYS, path, errors);
795
+ const tierId = boundedString(item.tierId, `${path}.tierId`, errors, 80, true) ?? "";
796
+ if (tierId && !TIER_ID.test(tierId)) errors.push(`${path}.tierId must be a lowercase kebab-case id`);
797
+ if (seen.has(tierId)) errors.push(`${path}.tierId duplicates "${tierId}"`);
798
+ seen.add(tierId);
799
+ if (authority.size && !authority.has(tierId)) errors.push(`${path}.tierId "${tierId}" is not an offered tier`);
800
+ const variant = item.variant;
801
+ if (variant !== void 0 && (typeof variant !== "string" || !VARIANTS.has(variant))) {
802
+ errors.push(`${path}.variant must be standard, featured, or supporting`);
803
+ }
804
+ const compareAt = item.compareAtPriceCents;
805
+ if (compareAt !== void 0 && (!Number.isInteger(compareAt) || Number(compareAt) < 0 || Number(compareAt) > 1e8)) {
806
+ errors.push(`${path}.compareAtPriceCents must be an integer from 0 to 100,000,000`);
807
+ }
808
+ const actual = authority.get(tierId)?.priceCents;
809
+ if (Number.isInteger(compareAt) && actual !== void 0 && Number(compareAt) <= actual) {
810
+ errors.push(`${path}.compareAtPriceCents must be greater than the authoritative tier price`);
811
+ }
812
+ let benefits;
813
+ if (item.benefits !== void 0) {
814
+ if (!Array.isArray(item.benefits)) errors.push(`${path}.benefits must be an array`);
815
+ else {
816
+ if (item.benefits.length > MAX_BENEFITS) errors.push(`${path}.benefits supports at most ${MAX_BENEFITS} entries`);
817
+ benefits = item.benefits.slice(0, MAX_BENEFITS).flatMap((benefit, benefitIndex) => {
818
+ const text = boundedString(benefit, `${path}.benefits[${benefitIndex}]`, errors, 2e3, true);
819
+ return text ? [text] : [];
820
+ });
821
+ }
822
+ }
823
+ let callout;
824
+ if (item.callout !== void 0) {
825
+ const rawCallout = objectRecord(item.callout);
826
+ if (!rawCallout) errors.push(`${path}.callout must be an object`);
827
+ else {
828
+ rejectUnknownKeys(rawCallout, CALLOUT_KEYS, `${path}.callout`, errors);
829
+ const bodyMarkdown = boundedString(rawCallout.bodyMarkdown, `${path}.callout.bodyMarkdown`, errors, MAX_MARKDOWN, true);
830
+ const label = boundedString(rawCallout.label, `${path}.callout.label`, errors, MAX_LABEL);
831
+ if (bodyMarkdown) callout = { ...label ? { label } : {}, bodyMarkdown };
832
+ }
833
+ }
834
+ let capacity;
835
+ if (item.capacity !== void 0) {
836
+ const rawCapacity = objectRecord(item.capacity);
837
+ if (!rawCapacity) errors.push(`${path}.capacity must be an object`);
838
+ else {
839
+ rejectUnknownKeys(rawCapacity, CAPACITY_KEYS, `${path}.capacity`, errors);
840
+ const total = rawCapacity.total;
841
+ if (!Number.isInteger(total) || Number(total) < 1 || Number(total) > 1e6) {
842
+ errors.push(`${path}.capacity.total must be an integer from 1 to 1,000,000`);
843
+ }
844
+ const label = boundedString(rawCapacity.label, `${path}.capacity.label`, errors, MAX_LABEL);
845
+ if (Number.isInteger(total) && Number(total) >= 1 && Number(total) <= 1e6) {
846
+ capacity = { total: Number(total), ...label ? { label } : {} };
847
+ }
848
+ }
849
+ }
850
+ const badge = boundedString(item.badge, `${path}.badge`, errors, MAX_LABEL);
851
+ const summaryMarkdown = boundedString(item.summaryMarkdown, `${path}.summaryMarkdown`, errors, MAX_MARKDOWN);
852
+ const priceEyebrow = boundedString(item.priceEyebrow, `${path}.priceEyebrow`, errors, MAX_LABEL);
853
+ const billingLabel = boundedString(item.billingLabel, `${path}.billingLabel`, errors, MAX_LABEL);
854
+ const priceNoteMarkdown = boundedString(item.priceNoteMarkdown, `${path}.priceNoteMarkdown`, errors, MAX_MARKDOWN);
855
+ const ctaLabel = boundedString(item.ctaLabel, `${path}.ctaLabel`, errors, MAX_LABEL);
856
+ tiers.push({
857
+ tierId,
858
+ ...typeof variant === "string" && VARIANTS.has(variant) ? { variant } : {},
859
+ ...badge ? { badge } : {},
860
+ ...summaryMarkdown ? { summaryMarkdown } : {},
861
+ ...priceEyebrow ? { priceEyebrow } : {},
862
+ ...Number.isInteger(compareAt) ? { compareAtPriceCents: Number(compareAt) } : {},
863
+ ...billingLabel ? { billingLabel } : {},
864
+ ...priceNoteMarkdown ? { priceNoteMarkdown } : {},
865
+ ...capacity ? { capacity } : {},
866
+ ...benefits ? { benefits } : {},
867
+ ...callout ? { callout } : {},
868
+ ...ctaLabel ? { ctaLabel } : {}
869
+ });
870
+ }
871
+ if (authority.size) {
872
+ for (const tierId of authority.keys()) {
873
+ if (!seen.has(tierId)) errors.push(`document.tiers is missing offered tier "${tierId}"`);
874
+ }
875
+ }
876
+ const footnoteMarkdown = boundedString(root.footnoteMarkdown, "document.footnoteMarkdown", errors, MAX_MARKDOWN);
877
+ if (errors.length) return { ok: false, errors };
878
+ return {
879
+ ok: true,
880
+ value: {
881
+ schemaVersion: 1,
882
+ renderer: MEMBERSHIP_PAGE_RENDERER,
883
+ tiers,
884
+ ...footnoteMarkdown ? { footnoteMarkdown } : {}
885
+ }
886
+ };
887
+ }
888
+ function membershipPageDocument(input, options = {}) {
889
+ const result = validateMembershipPageDocument(input, options);
890
+ if (!result.ok) throw new TypeError(`Invalid membership page document: ${result.errors.join("; ")}`);
891
+ return result.value;
892
+ }
893
+
894
+ // src/ui/membership-cards.tsx
895
+ import { jsx as jsx8, jsxs as jsxs8 } from "preact/jsx-runtime";
896
+ var MEMBERSHIP_CARDS_CSS = `
897
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}]{font-family:var(--ui-font-sans,ui-sans-serif,system-ui,sans-serif);color:var(--ui-text,#17252d)}
898
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-grid{display:grid;grid-template-columns:1fr;gap:var(--ui-space-6,1.5rem);align-items:stretch}
899
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-card{position:relative;display:flex;min-width:0;flex-direction:column;padding:clamp(1.35rem,3vw,2.25rem);border:1px solid var(--ui-border,#d9e1e1);border-radius:var(--ui-radius-lg,1.25rem);background:var(--ui-surface,#fff);box-shadow:var(--ui-shadow,0 10px 30px rgb(8 31 44 / .05))}
900
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-card[data-variant=featured]{border-color:var(--ui-accent,#168c84);box-shadow:var(--ui-shadow-strong,var(--ui-shadow,0 18px 48px rgb(8 31 44 / .1)))}
901
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-badge{align-self:flex-start;margin:-2.85rem 0 1.35rem;padding:.55rem 1rem;border-radius:999px;background:var(--ui-accent,#168c84);color:var(--ui-on-accent,#fff);font-family:var(--ui-font-mono,ui-monospace,monospace);font-size:var(--ui-text-xs,.75rem);font-weight:700;letter-spacing:.12em;text-transform:uppercase}
902
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] [data-variant=supporting] .chapter-membership-badge{background:var(--ui-accent-strong,#0d3b52)}
903
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-name{margin:0;font-family:var(--ui-font-display,ui-serif,Georgia,serif);font-size:clamp(1.65rem,3vw,2.25rem);font-weight:var(--ui-font-weight-display,400);line-height:1.08;color:var(--ui-text,#17252d)}
904
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-price-row{display:flex;flex-wrap:wrap;align-items:end;gap:.45rem;margin:1.1rem 0 .7rem}
905
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-price{font-family:var(--ui-font-numeral,var(--ui-font-display,ui-serif,Georgia,serif));font-size:clamp(2rem,4vw,3rem);line-height:1;color:var(--ui-text,#17252d)}
906
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-compare{font-size:var(--ui-text-lg,1.125rem);color:var(--ui-text-muted,#66777f);text-decoration:line-through}
907
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-billing{padding-bottom:.18rem;color:var(--ui-text-muted,#66777f)}
908
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-price-eyebrow{margin:1rem 0 -.75rem;font-family:var(--ui-font-mono,ui-monospace,monospace);font-size:var(--ui-text-xs,.75rem);font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--ui-accent-strong,#0d3b52)}
909
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-summary{color:var(--ui-text-muted,#66777f)}
910
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-summary.ui-markdown,[data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-price-note.ui-markdown,[data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-benefit.ui-markdown,[data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-callout .ui-markdown{font:inherit;color:inherit}
911
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-capacity{display:grid;place-items:center;align-self:flex-start;width:4.75rem;height:4.75rem;margin:.35rem 0;border:2px solid var(--ui-accent,#168c84);border-radius:50%;color:var(--ui-accent-strong,#0d3b52)}
912
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-capacity strong{font-family:var(--ui-font-numeral,var(--ui-font-display,ui-serif,Georgia,serif));font-size:1.7rem;font-weight:400;line-height:1}
913
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-capacity small{font-family:var(--ui-font-mono,ui-monospace,monospace);font-size:.62rem;letter-spacing:.1em;text-transform:uppercase}
914
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-benefits{display:grid;gap:.8rem;margin:1.3rem 0;padding:0;list-style:none}
915
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-benefits li{display:grid;grid-template-columns:1.2rem minmax(0,1fr);gap:.55rem;align-items:start}
916
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-check{display:grid;place-items:center;width:1.15rem;height:1.15rem;margin-top:.12rem;border-radius:50%;background:var(--ui-accent-soft,#edf7fa);color:var(--ui-accent-strong,#0d3b52);font-size:.72rem;font-weight:800}
917
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-callout{margin:1rem 0;padding:1.15rem;border-radius:var(--ui-radius-md,.9rem);background:var(--ui-surface-2,#f1f6fa);color:var(--ui-text,#17252d)}
918
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-callout-label{margin:0 0 .6rem;font-family:var(--ui-font-mono,ui-monospace,monospace);font-size:var(--ui-text-xs,.75rem);font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--ui-accent-strong,#0d3b52)}
919
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-action{display:inline-flex;align-items:center;justify-content:flex-start;gap:.4rem;margin-top:auto;padding:1.25rem 0 0;border:0;background:transparent;color:var(--ui-accent-strong,#0d3b52);font:inherit;font-weight:700;text-decoration:none;cursor:pointer}
920
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-action[aria-current=true]{color:var(--ui-accent,#168c84)}
921
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-action:focus-visible{outline:none;box-shadow:var(--ui-focus,0 0 0 3px var(--ui-accent-soft,#dff5f1));border-radius:var(--ui-radius-sm,.2rem)}
922
+ [data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-footnote{margin:1.5rem auto 0;max-width:70ch;color:var(--ui-text-muted,#66777f);text-align:center}
923
+ @media (min-width:56rem){[data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-grid{grid-template-columns:repeat(var(--chapter-membership-columns,3),minmax(0,1fr))}}
924
+ @media (prefers-reduced-motion:no-preference){[data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-action{transition:color .16s ease,transform .16s ease}[data-chapter-membership=${MEMBERSHIP_PAGE_RENDERER}] .chapter-membership-action:hover{transform:translateX(.2rem)}}
925
+ `;
926
+ var safeApplicationPath = (value) => {
927
+ if (typeof value !== "string" || !/^\/(?!\/)[^\s#\\]*$/.test(value)) return "/apply";
928
+ try {
929
+ const resolved = new URL(value, "https://chapter.invalid");
930
+ return resolved.origin === "https://chapter.invalid" ? `${resolved.pathname}${resolved.search}` : "/apply";
931
+ } catch {
932
+ return "/apply";
933
+ }
934
+ };
935
+ function membershipApplicationHref(applicationPath, tierId) {
936
+ const path = safeApplicationPath(applicationPath);
937
+ return `${path}${path.includes("?") ? "&" : "?"}tier=${encodeURIComponent(tierId)}`;
938
+ }
939
+ function TierAction(props) {
940
+ if (props.mode === "select") {
941
+ return /* @__PURE__ */ jsxs8("button", { type: "button", className: "chapter-membership-action", "aria-current": props.selected ? "true" : void 0, onClick: () => props.onSelectTier?.(props.tierId), children: [
942
+ props.label,
943
+ /* @__PURE__ */ jsx8("span", { "aria-hidden": "true", children: "\u2192" })
944
+ ] });
945
+ }
946
+ if (props.mode === "preview") {
947
+ return /* @__PURE__ */ jsxs8("span", { className: "chapter-membership-action", "aria-disabled": "true", children: [
948
+ props.label,
949
+ /* @__PURE__ */ jsx8("span", { "aria-hidden": "true", children: "\u2192" })
950
+ ] });
951
+ }
952
+ return /* @__PURE__ */ jsxs8("a", { className: "chapter-membership-action", href: membershipApplicationHref(props.applicationPath, props.tierId), children: [
953
+ props.label,
954
+ /* @__PURE__ */ jsx8("span", { "aria-hidden": "true", children: "\u2192" })
955
+ ] });
956
+ }
957
+ function TierCard(props) {
958
+ const { tier, presentation } = props;
959
+ const summary = presentation.summaryMarkdown ?? tier.blurb;
960
+ const action = presentation.ctaLabel ?? (tier.free ? "Join for free" : `Choose ${tier.name}`);
961
+ return /* @__PURE__ */ jsxs8("article", { className: "chapter-membership-card", "data-tier-id": tier.id, "data-variant": presentation.variant ?? "standard", children: [
962
+ presentation.badge ? /* @__PURE__ */ jsx8("div", { className: "chapter-membership-badge", children: presentation.badge }) : null,
963
+ /* @__PURE__ */ jsx8("h3", { className: "chapter-membership-name", children: tier.name }),
964
+ presentation.priceEyebrow ? /* @__PURE__ */ jsx8("p", { className: "chapter-membership-price-eyebrow", children: presentation.priceEyebrow }) : null,
965
+ presentation.capacity ? /* @__PURE__ */ jsxs8("div", { className: "chapter-membership-capacity", "aria-label": `${presentation.capacity.total} ${presentation.capacity.label ?? "available"}`, children: [
966
+ /* @__PURE__ */ jsx8("strong", { children: presentation.capacity.total.toLocaleString("en-US") }),
967
+ /* @__PURE__ */ jsx8("small", { children: presentation.capacity.label ?? "available" })
968
+ ] }) : null,
969
+ /* @__PURE__ */ jsxs8("div", { className: "chapter-membership-price-row", children: [
970
+ presentation.compareAtPriceCents !== void 0 ? /* @__PURE__ */ jsx8("span", { className: "chapter-membership-compare", children: fmtMoney(presentation.compareAtPriceCents) }) : null,
971
+ /* @__PURE__ */ jsx8("span", { className: "chapter-membership-price", children: tier.free ? "Free" : fmtMoney(tier.priceCents) }),
972
+ !tier.free && presentation.billingLabel ? /* @__PURE__ */ jsx8("span", { className: "chapter-membership-billing", children: presentation.billingLabel }) : null
973
+ ] }),
974
+ presentation.priceNoteMarkdown ? /* @__PURE__ */ jsx8(Markdown, { className: "chapter-membership-price-note", source: presentation.priceNoteMarkdown }) : null,
975
+ summary ? /* @__PURE__ */ jsx8(Markdown, { className: "chapter-membership-summary", source: summary }) : null,
976
+ presentation.benefits?.length ? /* @__PURE__ */ jsx8("ul", { className: "chapter-membership-benefits", children: presentation.benefits.map((benefit, index) => /* @__PURE__ */ jsxs8("li", { children: [
977
+ /* @__PURE__ */ jsx8("span", { className: "chapter-membership-check", "aria-hidden": "true", children: "\u2713" }),
978
+ /* @__PURE__ */ jsx8(Markdown, { className: "chapter-membership-benefit", source: benefit })
979
+ ] }, `${tier.id}-benefit-${index}`)) }) : null,
980
+ presentation.callout ? /* @__PURE__ */ jsxs8("aside", { className: "chapter-membership-callout", children: [
981
+ presentation.callout.label ? /* @__PURE__ */ jsx8("p", { className: "chapter-membership-callout-label", children: presentation.callout.label }) : null,
982
+ /* @__PURE__ */ jsx8(Markdown, { source: presentation.callout.bodyMarkdown })
983
+ ] }) : null,
984
+ /* @__PURE__ */ jsx8(TierAction, { tierId: tier.id, label: action, mode: props.mode, selected: props.selected, applicationPath: props.applicationPath, onSelectTier: props.onSelectTier })
985
+ ] });
986
+ }
987
+ function MembershipCards(props) {
988
+ const document2 = membershipPageDocument(props.document, { tiers: props.tiers });
989
+ const byId = new Map(props.tiers.map((tier) => [tier.id, tier]));
990
+ const mode = props.mode ?? "link";
991
+ return /* @__PURE__ */ jsxs8("section", { className: ["chapter-membership", props.className].filter(Boolean).join(" "), "data-chapter-membership": MEMBERSHIP_PAGE_RENDERER, "aria-label": props.ariaLabel ?? "Membership options", children: [
992
+ /* @__PURE__ */ jsx8("style", { children: MEMBERSHIP_CARDS_CSS }),
993
+ /* @__PURE__ */ jsx8("div", { className: "chapter-membership-grid", style: { "--chapter-membership-columns": String(Math.min(document2.tiers.length, 3)) }, children: document2.tiers.map((presentation) => {
994
+ const tier = byId.get(presentation.tierId);
995
+ return /* @__PURE__ */ jsx8(TierCard, { tier, presentation, mode, selected: props.selectedTierId === tier.id, applicationPath: props.applicationPath, onSelectTier: props.onSelectTier }, tier.id);
996
+ }) }),
997
+ document2.footnoteMarkdown ? /* @__PURE__ */ jsx8(Markdown, { className: "chapter-membership-footnote", source: document2.footnoteMarkdown }) : null
998
+ ] });
999
+ }
1000
+
723
1001
  export {
724
1002
  tzShort,
725
1003
  dayKey,
@@ -737,6 +1015,8 @@ export {
737
1015
  TierPlaceholder,
738
1016
  tierNeedsPayment,
739
1017
  loadJoinResume,
740
- JoinIsland
1018
+ JoinIsland,
1019
+ membershipApplicationHref,
1020
+ MembershipCards
741
1021
  };
742
- //# sourceMappingURL=chunk-QWMP6TSM.js.map
1022
+ //# sourceMappingURL=chunk-BBCT7U3D.js.map