@tokenoftrust/storefront-runner 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (310) hide show
  1. package/LICENSE +58 -0
  2. package/README.md +42 -0
  3. package/apps/storefront/astro.config.mjs +66 -0
  4. package/apps/storefront/dev-plugins/dev-loop-state.mjs +147 -0
  5. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +238 -0
  6. package/apps/storefront/env.d.ts +162 -0
  7. package/apps/storefront/migrations/0000_baseline.sql +54 -0
  8. package/apps/storefront/migrations/0001_products_fts.sql +9 -0
  9. package/apps/storefront/migrations/README.md +54 -0
  10. package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
  11. package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
  12. package/apps/storefront/migrations/meta/_journal.json +20 -0
  13. package/apps/storefront/package.json +42 -0
  14. package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
  15. package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
  16. package/apps/storefront/public/brand/northwind-og.svg +13 -0
  17. package/apps/storefront/public/favicon.svg +5 -0
  18. package/apps/storefront/public/js/dashboard-team.js +147 -0
  19. package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
  20. package/apps/storefront/public/shared/commerce-chrome.css +369 -0
  21. package/apps/storefront/src/components/Badge.astro +34 -0
  22. package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
  23. package/apps/storefront/src/components/CollectionCard.astro +56 -0
  24. package/apps/storefront/src/components/EmptyState.astro +55 -0
  25. package/apps/storefront/src/components/Img.astro +68 -0
  26. package/apps/storefront/src/components/ProductCard.astro +228 -0
  27. package/apps/storefront/src/components/Section.astro +39 -0
  28. package/apps/storefront/src/components/Seo.astro +70 -0
  29. package/apps/storefront/src/components/WidgetFrame.astro +34 -0
  30. package/apps/storefront/src/components/admin/AdminAIWorkflowsTab.astro +531 -0
  31. package/apps/storefront/src/components/admin/AdminCustomersTab.astro +597 -0
  32. package/apps/storefront/src/components/admin/AdminFoxyCartSettingsPanel.astro +227 -0
  33. package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +465 -0
  34. package/apps/storefront/src/components/admin/AdminProductsTab.astro +901 -0
  35. package/apps/storefront/src/components/admin/AdminReportingTab.astro +435 -0
  36. package/apps/storefront/src/components/admin/AdminSettingsTab.astro +782 -0
  37. package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +1223 -0
  38. package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
  39. package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
  40. package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
  41. package/apps/storefront/src/components/chrome/NavDropdown.astro +201 -0
  42. package/apps/storefront/src/components/chrome/SiteFooter.astro +201 -0
  43. package/apps/storefront/src/components/chrome/SiteHeader.astro +234 -0
  44. package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
  45. package/apps/storefront/src/components/commerce/ArticleCard.astro +242 -0
  46. package/apps/storefront/src/components/commerce/BrandGrid.astro +175 -0
  47. package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
  48. package/apps/storefront/src/components/commerce/FoxySidecartStyles.astro +61 -0
  49. package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
  50. package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
  51. package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
  52. package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
  53. package/apps/storefront/src/components/commerce/ReviewList.astro +54 -0
  54. package/apps/storefront/src/components/commerce/ReviewSummary.astro +44 -0
  55. package/apps/storefront/src/components/commerce/TagFilterCloud.astro +149 -0
  56. package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +37 -0
  57. package/apps/storefront/src/components/compliance/ComplianceNotice.astro +99 -0
  58. package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +45 -0
  59. package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
  60. package/apps/storefront/src/components/compliance/PactActNotice.astro +35 -0
  61. package/apps/storefront/src/components/compliance/Prop65Warning.astro +80 -0
  62. package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +38 -0
  63. package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +35 -0
  64. package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +53 -0
  65. package/apps/storefront/src/components/home/Hero.astro +164 -0
  66. package/apps/storefront/src/components/islands/AgeGate.tsx +109 -0
  67. package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
  68. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
  69. package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
  70. package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
  71. package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
  72. package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
  73. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
  74. package/apps/storefront/src/components/islands/VariantSelector.tsx +375 -0
  75. package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
  76. package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
  77. package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
  78. package/apps/storefront/src/components/marketing/Faq.astro +37 -0
  79. package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
  80. package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
  81. package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
  82. package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
  83. package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
  84. package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
  85. package/apps/storefront/src/components/marketing/Steps.astro +36 -0
  86. package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
  87. package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
  88. package/apps/storefront/src/components/membership/IllustrativeLabel.astro +56 -0
  89. package/apps/storefront/src/components/membership/TierCards.astro +164 -0
  90. package/apps/storefront/src/components/membership/TierComparisonTable.astro +148 -0
  91. package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
  92. package/apps/storefront/src/components/plp/FacetSidebar.astro +199 -0
  93. package/apps/storefront/src/components/plp/Pagination.astro +99 -0
  94. package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
  95. package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +95 -0
  96. package/apps/storefront/src/components/subscription/HowItWorksSteps.astro +67 -0
  97. package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +67 -0
  98. package/apps/storefront/src/components/subscription/SavingsExplainer.astro +101 -0
  99. package/apps/storefront/src/config/adminTargetTenant.ts +28 -0
  100. package/apps/storefront/src/config/devTenantSeed.ts +78 -0
  101. package/apps/storefront/src/config/devTenants.ts +16 -0
  102. package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
  103. package/apps/storefront/src/config/resolver.ts +102 -0
  104. package/apps/storefront/src/config/tenants.ts +87 -0
  105. package/apps/storefront/src/layouts/Layout.astro +368 -0
  106. package/apps/storefront/src/lib/admin/README.md +122 -0
  107. package/apps/storefront/src/lib/admin/ai/approvalMode.ts +21 -0
  108. package/apps/storefront/src/lib/admin/ai/askEnvelope.ts +48 -0
  109. package/apps/storefront/src/lib/admin/ai/audit.ts +14 -0
  110. package/apps/storefront/src/lib/admin/ai/index.ts +6 -0
  111. package/apps/storefront/src/lib/admin/ai/operationalRuns.ts +155 -0
  112. package/apps/storefront/src/lib/admin/ai/recommendation.ts +20 -0
  113. package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +172 -0
  114. package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +65 -0
  115. package/apps/storefront/src/lib/admin/envelope.ts +10 -0
  116. package/apps/storefront/src/lib/admin/evidence/evidenceReference.ts +32 -0
  117. package/apps/storefront/src/lib/admin/memoizeByEnvelope.ts +27 -0
  118. package/apps/storefront/src/lib/admin/orderClient.ts +146 -0
  119. package/apps/storefront/src/lib/admin/seed.ts +54 -0
  120. package/apps/storefront/src/lib/admin/services/mock/aiWorkflows.ts +59 -0
  121. package/apps/storefront/src/lib/admin/services/mock/customers.ts +86 -0
  122. package/apps/storefront/src/lib/admin/services/mock/evidence.ts +99 -0
  123. package/apps/storefront/src/lib/admin/services/mock/foxyCartSettings.ts +78 -0
  124. package/apps/storefront/src/lib/admin/services/mock/fulfillment.ts +62 -0
  125. package/apps/storefront/src/lib/admin/services/mock/index.ts +12 -0
  126. package/apps/storefront/src/lib/admin/services/mock/orders.ts +184 -0
  127. package/apps/storefront/src/lib/admin/services/mock/products.ts +105 -0
  128. package/apps/storefront/src/lib/admin/services/mock/reporting.ts +51 -0
  129. package/apps/storefront/src/lib/admin/services/mock/settings.ts +70 -0
  130. package/apps/storefront/src/lib/admin/services/mock/tenants.ts +29 -0
  131. package/apps/storefront/src/lib/admin/services/orders.ts +300 -0
  132. package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +67 -0
  133. package/apps/storefront/src/lib/admin/viewmodels/customers.ts +56 -0
  134. package/apps/storefront/src/lib/admin/viewmodels/fulfillment.ts +49 -0
  135. package/apps/storefront/src/lib/admin/viewmodels/orders.ts +92 -0
  136. package/apps/storefront/src/lib/admin/viewmodels/products.ts +74 -0
  137. package/apps/storefront/src/lib/admin/viewmodels/reporting.ts +34 -0
  138. package/apps/storefront/src/lib/admin/viewmodels/settings.ts +64 -0
  139. package/apps/storefront/src/lib/adminTenantEnvelope.ts +90 -0
  140. package/apps/storefront/src/lib/analytics/index.ts +47 -0
  141. package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
  142. package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
  143. package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
  144. package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
  145. package/apps/storefront/src/lib/auth/loginGate.ts +119 -0
  146. package/apps/storefront/src/lib/auth/route.ts +146 -0
  147. package/apps/storefront/src/lib/auth/session.ts +203 -0
  148. package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
  149. package/apps/storefront/src/lib/basePath.ts +41 -0
  150. package/apps/storefront/src/lib/cfImage.ts +55 -0
  151. package/apps/storefront/src/lib/chrome/model.ts +244 -0
  152. package/apps/storefront/src/lib/chrome/parseConfig.ts +34 -0
  153. package/apps/storefront/src/lib/colors.ts +97 -0
  154. package/apps/storefront/src/lib/commerce/foxyCartSettings.ts +253 -0
  155. package/apps/storefront/src/lib/content-edit/applyDiff.ts +91 -0
  156. package/apps/storefront/src/lib/content-edit/client.ts +230 -0
  157. package/apps/storefront/src/lib/content-edit/index.ts +14 -0
  158. package/apps/storefront/src/lib/content-edit/route.ts +25 -0
  159. package/apps/storefront/src/lib/content-edit/types.ts +19 -0
  160. package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
  161. package/apps/storefront/src/lib/d1/catalog.ts +366 -0
  162. package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
  163. package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
  164. package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
  165. package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
  166. package/apps/storefront/src/lib/demoGate.ts +113 -0
  167. package/apps/storefront/src/lib/dev/activityIngestToken.ts +78 -0
  168. package/apps/storefront/src/lib/dev/activityStore.ts +72 -0
  169. package/apps/storefront/src/lib/dev/ai-widget-client.js +308 -0
  170. package/apps/storefront/src/lib/dev/apiBase.ts +14 -0
  171. package/apps/storefront/src/lib/dev/cliSignInCode.ts +155 -0
  172. package/apps/storefront/src/lib/dev/devDraft.ts +133 -0
  173. package/apps/storefront/src/lib/dev/hostedCockpit.ts +169 -0
  174. package/apps/storefront/src/lib/dev/livePreview.ts +77 -0
  175. package/apps/storefront/src/lib/dev/runtimeStore.ts +120 -0
  176. package/apps/storefront/src/lib/dev/storeStatus.ts +142 -0
  177. package/apps/storefront/src/lib/diag/redact.ts +20 -0
  178. package/apps/storefront/src/lib/edgeCache.ts +128 -0
  179. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
  180. package/apps/storefront/src/lib/env.ts +60 -0
  181. package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
  182. package/apps/storefront/src/lib/format.ts +38 -0
  183. package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
  184. package/apps/storefront/src/lib/imageAttrs.ts +55 -0
  185. package/apps/storefront/src/lib/jsonld.ts +166 -0
  186. package/apps/storefront/src/lib/maintenance.ts +71 -0
  187. package/apps/storefront/src/lib/membership/model.ts +97 -0
  188. package/apps/storefront/src/lib/memoryKv.ts +32 -0
  189. package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
  190. package/apps/storefront/src/lib/placeholder.ts +99 -0
  191. package/apps/storefront/src/lib/previewReload.ts +125 -0
  192. package/apps/storefront/src/lib/priceBuckets.ts +33 -0
  193. package/apps/storefront/src/lib/rawChrome.ts +505 -0
  194. package/apps/storefront/src/lib/rawMarketingHtml.ts +136 -0
  195. package/apps/storefront/src/lib/search/index.ts +74 -0
  196. package/apps/storefront/src/lib/sections.ts +129 -0
  197. package/apps/storefront/src/lib/securityHeaders.ts +31 -0
  198. package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
  199. package/apps/storefront/src/lib/storyblok/provider.ts +607 -0
  200. package/apps/storefront/src/lib/subscription/model.ts +68 -0
  201. package/apps/storefront/src/lib/tenantRouting.ts +91 -0
  202. package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
  203. package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
  204. package/apps/storefront/src/lib/tot/query.ts +249 -0
  205. package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
  206. package/apps/storefront/src/lib/url.ts +142 -0
  207. package/apps/storefront/src/lib/wishlist.ts +63 -0
  208. package/apps/storefront/src/middleware/index.ts +455 -0
  209. package/apps/storefront/src/pages/404.astro +61 -0
  210. package/apps/storefront/src/pages/[...slug].astro +90 -0
  211. package/apps/storefront/src/pages/admin.astro +1675 -0
  212. package/apps/storefront/src/pages/api/admin/orders/[id].ts +53 -0
  213. package/apps/storefront/src/pages/api/admin/orders.ts +71 -0
  214. package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
  215. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
  216. package/apps/storefront/src/pages/api/auth/send.ts +85 -0
  217. package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
  218. package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
  219. package/apps/storefront/src/pages/api/checkout-style.css.ts +43 -0
  220. package/apps/storefront/src/pages/api/cli/policy.ts +70 -0
  221. package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +164 -0
  222. package/apps/storefront/src/pages/api/csp-report.ts +25 -0
  223. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
  224. package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
  225. package/apps/storefront/src/pages/api/dev/activity.ts +124 -0
  226. package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +73 -0
  227. package/apps/storefront/src/pages/api/dev/diff.ts +68 -0
  228. package/apps/storefront/src/pages/api/dev/file.ts +57 -0
  229. package/apps/storefront/src/pages/api/dev/locate.ts +125 -0
  230. package/apps/storefront/src/pages/api/dev/publish.ts +128 -0
  231. package/apps/storefront/src/pages/api/dev/tree.ts +70 -0
  232. package/apps/storefront/src/pages/api/dev/write.ts +116 -0
  233. package/apps/storefront/src/pages/api/newsletter.ts +104 -0
  234. package/apps/storefront/src/pages/api/request-access.ts +172 -0
  235. package/apps/storefront/src/pages/auth/login.astro +149 -0
  236. package/apps/storefront/src/pages/auth/magic.astro +105 -0
  237. package/apps/storefront/src/pages/capabilities.astro +292 -0
  238. package/apps/storefront/src/pages/collections/[handle].astro +177 -0
  239. package/apps/storefront/src/pages/collections/index.astro +59 -0
  240. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
  241. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
  242. package/apps/storefront/src/pages/dashboard/index.astro +160 -0
  243. package/apps/storefront/src/pages/index.astro +290 -0
  244. package/apps/storefront/src/pages/llms.txt.ts +92 -0
  245. package/apps/storefront/src/pages/products/[handle].astro +402 -0
  246. package/apps/storefront/src/pages/robots.txt.ts +24 -0
  247. package/apps/storefront/src/pages/saved.astro +35 -0
  248. package/apps/storefront/src/pages/search.astro +150 -0
  249. package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
  250. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +1280 -0
  251. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +229 -0
  252. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +144 -0
  253. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +116 -0
  254. package/apps/storefront/src/pages/style-guide/index.astro +98 -0
  255. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
  256. package/apps/storefront/src/styles/global.css +292 -0
  257. package/apps/storefront/src/themes/reference.ts +68 -0
  258. package/apps/storefront/src/themes/schema.ts +111 -0
  259. package/apps/storefront/src/themes/tenants/index.ts +51 -0
  260. package/apps/storefront/tsconfig.json +16 -0
  261. package/apps/storefront/vitest.config.ts +22 -0
  262. package/package.json +26 -0
  263. package/packages/public-runtime/LICENSE +58 -0
  264. package/packages/public-runtime/package.json +22 -0
  265. package/packages/public-runtime/src/binary-path.ts +68 -0
  266. package/packages/public-runtime/src/capabilities.ts +89 -0
  267. package/packages/public-runtime/src/catalog-api.ts +81 -0
  268. package/packages/public-runtime/src/catalog-d1.ts +192 -0
  269. package/packages/public-runtime/src/checkout-style-publish.ts +200 -0
  270. package/packages/public-runtime/src/checkout-style.ts +375 -0
  271. package/packages/public-runtime/src/csp.ts +109 -0
  272. package/packages/public-runtime/src/customization-preview.ts +197 -0
  273. package/packages/public-runtime/src/customization-reconcile.ts +63 -0
  274. package/packages/public-runtime/src/customization-runtime.ts +157 -0
  275. package/packages/public-runtime/src/customization-scripts.ts +64 -0
  276. package/packages/public-runtime/src/customization-versioning.ts +95 -0
  277. package/packages/public-runtime/src/foxy-orders.ts +136 -0
  278. package/packages/public-runtime/src/foxy.ts +196 -0
  279. package/packages/public-runtime/src/hot-standby-readiness.ts +140 -0
  280. package/packages/public-runtime/src/index.ts +34 -0
  281. package/packages/public-runtime/src/product.ts +159 -0
  282. package/packages/public-runtime/src/search.ts +45 -0
  283. package/packages/public-runtime/src/tenant-assets.ts +444 -0
  284. package/packages/public-runtime/src/tenant.ts +384 -0
  285. package/packages/public-runtime/tsconfig.json +8 -0
  286. package/packages/public-runtime/vitest.config.ts +8 -0
  287. package/pnpm-workspace.yaml +7 -0
  288. package/scripts/build/copy-tenant-assets.mjs +69 -0
  289. package/scripts/dev/ai-edit.mjs +183 -0
  290. package/scripts/dev/checkout-watch.mjs +116 -0
  291. package/scripts/dev/file-browser.mjs +159 -0
  292. package/scripts/dev/file-writer.mjs +141 -0
  293. package/scripts/dev/git-status.mjs +171 -0
  294. package/scripts/dev/locate-handler.mjs +163 -0
  295. package/scripts/dev/locate.mjs +112 -0
  296. package/scripts/dev/port-check.mjs +48 -0
  297. package/scripts/dev/safe-path.mjs +109 -0
  298. package/scripts/dev/shot.mjs +46 -0
  299. package/scripts/dev/tenant-source-root.mjs +76 -0
  300. package/scripts/dev/unified-diff.mjs +81 -0
  301. package/scripts/tot-dev.mjs +376 -0
  302. package/tenants/README.md +14 -0
  303. package/tenants/home/content/chrome.json +7 -0
  304. package/tenants/home/content/home.html +58 -0
  305. package/tenants/home/content/home.json +11 -0
  306. package/tenants/home/public/js/storefront.js +210 -0
  307. package/tenants/home/public/logo-wordmark.png +0 -0
  308. package/tenants/home/public/pages/storefront.css +212 -0
  309. package/tenants/home/theme.json +6 -0
  310. package/tsconfig.base.json +20 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Per-tenant llms.txt — the emerging /llms.txt convention: a concise,
3
+ * machine-readable guide that helps LLM answer engines (ChatGPT, Perplexity,
4
+ * Google AI Overviews, etc.) understand what this store is and where its key
5
+ * content lives. Mirrors robots.txt / sitemap.xml: built per request from the
6
+ * resolved tenant's catalog + content, canonical base from the Host.
7
+ *
8
+ * Every line traces to REAL migrated data or tenant config — no fabricated copy
9
+ * (playbook/codebase-health.md T7). Summary/descriptions are the merchant's own
10
+ * SEO text; compliance facts come from tenant.compliance.
11
+ */
12
+ import type { APIContext } from "astro";
13
+ import { createContentProvider } from "@/lib/storyblok/provider";
14
+
15
+ /** Collapse HTML/whitespace to a single trimmed line for a link description. */
16
+ function oneLine(s: string | undefined, max = 140): string | undefined {
17
+ if (!s) return undefined;
18
+ const t = s.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
19
+ if (!t) return undefined;
20
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
21
+ }
22
+
23
+ export async function GET(context: APIContext): Promise<Response> {
24
+ const { tot, tenant, canonicalBase } = context.locals;
25
+ const base = canonicalBase.replace(/\/$/, "");
26
+ const provider = createContentProvider(tenant);
27
+
28
+ const [collections, pages, featured, home] = await Promise.all([
29
+ tot.listCollections(),
30
+ provider.listPages(),
31
+ tot.getFeatured(12),
32
+ provider.getHome().catch(() => null),
33
+ ]);
34
+
35
+ const lines: string[] = [`# ${tenant.displayName}`, ""];
36
+
37
+ const summary = oneLine(home?.seoDescription, 280);
38
+ if (summary) lines.push(`> ${summary}`, "");
39
+
40
+ // Compliance facts — sourced from tenant config, never invented.
41
+ const c = tenant.compliance;
42
+ if (c && (c.minAge || c.nicotineWarning || c.shippingRestriction)) {
43
+ const facts: string[] = [];
44
+ if (c.minAge) facts.push(`age-restricted — customers must be ${c.minAge}+`);
45
+ if (c.nicotineWarning) facts.push("products contain nicotine (FDA warning applies)");
46
+ if (facts.length) lines.push(`**Compliance:** ${facts.join("; ")}.`);
47
+ const ship = oneLine(c.shippingRestriction, 240);
48
+ if (ship) lines.push(`**Shipping:** ${ship}`);
49
+ lines.push("");
50
+ }
51
+
52
+ if (collections.length) {
53
+ lines.push("## Collections");
54
+ for (const col of collections) {
55
+ const desc = oneLine(col.description_text ?? col.seo.description);
56
+ lines.push(`- [${col.title}](${base}/collections/${col.handle})${desc ? `: ${desc}` : ""}`);
57
+ }
58
+ lines.push("");
59
+ }
60
+
61
+ if (featured.length) {
62
+ lines.push("## Featured products");
63
+ for (const p of featured) {
64
+ const desc = oneLine(p.seo.description ?? p.description_text);
65
+ lines.push(`- [${p.title}](${base}/products/${p.handle})${desc ? `: ${desc}` : ""}`);
66
+ }
67
+ lines.push("");
68
+ }
69
+
70
+ if (pages.length) {
71
+ lines.push("## Pages");
72
+ for (const pg of pages) {
73
+ lines.push(`- [${pg.title}](${base}/${pg.slug})`);
74
+ }
75
+ lines.push("");
76
+ }
77
+
78
+ lines.push("## Optional");
79
+ lines.push(`- [Full product catalog (XML sitemap)](${base}/sitemap.xml)`);
80
+ lines.push(`- [Search](${base}/search)`);
81
+ lines.push(
82
+ "- Every page ships Schema.org JSON-LD (Product, Offer, AggregateRating, Breadcrumb, Organization) for structured extraction.",
83
+ );
84
+ lines.push("");
85
+
86
+ return new Response(lines.join("\n"), {
87
+ headers: {
88
+ "content-type": "text/plain; charset=utf-8",
89
+ "cache-control": "public, max-age=300, s-maxage=3600",
90
+ },
91
+ });
92
+ }
@@ -0,0 +1,402 @@
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 ReviewSummary from "@/components/commerce/ReviewSummary.astro";
18
+ import ReviewList from "@/components/commerce/ReviewList.astro";
19
+ import { isOnSale, isNew, isAvailable, fromPrice } from "@/lib/tot/query";
20
+ import { buildVariantCartPayload } from "@tot/public-runtime";
21
+ import type { VariantCartPayload } from "@tot/public-runtime";
22
+ import { readCartSecret } from "@/lib/foxyCommerce";
23
+ import { truncate } from "@/lib/format";
24
+ import { productLd, breadcrumbLd, reviewLd } from "@/lib/jsonld";
25
+ import { isUsableImageUrl } from "@/lib/placeholder";
26
+ import { withBase } from "@/lib/basePath";
27
+
28
+ const { handle } = Astro.params;
29
+ const { tenant, tot, canonicalBase, basePath, commerce } = Astro.locals;
30
+ // Commerce-only route: marketing tenants have no product detail pages.
31
+ if (!Astro.locals.features.catalog) return new Response(null, { status: 404 });
32
+ const compliance = tenant.compliance;
33
+
34
+ if (!handle) return Astro.redirect(withBase(basePath, "/404"));
35
+ const product = await tot.getProductByHandle(handle);
36
+
37
+ // Draft canary: drafts resolve by handle (preview/direct link) but are noindex.
38
+ if (!product || product.status === "archived") {
39
+ return new Response(null, { status: 404 });
40
+ }
41
+ const isDraft = product.status !== "active";
42
+
43
+ // Related: same collection first, then shared-tag fallback, exclude self+drafts.
44
+ const related = await (async () => {
45
+ const out = new Map<string, typeof product>();
46
+ for (const colHandle of product.collections) {
47
+ const { products } = await tot.listProducts({ collection: colHandle, pageSize: 12 });
48
+ for (const p of products) if (p.handle !== product.handle) out.set(p.handle, p);
49
+ }
50
+ if (out.size < 4 && product.tags.length) {
51
+ const { products } = await tot.listProducts({ pageSize: 50 });
52
+ for (const p of products) {
53
+ if (p.handle === product.handle) continue;
54
+ if (p.tags.some((t) => product.tags.includes(t))) out.set(p.handle, p);
55
+ }
56
+ }
57
+ return [...out.values()].slice(0, 4);
58
+ })();
59
+
60
+ const crumbs = [
61
+ { label: tenant.displayName, href: "/" },
62
+ ...(product.collections[0]
63
+ ? [{ label: titleCase(product.collections[0]), href: `/collections/${product.collections[0]}` }]
64
+ : []),
65
+ { label: product.title, href: `/products/${product.handle}` },
66
+ ];
67
+
68
+ function titleCase(s: string) {
69
+ return s.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
70
+ }
71
+
72
+ // Metafield reader.
73
+ const mf = (ns: string, key: string) =>
74
+ product.metafields.find((m) => m.namespace === ns && m.key === key)?.value;
75
+
76
+ // Reviews — REAL data from the store's reviews app (Stamped), pulled as metafields.
77
+ const reviewCount = Number(mf("stamped", "reviews_count") ?? mf("reviews", "count") ?? "0");
78
+ const reviewAvg = Number(mf("stamped", "reviews_average") ?? mf("reviews", "rating") ?? "0");
79
+
80
+ // Short description (merchant's concise summary) shown high on the PDP, like the
81
+ // source store. The longer description_html still renders below the buy column.
82
+ const shortDescription = mf("my_fields", "short_description") ?? mf("specs", "short_description");
83
+
84
+ // OG image = the real product image (Shopify CDN supports ?width=), sized for
85
+ // social. Falls back to the tenant brand OG only when there's no usable image.
86
+ const ogPrimary = product.images[0];
87
+ const ogImage =
88
+ ogPrimary && isUsableImageUrl(ogPrimary.url, ogPrimary.rehosted)
89
+ ? `${ogPrimary.url}${ogPrimary.url.includes("?") ? "&" : "?"}width=1200`
90
+ : undefined;
91
+
92
+ // Product attributes shown the way the SOURCE STORE presents them: plain
93
+ // "Label: value" lines below the description (Flavors / VG % / Bottle Type),
94
+ // sourced from F:-prefixed tags + merchant my_fields. The compliance.* metafields
95
+ // are ToT-internal sync data and are NOT displayed — the source store doesn't
96
+ // show them either (they gate shipping/eligibility, not merchandising).
97
+ // Each flavor links to that flavor filtered within the product's collection —
98
+ // mirrors the source store's /collections/<handle>/<tag> pattern using our
99
+ // existing ?tag= PLP filter. General: any tag → a filtered listing; only the
100
+ // "F: " flavor-tag convention is merchant-specific (same as the display above).
101
+ const flavorCollection = product.collections[0];
102
+ const flavorTags = (product.tags ?? [])
103
+ .filter((t) => t.startsWith("F: "))
104
+ .map((t) => ({
105
+ label: t.slice(3).trim(),
106
+ href: flavorCollection
107
+ ? `/collections/${flavorCollection}?tag=${encodeURIComponent(t)}`
108
+ : undefined,
109
+ }))
110
+ .filter((f) => f.label);
111
+ const vgPercentage = mf("my_fields", "vg_percentage");
112
+ const bottleType = mf("my_fields", "bottle_type");
113
+
114
+ // State sales restrictions (ToT compliance sync). Where this can't ship/sell.
115
+ let restrictedStates: string[] = [];
116
+ const restrictedRaw = mf("compliance", "restricted_states");
117
+ if (restrictedRaw) {
118
+ try {
119
+ const parsed = JSON.parse(restrictedRaw);
120
+ restrictedStates = Array.isArray(parsed) ? parsed : Object.keys(parsed);
121
+ } catch {
122
+ /* not JSON — ignore */
123
+ }
124
+ }
125
+
126
+ // Legal/safety disclaimer (scraped from the store's own PDP by the migration),
127
+ // per product category — sub-ohm on salts, battery on devices, etc. Selected by
128
+ // product_type (or the compliance.product_category metafield); default fallback.
129
+ const chrome = await Astro.locals.content.getChrome();
130
+ const productCategory = product.product_type || mf("compliance", "product_category");
131
+ const disclaimer =
132
+ (productCategory && chrome.disclaimers?.[productCategory]) || chrome.disclaimer;
133
+
134
+ // Full review content (migrated from the store's reviews app). The aggregate
135
+ // (reviewAvg/reviewCount above) comes from metafields; this is the per-review text.
136
+ const reviews = await tot.getReviews(product.handle);
137
+
138
+ const onSale = isOnSale(product);
139
+ const available = isAvailable(product);
140
+
141
+ // FoxyCart add-to-cart (Track A). Pre-build each variant's cart payload server-
142
+ // side so the island can render a signed, tamper-proof form. In `signed` mode a
143
+ // missing secret means we render NO cart (never a tamperable form); the
144
+ // `unsigned-demo` mode needs no secret. Drafts still get a cart (preview links).
145
+ let foxyCart:
146
+ | { action: string; byVariantId: Record<string, VariantCartPayload> }
147
+ | undefined;
148
+ if (commerce) {
149
+ const secret = await readCartSecret(commerce);
150
+ if (commerce.mode !== "signed" || secret) {
151
+ const entries = await Promise.all(
152
+ product.variants.map(
153
+ async (v) =>
154
+ [
155
+ v.id,
156
+ await buildVariantCartPayload(product, v, {
157
+ mode: commerce.mode,
158
+ secret,
159
+ }),
160
+ ] as const,
161
+ ),
162
+ );
163
+ foxyCart = { action: commerce.cartUrl, byVariantId: Object.fromEntries(entries) };
164
+ }
165
+ }
166
+
167
+ const snapshot = {
168
+ handle: product.handle,
169
+ title: product.title,
170
+ price: fromPrice(product),
171
+ currency: product.price_range.currency,
172
+ vendor: product.vendor,
173
+ img: product.images[0]?.url,
174
+ rehosted: product.images[0]?.rehosted,
175
+ seed: product.images[0]?.id ?? product.handle,
176
+ };
177
+
178
+ // Enrich with the real aggregate rating + reviews for rich results (SEO), per the
179
+ // review-markup policy (docs/architecture/review-markup-policy.md).
180
+ const productJsonLd: Record<string, unknown> = {
181
+ ...productLd(product, tenant, canonicalBase),
182
+ ...reviewLd(reviewAvg, reviewCount, reviews),
183
+ };
184
+ const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
185
+ ---
186
+
187
+ <Layout
188
+ title={product.seo.title || product.title}
189
+ description={product.seo.description || truncate(product.description_text, 160)}
190
+ path={`/products/${product.handle}`}
191
+ ogType="product"
192
+ ogImage={ogImage}
193
+ noindex={isDraft}
194
+ jsonLd={jsonLd}
195
+ >
196
+ <div class="container-prose pt-6">
197
+ <Breadcrumbs crumbs={crumbs} />
198
+ </div>
199
+
200
+ {isDraft && (
201
+ <div class="container-prose">
202
+ <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)]">
203
+ Draft preview — this product isn't published and won't appear in listings or search.
204
+ </p>
205
+ </div>
206
+ )}
207
+
208
+ <div class="container-prose grid gap-10 py-8 md:grid-cols-2 lg:gap-16">
209
+ {/* Gallery (island) */}
210
+ <div>
211
+ <ImageGallery
212
+ client:idle
213
+ images={product.images}
214
+ title={product.title}
215
+ productHandle={product.handle}
216
+ />
217
+ </div>
218
+
219
+ {/* Buy column */}
220
+ <div class="md:pt-2">
221
+ <div class="flex flex-wrap items-center gap-2">
222
+ {onSale && <Badge variant="sale" label="Sale" />}
223
+ {!onSale && isNew(product) && <Badge variant="new" label="New" />}
224
+ {!available && <Badge variant="soldout" label="Sold out" />}
225
+ </div>
226
+
227
+ {product.vendor && (
228
+ <p class="eyebrow mt-4">{product.vendor}</p>
229
+ )}
230
+ <h1 class="display-md mt-1.5">
231
+ {product.title}
232
+ </h1>
233
+
234
+ {/* Review badge — REAL aggregate (rating + count) from the store's reviews
235
+ app, mirrored to product metafields. */}
236
+ {reviewCount > 0 && reviewAvg > 0 && (
237
+ <div class="mt-3">
238
+ <ReviewSummary average={reviewAvg} count={reviewCount} href={reviews.length > 0 ? "#reviews" : undefined} />
239
+ </div>
240
+ )}
241
+
242
+ {shortDescription && (
243
+ <p class="mt-4 max-w-prose text-[1.02rem] leading-relaxed text-[var(--color-text)]">
244
+ {shortDescription}
245
+ </p>
246
+ )}
247
+
248
+ {compliance?.nicotineWarning && (
249
+ <div class="mt-6">
250
+ <NicotineWarning variant="block" />
251
+ </div>
252
+ )}
253
+
254
+ {restrictedStates.length > 0 && (
255
+ <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">
256
+ <p class="font-display font-medium text-[var(--color-text)]">Where we can ship this</p>
257
+ <p class="mt-1 text-[var(--color-muted)]">
258
+ Due to state laws, this product can't ship to: {restrictedStates.join(", ")}.
259
+ </p>
260
+ </div>
261
+ )}
262
+
263
+ {/* Variant selector island owns live price/SKU/availability + buy CTA. */}
264
+ <div class="mt-7">
265
+ <VariantSelector
266
+ client:load
267
+ variants={product.variants}
268
+ options={product.options}
269
+ currency={product.price_range.currency}
270
+ locale={tenant.locale}
271
+ foxyCart={foxyCart}
272
+ />
273
+ <button
274
+ type="button"
275
+ 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)]"
276
+ aria-pressed="false"
277
+ aria-label={`Save ${product.title}`}
278
+ data-wishlist={JSON.stringify(snapshot)}
279
+ >
280
+ <svg class="wishlist-heart h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
281
+ <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" />
282
+ </svg>
283
+ <span class="wl-label">Save for later</span>
284
+ </button>
285
+ </div>
286
+
287
+ {/* Rich description + attribute lines — matches the source store's PDP:
288
+ the longer description, then plain "Label: value" lines (no spec table). */}
289
+ <div class="mt-9 border-t border-[var(--color-border)] pt-7">
290
+ <div class="rte text-[var(--color-text)]" set:html={product.description_html} />
291
+ {(flavorTags.length > 0 || vgPercentage || bottleType) && (
292
+ <div class="mt-5 space-y-1.5 text-sm leading-relaxed">
293
+ {flavorTags.length > 0 && (
294
+ <p>
295
+ <span class="text-[var(--color-muted)]">Flavors:</span>{" "}
296
+ {flavorTags.map((f, i) => (
297
+ <>
298
+ {i > 0 && <span class="text-[var(--color-border)]"> | </span>}
299
+ {f.href ? (
300
+ <a href={withBase(basePath, f.href)} class="link-accent">{f.label}</a>
301
+ ) : (
302
+ <span>{f.label}</span>
303
+ )}
304
+ </>
305
+ ))}
306
+ </p>
307
+ )}
308
+ {vgPercentage && (
309
+ <p><span class="text-[var(--color-muted)]">VG Percentage:</span> {vgPercentage}</p>
310
+ )}
311
+ {bottleType && (
312
+ <p><span class="text-[var(--color-muted)]">Bottle Type:</span> {bottleType}</p>
313
+ )}
314
+ </div>
315
+ )}
316
+ </div>
317
+
318
+ {/* Safety & legal (scraped disclaimer) + Shipping & returns. The source
319
+ store shows no attribute/spec table — attributes appear as lines above. */}
320
+ <div class="mt-8 border-t border-[var(--color-border)]">
321
+ {disclaimer && (
322
+ <details class="accordion group">
323
+ <summary class="accordion__summary">
324
+ <span>Safety &amp; legal</span>
325
+ <span class="accordion__chevron" aria-hidden="true">+</span>
326
+ </summary>
327
+ <div class="accordion__body rte text-sm leading-relaxed text-[var(--color-muted)]" set:html={disclaimer} />
328
+ </details>
329
+ )}
330
+
331
+ <details class="accordion group">
332
+ <summary class="accordion__summary">
333
+ <span>Shipping &amp; returns</span>
334
+ <span class="accordion__chevron" aria-hidden="true">+</span>
335
+ </summary>
336
+ <div class="accordion__body text-sm leading-relaxed text-[var(--color-text)]">
337
+ <p>
338
+ Delivery times, rates, and returns are covered in our policy —
339
+ including the adult-signature (21+) requirement on every order.
340
+ </p>
341
+ <a
342
+ href={withBase(basePath, "/shipping-returns")}
343
+ class="link-accent mt-3 inline-block font-medium"
344
+ >
345
+ Shipping &amp; returns policy
346
+ </a>
347
+ </div>
348
+ </details>
349
+ </div>
350
+ </div>
351
+ </div>
352
+
353
+ {/* Customer reviews — real content migrated from the store's reviews app. */}
354
+ {reviews.length > 0 && (
355
+ <Section
356
+ id="reviews"
357
+ title="Customer reviews"
358
+ eyebrow={`${reviewAvg.toFixed(1)} ★ · ${reviewCount || reviews.length} review${(reviewCount || reviews.length) === 1 ? "" : "s"}`}
359
+ >
360
+ <ReviewList reviews={reviews} locale={tenant.locale} />
361
+ </Section>
362
+ )}
363
+
364
+ {/* Related */}
365
+ {related.length > 0 && (
366
+ <Section title="You may also like" eyebrow="More from the line" tone="surface">
367
+ <div class="grid grid-cols-2 gap-5 lg:grid-cols-4">
368
+ {related.map((p, i) => (
369
+ <ProductCard product={p} position={i} list="related" />
370
+ ))}
371
+ </div>
372
+ </Section>
373
+ )}
374
+
375
+ {/* Recently viewed (client island; records this product, shows the rest). */}
376
+ <RecentlyViewed
377
+ client:idle
378
+ locale={tenant.locale}
379
+ current={{
380
+ handle: product.handle,
381
+ title: product.title,
382
+ price: fromPrice(product),
383
+ currency: product.price_range.currency,
384
+ vendor: product.vendor,
385
+ img: product.images[0]?.url,
386
+ rehosted: product.images[0]?.rehosted,
387
+ seed: product.images[0]?.id ?? product.handle,
388
+ }}
389
+ />
390
+
391
+ {/* PDP-view analytics event. Phase 1: dispatched to a window event that the
392
+ no-op sink (or a Phase 2 real sink) can subscribe to. No-op by default. */}
393
+ <script type="application/json" id="pdp-meta" set:html={JSON.stringify({ type: "pdp_view", handle: product.handle })}></script>
394
+ <script is:inline nonce={Astro.locals.cspNonce}>
395
+ try {
396
+ const el = document.getElementById("pdp-meta");
397
+ if (el?.textContent) {
398
+ window.dispatchEvent(new CustomEvent("tot:analytics", { detail: JSON.parse(el.textContent) }));
399
+ }
400
+ } catch {}
401
+ </script>
402
+ </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>