@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,154 @@
1
+ ---
2
+ /**
3
+ * /dashboard/<appDomain>/team — a vendor's developer team + invite-by-email
4
+ * (#8425 Stage 0).
5
+ *
6
+ * Guarded by the signed-in session (membership for this appDomain). The page is a
7
+ * thin client over two session-authed endpoints:
8
+ * GET /api/grants/team-members?tenant=<appDomain> → current developer invites
9
+ * POST /api/grants/team-invite {tenant, email} → invite a developer
10
+ * Both re-check membership server-side; the appDomain here is carried from the
11
+ * guarded route, and every call is authorized by the `tot_session` cookie.
12
+ */
13
+ export const prerender = false;
14
+
15
+ import { readViewerSession } from "@/lib/auth/route";
16
+ import { membershipFor } from "@/lib/dashboard/tenantSelection";
17
+
18
+ const cspNonce = Astro.locals.cspNonce;
19
+ const appDomain = decodeURIComponent(Astro.params.appDomain ?? "");
20
+
21
+ const session = await readViewerSession(Astro);
22
+ if (!session) {
23
+ return Astro.redirect(
24
+ `/auth/login?next=${encodeURIComponent(`/dashboard/${appDomain}/team`)}`,
25
+ 302,
26
+ );
27
+ }
28
+ // Member OR ToT-staff admitted via an explicit vendor selection (distinct path).
29
+ const membership = membershipFor(session.tenants, appDomain, {
30
+ selection: session.staffSelection,
31
+ nowSeconds: Math.floor(Date.now() / 1000),
32
+ });
33
+ if (!membership) {
34
+ return Astro.redirect("/dashboard", 302);
35
+ }
36
+ // #8453 (`staff-can-invite-governed`, REVERSING the earlier members-only block):
37
+ // a ToT-staff viewer admitted via an explicit vendor selection may now invite too —
38
+ // the write endpoint mints a signed broker assertion from their session email and
39
+ // tot20 verifies it to permit the invite. So the form shows for member OR staff
40
+ // (everyone admitted to this page). `isRealMember` is kept only to tailor the copy.
41
+ const isRealMember = !!membershipFor(session.tenants, appDomain);
42
+ const backHref = `/dashboard/${encodeURIComponent(appDomain)}`;
43
+ ---
44
+
45
+ <html lang="en">
46
+ <head>
47
+ <meta charset="utf-8" />
48
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
49
+ <meta name="robots" content="noindex, nofollow" />
50
+ <title>Team — {appDomain}</title>
51
+ <style nonce={cspNonce}>
52
+ :root { color-scheme: light dark; }
53
+ * { box-sizing: border-box; }
54
+ body { font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
55
+ margin: 0; min-height: 100vh; padding: 2.5rem 1.25rem; background: #f5f4f0; color: #1c1c1c; }
56
+ @media (prefers-color-scheme: dark) { body { background: #14141a; color: #ececf1; } }
57
+ main { max-width: 44rem; margin-inline: auto; }
58
+ .eyebrow { font: 600 .68rem/1 ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; color: #8a7f66; margin: 0 0 .5rem; }
59
+ h1 { font-size: 1.5rem; letter-spacing: -.01em; margin: 0 0 .3rem; }
60
+ h2 { font-size: .95rem; text-transform: uppercase; letter-spacing: .05em; color: #777; margin: 2rem 0 .6rem; }
61
+ .sub { color: #6a6a6a; margin: 0 0 1rem; }
62
+ @media (prefers-color-scheme: dark) { .sub { color: #a2a2ad; } }
63
+ .card { background: #fff; border: 1px solid #e6e4de; border-radius: 12px; padding: 1.25rem; }
64
+ @media (prefers-color-scheme: dark) { .card { background: #1e1e28; border-color: #33333f; } }
65
+ label { display: block; font-weight: 600; font-size: .82rem; margin: 0 0 .4rem; }
66
+ label[for=role], label[for=note] { margin-top: .9rem; }
67
+ input[type=email], select, textarea { width: 100%; padding: .7rem .8rem; font-size: 1rem; border-radius: 9px;
68
+ border: 1px solid #cfccc4; background: #fff; color: inherit; font-family: inherit; }
69
+ select { margin-bottom: .2rem; }
70
+ textarea { min-height: 4.5rem; resize: vertical; line-height: 1.4; }
71
+ @media (prefers-color-scheme: dark) { input[type=email], select, textarea { background: #14141a; border-color: #40404d; } }
72
+ button { margin-top: 1rem; padding: .7rem 1.1rem; border: 0; border-radius: 9px; background: #1a1a1a;
73
+ color: #fff; font-weight: 600; font-size: .95rem; cursor: pointer; }
74
+ @media (prefers-color-scheme: dark) { button { background: #ececf1; color: #14141a; } }
75
+ button:disabled { opacity: .5; cursor: default; }
76
+ .rowbtn { margin: 0; padding: .3rem .6rem; font-size: .78rem; background: transparent; color: inherit;
77
+ border: 1px solid #cfccc4; border-radius: 7px; font-weight: 600; }
78
+ @media (prefers-color-scheme: dark) { .rowbtn { border-color: #40404d; } }
79
+ .rowbtn:hover:not(:disabled) { border-color: #b7d0ff; }
80
+ td.act { text-align: right; white-space: nowrap; }
81
+ table { width: 100%; border-collapse: collapse; }
82
+ th, td { text-align: left; padding: .5rem .4rem; border-bottom: 1px solid #e6e4de; font-size: .9rem; }
83
+ @media (prefers-color-scheme: dark) { th, td { border-color: #33333f; } }
84
+ th { font: 600 .7rem/1 ui-monospace, monospace; text-transform: uppercase; letter-spacing: .05em; color: #999; }
85
+ .msg { margin-top: 1rem; font-size: .9rem; border-radius: 9px; padding: .6rem .8rem; display: none; }
86
+ .msg.ok { display: block; background: #eaf7ee; border: 1px solid #bfe4c9; color: #1b6b34; }
87
+ .msg.err { display: block; background: #fdecec; border: 1px solid #f5c2c2; color: #9a1c1c; }
88
+ @media (prefers-color-scheme: dark) {
89
+ .msg.ok { background: #16311f; border-color: #2f6b41; color: #a7e0ba; }
90
+ .msg.err { background: #3a1f22; border-color: #6b2f34; color: #f6b9b9; }
91
+ }
92
+ .muted { color: #9a9a9a; font-size: .85rem; }
93
+ .who { margin-top: 2rem; font-size: .82rem; color: #8a8a8a; }
94
+ .who a, a.back { color: inherit; }
95
+ .linkbtn { background: none; border: 0; color: inherit; font: inherit; text-decoration: underline; cursor: pointer; padding: 0; margin: 0; }
96
+ .logout { display: inline; }
97
+ </style>
98
+ </head>
99
+ <body>
100
+ <main>
101
+ <p class="eyebrow"><a class="back" href={backHref}>← {appDomain}</a></p>
102
+ <h1>Team</h1>
103
+ <p class="sub">Developers with access to <strong>{appDomain}</strong>.{isRealMember
104
+ ? " Invite by email — they'll get a branded sign-in link and become eligible on this vendor."
105
+ : " Viewing as Token of Trust staff — invite by email to grant a developer access."}</p>
106
+
107
+ <h2>Invite a developer</h2>
108
+ <div class="card">
109
+ <label for="email">Developer email</label>
110
+ <input id="email" type="email" placeholder="dev@agency.com" autocomplete="off" />
111
+ {!isRealMember && (
112
+ <>
113
+ {/* #8453: ToT staff may grant any role; a plain member is developer-only
114
+ (no selector — tot20 ignores a member's role and pins to developer).
115
+ Values are tot20's role vocabulary: developer | appAdmin | appOwner. */}
116
+ <label for="role">Role</label>
117
+ <select id="role">
118
+ <option value="developer" selected>Developer</option>
119
+ <option value="appAdmin">Admin</option>
120
+ <option value="appOwner">Owner</option>
121
+ </select>
122
+ </>
123
+ )}
124
+ <label for="note">Note to the developer (optional)</label>
125
+ <textarea id="note" rows="3" maxlength="1000"
126
+ placeholder="Add a personal message — they'll see it in the invite email."></textarea>
127
+ <button id="inviteBtn" type="button">Send invite</button>
128
+ <div id="msg" class="msg"></div>
129
+ </div>
130
+
131
+ <h2>Current developers</h2>
132
+ <div class="card">
133
+ <table>
134
+ <thead><tr><th>Developer</th><th>Roles</th><th></th></tr></thead>
135
+ <tbody id="members" data-tenant={appDomain}><tr><td colspan="3" class="muted">Loading…</td></tr></tbody>
136
+ </table>
137
+ </div>
138
+
139
+ <p class="who">Signed in as {session.email} ·
140
+ <a href="/dashboard">Switch vendor</a> ·
141
+ <form class="logout" method="post" action="/api/auth/logout"><button type="submit" class="linkbtn">Sign out</button></form></p>
142
+ </main>
143
+
144
+ <!--
145
+ Client behavior is a STATIC same-origin file (public/js/dashboard-team.js),
146
+ referenced via src so the strict CSP allows it through `script-src 'self'` — no
147
+ nonce, no per-build content-hash. An inline <script> here is blocked: Astro's
148
+ `is:inline` drops the templated nonce, and a processed inline script would need
149
+ its hash pinned in cspStaticHashes on every edit. `is:inline` keeps Astro from
150
+ trying to bundle the external-src tag; `defer` runs it after the DOM parses.
151
+ -->
152
+ <script src="/js/dashboard-team.js" is:inline defer></script>
153
+ </body>
154
+ </html>
@@ -0,0 +1,160 @@
1
+ ---
2
+ /**
3
+ * /dashboard — the vendor console entry (#8425 Stage 0).
4
+ *
5
+ * Federated: the viewer signed in via ToT (email OTP → offline-verified id_token →
6
+ * `tot_session`). This page holds NO identity of its own — it reads the session's
7
+ * tenant memberships and routes per the L3 contract
8
+ * (docs/architecture/identity-and-authorization.md):
9
+ * 0 memberships → "nothing to manage"; 1 → straight into that vendor; ≥2 → picker.
10
+ *
11
+ * STAGE-0: tot20's id_token currently carries only the one resource signed against,
12
+ * so this resolves to a single vendor in practice; the picker activates unchanged
13
+ * once tot20 populates all memberships (flagged to the workstream lead).
14
+ */
15
+ export const prerender = false;
16
+
17
+ import { readViewerSession } from "@/lib/auth/route";
18
+ import { selectTenant } from "@/lib/dashboard/tenantSelection";
19
+ import { isStaffSession } from "@/lib/dashboard/staffAdmission";
20
+
21
+ const cspNonce = Astro.locals.cspNonce;
22
+ const session = await readViewerSession(Astro);
23
+ if (!session) {
24
+ return Astro.redirect("/auth/login?next=%2Fdashboard", 302);
25
+ }
26
+
27
+ // ToT-STAFF viewers reach ANY vendor by explicit SEARCH-and-enter (a distinct
28
+ // path from tenants[] membership). When staff, we always render the hub (their
29
+ // memberships, if any, PLUS a vendor search) and skip the single-membership
30
+ // auto-redirect so they can still search for a vendor they don't belong to.
31
+ const isStaff = isStaffSession(session);
32
+ const selection = selectTenant(session.tenants);
33
+ if (!isStaff && selection.mode === "single") {
34
+ return Astro.redirect(`/dashboard/${encodeURIComponent(selection.tenant.resource)}`, 302);
35
+ }
36
+ const vendors =
37
+ selection.mode === "picker"
38
+ ? selection.tenants
39
+ : selection.mode === "single"
40
+ ? [selection.tenant]
41
+ : [];
42
+ ---
43
+
44
+ <html lang="en">
45
+ <head>
46
+ <meta charset="utf-8" />
47
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
48
+ <meta name="robots" content="noindex, nofollow" />
49
+ <title>Your stores — Token of Trust</title>
50
+ <style nonce={cspNonce}>
51
+ :root { color-scheme: light dark; }
52
+ * { box-sizing: border-box; }
53
+ body { font: 15px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
54
+ margin: 0; min-height: 100vh; padding: 2.5rem 1.25rem; background: #f5f4f0; color: #1c1c1c; }
55
+ @media (prefers-color-scheme: dark) { body { background: #14141a; color: #ececf1; } }
56
+ main { max-width: 40rem; margin-inline: auto; }
57
+ .eyebrow { font: 600 .68rem/1 ui-monospace, monospace; letter-spacing: .12em; text-transform: uppercase; color: #8a7f66; margin: 0 0 .5rem; }
58
+ h1 { font-size: 1.5rem; letter-spacing: -.01em; margin: 0 0 .3rem; }
59
+ .sub { color: #6a6a6a; margin: 0 0 1.75rem; }
60
+ @media (prefers-color-scheme: dark) { .sub { color: #a2a2ad; } }
61
+ ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .75rem; }
62
+ a.vendor { display: flex; align-items: center; justify-content: space-between; text-decoration: none;
63
+ background: #fff; border: 1px solid #e6e4de; border-radius: 12px; padding: 1rem 1.25rem; color: inherit; }
64
+ @media (prefers-color-scheme: dark) { a.vendor { background: #1e1e28; border-color: #33333f; } }
65
+ a.vendor:hover { border-color: #b7d0ff; }
66
+ .name { font-weight: 600; }
67
+ .role { font: 600 .68rem/1 ui-monospace, monospace; text-transform: uppercase; letter-spacing: .06em;
68
+ color: #4a6; border: 1px solid currentColor; border-radius: 999px; padding: .2rem .55rem; }
69
+ .who { margin-top: 2rem; font-size: .82rem; color: #8a8a8a; }
70
+ .who a { color: inherit; }
71
+ .linkbtn { background: none; border: 0; color: inherit; font: inherit; text-decoration: underline; cursor: pointer; padding: 0; }
72
+ .logout { display: inline; }
73
+ .empty { background: #fff; border: 1px solid #e6e4de; border-radius: 12px; padding: 1.5rem; }
74
+ @media (prefers-color-scheme: dark) { .empty { background: #1e1e28; border-color: #33333f; } }
75
+ /* Staff vendor search */
76
+ .staff { margin-top: 2.25rem; }
77
+ h2 { font-size: .95rem; text-transform: uppercase; letter-spacing: .05em; color: #777; margin: 0 0 .3rem; }
78
+ .staff .hint { color: #8a8a8a; font-size: .85rem; margin: 0 0 .7rem; }
79
+ .staff .hint code { font: .8em ui-monospace, monospace; background: #ece9e1; color: #4a4a4a;
80
+ padding: .1em .35em; border-radius: 5px; }
81
+ @media (prefers-color-scheme: dark) { .staff .hint code { background: #2a2a33; color: #cfcfda; } }
82
+ .search { position: relative; }
83
+ #vendorSearch { width: 100%; padding: .75rem .85rem; font-size: 1rem; border-radius: 10px;
84
+ border: 1px solid #cfccc4; background: #fff; color: inherit; }
85
+ @media (prefers-color-scheme: dark) { #vendorSearch { background: #14141a; border-color: #40404d; } }
86
+ #vendorResults { list-style: none; margin: .6rem 0 0; padding: 0; display: grid; gap: .5rem; }
87
+ #vendorResults button { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 1rem;
88
+ text-align: left; background: #fff; border: 1px solid #e6e4de; border-radius: 10px; padding: .8rem 1rem;
89
+ color: inherit; font: inherit; cursor: pointer; }
90
+ @media (prefers-color-scheme: dark) { #vendorResults button { background: #1e1e28; border-color: #33333f; } }
91
+ #vendorResults button:hover, #vendorResults button:focus-visible { border-color: #b7d0ff; }
92
+ #vendorResults button:disabled { opacity: .5; cursor: default; }
93
+ #vendorResults .dn { font-weight: 600; }
94
+ #vendorResults .ad { font: 600 .68rem/1 ui-monospace, monospace; color: #8a8a8a; }
95
+ #vendorSearchMsg { margin-top: .6rem; font-size: .88rem; color: #8a8a8a; min-height: 1.1em; }
96
+ #vendorSearchMsg.err { color: #9a1c1c; }
97
+ @media (prefers-color-scheme: dark) { #vendorSearchMsg.err { color: #f6b9b9; } }
98
+ .badge { display: inline-block; font: 600 .6rem/1 ui-monospace, monospace; letter-spacing: .08em;
99
+ text-transform: uppercase; color: #8a7f66; border: 1px solid currentColor; border-radius: 999px;
100
+ padding: .18rem .5rem; margin-left: .5rem; vertical-align: middle; }
101
+ </style>
102
+ </head>
103
+ <body>
104
+ <main>
105
+ <p class="eyebrow">Token of Trust</p>
106
+ <h1>Your stores{isStaff && <span class="badge">ToT staff</span>}</h1>
107
+ {vendors.length ? (
108
+ <>
109
+ <p class="sub">Choose a store to manage its team and preview.</p>
110
+ <ul>
111
+ {vendors.map((v) => (
112
+ <li>
113
+ <a class="vendor" href={`/dashboard/${encodeURIComponent(v.resource)}`}>
114
+ <span class="name">{v.resource}</span>
115
+ <span class="role">{v.role}</span>
116
+ </a>
117
+ </li>
118
+ ))}
119
+ </ul>
120
+ </>
121
+ ) : (
122
+ !isStaff && (
123
+ <div class="empty">
124
+ <p style="margin:0">You're signed in as <strong>{session.email}</strong>, but
125
+ you don't have access to any store yet. Ask a store's merchant to invite you.</p>
126
+ </div>
127
+ )
128
+ )}
129
+
130
+ {isStaff && (
131
+ <section class="staff">
132
+ <h2>Find a store</h2>
133
+ <p class="hint">As Token of Trust staff you can enter any store's console. Search by
134
+ domain, then select one to enter — every admission is logged.</p>
135
+ <p class="hint">Tip: <code>^</code> matches the start, <code>$</code> the end — e.g.
136
+ <code>^acme.com$</code> for an exact match, <code>acme$</code> to end with “acme”.</p>
137
+ <div class="search">
138
+ <input id="vendorSearch" type="text" placeholder="acme.com"
139
+ autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
140
+ aria-label="Search stores by domain" />
141
+ <ul id="vendorResults"></ul>
142
+ <p id="vendorSearchMsg" aria-live="polite"></p>
143
+ </div>
144
+ </section>
145
+ )}
146
+
147
+ <p class="who">Signed in as {session.email} ·
148
+ <form class="logout" method="post" action="/api/auth/logout"><button type="submit" class="linkbtn">Sign out</button></form></p>
149
+ </main>
150
+
151
+ <!--
152
+ Staff vendor-search behavior is a STATIC same-origin file
153
+ (public/js/dashboard-vendor-search.js), referenced via src so the strict CSP
154
+ allows it through `script-src 'self'` — no nonce, no per-build content-hash
155
+ (see public/js/dashboard-team.js for the proven pattern). The file no-ops when
156
+ #vendorSearch is absent (non-staff), so it's safe to always include.
157
+ -->
158
+ <script src="/js/dashboard-vendor-search.js" is:inline defer></script>
159
+ </body>
160
+ </html>
@@ -0,0 +1,290 @@
1
+ ---
2
+ /**
3
+ * Homepage — composed from Storyblok blocks (local fallback in Phase 1).
4
+ * Editorial composition comes from the ContentProvider; product/collection data
5
+ * is resolved from the ToTClient. All async data resolution happens HERE in the
6
+ * frontmatter (Astro templates can't host multi-statement async arrows), then
7
+ * the template renders simple typed view-models.
8
+ */
9
+ import Layout from "@/layouts/Layout.astro";
10
+ import Section from "@/components/Section.astro";
11
+ import Hero from "@/components/home/Hero.astro";
12
+ import ProductCard from "@/components/ProductCard.astro";
13
+ import CollectionCard from "@/components/CollectionCard.astro";
14
+ import Img from "@/components/Img.astro";
15
+ import MarketingBlocks from "@/components/marketing/MarketingBlocks.astro";
16
+ import { withBase } from "@/lib/basePath";
17
+ import { rawHtmlResponse } from "@/lib/rawMarketingHtml";
18
+ import type { CatalogCollection, CatalogProduct } from "@tot/public-runtime";
19
+ import {
20
+ isMarketingBlock,
21
+ type HeroBlock,
22
+ type EditorialBlock,
23
+ type NewsletterBlock,
24
+ type PromoTilesBlock,
25
+ type MarketingBlock,
26
+ } from "@/lib/storyblok/content-model";
27
+
28
+ const { tenant, tot, content, basePath, features } = Astro.locals;
29
+ const marketing = !features.catalog;
30
+
31
+ // Raw-HTML home (vendor-injected): serve the tenant's own self-contained
32
+ // document VERBATIM, bypassing the block Layout entirely. Its inline
33
+ // <style>/<script> get this request's CSP nonce so the strict policy still
34
+ // allows them. This route stays fully inside Astro — middleware already applied
35
+ // tenant resolution + canonical host, and on the way out applies security
36
+ // headers, the CSP, and (in prod) the edge page cache. SEO title/meta/canonical/
37
+ // OpenGraph/JSON-LD come from the document's own <head>. The homepage `/` is in
38
+ // the marketing sitemap (sitemap.xml.ts).
39
+ //
40
+ // Served regardless of siteType: a tenant that ships home.html gets its raw
41
+ // marketing home whether or not commerce is on. This is the HYBRID tenant —
42
+ // pixel-perfect marketing chrome at `/` surrounding a live commerce core at
43
+ // /collections + /products/* (see playbook/site-migration/commerce-handoff.md §1
44
+ // and product-boundaries.md). Commerce tenants with no home.html fall through to
45
+ // the block-composed home below, unchanged. Mirrors the raw-subpage path in
46
+ // [...slug].astro (getPageHtml is already siteType-agnostic).
47
+ {
48
+ const rawHtml = await content.getHomeHtml();
49
+ if (rawHtml) {
50
+ // Shared chrome: if the tenant ships a chrome.html wrapper AND this home is a
51
+ // body-only fragment, rawHtmlResponse splices it in; a full document serves
52
+ // verbatim (backward-compatible).
53
+ const chromeWrapper = await content.getChromeHtml();
54
+ return rawHtmlResponse(rawHtml, { nonce: Astro.locals.cspNonce, basePath, chromeWrapper });
55
+ }
56
+ }
57
+
58
+ const home = await content.getHome();
59
+
60
+ // Marketing tenants (no catalog) compose the homepage from marketing blocks and
61
+ // skip ALL product/collection resolution below.
62
+ const marketingBlocks: MarketingBlock[] = marketing
63
+ ? home.blocks.filter(isMarketingBlock)
64
+ : [];
65
+
66
+ const collections = marketing ? [] : await tot.listCollections();
67
+
68
+ // --- Resolve each block into a typed, render-ready view-model -----------------
69
+ type Resolved =
70
+ | { kind: "hero"; block: HeroBlock; product: CatalogProduct | null }
71
+ | { kind: "promo_tiles"; title?: string; tiles: PromoTilesBlock["tiles"] }
72
+ | { kind: "collections"; title?: string; cols: CatalogCollection[] }
73
+ | { kind: "products"; title?: string; products: CatalogProduct[] }
74
+ | { kind: "editorial"; block: EditorialBlock }
75
+ | { kind: "newsletter"; block: NewsletterBlock };
76
+
77
+ async function resolveProducts(
78
+ source: "featured" | "newest",
79
+ handles: string[] | undefined,
80
+ limit: number,
81
+ ): Promise<CatalogProduct[]> {
82
+ if (handles?.length) {
83
+ const all = await Promise.all(handles.map((h) => tot.getProductByHandle(h)));
84
+ return all
85
+ .filter((p): p is CatalogProduct => !!p && p.status === "active")
86
+ .slice(0, limit);
87
+ }
88
+ if (source === "newest") {
89
+ const { products } = await tot.listProducts({ sort: "newest", pageSize: limit });
90
+ return products;
91
+ }
92
+ return tot.getFeatured(limit);
93
+ }
94
+
95
+ const resolved: Resolved[] = [];
96
+ for (const block of marketing ? [] : home.blocks) {
97
+ switch (block.component) {
98
+ case "hero": {
99
+ let product: CatalogProduct | null = null;
100
+ if (block.featureCollection) {
101
+ const col = collections.find((c) => c.handle === block.featureCollection);
102
+ const firstHandle = col?.product_handles[0];
103
+ if (firstHandle) product = await tot.getProductByHandle(firstHandle);
104
+ }
105
+ resolved.push({ kind: "hero", block, product });
106
+ break;
107
+ }
108
+ case "promo_tiles": {
109
+ if (block.tiles?.length) {
110
+ resolved.push({ kind: "promo_tiles", title: block.title, tiles: block.tiles });
111
+ }
112
+ break;
113
+ }
114
+ case "featured_collections": {
115
+ const handles = block.handles?.length
116
+ ? block.handles
117
+ : collections.map((c) => c.handle);
118
+ const cols = handles
119
+ .map((h) => collections.find((c) => c.handle === h))
120
+ .filter((c): c is CatalogCollection => !!c);
121
+ resolved.push({ kind: "collections", title: block.title, cols });
122
+ break;
123
+ }
124
+ case "featured_products": {
125
+ const products = await resolveProducts(
126
+ block.source,
127
+ block.handles,
128
+ block.limit ?? 4,
129
+ );
130
+ resolved.push({ kind: "products", title: block.title, products });
131
+ break;
132
+ }
133
+ case "editorial":
134
+ resolved.push({ kind: "editorial", block });
135
+ break;
136
+ case "newsletter":
137
+ resolved.push({ kind: "newsletter", block });
138
+ break;
139
+ }
140
+ }
141
+ ---
142
+
143
+ <Layout
144
+ title={home.seoTitle ?? tenant.displayName}
145
+ description={home.seoDescription}
146
+ path="/"
147
+ ogType="website"
148
+ fullBleed={marketing}
149
+ >
150
+ {marketing && <MarketingBlocks blocks={marketingBlocks} basePath={basePath} />}
151
+ {
152
+ !marketing && resolved.map((r) => {
153
+ if (r.kind === "hero") {
154
+ return <Hero block={r.block} featureProduct={r.product} />;
155
+ }
156
+ if (r.kind === "promo_tiles") {
157
+ return (
158
+ <Section title={r.title} eyebrow="Featured" tone="bg">
159
+ <div
160
+ class:list={[
161
+ "grid gap-5",
162
+ r.tiles.length >= 3 ? "sm:grid-cols-3" : "sm:grid-cols-2",
163
+ ]}
164
+ >
165
+ {r.tiles.map((t) => (
166
+ <a
167
+ href={withBase(basePath, t.href)}
168
+ class="group relative block aspect-[16/9] overflow-hidden rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)]"
169
+ >
170
+ {t.image && (
171
+ <img
172
+ src={t.image}
173
+ alt={t.label}
174
+ loading="lazy"
175
+ class="absolute inset-0 h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
176
+ />
177
+ )}
178
+ <span class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-4 font-display text-lg font-semibold text-white">
179
+ {t.label}
180
+ </span>
181
+ </a>
182
+ ))}
183
+ </div>
184
+ </Section>
185
+ );
186
+ }
187
+ if (r.kind === "collections") {
188
+ return (
189
+ <Section title={r.title} eyebrow="Collections" tone="bg">
190
+ <div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
191
+ {r.cols.map((c) => (
192
+ <CollectionCard collection={c} productCount={c.product_handles.length} />
193
+ ))}
194
+ </div>
195
+ </Section>
196
+ );
197
+ }
198
+ if (r.kind === "products") {
199
+ return (
200
+ <Section title={r.title} eyebrow="Shop" tone="surface">
201
+ <div class="grid grid-cols-2 gap-5 lg:grid-cols-4">
202
+ {r.products.map((p, i) => (
203
+ <ProductCard product={p} position={i} list={r.title} />
204
+ ))}
205
+ </div>
206
+ </Section>
207
+ );
208
+ }
209
+ if (r.kind === "editorial") {
210
+ const b = r.block;
211
+ const right = b.align !== "left";
212
+ return (
213
+ <Section tone="bg">
214
+ <div
215
+ class:list={[
216
+ "grid items-center gap-10 md:grid-cols-2",
217
+ right ? "" : "md:[&>*:first-child]:order-2",
218
+ ]}
219
+ >
220
+ <div data-reveal>
221
+ {b.eyebrow && <p class="eyebrow eyebrow--tick mb-4">{b.eyebrow}</p>}
222
+ <h2 class="display-md">
223
+ {b.title}
224
+ </h2>
225
+ <div class="rte mt-5 text-[var(--color-muted)]" set:html={b.body_html} />
226
+ {b.ctaLabel && b.ctaHref && (
227
+ <a
228
+ href={withBase(basePath, b.ctaHref)}
229
+ class="link-accent mt-7 inline-block pb-0.5 font-display text-sm font-medium"
230
+ >
231
+ {b.ctaLabel} →
232
+ </a>
233
+ )}
234
+ </div>
235
+ <div data-reveal>
236
+ <div class="overflow-hidden rounded-[var(--radius-lg)] shadow-[var(--shadow-md)]">
237
+ <Img
238
+ seed={b.imageSeed ?? b.title}
239
+ label={b.title}
240
+ alt=""
241
+ width={1000}
242
+ height={750}
243
+ sizes="(min-width:768px) 48vw, 100vw"
244
+ class="h-full w-full object-cover"
245
+ />
246
+ </div>
247
+ </div>
248
+ </div>
249
+ </Section>
250
+ );
251
+ }
252
+ if (r.kind === "newsletter") {
253
+ const b = r.block;
254
+ return (
255
+ <Section tone="surface" id="newsletter">
256
+ <div class="mx-auto max-w-xl text-center" data-reveal>
257
+ <h2 class="display-md">
258
+ {b.title}
259
+ </h2>
260
+ {b.body && <p class="mt-3 text-[var(--color-muted)]">{b.body}</p>}
261
+ <form
262
+ action={withBase(basePath, "/api/newsletter")}
263
+ method="post"
264
+ class="mx-auto mt-7 flex max-w-md flex-col gap-3 sm:flex-row"
265
+ >
266
+ <input type="hidden" name="source" value="home" />
267
+ <label class="sr-only" for="home-news-email">Email address</label>
268
+ <input
269
+ id="home-news-email"
270
+ type="email"
271
+ name="email"
272
+ required
273
+ placeholder={b.placeholder ?? "you@example.com"}
274
+ class="flex-1 rounded-full border border-[var(--color-border)] bg-[var(--color-bg)] px-5 py-3 text-[var(--color-text)] placeholder:text-[var(--color-muted)]"
275
+ />
276
+ <button
277
+ type="submit"
278
+ class="rounded-full bg-[var(--color-primary)] px-6 py-3 font-display text-sm font-medium text-[var(--color-primary-contrast)] transition-transform hover:-translate-y-0.5"
279
+ >
280
+ {b.ctaLabel ?? "Subscribe"}
281
+ </button>
282
+ </form>
283
+ </div>
284
+ </Section>
285
+ );
286
+ }
287
+ return null;
288
+ })
289
+ }
290
+ </Layout>