@tokenoftrust/storefront-runner 2.5.5 → 3.0.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/agency-kit/tools/lib/public-binaries.mjs +95 -0
- package/apps/storefront/astro.config.mjs +7 -0
- package/apps/storefront/env.d.ts +3 -0
- package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +5 -4
- package/apps/storefront/src/components/catalog/storefront/commerce/PriceDisplay.astro +2 -1
- package/apps/storefront/src/components/catalog/storefront/islands/ImageGallery.tsx +20 -3
- package/apps/storefront/src/components/catalog/storefront/islands/SearchTypeahead.tsx +17 -20
- package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
- package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
- package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
- package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
- package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
- package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
- package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
- package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
- package/apps/storefront/src/config/devTenantSeed.ts +2 -1
- package/apps/storefront/src/layouts/Layout.astro +1 -1
- package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
- package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
- package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
- package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
- package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
- package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
- package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
- package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
- package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
- package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
- package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
- package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
- package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
- package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
- package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
- package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
- package/apps/storefront/src/lib/content/storyblok/provider.ts +14 -7
- package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
- package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
- package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
- package/apps/storefront/src/middleware/controlPlane.ts +14 -11
- package/apps/storefront/src/middleware/index.ts +33 -12
- package/apps/storefront/src/middleware/renderChain.ts +52 -26
- package/apps/storefront/src/pages/[...slug].astro +3 -0
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
- package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
- package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
- package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
- package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
- package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
- package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
- package/apps/storefront/src/pages/collections/[handle].astro +13 -198
- package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
- package/apps/storefront/src/pages/collections/index.astro +1 -1
- package/apps/storefront/src/pages/products/[handle].astro +18 -8
- package/apps/storefront/src/pages/search.astro +33 -33
- package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
- package/apps/storefront/vitest.config.cts +3 -0
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +318 -32
- package/packages/cli/src/publication-spaces.mjs +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
- package/packages/public-runtime/src/activity/catalog.ts +8 -1
- package/packages/public-runtime/src/candidate-index.ts +5 -0
- package/packages/public-runtime/src/capabilities.ts +12 -9
- package/packages/public-runtime/src/capture-rewrites.ts +687 -0
- package/packages/public-runtime/src/checkout-orders.ts +6 -7
- package/packages/public-runtime/src/checkout.ts +190 -6
- package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
- package/packages/public-runtime/src/compliance/profile.ts +90 -14
- package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
- package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
- package/packages/public-runtime/src/customization-runtime.ts +64 -2
- package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
- package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
- package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
- package/packages/public-runtime/src/membership.ts +3 -0
- package/packages/public-runtime/src/publication-space.ts +1 -0
- package/packages/public-runtime/src/tenant.ts +28 -4
- package/scripts/tenant/validate.mjs +17 -8
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <CatalogQueryView> — applies a listing or search URL's query in the browser
|
|
3
|
+
* when the page's render did not (lib/publication/shared/staticQuery.ts). A
|
|
4
|
+
* static host serves one document per path, so `?sort=`, facets, price and
|
|
5
|
+
* `?q=` reach no renderer there; this island reads the static catalog index and
|
|
6
|
+
* replaces the rendered results (`[data-catalog-query-rendered]`) with the view
|
|
7
|
+
* the URL asks for. Where the render already applied the query (the Worker, the
|
|
8
|
+
* local dev loop) it renders nothing.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useState } from "preact/hooks";
|
|
11
|
+
import type { Facet, ListProductsQuery } from "@tot/public-runtime";
|
|
12
|
+
import {
|
|
13
|
+
bucketByKey,
|
|
14
|
+
collectionViewFromIndex,
|
|
15
|
+
loadCatalogIndex,
|
|
16
|
+
searchFromIndex,
|
|
17
|
+
} from "@/lib/catalog/shared/catalogQueryView";
|
|
18
|
+
import type { CatalogIndexProduct } from "@/lib/catalog/shared/catalogIndex";
|
|
19
|
+
import { planCollectionQuery, searchQueryFromUrl } from "@/lib/publication/shared/staticQuery";
|
|
20
|
+
import {
|
|
21
|
+
buildQueryString,
|
|
22
|
+
isPriceBucketActive,
|
|
23
|
+
toggleFacet,
|
|
24
|
+
togglePriceBucket,
|
|
25
|
+
} from "@/lib/publication/shared/url";
|
|
26
|
+
import { formatPrice } from "@/lib/content/shared/format";
|
|
27
|
+
import { clientBasePath, withClientBase } from "@/lib/publication/shared/basePath";
|
|
28
|
+
|
|
29
|
+
type Props =
|
|
30
|
+
| {
|
|
31
|
+
mode: "collection";
|
|
32
|
+
collection: string;
|
|
33
|
+
/** The page the route's path names. */
|
|
34
|
+
pathPage: number;
|
|
35
|
+
/** Tenant-prefixed collection path in the tenant's URL form: query links hang off it. */
|
|
36
|
+
linkBase: string;
|
|
37
|
+
/** Tenant-prefixed collection path, unformed: page variants hang off it. */
|
|
38
|
+
pageBase: string;
|
|
39
|
+
trailingSlash: boolean;
|
|
40
|
+
locale: string;
|
|
41
|
+
}
|
|
42
|
+
| { mode: "search"; trailingSlash: boolean; locale: string };
|
|
43
|
+
|
|
44
|
+
type View =
|
|
45
|
+
| { kind: "collection"; query: ListProductsQuery; products: CatalogIndexProduct[]; page: number; pageSize: number; total: number; facets: Facet[] }
|
|
46
|
+
| { kind: "search"; q: string; products: CatalogIndexProduct[] }
|
|
47
|
+
| { kind: "error" };
|
|
48
|
+
|
|
49
|
+
function renderedRegion(): HTMLElement | null {
|
|
50
|
+
return document.querySelector<HTMLElement>("[data-catalog-query-rendered]");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default function CatalogQueryView(props: Props) {
|
|
54
|
+
const [view, setView] = useState<View | null>(null);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const region = renderedRegion();
|
|
58
|
+
const appliedKey = region?.dataset.appliedQuery ?? null;
|
|
59
|
+
const url = new URL(window.location.href);
|
|
60
|
+
let run: (() => Promise<View>) | null = null;
|
|
61
|
+
|
|
62
|
+
if (props.mode === "collection") {
|
|
63
|
+
const plan = planCollectionQuery({
|
|
64
|
+
url,
|
|
65
|
+
pathPage: props.pathPage,
|
|
66
|
+
appliedKey,
|
|
67
|
+
pageBase: props.pageBase,
|
|
68
|
+
trailingSlash: props.trailingSlash,
|
|
69
|
+
});
|
|
70
|
+
if (plan.kind === "rendered") return;
|
|
71
|
+
if (plan.kind === "redirect") {
|
|
72
|
+
window.location.replace(plan.href);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
run = async () => {
|
|
76
|
+
const index = await loadCatalogIndex(clientBasePath());
|
|
77
|
+
return { kind: "collection", query: plan.query, ...collectionViewFromIndex(index, props.collection, plan.query) } as View;
|
|
78
|
+
};
|
|
79
|
+
} else {
|
|
80
|
+
const q = searchQueryFromUrl(url);
|
|
81
|
+
if (appliedKey !== null && q === appliedKey) return;
|
|
82
|
+
run = async () => {
|
|
83
|
+
const index = await loadCatalogIndex(clientBasePath());
|
|
84
|
+
return { kind: "search", q, products: q ? searchFromIndex(index, q) : [] };
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
run()
|
|
89
|
+
.then(setView)
|
|
90
|
+
.catch(() => setView({ kind: "error" }));
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (!view || view.kind === "error") return;
|
|
95
|
+
const region = renderedRegion();
|
|
96
|
+
if (region) region.style.display = "none";
|
|
97
|
+
if (view.kind === "search") {
|
|
98
|
+
const heading = document.querySelector<HTMLElement>("[data-search-heading]");
|
|
99
|
+
if (heading) heading.textContent = view.q ? `Results for “${view.q}”` : "What are you after?";
|
|
100
|
+
const input = document.querySelector<HTMLInputElement>("input[name=q]");
|
|
101
|
+
if (input) input.value = view.q;
|
|
102
|
+
}
|
|
103
|
+
}, [view]);
|
|
104
|
+
|
|
105
|
+
if (!view || view.kind === "error") return null;
|
|
106
|
+
const productHref = (handle: string) => {
|
|
107
|
+
const path = withClientBase(`/products/${handle}`);
|
|
108
|
+
return props.trailingSlash && !path.endsWith("/") ? `${path}/` : path;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
if (view.kind === "search") {
|
|
112
|
+
return (
|
|
113
|
+
<div class="container-prose pb-[clamp(2rem,5vw,3.5rem)]" data-widget="catalog-query-search">
|
|
114
|
+
<p class="mb-6 text-sm text-[var(--color-muted)]">
|
|
115
|
+
{view.products.length} {view.products.length === 1 ? "result" : "results"}
|
|
116
|
+
</p>
|
|
117
|
+
{view.q && view.products.length === 0 ? (
|
|
118
|
+
<p class="display-md">No matches for “{view.q}”</p>
|
|
119
|
+
) : (
|
|
120
|
+
<Grid products={view.products} locale={props.locale} productHref={productHref} wide />
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const linkBase = props.mode === "collection" ? props.linkBase : "";
|
|
127
|
+
return (
|
|
128
|
+
<CollectionResults view={view} linkBase={linkBase} locale={props.locale} productHref={productHref} />
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function Grid(props: {
|
|
133
|
+
products: CatalogIndexProduct[];
|
|
134
|
+
locale: string;
|
|
135
|
+
productHref: (handle: string) => string;
|
|
136
|
+
wide?: boolean;
|
|
137
|
+
}) {
|
|
138
|
+
return (
|
|
139
|
+
<ul class={props.wide ? "grid grid-cols-2 gap-5 lg:grid-cols-4" : "grid grid-cols-2 gap-5 lg:grid-cols-3"}>
|
|
140
|
+
{props.products.map((p) => {
|
|
141
|
+
const img = p.image;
|
|
142
|
+
return (
|
|
143
|
+
<li class="group relative">
|
|
144
|
+
<div class="relative aspect-[4/5] overflow-hidden rounded-md bg-[var(--color-border)] ring-1 ring-[var(--color-border)]">
|
|
145
|
+
{img?.url && (
|
|
146
|
+
<img
|
|
147
|
+
src={img.url}
|
|
148
|
+
alt={img.alt ?? p.title}
|
|
149
|
+
width={400}
|
|
150
|
+
height={500}
|
|
151
|
+
loading="lazy"
|
|
152
|
+
class="h-full w-full object-cover transition-transform duration-500 motion-safe:group-hover:scale-[1.03]"
|
|
153
|
+
/>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
<a href={props.productHref(p.handle)} class="mt-3 block after:absolute after:inset-0">
|
|
157
|
+
{p.vendor && (
|
|
158
|
+
<span class="block font-mono text-[0.62rem] uppercase tracking-[0.14em] text-[var(--color-muted)]">
|
|
159
|
+
{p.vendor}
|
|
160
|
+
</span>
|
|
161
|
+
)}
|
|
162
|
+
<span class="mt-1 block font-display text-[0.95rem] font-medium leading-snug">{p.title}</span>
|
|
163
|
+
<span class="mt-1 block font-display text-sm tabular-nums">
|
|
164
|
+
{formatPrice(p.price_range.min, p.price_range.currency, props.locale)}
|
|
165
|
+
</span>
|
|
166
|
+
</a>
|
|
167
|
+
</li>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
170
|
+
</ul>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const SORTS: { value: NonNullable<ListProductsQuery["sort"]>; label: string }[] = [
|
|
175
|
+
{ value: "featured", label: "Featured" },
|
|
176
|
+
{ value: "newest", label: "Newest" },
|
|
177
|
+
{ value: "price-asc", label: "Price: Low to High" },
|
|
178
|
+
{ value: "price-desc", label: "Price: High to Low" },
|
|
179
|
+
{ value: "title-asc", label: "Alphabetical (A–Z)" },
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
function CollectionResults(props: {
|
|
183
|
+
view: Extract<View, { kind: "collection" }>;
|
|
184
|
+
linkBase: string;
|
|
185
|
+
locale: string;
|
|
186
|
+
productHref: (handle: string) => string;
|
|
187
|
+
}) {
|
|
188
|
+
const { query, products, page, pageSize, total, facets } = props.view;
|
|
189
|
+
const base = props.linkBase;
|
|
190
|
+
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
|
191
|
+
const chips: { label: string; href: string }[] = [];
|
|
192
|
+
for (const [name, values] of Object.entries(query.filters ?? {})) {
|
|
193
|
+
for (const v of values) chips.push({ label: name === "tags" ? `#${v}` : `${name}: ${v}`, href: base + toggleFacet(query, name, v) });
|
|
194
|
+
}
|
|
195
|
+
if (query.priceMin != null || query.priceMax != null) {
|
|
196
|
+
const { priceMin: _min, priceMax: _max, ...rest } = query;
|
|
197
|
+
chips.push({ label: `$${query.priceMin ?? 0}–${query.priceMax != null ? `$${query.priceMax}` : "any"}`, href: base + buildQueryString(rest) });
|
|
198
|
+
}
|
|
199
|
+
if (query.availability === "in-stock") {
|
|
200
|
+
const { availability: _a, ...rest } = query;
|
|
201
|
+
chips.push({ label: "In stock", href: base + buildQueryString(rest) });
|
|
202
|
+
}
|
|
203
|
+
const selected = (name: string, value: string) => (query.filters?.[name] ?? []).includes(value);
|
|
204
|
+
const facetLabel = (name: string) => (name === "tags" ? "Tags" : name === "availability" ? "Availability" : name);
|
|
205
|
+
const ordered = facets
|
|
206
|
+
.filter((f) => f.name !== "price")
|
|
207
|
+
.sort((a, b) => {
|
|
208
|
+
const rank = (n: string) => (n === "availability" ? 2 : n === "tags" ? 1 : 0);
|
|
209
|
+
return rank(a.name) - rank(b.name);
|
|
210
|
+
});
|
|
211
|
+
const priceFacet = facets.find((f) => f.name === "price");
|
|
212
|
+
const option = (href: string, label: string, count: number, isSel: boolean) => (
|
|
213
|
+
<li>
|
|
214
|
+
<a
|
|
215
|
+
href={href}
|
|
216
|
+
rel="nofollow"
|
|
217
|
+
aria-current={isSel ? "true" : undefined}
|
|
218
|
+
class={`flex items-center justify-between gap-3 rounded-[var(--radius-sm)] px-2 py-1 ${isSel ? "bg-[var(--color-primary)] text-[var(--color-primary-contrast)]" : "hover:bg-[color-mix(in_srgb,var(--color-primary)_8%,transparent)]"}`}
|
|
219
|
+
>
|
|
220
|
+
<span>{isSel ? "✓ " : ""}{label}</span>
|
|
221
|
+
<span class="text-xs">{count}</span>
|
|
222
|
+
</a>
|
|
223
|
+
</li>
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div class="container-prose grid gap-10 pb-16 lg:grid-cols-[230px_1fr]" data-widget="catalog-query-collection">
|
|
228
|
+
<aside aria-label="Filters" class="text-sm lg:sticky lg:top-24 lg:self-start">
|
|
229
|
+
<div class="space-y-7">
|
|
230
|
+
{ordered.map((facet) => (
|
|
231
|
+
<fieldset>
|
|
232
|
+
<legend class="mb-2.5 font-display text-[0.95rem] font-medium">{facetLabel(facet.name)}</legend>
|
|
233
|
+
<ul class="space-y-1.5">
|
|
234
|
+
{facet.values.map((fv) =>
|
|
235
|
+
option(
|
|
236
|
+
base + toggleFacet(query, facet.name, fv.value),
|
|
237
|
+
fv.value === "all" ? "All items" : fv.value,
|
|
238
|
+
fv.count,
|
|
239
|
+
selected(facet.name, fv.value),
|
|
240
|
+
),
|
|
241
|
+
)}
|
|
242
|
+
</ul>
|
|
243
|
+
</fieldset>
|
|
244
|
+
))}
|
|
245
|
+
{priceFacet && (
|
|
246
|
+
<fieldset>
|
|
247
|
+
<legend class="mb-2.5 font-display text-[0.95rem] font-medium">Price</legend>
|
|
248
|
+
<ul class="space-y-1.5">
|
|
249
|
+
{priceFacet.values.map((fv) => {
|
|
250
|
+
const bucket = bucketByKey(fv.value);
|
|
251
|
+
return bucket
|
|
252
|
+
? option(base + togglePriceBucket(query, bucket), bucket.label, fv.count, isPriceBucketActive(query, bucket))
|
|
253
|
+
: null;
|
|
254
|
+
})}
|
|
255
|
+
</ul>
|
|
256
|
+
</fieldset>
|
|
257
|
+
)}
|
|
258
|
+
</div>
|
|
259
|
+
</aside>
|
|
260
|
+
|
|
261
|
+
<div>
|
|
262
|
+
<div class="mb-5 flex flex-wrap items-center justify-between gap-3 border-b border-[var(--color-border)] pb-4">
|
|
263
|
+
<p class="text-sm text-[var(--color-muted)]">
|
|
264
|
+
{total} {total === 1 ? "item" : "items"}
|
|
265
|
+
</p>
|
|
266
|
+
<label class="flex items-center gap-2 text-sm">
|
|
267
|
+
<span class="text-[var(--color-muted)]">Sort</span>
|
|
268
|
+
<select
|
|
269
|
+
class="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 font-display"
|
|
270
|
+
onChange={(e) => {
|
|
271
|
+
const sort = (e.currentTarget as HTMLSelectElement).value as ListProductsQuery["sort"];
|
|
272
|
+
window.location.assign(base + buildQueryString(query, { sort, page: 1 }));
|
|
273
|
+
}}
|
|
274
|
+
>
|
|
275
|
+
{SORTS.map((o) => (
|
|
276
|
+
<option value={o.value} selected={o.value === (query.sort ?? "featured")}>{o.label}</option>
|
|
277
|
+
))}
|
|
278
|
+
</select>
|
|
279
|
+
</label>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
{chips.length > 0 && (
|
|
283
|
+
<div class="mb-5 flex flex-wrap items-center gap-2">
|
|
284
|
+
{chips.map((chip) => (
|
|
285
|
+
<a href={chip.href} rel="nofollow" class="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-3 pr-2 text-xs">
|
|
286
|
+
<span>{chip.label}</span>
|
|
287
|
+
<span aria-hidden="true">×</span>
|
|
288
|
+
<span class="sr-only">Remove filter</span>
|
|
289
|
+
</a>
|
|
290
|
+
))}
|
|
291
|
+
<a href={base} rel="nofollow" class="ml-1 text-xs text-[var(--color-muted)] underline underline-offset-2">Clear all</a>
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
|
|
295
|
+
{products.length === 0 ? (
|
|
296
|
+
<p class="display-md">Nothing matches — yet</p>
|
|
297
|
+
) : (
|
|
298
|
+
<Grid products={products} locale={props.locale} productHref={props.productHref} />
|
|
299
|
+
)}
|
|
300
|
+
|
|
301
|
+
{pageCount > 1 && (
|
|
302
|
+
<nav aria-label="Pagination" class="mt-12 flex justify-center">
|
|
303
|
+
<ul class="flex items-center gap-1">
|
|
304
|
+
{Array.from({ length: pageCount }, (_, i) => i + 1).map((p) => (
|
|
305
|
+
<li>
|
|
306
|
+
<a
|
|
307
|
+
href={base + buildQueryString(query, { page: p })}
|
|
308
|
+
aria-current={p === page ? "page" : undefined}
|
|
309
|
+
class={`grid h-9 w-9 place-items-center rounded-[var(--radius-sm)] font-display text-sm ${p === page ? "bg-[var(--color-primary)] text-[var(--color-primary-contrast)]" : "hover:bg-[var(--color-surface)]"}`}
|
|
310
|
+
>
|
|
311
|
+
{p}
|
|
312
|
+
</a>
|
|
313
|
+
</li>
|
|
314
|
+
))}
|
|
315
|
+
</ul>
|
|
316
|
+
</nav>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
@@ -19,8 +19,9 @@ import {
|
|
|
19
19
|
type DeclaredTenantConfig,
|
|
20
20
|
} from "@tot/public-runtime/declared-tenant-config";
|
|
21
21
|
import {
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
customizationArtifactKey,
|
|
23
|
+
readSiteVersion,
|
|
24
|
+
resolvePublishedArtifactKey,
|
|
24
25
|
tenantIdFromPath,
|
|
25
26
|
type CustomizationEnv,
|
|
26
27
|
type TenantConfig,
|
|
@@ -55,40 +56,67 @@ export function checkoutDeclaredTenantConfig(tenantId: string): DeclaredTenantCo
|
|
|
55
56
|
?? checkoutConfigFiles[`../../../../tenants/e2e/${tenantId}/.tot/config.json`]);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
59
|
+
/**
|
|
60
|
+
* A stored declaration read back: what it declares, or `null` when it could not be read.
|
|
61
|
+
* `null` is never "declares nothing" — a regulated store whose compliance lives only in its
|
|
62
|
+
* `.tot/config.json` would read as unregulated. A judge takes it as unread
|
|
63
|
+
* (`declaredComplianceSignals`); a render refuses (`DeclaredTenantConfigUnreadError`).
|
|
64
|
+
*/
|
|
65
|
+
export type DeclarationRead = DeclaredTenantConfig | null;
|
|
66
|
+
|
|
67
|
+
function parseDeclaration(raw: string | null): DeclarationRead {
|
|
68
|
+
if (raw === null) return null;
|
|
66
69
|
try {
|
|
67
70
|
return declaredTenantConfig(JSON.parse(raw));
|
|
68
71
|
} catch {
|
|
69
|
-
return
|
|
72
|
+
return null;
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
76
|
+
/**
|
|
77
|
+
* The declaration the reconcile published for this environment. `{}` when the environment
|
|
78
|
+
* serves no declaration (nothing published, or the served version has none); `null` when the
|
|
79
|
+
* served version names one whose body cannot be read or parsed.
|
|
80
|
+
*/
|
|
81
|
+
export async function readPublishedDeclaration(
|
|
82
|
+
kvGet: (key: string) => Promise<string | null>,
|
|
83
|
+
tenantId: string,
|
|
84
|
+
env: CustomizationEnv,
|
|
85
|
+
): Promise<DeclarationRead> {
|
|
86
|
+
const key = await resolvePublishedArtifactKey(kvGet, tenantId, env, declaredTenantConfigArtifactPath(tenantId));
|
|
87
|
+
return key ? parseDeclaration(await kvGet(key)) : {};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The declaration pinned to one immutable version (a candidate under review). `{}` when the
|
|
92
|
+
* version carries none (the reconcile materializes only a non-empty declaration); `null` when
|
|
93
|
+
* the version's manifest cannot be read, or it lists a declaration whose body cannot be read or
|
|
94
|
+
* parsed.
|
|
95
|
+
*/
|
|
96
|
+
export async function readVersionDeclaration(
|
|
75
97
|
kvGet: (key: string) => Promise<string | null>,
|
|
76
98
|
tenantId: string,
|
|
77
99
|
versionId: string,
|
|
78
|
-
): Promise<
|
|
79
|
-
const
|
|
80
|
-
if (!
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
100
|
+
): Promise<DeclarationRead> {
|
|
101
|
+
const manifest = await readSiteVersion(kvGet, tenantId, versionId);
|
|
102
|
+
if (!manifest) return null;
|
|
103
|
+
const contentHash = manifest.artifacts?.[declaredTenantConfigArtifactPath(tenantId)];
|
|
104
|
+
return contentHash ? parseDeclaration(await kvGet(customizationArtifactKey(tenantId, contentHash))) : {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A render refused because the declaration it would render under could not be read. */
|
|
108
|
+
export class DeclaredTenantConfigUnreadError extends Error {
|
|
109
|
+
constructor(tenantId: string, source: string) {
|
|
110
|
+
super(`declared config unreadable for ${tenantId} (${source}); refusing to render without it`);
|
|
111
|
+
this.name = "DeclaredTenantConfigUnreadError";
|
|
85
112
|
}
|
|
86
113
|
}
|
|
87
114
|
|
|
88
115
|
/**
|
|
89
116
|
* The tenant as it renders: its record with every declaration applied. A render
|
|
90
117
|
* pinned to one version (a candidate under review) takes that version's
|
|
91
|
-
* declaration; otherwise the environment's channel supplies it.
|
|
118
|
+
* declaration; otherwise the environment's channel supplies it. Throws
|
|
119
|
+
* {@link DeclaredTenantConfigUnreadError} when that declaration cannot be read.
|
|
92
120
|
*/
|
|
93
121
|
export async function withDeclaredTenantConfig(
|
|
94
122
|
tenant: TenantConfig,
|
|
@@ -96,10 +124,14 @@ export async function withDeclaredTenantConfig(
|
|
|
96
124
|
source: { env: CustomizationEnv; pinnedVersionId?: string },
|
|
97
125
|
): Promise<TenantConfig> {
|
|
98
126
|
const fromCheckout = applyDeclaredTenantConfig(tenant, checkoutDeclaredTenantConfig(tenant.tenant_id));
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
source.
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
127
|
+
const declared = source.pinnedVersionId
|
|
128
|
+
? await readVersionDeclaration(kvGet, tenant.tenant_id, source.pinnedVersionId)
|
|
129
|
+
: await readPublishedDeclaration(kvGet, tenant.tenant_id, source.env);
|
|
130
|
+
if (declared === null) {
|
|
131
|
+
throw new DeclaredTenantConfigUnreadError(
|
|
132
|
+
tenant.tenant_id,
|
|
133
|
+
source.pinnedVersionId ? `version ${source.pinnedVersionId}` : `channel ${source.env}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return applyDeclaredTenantConfig(fromCheckout, declared);
|
|
105
137
|
}
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import type { DeepPartial, TenantConfig, ThemeTokens } from "@tot/public-runtime";
|
|
29
29
|
import { SHARED_DEMO_CHECKOUT } from "@tot/public-runtime";
|
|
30
|
+
import { DEFAULT_CURRENCY } from "@tot/public-runtime/declared-tenant-config";
|
|
30
31
|
import { rootPublicationSpaceConfig } from "./publicationSpaceConfig";
|
|
31
32
|
import { loadTenantAnalyticsDeclarations } from "@/lib/integrations/integration/tenantAnalyticsDeclarations";
|
|
32
33
|
|
|
@@ -81,7 +82,7 @@ export function synthesizeDevTenant(
|
|
|
81
82
|
// content/home.html (globbed by dir == this id).
|
|
82
83
|
storyblok_space_token: "STORYBLOK_DEV_TENANT_TOKEN_PLACEHOLDER",
|
|
83
84
|
tot_data_scope: appDomain,
|
|
84
|
-
currency:
|
|
85
|
+
currency: DEFAULT_CURRENCY,
|
|
85
86
|
locale: "en-US",
|
|
86
87
|
theme_tokens: themeTokens,
|
|
87
88
|
publicationSpaces: [rootPublicationSpaceConfig(id, {
|
|
@@ -483,7 +483,7 @@ const footerWidgets = await renderWidgetSlot(
|
|
|
483
483
|
|
|
484
484
|
{/* Checkout loader — turns add-to-cart forms into the cart drawer + hosted
|
|
485
485
|
checkout. Only for commerce tenants with a store. */}
|
|
486
|
-
<CheckoutLoader />
|
|
486
|
+
<CheckoutLoader nonce={cspNonce} />
|
|
487
487
|
|
|
488
488
|
{/* Brand the in-page cart drawer to the tenant theme (baseline + optional
|
|
489
489
|
per-tenant commerce.checkout.cartDrawerCss override). Commerce tenants only. */}
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* renders publicly so people can browse + sign up.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { GATE_REMEDY, type GateReason } from "@/lib/access/shared/publicAccess";
|
|
15
|
+
|
|
14
16
|
/** Escape a string for safe interpolation into an HTML attribute value. */
|
|
15
17
|
function escapeAttr(s: string): string {
|
|
16
18
|
return s
|
|
@@ -62,6 +64,7 @@ export function gatedHoldingHtml(loginHref: string): string {
|
|
|
62
64
|
<span class="badge">Token of Trust</span>
|
|
63
65
|
<h1>Coming soon</h1>
|
|
64
66
|
<p>This storefront isn't open to the public yet. If you have access, sign in to preview it.</p>
|
|
67
|
+
<p style="font-size:.85rem;color:#94a3b8">Store owner? This hosted address is a sign-in review surface and stays gated by design; your public site is served on your own domain.</p>
|
|
65
68
|
<a class="btn" href="${href}">Sign in</a>
|
|
66
69
|
</main>
|
|
67
70
|
</body>
|
|
@@ -75,17 +78,26 @@ export function gatedHoldingHtml(loginHref: string): string {
|
|
|
75
78
|
*/
|
|
76
79
|
export const GATE_HEADER = "x-tot-gate";
|
|
77
80
|
export const GATE_WITHHELD = "store-not-public";
|
|
81
|
+
/** Why the gate withheld, and where the answer to "what turns it off" lives. */
|
|
82
|
+
export const GATE_REASON_HEADER = "x-tot-gate-reason";
|
|
83
|
+
export const GATE_DOCS_HEADER = "x-tot-gate-docs";
|
|
84
|
+
export const GATE_DOCS = "runbook://storefront-publish-go-live";
|
|
85
|
+
|
|
86
|
+
function gateHeaders(reason: GateReason): Record<string, string> {
|
|
87
|
+
return {
|
|
88
|
+
"cache-control": "no-store",
|
|
89
|
+
"x-robots-tag": "noindex, nofollow",
|
|
90
|
+
[GATE_HEADER]: GATE_WITHHELD,
|
|
91
|
+
[GATE_REASON_HEADER]: reason,
|
|
92
|
+
[GATE_DOCS_HEADER]: GATE_DOCS,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
78
95
|
|
|
79
96
|
/** A `200 OK` holding page — never indexed, never cached. */
|
|
80
|
-
export function gatedHoldingResponse(loginHref: string): Response {
|
|
97
|
+
export function gatedHoldingResponse(loginHref: string, reason: GateReason): Response {
|
|
81
98
|
return new Response(gatedHoldingHtml(loginHref), {
|
|
82
99
|
status: 200,
|
|
83
|
-
headers: {
|
|
84
|
-
"content-type": "text/html; charset=utf-8",
|
|
85
|
-
"cache-control": "no-store",
|
|
86
|
-
"x-robots-tag": "noindex, nofollow",
|
|
87
|
-
[GATE_HEADER]: GATE_WITHHELD,
|
|
88
|
-
},
|
|
100
|
+
headers: { ...gateHeaders(reason), "content-type": "text/html; charset=utf-8" },
|
|
89
101
|
});
|
|
90
102
|
}
|
|
91
103
|
|
|
@@ -103,12 +115,8 @@ function isNavigation(request: Request): boolean {
|
|
|
103
115
|
* the holding page with its Sign-in button, the same answer the store's pages give;
|
|
104
116
|
* a fetch gets JSON naming the reason and where to sign in.
|
|
105
117
|
*/
|
|
106
|
-
export function gatedApiResponse(loginHref: string, request: Request): Response {
|
|
107
|
-
const headers =
|
|
108
|
-
"cache-control": "no-store",
|
|
109
|
-
"x-robots-tag": "noindex, nofollow",
|
|
110
|
-
[GATE_HEADER]: GATE_WITHHELD,
|
|
111
|
-
};
|
|
118
|
+
export function gatedApiResponse(loginHref: string, request: Request, reason: GateReason): Response {
|
|
119
|
+
const headers = gateHeaders(reason);
|
|
112
120
|
if (isNavigation(request)) {
|
|
113
121
|
return new Response(gatedHoldingHtml(loginHref), {
|
|
114
122
|
status: 401,
|
|
@@ -116,7 +124,14 @@ export function gatedApiResponse(loginHref: string, request: Request): Response
|
|
|
116
124
|
});
|
|
117
125
|
}
|
|
118
126
|
return new Response(
|
|
119
|
-
JSON.stringify({
|
|
127
|
+
JSON.stringify({
|
|
128
|
+
ok: false,
|
|
129
|
+
error: GATE_WITHHELD,
|
|
130
|
+
reason,
|
|
131
|
+
remedy: GATE_REMEDY[reason],
|
|
132
|
+
docs: GATE_DOCS,
|
|
133
|
+
signIn: loginHref,
|
|
134
|
+
}),
|
|
120
135
|
{ status: 401, headers: { ...headers, "content-type": "application/json; charset=utf-8" } },
|
|
121
136
|
);
|
|
122
137
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Can the public see this store? — the one answer, derived from the SAME inputs the
|
|
3
|
+
* login gate (`controlPlane.ts` → `evaluateLoginGate`) decides on, so the pipeline
|
|
4
|
+
* verdict, the gate's own response headers, and the docs can never disagree.
|
|
5
|
+
*
|
|
6
|
+
* The gate withholds a store (the "Coming soon" page, `x-tot-gate: store-not-public`) for
|
|
7
|
+
* exactly two reasons, and no others:
|
|
8
|
+
*
|
|
9
|
+
* - `shared-preview-host`: the request host is on `DEMO_GATE_HOSTS` (the shared
|
|
10
|
+
* `*.tokenoftrust.store` hosts) in `login` mode and the platform registry does not
|
|
11
|
+
* declare the tenant `access.public`. That is the permanent state of every hosted
|
|
12
|
+
* preview URL, by design: it is a review surface, not a storefront.
|
|
13
|
+
* - `storefront-unpaid`: `storefront.mode` is explicitly set to something other than
|
|
14
|
+
* `"enabled"`. This gates the tenant on every host the Worker serves, billing-bound.
|
|
15
|
+
*
|
|
16
|
+
* A tenant's own public site is not on the shared host. Cloudflare tenants are served by
|
|
17
|
+
* the Worker on their own domain once it is attached (not in `DEMO_GATE_HOSTS`, so the
|
|
18
|
+
* gate does not apply); CloudFront (`s3-overwrite`) tenants are served CDN -> S3 with no
|
|
19
|
+
* Worker in the path, so the gate never applies to the public site at all.
|
|
20
|
+
*/
|
|
21
|
+
import { hostInGateList } from "./demoGate";
|
|
22
|
+
import { storefrontModeRequiresSignIn } from "@tot/public-runtime";
|
|
23
|
+
|
|
24
|
+
export type GateReason = "shared-preview-host" | "storefront-unpaid";
|
|
25
|
+
|
|
26
|
+
export interface PublicAccessInput {
|
|
27
|
+
/** The tenant's `storefront.mode`. */
|
|
28
|
+
storefrontMode: string | null | undefined;
|
|
29
|
+
/** `tenantIsDeclaredPublic(tenantId)` — the platform registry, never the KV record. */
|
|
30
|
+
declaredPublic: boolean;
|
|
31
|
+
/** `DEMO_GATE_MODE` parsed (`parseGateMode`). */
|
|
32
|
+
gateMode: "password" | "login";
|
|
33
|
+
/** `DEMO_GATE_HOSTS`. */
|
|
34
|
+
gateHosts: string | null | undefined;
|
|
35
|
+
/** `serving.provider`. */
|
|
36
|
+
provider: "worker-r2-pointer" | "s3-overwrite";
|
|
37
|
+
/** The host the question is asked on (the shared hosted URL's host). */
|
|
38
|
+
requestHost: string;
|
|
39
|
+
/** The tenant's hostnames that are real domains (not the shared preview hosts). */
|
|
40
|
+
domains: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PublicAccess {
|
|
44
|
+
/** The shared hosted URL (`https://storefront.tokenoftrust.store/<tenant>/`). */
|
|
45
|
+
sharedHost: "gated" | "public";
|
|
46
|
+
/** The tenant's own domain(s) as served today by the Worker or CDN. */
|
|
47
|
+
ownDomain: "public" | "gated";
|
|
48
|
+
/** Why `sharedHost` / `ownDomain` is gated; absent when both are public. */
|
|
49
|
+
reason?: GateReason;
|
|
50
|
+
/** `x-tot-gate` value a withheld response carries. */
|
|
51
|
+
gateHeader: "store-not-public";
|
|
52
|
+
/** One sentence a person or agent can relay as the answer to "can the public see my store?". */
|
|
53
|
+
summary: string;
|
|
54
|
+
/** What turns the gate off, or the honest statement that nothing should. */
|
|
55
|
+
remedy: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const GATE_REMEDY: Record<GateReason, string> = {
|
|
59
|
+
"shared-preview-host":
|
|
60
|
+
"Nothing to flip: the shared hosted URL is a sign-in review surface for every store. " +
|
|
61
|
+
"Visitors see the store on its own domain once the domain is attached; use a preview link (tot preview token) to show reviewers the pages. " +
|
|
62
|
+
"See runbook://storefront-publish-go-live (\"Can the public see my store?\").",
|
|
63
|
+
"storefront-unpaid":
|
|
64
|
+
"The store's storefront mode is not \"enabled\" (unpaid). Enable the storefront plan " +
|
|
65
|
+
"(scripts/tenant/set-storefront-mode.mjs --mode=enabled, a Token of Trust staff action); until then only signed-in members and preview links reach it.",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function describePublicAccess(input: PublicAccessInput): PublicAccess {
|
|
69
|
+
const unpaid = storefrontModeRequiresSignIn(input.storefrontMode);
|
|
70
|
+
const sharedGated =
|
|
71
|
+
unpaid || (!input.declaredPublic && input.gateMode === "login" && hostInGateList(input.requestHost, input.gateHosts));
|
|
72
|
+
const ownHostGated =
|
|
73
|
+
unpaid ||
|
|
74
|
+
(!input.declaredPublic &&
|
|
75
|
+
input.provider === "worker-r2-pointer" &&
|
|
76
|
+
input.gateMode === "login" &&
|
|
77
|
+
input.domains.some((d) => hostInGateList(d, input.gateHosts)));
|
|
78
|
+
const reason: GateReason | undefined = unpaid ? "storefront-unpaid" : sharedGated ? "shared-preview-host" : undefined;
|
|
79
|
+
const substrate = input.provider === "s3-overwrite" ? "CloudFront" : "Cloudflare";
|
|
80
|
+
const summary = unpaid
|
|
81
|
+
? "No: the store is unpaid (storefront.mode is not enabled), so every hosted URL shows \"Coming soon\" to signed-out visitors."
|
|
82
|
+
: sharedGated
|
|
83
|
+
? ownHostGated
|
|
84
|
+
? "No: every domain this store has points at a gated host, so signed-out visitors see \"Coming soon\"."
|
|
85
|
+
: `The shared hosted URL always shows "Coming soon" to signed-out visitors (by design); the public sees the store only on its own domain, served by ${substrate}.`
|
|
86
|
+
: "Yes: signed-out visitors can see the store.";
|
|
87
|
+
return {
|
|
88
|
+
sharedHost: sharedGated ? "gated" : "public",
|
|
89
|
+
ownDomain: ownHostGated ? "gated" : "public",
|
|
90
|
+
...(reason ? { reason } : {}),
|
|
91
|
+
gateHeader: "store-not-public",
|
|
92
|
+
summary,
|
|
93
|
+
remedy: reason ? GATE_REMEDY[reason] : "None needed.",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -30,6 +30,25 @@ export interface TenantRouting {
|
|
|
30
30
|
renderPath: string;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Keep preview navigation on its session host whenever that host can route the target. */
|
|
34
|
+
export function previewDashboardHref(
|
|
35
|
+
appDomain: string,
|
|
36
|
+
scope: {
|
|
37
|
+
routedTenant?: Pick<TenantConfig, "appDomain">;
|
|
38
|
+
basePath: string;
|
|
39
|
+
hostTenant: Pick<TenantConfig, "appDomain" | "pathPrefixTenants"> | null;
|
|
40
|
+
sharedOrigin: string;
|
|
41
|
+
},
|
|
42
|
+
): string {
|
|
43
|
+
if (scope.routedTenant?.appDomain === appDomain) {
|
|
44
|
+
return `${scope.basePath}/preview/dashboard`;
|
|
45
|
+
}
|
|
46
|
+
const path = `/${encodeURIComponent(appDomain)}/preview/dashboard`;
|
|
47
|
+
return scope.hostTenant && !scope.hostTenant.pathPrefixTenants
|
|
48
|
+
? new URL(path, scope.sharedOrigin).href
|
|
49
|
+
: path;
|
|
50
|
+
}
|
|
51
|
+
|
|
33
52
|
/**
|
|
34
53
|
* A path segment is treated as a tenant-domain candidate only if it looks like
|
|
35
54
|
* a hostname (has a dot, no "@" or other path-ish junk). Real first segments
|