@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
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* /search —
|
|
3
|
+
* /search — the full results page: a no-JS <form>, the echoed query, a result
|
|
4
|
+
* count, and a product grid. Search runs through the ToTClient (D1 FTS5 when
|
|
5
|
+
* bound, else a Fuse index over the snapshot) over ACTIVE products only (drafts
|
|
6
|
+
* never surface). Results pages are `noindex` (standard practice — thin,
|
|
7
|
+
* parameterized, duplicative).
|
|
4
8
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* result count, and a product grid. Results pages are `noindex` (standard
|
|
10
|
-
* practice — thin, parameterized, duplicative).
|
|
9
|
+
* A static host serves this page without its `?q=`, so the rendered results
|
|
10
|
+
* carry the query they applied (`data-applied-query`) and CatalogQueryView
|
|
11
|
+
* searches the static catalog index in the browser whenever the URL asks for a
|
|
12
|
+
* different one. The header typeahead searches that same index.
|
|
11
13
|
*/
|
|
12
14
|
import Layout from "@/layouts/Layout.astro";
|
|
13
15
|
import Breadcrumbs from "@/components/content/storefront/Breadcrumbs.astro";
|
|
@@ -15,32 +17,21 @@ import { homeCrumbLabel, siteTrail } from "@/lib/content/shared/breadcrumbs";
|
|
|
15
17
|
import ProductCard from "@/components/catalog/storefront/ProductCard.astro";
|
|
16
18
|
import EmptyState from "@/components/content/storefront/EmptyState.astro";
|
|
17
19
|
import Section from "@/components/content/storefront/Section.astro";
|
|
18
|
-
import
|
|
20
|
+
import CatalogQueryView from "@/components/publication/storefront/islands/CatalogQueryView.tsx";
|
|
21
|
+
import { SEARCH_RESULT_LIMIT } from "@/lib/catalog/shared/catalogQueryView";
|
|
22
|
+
import { searchQueryFromUrl } from "@/lib/publication/shared/staticQuery";
|
|
19
23
|
import { breadcrumbLd } from "@/lib/content/shared/jsonld";
|
|
20
24
|
import { withBase } from "@/lib/publication/shared/basePath";
|
|
21
25
|
|
|
26
|
+
// `?q=` never reaches a renderer on a static host: CatalogQueryView applies it there.
|
|
27
|
+
export const STATIC_QUERY = "client-applied";
|
|
28
|
+
|
|
22
29
|
const { tenant, tot, canonicalBase, basePath } = Astro.locals;
|
|
23
|
-
// Commerce-only route
|
|
30
|
+
// Commerce-only route: off for marketing tenants.
|
|
24
31
|
if (!Astro.locals.features.productSearch) return new Response(null, { status: 404 });
|
|
25
|
-
const
|
|
26
|
-
const q = (url.searchParams.get("q") ?? "").trim();
|
|
27
|
-
const isJson = url.searchParams.get("format") === "json";
|
|
28
|
-
|
|
29
|
-
// Search goes through the ToTClient: D1 FTS5 when a D1 binding is wired, else a
|
|
30
|
-
// Fuse index over the bundled snapshot (FixtureToTClient). It returns full
|
|
31
|
-
// products, so the HTML grid renders directly and the typeahead just projects to
|
|
32
|
-
// the compact shape.
|
|
33
|
-
|
|
34
|
-
// --- JSON path: powers the header typeahead island. Must return BEFORE render.
|
|
35
|
-
if (isJson) {
|
|
36
|
-
const docs = (await tot.search(q, 8)).map(toTypeaheadDoc);
|
|
37
|
-
return new Response(JSON.stringify({ products: docs }), {
|
|
38
|
-
headers: { "content-type": "application/json" },
|
|
39
|
-
});
|
|
40
|
-
}
|
|
32
|
+
const q = searchQueryFromUrl(Astro.url);
|
|
41
33
|
|
|
42
|
-
|
|
43
|
-
const results = q ? await tot.search(q, 24) : [];
|
|
34
|
+
const results = q ? await tot.search(q, SEARCH_RESULT_LIMIT) : [];
|
|
44
35
|
const featured = q ? [] : await tot.getFeatured(8);
|
|
45
36
|
|
|
46
37
|
const crumbs = siteTrail(
|
|
@@ -63,7 +54,7 @@ const crumbs = siteTrail(
|
|
|
63
54
|
{/* Search hero + no-JS form (works without the island). */}
|
|
64
55
|
<header class="container-prose pb-2 pt-8">
|
|
65
56
|
<p class="eyebrow eyebrow--tick mb-3">Search</p>
|
|
66
|
-
<h1 class="display-xl">
|
|
57
|
+
<h1 class="display-xl" data-search-heading>
|
|
67
58
|
{q ? <>Results for “{q}”</> : "What are you after?"}
|
|
68
59
|
</h1>
|
|
69
60
|
<form
|
|
@@ -103,13 +94,14 @@ const crumbs = siteTrail(
|
|
|
103
94
|
Search
|
|
104
95
|
</button>
|
|
105
96
|
</form>
|
|
106
|
-
{q && (
|
|
107
|
-
<p class="mt-4 text-sm text-[var(--color-muted)]">
|
|
108
|
-
{results.length} {results.length === 1 ? "result" : "results"}
|
|
109
|
-
</p>
|
|
110
|
-
)}
|
|
111
97
|
</header>
|
|
112
98
|
|
|
99
|
+
<div data-catalog-query-rendered data-applied-query={q}>
|
|
100
|
+
{q && (
|
|
101
|
+
<p class="container-prose mt-4 text-sm text-[var(--color-muted)]">
|
|
102
|
+
{results.length} {results.length === 1 ? "result" : "results"}
|
|
103
|
+
</p>
|
|
104
|
+
)}
|
|
113
105
|
{q ? (
|
|
114
106
|
results.length === 0 ? (
|
|
115
107
|
<EmptyState
|
|
@@ -148,4 +140,12 @@ const crumbs = siteTrail(
|
|
|
148
140
|
)}
|
|
149
141
|
</>
|
|
150
142
|
)}
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<CatalogQueryView
|
|
146
|
+
client:only="preact"
|
|
147
|
+
mode="search"
|
|
148
|
+
trailingSlash={!!tenant.seo?.trailingSlash}
|
|
149
|
+
locale={tenant.locale}
|
|
150
|
+
/>
|
|
151
151
|
</Layout>
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* loc is prefix-correct without special-casing here.
|
|
10
10
|
*/
|
|
11
11
|
import type { APIContext } from "astro";
|
|
12
|
-
import { nonEmptyCollections } from "@/lib/catalog/tot/query.js";
|
|
12
|
+
import { activeMemberCount, nonEmptyCollections } from "@/lib/catalog/tot/query.js";
|
|
13
|
+
import { collectionPageCount, collectionPagePath } from "@/lib/catalog/shared/collectionPaging.js";
|
|
13
14
|
import { getBlogSettings, listBlogPosts } from "@/lib/content/blog/provider.js";
|
|
14
15
|
import { authorSlug, categorySlug } from "@/lib/content/blog/collection.js";
|
|
15
16
|
import { resolveBlogIndexCopy } from "@/lib/content/blog/pagination.js";
|
|
@@ -64,6 +65,11 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
64
65
|
urls.push({ loc: `${base}/collections` });
|
|
65
66
|
for (const c of collections) {
|
|
66
67
|
urls.push({ loc: `${base}/collections/${c.handle}` });
|
|
68
|
+
// Paginated pages (N≥2), same reasoning as /blog/page/N/ below: listing them
|
|
69
|
+
// is what makes every product of a large collection reachable on both
|
|
70
|
+
// serving planes, which publish one document per path.
|
|
71
|
+
const pages = collectionPageCount(activeMemberCount(c, products));
|
|
72
|
+
for (let n = 2; n <= pages; n++) urls.push({ loc: `${base}${collectionPagePath(c.handle, n)}` });
|
|
67
73
|
}
|
|
68
74
|
for (const p of products) {
|
|
69
75
|
const entry: SitemapUrl = { loc: `${base}/products/${p.handle}` };
|
|
@@ -31,5 +31,8 @@ module.exports = defineConfig({
|
|
|
31
31
|
include: ["src/**/*.test.ts", "e2e/a11y/**/*.test.ts"],
|
|
32
32
|
environment: "node",
|
|
33
33
|
cache: false,
|
|
34
|
+
// Folds and CI run many suites at once; a fixture-heavy test that takes 2s alone can pass 5s
|
|
35
|
+
// under that load. A genuinely hung test still fails, only later.
|
|
36
|
+
testTimeout: 30_000,
|
|
34
37
|
},
|
|
35
38
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Mirrors packages/public-runtime/src/declared-tenant-config.mjs (this package ships
|
|
14
14
|
* to npm on its own); test/declared-config-parity.test.mjs pins the two together.
|
|
15
|
-
* Dependency-free: the storefront runner copies this file as-is
|
|
15
|
+
* Dependency-free: the storefront runner copies this file as-is, and the devkit page checks carry
|
|
16
|
+
* a byte-identical copy (docs/devkit-staging/tools/lib/declared-config.mjs, pinned by
|
|
17
|
+
* test/declared-config-devkit-copy.test.mjs) so their gates read a store's declaration the same way.
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
export const TOT_CONFIG_KEYS = [
|
|
19
21
|
"$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
|
|
20
|
-
"displayName", "siteType", "compliance", "capabilities", "features", "embeds", "seo",
|
|
22
|
+
"displayName", "siteType", "compliance", "capabilities", "features", "embeds", "seo", "currency", "shipping",
|
|
21
23
|
];
|
|
22
24
|
// Keys other tooling has written that the platform does NOT read, with the key it does.
|
|
23
25
|
const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
|
|
@@ -26,10 +28,48 @@ export const COMPLIANCE_KEYS = [
|
|
|
26
28
|
"adultSignature", "prop65", "purchaseLimit", "stateEligibility", "exciseTax",
|
|
27
29
|
];
|
|
28
30
|
export const FEATURE_KEYS = [
|
|
29
|
-
"catalog", "productSearch", "savedItems", "quickView", "newsletter", "commerceSitemap", "productJsonLd", "ageGate",
|
|
31
|
+
"catalog", "productSearch", "savedItems", "quickView", "productBadges", "newsletter", "commerceSitemap", "productJsonLd", "ageGate",
|
|
30
32
|
];
|
|
31
33
|
export const SITE_TYPES = ["commerce", "marketing"];
|
|
32
34
|
|
|
35
|
+
/** The currency a store sells in when `.tot/config.json` declares none. */
|
|
36
|
+
export const DEFAULT_CURRENCY = "USD";
|
|
37
|
+
|
|
38
|
+
/** ISO 4217 codes a store may declare as its `currency`. */
|
|
39
|
+
export const CURRENCY_CODES = Object.freeze(
|
|
40
|
+
("AED AFN ALL AMD ANG AOA ARS AUD AWG AZN BAM BBD BDT BGN BHD BIF BMD BND BOB BRL BSD BTN BWP BYN BZD CAD CDF CHF CLP CNY COP " +
|
|
41
|
+
"CRC CUP CVE CZK DJF DKK DOP DZD EGP ERN ETB EUR FJD FKP GBP GEL GHS GIP GMD GNF GTQ GYD HKD HNL HTG HUF IDR ILS INR IQD IRR " +
|
|
42
|
+
"ISK JMD JOD JPY KES KGS KHR KMF KPW KRW KWD KYD KZT LAK LBP LKR LRD LSL LYD MAD MDL MGA MKD MMK MNT MOP MRU MUR MVR MWK MXN " +
|
|
43
|
+
"MYR MZN NAD NGN NIO NOK NPR NZD OMR PAB PEN PGK PHP PKR PLN PYG QAR RON RSD RUB RWF SAR SBD SCR SDG SEK SGD SHP SLE SOS SRD " +
|
|
44
|
+
"SSP STN SVC SYP SZL THB TJS TMT TND TOP TRY TTD TWD TZS UAH UGX USD UYU UZS VES VND VUV WST XAF XCD XCG XOF XPF YER ZAR ZMW ZWG").split(" "),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The ONE place a store's currency is resolved. A declared ISO 4217 code wins; anything
|
|
49
|
+
* else (absent, wrong shape, lowercase, unknown code) is USD. Every surface that prices,
|
|
50
|
+
* signs or formats an amount takes the currency from here, never from a literal. Only the
|
|
51
|
+
* store owner's `.tot/config.json` can change it: a shopper's request never reaches this.
|
|
52
|
+
*
|
|
53
|
+
* @param {unknown} declared
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function resolveStoreCurrency(declared) {
|
|
57
|
+
return typeof declared === "string" && CURRENCY_CODES.includes(declared) ? declared : DEFAULT_CURRENCY;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What is wrong with a declared `currency`, one sentence; `[]` when it is valid.
|
|
62
|
+
*
|
|
63
|
+
* @param {unknown} currency
|
|
64
|
+
* @returns {string[]}
|
|
65
|
+
*/
|
|
66
|
+
export function currencyDeclarationProblems(currency) {
|
|
67
|
+
return typeof currency === "string" && CURRENCY_CODES.includes(currency)
|
|
68
|
+
? []
|
|
69
|
+
: [`\`currency\` is ${JSON.stringify(currency)} — must be an uppercase ISO 4217 code such as "USD" or "CAD"`];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
33
73
|
/** Every `seo` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
|
|
34
74
|
export const SEO_KEYS = Object.freeze(["trailingSlash", "verbatimSeoTitles", "structuredData"]);
|
|
35
75
|
|
|
@@ -156,6 +196,46 @@ export function seoDeclarationProblems(seo) {
|
|
|
156
196
|
return problems;
|
|
157
197
|
}
|
|
158
198
|
|
|
199
|
+
/** Every `shipping` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
|
|
200
|
+
export const SHIPPING_KEYS = Object.freeze(["flatRate", "freeAtOrAbove"]);
|
|
201
|
+
|
|
202
|
+
/** Largest shipping amount a declaration may state, in the cart's currency. */
|
|
203
|
+
export const MAX_SHIPPING_AMOUNT = 100000;
|
|
204
|
+
|
|
205
|
+
/** @param {unknown} value */
|
|
206
|
+
const isShippingAmount = (value) =>
|
|
207
|
+
typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_SHIPPING_AMOUNT
|
|
208
|
+
&& Math.abs(value * 100 - Math.round(value * 100)) < 1e-6;
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* What is wrong with a declared `shipping` value, one sentence per problem; `[]` when it is
|
|
212
|
+
* valid. A policy with any problem is refused whole, so checkout never charges half of one.
|
|
213
|
+
*
|
|
214
|
+
* @param {unknown} shipping
|
|
215
|
+
* @returns {string[]}
|
|
216
|
+
*/
|
|
217
|
+
export function shippingDeclarationProblems(shipping) {
|
|
218
|
+
if (!isPlainObject(shipping)) return ["`shipping` must be an object"];
|
|
219
|
+
const problems = [];
|
|
220
|
+
const source = /** @type {Record<string, unknown>} */ (shipping);
|
|
221
|
+
for (const key of Object.keys(source)) {
|
|
222
|
+
if (!SHIPPING_KEYS.includes(key)) problems.push(`\`shipping.${key}\` is not a shipping key (${SHIPPING_KEYS.join(", ")})`);
|
|
223
|
+
}
|
|
224
|
+
if (!isShippingAmount(source.flatRate)) {
|
|
225
|
+
problems.push(`\`shipping.flatRate\` must be an amount from 0 to ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
|
|
226
|
+
}
|
|
227
|
+
if (source.freeAtOrAbove !== undefined && !(isShippingAmount(source.freeAtOrAbove) && /** @type {number} */ (source.freeAtOrAbove) > 0)) {
|
|
228
|
+
problems.push(`\`shipping.freeAtOrAbove\` must be an amount above 0 and at most ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
|
|
229
|
+
}
|
|
230
|
+
return problems;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The owner-facing sentence for an invalid `shipping` declaration. */
|
|
234
|
+
function shippingProblemMessage(problems) {
|
|
235
|
+
return `\`shipping\` is not a valid shipping policy (${problems.join("; ")}). ` +
|
|
236
|
+
"Checkout charges no shipping until it is fixed.";
|
|
237
|
+
}
|
|
238
|
+
|
|
159
239
|
|
|
160
240
|
/** What each compliance key puts on the page — the thing that is missing when it is. */
|
|
161
241
|
export const COMPLIANCE_NOTICES = {
|
|
@@ -184,8 +264,43 @@ export const MANDATED_NOTICE_KEYS = [
|
|
|
184
264
|
|
|
185
265
|
const ERROR = "error";
|
|
186
266
|
const WARN = "warn";
|
|
267
|
+
/** @type {(v: unknown) => v is Record<string, any>} */
|
|
187
268
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
188
269
|
|
|
270
|
+
/**
|
|
271
|
+
* The platform's compliance obligations — the registry in
|
|
272
|
+
* packages/public-runtime/src/compliance-obligations.ts, mirrored here because this file
|
|
273
|
+
* is dependency-free: each duty's id, its compliance-authority surface, and the declared
|
|
274
|
+
* `compliance.*` keys that switch it on. packages/private-controlplane/tests/
|
|
275
|
+
* compliance-obligation-checks.test.ts pins this list to the registry, key for key.
|
|
276
|
+
*/
|
|
277
|
+
export const COMPLIANCE_OBLIGATIONS = Object.freeze([
|
|
278
|
+
{ id: "age_gate", surface: "regulated", keys: ["minAge"] },
|
|
279
|
+
{ id: "excise", surface: "regulated", keys: ["nicotineWarning", "exciseTax"] },
|
|
280
|
+
{ id: "nicotine_warning", surface: "regulated", keys: ["nicotineWarning"] },
|
|
281
|
+
{ id: "pact_act", surface: "regulated", keys: ["pactAct"] },
|
|
282
|
+
{ id: "adult_signature", surface: "regulated", keys: ["adultSignature"] },
|
|
283
|
+
{ id: "prop65", surface: "commerce", keys: ["prop65"] },
|
|
284
|
+
{ id: "jurisdiction", surface: "commerce", keys: ["stateEligibility", "shippingRestriction"] },
|
|
285
|
+
]);
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The obligations a declared compliance block switches on, as `resolveObligations` returns
|
|
289
|
+
* them (id + surface), in registry order.
|
|
290
|
+
* @param {Record<string, any> | null | undefined} compliance
|
|
291
|
+
* @returns {{ id: string, surface: string }[]}
|
|
292
|
+
*/
|
|
293
|
+
export function declaredObligations(compliance) {
|
|
294
|
+
return COMPLIANCE_OBLIGATIONS
|
|
295
|
+
.filter((o) => o.keys.some((key) => !!compliance?.[key]))
|
|
296
|
+
.map(({ id, surface }) => ({ id, surface }));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** True when the declared block puts the store on the regulated surface. */
|
|
300
|
+
export function isRegulatedCompliance(compliance) {
|
|
301
|
+
return isRegulatedObligationSet(declaredObligations(compliance));
|
|
302
|
+
}
|
|
303
|
+
|
|
189
304
|
/**
|
|
190
305
|
* Compliance coherence — the declared-config half of the hosted preview's `age_gate`,
|
|
191
306
|
* `excise` and `jurisdiction` evidence checks, so `tot validate` fails locally on
|
|
@@ -193,9 +308,42 @@ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
|
|
|
193
308
|
* packages/public-runtime/src/compliance-coherence.mjs (the platform's single source);
|
|
194
309
|
* test/declared-config-parity.test.mjs pins the two together.
|
|
195
310
|
*/
|
|
196
|
-
export
|
|
197
|
-
|
|
198
|
-
|
|
311
|
+
export const COHERENCE_CHECK_IDS = Object.freeze(/** @type {const} */ (["age_gate", "excise", "jurisdiction"]));
|
|
312
|
+
|
|
313
|
+
export const OBLIGATION_EVIDENCE_CHECK = Object.freeze({
|
|
314
|
+
age_gate: "age_gate",
|
|
315
|
+
excise: "excise",
|
|
316
|
+
nicotine_warning: "excise",
|
|
317
|
+
pact_act: "jurisdiction",
|
|
318
|
+
adult_signature: "jurisdiction",
|
|
319
|
+
jurisdiction: "jurisdiction",
|
|
320
|
+
prop65: null,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
/** @param {readonly { id: string, surface: string }[]} obligations */
|
|
324
|
+
export function isRegulatedObligationSet(obligations) {
|
|
325
|
+
return obligations.some((o) => o.surface === "regulated");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* @param {readonly { id: string, surface: string }[]} obligations
|
|
330
|
+
* @returns {("age_gate" | "excise" | "jurisdiction")[]}
|
|
331
|
+
*/
|
|
332
|
+
export function coherenceChecksFor(obligations) {
|
|
333
|
+
const out = new Set();
|
|
334
|
+
if (isRegulatedObligationSet(obligations)) {
|
|
335
|
+
out.add("age_gate");
|
|
336
|
+
out.add("jurisdiction");
|
|
337
|
+
}
|
|
338
|
+
for (const { id } of obligations) {
|
|
339
|
+
if (!Object.hasOwn(OBLIGATION_EVIDENCE_CHECK, id)) {
|
|
340
|
+
for (const check of COHERENCE_CHECK_IDS) out.add(check);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const check = OBLIGATION_EVIDENCE_CHECK[/** @type {keyof typeof OBLIGATION_EVIDENCE_CHECK} */ (id)];
|
|
344
|
+
if (check) out.add(check);
|
|
345
|
+
}
|
|
346
|
+
return COHERENCE_CHECK_IDS.filter((c) => out.has(c));
|
|
199
347
|
}
|
|
200
348
|
|
|
201
349
|
export function declaresJurisdictionNotice(compliance) {
|
|
@@ -203,19 +351,48 @@ export function declaresJurisdictionNotice(compliance) {
|
|
|
203
351
|
return compliance?.pactAct === true || (typeof text === "string" && text.trim().length > 0);
|
|
204
352
|
}
|
|
205
353
|
|
|
354
|
+
/** @param {unknown} declaration */
|
|
355
|
+
function stateEligibilityHasContent(declaration) {
|
|
356
|
+
if (declaration === null || typeof declaration !== "object" || Array.isArray(declaration)) return false;
|
|
357
|
+
const d = /** @type {Record<string, unknown>} */ (declaration);
|
|
358
|
+
const listed = (v) => Array.isArray(v) && v.some((s) => typeof s === "string" && s.trim().length > 0);
|
|
359
|
+
return listed(d.restrictedStates) || listed(d.eligibleStates) || (typeof d.note === "string" && d.note.trim().length > 0);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const REGULATED_KEY_FOR = Object.freeze({
|
|
363
|
+
age_gate: "minAge",
|
|
364
|
+
excise: "exciseTax",
|
|
365
|
+
nicotine_warning: "nicotineWarning",
|
|
366
|
+
pact_act: "pactAct",
|
|
367
|
+
adult_signature: "adultSignature",
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
function regulatedBy(compliance, obligations) {
|
|
371
|
+
const keys = new Set();
|
|
372
|
+
for (const o of obligations) {
|
|
373
|
+
if (o.surface !== "regulated") continue;
|
|
374
|
+
const key = REGULATED_KEY_FOR[/** @type {keyof typeof REGULATED_KEY_FOR} */ (o.id)];
|
|
375
|
+
if (o.id === "excise" && !compliance?.exciseTax) continue;
|
|
376
|
+
if (key) keys.add(key);
|
|
377
|
+
}
|
|
378
|
+
return [...keys].map((k) => `\`compliance.${k}\``).join(", ") || "a regulated obligation";
|
|
379
|
+
}
|
|
380
|
+
|
|
206
381
|
/**
|
|
207
382
|
* @param {Record<string, any> | null | undefined} compliance
|
|
383
|
+
* @param {readonly { id: string, surface: string }[]} [obligations] defaults to what the block declares
|
|
208
384
|
* @returns {Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string, detail: string, hint: string }>>}
|
|
209
385
|
*/
|
|
210
|
-
export function complianceCoherenceFailures(compliance) {
|
|
386
|
+
export function complianceCoherenceFailures(compliance, obligations = declaredObligations(compliance)) {
|
|
211
387
|
/** @type {Partial<Record<"age_gate" | "excise" | "jurisdiction", { field: string, detail: string, hint: string }>>} */
|
|
212
388
|
const out = {};
|
|
213
|
-
|
|
389
|
+
const checks = coherenceChecksFor(obligations);
|
|
390
|
+
const regulated = isRegulatedObligationSet(obligations);
|
|
214
391
|
const minAge = typeof compliance?.minAge === "number" ? compliance.minAge : null;
|
|
215
|
-
if (minAge == null || minAge < 18) {
|
|
392
|
+
if (checks.includes("age_gate") && regulated && (minAge == null || minAge < 18)) {
|
|
216
393
|
out.age_gate = {
|
|
217
394
|
field: "compliance.minAge",
|
|
218
|
-
detail:
|
|
395
|
+
detail: `Age-restricted products (${regulatedBy(compliance, obligations)}) require \`compliance.minAge\` (18 or higher) — none is set.`,
|
|
219
396
|
hint: "Set `compliance.minAge` (18+) in .tot/config.json.",
|
|
220
397
|
};
|
|
221
398
|
}
|
|
@@ -226,16 +403,29 @@ export function complianceCoherenceFailures(compliance) {
|
|
|
226
403
|
hint: "Set `compliance.minAge` to 21 in .tot/config.json (nicotine requires 21+, not just 18+).",
|
|
227
404
|
};
|
|
228
405
|
}
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
406
|
+
if (checks.includes("jurisdiction")) {
|
|
407
|
+
if (regulated && !declaresJurisdictionNotice(compliance)) {
|
|
408
|
+
out.jurisdiction = {
|
|
409
|
+
field: "compliance.pactAct",
|
|
410
|
+
detail:
|
|
411
|
+
`Regulated products (${regulatedBy(compliance, obligations)}) require a shipping/jurisdiction notice: ` +
|
|
412
|
+
"set `compliance.pactAct: true` or `compliance.shippingRestriction` — neither is set.",
|
|
413
|
+
hint:
|
|
414
|
+
"Set `compliance.pactAct: true` for the platform's PACT Act notice, or " +
|
|
415
|
+
"`compliance.shippingRestriction` to your own restriction text, in .tot/config.json.",
|
|
416
|
+
};
|
|
417
|
+
} else if (!regulated && !declaresJurisdictionNotice(compliance)
|
|
418
|
+
&& !stateEligibilityHasContent(compliance?.stateEligibility)) {
|
|
419
|
+
const field = compliance?.stateEligibility ? "compliance.stateEligibility" : "compliance.shippingRestriction";
|
|
420
|
+
out.jurisdiction = {
|
|
421
|
+
field,
|
|
422
|
+
detail:
|
|
423
|
+
`\`${field}\` is declared but gives its shipping/jurisdiction notice nothing to say, so no notice renders.`,
|
|
424
|
+
hint:
|
|
425
|
+
"Set `compliance.shippingRestriction` to your restriction text, or give `compliance.stateEligibility` " +
|
|
426
|
+
"`restrictedStates`, `eligibleStates` or a `note`, in .tot/config.json.",
|
|
427
|
+
};
|
|
428
|
+
}
|
|
239
429
|
}
|
|
240
430
|
return out;
|
|
241
431
|
}
|
|
@@ -251,27 +441,29 @@ export function complianceCoherenceFailures(compliance) {
|
|
|
251
441
|
*/
|
|
252
442
|
export function complianceFindings(config, file = ".tot/config.json") {
|
|
253
443
|
const compliance = isObject(config?.compliance) ? config.compliance : null;
|
|
444
|
+
const obligations = declaredObligations(compliance);
|
|
445
|
+
const owed = new Set(obligations.map((o) => o.id));
|
|
254
446
|
const out = [];
|
|
255
|
-
for (const [check, failure] of Object.entries(complianceCoherenceFailures(compliance))) {
|
|
447
|
+
for (const [check, failure] of Object.entries(complianceCoherenceFailures(compliance, obligations))) {
|
|
256
448
|
out.push(mk(ERROR, `compliance-${check.replace("_", "-")}`, `${file} compliance`,
|
|
257
449
|
`${failure.detail} The hosted preview fails its \`${check}\` check on this.`, failure.hint));
|
|
258
450
|
}
|
|
259
451
|
// The hosted reconcile refuses a declaration whose `capabilities` switch off the
|
|
260
452
|
// compliance floor (packages/public-runtime/src/capabilities.ts capabilityFloorViolations).
|
|
261
453
|
const capabilities = isObject(config?.capabilities) ? config.capabilities : {};
|
|
262
|
-
if (
|
|
454
|
+
if (owed.has("age_gate") && capabilities.ageVerification?.enabled === false) {
|
|
263
455
|
out.push(mk(ERROR, "capability-compliance-floor", `${file} capabilities`,
|
|
264
456
|
"`capabilities.ageVerification` cannot be disabled for a store with `compliance.minAge` — the hosted preview refuses this revision.",
|
|
265
457
|
"Remove `capabilities.ageVerification.enabled: false` from .tot/config.json."));
|
|
266
458
|
}
|
|
267
|
-
if (
|
|
459
|
+
if (owed.has("excise") && capabilities.exciseTax?.enabled === false) {
|
|
268
460
|
out.push(mk(ERROR, "capability-compliance-floor", `${file} capabilities`,
|
|
269
|
-
"`capabilities.exciseTax` cannot be disabled for a store with `compliance.nicotineWarning` — the hosted preview refuses this revision.",
|
|
461
|
+
"`capabilities.exciseTax` cannot be disabled for a store with `compliance.nicotineWarning` or `compliance.exciseTax` — the hosted preview refuses this revision.",
|
|
270
462
|
"Remove `capabilities.exciseTax.enabled: false` from .tot/config.json."));
|
|
271
463
|
}
|
|
272
|
-
if (
|
|
464
|
+
if (isRegulatedObligationSet(obligations) && !compliance?.ruleProfile) {
|
|
273
465
|
out.push(mk(WARN, "compliance-rule-profile", `${file} compliance`,
|
|
274
|
-
|
|
466
|
+
`This store is regulated (${regulatedBy(compliance, obligations)}) but declares no ` +
|
|
275
467
|
"`compliance.ruleProfile`, so no checkout rules resolve: the preview renders, but checkout blocks " +
|
|
276
468
|
"and going live refuses.",
|
|
277
469
|
'Declare `compliance.ruleProfile` (e.g. "vape-nicotine-us") in .tot/config.json.'));
|
|
@@ -416,6 +608,8 @@ export function declarationFindings(current, previous) {
|
|
|
416
608
|
if (warning.enabled && warning.problems.length > 0) {
|
|
417
609
|
out.push(`CONFIG: ${nicotineWarningProblemMessage(warning.problems)}`);
|
|
418
610
|
}
|
|
611
|
+
const shipping = current.shipping === undefined ? [] : shippingDeclarationProblems(current.shipping);
|
|
612
|
+
if (shipping.length > 0) out.push(`CONFIG: ${shippingProblemMessage(shipping)}`);
|
|
419
613
|
}
|
|
420
614
|
for (const { key, notice } of droppedMandatedNotices(previous, current)) {
|
|
421
615
|
out.push(
|
|
@@ -470,7 +664,13 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
470
664
|
}
|
|
471
665
|
}
|
|
472
666
|
out.push(...complianceFindings(config, file));
|
|
473
|
-
const
|
|
667
|
+
const declaration = isObject(config.compliance) ? config.compliance.nicotineWarning : undefined;
|
|
668
|
+
for (const problem of nicotineWarningCheckWidths(declaration).problems) {
|
|
669
|
+
out.push(mk(ERROR, "config-nicotine-warning-check-widths", `${file} compliance`,
|
|
670
|
+
`\`compliance.nicotineWarning\`: ${problem}. The page browser gate cannot check the warning at the widths you meant.`,
|
|
671
|
+
"list the widths where your layout changes (e.g. where your header becomes fixed), or remove `checkWidthsPx`"));
|
|
672
|
+
}
|
|
673
|
+
const warning = resolveNicotineWarning(declaration);
|
|
474
674
|
if (warning.enabled && warning.problems.length > 0) {
|
|
475
675
|
out.push(mk(ERROR, "config-nicotine-warning", `${file} compliance`, nicotineWarningProblemMessage(warning.problems),
|
|
476
676
|
'use `true`, `{ "preset": "dark" }` or `{ "preset": "light" }`, or fix the style values named above'));
|
|
@@ -488,11 +688,23 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
488
688
|
&& !(Array.isArray(config.embeds) && config.embeds.every((/** @type {unknown} */ s) => typeof s === "string"))) {
|
|
489
689
|
out.push(mk(ERROR, "config-embeds", file, '`embeds` must be an array of embed slugs, e.g. ["youtube"]'));
|
|
490
690
|
}
|
|
691
|
+
if (config.currency !== undefined) {
|
|
692
|
+
for (const problem of currencyDeclarationProblems(config.currency)) {
|
|
693
|
+
out.push(mk(ERROR, "config-currency", file, `${problem} — the store sells in ${DEFAULT_CURRENCY} until it is fixed`));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
491
696
|
if (config.seo !== undefined) {
|
|
492
697
|
for (const problem of seoDeclarationProblems(config.seo)) {
|
|
493
698
|
out.push(mk(ERROR, "config-seo", `${file} seo`, `${problem} — the whole \`seo\` declaration is ignored until it is fixed`));
|
|
494
699
|
}
|
|
495
700
|
}
|
|
701
|
+
if (config.shipping !== undefined) {
|
|
702
|
+
const problems = shippingDeclarationProblems(config.shipping);
|
|
703
|
+
if (problems.length > 0) {
|
|
704
|
+
out.push(mk(ERROR, "config-shipping", `${file} shipping`, shippingProblemMessage(problems),
|
|
705
|
+
'declare e.g. `"shipping": { "flatRate": 5.99, "freeAtOrAbove": 100 }`, or remove `shipping` to charge none'));
|
|
706
|
+
}
|
|
707
|
+
}
|
|
496
708
|
return out;
|
|
497
709
|
}
|
|
498
710
|
|
|
@@ -504,6 +716,7 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
504
716
|
// "nicotineWarning": true → the "dark" preset
|
|
505
717
|
// "nicotineWarning": { "preset": "light" } → a validated preset
|
|
506
718
|
// "nicotineWarning": { "preset": "light", "style": {...} } → a custom presentation
|
|
719
|
+
// "nicotineWarning": { "checkWidthsPx": [800] } → also checked seen at 800px wide
|
|
507
720
|
//
|
|
508
721
|
// Every presentation — preset or custom — must clear CONSPICUITY_FLOOR. A preset needs no
|
|
509
722
|
// review. A custom presentation is a deviation the owner approves at go-live:
|
|
@@ -562,6 +775,9 @@ export const NICOTINE_WARNING_PRESETS = Object.freeze({
|
|
|
562
775
|
* }} NicotineWarningPresentation
|
|
563
776
|
*/
|
|
564
777
|
|
|
778
|
+
/** Every key a `compliance.nicotineWarning` object may declare. */
|
|
779
|
+
export const NICOTINE_WARNING_KEYS = Object.freeze(["preset", "style", "checkWidthsPx"]);
|
|
780
|
+
|
|
565
781
|
export const NICOTINE_WARNING_STYLE_KEYS = Object.freeze([
|
|
566
782
|
"background", "text", "border", "textTransform", "fontWeight", "fontSizePx",
|
|
567
783
|
]);
|
|
@@ -669,8 +885,9 @@ export function resolveNicotineWarning(declaration) {
|
|
|
669
885
|
if (!isObject(declaration)) {
|
|
670
886
|
return resolved({ problems: ["must be `true` or an object like { \"preset\": \"light\" }"] });
|
|
671
887
|
}
|
|
672
|
-
const { preset: presetName = DEFAULT_NICOTINE_WARNING_PRESET, style, ...rest } =
|
|
673
|
-
|
|
888
|
+
const { preset: presetName = DEFAULT_NICOTINE_WARNING_PRESET, style, checkWidthsPx: _checkWidths, ...rest } =
|
|
889
|
+
/** @type {Record<string, unknown>} */ (declaration);
|
|
890
|
+
const problems = Object.keys(rest).map((k) => `\`${k}\` is not a nicotineWarning key (allowed: ${NICOTINE_WARNING_KEYS.join(", ")})`);
|
|
674
891
|
const base = typeof presetName === "string" ? NICOTINE_WARNING_PRESETS[presetName] : undefined;
|
|
675
892
|
if (!base) {
|
|
676
893
|
problems.push(`\`preset\` must be one of ${Object.keys(NICOTINE_WARNING_PRESETS).map((p) => `"${p}"`).join(", ")}`);
|
|
@@ -695,6 +912,35 @@ export function resolveNicotineWarning(declaration) {
|
|
|
695
912
|
return resolved({ presentation, preset: matching ?? null, custom: !matching });
|
|
696
913
|
}
|
|
697
914
|
|
|
915
|
+
/** The viewport widths a store may add to the page browser gate's nicotine-warning check. */
|
|
916
|
+
export const NICOTINE_WARNING_CHECK_WIDTH_BOUNDS = Object.freeze({ min: 320, max: 3840 });
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* The extra viewport widths at which the page browser gate checks the nicotine warning is seen.
|
|
920
|
+
*
|
|
921
|
+
* The gate always checks its own phone, tablet, laptop and desktop widths; `checkWidthsPx` adds
|
|
922
|
+
* the store's own breakpoints (e.g. the width where its header becomes fixed) and never removes
|
|
923
|
+
* a default. A store that does not declare the warning has no widths; `true` and an object
|
|
924
|
+
* without `checkWidthsPx` add none.
|
|
925
|
+
*
|
|
926
|
+
* "nicotineWarning": { "preset": "dark", "checkWidthsPx": [800, 1180] }
|
|
927
|
+
*
|
|
928
|
+
* `problems` lists why a declared value is unusable, in which case no extra width is used.
|
|
929
|
+
* @param {unknown} declaration the store's `compliance.nicotineWarning`
|
|
930
|
+
* @returns {{ widths: number[], problems: string[] }}
|
|
931
|
+
*/
|
|
932
|
+
export function nicotineWarningCheckWidths(declaration) {
|
|
933
|
+
if (!isObject(declaration)) return { widths: [], problems: [] };
|
|
934
|
+
const value = /** @type {Record<string, unknown>} */ (declaration).checkWidthsPx;
|
|
935
|
+
if (value === undefined) return { widths: [], problems: [] };
|
|
936
|
+
const { min, max } = NICOTINE_WARNING_CHECK_WIDTH_BOUNDS;
|
|
937
|
+
const usable = (/** @type {unknown} */ w) => Number.isInteger(w) && /** @type {number} */ (w) >= min && /** @type {number} */ (w) <= max;
|
|
938
|
+
if (!Array.isArray(value) || value.length === 0 || !value.every(usable)) {
|
|
939
|
+
return { widths: [], problems: [`\`checkWidthsPx\` must be a non-empty array of whole pixel widths from ${min} to ${max}, e.g. [800]`] };
|
|
940
|
+
}
|
|
941
|
+
return { widths: [...new Set(/** @type {number[]} */ (value))].sort((a, b) => a - b), problems: [] };
|
|
942
|
+
}
|
|
943
|
+
|
|
698
944
|
/**
|
|
699
945
|
* The exact, human-readable statement of a custom presentation — what the owner approves.
|
|
700
946
|
* Every resolved value is in it, so two presentations that differ in any value describe
|
|
@@ -712,6 +958,16 @@ export function describeNicotineWarningPresentation(p) {
|
|
|
712
958
|
* of every page; "block" is the reinforced box on the product page. Every interpolated value
|
|
713
959
|
* is a validated hex colour, enum or number. Styling is inline (no utility classes), so the
|
|
714
960
|
* markup renders the same wherever it is served, whatever stylesheet the page carries.
|
|
961
|
+
*
|
|
962
|
+
* Every presentation and visibility declaration is inline `!important`, which no tenant
|
|
963
|
+
* stylesheet rule can override: tenant CSS cannot hide, move, clip, fade or restyle the warning,
|
|
964
|
+
* and the text element re-asserts the warning's own presentation so a tenant `p { … }` rule does
|
|
965
|
+
* not reach it. What inline styles cannot stop is another element painting over the warning (a
|
|
966
|
+
* fixed or sticky header pinned to the top of the viewport). Raising the warning above page
|
|
967
|
+
* chrome would paint it over the tenant's own open menus and drawers, so occlusion is measured
|
|
968
|
+
* instead: the page browser gate (`validate-page-browser.mjs`, `nicotineWarning`) fails a page
|
|
969
|
+
* whose warning is covered at a phone, tablet or desktop width, or at a width the store declares
|
|
970
|
+
* in `checkWidthsPx`, and names the covering element.
|
|
715
971
|
* @param {unknown} declaration the tenant's `compliance.nicotineWarning`
|
|
716
972
|
* @param {"bar" | "block"} variant
|
|
717
973
|
*/
|
|
@@ -719,11 +975,40 @@ export function nicotineWarningHtml(declaration, variant) {
|
|
|
719
975
|
const { presentation, preset } = resolveNicotineWarning(declaration);
|
|
720
976
|
const attrs = `style="${nicotineWarningStyle(presentation, variant)}" role="note" aria-label="Health warning" ` +
|
|
721
977
|
`data-compliance="nicotine-warning" data-nicotine-warning-preset="${preset ?? "custom"}"`;
|
|
978
|
+
const text = NICOTINE_WARNING_TEXT_LOCK;
|
|
722
979
|
return variant === "bar"
|
|
723
|
-
? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block:0.5rem;text-align:center">${NICOTINE_WARNING_TEXT}</p></div>`
|
|
724
|
-
: `<div ${attrs}><p style="margin:0">${NICOTINE_WARNING_TEXT}</p></div>`;
|
|
980
|
+
? `<div ${attrs}><p class="container-prose" style="margin-block:0;padding-block:0.5rem;text-align:center;${text}">${NICOTINE_WARNING_TEXT}</p></div>`
|
|
981
|
+
: `<div ${attrs}><p style="margin:0;${text}">${NICOTINE_WARNING_TEXT}</p></div>`;
|
|
725
982
|
}
|
|
726
983
|
|
|
984
|
+
/** Declarations that keep the warning on screen, at full opacity, in its own place. */
|
|
985
|
+
const NICOTINE_WARNING_VISIBILITY_LOCK = [
|
|
986
|
+
"display:block",
|
|
987
|
+
"visibility:visible",
|
|
988
|
+
"opacity:1",
|
|
989
|
+
"position:relative",
|
|
990
|
+
"inset:auto",
|
|
991
|
+
"transform:none",
|
|
992
|
+
"clip-path:none",
|
|
993
|
+
"filter:none",
|
|
994
|
+
"height:auto",
|
|
995
|
+
"max-height:none",
|
|
996
|
+
"overflow:visible",
|
|
997
|
+
];
|
|
998
|
+
|
|
999
|
+
/** The text element inherits the warning's presentation, whatever a tenant `p` rule says. */
|
|
1000
|
+
const NICOTINE_WARNING_TEXT_LOCK = [
|
|
1001
|
+
"display:block",
|
|
1002
|
+
"visibility:visible",
|
|
1003
|
+
"opacity:1",
|
|
1004
|
+
"color:inherit",
|
|
1005
|
+
"font-size:inherit",
|
|
1006
|
+
"font-weight:inherit",
|
|
1007
|
+
"text-transform:inherit",
|
|
1008
|
+
"letter-spacing:inherit",
|
|
1009
|
+
"line-height:inherit",
|
|
1010
|
+
].map((d) => `${d}!important`).join(";");
|
|
1011
|
+
|
|
727
1012
|
/**
|
|
728
1013
|
* Inline CSS for a presentation. `variant` "block" (the product page) never renders smaller
|
|
729
1014
|
* than 14px.
|
|
@@ -744,5 +1029,6 @@ export function nicotineWarningStyle(p, variant) {
|
|
|
744
1029
|
`font-weight:${p.fontWeight}`,
|
|
745
1030
|
`font-size:${size}px`,
|
|
746
1031
|
"line-height:1.4",
|
|
747
|
-
|
|
1032
|
+
...NICOTINE_WARNING_VISIBILITY_LOCK,
|
|
1033
|
+
].map((d) => `${d}!important`).join(";");
|
|
748
1034
|
}
|