@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,164 @@
1
+ ---
2
+ /**
3
+ * <TierCards> — the membership / pricing tier comparison card row (the
4
+ * Guest / Member / Autopilot pattern). Presentational + EDUCATION ONLY: it shows
5
+ * tiers, their headline figures, and feature bullets, but ships NO pricing,
6
+ * checkout, or subscription mechanics — each tier's CTA is a plain handoff link
7
+ * into the tenant's own (product-owned) subscribe/learn flow.
8
+ *
9
+ * Illustrative-figures affordance (decision compliance-widgets-platform-owned):
10
+ * a tier's figure is treated as an EXAMPLE by default (isIllustrative). When any
11
+ * shown tier is illustrative the group header carries a first-class
12
+ * <IllustrativeLabel> badge, each such figure is prefixed with an "e.g."
13
+ * affordance + data-illustrative hook, and a footnote spells it out — so an
14
+ * example number is never presented as a live offer. A tenant opts a tier out
15
+ * only by asserting `real: true` on confirmed live values.
16
+ *
17
+ * Token-styled only — re-skins entirely from the theme's contract tokens
18
+ * (--color-…, --radius-…, --shadow-…); no hardcoded brand color.
19
+ * data-widget="tier-cards" is the audit/test hook.
20
+ */
21
+ import type { Tier } from "@/lib/membership/model";
22
+ import { isIllustrative } from "@/lib/membership/model";
23
+ import IllustrativeLabel from "@/components/membership/IllustrativeLabel.astro";
24
+
25
+ interface Props {
26
+ tiers: Tier[];
27
+ /** Optional small eyebrow above the heading. */
28
+ eyebrow?: string;
29
+ /** Optional section heading. */
30
+ heading?: string;
31
+ /** Optional supporting sentence under the heading. */
32
+ subhead?: string;
33
+ }
34
+
35
+ const { tiers, eyebrow, heading, subhead } = Astro.props;
36
+
37
+ // Any illustrative figure in the group drives the group-level affordances.
38
+ const anyIllustrative = tiers.some((t) => t.figure && isIllustrative(t));
39
+ ---
40
+
41
+ {
42
+ tiers.length > 0 && (
43
+ <section class="container-prose" data-widget="tier-cards">
44
+ {(eyebrow || heading || subhead || anyIllustrative) && (
45
+ <header class="mb-8 text-center">
46
+ {eyebrow && (
47
+ <p class="mb-2 text-xs font-semibold uppercase tracking-[0.12em] text-[var(--color-accent-ink)]">
48
+ {eyebrow}
49
+ </p>
50
+ )}
51
+ {heading && (
52
+ <h2 class="font-display text-2xl font-bold text-[var(--color-text)] sm:text-3xl">
53
+ {heading}
54
+ </h2>
55
+ )}
56
+ {subhead && (
57
+ <p class="mx-auto mt-3 max-w-[60ch] text-[var(--color-muted)]">{subhead}</p>
58
+ )}
59
+ {anyIllustrative && (
60
+ <div class="mt-4">
61
+ <IllustrativeLabel as="badge" />
62
+ </div>
63
+ )}
64
+ </header>
65
+ )}
66
+
67
+ <div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
68
+ {tiers.map((tier) => {
69
+ const illustrative = Boolean(tier.figure) && isIllustrative(tier);
70
+ return (
71
+ <article
72
+ class={`relative flex flex-col rounded-[var(--radius-lg)] border bg-[var(--color-surface)] p-6 ${
73
+ tier.featured
74
+ ? "border-2 border-[var(--color-primary)] shadow-[var(--shadow-md)]"
75
+ : "border-[var(--color-border)]"
76
+ }`}
77
+ data-tier={tier.name}
78
+ >
79
+ {tier.featured && tier.badge && (
80
+ <span class="absolute -top-3 left-1/2 -translate-x-1/2 rounded-[var(--radius-full)] bg-[var(--color-primary)] px-3 py-1 text-[0.65rem] font-bold uppercase tracking-[0.06em] text-[var(--color-primary-contrast)]">
81
+ {tier.badge}
82
+ </span>
83
+ )}
84
+
85
+ <h3 class="font-display text-lg font-semibold text-[var(--color-text)]">
86
+ {tier.name}
87
+ </h3>
88
+ {tier.tagline && (
89
+ <p class="mt-1 text-sm text-[var(--color-muted)]">{tier.tagline}</p>
90
+ )}
91
+
92
+ {tier.figure && (
93
+ <p
94
+ class="mt-4 flex items-baseline gap-1.5 text-[var(--color-text)]"
95
+ data-illustrative={illustrative ? "figure" : undefined}
96
+ >
97
+ {illustrative && (
98
+ <span
99
+ class="text-xs font-medium text-[var(--color-muted)]"
100
+ title="Illustrative example"
101
+ >
102
+ e.g.
103
+ </span>
104
+ )}
105
+ <span class="font-display text-3xl font-bold leading-none">
106
+ {tier.figure.value}
107
+ </span>
108
+ {tier.figure.caption && (
109
+ <span class="text-sm text-[var(--color-muted)]">{tier.figure.caption}</span>
110
+ )}
111
+ </p>
112
+ )}
113
+
114
+ {tier.features && tier.features.length > 0 && (
115
+ <ul class="mt-5 flex-1 space-y-2.5">
116
+ {tier.features.map((f) => (
117
+ <li
118
+ class={`flex items-start gap-2.5 text-sm ${
119
+ f.included === false
120
+ ? "text-[var(--color-muted)]"
121
+ : "text-[var(--color-text)]"
122
+ }`}
123
+ >
124
+ <span
125
+ aria-hidden="true"
126
+ class={`mt-0.5 ${
127
+ f.included === false
128
+ ? "text-[var(--color-muted)]"
129
+ : "text-[var(--color-success)]"
130
+ }`}
131
+ >
132
+ {f.included === false ? "–" : f.included === true ? "✓" : "•"}
133
+ </span>
134
+ <span class={f.included === false ? "line-through" : ""}>{f.label}</span>
135
+ </li>
136
+ ))}
137
+ </ul>
138
+ )}
139
+
140
+ {tier.cta && (
141
+ <a
142
+ href={tier.cta.href}
143
+ class={`mt-6 inline-flex min-h-[44px] items-center justify-center rounded-[var(--radius-full)] px-5 py-2.5 text-sm font-semibold ${
144
+ tier.featured
145
+ ? "bg-[var(--color-primary)] text-[var(--color-primary-contrast)]"
146
+ : "border-2 border-[var(--color-primary)] text-[var(--color-primary)]"
147
+ }`}
148
+ >
149
+ {tier.cta.label}
150
+ </a>
151
+ )}
152
+ </article>
153
+ );
154
+ })}
155
+ </div>
156
+
157
+ {anyIllustrative && (
158
+ <footer class="mt-6 text-center">
159
+ <IllustrativeLabel as="note" />
160
+ </footer>
161
+ )}
162
+ </section>
163
+ )
164
+ }
@@ -0,0 +1,148 @@
1
+ ---
2
+ /**
3
+ * <TierComparisonTable> — the membership/pricing feature matrix companion to
4
+ * <TierCards>: tiers across the top, features down the side, one cell per
5
+ * (feature × tier). Presentational + EDUCATION ONLY — no pricing/checkout/
6
+ * subscription mechanics; a tier's optional figure and CTA are display/handoff
7
+ * only.
8
+ *
9
+ * Illustrative-figures affordance (decision compliance-widgets-platform-owned):
10
+ * when any tier shows an illustrative figure the header carries a first-class
11
+ * <IllustrativeLabel> badge, each such figure gets an "e.g." affordance +
12
+ * data-illustrative hook, and a footnote spells it out — an example number is
13
+ * never presented as a live offer.
14
+ *
15
+ * Cells align to `tiers` by index (see ComparisonRow): boolean → check/dash,
16
+ * string → text, null/omitted → em dash. Token-styled only; horizontally
17
+ * scrollable so it never overflows the page on narrow viewports.
18
+ * data-widget="tier-comparison-table" is the audit/test hook.
19
+ */
20
+ import type { Tier, ComparisonRow } from "@/lib/membership/model";
21
+ import { isIllustrative } from "@/lib/membership/model";
22
+ import IllustrativeLabel from "@/components/membership/IllustrativeLabel.astro";
23
+
24
+ interface Props {
25
+ tiers: Tier[];
26
+ rows: ComparisonRow[];
27
+ /** Optional small eyebrow above the heading. */
28
+ eyebrow?: string;
29
+ /** Optional section heading. */
30
+ heading?: string;
31
+ }
32
+
33
+ const { tiers, rows, eyebrow, heading } = Astro.props;
34
+ const anyIllustrative = tiers.some((t) => t.figure && isIllustrative(t));
35
+ ---
36
+
37
+ {
38
+ tiers.length > 0 && (
39
+ <section class="container-prose" data-widget="tier-comparison-table">
40
+ {(eyebrow || heading || anyIllustrative) && (
41
+ <header class="mb-6 text-center">
42
+ {eyebrow && (
43
+ <p class="mb-2 text-xs font-semibold uppercase tracking-[0.12em] text-[var(--color-accent-ink)]">
44
+ {eyebrow}
45
+ </p>
46
+ )}
47
+ {heading && (
48
+ <h2 class="font-display text-2xl font-bold text-[var(--color-text)] sm:text-3xl">
49
+ {heading}
50
+ </h2>
51
+ )}
52
+ {anyIllustrative && (
53
+ <div class="mt-4">
54
+ <IllustrativeLabel as="badge" />
55
+ </div>
56
+ )}
57
+ </header>
58
+ )}
59
+
60
+ <div class="overflow-x-auto">
61
+ <table class="w-full min-w-[32rem] border-collapse text-sm">
62
+ <caption class="sr-only">
63
+ {heading ?? "Membership tier comparison"}
64
+ </caption>
65
+ <thead>
66
+ <tr>
67
+ <th scope="col" class="w-1/3 p-3 text-left align-bottom" />
68
+ {tiers.map((tier) => {
69
+ const illustrative = Boolean(tier.figure) && isIllustrative(tier);
70
+ return (
71
+ <th
72
+ scope="col"
73
+ class={`p-3 text-center align-bottom ${
74
+ tier.featured
75
+ ? "rounded-t-[var(--radius-md)] bg-[var(--color-surface-2)]"
76
+ : ""
77
+ }`}
78
+ data-tier={tier.name}
79
+ >
80
+ <span class="block font-display text-base font-semibold text-[var(--color-text)]">
81
+ {tier.name}
82
+ </span>
83
+ {tier.figure && (
84
+ <span
85
+ class="mt-1 block text-xs text-[var(--color-muted)]"
86
+ data-illustrative={illustrative ? "figure" : undefined}
87
+ >
88
+ {illustrative && <span title="Illustrative example">e.g. </span>}
89
+ {tier.figure.value}
90
+ {tier.figure.caption ? ` ${tier.figure.caption}` : ""}
91
+ </span>
92
+ )}
93
+ </th>
94
+ );
95
+ })}
96
+ </tr>
97
+ </thead>
98
+ <tbody>
99
+ {rows.map((row) => (
100
+ <tr class="border-t border-[var(--color-border)]">
101
+ <th
102
+ scope="row"
103
+ class="p-3 text-left font-medium text-[var(--color-text)]"
104
+ >
105
+ {row.label}
106
+ </th>
107
+ {tiers.map((tier, i) => {
108
+ const cell = row.cells[i];
109
+ return (
110
+ <td
111
+ class={`p-3 text-center ${
112
+ tier.featured ? "bg-[var(--color-surface-2)]" : ""
113
+ }`}
114
+ >
115
+ {typeof cell === "boolean" ? (
116
+ cell ? (
117
+ <span class="text-[var(--color-success)]" aria-label="Included">
118
+
119
+ </span>
120
+ ) : (
121
+ <span class="text-[var(--color-muted)]" aria-label="Not included">
122
+
123
+ </span>
124
+ )
125
+ ) : cell != null && cell !== "" ? (
126
+ <span class="text-[var(--color-text)]">{cell}</span>
127
+ ) : (
128
+ <span class="text-[var(--color-muted)]" aria-label="Not applicable">
129
+
130
+ </span>
131
+ )}
132
+ </td>
133
+ );
134
+ })}
135
+ </tr>
136
+ ))}
137
+ </tbody>
138
+ </table>
139
+ </div>
140
+
141
+ {anyIllustrative && (
142
+ <footer class="mt-4 text-center">
143
+ <IllustrativeLabel as="note" />
144
+ </footer>
145
+ )}
146
+ </section>
147
+ )
148
+ }
@@ -0,0 +1,79 @@
1
+ ---
2
+ /**
3
+ * Active-filter chips (Shopify-style). Shows what's applied as removable pills
4
+ * above the grid — each chip is a link that drops exactly one filter, so it's
5
+ * URL-addressable and back-button safe like the rest of faceting (src/lib/url.ts).
6
+ */
7
+ import type { ListProductsQuery } from "@tot/public-runtime";
8
+ import { toggleFacet, buildQueryString } from "@/lib/url";
9
+
10
+ interface Props {
11
+ query: ListProductsQuery;
12
+ basePath: string;
13
+ }
14
+ const { query, basePath } = Astro.props;
15
+
16
+ type Chip = { label: string; href: string };
17
+ const chips: Chip[] = [];
18
+
19
+ // Option + tag facet values (toggling an active value removes it).
20
+ for (const [name, values] of Object.entries(query.filters ?? {})) {
21
+ for (const v of values) {
22
+ chips.push({
23
+ label: name === "tags" ? `#${v}` : `${name}: ${v}`,
24
+ href: basePath + toggleFacet(query, name, v),
25
+ });
26
+ }
27
+ }
28
+
29
+ // Price range.
30
+ if (query.priceMin != null || query.priceMax != null) {
31
+ const lo = query.priceMin != null ? `$${query.priceMin}` : "$0";
32
+ const hi = query.priceMax != null ? `$${query.priceMax}` : "any";
33
+ const { priceMin, priceMax, ...rest } = query;
34
+ chips.push({ label: `${lo}–${hi}`, href: basePath + buildQueryString(rest) });
35
+ }
36
+
37
+ // Availability (the meaningful filter is "in-stock"; "all" is the default).
38
+ if (query.availability === "in-stock") {
39
+ const { availability, ...rest } = query;
40
+ chips.push({ label: "In stock", href: basePath + buildQueryString(rest) });
41
+ }
42
+
43
+ const clearHref =
44
+ basePath +
45
+ buildQueryString({ sort: query.sort, pageSize: query.pageSize });
46
+ ---
47
+
48
+ {
49
+ chips.length > 0 && (
50
+ <div class="mb-5 flex flex-wrap items-center gap-2" data-active-filters>
51
+ <span class="sr-only">Active filters:</span>
52
+ {chips.map((chip) => (
53
+ <a
54
+ href={chip.href}
55
+ rel="nofollow"
56
+ class="group inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-3 pr-2 text-xs transition-colors hover:border-[var(--color-accent-ink)] hover:text-[var(--color-accent-ink)]"
57
+ >
58
+ <span>{chip.label}</span>
59
+ <span
60
+ aria-hidden="true"
61
+ class="grid h-4 w-4 place-items-center rounded-full text-[var(--color-muted)] transition-colors group-hover:bg-[var(--color-accent-ink)] group-hover:text-[var(--color-primary-contrast)]"
62
+ >
63
+ ×
64
+ </span>
65
+ <span class="sr-only">Remove filter</span>
66
+ </a>
67
+ ))}
68
+ {chips.length > 1 && (
69
+ <a
70
+ href={clearHref}
71
+ rel="nofollow"
72
+ class="ml-1 text-xs text-[var(--color-muted)] underline underline-offset-2 hover:text-[var(--color-accent-ink)]"
73
+ >
74
+ Clear all
75
+ </a>
76
+ )}
77
+ </div>
78
+ )
79
+ }
@@ -0,0 +1,199 @@
1
+ ---
2
+ /**
3
+ * Faceted filtering UI (brief §5). URL-ADDRESSABLE and back-button safe: every
4
+ * facet is a real link that toggles a querystring param (no JS required for
5
+ * filtering). The optional mobile drawer toggle is the only island.
6
+ *
7
+ * Encoding handled by src/lib/url.ts (opt.<Name>=<value>, tag=, price, avail).
8
+ */
9
+ import type { Facet, ListProductsQuery } from "@tot/public-runtime";
10
+ import { toggleFacet, buildQueryString, isPriceBucketActive, togglePriceBucket } from "@/lib/url";
11
+ import { PRICE_BUCKETS, bucketByKey } from "@/lib/priceBuckets";
12
+
13
+ interface Props {
14
+ facets: Facet[];
15
+ query: ListProductsQuery;
16
+ basePath: string; // e.g. /collections/bestsellers
17
+ }
18
+ const { facets, query, basePath } = Astro.props;
19
+
20
+ const selected = (name: string, value: string) =>
21
+ (query.filters?.[name] ?? []).includes(value);
22
+
23
+ // "price" is rendered separately as a bucketed quick-select (below), not a
24
+ // generic query.filters facet — pull it out before ordering the rest.
25
+ const priceFacet = facets.find((f) => f.name === "price");
26
+
27
+ // Order facets: option facets first, then tags, availability last.
28
+ const ordered = [...facets]
29
+ .filter((f) => f.name !== "price")
30
+ .sort((a, b) => {
31
+ const rank = (n: string) => (n === "availability" ? 2 : n === "tags" ? 1 : 0);
32
+ return rank(a.name) - rank(b.name);
33
+ });
34
+
35
+ const hasActiveFilters =
36
+ !!query.filters && Object.keys(query.filters).length > 0;
37
+
38
+ const clearHref =
39
+ basePath +
40
+ buildQueryString({ sort: query.sort, q: query.q, pageSize: query.pageSize });
41
+ ---
42
+
43
+ <aside aria-label="Filters" class="text-sm">
44
+ <div class="mb-4 flex items-center justify-between">
45
+ <h2 class="eyebrow !text-[var(--color-text)]">Filter</h2>
46
+ {hasActiveFilters && (
47
+ <a href={clearHref} class="text-xs text-[var(--color-muted)] underline underline-offset-2 hover:text-[var(--color-accent-ink)]">
48
+ Clear all
49
+ </a>
50
+ )}
51
+ </div>
52
+
53
+ <div class="space-y-7">
54
+ {
55
+ ordered.map((facet) => (
56
+ <fieldset>
57
+ <legend class="mb-2.5 font-display text-[0.95rem] font-medium">
58
+ {facet.name === "tags"
59
+ ? "Tags"
60
+ : facet.name === "availability"
61
+ ? "Availability"
62
+ : facet.name}
63
+ </legend>
64
+ <ul class="space-y-1.5">
65
+ {facet.values.map((fv) => {
66
+ const isSel = selected(facet.name, fv.value);
67
+ const href =
68
+ basePath + toggleFacet(query, facet.name, fv.value);
69
+ return (
70
+ <li>
71
+ <a
72
+ href={href}
73
+ rel="nofollow"
74
+ aria-pressed={isSel}
75
+ class:list={[
76
+ "flex items-center justify-between gap-3 rounded-[var(--radius-sm)] px-2 py-1 transition-colors",
77
+ isSel
78
+ ? "bg-[var(--color-primary)] text-[var(--color-primary-contrast)]"
79
+ : "hover:bg-[color-mix(in_srgb,var(--color-primary)_8%,transparent)]",
80
+ ]}
81
+ >
82
+ <span class="flex items-center gap-2">
83
+ <span
84
+ aria-hidden="true"
85
+ class:list={[
86
+ "grid h-3.5 w-3.5 place-items-center rounded-[2px] border",
87
+ isSel
88
+ ? "border-[var(--color-primary-contrast)] bg-[var(--color-primary-contrast)] text-[var(--color-primary)]"
89
+ : "border-[var(--color-border)]",
90
+ ]}
91
+ >
92
+ {isSel ? "✓" : ""}
93
+ </span>
94
+ {fv.value === "all" ? "All items" : fv.value}
95
+ </span>
96
+ <span class:list={["text-xs", isSel ? "opacity-80" : "text-[var(--color-muted)]"]}>
97
+ {fv.count}
98
+ </span>
99
+ </a>
100
+ </li>
101
+ );
102
+ })}
103
+ </ul>
104
+ </fieldset>
105
+ ))
106
+ }
107
+
108
+ {/* Price range — bucketed quick-select (live counts) + a free-text form
109
+ for an arbitrary range. Both write the same priceMin/priceMax params,
110
+ so a bucket and the form stay in sync (selecting a bucket fills the
111
+ form; a custom range that matches a bucket shows it as active). */}
112
+ <fieldset class="border-t border-[var(--color-border)] pt-5">
113
+ <legend class="mb-2.5 font-display text-[0.95rem] font-medium">Price</legend>
114
+ {priceFacet && (
115
+ <ul class="mb-4 space-y-1.5">
116
+ {priceFacet.values.map((fv) => {
117
+ const bucket = bucketByKey(fv.value);
118
+ if (!bucket) return null;
119
+ const isSel = isPriceBucketActive(query, bucket);
120
+ const href = basePath + togglePriceBucket(query, bucket);
121
+ return (
122
+ <li>
123
+ <a
124
+ href={href}
125
+ rel="nofollow"
126
+ aria-pressed={isSel}
127
+ class:list={[
128
+ "flex items-center justify-between gap-3 rounded-[var(--radius-sm)] px-2 py-1 transition-colors",
129
+ isSel
130
+ ? "bg-[var(--color-primary)] text-[var(--color-primary-contrast)]"
131
+ : "hover:bg-[color-mix(in_srgb,var(--color-primary)_8%,transparent)]",
132
+ ]}
133
+ >
134
+ <span class="flex items-center gap-2">
135
+ <span
136
+ aria-hidden="true"
137
+ class:list={[
138
+ "grid h-3.5 w-3.5 place-items-center rounded-[2px] border",
139
+ isSel
140
+ ? "border-[var(--color-primary-contrast)] bg-[var(--color-primary-contrast)] text-[var(--color-primary)]"
141
+ : "border-[var(--color-border)]",
142
+ ]}
143
+ >
144
+ {isSel ? "✓" : ""}
145
+ </span>
146
+ {bucket.label}
147
+ </span>
148
+ <span class:list={["text-xs", isSel ? "opacity-80" : "text-[var(--color-muted)]"]}>
149
+ {fv.count}
150
+ </span>
151
+ </a>
152
+ </li>
153
+ );
154
+ })}
155
+ </ul>
156
+ )}
157
+ <form method="get" action={basePath}>
158
+ {query.sort && <input type="hidden" name="sort" value={query.sort} />}
159
+ {query.availability && query.availability !== "all" && (
160
+ <input type="hidden" name="availability" value={query.availability} />
161
+ )}
162
+ {Object.entries(query.filters ?? {}).flatMap(([name, values]) =>
163
+ values.map((v) => (
164
+ <input type="hidden" name={name === "tags" ? "tag" : `opt.${name}`} value={v} />
165
+ )),
166
+ )}
167
+ <div class="flex items-center gap-2">
168
+ <label class="sr-only" for="priceMin">Minimum price</label>
169
+ <input
170
+ id="priceMin"
171
+ type="number"
172
+ name="priceMin"
173
+ min="0"
174
+ placeholder="Min"
175
+ value={query.priceMin ?? ""}
176
+ class="w-20 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1"
177
+ />
178
+ <span aria-hidden="true" class="text-[var(--color-muted)]">–</span>
179
+ <label class="sr-only" for="priceMax">Maximum price</label>
180
+ <input
181
+ id="priceMax"
182
+ type="number"
183
+ name="priceMax"
184
+ min="0"
185
+ placeholder="Max"
186
+ value={query.priceMax ?? ""}
187
+ class="w-20 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-bg)] px-2 py-1"
188
+ />
189
+ <button
190
+ type="submit"
191
+ class="rounded-[var(--radius-sm)] bg-[var(--color-primary)] px-3 py-1 text-xs font-medium text-[var(--color-primary-contrast)]"
192
+ >
193
+ Go
194
+ </button>
195
+ </div>
196
+ </form>
197
+ </fieldset>
198
+ </div>
199
+ </aside>
@@ -0,0 +1,99 @@
1
+ ---
2
+ /**
3
+ * Numbered pagination (SEO-friendly, brief §5 default) + a "Load more" link.
4
+ * Both are plain anchors carrying the full query, so they're crawlable and
5
+ * back-button safe. "Load more" simply links to the next page; with JS it could
6
+ * be progressively enhanced to append, but the no-JS path stays correct.
7
+ */
8
+ import type { ListProductsQuery } from "@tot/public-runtime";
9
+ import { buildQueryString } from "@/lib/url";
10
+
11
+ interface Props {
12
+ query: ListProductsQuery;
13
+ basePath: string;
14
+ page: number;
15
+ pageSize: number;
16
+ total: number;
17
+ }
18
+ const { query, basePath, page, pageSize, total } = Astro.props;
19
+ const pageCount = Math.max(1, Math.ceil(total / pageSize));
20
+ if (pageCount <= 1) {
21
+ // nothing to render
22
+ }
23
+
24
+ const href = (p: number) => basePath + buildQueryString(query, { page: p });
25
+
26
+ // Compact page-number window.
27
+ const windowSize = 5;
28
+ let start = Math.max(1, page - Math.floor(windowSize / 2));
29
+ let end = Math.min(pageCount, start + windowSize - 1);
30
+ start = Math.max(1, end - windowSize + 1);
31
+ const pages = Array.from({ length: end - start + 1 }, (_, i) => start + i);
32
+ const hasNext = page < pageCount;
33
+ ---
34
+
35
+ {pageCount > 1 && (
36
+ <nav aria-label="Pagination" class="mt-12 flex flex-col items-center gap-6">
37
+ {hasNext && (
38
+ <a
39
+ href={href(page + 1)}
40
+ rel="next"
41
+ class="rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-8 py-3 font-display text-sm font-medium transition-colors hover:border-[var(--color-accent)] hover:text-[var(--color-accent-ink)]"
42
+ >
43
+ Load more
44
+ </a>
45
+ )}
46
+
47
+ <ul class="flex items-center gap-1">
48
+ <li>
49
+ <a
50
+ href={href(Math.max(1, page - 1))}
51
+ rel="prev"
52
+ aria-label="Previous page"
53
+ aria-disabled={page === 1}
54
+ class:list={[
55
+ "grid h-9 w-9 place-items-center rounded-[var(--radius-sm)]",
56
+ page === 1 ? "pointer-events-none text-[var(--color-muted)] opacity-40" : "hover:bg-[var(--color-surface)]",
57
+ ]}
58
+ >‹</a>
59
+ </li>
60
+ {start > 1 && (
61
+ <>
62
+ <li><a href={href(1)} class="grid h-9 w-9 place-items-center rounded-[var(--radius-sm)] hover:bg-[var(--color-surface)]">1</a></li>
63
+ {start > 2 && <li aria-hidden="true" class="px-1 text-[var(--color-muted)]">…</li>}
64
+ </>
65
+ )}
66
+ {pages.map((p) => (
67
+ <li>
68
+ <a
69
+ href={href(p)}
70
+ aria-current={p === page ? "page" : undefined}
71
+ class:list={[
72
+ "grid h-9 w-9 place-items-center rounded-[var(--radius-sm)] font-display text-sm",
73
+ p === page
74
+ ? "bg-[var(--color-primary)] text-[var(--color-primary-contrast)]"
75
+ : "hover:bg-[var(--color-surface)]",
76
+ ]}
77
+ >{p}</a>
78
+ </li>
79
+ ))}
80
+ {end < pageCount && (
81
+ <>
82
+ {end < pageCount - 1 && <li aria-hidden="true" class="px-1 text-[var(--color-muted)]">…</li>}
83
+ <li><a href={href(pageCount)} class="grid h-9 w-9 place-items-center rounded-[var(--radius-sm)] hover:bg-[var(--color-surface)]">{pageCount}</a></li>
84
+ </>
85
+ )}
86
+ <li>
87
+ <a
88
+ href={href(Math.min(pageCount, page + 1))}
89
+ aria-label="Next page"
90
+ aria-disabled={!hasNext}
91
+ class:list={[
92
+ "grid h-9 w-9 place-items-center rounded-[var(--radius-sm)]",
93
+ !hasNext ? "pointer-events-none text-[var(--color-muted)] opacity-40" : "hover:bg-[var(--color-surface)]",
94
+ ]}
95
+ >›</a>
96
+ </li>
97
+ </ul>
98
+ </nav>
99
+ )}