@tokenoftrust/storefront-runner 2.0.40 → 2.0.42
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/integrations/lifecycle-host-asset.mjs +20 -6
- package/apps/storefront/package.json +1 -0
- package/apps/storefront/platform-reconcile-tenants.json +3 -0
- package/apps/storefront/public/favicon.ico +0 -0
- package/apps/storefront/public/platform/google-measurement/v2/entry.mjs +1 -1
- package/apps/storefront/public/shared/commerce-chrome.css +147 -16
- package/apps/storefront/src/components/AsyncStylesheet.astro +26 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +8 -1
- package/apps/storefront/src/components/Img.astro +6 -5
- package/apps/storefront/src/components/ProductCard.astro +6 -4
- package/apps/storefront/src/components/Seo.astro +2 -1
- package/apps/storefront/src/components/chrome/SiteFooter.astro +45 -11
- package/apps/storefront/src/components/chrome/SiteHeader.astro +83 -13
- package/apps/storefront/src/components/commerce/RatingStars.astro +1 -1
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +2 -2
- package/apps/storefront/src/components/content/PromoTiles.astro +107 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +30 -14
- package/apps/storefront/src/components/home/Hero.astro +156 -4
- package/apps/storefront/src/components/islands/AgeGate.tsx +135 -26
- package/apps/storefront/src/components/islands/ImageGallery.tsx +6 -9
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +194 -46
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +10 -4
- package/apps/storefront/src/components/marketing/Faq.astro +9 -2
- package/apps/storefront/src/config/devTenantSeed.ts +1 -1
- package/apps/storefront/src/config/kvTenantResolver.ts +7 -1
- package/apps/storefront/src/config/publicationSpaceConfig.ts +12 -1
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +5 -0
- package/apps/storefront/src/config/tenants.ts +22 -0
- package/apps/storefront/src/layouts/Layout.astro +69 -44
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +24 -8
- package/apps/storefront/src/lib/activity/recordActivity.ts +15 -0
- package/apps/storefront/src/lib/asyncCss.ts +23 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +22 -11
- package/apps/storefront/src/lib/basePath.ts +70 -9
- package/apps/storefront/src/lib/chrome/managedCart.ts +50 -0
- package/apps/storefront/src/lib/chrome/model.ts +4 -0
- package/apps/storefront/src/lib/chrome/newsletterAgeAttestation.ts +9 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +38 -3
- package/apps/storefront/src/lib/cspStaticHashes.ts +4 -1
- package/apps/storefront/src/lib/diag/capture.ts +161 -0
- package/apps/storefront/src/lib/diag/consoleLine.ts +76 -0
- package/apps/storefront/src/lib/diag/debugConsole.ts +118 -0
- package/apps/storefront/src/lib/diag/debugMode.ts +163 -0
- package/apps/storefront/src/lib/diag/debugWindow.ts +111 -0
- package/apps/storefront/src/lib/diag/reference.ts +148 -0
- package/apps/storefront/src/lib/diag/referenceStore.ts +113 -0
- package/apps/storefront/src/lib/diag/report.ts +109 -0
- package/apps/storefront/src/lib/diag/session.ts +93 -0
- package/apps/storefront/src/lib/featuredProductsPresentation.ts +5 -0
- package/apps/storefront/src/lib/heroSlideshow.ts +41 -0
- package/apps/storefront/src/lib/homeVisualParity.ts +107 -0
- package/apps/storefront/src/lib/imageAttrs.ts +9 -6
- package/apps/storefront/src/lib/integration/nonceStamp.ts +23 -1
- package/apps/storefront/src/lib/integration/renderIntegrationState.ts +36 -0
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +17 -12
- package/apps/storefront/src/lib/jsonScript.ts +13 -0
- package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +31 -0
- package/apps/storefront/src/lib/previewReload.ts +53 -9
- package/apps/storefront/src/lib/rawChrome.ts +148 -19
- package/apps/storefront/src/lib/rawMarketingHtml.ts +120 -7
- package/apps/storefront/src/lib/storyblok/content-model.ts +46 -3
- package/apps/storefront/src/lib/styleGuideThemes.ts +107 -0
- package/apps/storefront/src/lib/tenantRouting.ts +15 -4
- package/apps/storefront/src/lib/tot/syncState.ts +21 -0
- package/apps/storefront/src/lib/tracking/publishSafetyNet.ts +29 -3
- package/apps/storefront/src/middleware/controlPlane.ts +9 -5
- package/apps/storefront/src/middleware/index.ts +13 -1
- package/apps/storefront/src/middleware/renderChain.ts +54 -11
- package/apps/storefront/src/middleware/routeBranch.ts +58 -12
- package/apps/storefront/src/middleware/staticBundle.ts +37 -3
- package/apps/storefront/src/pages/[...slug].astro +6 -2
- package/apps/storefront/src/pages/api/request-access.ts +5 -4
- package/apps/storefront/src/pages/blog/[slug].astro +11 -1
- package/apps/storefront/src/pages/cart.ts +23 -0
- package/apps/storefront/src/pages/index.astro +64 -85
- package/apps/storefront/src/pages/products/[handle].astro +8 -6
- package/apps/storefront/src/pages/sitemap.xml.ts +8 -2
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +14 -25
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +10 -17
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +9 -16
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +9 -15
- package/apps/storefront/src/pages/the-build/[slug].astro +7 -1
- package/apps/storefront/src/styles/fonts.css +7 -0
- package/apps/storefront/src/styles/global.css +397 -3
- package/package.json +4 -1
- package/packages/cli/src/visual-parity.mjs +321 -0
- package/packages/public-runtime/package.json +2 -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/chrome.ts +44 -7
- package/packages/public-runtime/src/csp.ts +12 -1
- package/packages/public-runtime/src/extension-contract-values.d.mts +19 -0
- package/packages/public-runtime/src/extension-contract-values.mjs +62 -0
- package/packages/public-runtime/src/extension-contract.ts +20 -6
- package/packages/public-runtime/src/index.ts +8 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +10 -7
- package/packages/public-runtime/src/integration/consent-config.ts +9 -0
- package/packages/public-runtime/src/integration/contact-sales-action-source.ts +56 -10
- package/packages/public-runtime/src/integration/csp-compiler.ts +6 -3
- package/packages/public-runtime/src/integration/governed-event-properties.ts +62 -0
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +34 -6
- package/packages/public-runtime/src/integration/lifecycle-host-asset.d.mts +2 -0
- package/packages/public-runtime/src/integration/lifecycle-host-asset.mjs +17 -3
- package/packages/public-runtime/src/integration/native-amplitude.ts +12 -3
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +2 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +35 -1
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +45 -12
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +28 -7
- package/packages/public-runtime/src/integration/native-ga4.ts +18 -2
- package/packages/public-runtime/src/integration/platform-adapters.ts +14 -3
- package/packages/public-runtime/src/integration/render-phases.ts +28 -2
- package/packages/public-runtime/src/publication-redirects.d.mts +45 -0
- package/packages/public-runtime/src/publication-redirects.mjs +379 -0
- package/packages/public-runtime/src/publication-space.ts +24 -3
- package/packages/public-runtime/src/static-bundle.ts +145 -26
- package/packages/public-runtime/src/tenant-embed-defaults.d.mts +3 -0
- package/packages/public-runtime/src/tenant-embed-defaults.mjs +40 -0
- package/packages/public-runtime/src/tenant.ts +59 -1
- package/pnpm-lock.runner.yaml +12 -0
- package/pnpm-workspace.yaml +4 -0
- package/scripts/dev/git-status.mjs +1 -1
- package/scripts/dev/publish.mjs +6 -0
- package/scripts/lib/tenant-asset-copy.mjs +4 -2
- package/scripts/tenant/materialize-tenant-content.mjs +1 -0
- package/scripts/tenant/validate.mjs +171 -8
- package/apps/storefront/src/lib/cfImage.ts +0 -55
- package/apps/storefront/src/workers/pruneRollupWorker.ts +0 -120
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/js/storefront.js +0 -129
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +0 -235
- package/tenants/home/theme.json +0 -6
|
@@ -30,6 +30,15 @@ import { dirname, resolve, join, relative } from "node:path";
|
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
31
|
import { KNOWN_EMBEDS, lintTenantEmbeds } from "./lint-embeds.mjs";
|
|
32
32
|
import { listTenantDirIds, tenantDirSegments } from "../lib/tenant-dirs.mjs";
|
|
33
|
+
import {
|
|
34
|
+
BLOCK_PALETTE_SUPPORTED_CONTRACTS,
|
|
35
|
+
FEATURED_PRODUCT_PRESENTATIONS,
|
|
36
|
+
PROMO_TILE_PRESENTATIONS,
|
|
37
|
+
isSafeRehostedAssetPath,
|
|
38
|
+
isSafeRehostedAssetPathForTenant,
|
|
39
|
+
isSafeStorefrontHref,
|
|
40
|
+
} from "../../packages/public-runtime/src/extension-contract-values.mjs";
|
|
41
|
+
import { validateVisualParityClosure } from "../../packages/cli/src/visual-parity.mjs";
|
|
33
42
|
|
|
34
43
|
/** Catalogued embed slugs a tenant may opt into (mirror of EMBED_PROVIDERS). */
|
|
35
44
|
const KNOWN_EMBED_SLUGS = new Set(KNOWN_EMBEDS.map((p) => p.slug));
|
|
@@ -43,6 +52,8 @@ const NUL = String.fromCharCode(0);
|
|
|
43
52
|
// --- findings model ----------------------------------------------------------
|
|
44
53
|
const ERROR = "error";
|
|
45
54
|
const WARN = "warn";
|
|
55
|
+
const mergeFindings = (...groups) => [...new Map(groups.flat().map((item) => [JSON.stringify(item), item])).values()]
|
|
56
|
+
.sort((a, b) => `${a.file}\0${a.rule}\0${a.message}`.localeCompare(`${b.file}\0${b.rule}\0${b.message}`));
|
|
46
57
|
/** @typedef {{level:string, rule:string, file:string, message:string, fix?:string}} Finding */
|
|
47
58
|
|
|
48
59
|
function mk(level, rule, file, message, fix) {
|
|
@@ -166,8 +177,8 @@ function validateRawHtmlBody(html) {
|
|
|
166
177
|
// `@tot/public-runtime` package at runtime — this is a copy. Parity test:
|
|
167
178
|
// packages/public-runtime/tests/chrome-config-shape-parity.test.ts. Kept in
|
|
168
179
|
// step with packages/cli/src/validate.mjs's identical copy.
|
|
169
|
-
const CHROME_HEADER_VARIANTS = new Set(["primary", "minimal"]);
|
|
170
|
-
const CHROME_FOOTER_VARIANTS = new Set(["default"]);
|
|
180
|
+
const CHROME_HEADER_VARIANTS = new Set(["primary", "minimal", "compact-centered"]);
|
|
181
|
+
const CHROME_FOOTER_VARIANTS = new Set(["default", "light-collapsible"]);
|
|
171
182
|
/**
|
|
172
183
|
* The declarative-interaction-tracking rule, mirrored: every actionable item —
|
|
173
184
|
* an object carrying both `id` and `href` — declares EXACTLY ONE of a non-empty
|
|
@@ -302,10 +313,11 @@ function validateScriptsDoc(doc, file, config) {
|
|
|
302
313
|
return out;
|
|
303
314
|
}
|
|
304
315
|
|
|
305
|
-
const SUPPORTED_BLOCK_CONTRACTS = new Set(
|
|
316
|
+
const SUPPORTED_BLOCK_CONTRACTS = new Set(BLOCK_PALETTE_SUPPORTED_CONTRACTS);
|
|
306
317
|
const KNOWN_BLOCKS = new Set([
|
|
307
318
|
"hero",
|
|
308
319
|
"promo_tiles",
|
|
320
|
+
"trust_strip",
|
|
309
321
|
"featured_collections",
|
|
310
322
|
"featured_products",
|
|
311
323
|
"membership_band",
|
|
@@ -325,6 +337,7 @@ const KNOWN_BLOCKS = new Set([
|
|
|
325
337
|
const REQUIRED_BLOCK_PROPS = {
|
|
326
338
|
hero: ["headline"],
|
|
327
339
|
promo_tiles: ["tiles"],
|
|
340
|
+
trust_strip: ["items"],
|
|
328
341
|
featured_collections: ["handles"],
|
|
329
342
|
membership_band: ["tiers"],
|
|
330
343
|
editorial: ["title"],
|
|
@@ -352,6 +365,7 @@ const PRODUCT_SOURCE_KEYS = new Set([
|
|
|
352
365
|
"recentlyViewed",
|
|
353
366
|
]);
|
|
354
367
|
const PRODUCT_SOURCE_SHORTCUTS = new Set(["featured", "newest", "bestSelling", "onSale"]);
|
|
368
|
+
const PROMO_TILE_PRESENTATION_SET = new Set(PROMO_TILE_PRESENTATIONS);
|
|
355
369
|
|
|
356
370
|
function validateProductSource(value, file, at) {
|
|
357
371
|
const out = [];
|
|
@@ -387,7 +401,124 @@ function validateProductSource(value, file, at) {
|
|
|
387
401
|
return out;
|
|
388
402
|
}
|
|
389
403
|
|
|
390
|
-
function
|
|
404
|
+
function validateVisualAsset(value, file, at, out, tenantId) {
|
|
405
|
+
const safe = tenantId
|
|
406
|
+
? isSafeRehostedAssetPathForTenant(value, tenantId)
|
|
407
|
+
: isSafeRehostedAssetPath(value);
|
|
408
|
+
if (!safe) {
|
|
409
|
+
out.push(mk(ERROR, "asset-path", file, `${at} must be a /shared/... asset or belong to the active tenant`));
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function validatePromoTiles(block, file, at, out, tenantId) {
|
|
414
|
+
if (block.presentation !== undefined && !PROMO_TILE_PRESENTATION_SET.has(block.presentation)) {
|
|
415
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.presentation must be overlay, portrait-cards, editorial-cards, or logo-strip`));
|
|
416
|
+
}
|
|
417
|
+
if (!Array.isArray(block.tiles)) {
|
|
418
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.tiles must be an array`));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
block.tiles.forEach((tile, index) => {
|
|
422
|
+
const tileAt = `${at}.tiles[${index}]`;
|
|
423
|
+
if (tile == null || typeof tile !== "object" || Array.isArray(tile)) {
|
|
424
|
+
out.push(mk(ERROR, "block-prop", file, `${tileAt} must be an object`));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
for (const prop of ["label", "href"]) {
|
|
428
|
+
if (typeof tile[prop] !== "string" || !tile[prop].trim()) {
|
|
429
|
+
out.push(mk(ERROR, "block-prop", file, `${tileAt}.${prop} must be a non-empty string`));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
for (const prop of ["imageAlt", "meta"]) {
|
|
433
|
+
if (tile[prop] !== undefined && typeof tile[prop] !== "string") {
|
|
434
|
+
out.push(mk(ERROR, "block-prop", file, `${tileAt}.${prop} must be a string`));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (tile.image !== undefined) validateVisualAsset(tile.image, file, `${tileAt}.image`, out, tenantId);
|
|
438
|
+
if (typeof tile.href === "string" && !isSafeStorefrontHref(tile.href)) {
|
|
439
|
+
out.push(mk(ERROR, "navigation-href", file, `${tileAt}.href must be a same-storefront root-relative path`));
|
|
440
|
+
}
|
|
441
|
+
if ((block.presentation ?? "overlay") !== "overlay" && tile.image === undefined) {
|
|
442
|
+
out.push(mk(ERROR, "block-required", file, `${tileAt}.image is required for ${block.presentation}`));
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
if ((block.ctaLabel === undefined) !== (block.ctaHref === undefined)) {
|
|
446
|
+
out.push(mk(ERROR, "block-required", file, `${at}.ctaLabel and ${at}.ctaHref must be supplied together`));
|
|
447
|
+
}
|
|
448
|
+
for (const prop of ["ctaLabel", "ctaHref"]) {
|
|
449
|
+
if (block[prop] !== undefined && (typeof block[prop] !== "string" || !block[prop].trim())) {
|
|
450
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.${prop} must be a non-empty string`));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (typeof block.ctaHref === "string" && !isSafeStorefrontHref(block.ctaHref)) {
|
|
454
|
+
out.push(mk(ERROR, "navigation-href", file, `${at}.ctaHref must be a same-storefront root-relative path`));
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function validateHeroSlides(slides, file, at, out, tenantId) {
|
|
459
|
+
if (!Array.isArray(slides) || slides.length === 0) { out.push(mk(ERROR, "block-prop", file, `${at}.slides must be a non-empty array`)); return; }
|
|
460
|
+
slides.forEach((slide, index) => {
|
|
461
|
+
const slideAt = `${at}.slides[${index}]`;
|
|
462
|
+
if (slide == null || typeof slide !== "object" || Array.isArray(slide)) { out.push(mk(ERROR, "block-prop", file, `${slideAt} must be an object`)); return; }
|
|
463
|
+
for (const prop of ["image", "imageAlt"]) if (typeof slide[prop] !== "string" || !slide[prop].trim()) out.push(mk(ERROR, "block-required", file, `${slideAt}.${prop} must be a non-empty string`));
|
|
464
|
+
validateVisualAsset(slide.image, file, `${slideAt}.image`, out, tenantId);
|
|
465
|
+
for (const prop of ["headline", "ctaLabel", "ctaHref"]) if (slide[prop] !== undefined && (typeof slide[prop] !== "string" || !slide[prop].trim())) out.push(mk(ERROR, "block-prop", file, `${slideAt}.${prop} must be a non-empty string`));
|
|
466
|
+
if ((slide.ctaLabel === undefined) !== (slide.ctaHref === undefined)) out.push(mk(ERROR, "block-required", file, `${slideAt}.ctaLabel and ${slideAt}.ctaHref must be supplied together`));
|
|
467
|
+
if (typeof slide.ctaHref === "string" && !isSafeStorefrontHref(slide.ctaHref)) out.push(mk(ERROR, "navigation-href", file, `${slideAt}.ctaHref must be a same-storefront root-relative path`));
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function validateTrustStrip(block, file, at, out, tenantId) {
|
|
472
|
+
if (!Array.isArray(block.items)) {
|
|
473
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
block.items.forEach((item, index) => {
|
|
477
|
+
const itemAt = `${at}.items[${index}]`;
|
|
478
|
+
if (item == null || typeof item !== "object" || Array.isArray(item) || !["claim", "rating"].includes(item.kind)) {
|
|
479
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.kind must be claim or rating`));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (item.kind === "claim") {
|
|
483
|
+
if (typeof item.label !== "string" || !item.label.trim()) {
|
|
484
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.label must be a non-empty string`));
|
|
485
|
+
}
|
|
486
|
+
if (item.icon !== undefined) validateVisualAsset(item.icon, file, `${itemAt}.icon`, out, tenantId);
|
|
487
|
+
if (item.iconAlt !== undefined && typeof item.iconAlt !== "string") {
|
|
488
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.iconAlt must be a string`));
|
|
489
|
+
}
|
|
490
|
+
} else if (
|
|
491
|
+
typeof item.average !== "number" || item.average <= 0 || item.average > 5 ||
|
|
492
|
+
typeof item.count !== "number" || item.count <= 0
|
|
493
|
+
) {
|
|
494
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt} requires average 1–5 and a positive count`));
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function validateFaq(block, file, at, out) {
|
|
500
|
+
if (block.lede !== undefined && (typeof block.lede !== "string" || !block.lede.trim())) {
|
|
501
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.lede must be a non-empty plain-text string`));
|
|
502
|
+
}
|
|
503
|
+
if (!Array.isArray(block.items)) {
|
|
504
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
block.items.forEach((item, index) => {
|
|
508
|
+
const itemAt = `${at}.items[${index}]`;
|
|
509
|
+
if (item == null || typeof item !== "object" || Array.isArray(item)) {
|
|
510
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt} must be an object`));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
for (const prop of ["q", "a"]) {
|
|
514
|
+
if (typeof item[prop] !== "string" || !item[prop].trim()) {
|
|
515
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.${prop} must be a non-empty string`));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function validateHomeDoc(doc, file, tenantId) {
|
|
391
522
|
const out = [];
|
|
392
523
|
if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
393
524
|
return [mk(ERROR, "home-root", file, "home.json must be a JSON object")];
|
|
@@ -417,6 +548,9 @@ function validateHomeDoc(doc, file) {
|
|
|
417
548
|
for (const prop of REQUIRED_BLOCK_PROPS[component] || []) {
|
|
418
549
|
if (block[prop] === undefined) out.push(mk(ERROR, "block-required", file, `${at} (${component}) is missing "${prop}"`));
|
|
419
550
|
}
|
|
551
|
+
if (component === "featured_collections" && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
|
|
552
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.handles must be a string array`));
|
|
553
|
+
}
|
|
420
554
|
if (component === "featured_products") {
|
|
421
555
|
if (block.source !== undefined) out.push(...validateProductSource(block.source, file, at));
|
|
422
556
|
if (block.handles !== undefined && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
|
|
@@ -425,7 +559,21 @@ function validateHomeDoc(doc, file) {
|
|
|
425
559
|
if (block.limit !== undefined && typeof block.limit !== "number") {
|
|
426
560
|
out.push(mk(ERROR, "block-prop", file, `${at}.limit must be a number`));
|
|
427
561
|
}
|
|
562
|
+
if (block.presentation !== undefined && !FEATURED_PRODUCT_PRESENTATIONS.includes(block.presentation)) out.push(mk(ERROR, "block-prop", file, `${at}.presentation must be grid or carousel`));
|
|
428
563
|
}
|
|
564
|
+
if (component === "hero" && block.image !== undefined) {
|
|
565
|
+
validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
|
|
566
|
+
}
|
|
567
|
+
if (component === "hero" && block.slides !== undefined) validateHeroSlides(block.slides, file, at, out, tenantId);
|
|
568
|
+
if (component === "editorial" && block.image !== undefined) {
|
|
569
|
+
validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
|
|
570
|
+
if (typeof block.imageAlt !== "string") {
|
|
571
|
+
out.push(mk(ERROR, "block-required", file, `${at}.imageAlt is required when ${at}.image is supplied`));
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (component === "promo_tiles") validatePromoTiles(block, file, at, out, tenantId);
|
|
575
|
+
if (component === "trust_strip") validateTrustStrip(block, file, at, out, tenantId);
|
|
576
|
+
if (component === "faq") validateFaq(block, file, at, out);
|
|
429
577
|
});
|
|
430
578
|
return out;
|
|
431
579
|
}
|
|
@@ -856,6 +1004,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
856
1004
|
);
|
|
857
1005
|
}
|
|
858
1006
|
const scope = opts.scope || config?.scope;
|
|
1007
|
+
const activeTenantId = opts.tenantId || scope || config?.tenant;
|
|
859
1008
|
// platform-route awareness: which framework routes the host platform owns for this tenant
|
|
860
1009
|
const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
|
|
861
1010
|
const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
|
|
@@ -914,7 +1063,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
914
1063
|
if (error) {
|
|
915
1064
|
findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
|
|
916
1065
|
} else if (name === "home.json") {
|
|
917
|
-
findings.push(...validateHomeDoc(value, "content/home.json"));
|
|
1066
|
+
findings.push(...validateHomeDoc(value, "content/home.json", activeTenantId));
|
|
918
1067
|
} else if (name === "chrome.json") {
|
|
919
1068
|
findings.push(...findDuplicateCtaIds(value, "content/chrome.json"));
|
|
920
1069
|
if (opts.previousChromeJson != null) {
|
|
@@ -1114,7 +1263,7 @@ function resolveTenantDir(argv) {
|
|
|
1114
1263
|
if (!dir) return { error: "--workspace requires a path" };
|
|
1115
1264
|
return { dir: resolve(dir), tenantId: null };
|
|
1116
1265
|
}
|
|
1117
|
-
const id = argv.find((a) => !a.startsWith("--"));
|
|
1266
|
+
const id = argv.find((a, index) => !a.startsWith("--") && argv[index - 1] !== "--profile");
|
|
1118
1267
|
if (!id) return { error: "usage: node scripts/tenant/validate.mjs <tenant> | --workspace <dir> [--json]" };
|
|
1119
1268
|
return { dir: tenantDir(id), tenantId: id };
|
|
1120
1269
|
}
|
|
@@ -1123,6 +1272,12 @@ const isMain = resolve(process.argv[1] || "") === resolve(fileURLToPath(import.m
|
|
|
1123
1272
|
if (isMain) {
|
|
1124
1273
|
const argv = process.argv.slice(2);
|
|
1125
1274
|
const asJson = argv.includes("--json");
|
|
1275
|
+
const profileIndex = argv.indexOf("--profile");
|
|
1276
|
+
const profile = profileIndex === -1 ? null : argv[profileIndex + 1];
|
|
1277
|
+
if (profileIndex !== -1 && profile !== "visual-parity") {
|
|
1278
|
+
console.error("usage: --profile visual-parity");
|
|
1279
|
+
process.exit(2);
|
|
1280
|
+
}
|
|
1126
1281
|
const { dir, tenantId, error } = resolveTenantDir(argv);
|
|
1127
1282
|
if (error) {
|
|
1128
1283
|
console.error(error);
|
|
@@ -1134,14 +1289,22 @@ if (isMain) {
|
|
|
1134
1289
|
process.exit(2);
|
|
1135
1290
|
}
|
|
1136
1291
|
const mode = argv.includes("--workspace") ? "workspace" : "monorepo";
|
|
1137
|
-
const
|
|
1292
|
+
const baseline = validateTenant(dir, { tenantId: tenantId ?? undefined, mode });
|
|
1293
|
+
const result = profile === "visual-parity"
|
|
1294
|
+
? (() => {
|
|
1295
|
+
const parity = validateVisualParityClosure(dir, { tenantId: tenantId ?? undefined });
|
|
1296
|
+
const findings = mergeFindings(baseline.findings, parity.findings);
|
|
1297
|
+
return { ok: !findings.some((item) => item.level === ERROR), findings };
|
|
1298
|
+
})()
|
|
1299
|
+
: baseline;
|
|
1300
|
+
const { ok, findings } = result;
|
|
1138
1301
|
if (asJson) {
|
|
1139
1302
|
console.log(JSON.stringify({ ok, findings }, null, 2));
|
|
1140
1303
|
process.exit(ok ? 0 : 1);
|
|
1141
1304
|
}
|
|
1142
1305
|
const errors = findings.filter((f) => f.level === ERROR);
|
|
1143
1306
|
const warns = findings.filter((f) => f.level === WARN);
|
|
1144
|
-
console.log(`\ntot validate — ${tenantId ?? dir}\n`);
|
|
1307
|
+
console.log(`\ntot validate${profile ? ` --profile ${profile}` : ""} — ${tenantId ?? dir}\n`);
|
|
1145
1308
|
// Named, grouped callouts FIRST — see printLoudAdvisories above.
|
|
1146
1309
|
printLoudAdvisories(findings, { log: (s) => console.log(s) });
|
|
1147
1310
|
for (const f of findings) {
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cloudflare Image Resizing URL helpers (Epic Phase-2 D2).
|
|
3
|
-
*
|
|
4
|
-
* Rehosted product images live in the PRODUCT_IMAGES R2 bucket, which does NOT
|
|
5
|
-
* transform on a `?width=` query the way Shopify's CDN does. Cloudflare Image
|
|
6
|
-
* Resizing — the `/cdn-cgi/image/<options>/<source>` path, run at the zone edge —
|
|
7
|
-
* resizes AND reformats (`format=auto` → AVIF/WebP where supported) any same-zone
|
|
8
|
-
* or allowlisted source.
|
|
9
|
-
*
|
|
10
|
-
* We emit ROOT-RELATIVE `/cdn-cgi/image/...` URLs so they resolve against
|
|
11
|
-
* whichever zone is serving the page (and an extracted/self-hosted tenant's zone
|
|
12
|
-
* too — no hardcoded host). Referenced (non-rehosted) Shopify CDN URLs keep their
|
|
13
|
-
* native `?width=` scheme in Img.astro, since Shopify already resizes those — no
|
|
14
|
-
* need to route them back through our resizer.
|
|
15
|
-
*/
|
|
16
|
-
export interface CfImageOpts {
|
|
17
|
-
width: number;
|
|
18
|
-
height?: number;
|
|
19
|
-
fit?: "cover" | "contain" | "scale-down" | "crop" | "pad";
|
|
20
|
-
format?: "auto" | "avif" | "webp";
|
|
21
|
-
quality?: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** The responsive widths we generate, in CSS px. */
|
|
25
|
-
export const CF_IMAGE_WIDTHS = [320, 480, 640, 960, 1280, 1600] as const;
|
|
26
|
-
|
|
27
|
-
/** Build a single `/cdn-cgi/image/...` URL for a source image. */
|
|
28
|
-
export function cfImageUrl(src: string, opts: CfImageOpts): string {
|
|
29
|
-
const options = [
|
|
30
|
-
`width=${Math.round(opts.width)}`,
|
|
31
|
-
opts.height ? `height=${Math.round(opts.height)}` : null,
|
|
32
|
-
`fit=${opts.fit ?? "cover"}`,
|
|
33
|
-
`format=${opts.format ?? "auto"}`,
|
|
34
|
-
`quality=${opts.quality ?? 82}`,
|
|
35
|
-
]
|
|
36
|
-
.filter(Boolean)
|
|
37
|
-
.join(",");
|
|
38
|
-
// Cloudflare accepts both absolute source URLs and same-zone paths verbatim
|
|
39
|
-
// after the options segment.
|
|
40
|
-
return `/cdn-cgi/image/${options}/${src}`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Build a `srcset` of resized variants up to ~2x the display width, so the
|
|
45
|
-
* browser picks the right density. `displayWidth` is the intrinsic/layout width.
|
|
46
|
-
*/
|
|
47
|
-
export function cfImageSrcset(
|
|
48
|
-
src: string,
|
|
49
|
-
displayWidth: number,
|
|
50
|
-
opts: Omit<CfImageOpts, "width"> = {},
|
|
51
|
-
): string {
|
|
52
|
-
const widths = CF_IMAGE_WIDTHS.filter((w) => w <= displayWidth * 2);
|
|
53
|
-
const list = widths.length ? widths : [displayWidth];
|
|
54
|
-
return list.map((w) => `${cfImageUrl(src, { ...opts, width: w })} ${w}w`).join(", ");
|
|
55
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The scheduled Cloudflare Worker entry for `dt-prune-and-rollup`.
|
|
3
|
-
*
|
|
4
|
-
* A SEPARATE, DEDICATED Worker script from the Astro-adapted storefront
|
|
5
|
-
* Worker — deliberately, not a `scheduled` export bolted onto the Astro
|
|
6
|
-
* entry. The @astrojs/cloudflare adapter OWNS `dist/server/entry.mjs` and
|
|
7
|
-
* bakes its target environment in at BUILD time via `CLOUDFLARE_ENV`
|
|
8
|
-
* (`wrangler.toml`'s own "DEPLOY FOOTGUN" comment); the documented way to add
|
|
9
|
-
* a non-`fetch` handler there is a custom entrypoint that re-imports the
|
|
10
|
-
* adapter's generated `handle()`, which would make this cron trigger's
|
|
11
|
-
* correctness depend on the SAME footgun-prone build/deploy sequencing as
|
|
12
|
-
* the whole storefront. This module instead has nothing to do with Astro at
|
|
13
|
-
* all — it is a plain `ExportedHandler` deployed under its own Worker name
|
|
14
|
-
* (`wrangler.prune-rollup.toml`, `[triggers]` below), reusing the tracking
|
|
15
|
-
* library's already-built, framework-agnostic pieces
|
|
16
|
-
* (`lib/tracking/pruneRollup.ts`, `d1Control.ts`, `interactionStore.ts`).
|
|
17
|
-
* There is no other scheduled-Worker precedent in this repo to follow; this
|
|
18
|
-
* is the first one, chosen to keep the footgun-prone Astro deploy path
|
|
19
|
-
* completely untouched.
|
|
20
|
-
*
|
|
21
|
-
* BINDINGS (declared in `wrangler.prune-rollup.toml`, not this file):
|
|
22
|
-
* - `STOREFRONT_APPS_DB` — the SAME shared D1 database the main storefront
|
|
23
|
-
* Worker binds, for the `tracking_tenant_databases` directory.
|
|
24
|
-
* - `INTERACTION_ANALYTICS` — the SAME Analytics Engine dataset the main
|
|
25
|
-
* Worker writes rollups to.
|
|
26
|
-
* - `CF_ACCOUNT_ID` / `TRACKING_D1_API_TOKEN` (secret) — D1 REST
|
|
27
|
-
* credentials for reaching each tenant's OWN database (Worker bindings
|
|
28
|
-
* are static; a per-tenant database can only be addressed over the D1
|
|
29
|
-
* HTTP API, same reasoning as `d1Control.ts`'s module doc).
|
|
30
|
-
* - `TRACKING_D1_NAME_PREFIX` (optional) — unused by this worker directly,
|
|
31
|
-
* kept only for parity/documentation; the prune sweep addresses tenants
|
|
32
|
-
* by `database_uuid` from the directory, never by re-deriving the name.
|
|
33
|
-
*/
|
|
34
|
-
import type { Queryable } from "../lib/d1/catalog.js";
|
|
35
|
-
import type { D1Like } from "../lib/d1/catalog.js";
|
|
36
|
-
import { fromD1 } from "../lib/d1/catalog.js";
|
|
37
|
-
import type { AnalyticsEngineLike } from "../lib/activity/store.js";
|
|
38
|
-
import type { D1ControlConfig } from "../lib/tracking/d1Control.js";
|
|
39
|
-
import { runPruneSweep, type PruneSweepResult } from "../lib/tracking/pruneRollup.js";
|
|
40
|
-
|
|
41
|
-
/** The env bindings this Worker needs — a local, minimal slice (this repo
|
|
42
|
-
* does not take a `@cloudflare/workers-types` dependency; see `d1Control.ts`
|
|
43
|
-
* and `activity/store.ts` for the same convention). */
|
|
44
|
-
export interface PruneRollupEnv {
|
|
45
|
-
STOREFRONT_APPS_DB: D1Like;
|
|
46
|
-
INTERACTION_ANALYTICS?: AnalyticsEngineLike;
|
|
47
|
-
CF_ACCOUNT_ID?: string;
|
|
48
|
-
TRACKING_D1_API_TOKEN?: string;
|
|
49
|
-
/** Tenants processed per invocation; overridable for a slower/faster cron. */
|
|
50
|
-
PRUNE_BATCH_SIZE?: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** The minimal slice of Cloudflare's `ScheduledController` this handler uses. */
|
|
54
|
-
export interface ScheduledControllerLike {
|
|
55
|
-
scheduledTime: number;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** The minimal slice of Cloudflare's `ExecutionContext` this handler uses. */
|
|
59
|
-
export interface ExecutionContextLike {
|
|
60
|
-
waitUntil(promise: Promise<unknown>): void;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Build the D1 REST control config from env, or undefined when unconfigured
|
|
64
|
-
* (mirrors `d1Control.ts`'s `readD1Control()` "skip, never fail" posture). */
|
|
65
|
-
function controlFromEnv(env: PruneRollupEnv): D1ControlConfig | undefined {
|
|
66
|
-
if (!env.CF_ACCOUNT_ID || !env.TRACKING_D1_API_TOKEN) return undefined;
|
|
67
|
-
return { accountId: env.CF_ACCOUNT_ID, apiToken: env.TRACKING_D1_API_TOKEN };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* The pure orchestration the `scheduled` export below delegates to — kept
|
|
72
|
-
* separate so it is callable/testable without a real `ScheduledController`.
|
|
73
|
-
* Never throws: a misconfigured environment (no D1 REST credentials) logs
|
|
74
|
-
* and no-ops rather than failing the cron invocation.
|
|
75
|
-
*/
|
|
76
|
-
export async function handleScheduledPrune(
|
|
77
|
-
env: PruneRollupEnv,
|
|
78
|
-
now: string = new Date().toISOString(),
|
|
79
|
-
): Promise<PruneSweepResult | undefined> {
|
|
80
|
-
const control = controlFromEnv(env);
|
|
81
|
-
if (!control) {
|
|
82
|
-
console.error(
|
|
83
|
-
"[tracking:prune] skipped — CF_ACCOUNT_ID/TRACKING_D1_API_TOKEN not configured",
|
|
84
|
-
);
|
|
85
|
-
return undefined;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const apps: Queryable = fromD1(env.STOREFRONT_APPS_DB);
|
|
89
|
-
const batchSize = env.PRUNE_BATCH_SIZE ? Number.parseInt(env.PRUNE_BATCH_SIZE, 10) : undefined;
|
|
90
|
-
|
|
91
|
-
const result = await runPruneSweep({
|
|
92
|
-
apps,
|
|
93
|
-
control,
|
|
94
|
-
analytics: env.INTERACTION_ANALYTICS,
|
|
95
|
-
now,
|
|
96
|
-
...(batchSize && Number.isFinite(batchSize) ? { batchSize } : {}),
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
console.info(
|
|
100
|
-
`[tracking:prune] swept ${result.tenantsSwept} tenant(s), pruned ${result.totalPrunedRows} row(s), ` +
|
|
101
|
-
`wrote ${result.totalAggregatesWritten} aggregate(s)`,
|
|
102
|
-
);
|
|
103
|
-
return result;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export default {
|
|
107
|
-
async scheduled(
|
|
108
|
-
controller: ScheduledControllerLike,
|
|
109
|
-
env: PruneRollupEnv,
|
|
110
|
-
ctx: ExecutionContextLike,
|
|
111
|
-
): Promise<void> {
|
|
112
|
-
ctx.waitUntil(
|
|
113
|
-
handleScheduledPrune(env, new Date(controller.scheduledTime).toISOString()).catch((err) => {
|
|
114
|
-
console.error(
|
|
115
|
-
`[tracking:prune] sweep threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
116
|
-
);
|
|
117
|
-
}),
|
|
118
|
-
);
|
|
119
|
-
},
|
|
120
|
-
};
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
/* Storefront early-access landing — interactions.
|
|
2
|
-
* Served at /tenants/home/js/storefront.js (mirrored from tenants/home/public/).
|
|
3
|
-
*/
|
|
4
|
-
(function () {
|
|
5
|
-
// 1) comparison expand/collapse
|
|
6
|
-
document.querySelectorAll(".cmp .top").forEach(function (btn) {
|
|
7
|
-
btn.addEventListener("click", function () {
|
|
8
|
-
const card = btn.closest(".cmp"), open = card.getAttribute("aria-expanded") === "true";
|
|
9
|
-
card.setAttribute("aria-expanded", open ? "false" : "true");
|
|
10
|
-
btn.setAttribute("aria-expanded", open ? "false" : "true");
|
|
11
|
-
});
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
// 2) persona tabs
|
|
15
|
-
const tabs = document.querySelectorAll(".seg [data-persona]");
|
|
16
|
-
const panels = document.querySelectorAll(".ppanel");
|
|
17
|
-
tabs.forEach(function (tab) {
|
|
18
|
-
tab.addEventListener("click", function () {
|
|
19
|
-
const who = tab.getAttribute("data-persona");
|
|
20
|
-
tabs.forEach(function (t) { t.setAttribute("aria-selected", t === tab ? "true" : "false"); });
|
|
21
|
-
panels.forEach(function (p) { p.classList.toggle("on", p.getAttribute("data-panel") === who); });
|
|
22
|
-
});
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
// ---- request-access modal --------------------------------------------------
|
|
26
|
-
// Board address the mailto: fallback targets (mirrors the server route's
|
|
27
|
-
// recipient). Kept in sync with apps/storefront/src/pages/api/request-access.ts.
|
|
28
|
-
const BOARD_EMAIL = "darrin84+l6gajmvvoo1x4edew5qs@boards.trello.com";
|
|
29
|
-
const modal = document.getElementById("ra-modal");
|
|
30
|
-
const form = modal && modal.querySelector("form.ra");
|
|
31
|
-
|
|
32
|
-
function openModal() {
|
|
33
|
-
if (!modal) return;
|
|
34
|
-
modal.classList.remove("done");
|
|
35
|
-
if (form) form.classList.remove("has-error");
|
|
36
|
-
modal.classList.add("on");
|
|
37
|
-
document.body.style.overflow = "hidden";
|
|
38
|
-
const first = modal.querySelector("input[name=email]");
|
|
39
|
-
if (first) setTimeout(function () { first.focus(); }, 40);
|
|
40
|
-
}
|
|
41
|
-
function closeModal() {
|
|
42
|
-
if (!modal) return;
|
|
43
|
-
modal.classList.remove("on");
|
|
44
|
-
document.body.style.overflow = "";
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// Any element pointing at #request opens the modal instead of jumping.
|
|
48
|
-
document.querySelectorAll('a[href="#request"]').forEach(function (a) {
|
|
49
|
-
a.addEventListener("click", function (e) {
|
|
50
|
-
e.preventDefault();
|
|
51
|
-
openModal();
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
if (modal) {
|
|
55
|
-
modal.querySelectorAll("[data-close]").forEach(function (el) {
|
|
56
|
-
el.addEventListener("click", closeModal);
|
|
57
|
-
});
|
|
58
|
-
document.addEventListener("keydown", function (e) {
|
|
59
|
-
if (e.key === "Escape" && modal.classList.contains("on")) closeModal();
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function mailtoFallback(fields) {
|
|
64
|
-
const lines = [
|
|
65
|
-
"First name: " + fields.firstName,
|
|
66
|
-
"Last name: " + fields.lastName,
|
|
67
|
-
"Email: " + fields.email,
|
|
68
|
-
fields.phone ? "Phone: " + fields.phone : null,
|
|
69
|
-
fields.details ? "\nDetails:\n" + fields.details : null,
|
|
70
|
-
].filter(Boolean).join("\n");
|
|
71
|
-
const subject = "Early-access request — " + fields.firstName + " " + fields.lastName;
|
|
72
|
-
return "mailto:" + BOARD_EMAIL + "?subject=" + encodeURIComponent(subject) + "&body=" + encodeURIComponent(lines);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (form) {
|
|
76
|
-
form.addEventListener("submit", async function (e) {
|
|
77
|
-
e.preventDefault();
|
|
78
|
-
form.classList.remove("has-error");
|
|
79
|
-
const fd = new FormData(form);
|
|
80
|
-
const fields = {
|
|
81
|
-
email: String(fd.get("email") || "").trim(),
|
|
82
|
-
firstName: String(fd.get("firstName") || "").trim(),
|
|
83
|
-
lastName: String(fd.get("lastName") || "").trim(),
|
|
84
|
-
phone: String(fd.get("phone") || "").trim(),
|
|
85
|
-
details: String(fd.get("details") || "").trim(),
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
const btn = form.querySelector("button[type=submit]");
|
|
89
|
-
const label = btn ? btn.textContent : "";
|
|
90
|
-
if (btn) { btn.disabled = true; btn.textContent = "Sending…"; }
|
|
91
|
-
|
|
92
|
-
try {
|
|
93
|
-
const res = await fetch("/api/request-access", {
|
|
94
|
-
method: "POST",
|
|
95
|
-
headers: { "content-type": "application/json", accept: "application/json" },
|
|
96
|
-
body: JSON.stringify(fields),
|
|
97
|
-
});
|
|
98
|
-
const data = await res.json().catch(function () { return {}; });
|
|
99
|
-
if (res.ok && data && data.ok) {
|
|
100
|
-
// If the endpoint couldn't send server-side yet, it hands back a
|
|
101
|
-
// mailto: so the request is never dropped on the floor.
|
|
102
|
-
if (data.fallback === "mailto") {
|
|
103
|
-
window.location.href = data.mailto || mailtoFallback(fields);
|
|
104
|
-
}
|
|
105
|
-
modal.classList.add("done");
|
|
106
|
-
} else {
|
|
107
|
-
throw new Error((data && data.error) || "send_failed");
|
|
108
|
-
}
|
|
109
|
-
} catch (err) {
|
|
110
|
-
// Never a dead form: offer the mailto: as a client-side last resort.
|
|
111
|
-
// Built with DOM methods (no innerHTML) so field values can't inject markup.
|
|
112
|
-
form.classList.add("has-error");
|
|
113
|
-
const errEl = form.querySelector(".err");
|
|
114
|
-
if (errEl) {
|
|
115
|
-
errEl.textContent = "Something went wrong. ";
|
|
116
|
-
const link = document.createElement("a");
|
|
117
|
-
link.href = mailtoFallback(fields);
|
|
118
|
-
link.style.color = "inherit";
|
|
119
|
-
link.style.textDecoration = "underline";
|
|
120
|
-
link.textContent = "Email us directly";
|
|
121
|
-
errEl.appendChild(link);
|
|
122
|
-
errEl.appendChild(document.createTextNode(" and we'll take it from there."));
|
|
123
|
-
}
|
|
124
|
-
} finally {
|
|
125
|
-
if (btn) { btn.disabled = false; btn.textContent = label; }
|
|
126
|
-
}
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
})();
|
|
Binary file
|