@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,443 @@
1
+ ---
2
+ /**
3
+ * PDP — the conversion centerpiece (brief §5). Gallery + variant selector
4
+ * islands, breadcrumbs, structured specs from metafields, related products,
5
+ * badges, review-stars placeholder, disabled buy CTA (no cart in Phase 1),
6
+ * Product + BreadcrumbList JSON-LD.
7
+ */
8
+ import Layout from "@/layouts/Layout.astro";
9
+ import Breadcrumbs from "@/components/Breadcrumbs.astro";
10
+ import Badge from "@/components/Badge.astro";
11
+ import ProductCard from "@/components/ProductCard.astro";
12
+ import Section from "@/components/Section.astro";
13
+ import ImageGallery from "@/components/islands/ImageGallery.tsx";
14
+ import VariantSelector from "@/components/islands/VariantSelector.tsx";
15
+ import RecentlyViewed from "@/components/islands/RecentlyViewed.tsx";
16
+ import NicotineWarning from "@/components/compliance/NicotineWarning.astro";
17
+ import { isOnSale, isNew, isAvailable, fromPrice } from "@/lib/tot/query";
18
+ import { buildVariantCartPayload } from "@tot/public-runtime";
19
+ import type { VariantCartPayload } from "@tot/public-runtime";
20
+ import { readCartSecret } from "@/lib/foxyCommerce";
21
+ import { truncate } from "@/lib/format";
22
+ import { productLd, breadcrumbLd } from "@/lib/jsonld";
23
+ import { isUsableImageUrl } from "@/lib/placeholder";
24
+ import { withBase } from "@/lib/basePath";
25
+
26
+ const { handle } = Astro.params;
27
+ const { tenant, tot, canonicalBase, basePath, commerce } = Astro.locals;
28
+ // Commerce-only route: marketing tenants have no product detail pages.
29
+ if (!Astro.locals.features.catalog) return new Response(null, { status: 404 });
30
+ const compliance = tenant.compliance;
31
+
32
+ if (!handle) return Astro.redirect(withBase(basePath, "/404"));
33
+ const product = await tot.getProductByHandle(handle);
34
+
35
+ // Draft canary: drafts resolve by handle (preview/direct link) but are noindex.
36
+ if (!product || product.status === "archived") {
37
+ return new Response(null, { status: 404 });
38
+ }
39
+ const isDraft = product.status !== "active";
40
+
41
+ // Related: same collection first, then shared-tag fallback, exclude self+drafts.
42
+ const related = await (async () => {
43
+ const out = new Map<string, typeof product>();
44
+ for (const colHandle of product.collections) {
45
+ const { products } = await tot.listProducts({ collection: colHandle, pageSize: 12 });
46
+ for (const p of products) if (p.handle !== product.handle) out.set(p.handle, p);
47
+ }
48
+ if (out.size < 4 && product.tags.length) {
49
+ const { products } = await tot.listProducts({ pageSize: 50 });
50
+ for (const p of products) {
51
+ if (p.handle === product.handle) continue;
52
+ if (p.tags.some((t) => product.tags.includes(t))) out.set(p.handle, p);
53
+ }
54
+ }
55
+ return [...out.values()].slice(0, 4);
56
+ })();
57
+
58
+ const crumbs = [
59
+ { label: tenant.displayName, href: "/" },
60
+ ...(product.collections[0]
61
+ ? [{ label: titleCase(product.collections[0]), href: `/collections/${product.collections[0]}` }]
62
+ : []),
63
+ { label: product.title, href: `/products/${product.handle}` },
64
+ ];
65
+
66
+ function titleCase(s: string) {
67
+ return s.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
68
+ }
69
+
70
+ // Metafield reader.
71
+ const mf = (ns: string, key: string) =>
72
+ product.metafields.find((m) => m.namespace === ns && m.key === key)?.value;
73
+
74
+ // Reviews — REAL data from the store's reviews app (Stamped), pulled as metafields.
75
+ const reviewCount = Number(mf("stamped", "reviews_count") ?? mf("reviews", "count") ?? "0");
76
+ const reviewAvg = Number(mf("stamped", "reviews_average") ?? mf("reviews", "rating") ?? "0");
77
+
78
+ // Short description (merchant's concise summary) shown high on the PDP, like the
79
+ // source store. The longer description_html still renders below the buy column.
80
+ const shortDescription = mf("my_fields", "short_description") ?? mf("specs", "short_description");
81
+
82
+ // OG image = the real product image (Shopify CDN supports ?width=), sized for
83
+ // social. Falls back to the tenant brand OG only when there's no usable image.
84
+ const ogPrimary = product.images[0];
85
+ const ogImage =
86
+ ogPrimary && isUsableImageUrl(ogPrimary.url, ogPrimary.rehosted)
87
+ ? `${ogPrimary.url}${ogPrimary.url.includes("?") ? "&" : "?"}width=1200`
88
+ : undefined;
89
+
90
+ // Product attributes shown the way the SOURCE STORE presents them: plain
91
+ // "Label: value" lines below the description (Flavors / VG % / Bottle Type),
92
+ // sourced from F:-prefixed tags + merchant my_fields. The compliance.* metafields
93
+ // are ToT-internal sync data and are NOT displayed — the source store doesn't
94
+ // show them either (they gate shipping/eligibility, not merchandising).
95
+ // Each flavor links to that flavor filtered within the product's collection —
96
+ // mirrors the source store's /collections/<handle>/<tag> pattern using our
97
+ // existing ?tag= PLP filter. General: any tag → a filtered listing; only the
98
+ // "F: " flavor-tag convention is merchant-specific (same as the display above).
99
+ const flavorCollection = product.collections[0];
100
+ const flavorTags = (product.tags ?? [])
101
+ .filter((t) => t.startsWith("F: "))
102
+ .map((t) => ({
103
+ label: t.slice(3).trim(),
104
+ href: flavorCollection
105
+ ? `/collections/${flavorCollection}?tag=${encodeURIComponent(t)}`
106
+ : undefined,
107
+ }))
108
+ .filter((f) => f.label);
109
+ const vgPercentage = mf("my_fields", "vg_percentage");
110
+ const bottleType = mf("my_fields", "bottle_type");
111
+
112
+ // State sales restrictions (ToT compliance sync). Where this can't ship/sell.
113
+ let restrictedStates: string[] = [];
114
+ const restrictedRaw = mf("compliance", "restricted_states");
115
+ if (restrictedRaw) {
116
+ try {
117
+ const parsed = JSON.parse(restrictedRaw);
118
+ restrictedStates = Array.isArray(parsed) ? parsed : Object.keys(parsed);
119
+ } catch {
120
+ /* not JSON — ignore */
121
+ }
122
+ }
123
+
124
+ // Legal/safety disclaimer (scraped from the store's own PDP by the migration),
125
+ // per product category — sub-ohm on salts, battery on devices, etc. Selected by
126
+ // product_type (or the compliance.product_category metafield); default fallback.
127
+ const chrome = await Astro.locals.content.getChrome();
128
+ const productCategory = product.product_type || mf("compliance", "product_category");
129
+ const disclaimer =
130
+ (productCategory && chrome.disclaimers?.[productCategory]) || chrome.disclaimer;
131
+
132
+ // Full review content (migrated from the store's reviews app). The aggregate
133
+ // (reviewAvg/reviewCount above) comes from metafields; this is the per-review text.
134
+ const reviews = await tot.getReviews(product.handle);
135
+ function reviewDate(iso?: string): string {
136
+ if (!iso) return "";
137
+ const d = new Date(iso);
138
+ return Number.isNaN(d.getTime())
139
+ ? ""
140
+ : d.toLocaleDateString(tenant.locale, { year: "numeric", month: "short", day: "numeric" });
141
+ }
142
+
143
+ const onSale = isOnSale(product);
144
+ const available = isAvailable(product);
145
+
146
+ // FoxyCart add-to-cart (Track A). Pre-build each variant's cart payload server-
147
+ // side so the island can render a signed, tamper-proof form. In `signed` mode a
148
+ // missing secret means we render NO cart (never a tamperable form); the
149
+ // `unsigned-demo` mode needs no secret. Drafts still get a cart (preview links).
150
+ let foxyCart:
151
+ | { action: string; byVariantId: Record<string, VariantCartPayload> }
152
+ | undefined;
153
+ if (commerce) {
154
+ const secret = await readCartSecret(commerce);
155
+ if (commerce.mode !== "signed" || secret) {
156
+ const entries = await Promise.all(
157
+ product.variants.map(
158
+ async (v) =>
159
+ [
160
+ v.id,
161
+ await buildVariantCartPayload(product, v, {
162
+ mode: commerce.mode,
163
+ secret,
164
+ }),
165
+ ] as const,
166
+ ),
167
+ );
168
+ foxyCart = { action: commerce.cartUrl, byVariantId: Object.fromEntries(entries) };
169
+ }
170
+ }
171
+
172
+ const snapshot = {
173
+ handle: product.handle,
174
+ title: product.title,
175
+ price: fromPrice(product),
176
+ currency: product.price_range.currency,
177
+ vendor: product.vendor,
178
+ img: product.images[0]?.url,
179
+ rehosted: product.images[0]?.rehosted,
180
+ seed: product.images[0]?.id ?? product.handle,
181
+ };
182
+
183
+ const productJsonLd: Record<string, unknown> = productLd(product, tenant, canonicalBase);
184
+ // Enrich with real aggregate rating + reviews for rich results (SEO).
185
+ if (reviewCount > 0 && reviewAvg > 0) {
186
+ productJsonLd.aggregateRating = {
187
+ "@type": "AggregateRating",
188
+ ratingValue: reviewAvg,
189
+ reviewCount,
190
+ };
191
+ }
192
+ if (reviews.length > 0) {
193
+ productJsonLd.review = reviews.slice(0, 10).map((r) => ({
194
+ "@type": "Review",
195
+ reviewRating: { "@type": "Rating", ratingValue: r.rating, bestRating: 5 },
196
+ author: { "@type": "Person", name: r.author },
197
+ ...(r.title ? { name: r.title } : {}),
198
+ reviewBody: r.body,
199
+ ...(r.date ? { datePublished: r.date } : {}),
200
+ }));
201
+ }
202
+ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
203
+ ---
204
+
205
+ <Layout
206
+ title={product.seo.title || product.title}
207
+ description={product.seo.description || truncate(product.description_text, 160)}
208
+ path={`/products/${product.handle}`}
209
+ ogType="product"
210
+ ogImage={ogImage}
211
+ noindex={isDraft}
212
+ jsonLd={jsonLd}
213
+ >
214
+ <div class="container-prose pt-6">
215
+ <Breadcrumbs crumbs={crumbs} />
216
+ </div>
217
+
218
+ {isDraft && (
219
+ <div class="container-prose">
220
+ <p class="mt-4 rounded-[var(--radius-md)] border border-[var(--color-accent)] bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] px-4 py-3 text-sm text-[var(--color-text)]">
221
+ Draft preview — this product isn't published and won't appear in listings or search.
222
+ </p>
223
+ </div>
224
+ )}
225
+
226
+ <div class="container-prose grid gap-10 py-8 md:grid-cols-2 lg:gap-16">
227
+ {/* Gallery (island) */}
228
+ <div>
229
+ <ImageGallery
230
+ client:idle
231
+ images={product.images}
232
+ title={product.title}
233
+ productHandle={product.handle}
234
+ />
235
+ </div>
236
+
237
+ {/* Buy column */}
238
+ <div class="md:pt-2">
239
+ <div class="flex flex-wrap items-center gap-2">
240
+ {onSale && <Badge variant="sale" label="Sale" />}
241
+ {!onSale && isNew(product) && <Badge variant="new" label="New" />}
242
+ {!available && <Badge variant="soldout" label="Sold out" />}
243
+ </div>
244
+
245
+ {product.vendor && (
246
+ <p class="eyebrow mt-4">{product.vendor}</p>
247
+ )}
248
+ <h1 class="display-md mt-1.5">
249
+ {product.title}
250
+ </h1>
251
+
252
+ {/* Review badge — REAL aggregate (rating + count) from the store's reviews
253
+ app, mirrored to product metafields. Individual review content is not
254
+ migrated in Phase 1 (see LIMITATIONS.md / bonus round). */}
255
+ {reviewCount > 0 && reviewAvg > 0 && (
256
+ <div class="mt-3 flex items-center gap-2 text-sm text-[var(--color-muted)]">
257
+ <span class="text-[var(--color-accent)]" aria-hidden="true">
258
+ {"★".repeat(Math.round(reviewAvg))}{"☆".repeat(5 - Math.round(reviewAvg))}
259
+ </span>
260
+ <span>{reviewAvg.toFixed(1)} · {reviewCount} {reviewCount === 1 ? "review" : "reviews"}</span>
261
+ </div>
262
+ )}
263
+
264
+ {shortDescription && (
265
+ <p class="mt-4 max-w-prose text-[1.02rem] leading-relaxed text-[var(--color-text)]">
266
+ {shortDescription}
267
+ </p>
268
+ )}
269
+
270
+ {compliance?.nicotineWarning && (
271
+ <div class="mt-6">
272
+ <NicotineWarning variant="block" />
273
+ </div>
274
+ )}
275
+
276
+ {restrictedStates.length > 0 && (
277
+ <div class="mt-4 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm" role="note">
278
+ <p class="font-display font-medium text-[var(--color-text)]">Where we can ship this</p>
279
+ <p class="mt-1 text-[var(--color-muted)]">
280
+ Due to state laws, this product can't ship to: {restrictedStates.join(", ")}.
281
+ </p>
282
+ </div>
283
+ )}
284
+
285
+ {/* Variant selector island owns live price/SKU/availability + buy CTA. */}
286
+ <div class="mt-7">
287
+ <VariantSelector
288
+ client:load
289
+ variants={product.variants}
290
+ options={product.options}
291
+ currency={product.price_range.currency}
292
+ locale={tenant.locale}
293
+ foxyCart={foxyCart}
294
+ />
295
+ <button
296
+ type="button"
297
+ class="wishlist-toggle group mt-3 inline-flex w-full items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] py-2.5 font-display text-sm font-medium transition-colors hover:border-[var(--color-sale)] hover:text-[var(--color-sale)] aria-pressed:border-[var(--color-sale)] aria-pressed:text-[var(--color-sale)]"
298
+ aria-pressed="false"
299
+ aria-label={`Save ${product.title}`}
300
+ data-wishlist={JSON.stringify(snapshot)}
301
+ >
302
+ <svg class="wishlist-heart h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
303
+ <path d="M12 21s-7.5-4.6-10-9.2C.4 8.6 2 5 5.5 5 8 5 9.4 6.6 12 9c2.6-2.4 4-4 6.5-4C22 5 23.6 8.6 22 11.8 19.5 16.4 12 21 12 21z" />
304
+ </svg>
305
+ <span class="wl-label">Save for later</span>
306
+ </button>
307
+ </div>
308
+
309
+ {/* Rich description + attribute lines — matches the source store's PDP:
310
+ the longer description, then plain "Label: value" lines (no spec table). */}
311
+ <div class="mt-9 border-t border-[var(--color-border)] pt-7">
312
+ <div class="rte text-[var(--color-text)]" set:html={product.description_html} />
313
+ {(flavorTags.length > 0 || vgPercentage || bottleType) && (
314
+ <div class="mt-5 space-y-1.5 text-sm leading-relaxed">
315
+ {flavorTags.length > 0 && (
316
+ <p>
317
+ <span class="text-[var(--color-muted)]">Flavors:</span>{" "}
318
+ {flavorTags.map((f, i) => (
319
+ <>
320
+ {i > 0 && <span class="text-[var(--color-border)]"> | </span>}
321
+ {f.href ? (
322
+ <a href={withBase(basePath, f.href)} class="link-accent">{f.label}</a>
323
+ ) : (
324
+ <span>{f.label}</span>
325
+ )}
326
+ </>
327
+ ))}
328
+ </p>
329
+ )}
330
+ {vgPercentage && (
331
+ <p><span class="text-[var(--color-muted)]">VG Percentage:</span> {vgPercentage}</p>
332
+ )}
333
+ {bottleType && (
334
+ <p><span class="text-[var(--color-muted)]">Bottle Type:</span> {bottleType}</p>
335
+ )}
336
+ </div>
337
+ )}
338
+ </div>
339
+
340
+ {/* Safety & legal (scraped disclaimer) + Shipping & returns. The source
341
+ store shows no attribute/spec table — attributes appear as lines above. */}
342
+ <div class="mt-8 border-t border-[var(--color-border)]">
343
+ {disclaimer && (
344
+ <details class="accordion group">
345
+ <summary class="accordion__summary">
346
+ <span>Safety &amp; legal</span>
347
+ <span class="accordion__chevron" aria-hidden="true">+</span>
348
+ </summary>
349
+ <div class="accordion__body rte text-sm leading-relaxed text-[var(--color-muted)]" set:html={disclaimer} />
350
+ </details>
351
+ )}
352
+
353
+ <details class="accordion group">
354
+ <summary class="accordion__summary">
355
+ <span>Shipping &amp; returns</span>
356
+ <span class="accordion__chevron" aria-hidden="true">+</span>
357
+ </summary>
358
+ <div class="accordion__body text-sm leading-relaxed text-[var(--color-text)]">
359
+ <p>
360
+ Delivery times, rates, and returns are covered in our policy —
361
+ including the adult-signature (21+) requirement on every order.
362
+ </p>
363
+ <a
364
+ href={withBase(basePath, "/shipping-returns")}
365
+ class="link-accent mt-3 inline-block font-medium"
366
+ >
367
+ Shipping &amp; returns policy
368
+ </a>
369
+ </div>
370
+ </details>
371
+ </div>
372
+ </div>
373
+ </div>
374
+
375
+ {/* Customer reviews — real content migrated from the store's reviews app. */}
376
+ {reviews.length > 0 && (
377
+ <Section
378
+ title="Customer reviews"
379
+ eyebrow={`${reviewAvg.toFixed(1)} ★ · ${reviewCount || reviews.length} review${(reviewCount || reviews.length) === 1 ? "" : "s"}`}
380
+ >
381
+ <ul class="grid gap-5 md:grid-cols-2">
382
+ {reviews.map((r) => (
383
+ <li class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
384
+ <div class="flex items-center justify-between gap-3">
385
+ <span class="text-[var(--color-accent)]" aria-label={`${r.rating} out of 5 stars`}>
386
+ {"★".repeat(Math.round(r.rating))}<span class="text-[var(--color-border)]">{"★".repeat(5 - Math.round(r.rating))}</span>
387
+ </span>
388
+ {r.verifiedBuyer && (
389
+ <span class="font-mono text-[0.6rem] uppercase tracking-[0.12em] text-[var(--color-success)]">
390
+ Verified buyer
391
+ </span>
392
+ )}
393
+ </div>
394
+ {r.title && <p class="mt-2.5 font-display font-semibold leading-snug">{r.title}</p>}
395
+ <p class="mt-1.5 text-sm leading-relaxed text-[var(--color-text)]">{r.body}</p>
396
+ <p class="mt-3 text-xs text-[var(--color-muted)]">
397
+ {r.author}{reviewDate(r.date) ? ` · ${reviewDate(r.date)}` : ""}
398
+ </p>
399
+ </li>
400
+ ))}
401
+ </ul>
402
+ </Section>
403
+ )}
404
+
405
+ {/* Related */}
406
+ {related.length > 0 && (
407
+ <Section title="You may also like" eyebrow="More from the line" tone="surface">
408
+ <div class="grid grid-cols-2 gap-5 lg:grid-cols-4">
409
+ {related.map((p, i) => (
410
+ <ProductCard product={p} position={i} list="related" />
411
+ ))}
412
+ </div>
413
+ </Section>
414
+ )}
415
+
416
+ {/* Recently viewed (client island; records this product, shows the rest). */}
417
+ <RecentlyViewed
418
+ client:idle
419
+ locale={tenant.locale}
420
+ current={{
421
+ handle: product.handle,
422
+ title: product.title,
423
+ price: fromPrice(product),
424
+ currency: product.price_range.currency,
425
+ vendor: product.vendor,
426
+ img: product.images[0]?.url,
427
+ rehosted: product.images[0]?.rehosted,
428
+ seed: product.images[0]?.id ?? product.handle,
429
+ }}
430
+ />
431
+
432
+ {/* PDP-view analytics event. Phase 1: dispatched to a window event that the
433
+ no-op sink (or a Phase 2 real sink) can subscribe to. No-op by default. */}
434
+ <script type="application/json" id="pdp-meta" set:html={JSON.stringify({ type: "pdp_view", handle: product.handle })}></script>
435
+ <script is:inline nonce={Astro.locals.cspNonce}>
436
+ try {
437
+ const el = document.getElementById("pdp-meta");
438
+ if (el?.textContent) {
439
+ window.dispatchEvent(new CustomEvent("tot:analytics", { detail: JSON.parse(el.textContent) }));
440
+ }
441
+ } catch {}
442
+ </script>
443
+ </Layout>
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Per-tenant robots.txt. Allow all, keep crawlers out of the parameterized
3
+ * /search results, and point at this tenant's sitemap (canonical base is
4
+ * resolved per-request from the Host).
5
+ */
6
+ import type { APIContext } from "astro";
7
+
8
+ export async function GET(context: APIContext): Promise<Response> {
9
+ const base = context.locals.canonicalBase.replace(/\/$/, "");
10
+
11
+ const body = `User-agent: *
12
+ Allow: /
13
+ Disallow: /search
14
+
15
+ Sitemap: ${base}/sitemap.xml
16
+ `;
17
+
18
+ return new Response(body, {
19
+ headers: {
20
+ "content-type": "text/plain; charset=utf-8",
21
+ "cache-control": "public, max-age=3600",
22
+ },
23
+ });
24
+ }
@@ -0,0 +1,35 @@
1
+ ---
2
+ /**
3
+ * /saved — the wishlist page. Content is client-rendered from localStorage
4
+ * (SavedList island); noindex since it's per-device and has no stable content.
5
+ */
6
+ import Layout from "@/layouts/Layout.astro";
7
+ import Breadcrumbs from "@/components/Breadcrumbs.astro";
8
+ import SavedList from "@/components/islands/SavedList.tsx";
9
+
10
+ const { tenant } = Astro.locals;
11
+ // Commerce-only route: marketing tenants have no saved-items surface.
12
+ if (!Astro.locals.features.savedItems) return new Response(null, { status: 404 });
13
+ const crumbs = [
14
+ { label: tenant.displayName, href: "/" },
15
+ { label: "Saved", href: "/saved" },
16
+ ];
17
+ ---
18
+
19
+ <Layout
20
+ title="Saved items"
21
+ description="Products you've saved for later."
22
+ path="/saved"
23
+ noindex
24
+ >
25
+ <div class="container-prose pt-6">
26
+ <Breadcrumbs crumbs={crumbs} />
27
+ </div>
28
+ <div class="container-prose py-8">
29
+ <header class="mb-8" data-reveal>
30
+ <p class="eyebrow eyebrow--tick mb-3">Your list</p>
31
+ <h1 class="display-xl">Saved</h1>
32
+ </header>
33
+ <SavedList client:only="preact" locale={tenant.locale} />
34
+ </div>
35
+ </Layout>
@@ -0,0 +1,150 @@
1
+ ---
2
+ /**
3
+ * /search — dual-purpose route (brief §5 Search).
4
+ *
5
+ * 1. `?format=json` — returns `{ products: TypeaheadDoc[] }` for the header
6
+ * typeahead island. Search runs through the ToTClient (D1 FTS5 when bound, else
7
+ * a Fuse index over the snapshot) over ACTIVE products only (drafts never surface).
8
+ * 2. Otherwise — the full results page: a no-JS <form>, the echoed query, a
9
+ * result count, and a product grid. Results pages are `noindex` (standard
10
+ * practice — thin, parameterized, duplicative).
11
+ */
12
+ import Layout from "@/layouts/Layout.astro";
13
+ import Breadcrumbs from "@/components/Breadcrumbs.astro";
14
+ import ProductCard from "@/components/ProductCard.astro";
15
+ import EmptyState from "@/components/EmptyState.astro";
16
+ import Section from "@/components/Section.astro";
17
+ import { toTypeaheadDoc } from "@/lib/search";
18
+ import { breadcrumbLd } from "@/lib/jsonld";
19
+ import { withBase } from "@/lib/basePath";
20
+
21
+ const { tenant, tot, canonicalBase, basePath } = Astro.locals;
22
+ // Commerce-only route (page + typeahead JSON): off for marketing tenants.
23
+ if (!Astro.locals.features.productSearch) return new Response(null, { status: 404 });
24
+ const url = new URL(Astro.request.url);
25
+ const q = (url.searchParams.get("q") ?? "").trim();
26
+ const isJson = url.searchParams.get("format") === "json";
27
+
28
+ // Search goes through the ToTClient: D1 FTS5 when a D1 binding is wired, else a
29
+ // Fuse index over the bundled snapshot (FixtureToTClient). It returns full
30
+ // products, so the HTML grid renders directly and the typeahead just projects to
31
+ // the compact shape. See epics/edge-read-replica.md.
32
+
33
+ // --- JSON path: powers the header typeahead island. Must return BEFORE render.
34
+ if (isJson) {
35
+ const docs = (await tot.search(q, 8)).map(toTypeaheadDoc);
36
+ return new Response(JSON.stringify({ products: docs }), {
37
+ headers: { "content-type": "application/json" },
38
+ });
39
+ }
40
+
41
+ // --- HTML path.
42
+ const results = q ? await tot.search(q, 24) : [];
43
+ const featured = q ? [] : await tot.getFeatured(8);
44
+
45
+ const crumbs = [
46
+ { label: tenant.displayName, href: "/" },
47
+ { label: "Search", href: "/search" },
48
+ ];
49
+ ---
50
+
51
+ <Layout
52
+ title={q ? `Search: ${q}` : "Search"}
53
+ description={`Search the ${tenant.displayName} catalog.`}
54
+ path="/search"
55
+ noindex={true}
56
+ jsonLd={breadcrumbLd(crumbs, canonicalBase)}
57
+ >
58
+ <div class="container-prose pt-6">
59
+ <Breadcrumbs crumbs={crumbs} />
60
+ </div>
61
+
62
+ {/* Search hero + no-JS form (works without the island). */}
63
+ <header class="container-prose pb-2 pt-8">
64
+ <p class="eyebrow eyebrow--tick mb-3">Search</p>
65
+ <h1 class="display-xl">
66
+ {q ? <>Results for “{q}”</> : "What are you after?"}
67
+ </h1>
68
+ <form
69
+ method="get"
70
+ action={withBase(basePath, "/search")}
71
+ role="search"
72
+ class="mt-7 flex max-w-xl items-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2 focus-within:border-[var(--color-primary)]"
73
+ >
74
+ <svg
75
+ width="18"
76
+ height="18"
77
+ viewBox="0 0 24 24"
78
+ fill="none"
79
+ stroke="currentColor"
80
+ stroke-width="2"
81
+ stroke-linecap="round"
82
+ aria-hidden="true"
83
+ class="shrink-0 text-[var(--color-muted)]"
84
+ >
85
+ <circle cx="11" cy="11" r="7"></circle>
86
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
87
+ </svg>
88
+ <label class="sr-only" for="search-input">Search products</label>
89
+ <input
90
+ id="search-input"
91
+ type="search"
92
+ name="q"
93
+ value={q}
94
+ autocomplete="off"
95
+ placeholder="Search products"
96
+ class="w-full min-w-0 bg-transparent font-body text-text placeholder:text-muted focus:outline-none"
97
+ />
98
+ <button
99
+ type="submit"
100
+ class="shrink-0 rounded-full bg-[var(--color-primary)] px-5 py-1.5 font-display text-sm font-medium text-[var(--color-primary-contrast)] transition-transform hover:-translate-y-0.5"
101
+ >
102
+ Search
103
+ </button>
104
+ </form>
105
+ {q && (
106
+ <p class="mt-4 text-sm text-[var(--color-muted)]">
107
+ {results.length} {results.length === 1 ? "result" : "results"}
108
+ </p>
109
+ )}
110
+ </header>
111
+
112
+ {q ? (
113
+ results.length === 0 ? (
114
+ <EmptyState
115
+ title={`No matches for “${q}”`}
116
+ body="Nothing in the catalog answers to that — yet. Try a broader word, a category, or browse the full range."
117
+ ctaLabel="Browse collections"
118
+ ctaHref="/collections"
119
+ />
120
+ ) : (
121
+ <section class="bg-[var(--color-bg)] py-[clamp(2rem,5vw,3.5rem)]">
122
+ <div class="container-prose">
123
+ <div class="grid grid-cols-2 gap-5 sm:grid-cols-2 lg:grid-cols-4">
124
+ {results.map((p, i) => (
125
+ <ProductCard product={p} position={i} list="search" />
126
+ ))}
127
+ </div>
128
+ </div>
129
+ </section>
130
+ )
131
+ ) : (
132
+ <>
133
+ <EmptyState
134
+ title="Start with a word"
135
+ body="Search by name, maker, or material. Or wander through the collections below."
136
+ ctaLabel="Browse collections"
137
+ ctaHref="/collections"
138
+ />
139
+ {featured.length > 0 && (
140
+ <Section title="While you're here" eyebrow="Featured" tone="surface">
141
+ <div class="grid grid-cols-2 gap-5 lg:grid-cols-4">
142
+ {featured.map((p, i) => (
143
+ <ProductCard product={p} position={i} list="search-featured" />
144
+ ))}
145
+ </div>
146
+ </Section>
147
+ )}
148
+ </>
149
+ )}
150
+ </Layout>