@tokenoftrust/storefront-runner 1.3.2 → 1.3.4-rc.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.
Files changed (79) hide show
  1. package/apps/storefront/astro.config.mjs +59 -0
  2. package/apps/storefront/env.d.ts +24 -8
  3. package/apps/storefront/package.json +1 -1
  4. package/apps/storefront/src/components/admin/{AdminFoxyCartSettingsPanel.astro → AdminCheckoutSettingsPanel.astro} +50 -51
  5. package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
  6. package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +613 -0
  7. package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +3 -3
  8. package/apps/storefront/src/components/chrome/SiteHeader.astro +3 -4
  9. package/apps/storefront/src/components/commerce/{FoxySidecartStyles.astro → CartDrawerStyles.astro} +13 -14
  10. package/apps/storefront/src/components/commerce/CheckoutLoader.astro +21 -0
  11. package/apps/storefront/src/components/islands/VariantSelector.tsx +101 -20
  12. package/apps/storefront/src/config/adminTargetTenant.ts +2 -2
  13. package/apps/storefront/src/config/devTenantSeed.ts +65 -16
  14. package/apps/storefront/src/config/resolver.ts +48 -1
  15. package/apps/storefront/src/layouts/Layout.astro +14 -12
  16. package/apps/storefront/src/lib/admin/README.md +2 -2
  17. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +5 -5
  18. package/apps/storefront/src/lib/admin/orderClient.ts +20 -20
  19. package/apps/storefront/src/lib/admin/services/mock/{foxyCartSettings.ts → checkoutSettings.ts} +21 -22
  20. package/apps/storefront/src/lib/admin/services/mock/index.ts +3 -2
  21. package/apps/storefront/src/lib/admin/services/mock/settings.ts +1 -1
  22. package/apps/storefront/src/lib/admin/services/mock/subscriptions.ts +398 -0
  23. package/apps/storefront/src/lib/admin/services/orders.ts +21 -21
  24. package/apps/storefront/src/lib/admin/viewmodels/subscriptions.ts +106 -0
  25. package/apps/storefront/src/lib/auth/loginGate.ts +14 -3
  26. package/apps/storefront/src/lib/{foxyCommerce.ts → checkoutCommerce.ts} +20 -18
  27. package/apps/storefront/src/lib/commerce/{foxyCartSettings.ts → checkoutSettings.ts} +65 -66
  28. package/apps/storefront/src/lib/dev/activityIngestToken.ts +9 -0
  29. package/apps/storefront/src/lib/dev/activityStore.ts +12 -0
  30. package/apps/storefront/src/lib/dev/cliSignInCode.ts +23 -4
  31. package/apps/storefront/src/lib/dev/runtimeStore.ts +193 -22
  32. package/apps/storefront/src/lib/edgeCache.ts +7 -6
  33. package/apps/storefront/src/lib/health.ts +53 -0
  34. package/apps/storefront/src/lib/privacy/emailHint.ts +33 -0
  35. package/apps/storefront/src/lib/rawMarketingHtml.ts +1 -0
  36. package/apps/storefront/src/lib/sections.ts +19 -23
  37. package/apps/storefront/src/lib/securityHeaders.ts +1 -1
  38. package/apps/storefront/src/lib/storyblok/content-model.ts +3 -2
  39. package/apps/storefront/src/middleware/index.ts +29 -12
  40. package/apps/storefront/src/pages/[...slug].astro +5 -6
  41. package/apps/storefront/src/pages/admin.astro +10 -3
  42. package/apps/storefront/src/pages/api/admin/orders/[id].ts +4 -4
  43. package/apps/storefront/src/pages/api/admin/orders.ts +5 -5
  44. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +14 -7
  45. package/apps/storefront/src/pages/api/checkout-style.css.ts +5 -5
  46. package/apps/storefront/src/pages/auth/login.astro +4 -1
  47. package/apps/storefront/src/pages/auth/magic.astro +263 -43
  48. package/apps/storefront/src/pages/capabilities.astro +1 -1
  49. package/apps/storefront/src/pages/cockpit.astro +2365 -0
  50. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +19 -0
  51. package/apps/storefront/src/pages/index.astro +23 -29
  52. package/apps/storefront/src/pages/products/[handle].astro +50 -20
  53. package/package.json +6 -2
  54. package/packages/public-runtime/src/capabilities.ts +97 -9
  55. package/packages/public-runtime/src/{foxy-orders.ts → checkout-orders.ts} +28 -27
  56. package/packages/public-runtime/src/checkout-style-publish.ts +25 -25
  57. package/packages/public-runtime/src/checkout-style.ts +13 -13
  58. package/packages/public-runtime/src/{foxy.ts → checkout.ts} +111 -34
  59. package/packages/public-runtime/src/csp.ts +11 -11
  60. package/packages/public-runtime/src/customization-scripts.ts +28 -0
  61. package/packages/public-runtime/src/extension-contract.ts +73 -0
  62. package/packages/public-runtime/src/index.ts +4 -2
  63. package/packages/public-runtime/src/product.ts +57 -0
  64. package/packages/public-runtime/src/raw-html-policy.ts +35 -0
  65. package/packages/public-runtime/src/tenant.ts +14 -11
  66. package/pnpm-workspace.yaml +1 -0
  67. package/scripts/dev/checkout-watch.mjs +9 -0
  68. package/scripts/dev/file-browser.mjs +5 -1
  69. package/scripts/dev/transient-files.mjs +34 -0
  70. package/scripts/tot-dev.mjs +16 -3
  71. package/apps/storefront/src/components/commerce/FoxyLoader.astro +0 -23
  72. package/apps/storefront/src/pages/api/dev/activity.ts +0 -124
  73. package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +0 -73
  74. package/apps/storefront/src/pages/api/dev/diff.ts +0 -68
  75. package/apps/storefront/src/pages/api/dev/file.ts +0 -57
  76. package/apps/storefront/src/pages/api/dev/locate.ts +0 -125
  77. package/apps/storefront/src/pages/api/dev/publish.ts +0 -128
  78. package/apps/storefront/src/pages/api/dev/tree.ts +0 -70
  79. package/apps/storefront/src/pages/api/dev/write.ts +0 -116
@@ -1,28 +1,27 @@
1
1
  ---
2
2
  /**
3
- * <FoxySidecartStyles> — brands Foxy's v2.0 sidecart to the tenant's theme.
3
+ * <CartDrawerStyles> — brands the in-page cart drawer to the tenant's theme.
4
4
  *
5
- * Foxy's loader.js injects the sidecart into OUR page DOM (root `#fc`, light DOM
5
+ * The managed checkout loader injects the cart into OUR page DOM (root `#fc`, light DOM
6
6
  * — no iframe, no shadow root), so it inherits the theme's CSS custom properties
7
7
  * (`--color-primary`, `--font-*`, …) and can be restyled with plain CSS. We emit
8
- * a small nonce'd <style> that maps the sidecart's own selectors to those tokens,
8
+ * a small nonce'd <style> that maps the cart drawer's own selectors to those tokens,
9
9
  * so a merchant's cart matches their brand out of the box.
10
10
  *
11
- * Two layers: this brand BASELINE, then the tenant's optional `sidecartCss`
12
- * override (commerce.foxy.sidecartCss) appended verbatim for bespoke tweaks.
11
+ * Two layers: this brand BASELINE, then the tenant's optional `cartDrawerCss`
12
+ * override (commerce.checkout.cartDrawerCss) appended verbatim for bespoke tweaks.
13
13
  *
14
- * `!important` is deliberate: Foxy's own responsive_styles.css loads from its CDN
15
- * AFTER this <head> <style>, so equal-specificity rules would lose the cascade.
16
- * Everything is scoped under `#fc` so nothing leaks to the storefront itself.
14
+ * `!important` is deliberate: the checkout loader's responsive stylesheet loads
15
+ * after this <head> <style>, so equal-specificity rules would lose the cascade.
16
+ * Everything is scoped under `#fc` so nothing leaks into the storefront itself.
17
17
  *
18
18
  * Rendered only for commerce tenants with a store (Astro.locals.commerce); the
19
- * hosted checkout PAGE is a separate origin and is themed in the Foxy admin — out
20
- * of scope here.
19
+ * hosted checkout page is a separate origin and is themed through checkout settings.
21
20
  */
22
21
  const { commerce, cspNonce } = Astro.locals;
23
22
 
24
- // Selectors verified against the live tot-preview sidecart DOM:
25
- // #fc — sidecart root (in-page)
23
+ // Selectors verified against the live tot-preview cart DOM:
24
+ // #fc — cart root (in-page)
26
25
  // #fc-cart.fc-container — cart body
27
26
  // .fc-action--checkout--button — the primary "Proceed to Checkout" CTA
28
27
  // a.fc-btn-action.fc-sidecart-button — the fixed Keep-Shopping/Checkout nav
@@ -54,8 +53,8 @@ const baseline = `
54
53
  }
55
54
  `.trim();
56
55
 
57
- const css = commerce?.sidecartCss
58
- ? `${baseline}\n\n/* --- tenant override --- */\n${commerce.sidecartCss}`
56
+ const css = commerce?.cartDrawerCss
57
+ ? `${baseline}\n\n/* --- tenant override --- */\n${commerce.cartDrawerCss}`
59
58
  : baseline;
60
59
  ---
61
60
  {commerce && <style set:html={css} nonce={cspNonce}></style>}
@@ -0,0 +1,21 @@
1
+ ---
2
+ /**
3
+ * <CheckoutLoader> — injects the managed checkout loader (Track A).
4
+ *
5
+ * The loader turns our add-to-cart forms/links into the cart drawer + hosted
6
+ * checkout. It's loaded from the store's CDN path and must sit near the end of
7
+ * <body>. Rendered only when the tenant has a store (locals.commerce);
8
+ * marketing tenants ship nothing.
9
+ *
10
+ * Note: the loader is a mutable, versioned CDN artifact and does NOT support
11
+ * Subresource Integrity. It's constrained instead by the per-tenant CSP
12
+ * allowlist set in middleware. It is loaded as a plain cross-origin script
13
+ * (NO `crossorigin` attribute) because the CDN serves it without CORS headers.
14
+ */
15
+ const { commerce } = Astro.locals;
16
+ ---
17
+ {
18
+ commerce && (
19
+ <script src={commerce.loaderSrc} async defer></script>
20
+ )
21
+ }
@@ -11,13 +11,19 @@
11
11
  * - `variant:image` { imageId } → <ImageGallery> swaps the main image
12
12
  * - `variant:change` { variantId, available }
13
13
  *
14
- * Buy button is an intentionally-disabled placeholder (Phase 1 has no cart).
14
+ * Buy button renders when checkout is wired; otherwise it degrades to a
15
+ * disabled placeholder.
15
16
  *
16
17
  * A11y: role=radiogroup/radio with aria-checked, disabled values get
17
18
  * aria-disabled, live region is aria-live="polite".
18
19
  */
19
20
  import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
20
- import type { CatalogVariant, VariantCartPayload } from "@tot/public-runtime";
21
+ import type {
22
+ CatalogVariant,
23
+ ProductPurchaseOption,
24
+ VariantCartPayload,
25
+ } from "@tot/public-runtime";
26
+ import { purchaseOptionPrice } from "@tot/public-runtime";
21
27
  import { formatPrice } from "@/lib/format";
22
28
 
23
29
  interface OptionDef {
@@ -32,14 +38,15 @@ interface Props {
32
38
  currency: string;
33
39
  locale?: string;
34
40
  /**
35
- * FoxyCart add-to-cart (Track A). Server pre-builds a signed cart payload per
36
- * variant; the island renders the selected variant's payload as a real Foxy
37
- * form (loader.js turns it into the sidecart). Undefined → no cart (marketing
38
- * tenant, or signed mode missing secret) → the button stays a placeholder.
41
+ * Managed add-to-cart (Track A). Server pre-builds a signed cart payload per
42
+ * variant; the island renders the selected variant's payload as a real checkout
43
+ * form. Undefined → no cart (marketing tenant, or signed mode missing secret)
44
+ * → the button stays a placeholder.
39
45
  */
40
- foxyCart?: {
46
+ checkoutCart?: {
41
47
  action: string;
42
- byVariantId: Record<string, VariantCartPayload>;
48
+ purchaseOptions: ProductPurchaseOption[];
49
+ byVariantId: Record<string, Record<string, VariantCartPayload>>;
43
50
  };
44
51
  }
45
52
 
@@ -63,7 +70,7 @@ export default function VariantSelector({
63
70
  options,
64
71
  currency,
65
72
  locale,
66
- foxyCart,
73
+ checkoutCart,
67
74
  }: Props) {
68
75
  const sortedOptions = useMemo(
69
76
  () => [...options].sort((a, b) => a.position - b.position),
@@ -86,12 +93,25 @@ export default function VariantSelector({
86
93
 
87
94
  const [selection, setSelection] = useState<Selection>(defaultSelection);
88
95
  const [quantity, setQuantity] = useState(1);
96
+ const [purchaseOptionId, setPurchaseOptionId] = useState<string | undefined>(
97
+ () => checkoutCart?.purchaseOptions[0]?.id,
98
+ );
89
99
 
90
100
  const selected = useMemo(
91
101
  () => findVariant(variants, selection),
92
102
  [variants, selection],
93
103
  );
94
104
 
105
+ const purchaseOptions = checkoutCart?.purchaseOptions ?? [];
106
+ const selectedPurchaseOption =
107
+ purchaseOptions.find((o) => o.id === purchaseOptionId) ??
108
+ purchaseOptions[0];
109
+
110
+ useEffect(() => {
111
+ if (!purchaseOptions.length) return;
112
+ if (!selectedPurchaseOption) setPurchaseOptionId(purchaseOptions[0]?.id);
113
+ }, [purchaseOptions, selectedPurchaseOption]);
114
+
95
115
  const complete = useMemo(
96
116
  () => sortedOptions.every((o) => selection[o.name] !== undefined),
97
117
  [sortedOptions, selection],
@@ -181,22 +201,38 @@ export default function VariantSelector({
181
201
  }
182
202
  }
183
203
 
204
+ const selectedPrice = selected
205
+ ? purchaseOptionPrice(selected.price, selectedPurchaseOption)
206
+ : undefined;
184
207
  const compareAt = selected?.compare_at_price;
208
+ const purchaseOptionCompareAt =
209
+ selectedPrice !== undefined && selected && selectedPrice < selected.price
210
+ ? selected.price
211
+ : undefined;
212
+ const displayCompareAt =
213
+ compareAt !== undefined && selectedPrice !== undefined && compareAt > selectedPrice
214
+ ? compareAt
215
+ : purchaseOptionCompareAt;
185
216
  const showCompare =
186
217
  selected !== undefined &&
187
- compareAt !== undefined &&
188
- compareAt > selected.price;
218
+ selectedPrice !== undefined &&
219
+ displayCompareAt !== undefined &&
220
+ displayCompareAt > selectedPrice;
189
221
 
190
- // FoxyCart payload for the resolved variant (server pre-signed). Buyable only
222
+ // Cart payload for the resolved variant (server pre-signed). Buyable only
191
223
  // when the selection is complete, in stock, AND a cart is wired.
192
224
  const cartPayload =
193
- foxyCart && selected ? foxyCart.byVariantId[selected.id] : undefined;
225
+ checkoutCart && selected && selectedPurchaseOption
226
+ ? checkoutCart.byVariantId[selected.id]?.[selectedPurchaseOption.id]
227
+ : undefined;
194
228
  const canBuy = complete && available && !!cartPayload;
195
229
  const buyLabel = !complete
196
230
  ? "Select options"
197
231
  : !available
198
232
  ? "Sold out"
199
- : "Add to cart";
233
+ : selectedPurchaseOption?.kind === "subscription"
234
+ ? "Subscribe"
235
+ : "Add to cart";
200
236
 
201
237
  return (
202
238
  <div class="flex flex-col gap-6">
@@ -206,11 +242,11 @@ export default function VariantSelector({
206
242
  <>
207
243
  <div class="flex items-baseline gap-3">
208
244
  <span class={`font-display text-[1.75rem] font-semibold tabular-nums leading-none ${showCompare ? "text-sale" : "text-text"}`}>
209
- {formatPrice(selected.price, currency, locale)}
245
+ {formatPrice(selectedPrice ?? selected.price, currency, locale)}
210
246
  </span>
211
247
  {showCompare && (
212
248
  <span class="font-body text-base text-muted line-through tabular-nums">
213
- {formatPrice(compareAt!, currency, locale)}
249
+ {formatPrice(displayCompareAt!, currency, locale)}
214
250
  </span>
215
251
  )}
216
252
  </div>
@@ -283,6 +319,51 @@ export default function VariantSelector({
283
319
  );
284
320
  })}
285
321
 
322
+ {/* Purchase options */}
323
+ {purchaseOptions.length > 1 && (
324
+ <fieldset class="flex flex-col gap-2">
325
+ <legend class="font-mono text-xs uppercase tracking-[0.12em] text-muted">
326
+ Purchase option
327
+ </legend>
328
+ <div role="radiogroup" aria-label="Purchase option" class="grid gap-2">
329
+ {purchaseOptions.map((option) => {
330
+ const checked = selectedPurchaseOption?.id === option.id;
331
+ return (
332
+ <button
333
+ key={option.id}
334
+ type="button"
335
+ role="radio"
336
+ aria-checked={checked}
337
+ onClick={() => setPurchaseOptionId(option.id)}
338
+ class={[
339
+ "grid min-h-[4.25rem] w-full gap-1 rounded-md border px-4 py-3 text-left transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary",
340
+ checked
341
+ ? "border-primary bg-[color-mix(in_srgb,var(--color-primary)_10%,var(--color-surface))]"
342
+ : "border-border bg-surface hover:border-primary",
343
+ ].join(" ")}
344
+ >
345
+ <span class="flex flex-wrap items-baseline justify-between gap-2">
346
+ <span class="font-display text-sm font-semibold text-text">
347
+ {option.label}
348
+ </span>
349
+ {"savingsLabel" in option && option.savingsLabel && (
350
+ <span class="rounded-full bg-accent px-2 py-0.5 font-mono text-[0.68rem] uppercase tracking-[0.08em] text-primary-contrast">
351
+ {option.savingsLabel}
352
+ </span>
353
+ )}
354
+ </span>
355
+ {option.description && (
356
+ <span class="text-xs leading-relaxed text-muted">
357
+ {option.description}
358
+ </span>
359
+ )}
360
+ </button>
361
+ );
362
+ })}
363
+ </div>
364
+ </fieldset>
365
+ )}
366
+
286
367
  {/* Quantity stepper (UI only) */}
287
368
  <div class="flex flex-col gap-2">
288
369
  <span
@@ -323,15 +404,15 @@ export default function VariantSelector({
323
404
  </div>
324
405
  </div>
325
406
 
326
- {/* Add to cart. With a wired FoxyCart store this is a real Foxy form —
327
- loader.js turns the submit into the sidecart + hosted checkout. The
407
+ {/* Add to cart. With a wired checkout store this is a real checkout form —
408
+ loader.js turns the submit into the cart drawer + hosted checkout. The
328
409
  product fields are server-pre-signed (tamper-proof); the quantity
329
410
  rides along as the selected stepper value. Without a store (marketing
330
411
  tenant / signed mode missing secret) it degrades to a disabled
331
412
  placeholder. */}
332
- {foxyCart ? (
413
+ {checkoutCart ? (
333
414
  <div class="flex flex-col gap-1.5">
334
- <form action={foxyCart.action} method="post" target="_top">
415
+ <form action={checkoutCart.action} method="post" target="_top">
335
416
  {canBuy &&
336
417
  cartPayload!.hidden.map((f) => (
337
418
  <input key={f.name} type="hidden" name={f.name} value={f.value} />
@@ -5,8 +5,8 @@
5
5
  * The admin console can operate on a target tenant that differs from the routed
6
6
  * (adminApp) tenant on `Astro.locals.tenant` — a real ToT-staff session may point
7
7
  * `targetTenant` at another tenant it has access to (see lib/adminTenantEnvelope).
8
- * Panels that need the target's real config (e.g. the FoxyCart cart-appearance
9
- * panel, which derives from `theme_tokens` + `commerce.foxy`) resolve it HERE,
8
+ * Panels that need the target's real config (e.g. the checkout appearance
9
+ * panel, which derives from `theme_tokens` + `commerce.checkout`) resolve it HERE,
10
10
  * in the config layer, rather than reaching into the static tenant map — so they
11
11
  * stay behind the `tenancy-resolver-fence` (.dependency-cruiser.cjs) and a
12
12
  * single-tenant extraction stays a drop-in (the concrete resolver is swapped, not
@@ -24,55 +24,104 @@
24
24
  */
25
25
  import type { DeepPartial, TenantConfig, ThemeTokens } from "@tot/public-runtime";
26
26
 
27
+ /** Per-dir overrides the resolver derives from disk before synthesis. */
28
+ export interface DevTenantHints {
29
+ /**
30
+ * (E3) The resolvable appDomain for a SLUG tenant whose dir name isn't its
31
+ * domain. Read from the checkout's `.tot/config.json` `scope` when that scope
32
+ * looks like a hostname (has a dot) — mirrors scripts/tot-dev.mjs resolveDomain.
33
+ * Undefined ⇒ the colocation convention (dir == appDomain == scope).
34
+ */
35
+ appDomain?: string;
36
+ /**
37
+ * (E2) True when catalog fixtures for this tenant's data scope are committed on
38
+ * disk (the same `packages/migration/out/catalog/<scope>/` the FixtureToTClient
39
+ * reads). When true we serve a real COMMERCE tenant so /collections + /products
40
+ * render from them; when false we degrade to a marketing home (no catalog).
41
+ */
42
+ hasCatalog?: boolean;
43
+ }
44
+
27
45
  /**
28
- * A minimal servable TenantConfig whose identity is its colocated dir name.
29
- * No Storyblok space (a "PLACEHOLDER" token LocalContentProvider serves the
30
- * checkout's content/), no catalog/compliance (siteType "marketing" degrades
31
- * commerce to empty). Theme tokens come from the checkout's theme.json.
46
+ * A servable TenantConfig for a local dev-loop checkout. Identity defaults to the
47
+ * colocated dir name (dir == id == appDomain == tot_data_scope); a slug tenant can
48
+ * override the resolvable appDomain via `hints.appDomain` (E3). No Storyblok space
49
+ * (a "PLACEHOLDER" token LocalContentProvider serves the checkout's content/).
50
+ * Commerce is data-driven (E2): with catalog fixtures on disk the tenant is
51
+ * COMMERCE (catalog on); without them it degrades to a marketing home (siteType
52
+ * "marketing" → no catalog required, so the root renders instead of 404ing).
53
+ * Theme tokens come from the checkout's theme.json.
32
54
  */
33
55
  export function synthesizeDevTenant(
34
56
  id: string,
35
57
  themeTokens: DeepPartial<ThemeTokens>,
58
+ hints: DevTenantHints = {},
36
59
  ): TenantConfig {
37
- return {
60
+ // (E3) Prefer the .tot/config.json scope for a slug tenant; else colocation.
61
+ const appDomain = hints.appDomain ?? id;
62
+ const base: TenantConfig = {
38
63
  tenant_id: id,
39
- appDomain: id,
40
- domains: [id],
41
- canonicalDomain: id,
64
+ appDomain,
65
+ domains: [appDomain],
66
+ canonicalDomain: appDomain,
42
67
  displayName: id,
43
- // Contains "PLACEHOLDER" → createContentProvider picks LocalContentProvider,
44
- // which serves the checkout's content/home.html (globbed by dir == this id).
68
+ // Contains "PLACEHOLDER" → LocalContentProvider serves the checkout's
69
+ // content/home.html (globbed by dir == this id).
45
70
  storyblok_space_token: "STORYBLOK_DEV_TENANT_TOKEN_PLACEHOLDER",
46
- tot_data_scope: id,
71
+ tot_data_scope: appDomain,
47
72
  currency: "USD",
48
73
  locale: "en-US",
49
74
  theme_tokens: themeTokens,
50
- // No bundled catalog for an arbitrary dev tenant → serve the raw marketing
51
- // home and degrade commerce to empty rather than 404 the tenant root.
52
- siteType: "marketing",
53
75
  };
76
+ if (hints.hasCatalog) {
77
+ // (E2) Catalog fixtures on disk → a real commerce tenant (siteType omitted ==
78
+ // commerce → catalog on). Cart wires to the shared ToT demo checkout store
79
+ // (test mode, no secret), the same as the bundled demo tenants — so
80
+ // /collections + /products resolve and add-to-cart works on localhost.
81
+ return {
82
+ ...base,
83
+ commerce: { checkout: { storeSubDomain: "tot-preview", mode: "unsigned-demo" } },
84
+ };
85
+ }
86
+ // No catalog on disk → serve the raw marketing home and degrade commerce to
87
+ // empty rather than 404 the tenant root.
88
+ return { ...base, siteType: "marketing" };
89
+ }
90
+
91
+ /** Per-dir disk facts the resolver supplies so synthesis can go beyond colocation. */
92
+ export interface DevTenantSeedOptions {
93
+ /** (E3) Resolved appDomain for a slug tenant (from `.tot/config.json` scope). */
94
+ scopeFor?: (id: string) => string | undefined;
95
+ /** (E2) Whether catalog fixtures are committed on disk for a given data scope. */
96
+ hasCatalog?: (scope: string) => boolean;
54
97
  }
55
98
 
56
99
  /**
57
100
  * The dev tenants to seed ALONGSIDE staticTenants: one synthesized config per
58
101
  * local tenant dir that isn't already a static tenant (those keep their richer
59
- * hardcoded config). `themeFor` supplies each dir's theme tokens.
102
+ * hardcoded config). `themeFor` supplies each dir's theme tokens; `opts` carries
103
+ * the per-dir disk facts (E2 catalog presence, E3 slug scope) the resolver reads.
60
104
  *
61
105
  * @param localDirIds dir names discovered under `tenants/*` (dir == tenant id)
62
106
  * @param staticIds tenant_ids already in the static registry (skipped)
63
107
  * @param themeFor theme-token lookup by dir/tenant id
108
+ * @param opts optional per-dir hints (scopeFor, hasCatalog)
64
109
  */
65
110
  export function devTenantsToSeed(
66
111
  localDirIds: Iterable<string>,
67
112
  staticIds: ReadonlySet<string>,
68
113
  themeFor: (id: string) => DeepPartial<ThemeTokens>,
114
+ opts: DevTenantSeedOptions = {},
69
115
  ): TenantConfig[] {
70
116
  const out: TenantConfig[] = [];
71
117
  const seen = new Set<string>();
72
118
  for (const id of localDirIds) {
73
119
  if (!id || staticIds.has(id) || seen.has(id)) continue;
74
120
  seen.add(id);
75
- out.push(synthesizeDevTenant(id, themeFor(id)));
121
+ const appDomain = opts.scopeFor?.(id);
122
+ const scope = appDomain ?? id;
123
+ const hasCatalog = opts.hasCatalog?.(scope) ?? false;
124
+ out.push(synthesizeDevTenant(id, themeFor(id), { appDomain, hasCatalog }));
76
125
  }
77
126
  return out;
78
127
  }
@@ -55,6 +55,48 @@ function localTenantDirIds(): string[] {
55
55
  return [...ids];
56
56
  }
57
57
 
58
+ // (E2) Catalog fixtures committed on disk, keyed by data scope (== out/catalog dir
59
+ // name). A dev tenant whose scope has fixtures is served as a real COMMERCE tenant
60
+ // (see devTenantSeed); one without them degrades to a marketing home. Keys only —
61
+ // the same fixtures the FixtureToTClient reads. Repo root is four up from config/.
62
+ const catalogFixtureFiles = import.meta.glob(
63
+ "../../../../packages/migration/out/catalog/*/products.json",
64
+ );
65
+
66
+ /** Data scopes (== out/catalog dir names) that have committed catalog fixtures. */
67
+ function catalogScopes(): Set<string> {
68
+ const scopes = new Set<string>();
69
+ for (const key of Object.keys(catalogFixtureFiles)) {
70
+ const scope = /\/out\/catalog\/([^/]+)\//.exec(key)?.[1];
71
+ if (scope) scopes.add(scope);
72
+ }
73
+ return scopes;
74
+ }
75
+
76
+ // (E3) A slug tenant (dir name != its domain) declares its resolvable appDomain in
77
+ // the checkout's `.tot/config.json` `scope`. Read eagerly (tiny JSON) so synthesis
78
+ // can resolve it at `/<domain>/`; trusted only when it looks like a hostname (has a
79
+ // dot), mirroring scripts/tot-dev.mjs resolveDomain. Only the DEV fallback consults
80
+ // it. The explicit `.tot` literal in the pattern matches the dotdir; if the glob
81
+ // sees nothing, synthesis simply falls back to the colocation convention.
82
+ const tenantMarkerFiles = import.meta.glob<{ scope?: string }>(
83
+ "../../../../tenants/*/.tot/config.json",
84
+ { eager: true, import: "default" },
85
+ );
86
+
87
+ /** dir id → resolved appDomain from `.tot/config.json` scope (hostname only). */
88
+ function slugScopes(): Map<string, string> {
89
+ const map = new Map<string, string>();
90
+ for (const [key, cfg] of Object.entries(tenantMarkerFiles)) {
91
+ const id = /\/tenants\/([^/]+)\//.exec(key)?.[1];
92
+ const scope = cfg?.scope;
93
+ // A hostname scope that DIFFERS from the dir is the slug override; a scope
94
+ // equal to the dir (the colocation norm) needs no override.
95
+ if (id && scope && scope.includes(".") && scope !== id) map.set(id, scope);
96
+ }
97
+ return map;
98
+ }
99
+
58
100
  /**
59
101
  * The fallback resolver consulted on a KV miss. In PROD it's the plain static
60
102
  * registry. In DEV it's the static registry EXTENDED with a synthesized,
@@ -68,7 +110,12 @@ function localTenantDirIds(): string[] {
68
110
  function devFallbackResolver(): TenantResolver {
69
111
  if (!import.meta.env.DEV) return staticResolver;
70
112
  const staticIds = new Set(staticTenants.map((t) => t.tenant_id));
71
- const dev = devTenantsToSeed(localTenantDirIds(), staticIds, getTenantTheme);
113
+ const withCatalog = catalogScopes(); // E2
114
+ const scopes = slugScopes(); // E3
115
+ const dev = devTenantsToSeed(localTenantDirIds(), staticIds, getTenantTheme, {
116
+ scopeFor: (id) => scopes.get(id),
117
+ hasCatalog: (scope) => withCatalog.has(scope),
118
+ });
72
119
  if (dev.length === 0) return staticResolver;
73
120
  // registry[0] stays the static default (HOME_TENANT) for unknown-host fallback.
74
121
  return new StaticTenantResolver([...staticTenants, ...dev]);
@@ -10,8 +10,8 @@ import AnnouncementBar from "@/components/chrome/AnnouncementBar.astro";
10
10
  import SiteHeader from "@/components/chrome/SiteHeader.astro";
11
11
  import SiteFooter from "@/components/chrome/SiteFooter.astro";
12
12
  import QuickView from "@/components/islands/QuickView.tsx";
13
- import FoxyLoader from "@/components/commerce/FoxyLoader.astro";
14
- import FoxySidecartStyles from "@/components/commerce/FoxySidecartStyles.astro";
13
+ import CheckoutLoader from "@/components/commerce/CheckoutLoader.astro";
14
+ import CartDrawerStyles from "@/components/commerce/CartDrawerStyles.astro";
15
15
  import NicotineWarning from "@/components/compliance/NicotineWarning.astro";
16
16
  import ShippingRestrictionNotice from "@/components/compliance/ShippingRestrictionNotice.astro";
17
17
  import IsolatedAgeGate from "@/components/islands/IsolatedAgeGate.tsx";
@@ -182,11 +182,12 @@ const analyticsEnabled =
182
182
 
183
183
  <AuthBadge />
184
184
 
185
+ {compliance?.nicotineWarning && <NicotineWarning variant="bar" />}
186
+
185
187
  {sharedChrome ? (
186
188
  <Fragment set:html={sharedChrome.header} />
187
189
  ) : (
188
190
  <>
189
- {compliance?.nicotineWarning && <NicotineWarning variant="bar" />}
190
191
  <div class="sticky top-0 z-40">
191
192
  {chrome.announcement && <AnnouncementBar text={chrome.announcement} variant="static" />}
192
193
  <SiteHeader
@@ -209,11 +210,12 @@ const analyticsEnabled =
209
210
  <slot />
210
211
  </main>
211
212
 
213
+ <ShippingRestrictionNotice text={compliance?.shippingRestriction} />
214
+
212
215
  {sharedChrome ? (
213
216
  <Fragment set:html={sharedChrome.footer} />
214
217
  ) : (
215
218
  <>
216
- <ShippingRestrictionNotice text={compliance?.shippingRestriction} />
217
219
  <SiteFooter
218
220
  brand={{
219
221
  wordmark: tenant.displayName,
@@ -237,8 +239,8 @@ const analyticsEnabled =
237
239
  {features.quickView && <QuickView client:idle locale={tenant.locale} />}
238
240
 
239
241
  {/* Excise tax (Track B) — when enabled for this environment, the excise line
240
- is calculated + added inside the FoxyCart cart (tot-foxycart pre-cart
241
- webhook). This is the storefront's honest shopper-facing signal of it. */}
242
+ is calculated + added inside the managed cart. This is the storefront's
243
+ honest shopper-facing signal of it. */}
242
244
  {capabilities.exciseTax.enabled && features.catalog && (
243
245
  <p
244
246
  class="border-t border-[var(--color-border)] bg-[var(--color-bg)] py-2.5 text-center text-xs text-[var(--color-muted)]"
@@ -248,13 +250,13 @@ const analyticsEnabled =
248
250
  </p>
249
251
  )}
250
252
 
251
- {/* FoxyCart loader (Powered by FoxyCart) — turns add-to-cart forms into the
252
- sidecart + hosted checkout. Only for commerce tenants with a store. */}
253
- <FoxyLoader />
253
+ {/* Checkout loader — turns add-to-cart forms into the cart drawer + hosted
254
+ checkout. Only for commerce tenants with a store. */}
255
+ <CheckoutLoader />
254
256
 
255
- {/* Brand Foxy's in-page sidecart to the tenant theme (baseline + optional
256
- per-tenant commerce.foxy.sidecartCss override). Commerce tenants only. */}
257
- <FoxySidecartStyles />
257
+ {/* Brand the in-page cart drawer to the tenant theme (baseline + optional
258
+ per-tenant commerce.checkout.cartDrawerCss override). Commerce tenants only. */}
259
+ <CartDrawerStyles />
258
260
 
259
261
  {/* Wishlist hearts: event-delegated toggle + state hydration + count sync.
260
262
  Cards/PDP carry [data-wishlist]; the header shows [data-wishlist-count].
@@ -1,7 +1,7 @@
1
1
  # `lib/admin` — admin data/view + AI/evidence contracts
2
2
 
3
- Read-oriented view models and deterministic mock services for the 7 admin
4
- domains (orders, products, customers, fulfillment, reporting, settings,
3
+ Read-oriented view models and deterministic mock services for the 8 admin
4
+ domains (orders, products, subscriptions, customers, fulfillment, reporting, settings,
5
5
  ai-workflows), plus the AI/evidence substrate (`ai/`, `evidence/`) those
6
6
  domains' assist surfaces build on. See
7
7
  `docs/handoffs/admin-app-implementation-baseline.md` for the product spec
@@ -8,8 +8,8 @@
8
8
  * complete descriptor. It is the WRITE-side guard; the go-live GateReport (U13)
9
9
  * still does the deeper compliance/contrast/perf pass on promote.
10
10
  *
11
- * Two hard constraints enforced here (both from the epic's Foxy-theme reality):
12
- * - **ASCII-safe.** Foxy's checkout cache pass is not Unicode-safe — a non-ASCII
11
+ * Two hard constraints enforced here:
12
+ * - **ASCII-safe.** Hosted checkout cache pass is not Unicode-safe — a non-ASCII
13
13
  * byte in a value that U11 renders into the pushed CSS can blank the checkout.
14
14
  * Every provided string leaf must be printable ASCII.
15
15
  * - **CSS-token hygiene.** These values are interpolated into generated CSS
@@ -32,7 +32,7 @@ export type CheckoutStyleInputResult =
32
32
  | { ok: false; errors: string[] };
33
33
 
34
34
  /** Per-field length ceilings — generous enough for real stacks, tight enough to
35
- * bound what gets pushed to Foxy. */
35
+ * bound what gets pushed to checkout. */
36
36
  const MAX = {
37
37
  color: 64,
38
38
  fontFamily: 200,
@@ -50,7 +50,7 @@ const COLOR_KEYS: (keyof CheckoutStyleColors)[] = [
50
50
  ];
51
51
 
52
52
  /** Printable ASCII only (0x20–0x7E). Rejects control chars, newlines, and any
53
- * non-ASCII (the Foxy-cache Unicode hazard). */
53
+ * non-ASCII (the hosted-checkout Unicode hazard). */
54
54
  function isPrintableAscii(value: string): boolean {
55
55
  return /^[\x20-\x7E]*$/.test(value);
56
56
  }
@@ -88,7 +88,7 @@ function takeString(
88
88
  return undefined;
89
89
  }
90
90
  if (!isPrintableAscii(value)) {
91
- errors.push(`${label} must be printable ASCII (Foxy checkout is not Unicode-safe)`);
91
+ errors.push(`${label} must be printable ASCII (hosted checkout is not Unicode-safe)`);
92
92
  return undefined;
93
93
  }
94
94
  if (hasUnsafeCssChars(value)) {