@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.
- package/apps/storefront/astro.config.mjs +59 -0
- package/apps/storefront/env.d.ts +24 -8
- package/apps/storefront/package.json +1 -1
- package/apps/storefront/src/components/admin/{AdminFoxyCartSettingsPanel.astro → AdminCheckoutSettingsPanel.astro} +50 -51
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +2 -2
- package/apps/storefront/src/components/admin/AdminSubscriptionsTab.astro +613 -0
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +3 -3
- package/apps/storefront/src/components/chrome/SiteHeader.astro +3 -4
- package/apps/storefront/src/components/commerce/{FoxySidecartStyles.astro → CartDrawerStyles.astro} +13 -14
- package/apps/storefront/src/components/commerce/CheckoutLoader.astro +21 -0
- package/apps/storefront/src/components/islands/VariantSelector.tsx +101 -20
- package/apps/storefront/src/config/adminTargetTenant.ts +2 -2
- package/apps/storefront/src/config/devTenantSeed.ts +65 -16
- package/apps/storefront/src/config/resolver.ts +48 -1
- package/apps/storefront/src/layouts/Layout.astro +14 -12
- package/apps/storefront/src/lib/admin/README.md +2 -2
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +5 -5
- package/apps/storefront/src/lib/admin/orderClient.ts +20 -20
- package/apps/storefront/src/lib/admin/services/mock/{foxyCartSettings.ts → checkoutSettings.ts} +21 -22
- package/apps/storefront/src/lib/admin/services/mock/index.ts +3 -2
- package/apps/storefront/src/lib/admin/services/mock/settings.ts +1 -1
- package/apps/storefront/src/lib/admin/services/mock/subscriptions.ts +398 -0
- package/apps/storefront/src/lib/admin/services/orders.ts +21 -21
- package/apps/storefront/src/lib/admin/viewmodels/subscriptions.ts +106 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +14 -3
- package/apps/storefront/src/lib/{foxyCommerce.ts → checkoutCommerce.ts} +20 -18
- package/apps/storefront/src/lib/commerce/{foxyCartSettings.ts → checkoutSettings.ts} +65 -66
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +9 -0
- package/apps/storefront/src/lib/dev/activityStore.ts +12 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +23 -4
- package/apps/storefront/src/lib/dev/runtimeStore.ts +193 -22
- package/apps/storefront/src/lib/edgeCache.ts +7 -6
- package/apps/storefront/src/lib/health.ts +53 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +33 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +1 -0
- package/apps/storefront/src/lib/sections.ts +19 -23
- package/apps/storefront/src/lib/securityHeaders.ts +1 -1
- package/apps/storefront/src/lib/storyblok/content-model.ts +3 -2
- package/apps/storefront/src/middleware/index.ts +29 -12
- package/apps/storefront/src/pages/[...slug].astro +5 -6
- package/apps/storefront/src/pages/admin.astro +10 -3
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +4 -4
- package/apps/storefront/src/pages/api/admin/orders.ts +5 -5
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +14 -7
- package/apps/storefront/src/pages/api/checkout-style.css.ts +5 -5
- package/apps/storefront/src/pages/auth/login.astro +4 -1
- package/apps/storefront/src/pages/auth/magic.astro +263 -43
- package/apps/storefront/src/pages/capabilities.astro +1 -1
- package/apps/storefront/src/pages/cockpit.astro +2365 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +19 -0
- package/apps/storefront/src/pages/index.astro +23 -29
- package/apps/storefront/src/pages/products/[handle].astro +50 -20
- package/package.json +6 -2
- package/packages/public-runtime/src/capabilities.ts +97 -9
- package/packages/public-runtime/src/{foxy-orders.ts → checkout-orders.ts} +28 -27
- package/packages/public-runtime/src/checkout-style-publish.ts +25 -25
- package/packages/public-runtime/src/checkout-style.ts +13 -13
- package/packages/public-runtime/src/{foxy.ts → checkout.ts} +111 -34
- package/packages/public-runtime/src/csp.ts +11 -11
- package/packages/public-runtime/src/customization-scripts.ts +28 -0
- package/packages/public-runtime/src/extension-contract.ts +73 -0
- package/packages/public-runtime/src/index.ts +4 -2
- package/packages/public-runtime/src/product.ts +57 -0
- package/packages/public-runtime/src/raw-html-policy.ts +35 -0
- package/packages/public-runtime/src/tenant.ts +14 -11
- package/pnpm-workspace.yaml +1 -0
- package/scripts/dev/checkout-watch.mjs +9 -0
- package/scripts/dev/file-browser.mjs +5 -1
- package/scripts/dev/transient-files.mjs +34 -0
- package/scripts/tot-dev.mjs +16 -3
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +0 -23
- package/apps/storefront/src/pages/api/dev/activity.ts +0 -124
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +0 -73
- package/apps/storefront/src/pages/api/dev/diff.ts +0 -68
- package/apps/storefront/src/pages/api/dev/file.ts +0 -57
- package/apps/storefront/src/pages/api/dev/locate.ts +0 -125
- package/apps/storefront/src/pages/api/dev/publish.ts +0 -128
- package/apps/storefront/src/pages/api/dev/tree.ts +0 -70
- package/apps/storefront/src/pages/api/dev/write.ts +0 -116
package/apps/storefront/src/components/commerce/{FoxySidecartStyles.astro → CartDrawerStyles.astro}
RENAMED
|
@@ -1,28 +1,27 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* <
|
|
3
|
+
* <CartDrawerStyles> — brands the in-page cart drawer to the tenant's theme.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
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
|
|
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 `
|
|
12
|
-
* override (commerce.
|
|
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:
|
|
15
|
-
*
|
|
16
|
-
* Everything is scoped under `#fc` so nothing leaks
|
|
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
|
|
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
|
|
25
|
-
// #fc —
|
|
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?.
|
|
58
|
-
? `${baseline}\n\n/* --- tenant override --- */\n${commerce.
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
36
|
-
* variant; the island renders the selected variant's payload as a real
|
|
37
|
-
* form
|
|
38
|
-
*
|
|
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
|
-
|
|
46
|
+
checkoutCart?: {
|
|
41
47
|
action: string;
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
|
|
218
|
+
selectedPrice !== undefined &&
|
|
219
|
+
displayCompareAt !== undefined &&
|
|
220
|
+
displayCompareAt > selectedPrice;
|
|
189
221
|
|
|
190
|
-
//
|
|
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
|
-
|
|
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
|
-
:
|
|
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(
|
|
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
|
|
327
|
-
loader.js turns the submit into 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
|
-
{
|
|
413
|
+
{checkoutCart ? (
|
|
333
414
|
<div class="flex flex-col gap-1.5">
|
|
334
|
-
<form action={
|
|
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
|
|
9
|
-
* panel, which derives from `theme_tokens` + `commerce.
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
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
|
|
40
|
-
domains: [
|
|
41
|
-
canonicalDomain:
|
|
64
|
+
appDomain,
|
|
65
|
+
domains: [appDomain],
|
|
66
|
+
canonicalDomain: appDomain,
|
|
42
67
|
displayName: id,
|
|
43
|
-
// Contains "PLACEHOLDER" →
|
|
44
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
14
|
-
import
|
|
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
|
|
241
|
-
|
|
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
|
-
{/*
|
|
252
|
-
|
|
253
|
-
<
|
|
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
|
|
256
|
-
per-tenant commerce.
|
|
257
|
-
<
|
|
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
|
|
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
|
|
12
|
-
* - **ASCII-safe.**
|
|
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
|
|
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
|
|
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 (
|
|
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)) {
|