@tokenoftrust/storefront-runner 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (337) hide show
  1. package/LICENSE +58 -0
  2. package/README.md +40 -0
  3. package/apps/storefront/.dev.vars.example +41 -0
  4. package/apps/storefront/.env.example +24 -0
  5. package/apps/storefront/astro.config.mjs +245 -0
  6. package/apps/storefront/env.d.ts +155 -0
  7. package/apps/storefront/lib/ensure-preview-reconcile-secret.sh +31 -0
  8. package/apps/storefront/lib/resolve-cf-token.sh +48 -0
  9. package/apps/storefront/lib/resolve-tot-credentials.sh +69 -0
  10. package/apps/storefront/migrations/0000_baseline.sql +54 -0
  11. package/apps/storefront/migrations/0001_products_fts.sql +9 -0
  12. package/apps/storefront/migrations/README.md +54 -0
  13. package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
  14. package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
  15. package/apps/storefront/migrations/meta/_journal.json +20 -0
  16. package/apps/storefront/package.json +41 -0
  17. package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
  18. package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
  19. package/apps/storefront/public/brand/northwind-og.svg +13 -0
  20. package/apps/storefront/public/favicon.svg +5 -0
  21. package/apps/storefront/public/js/dashboard-team.js +147 -0
  22. package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
  23. package/apps/storefront/src/components/Badge.astro +34 -0
  24. package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
  25. package/apps/storefront/src/components/CollectionCard.astro +56 -0
  26. package/apps/storefront/src/components/EmptyState.astro +35 -0
  27. package/apps/storefront/src/components/Img.astro +68 -0
  28. package/apps/storefront/src/components/ProductCard.astro +228 -0
  29. package/apps/storefront/src/components/Section.astro +39 -0
  30. package/apps/storefront/src/components/Seo.astro +70 -0
  31. package/apps/storefront/src/components/WidgetFrame.astro +34 -0
  32. package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
  33. package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
  34. package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
  35. package/apps/storefront/src/components/chrome/NavDropdown.astro +116 -0
  36. package/apps/storefront/src/components/chrome/SiteFooter.astro +181 -0
  37. package/apps/storefront/src/components/chrome/SiteHeader.astro +181 -0
  38. package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
  39. package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
  40. package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
  41. package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
  42. package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
  43. package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
  44. package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
  45. package/apps/storefront/src/components/home/Hero.astro +164 -0
  46. package/apps/storefront/src/components/islands/AgeGate.tsx +105 -0
  47. package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
  48. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
  49. package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
  50. package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
  51. package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
  52. package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
  53. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
  54. package/apps/storefront/src/components/islands/VariantSelector.tsx +378 -0
  55. package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
  56. package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
  57. package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
  58. package/apps/storefront/src/components/marketing/Faq.astro +37 -0
  59. package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
  60. package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
  61. package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
  62. package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
  63. package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
  64. package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
  65. package/apps/storefront/src/components/marketing/Steps.astro +36 -0
  66. package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
  67. package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
  68. package/apps/storefront/src/components/nav/Footer.astro +148 -0
  69. package/apps/storefront/src/components/nav/Header.astro +190 -0
  70. package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
  71. package/apps/storefront/src/components/plp/FacetSidebar.astro +146 -0
  72. package/apps/storefront/src/components/plp/Pagination.astro +99 -0
  73. package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
  74. package/apps/storefront/src/config/devTenants.ts +16 -0
  75. package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
  76. package/apps/storefront/src/config/resolver.ts +46 -0
  77. package/apps/storefront/src/config/tenants.ts +193 -0
  78. package/apps/storefront/src/layouts/Layout.astro +303 -0
  79. package/apps/storefront/src/lib/analytics/index.ts +47 -0
  80. package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
  81. package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
  82. package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
  83. package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
  84. package/apps/storefront/src/lib/auth/loginGate.ts +107 -0
  85. package/apps/storefront/src/lib/auth/route.ts +146 -0
  86. package/apps/storefront/src/lib/auth/session.ts +203 -0
  87. package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
  88. package/apps/storefront/src/lib/basePath.ts +41 -0
  89. package/apps/storefront/src/lib/cfImage.ts +55 -0
  90. package/apps/storefront/src/lib/chrome/model.ts +65 -0
  91. package/apps/storefront/src/lib/colors.ts +97 -0
  92. package/apps/storefront/src/lib/content-edit/client.ts +230 -0
  93. package/apps/storefront/src/lib/content-edit/index.ts +14 -0
  94. package/apps/storefront/src/lib/content-edit/route.ts +25 -0
  95. package/apps/storefront/src/lib/content-edit/types.ts +19 -0
  96. package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
  97. package/apps/storefront/src/lib/d1/catalog.ts +289 -0
  98. package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
  99. package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
  100. package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
  101. package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
  102. package/apps/storefront/src/lib/demoGate.ts +113 -0
  103. package/apps/storefront/src/lib/dev/ai-widget-client.js +127 -0
  104. package/apps/storefront/src/lib/dev/cliSignInCode.ts +132 -0
  105. package/apps/storefront/src/lib/diag/redact.ts +20 -0
  106. package/apps/storefront/src/lib/edgeCache.ts +128 -0
  107. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
  108. package/apps/storefront/src/lib/env.ts +60 -0
  109. package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
  110. package/apps/storefront/src/lib/format.ts +29 -0
  111. package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
  112. package/apps/storefront/src/lib/imageAttrs.ts +55 -0
  113. package/apps/storefront/src/lib/jsonld.ts +136 -0
  114. package/apps/storefront/src/lib/maintenance.ts +71 -0
  115. package/apps/storefront/src/lib/memoryKv.ts +32 -0
  116. package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
  117. package/apps/storefront/src/lib/placeholder.ts +99 -0
  118. package/apps/storefront/src/lib/previewReload.ts +125 -0
  119. package/apps/storefront/src/lib/rawMarketingHtml.ts +133 -0
  120. package/apps/storefront/src/lib/search/index.ts +74 -0
  121. package/apps/storefront/src/lib/sections.ts +129 -0
  122. package/apps/storefront/src/lib/securityHeaders.ts +31 -0
  123. package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
  124. package/apps/storefront/src/lib/storyblok/provider.ts +524 -0
  125. package/apps/storefront/src/lib/tenantRouting.ts +91 -0
  126. package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
  127. package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
  128. package/apps/storefront/src/lib/tot/query.ts +187 -0
  129. package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
  130. package/apps/storefront/src/lib/url.ts +119 -0
  131. package/apps/storefront/src/lib/wishlist.ts +63 -0
  132. package/apps/storefront/src/middleware/index.ts +413 -0
  133. package/apps/storefront/src/pages/404.astro +61 -0
  134. package/apps/storefront/src/pages/[...slug].astro +90 -0
  135. package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
  136. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
  137. package/apps/storefront/src/pages/api/auth/send.ts +85 -0
  138. package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
  139. package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
  140. package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +64 -0
  141. package/apps/storefront/src/pages/api/csp-report.ts +25 -0
  142. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
  143. package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
  144. package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +44 -0
  145. package/apps/storefront/src/pages/api/newsletter.ts +104 -0
  146. package/apps/storefront/src/pages/api/request-access.ts +172 -0
  147. package/apps/storefront/src/pages/auth/login.astro +149 -0
  148. package/apps/storefront/src/pages/auth/magic.astro +105 -0
  149. package/apps/storefront/src/pages/capabilities.astro +292 -0
  150. package/apps/storefront/src/pages/collections/[handle].astro +154 -0
  151. package/apps/storefront/src/pages/collections/index.astro +59 -0
  152. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
  153. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
  154. package/apps/storefront/src/pages/dashboard/index.astro +160 -0
  155. package/apps/storefront/src/pages/dev.astro +653 -0
  156. package/apps/storefront/src/pages/index.astro +290 -0
  157. package/apps/storefront/src/pages/llms.txt.ts +92 -0
  158. package/apps/storefront/src/pages/products/[handle].astro +443 -0
  159. package/apps/storefront/src/pages/robots.txt.ts +24 -0
  160. package/apps/storefront/src/pages/saved.astro +35 -0
  161. package/apps/storefront/src/pages/search.astro +150 -0
  162. package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
  163. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +646 -0
  164. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +85 -0
  165. package/apps/storefront/src/pages/style-guide/index.astro +94 -0
  166. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
  167. package/apps/storefront/src/styles/global.css +277 -0
  168. package/apps/storefront/src/themes/reference.ts +61 -0
  169. package/apps/storefront/src/themes/schema.ts +111 -0
  170. package/apps/storefront/src/themes/tenants/index.ts +51 -0
  171. package/apps/storefront/tsconfig.json +16 -0
  172. package/apps/storefront/vitest.config.ts +22 -0
  173. package/apps/storefront/wrangler.toml +175 -0
  174. package/package.json +23 -0
  175. package/packages/public-runtime/LICENSE +58 -0
  176. package/packages/public-runtime/package.json +22 -0
  177. package/packages/public-runtime/src/binary-path.ts +68 -0
  178. package/packages/public-runtime/src/capabilities.ts +89 -0
  179. package/packages/public-runtime/src/catalog-api.ts +81 -0
  180. package/packages/public-runtime/src/catalog-d1.ts +192 -0
  181. package/packages/public-runtime/src/csp.ts +109 -0
  182. package/packages/public-runtime/src/customization-preview.ts +197 -0
  183. package/packages/public-runtime/src/customization-reconcile.ts +63 -0
  184. package/packages/public-runtime/src/customization-runtime.ts +157 -0
  185. package/packages/public-runtime/src/customization-scripts.ts +64 -0
  186. package/packages/public-runtime/src/customization-versioning.ts +95 -0
  187. package/packages/public-runtime/src/foxy.ts +184 -0
  188. package/packages/public-runtime/src/index.ts +30 -0
  189. package/packages/public-runtime/src/product.ts +159 -0
  190. package/packages/public-runtime/src/search.ts +45 -0
  191. package/packages/public-runtime/src/tenant-assets.ts +444 -0
  192. package/packages/public-runtime/src/tenant.ts +243 -0
  193. package/packages/public-runtime/tsconfig.json +8 -0
  194. package/packages/public-runtime/vitest.config.ts +8 -0
  195. package/pnpm-workspace.yaml +7 -0
  196. package/scripts/build/copy-tenant-assets.mjs +69 -0
  197. package/scripts/tot-dev.mjs +439 -0
  198. package/tenants/bigdvapor/content/blog.json +52 -0
  199. package/tenants/bigdvapor/content/chrome.html +149 -0
  200. package/tenants/bigdvapor/content/chrome.json +47 -0
  201. package/tenants/bigdvapor/content/home.html +463 -0
  202. package/tenants/bigdvapor/content/home.json +73 -0
  203. package/tenants/bigdvapor/content/pages/about.json +5 -0
  204. package/tenants/bigdvapor/content/pages/privacy.json +6 -0
  205. package/tenants/bigdvapor/content/pages/shipping-returns.json +6 -0
  206. package/tenants/bigdvapor/content/pages-html/blogs/news.html +86 -0
  207. package/tenants/bigdvapor/content/pages-html/pages/about-us.html +106 -0
  208. package/tenants/bigdvapor/content/pages-html/pages/contact-us.html +61 -0
  209. package/tenants/bigdvapor/content/pages-html/pages/privacy-policy.html +72 -0
  210. package/tenants/bigdvapor/content/pages-html/pages/shipping-returns.html +172 -0
  211. package/tenants/bigdvapor/content/themes/bigd-navy.json +109 -0
  212. package/tenants/bigdvapor/public/img/brands/adjust-vape-disposable.png +0 -0
  213. package/tenants/bigdvapor/public/img/brands/again-vape-disposable.png +0 -0
  214. package/tenants/bigdvapor/public/img/brands/al-fakher-vape-disposable.png +0 -0
  215. package/tenants/bigdvapor/public/img/brands/arro-vape-disposable.png +0 -0
  216. package/tenants/bigdvapor/public/img/brands/cookies-vape-disposable.png +0 -0
  217. package/tenants/bigdvapor/public/img/brands/core-vape-disposable.png +0 -0
  218. package/tenants/bigdvapor/public/img/brands/cyclone-vape-disposable.png +0 -0
  219. package/tenants/bigdvapor/public/img/brands/dinner-lady-vape-disposable.png +0 -0
  220. package/tenants/bigdvapor/public/img/brands/extre-bar-vape-disposable.png +0 -0
  221. package/tenants/bigdvapor/public/img/brands/fifty-bar-vape-disposable.jpg +0 -0
  222. package/tenants/bigdvapor/public/img/brands/firerose-vape-disposable.jpg +0 -0
  223. package/tenants/bigdvapor/public/img/brands/flavor-beast-vape-disposable.jpg +0 -0
  224. package/tenants/bigdvapor/public/img/brands/foger-vape-disposable.png +0 -0
  225. package/tenants/bigdvapor/public/img/brands/kk-energy-vape-disposable.png +0 -0
  226. package/tenants/bigdvapor/public/img/brands/kumi-vape-disposable.png +0 -0
  227. package/tenants/bigdvapor/public/img/brands/lost-vape-dispoable.png +0 -0
  228. package/tenants/bigdvapor/public/img/brands/melo-labs-vape-disposable.png +0 -0
  229. package/tenants/bigdvapor/public/img/brands/mixo-vape-disposable.webp +0 -0
  230. package/tenants/bigdvapor/public/img/brands/one-tank-vape-disposable.jpg +0 -0
  231. package/tenants/bigdvapor/public/img/brands/oxbar-vape-disposable.webp +0 -0
  232. package/tenants/bigdvapor/public/img/brands/pillow-talk-vape-disposable.jpg +0 -0
  233. package/tenants/bigdvapor/public/img/brands/pod-king-vape-disposable.jpg +0 -0
  234. package/tenants/bigdvapor/public/img/brands/raz-vape-disposable.png +0 -0
  235. package/tenants/bigdvapor/public/img/hero-texas.jpg +0 -0
  236. package/tenants/bigdvapor/public/img/og.jpg +0 -0
  237. package/tenants/bigdvapor/public/img/pages/AdobeStock_202106312_web.jpg +0 -0
  238. package/tenants/bigdvapor/public/img/pages/CREW_web.jpg +0 -0
  239. package/tenants/bigdvapor/public/img/pages/Hero-Image.jpg +0 -0
  240. package/tenants/bigdvapor/public/img/tiles/adjust.png +0 -0
  241. package/tenants/bigdvapor/public/img/tiles/again.png +0 -0
  242. package/tenants/bigdvapor/public/img/tiles/al-fakher.png +0 -0
  243. package/tenants/bigdvapor/public/img/tiles/arro.png +0 -0
  244. package/tenants/bigdvapor/public/img/tiles/cookies.png +0 -0
  245. package/tenants/bigdvapor/public/img/tiles/core.png +0 -0
  246. package/tenants/bigdvapor/public/img/tiles/cyclone.png +0 -0
  247. package/tenants/bigdvapor/public/img/tiles/disposables.svg +23 -0
  248. package/tenants/bigdvapor/public/logo-wordmark.png +0 -0
  249. package/tenants/bigdvapor/public/pages/home.css +118 -0
  250. package/tenants/bigdvapor/public/pages/mkt.css +185 -0
  251. package/tenants/bigdvapor/public/pages/page.css +148 -0
  252. package/tenants/bigdvapor/public/themes/bigd-navy.css +73 -0
  253. package/tenants/bigdvapor/theme.json +12 -0
  254. package/tenants/giantvapes/content/chrome.html +152 -0
  255. package/tenants/giantvapes/content/chrome.json +94 -0
  256. package/tenants/giantvapes/content/home.html +194 -0
  257. package/tenants/giantvapes/content/home.json +50 -0
  258. package/tenants/giantvapes/content/pages/about.json +10 -0
  259. package/tenants/giantvapes/content/pages/privacy.json +6 -0
  260. package/tenants/giantvapes/content/pages/shipping-returns.json +6 -0
  261. package/tenants/giantvapes/content/pages-html/blogs/news.html +68 -0
  262. package/tenants/giantvapes/content/pages-html/pages/about-us.html +95 -0
  263. package/tenants/giantvapes/content/pages-html/pages/contact-us.html +68 -0
  264. package/tenants/giantvapes/content/pages-html/pages/privacy-policy.html +65 -0
  265. package/tenants/giantvapes/content/pages-html/pages/shipping-returns.html +77 -0
  266. package/tenants/giantvapes/content/themes/giant-navy.json +73 -0
  267. package/tenants/giantvapes/public/img/hero-suicide-bunny.jpg +0 -0
  268. package/tenants/giantvapes/public/img/hero.webp +0 -0
  269. package/tenants/giantvapes/public/img/og.jpg +0 -0
  270. package/tenants/giantvapes/public/logo-wordmark.png +0 -0
  271. package/tenants/giantvapes/public/pages/home.css +120 -0
  272. package/tenants/giantvapes/public/pages/mkt.css +185 -0
  273. package/tenants/giantvapes/public/pages/page.css +155 -0
  274. package/tenants/giantvapes/public/themes/giant-navy.css +76 -0
  275. package/tenants/giantvapes/theme.json +39 -0
  276. package/tenants/home/content/chrome.json +11 -0
  277. package/tenants/home/content/home.html +304 -0
  278. package/tenants/home/content/home.json +13 -0
  279. package/tenants/home/public/js/storefront.js +210 -0
  280. package/tenants/home/public/logo-wordmark.png +0 -0
  281. package/tenants/home/public/pages/storefront.css +212 -0
  282. package/tenants/home/theme.json +6 -0
  283. package/tenants/tokenoftrust/.tot/config.json +9 -0
  284. package/tenants/tokenoftrust/content/chrome.json +55 -0
  285. package/tenants/tokenoftrust/content/home.html +723 -0
  286. package/tenants/tokenoftrust/content/home.json +233 -0
  287. package/tenants/tokenoftrust/content/pages-html/careers.html +231 -0
  288. package/tenants/tokenoftrust/content/pages-html/company.html +275 -0
  289. package/tenants/tokenoftrust/content/pages-html/contact/contact-sales.html +188 -0
  290. package/tenants/tokenoftrust/content/pages-html/contact/startup-apply.html +206 -0
  291. package/tenants/tokenoftrust/content/pages-html/contact.html +178 -0
  292. package/tenants/tokenoftrust/content/pages-html/industries/adult-content-age-verification.html +286 -0
  293. package/tenants/tokenoftrust/content/pages-html/industries/cannabis.html +277 -0
  294. package/tenants/tokenoftrust/content/pages-html/pact-act-pricing.html +289 -0
  295. package/tenants/tokenoftrust/content/pages-html/pricing.html +483 -0
  296. package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-estimation.html +278 -0
  297. package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-gate.html +279 -0
  298. package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-verification.html +265 -0
  299. package/tenants/tokenoftrust/content/pages-html/product/age-assurance.html +173 -0
  300. package/tenants/tokenoftrust/content/pages-html/product/compliance/aml.html +214 -0
  301. package/tenants/tokenoftrust/content/pages-html/product/compliance/pact-act.html +226 -0
  302. package/tenants/tokenoftrust/content/pages-html/product/fraud-prevention.html +254 -0
  303. package/tenants/tokenoftrust/content/pages-html/product/social-profile-verification.html +212 -0
  304. package/tenants/tokenoftrust/content/pages-html/product/tax/excise-tax.html +213 -0
  305. package/tenants/tokenoftrust/content/pages-html/product/verification/aml.html +259 -0
  306. package/tenants/tokenoftrust/content/pages-html/product/verification/biometric-selfie.html +305 -0
  307. package/tenants/tokenoftrust/content/pages-html/product/verification/coverage.html +254 -0
  308. package/tenants/tokenoftrust/content/pages-html/product/verification/document-verification.html +306 -0
  309. package/tenants/tokenoftrust/content/pages-html/product/verification/electronic-identity-verification.html +304 -0
  310. package/tenants/tokenoftrust/content/pages-html/product/verification/government-id-verification.html +308 -0
  311. package/tenants/tokenoftrust/content/pages-html/product/verification.html +183 -0
  312. package/tenants/tokenoftrust/content/pages-html/product.html +228 -0
  313. package/tenants/tokenoftrust/content/pages-html/resources/integrations/bigcommerce-integration.html +220 -0
  314. package/tenants/tokenoftrust/content/pages-html/resources/integrations/shopify-integration.html +221 -0
  315. package/tenants/tokenoftrust/content/pages-html/resources/integrations/wordpress.html +220 -0
  316. package/tenants/tokenoftrust/content/pages-html/resources/integrations.html +172 -0
  317. package/tenants/tokenoftrust/content/pages-html/resources/refer-a-friend-program.html +317 -0
  318. package/tenants/tokenoftrust/content/pages-html/resources.html +177 -0
  319. package/tenants/tokenoftrust/content/pages-html/reviews.html +273 -0
  320. package/tenants/tokenoftrust/content/pages-html/solutions.html +292 -0
  321. package/tenants/tokenoftrust/content/pages-html/vlp-alcohol.html +289 -0
  322. package/tenants/tokenoftrust/content/pages-html/vlp-cigars-and-tobacco.html +286 -0
  323. package/tenants/tokenoftrust/content/pages-html/vlp-firearm-accessories.html +267 -0
  324. package/tenants/tokenoftrust/content/pages-html/vlp-peptides.html +273 -0
  325. package/tenants/tokenoftrust/content/themes/tot-navy.json +88 -0
  326. package/tenants/tokenoftrust/public/favicon.png +0 -0
  327. package/tenants/tokenoftrust/public/logo-icon.png +0 -0
  328. package/tenants/tokenoftrust/public/logo-wordmark.png +0 -0
  329. package/tenants/tokenoftrust/public/pages/company.css +108 -0
  330. package/tenants/tokenoftrust/public/pages/home.css +457 -0
  331. package/tenants/tokenoftrust/public/pages/mkt.css +482 -0
  332. package/tenants/tokenoftrust/public/pages/pricing.css +214 -0
  333. package/tenants/tokenoftrust/public/pages/solutions.css +66 -0
  334. package/tenants/tokenoftrust/public/themes/tot-navy-1.0.0.css +136 -0
  335. package/tenants/tokenoftrust/public/themes/tot-navy.css +358 -0
  336. package/tenants/tokenoftrust/theme.json +31 -0
  337. package/tsconfig.base.json +20 -0
@@ -0,0 +1,58 @@
1
+ ---
2
+ /**
3
+ * <RatingStars> — atom. A 0–5 star rating with fractional fill (a single clipped
4
+ * overlay, no half-star glyph juggling) and an accessible label. Tenant-agnostic:
5
+ * filled stars use `--color-accent`, the empty track uses `--color-border`.
6
+ *
7
+ * Presentation only — it renders whatever aggregate you pass. No review data is
8
+ * invented here (claims-ledger discipline: source ratings before shipping them).
9
+ */
10
+ interface Props {
11
+ /** 0–5. */
12
+ rating: number;
13
+ /** Optional review count shown after the stars. */
14
+ count?: number;
15
+ max?: number;
16
+ size?: "sm" | "md";
17
+ /** Show the numeric "(N)" count. */
18
+ showCount?: boolean;
19
+ class?: string;
20
+ }
21
+
22
+ const {
23
+ rating,
24
+ count,
25
+ max = 5,
26
+ size = "sm",
27
+ showCount = true,
28
+ class: className,
29
+ } = Astro.props;
30
+
31
+ const clamped = Math.max(0, Math.min(max, rating));
32
+ const pct = (clamped / max) * 100;
33
+ const dim = size === "md" ? "text-base" : "text-xs";
34
+ const stars = "★".repeat(max);
35
+ const label =
36
+ count != null
37
+ ? `Rated ${clamped} out of ${max} — ${count} review${count === 1 ? "" : "s"}`
38
+ : `Rated ${clamped} out of ${max}`;
39
+ ---
40
+
41
+ <span class:list={["inline-flex items-center gap-1.5", className]} aria-label={label}>
42
+ <span class:list={["relative inline-block leading-none tracking-[0.08em]", dim]} aria-hidden="true">
43
+ <span class="text-[var(--color-border)]">{stars}</span>
44
+ <span
45
+ class="absolute inset-0 overflow-hidden whitespace-nowrap text-[var(--color-accent)]"
46
+ style={`width:${pct}%`}
47
+ >
48
+ {stars}
49
+ </span>
50
+ </span>
51
+ {
52
+ showCount && count != null && (
53
+ <span class="font-mono text-[0.62rem] text-muted" aria-hidden="true">
54
+ ({count})
55
+ </span>
56
+ )
57
+ }
58
+ </span>
@@ -0,0 +1,33 @@
1
+ ---
2
+ /**
3
+ * FDA-mandated nicotine warning (21 CFR 1143.3). The wording is fixed by law and
4
+ * must be conspicuous — do not paraphrase. Two presentations:
5
+ * - "bar": a persistent high-contrast strip at the very top of every page.
6
+ * - "block": a reinforced box on the PDP (regulation also requires it on the
7
+ * product's own page/ads).
8
+ * Rendered only when the tenant's compliance.nicotineWarning is set.
9
+ */
10
+ interface Props {
11
+ variant?: "bar" | "block";
12
+ }
13
+ const { variant = "bar" } = Astro.props;
14
+
15
+ const TEXT =
16
+ "WARNING: This product contains nicotine. Nicotine is an addictive chemical.";
17
+ ---
18
+
19
+ {
20
+ variant === "bar" ? (
21
+ <div class="border-b-2 border-[var(--color-text)] bg-[var(--color-text)] text-[var(--color-bg)]" role="note" aria-label="Health warning" data-compliance="nicotine-warning">
22
+ <p class="container-prose py-2 text-center text-[0.72rem] font-semibold uppercase tracking-[0.06em] sm:text-xs">
23
+ {TEXT}
24
+ </p>
25
+ </div>
26
+ ) : (
27
+ <div class="rounded-[var(--radius-md)] border-2 border-[var(--color-text)] px-4 py-3" role="note" aria-label="Health warning" data-compliance="nicotine-warning">
28
+ <p class="text-sm font-semibold uppercase tracking-[0.04em] text-[var(--color-text)]">
29
+ {TEXT}
30
+ </p>
31
+ </div>
32
+ )
33
+ }
@@ -0,0 +1,164 @@
1
+ ---
2
+ /**
3
+ * Homepage hero — the SIGNATURE moment (brief §6: "hero is a thesis", spend
4
+ * boldness in ONE place). Two treatments, by the tenant's brand.heroVariant:
5
+ * - "bold" (default): full-bleed primary background, light contrast text.
6
+ * - "light": hero on the page background, brand color as an accent on the
7
+ * headline + CTA — matches merchants whose own site is a light page with
8
+ * color accents. The brand adopter sets "light" when it captures a hero image.
9
+ * The feature image is the merchant's own hero slide (block.image) when adopted,
10
+ * else the hero collection's first product.
11
+ */
12
+ import type { HeroBlock } from "@/lib/storyblok/content-model";
13
+ import type { CatalogProduct } from "@tot/public-runtime";
14
+ import Img from "@/components/Img.astro";
15
+ import { withBase } from "@/lib/basePath";
16
+
17
+ interface Props {
18
+ block: HeroBlock;
19
+ featureProduct?: CatalogProduct | null;
20
+ }
21
+ const { block, featureProduct } = Astro.props;
22
+ const { basePath } = Astro.locals;
23
+ const light = (Astro.locals.tenant?.theme_tokens?.brand?.heroVariant ?? "bold") === "light";
24
+ const prodImg = featureProduct?.images[0];
25
+ const heroImg = block.image;
26
+ const hasImage = Boolean(heroImg || (prodImg && featureProduct));
27
+ ---
28
+
29
+ <section
30
+ class:list={[
31
+ "relative overflow-hidden",
32
+ light
33
+ ? "bg-[var(--color-bg)] text-[var(--color-text)]"
34
+ : "bg-[var(--color-primary)] text-[var(--color-primary-contrast)]",
35
+ ]}
36
+ >
37
+ <div
38
+ class:list={[
39
+ "container-prose grid items-center gap-10 py-[clamp(3.5rem,9vw,7rem)]",
40
+ hasImage && "md:grid-cols-[1.1fr_0.9fr]",
41
+ ]}
42
+ >
43
+ <div data-reveal>
44
+ {
45
+ block.eyebrow && (
46
+ <p
47
+ class:list={[
48
+ "eyebrow eyebrow--tick mb-6",
49
+ light
50
+ ? "!text-[var(--color-muted)]"
51
+ : "!text-[color-mix(in_srgb,var(--color-primary-contrast)_72%,transparent)]",
52
+ ]}
53
+ >
54
+ {block.eyebrow}
55
+ </p>
56
+ )
57
+ }
58
+ <h1 class="display-hero">
59
+ <span class:list={["block", light && "text-[var(--color-primary)]"]}>{block.headline}</span>
60
+ {
61
+ block.headlineAccent && (
62
+ <span class="relative block w-fit text-[var(--color-accent)]">
63
+ {block.headlineAccent}
64
+ <span
65
+ aria-hidden="true"
66
+ class="absolute -bottom-1 left-0 h-[3px] w-full origin-left bg-[var(--color-accent)]/55"
67
+ />
68
+ </span>
69
+ )
70
+ }
71
+ </h1>
72
+ {
73
+ block.subhead && (
74
+ <p
75
+ class:list={[
76
+ "mt-7 max-w-prose text-[1.1rem] leading-relaxed",
77
+ light
78
+ ? "text-[var(--color-muted)]"
79
+ : "text-[color-mix(in_srgb,var(--color-primary-contrast)_82%,transparent)]",
80
+ ]}
81
+ >
82
+ {block.subhead}
83
+ </p>
84
+ )
85
+ }
86
+ {
87
+ block.ctaLabel && block.ctaHref && (
88
+ <a
89
+ href={withBase(basePath, block.ctaHref)}
90
+ class="group mt-9 inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-7 py-3 font-display text-[0.95rem] font-semibold text-white transition-transform duration-200 hover:-translate-y-0.5 focus-visible:-translate-y-0.5"
91
+ >
92
+ {block.ctaLabel}
93
+ <span aria-hidden="true" class="transition-transform duration-200 group-hover:translate-x-1">
94
+
95
+ </span>
96
+ </a>
97
+ )
98
+ }
99
+ </div>
100
+
101
+ {
102
+ heroImg ? (
103
+ <div class="relative" data-reveal>
104
+ <div
105
+ class:list={[
106
+ "overflow-hidden rounded-[var(--radius-lg)] shadow-[var(--shadow-lg)] ring-1",
107
+ light ? "ring-[var(--color-border)]" : "ring-white/10",
108
+ ]}
109
+ >
110
+ <img
111
+ src={heroImg}
112
+ alt={block.imageAlt ?? block.headline}
113
+ loading="eager"
114
+ class="h-full w-full object-cover"
115
+ />
116
+ </div>
117
+ </div>
118
+ ) : (
119
+ prodImg &&
120
+ featureProduct && (
121
+ <div class="relative" data-reveal>
122
+ <div
123
+ class:list={[
124
+ "overflow-hidden rounded-[var(--radius-lg)] shadow-[var(--shadow-lg)] ring-1",
125
+ light ? "ring-[var(--color-border)]" : "ring-white/10",
126
+ ]}
127
+ >
128
+ <Img
129
+ src={prodImg.url}
130
+ rehosted={prodImg.rehosted}
131
+ seed={prodImg.id ?? featureProduct.handle}
132
+ label={featureProduct.title}
133
+ alt={prodImg.alt ?? featureProduct.title}
134
+ width={prodImg.width ?? 1000}
135
+ height={prodImg.height ?? 1250}
136
+ priority={true}
137
+ sizes="(min-width: 768px) 42vw, 100vw"
138
+ class="h-full w-full object-cover"
139
+ />
140
+ </div>
141
+ <a
142
+ href={withBase(basePath, `/products/${featureProduct.handle}`)}
143
+ class="absolute bottom-4 left-4 right-4 flex items-center justify-between rounded-[var(--radius-md)] bg-[var(--color-surface)]/95 px-4 py-3 text-[var(--color-text)] backdrop-blur"
144
+ >
145
+ <span class="font-display text-sm font-medium">{featureProduct.title}</span>
146
+ <span class="eyebrow !text-[var(--color-accent)]">Shop →</span>
147
+ </a>
148
+ </div>
149
+ )
150
+ )
151
+ }
152
+ </div>
153
+
154
+ <div
155
+ aria-hidden="true"
156
+ class:list={[
157
+ "pointer-events-none absolute inset-x-0 bottom-0 h-px",
158
+ light
159
+ ? "bg-[var(--color-border)]"
160
+ : "bg-[color-mix(in_srgb,var(--color-primary-contrast)_18%,transparent)]",
161
+ ]}
162
+ >
163
+ </div>
164
+ </section>
@@ -0,0 +1,105 @@
1
+ /**
2
+ * <AgeGate> — soft 21+ affirmation shown once per device for regulated tenants.
3
+ *
4
+ * IMPORTANT: this is the storefront's front-door affirmation only. The REAL
5
+ * identity/age verification is Token of Trust's product and is wired at a later
6
+ * phase (the "Yes" path is where that handoff will live). We deliberately do not
7
+ * reimplement verification here. Affirmation is remembered in localStorage.
8
+ */
9
+ import { useEffect, useRef, useState } from "preact/hooks";
10
+
11
+ interface Props {
12
+ minAge: number;
13
+ brand: string;
14
+ }
15
+
16
+ const KEY = "tot:age-affirmed";
17
+
18
+ export default function AgeGate({ minAge, brand }: Props) {
19
+ const [state, setState] = useState<"hidden" | "ask" | "denied">("hidden");
20
+ const yesRef = useRef<HTMLButtonElement>(null);
21
+
22
+ useEffect(() => {
23
+ let affirmed = false;
24
+ try {
25
+ affirmed = localStorage.getItem(KEY) === "yes";
26
+ } catch {
27
+ /* storage disabled — show the gate */
28
+ }
29
+ if (!affirmed) {
30
+ setState("ask");
31
+ document.documentElement.style.overflow = "hidden";
32
+ }
33
+ }, []);
34
+
35
+ useEffect(() => {
36
+ if (state === "ask") yesRef.current?.focus();
37
+ if (state === "hidden") document.documentElement.style.overflow = "";
38
+ }, [state]);
39
+
40
+ const affirm = () => {
41
+ try {
42
+ localStorage.setItem(KEY, "yes");
43
+ } catch {
44
+ /* ignore */
45
+ }
46
+ setState("hidden");
47
+ };
48
+
49
+ if (state === "hidden") return null;
50
+
51
+ return (
52
+ <div
53
+ class="fixed inset-0 z-[100] grid place-items-center bg-[color-mix(in_srgb,var(--color-text)_75%,transparent)] p-5 backdrop-blur-sm"
54
+ role="dialog"
55
+ aria-modal="true"
56
+ aria-labelledby="agegate-title"
57
+ >
58
+ <div class="w-[min(30rem,100%)] rounded-[var(--radius-lg)] bg-[var(--color-surface)] p-7 text-center shadow-[var(--shadow-lg)]">
59
+ {state === "ask" ? (
60
+ <>
61
+ <p class="eyebrow eyebrow--tick mx-auto mb-4 w-fit">Age verification</p>
62
+ <h2 id="agegate-title" class="display-md">
63
+ Are you {minAge} or older?
64
+ </h2>
65
+ <p class="mx-auto mt-3 max-w-sm text-[var(--color-muted)]">
66
+ {brand} sells age-restricted products. You must be {minAge}+ to enter.
67
+ By continuing you confirm your age — we verify it for real at checkout.
68
+ </p>
69
+ <div class="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-center">
70
+ <button
71
+ ref={yesRef}
72
+ type="button"
73
+ onClick={affirm}
74
+ class="rounded-[var(--radius-md)] bg-[var(--color-primary)] px-6 py-3 font-display text-sm font-semibold text-[var(--color-primary-contrast)]"
75
+ >
76
+ Yes, I'm {minAge} or older
77
+ </button>
78
+ <button
79
+ type="button"
80
+ onClick={() => setState("denied")}
81
+ class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-6 py-3 font-display text-sm font-medium text-[var(--color-text)] hover:border-[var(--color-text)]"
82
+ >
83
+ No
84
+ </button>
85
+ </div>
86
+ </>
87
+ ) : (
88
+ <>
89
+ <h2 id="agegate-title" class="display-md">Come back when you're {minAge}</h2>
90
+ <p class="mx-auto mt-3 max-w-sm text-[var(--color-muted)]">
91
+ You must be {minAge} or older to view this store. Please close this tab.
92
+ </p>
93
+ <button
94
+ type="button"
95
+ onClick={() => setState("ask")}
96
+ class="link-accent mt-6 inline-block font-display text-sm font-medium"
97
+ >
98
+ I entered my age incorrectly
99
+ </button>
100
+ </>
101
+ )}
102
+ </div>
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * <ImageGallery> — PDP image gallery (main image + thumbnail strip).
3
+ *
4
+ * Interactive island (Preact). Thumbnails swap the main image; the main image
5
+ * has a tasteful click-to-toggle zoom (reduced-motion safe). The first image is
6
+ * the LCP candidate (eager / high priority / sync decode) until the user
7
+ * interacts.
8
+ *
9
+ * Cross-island wiring: listens on `window` for a `variant:image` CustomEvent
10
+ * `{ detail: { imageId } }` (dispatched by <VariantSelector>) and switches to
11
+ * the matching image when present.
12
+ *
13
+ * CLS-free: explicit width/height + aspect-ratio on every image. Dead fixture
14
+ * CDN URLs fall back to a deterministic placeholder.
15
+ */
16
+ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
17
+ import type { CatalogImage } from "@tot/public-runtime";
18
+ import { placeholderDataUri, isUsableImageUrl } from "@/lib/placeholder";
19
+
20
+ interface Props {
21
+ images: CatalogImage[];
22
+ title: string;
23
+ productHandle: string;
24
+ activeImageId?: string;
25
+ }
26
+
27
+ const FALLBACK_W = 1000;
28
+ const FALLBACK_H = 1250;
29
+
30
+ function srcFor(img: CatalogImage, title: string): string {
31
+ if (isUsableImageUrl(img.url, img.rehosted)) return img.url;
32
+ return placeholderDataUri({
33
+ seed: img.id || title,
34
+ width: img.width ?? FALLBACK_W,
35
+ height: img.height ?? FALLBACK_H,
36
+ label: title,
37
+ });
38
+ }
39
+
40
+ // Responsive srcset for CDNs that accept `?width=` (our rehost target + Shopify's
41
+ // image CDN), so referenced Shopify images are responsive without rehosting.
42
+ const GALLERY_WIDTHS = [320, 480, 640, 960, 1280, 1600];
43
+ function srcsetFor(img: CatalogImage): string | undefined {
44
+ if (!isUsableImageUrl(img.url, img.rehosted)) return undefined;
45
+ const cdn = img.rehosted || /\/\/cdn\.shopify\.com\//.test(img.url) || /\/cdn\/shop\//.test(img.url);
46
+ if (!cdn) return undefined;
47
+ const max = (img.width ?? FALLBACK_W) * 2;
48
+ return GALLERY_WIDTHS.filter((w) => w <= max)
49
+ .map((w) => `${img.url}${img.url.includes("?") ? "&" : "?"}width=${w} ${w}w`)
50
+ .join(", ");
51
+ }
52
+
53
+ function prefersReducedMotion(): boolean {
54
+ return (
55
+ typeof window !== "undefined" &&
56
+ typeof window.matchMedia === "function" &&
57
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches
58
+ );
59
+ }
60
+
61
+ export default function ImageGallery({
62
+ images,
63
+ title,
64
+ productHandle,
65
+ activeImageId,
66
+ }: Props) {
67
+ const ordered = useMemo(
68
+ () => [...images].sort((a, b) => a.position - b.position),
69
+ [images],
70
+ );
71
+
72
+ const initialIndex = useMemo(() => {
73
+ if (activeImageId) {
74
+ const i = ordered.findIndex((im) => im.id === activeImageId);
75
+ if (i >= 0) return i;
76
+ }
77
+ return 0;
78
+ }, [ordered, activeImageId]);
79
+
80
+ const [index, setIndex] = useState(initialIndex);
81
+ const [zoomed, setZoomed] = useState(false);
82
+ // After the first interaction the LCP hint no longer applies to the new image.
83
+ const [interacted, setInteracted] = useState(false);
84
+ const reduced = prefersReducedMotion();
85
+ const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]);
86
+
87
+ const select = useCallback(
88
+ (i: number) => {
89
+ setIndex(i);
90
+ setZoomed(false);
91
+ setInteracted(true);
92
+ },
93
+ [],
94
+ );
95
+
96
+ // Cross-island: variant selector drives the active image.
97
+ useEffect(() => {
98
+ function onVariantImage(e: Event) {
99
+ const detail = (e as CustomEvent<{ imageId?: string }>).detail;
100
+ if (!detail?.imageId) return;
101
+ const i = ordered.findIndex((im) => im.id === detail.imageId);
102
+ if (i >= 0) {
103
+ setIndex(i);
104
+ setZoomed(false);
105
+ setInteracted(true);
106
+ }
107
+ }
108
+ window.addEventListener("variant:image", onVariantImage);
109
+ return () => window.removeEventListener("variant:image", onVariantImage);
110
+ }, [ordered]);
111
+
112
+ // Keyboard nav on the thumbnail strip (roving via arrow keys).
113
+ const onThumbKeyDown = useCallback(
114
+ (e: KeyboardEvent, i: number) => {
115
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
116
+ e.preventDefault();
117
+ const next = (i + 1) % ordered.length;
118
+ select(next);
119
+ thumbRefs.current[next]?.focus();
120
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
121
+ e.preventDefault();
122
+ const prev = i <= 0 ? ordered.length - 1 : i - 1;
123
+ select(prev);
124
+ thumbRefs.current[prev]?.focus();
125
+ }
126
+ },
127
+ [ordered.length, select],
128
+ );
129
+
130
+ if (ordered.length === 0) {
131
+ return (
132
+ <div
133
+ class="w-full overflow-hidden rounded-md"
134
+ style={`aspect-ratio:${FALLBACK_W}/${FALLBACK_H};background:var(--color-border)`}
135
+ >
136
+ <img
137
+ src={placeholderDataUri({
138
+ seed: productHandle,
139
+ width: FALLBACK_W,
140
+ height: FALLBACK_H,
141
+ label: title,
142
+ })}
143
+ alt={title}
144
+ width={FALLBACK_W}
145
+ height={FALLBACK_H}
146
+ decoding="sync"
147
+ fetchpriority="high"
148
+ loading="eager"
149
+ class="h-full w-full object-cover"
150
+ />
151
+ </div>
152
+ );
153
+ }
154
+
155
+ const current = ordered[index] ?? ordered[0]!;
156
+ const w = current.width ?? FALLBACK_W;
157
+ const h = current.height ?? FALLBACK_H;
158
+ const isLcp = index === 0 && !interacted;
159
+
160
+ return (
161
+ <div class="flex flex-col gap-3 md:flex-row-reverse md:gap-4">
162
+ {/* Main image */}
163
+ <div class="md:flex-1">
164
+ <button
165
+ type="button"
166
+ aria-label={zoomed ? "Zoom out" : "Zoom in"}
167
+ aria-pressed={zoomed}
168
+ onClick={() => setZoomed((z) => !z)}
169
+ class={`group block w-full overflow-hidden rounded-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary ${
170
+ zoomed ? "cursor-zoom-out" : "cursor-zoom-in"
171
+ }`}
172
+ style={`aspect-ratio:${w}/${h};background:var(--color-border)`}
173
+ >
174
+ <img
175
+ src={srcFor(current, title)}
176
+ srcset={srcsetFor(current)}
177
+ sizes="(min-width: 768px) 45vw, 100vw"
178
+ alt={current.alt ?? title}
179
+ width={w}
180
+ height={h}
181
+ loading={isLcp ? "eager" : "lazy"}
182
+ decoding={isLcp ? "sync" : "async"}
183
+ fetchpriority={isLcp ? "high" : "auto"}
184
+ class={`h-full w-full object-cover ${
185
+ reduced ? "" : "transition-transform duration-300"
186
+ } ${zoomed ? "scale-150" : "scale-100"}`}
187
+ />
188
+ </button>
189
+ </div>
190
+
191
+ {/* Thumbnail strip */}
192
+ {ordered.length > 1 && (
193
+ <ul
194
+ aria-label="Product images"
195
+ class="flex gap-2 overflow-x-auto md:w-20 md:flex-col md:overflow-visible"
196
+ >
197
+ {ordered.map((img, i) => {
198
+ const tw = img.width ?? FALLBACK_W;
199
+ const th = img.height ?? FALLBACK_H;
200
+ return (
201
+ <li key={img.id} class="shrink-0">
202
+ <button
203
+ type="button"
204
+ ref={(el) => {
205
+ thumbRefs.current[i] = el;
206
+ }}
207
+ aria-label={`View image ${i + 1}`}
208
+ aria-pressed={i === index}
209
+ onClick={() => select(i)}
210
+ onKeyDown={(e) => onThumbKeyDown(e, i)}
211
+ class={`block h-16 w-16 overflow-hidden rounded-md border-2 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary md:h-20 md:w-20 ${
212
+ i === index
213
+ ? "border-primary"
214
+ : "border-transparent hover:border-border"
215
+ }`}
216
+ style={`aspect-ratio:${tw}/${th};background:var(--color-border)`}
217
+ >
218
+ <img
219
+ src={srcFor(img, title)}
220
+ srcset={srcsetFor(img)}
221
+ sizes="80px"
222
+ alt=""
223
+ width={tw}
224
+ height={th}
225
+ loading="lazy"
226
+ decoding="async"
227
+ class="h-full w-full object-cover"
228
+ />
229
+ </button>
230
+ </li>
231
+ );
232
+ })}
233
+ </ul>
234
+ )}
235
+ </div>
236
+ );
237
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * <IsolatedAgeGate> — mounts the 21+ affirmation gate inside a **closed shadow
3
+ * root** so no tenant CSS or JavaScript can select, restyle, or hide it.
4
+ *
5
+ * Why a wrapper instead of rendering <AgeGate> directly: a closed shadow root
6
+ * also blocks the platform's own global stylesheet, so we clone the document's
7
+ * stylesheets into the shadow root (Tailwind utilities + fonts). Theme tokens
8
+ * are CSS custom properties on :root, which *do* inherit across the shadow
9
+ * boundary, so the gate keeps the tenant's colors.
10
+ *
11
+ * The host element lives in light DOM (the only attack surface), so a
12
+ * MutationObserver re-asserts it: if tenant code removes it or sets it
13
+ * display:none/hidden, it is restored. The gate's *contents* are unreachable
14
+ * (closed shadow). This makes "customize anything except what keeps you legal"
15
+ * a technical guarantee, not a policy.
16
+ */
17
+ import { render } from "preact";
18
+ import { useEffect } from "preact/hooks";
19
+ import AgeGate from "./AgeGate";
20
+
21
+ interface Props {
22
+ minAge: number;
23
+ brand: string;
24
+ }
25
+
26
+ const HOST_MARKER = "data-tot-compliance";
27
+
28
+ export default function IsolatedAgeGate({ minAge, brand }: Props) {
29
+ useEffect(() => {
30
+ // Guard against an accidental double-mount (e.g. HMR).
31
+ if (document.querySelector(`[${HOST_MARKER}]`)) return;
32
+
33
+ const host = document.createElement("div");
34
+ host.setAttribute(HOST_MARKER, "");
35
+ const shadow = host.attachShadow({ mode: "closed" });
36
+
37
+ // Bring platform styles into the shadow root via the external stylesheet
38
+ // <link>s only (Tailwind utilities; build externalizes all CSS). We do NOT
39
+ // clone the inline theme <style>: cloning strips its CSP nonce, and it's
40
+ // unneeded — CSS custom properties (the theme tokens on :root) inherit
41
+ // across the shadow boundary, so the gate keeps the tenant's colors anyway.
42
+ document
43
+ .querySelectorAll('link[rel="stylesheet"]')
44
+ .forEach((link) => shadow.appendChild(link.cloneNode(true)));
45
+
46
+ const mount = document.createElement("div");
47
+ shadow.appendChild(mount);
48
+ render(<AgeGate minAge={minAge} brand={brand} />, mount);
49
+ document.body.appendChild(host);
50
+
51
+ // Re-assert: restore the host if tenant code detaches or hides it.
52
+ const ensure = () => {
53
+ if (!host.isConnected) document.body.appendChild(host);
54
+ if (host.style.display === "none") host.style.display = "";
55
+ if (host.style.visibility === "hidden") host.style.visibility = "";
56
+ if (host.hasAttribute("hidden")) host.removeAttribute("hidden");
57
+ };
58
+ const observer = new MutationObserver(ensure);
59
+ observer.observe(document.body, { childList: true });
60
+ observer.observe(host, {
61
+ attributes: true,
62
+ attributeFilter: ["style", "class", "hidden"],
63
+ });
64
+
65
+ return () => {
66
+ observer.disconnect();
67
+ render(null, mount);
68
+ host.remove();
69
+ };
70
+ }, [minAge, brand]);
71
+
72
+ return null;
73
+ }