@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.
Files changed (310) hide show
  1. package/LICENSE +58 -0
  2. package/README.md +42 -0
  3. package/apps/storefront/astro.config.mjs +66 -0
  4. package/apps/storefront/dev-plugins/dev-loop-state.mjs +147 -0
  5. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +238 -0
  6. package/apps/storefront/env.d.ts +162 -0
  7. package/apps/storefront/migrations/0000_baseline.sql +54 -0
  8. package/apps/storefront/migrations/0001_products_fts.sql +9 -0
  9. package/apps/storefront/migrations/README.md +54 -0
  10. package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
  11. package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
  12. package/apps/storefront/migrations/meta/_journal.json +20 -0
  13. package/apps/storefront/package.json +42 -0
  14. package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
  15. package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
  16. package/apps/storefront/public/brand/northwind-og.svg +13 -0
  17. package/apps/storefront/public/favicon.svg +5 -0
  18. package/apps/storefront/public/js/dashboard-team.js +147 -0
  19. package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
  20. package/apps/storefront/public/shared/commerce-chrome.css +369 -0
  21. package/apps/storefront/src/components/Badge.astro +34 -0
  22. package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
  23. package/apps/storefront/src/components/CollectionCard.astro +56 -0
  24. package/apps/storefront/src/components/EmptyState.astro +55 -0
  25. package/apps/storefront/src/components/Img.astro +68 -0
  26. package/apps/storefront/src/components/ProductCard.astro +228 -0
  27. package/apps/storefront/src/components/Section.astro +39 -0
  28. package/apps/storefront/src/components/Seo.astro +70 -0
  29. package/apps/storefront/src/components/WidgetFrame.astro +34 -0
  30. package/apps/storefront/src/components/admin/AdminAIWorkflowsTab.astro +531 -0
  31. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +597 -0
  32. package/apps/storefront/src/components/admin/AdminFoxyCartSettingsPanel.astro +227 -0
  33. package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +465 -0
  34. package/apps/storefront/src/components/admin/AdminProductsTab.astro +901 -0
  35. package/apps/storefront/src/components/admin/AdminReportingTab.astro +435 -0
  36. package/apps/storefront/src/components/admin/AdminSettingsTab.astro +782 -0
  37. package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +1223 -0
  38. package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
  39. package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
  40. package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
  41. package/apps/storefront/src/components/chrome/NavDropdown.astro +201 -0
  42. package/apps/storefront/src/components/chrome/SiteFooter.astro +201 -0
  43. package/apps/storefront/src/components/chrome/SiteHeader.astro +234 -0
  44. package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
  45. package/apps/storefront/src/components/commerce/ArticleCard.astro +242 -0
  46. package/apps/storefront/src/components/commerce/BrandGrid.astro +175 -0
  47. package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
  48. package/apps/storefront/src/components/commerce/FoxySidecartStyles.astro +61 -0
  49. package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
  50. package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
  51. package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
  52. package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
  53. package/apps/storefront/src/components/commerce/ReviewList.astro +54 -0
  54. package/apps/storefront/src/components/commerce/ReviewSummary.astro +44 -0
  55. package/apps/storefront/src/components/commerce/TagFilterCloud.astro +149 -0
  56. package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +37 -0
  57. package/apps/storefront/src/components/compliance/ComplianceNotice.astro +99 -0
  58. package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +45 -0
  59. package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
  60. package/apps/storefront/src/components/compliance/PactActNotice.astro +35 -0
  61. package/apps/storefront/src/components/compliance/Prop65Warning.astro +80 -0
  62. package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +38 -0
  63. package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +35 -0
  64. package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +53 -0
  65. package/apps/storefront/src/components/home/Hero.astro +164 -0
  66. package/apps/storefront/src/components/islands/AgeGate.tsx +109 -0
  67. package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
  68. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
  69. package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
  70. package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
  71. package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
  72. package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
  73. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
  74. package/apps/storefront/src/components/islands/VariantSelector.tsx +375 -0
  75. package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
  76. package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
  77. package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
  78. package/apps/storefront/src/components/marketing/Faq.astro +37 -0
  79. package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
  80. package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
  81. package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
  82. package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
  83. package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
  84. package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
  85. package/apps/storefront/src/components/marketing/Steps.astro +36 -0
  86. package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
  87. package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
  88. package/apps/storefront/src/components/membership/IllustrativeLabel.astro +56 -0
  89. package/apps/storefront/src/components/membership/TierCards.astro +164 -0
  90. package/apps/storefront/src/components/membership/TierComparisonTable.astro +148 -0
  91. package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
  92. package/apps/storefront/src/components/plp/FacetSidebar.astro +199 -0
  93. package/apps/storefront/src/components/plp/Pagination.astro +99 -0
  94. package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
  95. package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +95 -0
  96. package/apps/storefront/src/components/subscription/HowItWorksSteps.astro +67 -0
  97. package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +67 -0
  98. package/apps/storefront/src/components/subscription/SavingsExplainer.astro +101 -0
  99. package/apps/storefront/src/config/adminTargetTenant.ts +28 -0
  100. package/apps/storefront/src/config/devTenantSeed.ts +78 -0
  101. package/apps/storefront/src/config/devTenants.ts +16 -0
  102. package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
  103. package/apps/storefront/src/config/resolver.ts +102 -0
  104. package/apps/storefront/src/config/tenants.ts +87 -0
  105. package/apps/storefront/src/layouts/Layout.astro +368 -0
  106. package/apps/storefront/src/lib/admin/README.md +122 -0
  107. package/apps/storefront/src/lib/admin/ai/approvalMode.ts +21 -0
  108. package/apps/storefront/src/lib/admin/ai/askEnvelope.ts +48 -0
  109. package/apps/storefront/src/lib/admin/ai/audit.ts +14 -0
  110. package/apps/storefront/src/lib/admin/ai/index.ts +6 -0
  111. package/apps/storefront/src/lib/admin/ai/operationalRuns.ts +155 -0
  112. package/apps/storefront/src/lib/admin/ai/recommendation.ts +20 -0
  113. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +172 -0
  114. package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +65 -0
  115. package/apps/storefront/src/lib/admin/envelope.ts +10 -0
  116. package/apps/storefront/src/lib/admin/evidence/evidenceReference.ts +32 -0
  117. package/apps/storefront/src/lib/admin/memoizeByEnvelope.ts +27 -0
  118. package/apps/storefront/src/lib/admin/orderClient.ts +146 -0
  119. package/apps/storefront/src/lib/admin/seed.ts +54 -0
  120. package/apps/storefront/src/lib/admin/services/mock/aiWorkflows.ts +59 -0
  121. package/apps/storefront/src/lib/admin/services/mock/customers.ts +86 -0
  122. package/apps/storefront/src/lib/admin/services/mock/evidence.ts +99 -0
  123. package/apps/storefront/src/lib/admin/services/mock/foxyCartSettings.ts +78 -0
  124. package/apps/storefront/src/lib/admin/services/mock/fulfillment.ts +62 -0
  125. package/apps/storefront/src/lib/admin/services/mock/index.ts +12 -0
  126. package/apps/storefront/src/lib/admin/services/mock/orders.ts +184 -0
  127. package/apps/storefront/src/lib/admin/services/mock/products.ts +105 -0
  128. package/apps/storefront/src/lib/admin/services/mock/reporting.ts +51 -0
  129. package/apps/storefront/src/lib/admin/services/mock/settings.ts +70 -0
  130. package/apps/storefront/src/lib/admin/services/mock/tenants.ts +29 -0
  131. package/apps/storefront/src/lib/admin/services/orders.ts +300 -0
  132. package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +67 -0
  133. package/apps/storefront/src/lib/admin/viewmodels/customers.ts +56 -0
  134. package/apps/storefront/src/lib/admin/viewmodels/fulfillment.ts +49 -0
  135. package/apps/storefront/src/lib/admin/viewmodels/orders.ts +92 -0
  136. package/apps/storefront/src/lib/admin/viewmodels/products.ts +74 -0
  137. package/apps/storefront/src/lib/admin/viewmodels/reporting.ts +34 -0
  138. package/apps/storefront/src/lib/admin/viewmodels/settings.ts +64 -0
  139. package/apps/storefront/src/lib/adminTenantEnvelope.ts +90 -0
  140. package/apps/storefront/src/lib/analytics/index.ts +47 -0
  141. package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
  142. package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
  143. package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
  144. package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
  145. package/apps/storefront/src/lib/auth/loginGate.ts +119 -0
  146. package/apps/storefront/src/lib/auth/route.ts +146 -0
  147. package/apps/storefront/src/lib/auth/session.ts +203 -0
  148. package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
  149. package/apps/storefront/src/lib/basePath.ts +41 -0
  150. package/apps/storefront/src/lib/cfImage.ts +55 -0
  151. package/apps/storefront/src/lib/chrome/model.ts +244 -0
  152. package/apps/storefront/src/lib/chrome/parseConfig.ts +34 -0
  153. package/apps/storefront/src/lib/colors.ts +97 -0
  154. package/apps/storefront/src/lib/commerce/foxyCartSettings.ts +253 -0
  155. package/apps/storefront/src/lib/content-edit/applyDiff.ts +91 -0
  156. package/apps/storefront/src/lib/content-edit/client.ts +230 -0
  157. package/apps/storefront/src/lib/content-edit/index.ts +14 -0
  158. package/apps/storefront/src/lib/content-edit/route.ts +25 -0
  159. package/apps/storefront/src/lib/content-edit/types.ts +19 -0
  160. package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
  161. package/apps/storefront/src/lib/d1/catalog.ts +366 -0
  162. package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
  163. package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
  164. package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
  165. package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
  166. package/apps/storefront/src/lib/demoGate.ts +113 -0
  167. package/apps/storefront/src/lib/dev/activityIngestToken.ts +78 -0
  168. package/apps/storefront/src/lib/dev/activityStore.ts +72 -0
  169. package/apps/storefront/src/lib/dev/ai-widget-client.js +308 -0
  170. package/apps/storefront/src/lib/dev/apiBase.ts +14 -0
  171. package/apps/storefront/src/lib/dev/cliSignInCode.ts +155 -0
  172. package/apps/storefront/src/lib/dev/devDraft.ts +133 -0
  173. package/apps/storefront/src/lib/dev/hostedCockpit.ts +169 -0
  174. package/apps/storefront/src/lib/dev/livePreview.ts +77 -0
  175. package/apps/storefront/src/lib/dev/runtimeStore.ts +120 -0
  176. package/apps/storefront/src/lib/dev/storeStatus.ts +142 -0
  177. package/apps/storefront/src/lib/diag/redact.ts +20 -0
  178. package/apps/storefront/src/lib/edgeCache.ts +128 -0
  179. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
  180. package/apps/storefront/src/lib/env.ts +60 -0
  181. package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
  182. package/apps/storefront/src/lib/format.ts +38 -0
  183. package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
  184. package/apps/storefront/src/lib/imageAttrs.ts +55 -0
  185. package/apps/storefront/src/lib/jsonld.ts +166 -0
  186. package/apps/storefront/src/lib/maintenance.ts +71 -0
  187. package/apps/storefront/src/lib/membership/model.ts +97 -0
  188. package/apps/storefront/src/lib/memoryKv.ts +32 -0
  189. package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
  190. package/apps/storefront/src/lib/placeholder.ts +99 -0
  191. package/apps/storefront/src/lib/previewReload.ts +125 -0
  192. package/apps/storefront/src/lib/priceBuckets.ts +33 -0
  193. package/apps/storefront/src/lib/rawChrome.ts +505 -0
  194. package/apps/storefront/src/lib/rawMarketingHtml.ts +136 -0
  195. package/apps/storefront/src/lib/search/index.ts +74 -0
  196. package/apps/storefront/src/lib/sections.ts +129 -0
  197. package/apps/storefront/src/lib/securityHeaders.ts +31 -0
  198. package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
  199. package/apps/storefront/src/lib/storyblok/provider.ts +607 -0
  200. package/apps/storefront/src/lib/subscription/model.ts +68 -0
  201. package/apps/storefront/src/lib/tenantRouting.ts +91 -0
  202. package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
  203. package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
  204. package/apps/storefront/src/lib/tot/query.ts +249 -0
  205. package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
  206. package/apps/storefront/src/lib/url.ts +142 -0
  207. package/apps/storefront/src/lib/wishlist.ts +63 -0
  208. package/apps/storefront/src/middleware/index.ts +455 -0
  209. package/apps/storefront/src/pages/404.astro +61 -0
  210. package/apps/storefront/src/pages/[...slug].astro +90 -0
  211. package/apps/storefront/src/pages/admin.astro +1675 -0
  212. package/apps/storefront/src/pages/api/admin/orders/[id].ts +53 -0
  213. package/apps/storefront/src/pages/api/admin/orders.ts +71 -0
  214. package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
  215. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
  216. package/apps/storefront/src/pages/api/auth/send.ts +85 -0
  217. package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
  218. package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
  219. package/apps/storefront/src/pages/api/checkout-style.css.ts +43 -0
  220. package/apps/storefront/src/pages/api/cli/policy.ts +70 -0
  221. package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +164 -0
  222. package/apps/storefront/src/pages/api/csp-report.ts +25 -0
  223. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
  224. package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
  225. package/apps/storefront/src/pages/api/dev/activity.ts +124 -0
  226. package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +73 -0
  227. package/apps/storefront/src/pages/api/dev/diff.ts +68 -0
  228. package/apps/storefront/src/pages/api/dev/file.ts +57 -0
  229. package/apps/storefront/src/pages/api/dev/locate.ts +125 -0
  230. package/apps/storefront/src/pages/api/dev/publish.ts +128 -0
  231. package/apps/storefront/src/pages/api/dev/tree.ts +70 -0
  232. package/apps/storefront/src/pages/api/dev/write.ts +116 -0
  233. package/apps/storefront/src/pages/api/newsletter.ts +104 -0
  234. package/apps/storefront/src/pages/api/request-access.ts +172 -0
  235. package/apps/storefront/src/pages/auth/login.astro +149 -0
  236. package/apps/storefront/src/pages/auth/magic.astro +105 -0
  237. package/apps/storefront/src/pages/capabilities.astro +292 -0
  238. package/apps/storefront/src/pages/collections/[handle].astro +177 -0
  239. package/apps/storefront/src/pages/collections/index.astro +59 -0
  240. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
  241. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
  242. package/apps/storefront/src/pages/dashboard/index.astro +160 -0
  243. package/apps/storefront/src/pages/index.astro +290 -0
  244. package/apps/storefront/src/pages/llms.txt.ts +92 -0
  245. package/apps/storefront/src/pages/products/[handle].astro +402 -0
  246. package/apps/storefront/src/pages/robots.txt.ts +24 -0
  247. package/apps/storefront/src/pages/saved.astro +35 -0
  248. package/apps/storefront/src/pages/search.astro +150 -0
  249. package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
  250. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +1280 -0
  251. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +229 -0
  252. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +144 -0
  253. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +116 -0
  254. package/apps/storefront/src/pages/style-guide/index.astro +98 -0
  255. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
  256. package/apps/storefront/src/styles/global.css +292 -0
  257. package/apps/storefront/src/themes/reference.ts +68 -0
  258. package/apps/storefront/src/themes/schema.ts +111 -0
  259. package/apps/storefront/src/themes/tenants/index.ts +51 -0
  260. package/apps/storefront/tsconfig.json +16 -0
  261. package/apps/storefront/vitest.config.ts +22 -0
  262. package/package.json +26 -0
  263. package/packages/public-runtime/LICENSE +58 -0
  264. package/packages/public-runtime/package.json +22 -0
  265. package/packages/public-runtime/src/binary-path.ts +68 -0
  266. package/packages/public-runtime/src/capabilities.ts +89 -0
  267. package/packages/public-runtime/src/catalog-api.ts +81 -0
  268. package/packages/public-runtime/src/catalog-d1.ts +192 -0
  269. package/packages/public-runtime/src/checkout-style-publish.ts +200 -0
  270. package/packages/public-runtime/src/checkout-style.ts +375 -0
  271. package/packages/public-runtime/src/csp.ts +109 -0
  272. package/packages/public-runtime/src/customization-preview.ts +197 -0
  273. package/packages/public-runtime/src/customization-reconcile.ts +63 -0
  274. package/packages/public-runtime/src/customization-runtime.ts +157 -0
  275. package/packages/public-runtime/src/customization-scripts.ts +64 -0
  276. package/packages/public-runtime/src/customization-versioning.ts +95 -0
  277. package/packages/public-runtime/src/foxy-orders.ts +136 -0
  278. package/packages/public-runtime/src/foxy.ts +196 -0
  279. package/packages/public-runtime/src/hot-standby-readiness.ts +140 -0
  280. package/packages/public-runtime/src/index.ts +34 -0
  281. package/packages/public-runtime/src/product.ts +159 -0
  282. package/packages/public-runtime/src/search.ts +45 -0
  283. package/packages/public-runtime/src/tenant-assets.ts +444 -0
  284. package/packages/public-runtime/src/tenant.ts +384 -0
  285. package/packages/public-runtime/tsconfig.json +8 -0
  286. package/packages/public-runtime/vitest.config.ts +8 -0
  287. package/pnpm-workspace.yaml +7 -0
  288. package/scripts/build/copy-tenant-assets.mjs +69 -0
  289. package/scripts/dev/ai-edit.mjs +183 -0
  290. package/scripts/dev/checkout-watch.mjs +116 -0
  291. package/scripts/dev/file-browser.mjs +159 -0
  292. package/scripts/dev/file-writer.mjs +141 -0
  293. package/scripts/dev/git-status.mjs +171 -0
  294. package/scripts/dev/locate-handler.mjs +163 -0
  295. package/scripts/dev/locate.mjs +112 -0
  296. package/scripts/dev/port-check.mjs +48 -0
  297. package/scripts/dev/safe-path.mjs +109 -0
  298. package/scripts/dev/shot.mjs +46 -0
  299. package/scripts/dev/tenant-source-root.mjs +76 -0
  300. package/scripts/dev/unified-diff.mjs +81 -0
  301. package/scripts/tot-dev.mjs +376 -0
  302. package/tenants/README.md +14 -0
  303. package/tenants/home/content/chrome.json +7 -0
  304. package/tenants/home/content/home.html +58 -0
  305. package/tenants/home/content/home.json +11 -0
  306. package/tenants/home/public/js/storefront.js +210 -0
  307. package/tenants/home/public/logo-wordmark.png +0 -0
  308. package/tenants/home/public/pages/storefront.css +212 -0
  309. package/tenants/home/theme.json +6 -0
  310. 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, "&amp;")
44
+ .replace(/</g, "&lt;")
45
+ .replace(/>/g, "&gt;")
46
+ .replace(/"/g, "&quot;")
47
+ .replace(/'/g, "&#39;");
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, "&amp;")
54
+ .replace(/</g, "&lt;")
55
+ .replace(/>/g, "&gt;")
56
+ .replace(/'/g, "&#39;");
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">&times;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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
+ }