@tokenoftrust/storefront-runner 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (337) hide show
  1. package/LICENSE +58 -0
  2. package/README.md +40 -0
  3. package/apps/storefront/.dev.vars.example +41 -0
  4. package/apps/storefront/.env.example +24 -0
  5. package/apps/storefront/astro.config.mjs +245 -0
  6. package/apps/storefront/env.d.ts +155 -0
  7. package/apps/storefront/lib/ensure-preview-reconcile-secret.sh +31 -0
  8. package/apps/storefront/lib/resolve-cf-token.sh +48 -0
  9. package/apps/storefront/lib/resolve-tot-credentials.sh +69 -0
  10. package/apps/storefront/migrations/0000_baseline.sql +54 -0
  11. package/apps/storefront/migrations/0001_products_fts.sql +9 -0
  12. package/apps/storefront/migrations/README.md +54 -0
  13. package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
  14. package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
  15. package/apps/storefront/migrations/meta/_journal.json +20 -0
  16. package/apps/storefront/package.json +41 -0
  17. package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
  18. package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
  19. package/apps/storefront/public/brand/northwind-og.svg +13 -0
  20. package/apps/storefront/public/favicon.svg +5 -0
  21. package/apps/storefront/public/js/dashboard-team.js +147 -0
  22. package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
  23. package/apps/storefront/src/components/Badge.astro +34 -0
  24. package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
  25. package/apps/storefront/src/components/CollectionCard.astro +56 -0
  26. package/apps/storefront/src/components/EmptyState.astro +35 -0
  27. package/apps/storefront/src/components/Img.astro +68 -0
  28. package/apps/storefront/src/components/ProductCard.astro +228 -0
  29. package/apps/storefront/src/components/Section.astro +39 -0
  30. package/apps/storefront/src/components/Seo.astro +70 -0
  31. package/apps/storefront/src/components/WidgetFrame.astro +34 -0
  32. package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
  33. package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
  34. package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
  35. package/apps/storefront/src/components/chrome/NavDropdown.astro +116 -0
  36. package/apps/storefront/src/components/chrome/SiteFooter.astro +181 -0
  37. package/apps/storefront/src/components/chrome/SiteHeader.astro +181 -0
  38. package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
  39. package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
  40. package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
  41. package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
  42. package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
  43. package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
  44. package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
  45. package/apps/storefront/src/components/home/Hero.astro +164 -0
  46. package/apps/storefront/src/components/islands/AgeGate.tsx +105 -0
  47. package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
  48. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
  49. package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
  50. package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
  51. package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
  52. package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
  53. package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
  54. package/apps/storefront/src/components/islands/VariantSelector.tsx +378 -0
  55. package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
  56. package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
  57. package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
  58. package/apps/storefront/src/components/marketing/Faq.astro +37 -0
  59. package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
  60. package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
  61. package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
  62. package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
  63. package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
  64. package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
  65. package/apps/storefront/src/components/marketing/Steps.astro +36 -0
  66. package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
  67. package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
  68. package/apps/storefront/src/components/nav/Footer.astro +148 -0
  69. package/apps/storefront/src/components/nav/Header.astro +190 -0
  70. package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
  71. package/apps/storefront/src/components/plp/FacetSidebar.astro +146 -0
  72. package/apps/storefront/src/components/plp/Pagination.astro +99 -0
  73. package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
  74. package/apps/storefront/src/config/devTenants.ts +16 -0
  75. package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
  76. package/apps/storefront/src/config/resolver.ts +46 -0
  77. package/apps/storefront/src/config/tenants.ts +193 -0
  78. package/apps/storefront/src/layouts/Layout.astro +303 -0
  79. package/apps/storefront/src/lib/analytics/index.ts +47 -0
  80. package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
  81. package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
  82. package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
  83. package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
  84. package/apps/storefront/src/lib/auth/loginGate.ts +107 -0
  85. package/apps/storefront/src/lib/auth/route.ts +146 -0
  86. package/apps/storefront/src/lib/auth/session.ts +203 -0
  87. package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
  88. package/apps/storefront/src/lib/basePath.ts +41 -0
  89. package/apps/storefront/src/lib/cfImage.ts +55 -0
  90. package/apps/storefront/src/lib/chrome/model.ts +65 -0
  91. package/apps/storefront/src/lib/colors.ts +97 -0
  92. package/apps/storefront/src/lib/content-edit/client.ts +230 -0
  93. package/apps/storefront/src/lib/content-edit/index.ts +14 -0
  94. package/apps/storefront/src/lib/content-edit/route.ts +25 -0
  95. package/apps/storefront/src/lib/content-edit/types.ts +19 -0
  96. package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
  97. package/apps/storefront/src/lib/d1/catalog.ts +289 -0
  98. package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
  99. package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
  100. package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
  101. package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
  102. package/apps/storefront/src/lib/demoGate.ts +113 -0
  103. package/apps/storefront/src/lib/dev/ai-widget-client.js +127 -0
  104. package/apps/storefront/src/lib/dev/cliSignInCode.ts +132 -0
  105. package/apps/storefront/src/lib/diag/redact.ts +20 -0
  106. package/apps/storefront/src/lib/edgeCache.ts +128 -0
  107. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
  108. package/apps/storefront/src/lib/env.ts +60 -0
  109. package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
  110. package/apps/storefront/src/lib/format.ts +29 -0
  111. package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
  112. package/apps/storefront/src/lib/imageAttrs.ts +55 -0
  113. package/apps/storefront/src/lib/jsonld.ts +136 -0
  114. package/apps/storefront/src/lib/maintenance.ts +71 -0
  115. package/apps/storefront/src/lib/memoryKv.ts +32 -0
  116. package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
  117. package/apps/storefront/src/lib/placeholder.ts +99 -0
  118. package/apps/storefront/src/lib/previewReload.ts +125 -0
  119. package/apps/storefront/src/lib/rawMarketingHtml.ts +133 -0
  120. package/apps/storefront/src/lib/search/index.ts +74 -0
  121. package/apps/storefront/src/lib/sections.ts +129 -0
  122. package/apps/storefront/src/lib/securityHeaders.ts +31 -0
  123. package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
  124. package/apps/storefront/src/lib/storyblok/provider.ts +524 -0
  125. package/apps/storefront/src/lib/tenantRouting.ts +91 -0
  126. package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
  127. package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
  128. package/apps/storefront/src/lib/tot/query.ts +187 -0
  129. package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
  130. package/apps/storefront/src/lib/url.ts +119 -0
  131. package/apps/storefront/src/lib/wishlist.ts +63 -0
  132. package/apps/storefront/src/middleware/index.ts +413 -0
  133. package/apps/storefront/src/pages/404.astro +61 -0
  134. package/apps/storefront/src/pages/[...slug].astro +90 -0
  135. package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
  136. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
  137. package/apps/storefront/src/pages/api/auth/send.ts +85 -0
  138. package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
  139. package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
  140. package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +64 -0
  141. package/apps/storefront/src/pages/api/csp-report.ts +25 -0
  142. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
  143. package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
  144. package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +44 -0
  145. package/apps/storefront/src/pages/api/newsletter.ts +104 -0
  146. package/apps/storefront/src/pages/api/request-access.ts +172 -0
  147. package/apps/storefront/src/pages/auth/login.astro +149 -0
  148. package/apps/storefront/src/pages/auth/magic.astro +105 -0
  149. package/apps/storefront/src/pages/capabilities.astro +292 -0
  150. package/apps/storefront/src/pages/collections/[handle].astro +154 -0
  151. package/apps/storefront/src/pages/collections/index.astro +59 -0
  152. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
  153. package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
  154. package/apps/storefront/src/pages/dashboard/index.astro +160 -0
  155. package/apps/storefront/src/pages/dev.astro +653 -0
  156. package/apps/storefront/src/pages/index.astro +290 -0
  157. package/apps/storefront/src/pages/llms.txt.ts +92 -0
  158. package/apps/storefront/src/pages/products/[handle].astro +443 -0
  159. package/apps/storefront/src/pages/robots.txt.ts +24 -0
  160. package/apps/storefront/src/pages/saved.astro +35 -0
  161. package/apps/storefront/src/pages/search.astro +150 -0
  162. package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
  163. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +646 -0
  164. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +85 -0
  165. package/apps/storefront/src/pages/style-guide/index.astro +94 -0
  166. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
  167. package/apps/storefront/src/styles/global.css +277 -0
  168. package/apps/storefront/src/themes/reference.ts +61 -0
  169. package/apps/storefront/src/themes/schema.ts +111 -0
  170. package/apps/storefront/src/themes/tenants/index.ts +51 -0
  171. package/apps/storefront/tsconfig.json +16 -0
  172. package/apps/storefront/vitest.config.ts +22 -0
  173. package/apps/storefront/wrangler.toml +175 -0
  174. package/package.json +23 -0
  175. package/packages/public-runtime/LICENSE +58 -0
  176. package/packages/public-runtime/package.json +22 -0
  177. package/packages/public-runtime/src/binary-path.ts +68 -0
  178. package/packages/public-runtime/src/capabilities.ts +89 -0
  179. package/packages/public-runtime/src/catalog-api.ts +81 -0
  180. package/packages/public-runtime/src/catalog-d1.ts +192 -0
  181. package/packages/public-runtime/src/csp.ts +109 -0
  182. package/packages/public-runtime/src/customization-preview.ts +197 -0
  183. package/packages/public-runtime/src/customization-reconcile.ts +63 -0
  184. package/packages/public-runtime/src/customization-runtime.ts +157 -0
  185. package/packages/public-runtime/src/customization-scripts.ts +64 -0
  186. package/packages/public-runtime/src/customization-versioning.ts +95 -0
  187. package/packages/public-runtime/src/foxy.ts +184 -0
  188. package/packages/public-runtime/src/index.ts +30 -0
  189. package/packages/public-runtime/src/product.ts +159 -0
  190. package/packages/public-runtime/src/search.ts +45 -0
  191. package/packages/public-runtime/src/tenant-assets.ts +444 -0
  192. package/packages/public-runtime/src/tenant.ts +243 -0
  193. package/packages/public-runtime/tsconfig.json +8 -0
  194. package/packages/public-runtime/vitest.config.ts +8 -0
  195. package/pnpm-workspace.yaml +7 -0
  196. package/scripts/build/copy-tenant-assets.mjs +69 -0
  197. package/scripts/tot-dev.mjs +439 -0
  198. package/tenants/bigdvapor/content/blog.json +52 -0
  199. package/tenants/bigdvapor/content/chrome.html +149 -0
  200. package/tenants/bigdvapor/content/chrome.json +47 -0
  201. package/tenants/bigdvapor/content/home.html +463 -0
  202. package/tenants/bigdvapor/content/home.json +73 -0
  203. package/tenants/bigdvapor/content/pages/about.json +5 -0
  204. package/tenants/bigdvapor/content/pages/privacy.json +6 -0
  205. package/tenants/bigdvapor/content/pages/shipping-returns.json +6 -0
  206. package/tenants/bigdvapor/content/pages-html/blogs/news.html +86 -0
  207. package/tenants/bigdvapor/content/pages-html/pages/about-us.html +106 -0
  208. package/tenants/bigdvapor/content/pages-html/pages/contact-us.html +61 -0
  209. package/tenants/bigdvapor/content/pages-html/pages/privacy-policy.html +72 -0
  210. package/tenants/bigdvapor/content/pages-html/pages/shipping-returns.html +172 -0
  211. package/tenants/bigdvapor/content/themes/bigd-navy.json +109 -0
  212. package/tenants/bigdvapor/public/img/brands/adjust-vape-disposable.png +0 -0
  213. package/tenants/bigdvapor/public/img/brands/again-vape-disposable.png +0 -0
  214. package/tenants/bigdvapor/public/img/brands/al-fakher-vape-disposable.png +0 -0
  215. package/tenants/bigdvapor/public/img/brands/arro-vape-disposable.png +0 -0
  216. package/tenants/bigdvapor/public/img/brands/cookies-vape-disposable.png +0 -0
  217. package/tenants/bigdvapor/public/img/brands/core-vape-disposable.png +0 -0
  218. package/tenants/bigdvapor/public/img/brands/cyclone-vape-disposable.png +0 -0
  219. package/tenants/bigdvapor/public/img/brands/dinner-lady-vape-disposable.png +0 -0
  220. package/tenants/bigdvapor/public/img/brands/extre-bar-vape-disposable.png +0 -0
  221. package/tenants/bigdvapor/public/img/brands/fifty-bar-vape-disposable.jpg +0 -0
  222. package/tenants/bigdvapor/public/img/brands/firerose-vape-disposable.jpg +0 -0
  223. package/tenants/bigdvapor/public/img/brands/flavor-beast-vape-disposable.jpg +0 -0
  224. package/tenants/bigdvapor/public/img/brands/foger-vape-disposable.png +0 -0
  225. package/tenants/bigdvapor/public/img/brands/kk-energy-vape-disposable.png +0 -0
  226. package/tenants/bigdvapor/public/img/brands/kumi-vape-disposable.png +0 -0
  227. package/tenants/bigdvapor/public/img/brands/lost-vape-dispoable.png +0 -0
  228. package/tenants/bigdvapor/public/img/brands/melo-labs-vape-disposable.png +0 -0
  229. package/tenants/bigdvapor/public/img/brands/mixo-vape-disposable.webp +0 -0
  230. package/tenants/bigdvapor/public/img/brands/one-tank-vape-disposable.jpg +0 -0
  231. package/tenants/bigdvapor/public/img/brands/oxbar-vape-disposable.webp +0 -0
  232. package/tenants/bigdvapor/public/img/brands/pillow-talk-vape-disposable.jpg +0 -0
  233. package/tenants/bigdvapor/public/img/brands/pod-king-vape-disposable.jpg +0 -0
  234. package/tenants/bigdvapor/public/img/brands/raz-vape-disposable.png +0 -0
  235. package/tenants/bigdvapor/public/img/hero-texas.jpg +0 -0
  236. package/tenants/bigdvapor/public/img/og.jpg +0 -0
  237. package/tenants/bigdvapor/public/img/pages/AdobeStock_202106312_web.jpg +0 -0
  238. package/tenants/bigdvapor/public/img/pages/CREW_web.jpg +0 -0
  239. package/tenants/bigdvapor/public/img/pages/Hero-Image.jpg +0 -0
  240. package/tenants/bigdvapor/public/img/tiles/adjust.png +0 -0
  241. package/tenants/bigdvapor/public/img/tiles/again.png +0 -0
  242. package/tenants/bigdvapor/public/img/tiles/al-fakher.png +0 -0
  243. package/tenants/bigdvapor/public/img/tiles/arro.png +0 -0
  244. package/tenants/bigdvapor/public/img/tiles/cookies.png +0 -0
  245. package/tenants/bigdvapor/public/img/tiles/core.png +0 -0
  246. package/tenants/bigdvapor/public/img/tiles/cyclone.png +0 -0
  247. package/tenants/bigdvapor/public/img/tiles/disposables.svg +23 -0
  248. package/tenants/bigdvapor/public/logo-wordmark.png +0 -0
  249. package/tenants/bigdvapor/public/pages/home.css +118 -0
  250. package/tenants/bigdvapor/public/pages/mkt.css +185 -0
  251. package/tenants/bigdvapor/public/pages/page.css +148 -0
  252. package/tenants/bigdvapor/public/themes/bigd-navy.css +73 -0
  253. package/tenants/bigdvapor/theme.json +12 -0
  254. package/tenants/giantvapes/content/chrome.html +152 -0
  255. package/tenants/giantvapes/content/chrome.json +94 -0
  256. package/tenants/giantvapes/content/home.html +194 -0
  257. package/tenants/giantvapes/content/home.json +50 -0
  258. package/tenants/giantvapes/content/pages/about.json +10 -0
  259. package/tenants/giantvapes/content/pages/privacy.json +6 -0
  260. package/tenants/giantvapes/content/pages/shipping-returns.json +6 -0
  261. package/tenants/giantvapes/content/pages-html/blogs/news.html +68 -0
  262. package/tenants/giantvapes/content/pages-html/pages/about-us.html +95 -0
  263. package/tenants/giantvapes/content/pages-html/pages/contact-us.html +68 -0
  264. package/tenants/giantvapes/content/pages-html/pages/privacy-policy.html +65 -0
  265. package/tenants/giantvapes/content/pages-html/pages/shipping-returns.html +77 -0
  266. package/tenants/giantvapes/content/themes/giant-navy.json +73 -0
  267. package/tenants/giantvapes/public/img/hero-suicide-bunny.jpg +0 -0
  268. package/tenants/giantvapes/public/img/hero.webp +0 -0
  269. package/tenants/giantvapes/public/img/og.jpg +0 -0
  270. package/tenants/giantvapes/public/logo-wordmark.png +0 -0
  271. package/tenants/giantvapes/public/pages/home.css +120 -0
  272. package/tenants/giantvapes/public/pages/mkt.css +185 -0
  273. package/tenants/giantvapes/public/pages/page.css +155 -0
  274. package/tenants/giantvapes/public/themes/giant-navy.css +76 -0
  275. package/tenants/giantvapes/theme.json +39 -0
  276. package/tenants/home/content/chrome.json +11 -0
  277. package/tenants/home/content/home.html +304 -0
  278. package/tenants/home/content/home.json +13 -0
  279. package/tenants/home/public/js/storefront.js +210 -0
  280. package/tenants/home/public/logo-wordmark.png +0 -0
  281. package/tenants/home/public/pages/storefront.css +212 -0
  282. package/tenants/home/theme.json +6 -0
  283. package/tenants/tokenoftrust/.tot/config.json +9 -0
  284. package/tenants/tokenoftrust/content/chrome.json +55 -0
  285. package/tenants/tokenoftrust/content/home.html +723 -0
  286. package/tenants/tokenoftrust/content/home.json +233 -0
  287. package/tenants/tokenoftrust/content/pages-html/careers.html +231 -0
  288. package/tenants/tokenoftrust/content/pages-html/company.html +275 -0
  289. package/tenants/tokenoftrust/content/pages-html/contact/contact-sales.html +188 -0
  290. package/tenants/tokenoftrust/content/pages-html/contact/startup-apply.html +206 -0
  291. package/tenants/tokenoftrust/content/pages-html/contact.html +178 -0
  292. package/tenants/tokenoftrust/content/pages-html/industries/adult-content-age-verification.html +286 -0
  293. package/tenants/tokenoftrust/content/pages-html/industries/cannabis.html +277 -0
  294. package/tenants/tokenoftrust/content/pages-html/pact-act-pricing.html +289 -0
  295. package/tenants/tokenoftrust/content/pages-html/pricing.html +483 -0
  296. package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-estimation.html +278 -0
  297. package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-gate.html +279 -0
  298. package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-verification.html +265 -0
  299. package/tenants/tokenoftrust/content/pages-html/product/age-assurance.html +173 -0
  300. package/tenants/tokenoftrust/content/pages-html/product/compliance/aml.html +214 -0
  301. package/tenants/tokenoftrust/content/pages-html/product/compliance/pact-act.html +226 -0
  302. package/tenants/tokenoftrust/content/pages-html/product/fraud-prevention.html +254 -0
  303. package/tenants/tokenoftrust/content/pages-html/product/social-profile-verification.html +212 -0
  304. package/tenants/tokenoftrust/content/pages-html/product/tax/excise-tax.html +213 -0
  305. package/tenants/tokenoftrust/content/pages-html/product/verification/aml.html +259 -0
  306. package/tenants/tokenoftrust/content/pages-html/product/verification/biometric-selfie.html +305 -0
  307. package/tenants/tokenoftrust/content/pages-html/product/verification/coverage.html +254 -0
  308. package/tenants/tokenoftrust/content/pages-html/product/verification/document-verification.html +306 -0
  309. package/tenants/tokenoftrust/content/pages-html/product/verification/electronic-identity-verification.html +304 -0
  310. package/tenants/tokenoftrust/content/pages-html/product/verification/government-id-verification.html +308 -0
  311. package/tenants/tokenoftrust/content/pages-html/product/verification.html +183 -0
  312. package/tenants/tokenoftrust/content/pages-html/product.html +228 -0
  313. package/tenants/tokenoftrust/content/pages-html/resources/integrations/bigcommerce-integration.html +220 -0
  314. package/tenants/tokenoftrust/content/pages-html/resources/integrations/shopify-integration.html +221 -0
  315. package/tenants/tokenoftrust/content/pages-html/resources/integrations/wordpress.html +220 -0
  316. package/tenants/tokenoftrust/content/pages-html/resources/integrations.html +172 -0
  317. package/tenants/tokenoftrust/content/pages-html/resources/refer-a-friend-program.html +317 -0
  318. package/tenants/tokenoftrust/content/pages-html/resources.html +177 -0
  319. package/tenants/tokenoftrust/content/pages-html/reviews.html +273 -0
  320. package/tenants/tokenoftrust/content/pages-html/solutions.html +292 -0
  321. package/tenants/tokenoftrust/content/pages-html/vlp-alcohol.html +289 -0
  322. package/tenants/tokenoftrust/content/pages-html/vlp-cigars-and-tobacco.html +286 -0
  323. package/tenants/tokenoftrust/content/pages-html/vlp-firearm-accessories.html +267 -0
  324. package/tenants/tokenoftrust/content/pages-html/vlp-peptides.html +273 -0
  325. package/tenants/tokenoftrust/content/themes/tot-navy.json +88 -0
  326. package/tenants/tokenoftrust/public/favicon.png +0 -0
  327. package/tenants/tokenoftrust/public/logo-icon.png +0 -0
  328. package/tenants/tokenoftrust/public/logo-wordmark.png +0 -0
  329. package/tenants/tokenoftrust/public/pages/company.css +108 -0
  330. package/tenants/tokenoftrust/public/pages/home.css +457 -0
  331. package/tenants/tokenoftrust/public/pages/mkt.css +482 -0
  332. package/tenants/tokenoftrust/public/pages/pricing.css +214 -0
  333. package/tenants/tokenoftrust/public/pages/solutions.css +66 -0
  334. package/tenants/tokenoftrust/public/themes/tot-navy-1.0.0.css +136 -0
  335. package/tenants/tokenoftrust/public/themes/tot-navy.css +358 -0
  336. package/tenants/tokenoftrust/theme.json +31 -0
  337. package/tsconfig.base.json +20 -0
@@ -0,0 +1,248 @@
1
+ /**
2
+ * <SearchTypeahead> — compact header search with debounced typeahead dropdown.
3
+ *
4
+ * Interactive island (Preact). Fetches `GET {action}?q=<q>&format=json` (the
5
+ * /search page returns `{ products: TypeaheadDoc[] }` when `format=json`) and
6
+ * renders up to 6 results as an ARIA combobox/listbox. Submitting the form (or
7
+ * Enter with no active option) navigates to the full `{action}?q=<q>` page.
8
+ *
9
+ * A11y: role=combobox input owning a role=listbox of role=option rows,
10
+ * aria-activedescendant for the active row, ArrowUp/Down navigation, Enter to
11
+ * open a product, Escape to dismiss. Fetch failures degrade to "no dropdown".
12
+ */
13
+ import { useCallback, useEffect, useRef, useState } from "preact/hooks";
14
+ import type { TypeaheadDoc } from "@/lib/search";
15
+ import { formatPrice } from "@/lib/format";
16
+ import { placeholderDataUri, isUsableImageUrl } from "@/lib/placeholder";
17
+ import { withClientBase } from "@/lib/basePath";
18
+
19
+ interface Props {
20
+ action?: string;
21
+ }
22
+
23
+ const MAX_RESULTS = 6;
24
+ const DEBOUNCE_MS = 150;
25
+ const MIN_CHARS = 2;
26
+ const LISTBOX_ID = "typeahead-listbox";
27
+
28
+ function thumbFor(doc: TypeaheadDoc): string {
29
+ if (isUsableImageUrl(doc.image, false)) return doc.image!;
30
+ return placeholderDataUri({
31
+ seed: doc.handle,
32
+ width: 48,
33
+ height: 48,
34
+ label: doc.title,
35
+ });
36
+ }
37
+
38
+ export default function SearchTypeahead({ action = "/search" }: Props) {
39
+ const [query, setQuery] = useState("");
40
+ const [results, setResults] = useState<TypeaheadDoc[]>([]);
41
+ const [open, setOpen] = useState(false);
42
+ const [active, setActive] = useState(-1); // index into results, -1 = none
43
+ const rootRef = useRef<HTMLFormElement>(null);
44
+ const inputRef = useRef<HTMLInputElement>(null);
45
+ const abortRef = useRef<AbortController | null>(null);
46
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
47
+
48
+ const closeDropdown = useCallback(() => {
49
+ setOpen(false);
50
+ setActive(-1);
51
+ }, []);
52
+
53
+ // Debounced fetch.
54
+ useEffect(() => {
55
+ const q = query.trim();
56
+ if (debounceRef.current) clearTimeout(debounceRef.current);
57
+ if (q.length < MIN_CHARS) {
58
+ setResults([]);
59
+ setOpen(false);
60
+ setActive(-1);
61
+ return;
62
+ }
63
+ debounceRef.current = setTimeout(() => {
64
+ abortRef.current?.abort();
65
+ const ctrl = new AbortController();
66
+ abortRef.current = ctrl;
67
+ // Prefix with the tenant base path so the typeahead queries THIS tenant's
68
+ // /search (path-routed) rather than the root fallback tenant.
69
+ const url = `${withClientBase(action)}?q=${encodeURIComponent(q)}&format=json`;
70
+ fetch(url, { signal: ctrl.signal, headers: { Accept: "application/json" } })
71
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error("bad status"))))
72
+ .then((data: { products?: TypeaheadDoc[] }) => {
73
+ const products = Array.isArray(data.products)
74
+ ? data.products.slice(0, MAX_RESULTS)
75
+ : [];
76
+ setResults(products);
77
+ setActive(-1);
78
+ setOpen(products.length > 0);
79
+ })
80
+ .catch(() => {
81
+ // Network/parse error or aborted: degrade silently (no dropdown).
82
+ setResults([]);
83
+ setOpen(false);
84
+ setActive(-1);
85
+ });
86
+ }, DEBOUNCE_MS);
87
+
88
+ return () => {
89
+ if (debounceRef.current) clearTimeout(debounceRef.current);
90
+ };
91
+ }, [query, action]);
92
+
93
+ // Close when focus/click leaves the widget.
94
+ useEffect(() => {
95
+ function onDocClick(e: MouseEvent) {
96
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
97
+ closeDropdown();
98
+ }
99
+ }
100
+ document.addEventListener("mousedown", onDocClick);
101
+ return () => document.removeEventListener("mousedown", onDocClick);
102
+ }, [closeDropdown]);
103
+
104
+ const go = useCallback((href: string) => {
105
+ // Keep navigations inside the tenant base path (path-routed tenants).
106
+ window.location.href = withClientBase(href);
107
+ }, []);
108
+
109
+ const submitSearch = useCallback(() => {
110
+ const q = query.trim();
111
+ if (q.length === 0) return;
112
+ go(`${action}?q=${encodeURIComponent(q)}`);
113
+ }, [query, action, go]);
114
+
115
+ const onKeyDown = useCallback(
116
+ (e: KeyboardEvent) => {
117
+ if (e.key === "Escape") {
118
+ closeDropdown();
119
+ return;
120
+ }
121
+ if (!open || results.length === 0) {
122
+ // Enter falls through to native form submit handler.
123
+ return;
124
+ }
125
+ if (e.key === "ArrowDown") {
126
+ e.preventDefault();
127
+ setActive((i) => (i + 1) % results.length);
128
+ } else if (e.key === "ArrowUp") {
129
+ e.preventDefault();
130
+ setActive((i) => (i <= 0 ? results.length - 1 : i - 1));
131
+ } else if (e.key === "Enter") {
132
+ if (active >= 0 && active < results.length) {
133
+ e.preventDefault();
134
+ go(`/products/${results[active]!.handle}`);
135
+ }
136
+ // else: let the form submit (full results page).
137
+ }
138
+ },
139
+ [open, results, active, closeDropdown, go],
140
+ );
141
+
142
+ const activeId = active >= 0 ? `typeahead-opt-${active}` : undefined;
143
+
144
+ return (
145
+ <form
146
+ ref={rootRef}
147
+ role="search"
148
+ aria-label="Site search"
149
+ action={action}
150
+ method="get"
151
+ onSubmit={(e) => {
152
+ e.preventDefault();
153
+ submitSearch();
154
+ }}
155
+ class="relative w-full"
156
+ >
157
+ <div class="flex items-center gap-2 rounded-full border border-border bg-surface px-3 py-1.5 focus-within:border-primary">
158
+ <svg
159
+ width="16"
160
+ height="16"
161
+ viewBox="0 0 24 24"
162
+ fill="none"
163
+ stroke="currentColor"
164
+ stroke-width="2"
165
+ stroke-linecap="round"
166
+ aria-hidden="true"
167
+ class="shrink-0 text-muted"
168
+ >
169
+ <circle cx="11" cy="11" r="7" />
170
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
171
+ </svg>
172
+ <input
173
+ ref={inputRef}
174
+ type="search"
175
+ name="q"
176
+ value={query}
177
+ autocomplete="off"
178
+ spellcheck={false}
179
+ aria-label="Search products"
180
+ // eslint-disable-next-line jsx-a11y/role-has-required-aria-props
181
+ role="combobox"
182
+ aria-expanded={open}
183
+ aria-controls={LISTBOX_ID}
184
+ aria-autocomplete="list"
185
+ aria-activedescendant={activeId}
186
+ placeholder="Search products"
187
+ onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
188
+ onFocus={() => {
189
+ if (results.length > 0) setOpen(true);
190
+ }}
191
+ onKeyDown={onKeyDown}
192
+ class="w-full min-w-0 bg-transparent font-body text-sm text-text placeholder:text-muted focus:outline-none"
193
+ />
194
+ </div>
195
+
196
+ {open && results.length > 0 && (
197
+ <ul
198
+ id={LISTBOX_ID}
199
+ role="listbox"
200
+ aria-label="Search suggestions"
201
+ class="absolute left-0 right-0 top-[calc(100%+0.4rem)] z-50 overflow-hidden rounded-md border border-border bg-surface shadow-lg"
202
+ >
203
+ {results.map((doc, i) => (
204
+ <li
205
+ key={doc.handle}
206
+ id={`typeahead-opt-${i}`}
207
+ role="option"
208
+ aria-selected={i === active}
209
+ onMouseEnter={() => setActive(i)}
210
+ onMouseDown={(e) => {
211
+ // Prevent input blur before navigation.
212
+ e.preventDefault();
213
+ go(`/products/${doc.handle}`);
214
+ }}
215
+ class={`flex cursor-pointer items-center gap-3 px-3 py-2 ${
216
+ i === active ? "bg-border/50" : ""
217
+ }`}
218
+ >
219
+ <img
220
+ src={thumbFor(doc)}
221
+ alt=""
222
+ width={40}
223
+ height={40}
224
+ loading="lazy"
225
+ decoding="async"
226
+ class="h-10 w-10 shrink-0 rounded object-cover"
227
+ style="background:var(--color-border)"
228
+ />
229
+ <span class="min-w-0 flex-1">
230
+ <span class="block truncate font-body text-sm text-text">
231
+ {doc.title}
232
+ </span>
233
+ {(doc.vendor || doc.type) && (
234
+ <span class="block truncate font-mono text-[0.65rem] uppercase tracking-[0.08em] text-muted">
235
+ {[doc.vendor, doc.type].filter(Boolean).join(" · ")}
236
+ </span>
237
+ )}
238
+ </span>
239
+ <span class="shrink-0 font-mono text-sm text-text">
240
+ {formatPrice(doc.price, doc.currency)}
241
+ </span>
242
+ </li>
243
+ ))}
244
+ </ul>
245
+ )}
246
+ </form>
247
+ );
248
+ }
@@ -0,0 +1,378 @@
1
+ /**
2
+ * <VariantSelector> — PDP variant picker (the centerpiece interaction).
3
+ *
4
+ * Interactive island (Preact). One radiogroup per product option (Color, Size,
5
+ * …). Selecting values resolves the matching CatalogVariant via `option_values`.
6
+ * For each value we compute availability GIVEN the other current selections and
7
+ * clearly disable unbuyable combinations (struck-through + aria-disabled).
8
+ *
9
+ * On a complete valid selection we update a polite live region (price /
10
+ * compare-at / SKU / stock state) and dispatch two window CustomEvents:
11
+ * - `variant:image` { imageId } → <ImageGallery> swaps the main image
12
+ * - `variant:change` { variantId, available }
13
+ *
14
+ * Buy button is an intentionally-disabled placeholder (Phase 1 has no cart).
15
+ *
16
+ * A11y: role=radiogroup/radio with aria-checked, disabled values get
17
+ * aria-disabled, live region is aria-live="polite".
18
+ */
19
+ import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
20
+ import type { CatalogVariant, VariantCartPayload } from "@tot/public-runtime";
21
+ import { formatPrice } from "@/lib/format";
22
+
23
+ interface OptionDef {
24
+ name: string;
25
+ position: number;
26
+ values: string[];
27
+ }
28
+
29
+ interface Props {
30
+ variants: CatalogVariant[];
31
+ options: OptionDef[];
32
+ currency: string;
33
+ locale?: string;
34
+ /**
35
+ * FoxyCart add-to-cart (Track A). Server pre-builds a signed cart payload per
36
+ * variant; the island renders the selected variant's payload as a real Foxy
37
+ * form (loader.js turns it into the sidecart). Undefined → no cart (marketing
38
+ * tenant, or signed mode missing secret) → the button stays a placeholder.
39
+ */
40
+ foxyCart?: {
41
+ action: string;
42
+ byVariantId: Record<string, VariantCartPayload>;
43
+ };
44
+ }
45
+
46
+ const LOW_STOCK_THRESHOLD = 5;
47
+
48
+ type Selection = Record<string, string>;
49
+
50
+ function variantMatches(v: CatalogVariant, sel: Selection): boolean {
51
+ return Object.entries(sel).every(([name, value]) => v.option_values[name] === value);
52
+ }
53
+
54
+ function findVariant(
55
+ variants: CatalogVariant[],
56
+ sel: Selection,
57
+ ): CatalogVariant | undefined {
58
+ return variants.find((v) => variantMatches(v, sel));
59
+ }
60
+
61
+ export default function VariantSelector({
62
+ variants,
63
+ options,
64
+ currency,
65
+ locale,
66
+ foxyCart,
67
+ }: Props) {
68
+ const sortedOptions = useMemo(
69
+ () => [...options].sort((a, b) => a.position - b.position),
70
+ [options],
71
+ );
72
+
73
+ // Default selection: first available variant (fallback: first variant).
74
+ const defaultSelection = useMemo<Selection>(() => {
75
+ const base =
76
+ variants.find((v) => v.inventory.available) ?? variants[0];
77
+ const sel: Selection = {};
78
+ if (base) {
79
+ for (const opt of sortedOptions) {
80
+ const val = base.option_values[opt.name];
81
+ if (val !== undefined) sel[opt.name] = val;
82
+ }
83
+ }
84
+ return sel;
85
+ }, [variants, sortedOptions]);
86
+
87
+ const [selection, setSelection] = useState<Selection>(defaultSelection);
88
+ const [quantity, setQuantity] = useState(1);
89
+
90
+ const selected = useMemo(
91
+ () => findVariant(variants, selection),
92
+ [variants, selection],
93
+ );
94
+
95
+ const complete = useMemo(
96
+ () => sortedOptions.every((o) => selection[o.name] !== undefined),
97
+ [sortedOptions, selection],
98
+ );
99
+
100
+ // Is a value buyable given the OTHER current selections?
101
+ const isValueAvailable = useCallback(
102
+ (optionName: string, value: string): boolean => {
103
+ const trial: Selection = { ...selection, [optionName]: value };
104
+ // Only constrain on options that the trial actually pins.
105
+ return variants.some(
106
+ (v) =>
107
+ v.inventory.available &&
108
+ Object.entries(trial).every(
109
+ ([n, val]) => v.option_values[n] === val,
110
+ ),
111
+ );
112
+ },
113
+ [selection, variants],
114
+ );
115
+
116
+ const selectValue = useCallback(
117
+ (optionName: string, value: string) => {
118
+ setSelection((prev) => ({ ...prev, [optionName]: value }));
119
+ },
120
+ [],
121
+ );
122
+
123
+ // Roving radio keyboard handling within a group.
124
+ const onRadioKeyDown = useCallback(
125
+ (e: KeyboardEvent, opt: OptionDef, currentIdx: number) => {
126
+ const keys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"];
127
+ if (!keys.includes(e.key)) return;
128
+ e.preventDefault();
129
+ const fwd = e.key === "ArrowRight" || e.key === "ArrowDown";
130
+ const len = opt.values.length;
131
+ let i = currentIdx;
132
+ // Step to the next selectable (available) value, wrapping; if none other
133
+ // is available, stay put.
134
+ for (let step = 0; step < len; step++) {
135
+ i = fwd ? (i + 1) % len : (i - 1 + len) % len;
136
+ const val = opt.values[i]!;
137
+ if (isValueAvailable(opt.name, val)) {
138
+ selectValue(opt.name, val);
139
+ return;
140
+ }
141
+ }
142
+ },
143
+ [isValueAvailable, selectValue],
144
+ );
145
+
146
+ // Dispatch cross-island events when the resolved variant changes.
147
+ useEffect(() => {
148
+ if (!complete || !selected) return;
149
+ if (selected.image_id) {
150
+ window.dispatchEvent(
151
+ new CustomEvent("variant:image", {
152
+ detail: { imageId: selected.image_id },
153
+ }),
154
+ );
155
+ }
156
+ window.dispatchEvent(
157
+ new CustomEvent("variant:change", {
158
+ detail: {
159
+ variantId: selected.id,
160
+ available: selected.inventory.available,
161
+ },
162
+ }),
163
+ );
164
+ }, [complete, selected]);
165
+
166
+ // ---- live-region status copy --------------------------------------------
167
+ const qty = selected?.inventory.quantity;
168
+ const available = selected?.inventory.available ?? false;
169
+ let stockLabel = "";
170
+ let stockClass = "text-muted";
171
+ if (complete && selected) {
172
+ if (!available) {
173
+ stockLabel = "Sold out";
174
+ stockClass = "text-sale";
175
+ } else if (qty !== undefined && qty !== null && qty <= LOW_STOCK_THRESHOLD) {
176
+ stockLabel = `Only ${qty} left`;
177
+ stockClass = "text-accent";
178
+ } else {
179
+ stockLabel = "In stock";
180
+ stockClass = "text-primary";
181
+ }
182
+ }
183
+
184
+ const compareAt = selected?.compare_at_price;
185
+ const showCompare =
186
+ selected !== undefined &&
187
+ compareAt !== undefined &&
188
+ compareAt > selected.price;
189
+
190
+ // FoxyCart payload for the resolved variant (server pre-signed). Buyable only
191
+ // when the selection is complete, in stock, AND a cart is wired.
192
+ const cartPayload =
193
+ foxyCart && selected ? foxyCart.byVariantId[selected.id] : undefined;
194
+ const canBuy = complete && available && !!cartPayload;
195
+ const buyLabel = !complete
196
+ ? "Select options"
197
+ : !available
198
+ ? "Sold out"
199
+ : "Add to cart";
200
+
201
+ return (
202
+ <div class="flex flex-col gap-6">
203
+ {/* Live region: price + stock + sku */}
204
+ <div aria-live="polite" class="flex flex-col gap-1">
205
+ {selected ? (
206
+ <>
207
+ <div class="flex items-baseline gap-3">
208
+ <span class={`font-display text-[1.75rem] font-semibold tabular-nums leading-none ${showCompare ? "text-sale" : "text-text"}`}>
209
+ {formatPrice(selected.price, currency, locale)}
210
+ </span>
211
+ {showCompare && (
212
+ <span class="font-body text-base text-muted line-through tabular-nums">
213
+ {formatPrice(compareAt!, currency, locale)}
214
+ </span>
215
+ )}
216
+ </div>
217
+ <p class={`font-mono text-xs uppercase tracking-[0.08em] ${stockClass}`}>
218
+ {stockLabel}
219
+ </p>
220
+ {selected.sku && (
221
+ <p class="font-mono text-[0.7rem] text-muted">
222
+ SKU: {selected.sku}
223
+ </p>
224
+ )}
225
+ </>
226
+ ) : (
227
+ <p class="font-body text-sm text-muted">
228
+ Select options to see price &amp; availability.
229
+ </p>
230
+ )}
231
+ </div>
232
+
233
+ {/* Option groups */}
234
+ {sortedOptions.map((opt) => {
235
+ const groupSelected = selection[opt.name];
236
+ return (
237
+ <fieldset key={opt.name} class="flex flex-col gap-2">
238
+ <legend class="font-mono text-xs uppercase tracking-[0.12em] text-muted">
239
+ {opt.name}
240
+ {groupSelected && (
241
+ <span class="ml-2 normal-case tracking-normal text-text">
242
+ {groupSelected}
243
+ </span>
244
+ )}
245
+ </legend>
246
+ <div
247
+ role="radiogroup"
248
+ aria-label={opt.name}
249
+ class="flex flex-wrap gap-2"
250
+ >
251
+ {opt.values.map((value, vi) => {
252
+ const checked = groupSelected === value;
253
+ const enabled = isValueAvailable(opt.name, value);
254
+ return (
255
+ <button
256
+ key={value}
257
+ type="button"
258
+ role="radio"
259
+ aria-checked={checked}
260
+ aria-disabled={!enabled}
261
+ disabled={!enabled}
262
+ tabIndex={checked ? 0 : -1}
263
+ onClick={() => enabled && selectValue(opt.name, value)}
264
+ onKeyDown={(e) => onRadioKeyDown(e, opt, vi)}
265
+ class={[
266
+ "min-w-[2.75rem] rounded-md border px-3 py-2 font-body text-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary",
267
+ checked
268
+ ? "border-primary bg-primary text-primary-contrast"
269
+ : "border-border bg-surface text-text hover:border-primary",
270
+ !enabled
271
+ ? "cursor-not-allowed border-border bg-surface text-muted line-through opacity-50 hover:border-border"
272
+ : "",
273
+ ]
274
+ .filter(Boolean)
275
+ .join(" ")}
276
+ >
277
+ {value}
278
+ </button>
279
+ );
280
+ })}
281
+ </div>
282
+ </fieldset>
283
+ );
284
+ })}
285
+
286
+ {/* Quantity stepper (UI only) */}
287
+ <div class="flex flex-col gap-2">
288
+ <span
289
+ id="qty-label"
290
+ class="font-mono text-xs uppercase tracking-[0.12em] text-muted"
291
+ >
292
+ Quantity
293
+ </span>
294
+ <div class="inline-flex items-center rounded-md border border-border bg-surface">
295
+ <button
296
+ type="button"
297
+ aria-label="Decrease quantity"
298
+ disabled={quantity <= 1}
299
+ onClick={() => setQuantity((q) => Math.max(1, q - 1))}
300
+ class="flex h-10 w-10 items-center justify-center text-lg text-text transition-colors hover:bg-border/40 disabled:opacity-40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
301
+ >
302
+
303
+ </button>
304
+ <input
305
+ type="number"
306
+ min={1}
307
+ value={quantity}
308
+ aria-labelledby="qty-label"
309
+ onInput={(e) => {
310
+ const n = parseInt((e.target as HTMLInputElement).value, 10);
311
+ setQuantity(Number.isNaN(n) || n < 1 ? 1 : n);
312
+ }}
313
+ class="w-12 [appearance:textfield] bg-transparent text-center font-mono text-sm text-text focus:outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
314
+ />
315
+ <button
316
+ type="button"
317
+ aria-label="Increase quantity"
318
+ onClick={() => setQuantity((q) => q + 1)}
319
+ class="flex h-10 w-10 items-center justify-center text-lg text-text transition-colors hover:bg-border/40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
320
+ >
321
+ +
322
+ </button>
323
+ </div>
324
+ </div>
325
+
326
+ {/* Add to cart. With a wired FoxyCart store this is a real Foxy form —
327
+ loader.js turns the submit into the sidecart + hosted checkout. The
328
+ product fields are server-pre-signed (tamper-proof); the quantity
329
+ rides along as the selected stepper value. Without a store (marketing
330
+ tenant / signed mode missing secret) it degrades to a disabled
331
+ placeholder. */}
332
+ {foxyCart ? (
333
+ <div class="flex flex-col gap-1.5">
334
+ <form action={foxyCart.action} method="post" target="_top">
335
+ {canBuy &&
336
+ cartPayload!.hidden.map((f) => (
337
+ <input key={f.name} type="hidden" name={f.name} value={f.value} />
338
+ ))}
339
+ {canBuy && (
340
+ <input
341
+ type="hidden"
342
+ name={cartPayload!.quantityName}
343
+ value={String(quantity)}
344
+ />
345
+ )}
346
+ <button
347
+ type="submit"
348
+ disabled={!canBuy}
349
+ aria-disabled={!canBuy}
350
+ class={`w-full rounded-full bg-primary px-6 py-3.5 font-display text-sm font-medium uppercase tracking-[0.08em] text-primary-contrast transition-opacity ${
351
+ canBuy
352
+ ? "hover:opacity-90"
353
+ : "cursor-not-allowed opacity-60"
354
+ }`}
355
+ >
356
+ {buyLabel}
357
+ </button>
358
+ </form>
359
+ <p class="text-center font-body text-xs text-muted">
360
+ Powered by FoxyCart
361
+ </p>
362
+ </div>
363
+ ) : (
364
+ <div class="flex flex-col gap-1.5">
365
+ <button
366
+ type="button"
367
+ disabled
368
+ aria-disabled="true"
369
+ class="w-full cursor-not-allowed rounded-full bg-primary px-6 py-3.5 font-display text-sm font-medium uppercase tracking-[0.08em] text-primary-contrast opacity-60"
370
+ >
371
+ Add to cart
372
+ </button>
373
+ <p class="text-center font-body text-xs text-muted">Checkout coming soon</p>
374
+ </div>
375
+ )}
376
+ </div>
377
+ );
378
+ }
@@ -0,0 +1,72 @@
1
+ ---
2
+ /**
3
+ * <CardGrid> — a responsive grid of capability/industry/feature cards. One
4
+ * component, three visual variants (density + emphasis). Cards may link
5
+ * internally (base-path prefixed) or externally.
6
+ */
7
+ import Section from "@/components/Section.astro";
8
+ import type { CardGridBlock } from "@/lib/storyblok/content-model";
9
+ import { withBase } from "@/lib/basePath";
10
+
11
+ interface Props {
12
+ block: CardGridBlock;
13
+ basePath: string;
14
+ }
15
+ const { block, basePath } = Astro.props;
16
+ const variant = block.variant ?? "capability";
17
+ const cols =
18
+ variant === "industry"
19
+ ? "sm:grid-cols-2 lg:grid-cols-3"
20
+ : "sm:grid-cols-2 lg:grid-cols-4";
21
+ const isExternal = (href: string) => /^https?:\/\//i.test(href);
22
+ const hrefOf = (href: string) => (isExternal(href) ? href : withBase(basePath, href));
23
+ ---
24
+ <Section id={block.anchor} eyebrow={block.eyebrow} title={block.title} tone="bg">
25
+ {block.subhead && (
26
+ <p class="-mt-4 mb-9 max-w-[60ch] text-[var(--color-muted)]" data-reveal>
27
+ {block.subhead}
28
+ </p>
29
+ )}
30
+ <div class:list={["grid gap-5", cols]} data-reveal>
31
+ {block.cards.map((card) => {
32
+ const Tag = card.href ? "a" : "div";
33
+ const linkProps = card.href
34
+ ? {
35
+ href: hrefOf(card.href),
36
+ ...(isExternal(card.href)
37
+ ? { target: "_blank", rel: "noopener noreferrer" }
38
+ : {}),
39
+ }
40
+ : {};
41
+ return (
42
+ <Tag
43
+ {...linkProps}
44
+ class:list={[
45
+ "flex flex-col rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6 transition-shadow",
46
+ card.href ? "hover:shadow-[var(--shadow-md)]" : "",
47
+ ]}
48
+ >
49
+ {card.icon && (
50
+ <span aria-hidden="true" class="text-2xl leading-none">{card.icon}</span>
51
+ )}
52
+ <h3 class="mt-3 font-display text-base font-semibold text-[var(--color-text)]">
53
+ {card.title}
54
+ </h3>
55
+ {card.body && (
56
+ <p class="mt-2 flex-1 text-sm text-[var(--color-muted)]">{card.body}</p>
57
+ )}
58
+ {card.meta && (
59
+ <p class="mt-4 font-mono text-[0.7rem] uppercase tracking-[0.08em] text-[var(--color-muted)]">
60
+ {card.meta}
61
+ </p>
62
+ )}
63
+ </Tag>
64
+ );
65
+ })}
66
+ </div>
67
+ {block.footnote && (
68
+ <p class="mt-8 text-center text-sm text-[var(--color-muted)]" data-reveal>
69
+ {block.footnote}
70
+ </p>
71
+ )}
72
+ </Section>