@tokenoftrust/storefront-runner 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +58 -0
- package/README.md +42 -0
- package/apps/storefront/astro.config.mjs +66 -0
- package/apps/storefront/dev-plugins/dev-loop-state.mjs +147 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +238 -0
- package/apps/storefront/env.d.ts +162 -0
- package/apps/storefront/migrations/0000_baseline.sql +54 -0
- package/apps/storefront/migrations/0001_products_fts.sql +9 -0
- package/apps/storefront/migrations/README.md +54 -0
- package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/_journal.json +20 -0
- package/apps/storefront/package.json +42 -0
- package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
- package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
- package/apps/storefront/public/brand/northwind-og.svg +13 -0
- package/apps/storefront/public/favicon.svg +5 -0
- package/apps/storefront/public/js/dashboard-team.js +147 -0
- package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
- package/apps/storefront/public/shared/commerce-chrome.css +369 -0
- package/apps/storefront/src/components/Badge.astro +34 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
- package/apps/storefront/src/components/CollectionCard.astro +56 -0
- package/apps/storefront/src/components/EmptyState.astro +55 -0
- package/apps/storefront/src/components/Img.astro +68 -0
- package/apps/storefront/src/components/ProductCard.astro +228 -0
- package/apps/storefront/src/components/Section.astro +39 -0
- package/apps/storefront/src/components/Seo.astro +70 -0
- package/apps/storefront/src/components/WidgetFrame.astro +34 -0
- package/apps/storefront/src/components/admin/AdminAIWorkflowsTab.astro +531 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +597 -0
- package/apps/storefront/src/components/admin/AdminFoxyCartSettingsPanel.astro +227 -0
- package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +465 -0
- package/apps/storefront/src/components/admin/AdminProductsTab.astro +901 -0
- package/apps/storefront/src/components/admin/AdminReportingTab.astro +435 -0
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +782 -0
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +1223 -0
- package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
- package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
- package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +201 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +201 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +234 -0
- package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
- package/apps/storefront/src/components/commerce/ArticleCard.astro +242 -0
- package/apps/storefront/src/components/commerce/BrandGrid.astro +175 -0
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
- package/apps/storefront/src/components/commerce/FoxySidecartStyles.astro +61 -0
- package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
- package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
- package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
- package/apps/storefront/src/components/commerce/ReviewList.astro +54 -0
- package/apps/storefront/src/components/commerce/ReviewSummary.astro +44 -0
- package/apps/storefront/src/components/commerce/TagFilterCloud.astro +149 -0
- package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +37 -0
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +99 -0
- package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +45 -0
- package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
- package/apps/storefront/src/components/compliance/PactActNotice.astro +35 -0
- package/apps/storefront/src/components/compliance/Prop65Warning.astro +80 -0
- package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +38 -0
- package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +35 -0
- package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +53 -0
- package/apps/storefront/src/components/home/Hero.astro +164 -0
- package/apps/storefront/src/components/islands/AgeGate.tsx +109 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
- package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
- package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
- package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
- package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
- package/apps/storefront/src/components/islands/VariantSelector.tsx +375 -0
- package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
- package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
- package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
- package/apps/storefront/src/components/marketing/Faq.astro +37 -0
- package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
- package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
- package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
- package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
- package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
- package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
- package/apps/storefront/src/components/marketing/Steps.astro +36 -0
- package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
- package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
- package/apps/storefront/src/components/membership/IllustrativeLabel.astro +56 -0
- package/apps/storefront/src/components/membership/TierCards.astro +164 -0
- package/apps/storefront/src/components/membership/TierComparisonTable.astro +148 -0
- package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
- package/apps/storefront/src/components/plp/FacetSidebar.astro +199 -0
- package/apps/storefront/src/components/plp/Pagination.astro +99 -0
- package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
- package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +95 -0
- package/apps/storefront/src/components/subscription/HowItWorksSteps.astro +67 -0
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +67 -0
- package/apps/storefront/src/components/subscription/SavingsExplainer.astro +101 -0
- package/apps/storefront/src/config/adminTargetTenant.ts +28 -0
- package/apps/storefront/src/config/devTenantSeed.ts +78 -0
- package/apps/storefront/src/config/devTenants.ts +16 -0
- package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
- package/apps/storefront/src/config/resolver.ts +102 -0
- package/apps/storefront/src/config/tenants.ts +87 -0
- package/apps/storefront/src/layouts/Layout.astro +368 -0
- package/apps/storefront/src/lib/admin/README.md +122 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +21 -0
- package/apps/storefront/src/lib/admin/ai/askEnvelope.ts +48 -0
- package/apps/storefront/src/lib/admin/ai/audit.ts +14 -0
- package/apps/storefront/src/lib/admin/ai/index.ts +6 -0
- package/apps/storefront/src/lib/admin/ai/operationalRuns.ts +155 -0
- package/apps/storefront/src/lib/admin/ai/recommendation.ts +20 -0
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +172 -0
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +65 -0
- package/apps/storefront/src/lib/admin/envelope.ts +10 -0
- package/apps/storefront/src/lib/admin/evidence/evidenceReference.ts +32 -0
- package/apps/storefront/src/lib/admin/memoizeByEnvelope.ts +27 -0
- package/apps/storefront/src/lib/admin/orderClient.ts +146 -0
- package/apps/storefront/src/lib/admin/seed.ts +54 -0
- package/apps/storefront/src/lib/admin/services/mock/aiWorkflows.ts +59 -0
- package/apps/storefront/src/lib/admin/services/mock/customers.ts +86 -0
- package/apps/storefront/src/lib/admin/services/mock/evidence.ts +99 -0
- package/apps/storefront/src/lib/admin/services/mock/foxyCartSettings.ts +78 -0
- package/apps/storefront/src/lib/admin/services/mock/fulfillment.ts +62 -0
- package/apps/storefront/src/lib/admin/services/mock/index.ts +12 -0
- package/apps/storefront/src/lib/admin/services/mock/orders.ts +184 -0
- package/apps/storefront/src/lib/admin/services/mock/products.ts +105 -0
- package/apps/storefront/src/lib/admin/services/mock/reporting.ts +51 -0
- package/apps/storefront/src/lib/admin/services/mock/settings.ts +70 -0
- package/apps/storefront/src/lib/admin/services/mock/tenants.ts +29 -0
- package/apps/storefront/src/lib/admin/services/orders.ts +300 -0
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +67 -0
- package/apps/storefront/src/lib/admin/viewmodels/customers.ts +56 -0
- package/apps/storefront/src/lib/admin/viewmodels/fulfillment.ts +49 -0
- package/apps/storefront/src/lib/admin/viewmodels/orders.ts +92 -0
- package/apps/storefront/src/lib/admin/viewmodels/products.ts +74 -0
- package/apps/storefront/src/lib/admin/viewmodels/reporting.ts +34 -0
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +64 -0
- package/apps/storefront/src/lib/adminTenantEnvelope.ts +90 -0
- package/apps/storefront/src/lib/analytics/index.ts +47 -0
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
- package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
- package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +119 -0
- package/apps/storefront/src/lib/auth/route.ts +146 -0
- package/apps/storefront/src/lib/auth/session.ts +203 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
- package/apps/storefront/src/lib/basePath.ts +41 -0
- package/apps/storefront/src/lib/cfImage.ts +55 -0
- package/apps/storefront/src/lib/chrome/model.ts +244 -0
- package/apps/storefront/src/lib/chrome/parseConfig.ts +34 -0
- package/apps/storefront/src/lib/colors.ts +97 -0
- package/apps/storefront/src/lib/commerce/foxyCartSettings.ts +253 -0
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +91 -0
- package/apps/storefront/src/lib/content-edit/client.ts +230 -0
- package/apps/storefront/src/lib/content-edit/index.ts +14 -0
- package/apps/storefront/src/lib/content-edit/route.ts +25 -0
- package/apps/storefront/src/lib/content-edit/types.ts +19 -0
- package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
- package/apps/storefront/src/lib/d1/catalog.ts +366 -0
- package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
- package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
- package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
- package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
- package/apps/storefront/src/lib/demoGate.ts +113 -0
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +78 -0
- package/apps/storefront/src/lib/dev/activityStore.ts +72 -0
- package/apps/storefront/src/lib/dev/ai-widget-client.js +308 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +14 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +155 -0
- package/apps/storefront/src/lib/dev/devDraft.ts +133 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +169 -0
- package/apps/storefront/src/lib/dev/livePreview.ts +77 -0
- package/apps/storefront/src/lib/dev/runtimeStore.ts +120 -0
- package/apps/storefront/src/lib/dev/storeStatus.ts +142 -0
- package/apps/storefront/src/lib/diag/redact.ts +20 -0
- package/apps/storefront/src/lib/edgeCache.ts +128 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
- package/apps/storefront/src/lib/env.ts +60 -0
- package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
- package/apps/storefront/src/lib/format.ts +38 -0
- package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
- package/apps/storefront/src/lib/imageAttrs.ts +55 -0
- package/apps/storefront/src/lib/jsonld.ts +166 -0
- package/apps/storefront/src/lib/maintenance.ts +71 -0
- package/apps/storefront/src/lib/membership/model.ts +97 -0
- package/apps/storefront/src/lib/memoryKv.ts +32 -0
- package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
- package/apps/storefront/src/lib/placeholder.ts +99 -0
- package/apps/storefront/src/lib/previewReload.ts +125 -0
- package/apps/storefront/src/lib/priceBuckets.ts +33 -0
- package/apps/storefront/src/lib/rawChrome.ts +505 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +136 -0
- package/apps/storefront/src/lib/search/index.ts +74 -0
- package/apps/storefront/src/lib/sections.ts +129 -0
- package/apps/storefront/src/lib/securityHeaders.ts +31 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +607 -0
- package/apps/storefront/src/lib/subscription/model.ts +68 -0
- package/apps/storefront/src/lib/tenantRouting.ts +91 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
- package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
- package/apps/storefront/src/lib/tot/query.ts +249 -0
- package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
- package/apps/storefront/src/lib/url.ts +142 -0
- package/apps/storefront/src/lib/wishlist.ts +63 -0
- package/apps/storefront/src/middleware/index.ts +455 -0
- package/apps/storefront/src/pages/404.astro +61 -0
- package/apps/storefront/src/pages/[...slug].astro +90 -0
- package/apps/storefront/src/pages/admin.astro +1675 -0
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +53 -0
- package/apps/storefront/src/pages/api/admin/orders.ts +71 -0
- package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
- package/apps/storefront/src/pages/api/auth/send.ts +85 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
- package/apps/storefront/src/pages/api/checkout-style.css.ts +43 -0
- package/apps/storefront/src/pages/api/cli/policy.ts +70 -0
- package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +164 -0
- package/apps/storefront/src/pages/api/csp-report.ts +25 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
- package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
- package/apps/storefront/src/pages/api/dev/activity.ts +124 -0
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +73 -0
- package/apps/storefront/src/pages/api/dev/diff.ts +68 -0
- package/apps/storefront/src/pages/api/dev/file.ts +57 -0
- package/apps/storefront/src/pages/api/dev/locate.ts +125 -0
- package/apps/storefront/src/pages/api/dev/publish.ts +128 -0
- package/apps/storefront/src/pages/api/dev/tree.ts +70 -0
- package/apps/storefront/src/pages/api/dev/write.ts +116 -0
- package/apps/storefront/src/pages/api/newsletter.ts +104 -0
- package/apps/storefront/src/pages/api/request-access.ts +172 -0
- package/apps/storefront/src/pages/auth/login.astro +149 -0
- package/apps/storefront/src/pages/auth/magic.astro +105 -0
- package/apps/storefront/src/pages/capabilities.astro +292 -0
- package/apps/storefront/src/pages/collections/[handle].astro +177 -0
- package/apps/storefront/src/pages/collections/index.astro +59 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
- package/apps/storefront/src/pages/dashboard/index.astro +160 -0
- package/apps/storefront/src/pages/index.astro +290 -0
- package/apps/storefront/src/pages/llms.txt.ts +92 -0
- package/apps/storefront/src/pages/products/[handle].astro +402 -0
- package/apps/storefront/src/pages/robots.txt.ts +24 -0
- package/apps/storefront/src/pages/saved.astro +35 -0
- package/apps/storefront/src/pages/search.astro +150 -0
- package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +1280 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +229 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +144 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +116 -0
- package/apps/storefront/src/pages/style-guide/index.astro +98 -0
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
- package/apps/storefront/src/styles/global.css +292 -0
- package/apps/storefront/src/themes/reference.ts +68 -0
- package/apps/storefront/src/themes/schema.ts +111 -0
- package/apps/storefront/src/themes/tenants/index.ts +51 -0
- package/apps/storefront/tsconfig.json +16 -0
- package/apps/storefront/vitest.config.ts +22 -0
- package/package.json +26 -0
- package/packages/public-runtime/LICENSE +58 -0
- package/packages/public-runtime/package.json +22 -0
- package/packages/public-runtime/src/binary-path.ts +68 -0
- package/packages/public-runtime/src/capabilities.ts +89 -0
- package/packages/public-runtime/src/catalog-api.ts +81 -0
- package/packages/public-runtime/src/catalog-d1.ts +192 -0
- package/packages/public-runtime/src/checkout-style-publish.ts +200 -0
- package/packages/public-runtime/src/checkout-style.ts +375 -0
- package/packages/public-runtime/src/csp.ts +109 -0
- package/packages/public-runtime/src/customization-preview.ts +197 -0
- package/packages/public-runtime/src/customization-reconcile.ts +63 -0
- package/packages/public-runtime/src/customization-runtime.ts +157 -0
- package/packages/public-runtime/src/customization-scripts.ts +64 -0
- package/packages/public-runtime/src/customization-versioning.ts +95 -0
- package/packages/public-runtime/src/foxy-orders.ts +136 -0
- package/packages/public-runtime/src/foxy.ts +196 -0
- package/packages/public-runtime/src/hot-standby-readiness.ts +140 -0
- package/packages/public-runtime/src/index.ts +34 -0
- package/packages/public-runtime/src/product.ts +159 -0
- package/packages/public-runtime/src/search.ts +45 -0
- package/packages/public-runtime/src/tenant-assets.ts +444 -0
- package/packages/public-runtime/src/tenant.ts +384 -0
- package/packages/public-runtime/tsconfig.json +8 -0
- package/packages/public-runtime/vitest.config.ts +8 -0
- package/pnpm-workspace.yaml +7 -0
- package/scripts/build/copy-tenant-assets.mjs +69 -0
- package/scripts/dev/ai-edit.mjs +183 -0
- package/scripts/dev/checkout-watch.mjs +116 -0
- package/scripts/dev/file-browser.mjs +159 -0
- package/scripts/dev/file-writer.mjs +141 -0
- package/scripts/dev/git-status.mjs +171 -0
- package/scripts/dev/locate-handler.mjs +163 -0
- package/scripts/dev/locate.mjs +112 -0
- package/scripts/dev/port-check.mjs +48 -0
- package/scripts/dev/safe-path.mjs +109 -0
- package/scripts/dev/shot.mjs +46 -0
- package/scripts/dev/tenant-source-root.mjs +76 -0
- package/scripts/dev/unified-diff.mjs +81 -0
- package/scripts/tot-dev.mjs +376 -0
- package/tenants/README.md +14 -0
- package/tenants/home/content/chrome.json +7 -0
- package/tenants/home/content/home.html +58 -0
- package/tenants/home/content/home.json +11 -0
- package/tenants/home/public/js/storefront.js +210 -0
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +212 -0
- package/tenants/home/theme.json +6 -0
- package/tsconfig.base.json +20 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-publish auto-refresh for the SHARED preview (reviewer/owner "it updates
|
|
3
|
+
* itself" experience). NOT the developer's local `tot dev` HMR loop — this is the
|
|
4
|
+
* shared `test`-env preview that others review.
|
|
5
|
+
*
|
|
6
|
+
* A tiny live-reload client is injected (by src/middleware) into `test`-env HTML
|
|
7
|
+
* pages ONLY — never `live`. It polls `GET /__preview/version?tenant=<id>` (the
|
|
8
|
+
* per-tenant edge cache-version integer, which bumps on every reconcile/publish;
|
|
9
|
+
* see lib/edgeCache.ts + api/preview/reconcile.ts) and reloads the page when the
|
|
10
|
+
* version changes. Poll-based v1; an SSE / Durable-Object PUSH is the later
|
|
11
|
+
* upgrade (TODO below) that removes the ~1s poll latency.
|
|
12
|
+
*
|
|
13
|
+
* CSP: the injected <script> carries the per-request nonce (same one the strict
|
|
14
|
+
* CSP header allows), so it is CSP-clean with no `unsafe-inline` and no inline
|
|
15
|
+
* event handlers.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const DEFAULT_POLL_MS = 1000;
|
|
19
|
+
|
|
20
|
+
/** Escape a value for safe embedding inside an inline <script> as JSON. */
|
|
21
|
+
function jsonForScript(value: unknown): string {
|
|
22
|
+
// JSON.stringify covers quoting; escaping `<` defends against a `</script>`
|
|
23
|
+
// (or `<!--`) breakout even though tenant ids / base paths never contain it.
|
|
24
|
+
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The live-reload client body (no <script> wrapper). Kept dependency-free and
|
|
29
|
+
* tiny so it is cheap to inline on every preview page.
|
|
30
|
+
*/
|
|
31
|
+
export function buildPreviewReloadScript(opts: {
|
|
32
|
+
tenantId: string;
|
|
33
|
+
basePath: string;
|
|
34
|
+
pollMs?: number;
|
|
35
|
+
}): string {
|
|
36
|
+
const tenant = jsonForScript(opts.tenantId);
|
|
37
|
+
const base = jsonForScript(opts.basePath ?? "");
|
|
38
|
+
const pollMs = Math.max(250, Math.floor(opts.pollMs ?? DEFAULT_POLL_MS));
|
|
39
|
+
// The version endpoint is fetched UNDER basePath so the same tenant-routing the
|
|
40
|
+
// page used resolves it (path-routed previews carry a /<domain> prefix).
|
|
41
|
+
return `(() => {
|
|
42
|
+
const TENANT = ${tenant}, BASE = ${base}, POLL_MS = ${pollMs};
|
|
43
|
+
const url = BASE + "/__preview/version?tenant=" + encodeURIComponent(TENANT);
|
|
44
|
+
let known = null;
|
|
45
|
+
const tick = async () => {
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(url, { headers: { accept: "application/json" }, cache: "no-store" });
|
|
48
|
+
if (res.ok) {
|
|
49
|
+
const v = (await res.json()).version;
|
|
50
|
+
if (known === null) { known = v; }
|
|
51
|
+
// TODO(theme-hot-swap): when the change is theme-only, swap the CSS custom
|
|
52
|
+
// properties in place (no reload). Needs the reconcile to report a
|
|
53
|
+
// theme-only delta; gated behind this same version signal until then.
|
|
54
|
+
else if (v !== known) { location.reload(); return; }
|
|
55
|
+
}
|
|
56
|
+
} catch (_e) { /* transient — keep polling */ }
|
|
57
|
+
setTimeout(tick, POLL_MS);
|
|
58
|
+
};
|
|
59
|
+
// TODO(push): replace this poll with an SSE / Durable-Object stream so the
|
|
60
|
+
// reload is instant instead of within POLL_MS of a publish.
|
|
61
|
+
setTimeout(tick, POLL_MS);
|
|
62
|
+
})();`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** True when a response is HTML we can inject into. */
|
|
66
|
+
export function isHtmlResponse(response: Response): boolean {
|
|
67
|
+
if (response.status !== 200) return false;
|
|
68
|
+
return (response.headers.get("content-type") ?? "").includes("text/html");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Inject the live-reload <script> into an HTML string, before </body> (or
|
|
73
|
+
* appended when there is no </body>). Pure + unit-testable.
|
|
74
|
+
*/
|
|
75
|
+
export function injectPreviewReloadHtml(
|
|
76
|
+
html: string,
|
|
77
|
+
opts: { tenantId: string; basePath: string; nonce: string; pollMs?: number },
|
|
78
|
+
): string {
|
|
79
|
+
const script = `<script nonce="${opts.nonce}">${buildPreviewReloadScript(opts)}</script>`;
|
|
80
|
+
if (/<\/body>/i.test(html)) {
|
|
81
|
+
return html.replace(/<\/body>/i, `${script}</body>`);
|
|
82
|
+
}
|
|
83
|
+
return html + script;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wrap a Response, returning a new one with the reload client injected. No-op
|
|
88
|
+
* (returns the original) for non-HTML responses. The caller must have already
|
|
89
|
+
* decided this is a `test`-env response — this helper does not check the env.
|
|
90
|
+
*/
|
|
91
|
+
export async function injectPreviewReload(
|
|
92
|
+
response: Response,
|
|
93
|
+
opts: { tenantId: string; basePath: string; nonce: string; pollMs?: number },
|
|
94
|
+
): Promise<Response> {
|
|
95
|
+
if (!isHtmlResponse(response)) return response;
|
|
96
|
+
const html = await response.text();
|
|
97
|
+
const injected = injectPreviewReloadHtml(html, opts);
|
|
98
|
+
// Preserve status + headers; the body length changed but we don't set a manual
|
|
99
|
+
// Content-Length anywhere (Workers/Astro stream), so no header fix-up needed.
|
|
100
|
+
return new Response(injected, {
|
|
101
|
+
status: response.status,
|
|
102
|
+
statusText: response.statusText,
|
|
103
|
+
headers: response.headers,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Env-gated entrypoint the middleware calls: inject the reload client ONLY for
|
|
109
|
+
* the SHARED preview (`test`) — NEVER `live`. Returns the response unchanged for
|
|
110
|
+
* `live` (or any non-HTML response). This is the single guardrail seam, so the
|
|
111
|
+
* "present in test, absent in live" contract is unit-testable here.
|
|
112
|
+
*/
|
|
113
|
+
export async function maybeInjectPreviewReload(
|
|
114
|
+
response: Response,
|
|
115
|
+
opts: {
|
|
116
|
+
env: "live" | "test";
|
|
117
|
+
tenantId: string;
|
|
118
|
+
basePath: string;
|
|
119
|
+
nonce: string;
|
|
120
|
+
pollMs?: number;
|
|
121
|
+
},
|
|
122
|
+
): Promise<Response> {
|
|
123
|
+
if (opts.env !== "test") return response;
|
|
124
|
+
return injectPreviewReload(response, opts);
|
|
125
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed price-bucket boundaries for the PLP price-range facet. Bucketed by a
|
|
3
|
+
* product's "from" price (price_range.min — the same figure ProductCard shows
|
|
4
|
+
* and price-asc/desc sorts by), so a product falls into exactly one bucket
|
|
5
|
+
* (no double-counting across buckets). Shared by both listProducts engines
|
|
6
|
+
* (lib/tot/query.ts, lib/d1/catalog.ts) and the FacetSidebar so bucket
|
|
7
|
+
* boundaries + labels never drift between them.
|
|
8
|
+
*/
|
|
9
|
+
export interface PriceBucket {
|
|
10
|
+
key: string;
|
|
11
|
+
min: number;
|
|
12
|
+
max?: number;
|
|
13
|
+
label: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const PRICE_BUCKETS: PriceBucket[] = [
|
|
17
|
+
{ key: "0-25", min: 0, max: 25, label: "Under $25" },
|
|
18
|
+
{ key: "25-50", min: 25, max: 50, label: "$25 – $50" },
|
|
19
|
+
{ key: "50-100", min: 50, max: 100, label: "$50 – $100" },
|
|
20
|
+
{ key: "100-200", min: 100, max: 200, label: "$100 – $200" },
|
|
21
|
+
{ key: "200-plus", min: 200, label: "$200 & up" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export function bucketForPrice(price: number): PriceBucket {
|
|
25
|
+
return (
|
|
26
|
+
PRICE_BUCKETS.find((b) => price >= b.min && (b.max == null || price < b.max)) ??
|
|
27
|
+
PRICE_BUCKETS[PRICE_BUCKETS.length - 1]!
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function bucketByKey(key: string): PriceBucket | undefined {
|
|
32
|
+
return PRICE_BUCKETS.find((b) => b.key === key);
|
|
33
|
+
}
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical RAW chrome renderer (unit F3).
|
|
3
|
+
*
|
|
4
|
+
* ONE platform-shipped chrome wrapper, driven entirely by a tenant's `chrome.json`
|
|
5
|
+
* (typed `TenantChrome`, lib/chrome/model.ts) — CONTENT ONLY. It replaces the three
|
|
6
|
+
* divergent, hand-authored `content/<tenant>/chrome.html` documents with a single
|
|
7
|
+
* generator, so chrome lives in ONE place and a tenant supplies only data. The
|
|
8
|
+
* output is a full wrapper document carrying the splice markers the rawMarketingHtml
|
|
9
|
+
* pipeline understands (`<!--PAGE_HEAD-->` / `<!--PAGE_BODY-->`), so body-only page
|
|
10
|
+
* fragments get wrapped exactly as before (see lib/rawMarketingHtml.ts,
|
|
11
|
+
* wrapFragmentInChrome). Per decisions:
|
|
12
|
+
* - raw-chrome-single-stylesheet: the wrapper LINKS the platform stylesheet
|
|
13
|
+
* (/shared/commerce-chrome.css, unit F2) and emits NO inline chrome CSS — every
|
|
14
|
+
* color/space/type comes from theme tokens the tenant's linked *-theme.css sets.
|
|
15
|
+
* - materialization-compat: this is a deterministic server-side string; the spliced
|
|
16
|
+
* document is a stable, self-contained artifact SEOMaterialize can capture (the
|
|
17
|
+
* behavior JS is progressive enhancement over already-rendered markup).
|
|
18
|
+
*
|
|
19
|
+
* The markup mirrors, class-for-class, the semantic markup the F2 stylesheet styles
|
|
20
|
+
* (`.site-header`, `.header-row`, `.primary-nav`, `.mega`, `.site-footer`, …) — the
|
|
21
|
+
* SAME DOM the retired tenant chrome.html files carried, now generated from config.
|
|
22
|
+
*
|
|
23
|
+
* SECURITY: renders first-party tenant marketing config. Values are HTML-escaped so a
|
|
24
|
+
* stray quote/angle-bracket can't break out of an attribute or element; this is
|
|
25
|
+
* defense-in-depth, not a sanitizer for untrusted input.
|
|
26
|
+
*/
|
|
27
|
+
import { CHROME_BODY_MARKER, CHROME_HEAD_MARKER } from "./rawMarketingHtml.js";
|
|
28
|
+
import type {
|
|
29
|
+
ChromeAgeGate,
|
|
30
|
+
ChromeAnnouncement,
|
|
31
|
+
ChromeBrand,
|
|
32
|
+
ChromeFooter,
|
|
33
|
+
ChromeLink,
|
|
34
|
+
ChromeNavItem,
|
|
35
|
+
ChromeUtilityNav,
|
|
36
|
+
SocialLink,
|
|
37
|
+
TenantChrome,
|
|
38
|
+
} from "./chrome/model.js";
|
|
39
|
+
|
|
40
|
+
/** HTML-escape text/attribute content. Covers the five significant characters. */
|
|
41
|
+
function esc(value: string): string {
|
|
42
|
+
return value
|
|
43
|
+
.replace(/&/g, "&")
|
|
44
|
+
.replace(/</g, "<")
|
|
45
|
+
.replace(/>/g, ">")
|
|
46
|
+
.replace(/"/g, """)
|
|
47
|
+
.replace(/'/g, "'");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Escape a value destined for a SINGLE-quoted attribute (e.g. data-messages JSON). */
|
|
51
|
+
function escSingle(value: string): string {
|
|
52
|
+
return value
|
|
53
|
+
.replace(/&/g, "&")
|
|
54
|
+
.replace(/</g, "<")
|
|
55
|
+
.replace(/>/g, ">")
|
|
56
|
+
.replace(/'/g, "'");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Render text preserving author line breaks as <br>. */
|
|
60
|
+
function withBreaks(text: string): string {
|
|
61
|
+
return esc(text).replace(/\r?\n/g, "<br>");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A rel="noopener" attribute for external/off-site links. */
|
|
65
|
+
function relFor(link: ChromeLink): string {
|
|
66
|
+
return link.external ? ' rel="noopener noreferrer"' : "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Header action icons — identical across the three source tenants; the theme tokens
|
|
70
|
+
// (via the shared stylesheet) color them. viewBox 0 0 24 24, no fill/stroke attrs so
|
|
71
|
+
// the sheet owns presentation.
|
|
72
|
+
const ICON_SEARCH =
|
|
73
|
+
'<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>';
|
|
74
|
+
const ICON_ACCOUNT =
|
|
75
|
+
'<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-7 8-7s8 2.6 8 7"/></svg>';
|
|
76
|
+
const ICON_CART =
|
|
77
|
+
'<svg viewBox="0 0 24 24"><path d="M6 6h15l-1.5 9h-12z"/><circle cx="9" cy="20" r="1.4"/><circle cx="18" cy="20" r="1.4"/><path d="M6 6L5 3H2"/></svg>';
|
|
78
|
+
|
|
79
|
+
/** Inline spans for a styled text wordmark (brand / age-gate mark). */
|
|
80
|
+
function renderWordmark(parts: { text: string; className?: string }[]): string {
|
|
81
|
+
return parts
|
|
82
|
+
.map((p) => `<span${p.className ? ` class="${esc(p.className)}"` : ""}>${esc(p.text)}</span>`)
|
|
83
|
+
.join("");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Brand identity: image logo when a valid src is present, else a text wordmark. */
|
|
87
|
+
function renderBrand(brand: ChromeBrand, opts: { footer?: boolean } = {}): string {
|
|
88
|
+
const href = esc(brand.href ?? "/");
|
|
89
|
+
const attrs = [
|
|
90
|
+
'class="brand"',
|
|
91
|
+
`href="${href}"`,
|
|
92
|
+
`aria-label="${esc(brand.label)}"`,
|
|
93
|
+
!opts.footer && brand.placeholder ? 'data-placeholder="brand-wordmark"' : "",
|
|
94
|
+
]
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.join(" ");
|
|
97
|
+
const logo = brand.logo;
|
|
98
|
+
const validLogo = logo && /^(https?:|\/)/.test(logo.src);
|
|
99
|
+
const inner = validLogo
|
|
100
|
+
? `<img src="${esc(logo.src)}" alt="${esc(logo.alt)}"${
|
|
101
|
+
logo.width ? ` width="${logo.width}"` : ""
|
|
102
|
+
}${logo.height ? ` height="${logo.height}"` : ""}>`
|
|
103
|
+
: brand.wordmark?.length
|
|
104
|
+
? renderWordmark(brand.wordmark)
|
|
105
|
+
: esc(brand.label);
|
|
106
|
+
return `<a ${attrs}>${inner}</a>`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A single social icon link (single-path `iconPath`, or a text fallback). */
|
|
110
|
+
function renderSocial(social: SocialLink[]): string {
|
|
111
|
+
return social
|
|
112
|
+
.map((s) => {
|
|
113
|
+
const inner = s.iconPath
|
|
114
|
+
? `<svg viewBox="0 0 24 24"><path d="${esc(s.iconPath)}"/></svg>`
|
|
115
|
+
: esc(s.label);
|
|
116
|
+
return `<a href="${esc(s.href)}" aria-label="${esc(s.label)}" rel="noopener">${inner}</a>`;
|
|
117
|
+
})
|
|
118
|
+
.join("");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function renderAnnouncement(a: ChromeAnnouncement): string {
|
|
122
|
+
const messages = a.messages.filter((m) => m.trim().length > 0);
|
|
123
|
+
if (messages.length === 0) return "";
|
|
124
|
+
const label = esc(a.label ?? messages[0]!);
|
|
125
|
+
|
|
126
|
+
if (a.variant === "marquee") {
|
|
127
|
+
// Repeat the message stream to fill the track so the loop reads seamless.
|
|
128
|
+
const spans: string[] = [];
|
|
129
|
+
for (let i = 0; i < 8; i++) spans.push(`<span>${esc(messages[i % messages.length]!)}</span>`);
|
|
130
|
+
return (
|
|
131
|
+
`<div class="announce-marquee" aria-label="${label}">\n` +
|
|
132
|
+
` <div class="announce-marquee__track" aria-hidden="true">${spans.join("")}</div>\n` +
|
|
133
|
+
`</div>`
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (a.variant === "bar") {
|
|
138
|
+
const data = escSingle(JSON.stringify(messages));
|
|
139
|
+
const closeBtn = a.dismissible
|
|
140
|
+
? `<button type="button" class="announce-bar__close" id="announceClose" aria-label="Dismiss announcement">×</button>`
|
|
141
|
+
: "";
|
|
142
|
+
const storeKey = a.storageKey ? ` data-storage-key="${esc(a.storageKey)}"` : "";
|
|
143
|
+
const rotate = a.rotateMs && messages.length > 1 ? ` data-rotate-ms="${Math.round(a.rotateMs)}"` : "";
|
|
144
|
+
return (
|
|
145
|
+
`<div class="announce-bar" id="announceBar" role="region" aria-label="Store announcements"${storeKey}>\n` +
|
|
146
|
+
` <div class="container">\n` +
|
|
147
|
+
` <p class="announce-bar__msg" id="announceMsg" aria-live="polite" data-messages='${data}'${rotate}>${esc(messages[0]!)}</p>\n` +
|
|
148
|
+
` ${closeBtn}\n` +
|
|
149
|
+
` </div>\n` +
|
|
150
|
+
`</div>`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// "solid"
|
|
155
|
+
return `<div class="announce"><div class="container"><p>${esc(messages[0]!)}</p></div></div>`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderUtilityNav(u: ChromeUtilityNav): string {
|
|
159
|
+
const links = (u.links ?? [])
|
|
160
|
+
.map((l) => `<li><a href="${esc(l.href)}"${relFor(l)}>${esc(l.label)}</a></li>`)
|
|
161
|
+
.join("");
|
|
162
|
+
const locale = (u.locale ?? []).map((s) => `<span class="sel">${esc(s)}</span>`).join("");
|
|
163
|
+
const social = u.social?.length ? `<span class="social">${renderSocial(u.social)}</span>` : "";
|
|
164
|
+
const cue = u.memberCue
|
|
165
|
+
? `<a class="member-cue" href="${esc(u.memberCue.href)}">${esc(u.memberCue.label)}</a>`
|
|
166
|
+
: "";
|
|
167
|
+
const right = `${locale}${social}${cue}`;
|
|
168
|
+
return (
|
|
169
|
+
`<div class="utility-nav">\n` +
|
|
170
|
+
` <div class="container">\n` +
|
|
171
|
+
` <ul>${links}</ul>\n` +
|
|
172
|
+
(right ? ` <div class="utility-nav__right">${right}</div>\n` : "") +
|
|
173
|
+
` </div>\n` +
|
|
174
|
+
`</div>`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A primary-nav item — flat link, or a mega panel when it has columns/children. */
|
|
179
|
+
function renderNavItem(item: ChromeNavItem): string {
|
|
180
|
+
const columns = item.columns?.length
|
|
181
|
+
? item.columns
|
|
182
|
+
: item.children?.length
|
|
183
|
+
? [{ links: item.children }]
|
|
184
|
+
: [];
|
|
185
|
+
const hasPanel = columns.length > 0;
|
|
186
|
+
const linkClass = item.flag ? ' class="is-flag"' : "";
|
|
187
|
+
const badge = item.badge ? `<span class="new-badge">${esc(item.badge)}</span>` : "";
|
|
188
|
+
const caret = hasPanel ? `<span class="caret" aria-hidden="true">▾</span>` : "";
|
|
189
|
+
const haspopup = hasPanel ? ' aria-haspopup="true"' : "";
|
|
190
|
+
const link = `<a${linkClass} href="${esc(item.href)}"${haspopup}>${esc(item.label)}${badge}${caret}</a>`;
|
|
191
|
+
if (!hasPanel) return `<li class="nav-item">${link}</li>`;
|
|
192
|
+
|
|
193
|
+
const wide = columns.length > 1 ? " mega--wide" : "";
|
|
194
|
+
const cols = columns
|
|
195
|
+
.map((c) => {
|
|
196
|
+
const heading = c.title ? `<h4>${esc(c.title)}</h4>` : "";
|
|
197
|
+
const links = c.links.map((l) => `<a href="${esc(l.href)}"${relFor(l)}>${esc(l.label)}</a>`).join("");
|
|
198
|
+
return `<div class="mega__col">${heading}${links}</div>`;
|
|
199
|
+
})
|
|
200
|
+
.join("");
|
|
201
|
+
const mega = `<div class="mega${wide}"><div class="mega__cols">${cols}</div></div>`;
|
|
202
|
+
return `<li class="nav-item">${link}${mega}</li>`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function renderHeader(chrome: TenantChrome): string {
|
|
206
|
+
const search = chrome.search
|
|
207
|
+
? `<form class="header-search" action="${esc(chrome.search.action ?? "/search")}" method="get" role="search">\n` +
|
|
208
|
+
` <label class="sr-only" for="hdr-q">${esc(chrome.search.label ?? "Search")}</label>\n` +
|
|
209
|
+
` <input id="hdr-q" type="search" name="q" placeholder="${esc(chrome.search.placeholder ?? "Search")}" autocomplete="off">\n` +
|
|
210
|
+
` <button type="submit" aria-label="Search">${ICON_SEARCH}</button>\n` +
|
|
211
|
+
` </form>`
|
|
212
|
+
: "";
|
|
213
|
+
const memberCue = chrome.memberCue
|
|
214
|
+
? `<a class="member-cue" href="${esc(chrome.memberCue.href)}" aria-label="${esc(chrome.memberCue.label)}">${esc(chrome.memberCue.label)}</a>`
|
|
215
|
+
: "";
|
|
216
|
+
const account = chrome.accountHref
|
|
217
|
+
? `<a href="${esc(chrome.accountHref)}" aria-label="Account">${ICON_ACCOUNT}</a>`
|
|
218
|
+
: "";
|
|
219
|
+
const cart = chrome.cartHref
|
|
220
|
+
? `<a class="cart" href="${esc(chrome.cartHref)}" aria-label="Cart">${ICON_CART}<span class="count">0</span></a>`
|
|
221
|
+
: "";
|
|
222
|
+
const hamburger =
|
|
223
|
+
`<button class="hamburger" id="hamburger" aria-label="Toggle navigation menu" aria-expanded="false" aria-controls="primaryNav"><span></span><span></span><span></span></button>`;
|
|
224
|
+
const navItems = chrome.nav.map(renderNavItem).join("\n ");
|
|
225
|
+
return (
|
|
226
|
+
`<header class="site-header">\n` +
|
|
227
|
+
` <div class="container header-row">\n` +
|
|
228
|
+
` ${renderBrand(chrome.brand)}\n` +
|
|
229
|
+
(search ? ` ${search}\n` : "") +
|
|
230
|
+
` <div class="header-actions">${memberCue}${account}${cart}${hamburger}</div>\n` +
|
|
231
|
+
` </div>\n` +
|
|
232
|
+
` <nav class="primary-nav-bar" aria-label="Primary">\n` +
|
|
233
|
+
` <div class="container">\n` +
|
|
234
|
+
` <nav class="primary-nav" id="primaryNav">\n` +
|
|
235
|
+
` <ul>\n ${navItems}\n </ul>\n` +
|
|
236
|
+
` </nav>\n` +
|
|
237
|
+
` </div>\n` +
|
|
238
|
+
` </nav>\n` +
|
|
239
|
+
`</header>`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Age-gate config, embedded as JSON (unit C1). The CHROME_BEHAVIOR_JS mechanism
|
|
245
|
+
* reads this and builds the dialog itself inside a closed shadow root — the same
|
|
246
|
+
* tamper-resistance pattern components/islands/IsolatedAgeGate.tsx uses for
|
|
247
|
+
* component pages (see its header comment). `<` is escaped so an authored value
|
|
248
|
+
* can't close the tag early.
|
|
249
|
+
*/
|
|
250
|
+
function renderAgeGate(g: ChromeAgeGate): string {
|
|
251
|
+
const json = JSON.stringify(g).replace(/</g, "\\u003c");
|
|
252
|
+
return `<script type="application/json" id="tot-age-gate-config">${json}</script>`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function renderFooterNewsletter(n: NonNullable<ChromeFooter["newsletter"]>): string {
|
|
256
|
+
const attest = n.ageAttestation
|
|
257
|
+
? `\n <label class="footer-attest"><input type="checkbox" name="age_attestation" value="21plus" required> I confirm I am 21 years of age or older and agree to receive marketing emails.</label>`
|
|
258
|
+
: "";
|
|
259
|
+
const title = n.title ? `<h4>${esc(n.title)}</h4>` : "";
|
|
260
|
+
const body = n.body ? `<p>${esc(n.body)}</p>` : "";
|
|
261
|
+
return (
|
|
262
|
+
`${title}${body}` +
|
|
263
|
+
`<form class="footer-news" action="${esc(n.action ?? "/api/newsletter")}" method="post">\n` +
|
|
264
|
+
` <input type="hidden" name="source" value="${esc(n.source ?? "footer")}">\n` +
|
|
265
|
+
` <label class="sr-only" for="foot-email">Email</label>\n` +
|
|
266
|
+
` <div class="footer-news__row">\n` +
|
|
267
|
+
` <input id="foot-email" type="email" name="email" placeholder="${esc(n.placeholder ?? "Email address")}" required>\n` +
|
|
268
|
+
` <button type="submit">Subscribe</button>\n` +
|
|
269
|
+
` </div>${attest}\n` +
|
|
270
|
+
` </form>`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function renderFooter(footer: ChromeFooter): string {
|
|
275
|
+
const brand = footer.brand;
|
|
276
|
+
const brandBlock =
|
|
277
|
+
brand || footer.newsletter
|
|
278
|
+
? (() => {
|
|
279
|
+
const logo = brand?.logo && /^(https?:|\/)/.test(brand.logo.src)
|
|
280
|
+
? `<img src="${esc(brand.logo.src)}" alt="${esc(brand.logo.alt)}"${
|
|
281
|
+
brand.logo.width ? ` width="${brand.logo.width}"` : ""
|
|
282
|
+
}${brand.logo.height ? ` height="${brand.logo.height}"` : ""}>`
|
|
283
|
+
: brand?.wordmark?.length
|
|
284
|
+
? `<span class="brand">${renderWordmark(brand.wordmark)}</span>`
|
|
285
|
+
: "";
|
|
286
|
+
const text = brand?.text ? `<p>${withBreaks(brand.text)}</p>` : "";
|
|
287
|
+
const news = footer.newsletter ? renderFooterNewsletter(footer.newsletter) : "";
|
|
288
|
+
return ` <div class="footer-brand">${logo}${text}${news}</div>\n`;
|
|
289
|
+
})()
|
|
290
|
+
: "";
|
|
291
|
+
const columns = footer.columns
|
|
292
|
+
.map((c) => {
|
|
293
|
+
const links = c.links
|
|
294
|
+
.map((l) => `<li><a href="${esc(l.href)}"${relFor(l)}>${esc(l.label)}</a></li>`)
|
|
295
|
+
.join("");
|
|
296
|
+
return ` <div class="footer-col">\n <h4>${esc(c.title)}</h4>\n <ul>${links}</ul>\n </div>`;
|
|
297
|
+
})
|
|
298
|
+
.join("\n");
|
|
299
|
+
const trust = footer.trustMarks?.length
|
|
300
|
+
? `\n <div class="trust-marks" aria-label="Store commitments">${footer.trustMarks
|
|
301
|
+
.map((t) => `<span><svg viewBox="0 0 24 24">${t.icon}</svg>${esc(t.label)}</span>`)
|
|
302
|
+
.join("")}</div>`
|
|
303
|
+
: "";
|
|
304
|
+
const social = footer.social?.length
|
|
305
|
+
? `\n <div class="footer-social">${renderSocial(footer.social)}</div>`
|
|
306
|
+
: "";
|
|
307
|
+
const payments = footer.payments?.length
|
|
308
|
+
? `\n <div class="pay-badges" aria-label="Accepted payments">${footer.payments
|
|
309
|
+
.map((p) => `<span>${esc(p)}</span>`)
|
|
310
|
+
.join("")}</div>`
|
|
311
|
+
: "";
|
|
312
|
+
const warning = footer.warning
|
|
313
|
+
? `\n <div class="footer-warning"><p><strong>WARNING:</strong> ${esc(footer.warning)}</p></div>`
|
|
314
|
+
: "";
|
|
315
|
+
const bottom = footer.bottom
|
|
316
|
+
? `\n <div class="footer-bottom">${footer.bottom.copyright ? `<p>${esc(footer.bottom.copyright)}</p>` : ""}${
|
|
317
|
+
footer.bottom.note ? `<p>${esc(footer.bottom.note)}</p>` : ""
|
|
318
|
+
}</div>`
|
|
319
|
+
: "";
|
|
320
|
+
return (
|
|
321
|
+
`<footer class="site-footer">\n` +
|
|
322
|
+
` <div class="container">\n` +
|
|
323
|
+
` <div class="footer-grid">\n${brandBlock}${columns}\n </div>` +
|
|
324
|
+
`${trust}${social}${payments}${warning}${bottom}\n` +
|
|
325
|
+
` </div>\n` +
|
|
326
|
+
`</footer>`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The ONE shared, nonce-safe chrome behavior script. Inlined into the wrapper (the
|
|
332
|
+
* rawMarketingHtml pipeline stamps it with the page nonce, so a strict CSP admits it)
|
|
333
|
+
* — a single canonical source, never copy-pasted per tenant. Each block is guarded on
|
|
334
|
+
* the presence of its elements, so a wrapper that omits the announce bar / age gate
|
|
335
|
+
* runs the remaining behavior harmlessly. Progressive enhancement: the markup already
|
|
336
|
+
* works without it (nav is visible, gate defaults open only when JS enables it).
|
|
337
|
+
*/
|
|
338
|
+
export const CHROME_BEHAVIOR_JS = `(function () {
|
|
339
|
+
// Mobile nav drawer
|
|
340
|
+
var burger = document.getElementById('hamburger');
|
|
341
|
+
var nav = document.getElementById('primaryNav');
|
|
342
|
+
if (burger && nav) burger.addEventListener('click', function () {
|
|
343
|
+
var open = nav.classList.toggle('open');
|
|
344
|
+
burger.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Announcement bar: dismiss (remembered) + rotate messages
|
|
348
|
+
var bar = document.getElementById('announceBar');
|
|
349
|
+
if (bar) {
|
|
350
|
+
var key = bar.getAttribute('data-storage-key') || 'tot_announce_dismissed';
|
|
351
|
+
var closeBtn = document.getElementById('announceClose');
|
|
352
|
+
var msg = document.getElementById('announceMsg');
|
|
353
|
+
try { if (localStorage.getItem(key) === '1') bar.hidden = true; } catch (e) {}
|
|
354
|
+
if (closeBtn) closeBtn.addEventListener('click', function () {
|
|
355
|
+
bar.hidden = true;
|
|
356
|
+
try { localStorage.setItem(key, '1'); } catch (e) {}
|
|
357
|
+
});
|
|
358
|
+
if (msg && !bar.hidden) {
|
|
359
|
+
var rotateMs = parseInt(msg.getAttribute('data-rotate-ms') || '0', 10);
|
|
360
|
+
var list; try { list = JSON.parse(msg.getAttribute('data-messages') || '[]'); } catch (e) { list = []; }
|
|
361
|
+
if (rotateMs > 0 && list.length > 1) {
|
|
362
|
+
var i = 0;
|
|
363
|
+
setInterval(function () { i = (i + 1) % list.length; msg.textContent = list[i]; }, rotateMs);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// 21+ age gate — shadow-isolated (unit C1). Only the host <div> lives in light
|
|
369
|
+
// DOM (data-tot-compliance, the same marker components/islands/IsolatedAgeGate.tsx
|
|
370
|
+
// uses); its contents sit behind a closed shadow root, so tenant CSS/JS can't
|
|
371
|
+
// select, restyle, or hide the dialog. A MutationObserver re-asserts the host the
|
|
372
|
+
// same way that component does. Config comes from the JSON payload rawChrome.ts
|
|
373
|
+
// renders (ChromeAgeGate, unchanged shape) so authoring stays the same.
|
|
374
|
+
var gateConfigEl = document.getElementById('tot-age-gate-config');
|
|
375
|
+
if (gateConfigEl && !document.querySelector('[data-tot-compliance]')) {
|
|
376
|
+
var gcfg; try { gcfg = JSON.parse(gateConfigEl.textContent || 'null'); } catch (e) { gcfg = null; }
|
|
377
|
+
if (gcfg) {
|
|
378
|
+
var gescape = function (s) {
|
|
379
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
380
|
+
};
|
|
381
|
+
var gkey = gcfg.storageKey || 'tot:age-affirmed';
|
|
382
|
+
|
|
383
|
+
var host = document.createElement('div');
|
|
384
|
+
host.setAttribute('data-tot-compliance', '');
|
|
385
|
+
var shadow = host.attachShadow({ mode: 'closed' });
|
|
386
|
+
document.querySelectorAll('link[rel="stylesheet"]').forEach(function (l) {
|
|
387
|
+
shadow.appendChild(l.cloneNode(true));
|
|
388
|
+
});
|
|
389
|
+
var mount = document.createElement('div');
|
|
390
|
+
shadow.appendChild(mount);
|
|
391
|
+
|
|
392
|
+
var gmark = gcfg.mark && gcfg.mark.length
|
|
393
|
+
? '<p class="age-gate__mark">' + gcfg.mark.map(function (p) {
|
|
394
|
+
return '<span' + (p.className ? ' class="' + gescape(p.className) + '"' : '') + '>' + gescape(p.text) + '</span>';
|
|
395
|
+
}).join('') + '</p>'
|
|
396
|
+
: '';
|
|
397
|
+
var gtitle = gescape(gcfg.title || ('Are you ' + gcfg.minAge + ' or older?'));
|
|
398
|
+
var glede = gcfg.lede ? '<p class="age-gate__lede" id="ageGateBody">' + gescape(gcfg.lede) + '</p>' : '';
|
|
399
|
+
var gfine = gcfg.fineText ? '<p class="age-gate__fine">' + gescape(gcfg.fineText) + '</p>' : '';
|
|
400
|
+
var gdeny = gescape(gcfg.denyText || ('You must be ' + gcfg.minAge + ' or older to enter this site.'));
|
|
401
|
+
var gdescribedby = gcfg.lede ? ' aria-describedby="ageGateBody"' : '';
|
|
402
|
+
|
|
403
|
+
mount.innerHTML =
|
|
404
|
+
'<div class="age-gate" id="ageGate" role="dialog" aria-modal="true" aria-labelledby="ageGateTitle"' + gdescribedby + '>' +
|
|
405
|
+
'<div class="age-gate__panel">' + gmark +
|
|
406
|
+
'<h2 id="ageGateTitle">' + gtitle + '</h2>' + glede +
|
|
407
|
+
'<div class="age-gate__actions">' +
|
|
408
|
+
'<button type="button" class="btn btn-primary age-gate__enter" id="ageYes">' + gescape(gcfg.yesLabel || ('Yes, I am ' + gcfg.minAge + '+')) + '</button>' +
|
|
409
|
+
'<button type="button" class="btn btn-outline age-gate__enter" id="ageNo">' + gescape(gcfg.noLabel || ('No, I am under ' + gcfg.minAge)) + '</button>' +
|
|
410
|
+
'</div>' +
|
|
411
|
+
'<p class="age-gate__deny" id="ageDeny">' + gdeny + '</p>' + gfine +
|
|
412
|
+
'</div></div>';
|
|
413
|
+
|
|
414
|
+
var gate = mount.querySelector('#ageGate');
|
|
415
|
+
var gok = false;
|
|
416
|
+
try { gok = localStorage.getItem(gkey) === 'yes'; } catch (e) {}
|
|
417
|
+
if (!gok) {
|
|
418
|
+
gate.classList.add('is-open');
|
|
419
|
+
document.documentElement.style.overflow = 'hidden';
|
|
420
|
+
}
|
|
421
|
+
var gyes = mount.querySelector('#ageYes');
|
|
422
|
+
var gno = mount.querySelector('#ageNo');
|
|
423
|
+
if (gyes) gyes.addEventListener('click', function () {
|
|
424
|
+
try { localStorage.setItem(gkey, 'yes'); } catch (e) {}
|
|
425
|
+
gate.classList.remove('is-open');
|
|
426
|
+
document.documentElement.style.overflow = '';
|
|
427
|
+
});
|
|
428
|
+
if (gno) gno.addEventListener('click', function () { gate.classList.add('is-denied'); });
|
|
429
|
+
|
|
430
|
+
document.body.appendChild(host);
|
|
431
|
+
|
|
432
|
+
var gensure = function () {
|
|
433
|
+
if (!host.isConnected) document.body.appendChild(host);
|
|
434
|
+
if (host.style.display === 'none') host.style.display = '';
|
|
435
|
+
if (host.style.visibility === 'hidden') host.style.visibility = '';
|
|
436
|
+
if (host.hasAttribute('hidden')) host.removeAttribute('hidden');
|
|
437
|
+
};
|
|
438
|
+
var gobserver = new MutationObserver(gensure);
|
|
439
|
+
gobserver.observe(document.body, { childList: true });
|
|
440
|
+
gobserver.observe(host, { attributes: true, attributeFilter: ['style', 'class', 'hidden'] });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
})();`;
|
|
444
|
+
|
|
445
|
+
/** The platform-shipped chrome stylesheet every rendered wrapper links (unit F2). */
|
|
446
|
+
export const SHARED_CHROME_STYLESHEET = "/shared/commerce-chrome.css";
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Render the canonical chrome wrapper document for a tenant, from its config.
|
|
450
|
+
*
|
|
451
|
+
* The returned string is a full `<!doctype html>` document containing the two splice
|
|
452
|
+
* markers (`<!--PAGE_HEAD-->`, `<!--PAGE_BODY-->`); pass it to rawHtmlResponse as the
|
|
453
|
+
* `chromeWrapper` so a body-only fragment is spliced in and the whole thing is
|
|
454
|
+
* nonce-stamped + base-path-prefixed. Optional sections (age gate, announcement,
|
|
455
|
+
* utility nav, member cue, footer extras) render only when their config is present.
|
|
456
|
+
*/
|
|
457
|
+
export function renderRawChrome(chrome: TenantChrome): string {
|
|
458
|
+
const lang = esc(chrome.lang ?? "en");
|
|
459
|
+
const head = chrome.head ?? {};
|
|
460
|
+
const headLinks = [
|
|
461
|
+
head.themeColor ? `<meta name="theme-color" content="${esc(head.themeColor)}">` : "",
|
|
462
|
+
head.favicon ? `<link rel="icon" type="image/png" href="${esc(head.favicon)}">` : "",
|
|
463
|
+
...(head.themeStylesheets ?? []).map((h) => `<link rel="stylesheet" href="${esc(h)}">`),
|
|
464
|
+
`<link rel="stylesheet" href="${SHARED_CHROME_STYLESHEET}">`,
|
|
465
|
+
]
|
|
466
|
+
.filter(Boolean)
|
|
467
|
+
.join("\n");
|
|
468
|
+
|
|
469
|
+
const skipHref = esc(chrome.skipLink?.href ?? "#main");
|
|
470
|
+
const skipLabel = esc(chrome.skipLink?.label ?? "Skip to main content");
|
|
471
|
+
|
|
472
|
+
const sections = [
|
|
473
|
+
chrome.warningBar ? `<div class="topbar-warning"><p>${esc(chrome.warningBar)}</p></div>` : "",
|
|
474
|
+
chrome.announcement ? renderAnnouncement(chrome.announcement) : "",
|
|
475
|
+
chrome.utilityNav ? renderUtilityNav(chrome.utilityNav) : "",
|
|
476
|
+
renderHeader(chrome),
|
|
477
|
+
].filter(Boolean);
|
|
478
|
+
|
|
479
|
+
const trailing = [
|
|
480
|
+
chrome.complianceStrip
|
|
481
|
+
? `<div class="compliance-strip"><div class="container"><p>${esc(chrome.complianceStrip)}</p></div></div>`
|
|
482
|
+
: "",
|
|
483
|
+
renderFooter(chrome.footer),
|
|
484
|
+
].filter(Boolean);
|
|
485
|
+
|
|
486
|
+
return (
|
|
487
|
+
`<!DOCTYPE html>\n` +
|
|
488
|
+
`<html lang="${lang}">\n` +
|
|
489
|
+
`<head>\n` +
|
|
490
|
+
`<meta charset="UTF-8">\n` +
|
|
491
|
+
`<meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
|
|
492
|
+
`${headLinks}\n` +
|
|
493
|
+
`${CHROME_HEAD_MARKER}\n` +
|
|
494
|
+
`</head>\n` +
|
|
495
|
+
`<body>\n` +
|
|
496
|
+
`<a class="skip-link" href="${skipHref}">${skipLabel}</a>\n\n` +
|
|
497
|
+
`${sections.join("\n\n")}\n\n` +
|
|
498
|
+
`${CHROME_BODY_MARKER}\n\n` +
|
|
499
|
+
`${trailing.join("\n\n")}\n\n` +
|
|
500
|
+
(chrome.ageGate ? `${renderAgeGate(chrome.ageGate)}\n` : "") +
|
|
501
|
+
`<script>${CHROME_BEHAVIOR_JS}</script>\n` +
|
|
502
|
+
`</body>\n` +
|
|
503
|
+
`</html>\n`
|
|
504
|
+
);
|
|
505
|
+
}
|