@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.
- package/LICENSE +58 -0
- package/README.md +40 -0
- package/apps/storefront/.dev.vars.example +41 -0
- package/apps/storefront/.env.example +24 -0
- package/apps/storefront/astro.config.mjs +245 -0
- package/apps/storefront/env.d.ts +155 -0
- package/apps/storefront/lib/ensure-preview-reconcile-secret.sh +31 -0
- package/apps/storefront/lib/resolve-cf-token.sh +48 -0
- package/apps/storefront/lib/resolve-tot-credentials.sh +69 -0
- package/apps/storefront/migrations/0000_baseline.sql +54 -0
- package/apps/storefront/migrations/0001_products_fts.sql +9 -0
- package/apps/storefront/migrations/README.md +54 -0
- package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/_journal.json +20 -0
- package/apps/storefront/package.json +41 -0
- package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
- package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
- package/apps/storefront/public/brand/northwind-og.svg +13 -0
- package/apps/storefront/public/favicon.svg +5 -0
- package/apps/storefront/public/js/dashboard-team.js +147 -0
- package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
- package/apps/storefront/src/components/Badge.astro +34 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
- package/apps/storefront/src/components/CollectionCard.astro +56 -0
- package/apps/storefront/src/components/EmptyState.astro +35 -0
- package/apps/storefront/src/components/Img.astro +68 -0
- package/apps/storefront/src/components/ProductCard.astro +228 -0
- package/apps/storefront/src/components/Section.astro +39 -0
- package/apps/storefront/src/components/Seo.astro +70 -0
- package/apps/storefront/src/components/WidgetFrame.astro +34 -0
- package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
- package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
- package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +116 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +181 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +181 -0
- package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
- package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
- package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
- package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
- package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
- package/apps/storefront/src/components/home/Hero.astro +164 -0
- package/apps/storefront/src/components/islands/AgeGate.tsx +105 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
- package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
- package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
- package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
- package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
- package/apps/storefront/src/components/islands/VariantSelector.tsx +378 -0
- package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
- package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
- package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
- package/apps/storefront/src/components/marketing/Faq.astro +37 -0
- package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
- package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
- package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
- package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
- package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
- package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
- package/apps/storefront/src/components/marketing/Steps.astro +36 -0
- package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
- package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
- package/apps/storefront/src/components/nav/Footer.astro +148 -0
- package/apps/storefront/src/components/nav/Header.astro +190 -0
- package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
- package/apps/storefront/src/components/plp/FacetSidebar.astro +146 -0
- package/apps/storefront/src/components/plp/Pagination.astro +99 -0
- package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
- package/apps/storefront/src/config/devTenants.ts +16 -0
- package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
- package/apps/storefront/src/config/resolver.ts +46 -0
- package/apps/storefront/src/config/tenants.ts +193 -0
- package/apps/storefront/src/layouts/Layout.astro +303 -0
- package/apps/storefront/src/lib/analytics/index.ts +47 -0
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
- package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
- package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +107 -0
- package/apps/storefront/src/lib/auth/route.ts +146 -0
- package/apps/storefront/src/lib/auth/session.ts +203 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
- package/apps/storefront/src/lib/basePath.ts +41 -0
- package/apps/storefront/src/lib/cfImage.ts +55 -0
- package/apps/storefront/src/lib/chrome/model.ts +65 -0
- package/apps/storefront/src/lib/colors.ts +97 -0
- package/apps/storefront/src/lib/content-edit/client.ts +230 -0
- package/apps/storefront/src/lib/content-edit/index.ts +14 -0
- package/apps/storefront/src/lib/content-edit/route.ts +25 -0
- package/apps/storefront/src/lib/content-edit/types.ts +19 -0
- package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
- package/apps/storefront/src/lib/d1/catalog.ts +289 -0
- package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
- package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
- package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
- package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
- package/apps/storefront/src/lib/demoGate.ts +113 -0
- package/apps/storefront/src/lib/dev/ai-widget-client.js +127 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +132 -0
- package/apps/storefront/src/lib/diag/redact.ts +20 -0
- package/apps/storefront/src/lib/edgeCache.ts +128 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
- package/apps/storefront/src/lib/env.ts +60 -0
- package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
- package/apps/storefront/src/lib/format.ts +29 -0
- package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
- package/apps/storefront/src/lib/imageAttrs.ts +55 -0
- package/apps/storefront/src/lib/jsonld.ts +136 -0
- package/apps/storefront/src/lib/maintenance.ts +71 -0
- package/apps/storefront/src/lib/memoryKv.ts +32 -0
- package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
- package/apps/storefront/src/lib/placeholder.ts +99 -0
- package/apps/storefront/src/lib/previewReload.ts +125 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +133 -0
- package/apps/storefront/src/lib/search/index.ts +74 -0
- package/apps/storefront/src/lib/sections.ts +129 -0
- package/apps/storefront/src/lib/securityHeaders.ts +31 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +524 -0
- package/apps/storefront/src/lib/tenantRouting.ts +91 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
- package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
- package/apps/storefront/src/lib/tot/query.ts +187 -0
- package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
- package/apps/storefront/src/lib/url.ts +119 -0
- package/apps/storefront/src/lib/wishlist.ts +63 -0
- package/apps/storefront/src/middleware/index.ts +413 -0
- package/apps/storefront/src/pages/404.astro +61 -0
- package/apps/storefront/src/pages/[...slug].astro +90 -0
- package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
- package/apps/storefront/src/pages/api/auth/send.ts +85 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
- package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +64 -0
- package/apps/storefront/src/pages/api/csp-report.ts +25 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
- package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +44 -0
- package/apps/storefront/src/pages/api/newsletter.ts +104 -0
- package/apps/storefront/src/pages/api/request-access.ts +172 -0
- package/apps/storefront/src/pages/auth/login.astro +149 -0
- package/apps/storefront/src/pages/auth/magic.astro +105 -0
- package/apps/storefront/src/pages/capabilities.astro +292 -0
- package/apps/storefront/src/pages/collections/[handle].astro +154 -0
- package/apps/storefront/src/pages/collections/index.astro +59 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
- package/apps/storefront/src/pages/dashboard/index.astro +160 -0
- package/apps/storefront/src/pages/dev.astro +653 -0
- package/apps/storefront/src/pages/index.astro +290 -0
- package/apps/storefront/src/pages/llms.txt.ts +92 -0
- package/apps/storefront/src/pages/products/[handle].astro +443 -0
- package/apps/storefront/src/pages/robots.txt.ts +24 -0
- package/apps/storefront/src/pages/saved.astro +35 -0
- package/apps/storefront/src/pages/search.astro +150 -0
- package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +646 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +85 -0
- package/apps/storefront/src/pages/style-guide/index.astro +94 -0
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
- package/apps/storefront/src/styles/global.css +277 -0
- package/apps/storefront/src/themes/reference.ts +61 -0
- package/apps/storefront/src/themes/schema.ts +111 -0
- package/apps/storefront/src/themes/tenants/index.ts +51 -0
- package/apps/storefront/tsconfig.json +16 -0
- package/apps/storefront/vitest.config.ts +22 -0
- package/apps/storefront/wrangler.toml +175 -0
- package/package.json +23 -0
- package/packages/public-runtime/LICENSE +58 -0
- package/packages/public-runtime/package.json +22 -0
- package/packages/public-runtime/src/binary-path.ts +68 -0
- package/packages/public-runtime/src/capabilities.ts +89 -0
- package/packages/public-runtime/src/catalog-api.ts +81 -0
- package/packages/public-runtime/src/catalog-d1.ts +192 -0
- package/packages/public-runtime/src/csp.ts +109 -0
- package/packages/public-runtime/src/customization-preview.ts +197 -0
- package/packages/public-runtime/src/customization-reconcile.ts +63 -0
- package/packages/public-runtime/src/customization-runtime.ts +157 -0
- package/packages/public-runtime/src/customization-scripts.ts +64 -0
- package/packages/public-runtime/src/customization-versioning.ts +95 -0
- package/packages/public-runtime/src/foxy.ts +184 -0
- package/packages/public-runtime/src/index.ts +30 -0
- package/packages/public-runtime/src/product.ts +159 -0
- package/packages/public-runtime/src/search.ts +45 -0
- package/packages/public-runtime/src/tenant-assets.ts +444 -0
- package/packages/public-runtime/src/tenant.ts +243 -0
- package/packages/public-runtime/tsconfig.json +8 -0
- package/packages/public-runtime/vitest.config.ts +8 -0
- package/pnpm-workspace.yaml +7 -0
- package/scripts/build/copy-tenant-assets.mjs +69 -0
- package/scripts/tot-dev.mjs +439 -0
- package/tenants/bigdvapor/content/blog.json +52 -0
- package/tenants/bigdvapor/content/chrome.html +149 -0
- package/tenants/bigdvapor/content/chrome.json +47 -0
- package/tenants/bigdvapor/content/home.html +463 -0
- package/tenants/bigdvapor/content/home.json +73 -0
- package/tenants/bigdvapor/content/pages/about.json +5 -0
- package/tenants/bigdvapor/content/pages/privacy.json +6 -0
- package/tenants/bigdvapor/content/pages/shipping-returns.json +6 -0
- package/tenants/bigdvapor/content/pages-html/blogs/news.html +86 -0
- package/tenants/bigdvapor/content/pages-html/pages/about-us.html +106 -0
- package/tenants/bigdvapor/content/pages-html/pages/contact-us.html +61 -0
- package/tenants/bigdvapor/content/pages-html/pages/privacy-policy.html +72 -0
- package/tenants/bigdvapor/content/pages-html/pages/shipping-returns.html +172 -0
- package/tenants/bigdvapor/content/themes/bigd-navy.json +109 -0
- package/tenants/bigdvapor/public/img/brands/adjust-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/again-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/al-fakher-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/arro-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/cookies-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/core-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/cyclone-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/dinner-lady-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/extre-bar-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/fifty-bar-vape-disposable.jpg +0 -0
- package/tenants/bigdvapor/public/img/brands/firerose-vape-disposable.jpg +0 -0
- package/tenants/bigdvapor/public/img/brands/flavor-beast-vape-disposable.jpg +0 -0
- package/tenants/bigdvapor/public/img/brands/foger-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/kk-energy-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/kumi-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/lost-vape-dispoable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/melo-labs-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/brands/mixo-vape-disposable.webp +0 -0
- package/tenants/bigdvapor/public/img/brands/one-tank-vape-disposable.jpg +0 -0
- package/tenants/bigdvapor/public/img/brands/oxbar-vape-disposable.webp +0 -0
- package/tenants/bigdvapor/public/img/brands/pillow-talk-vape-disposable.jpg +0 -0
- package/tenants/bigdvapor/public/img/brands/pod-king-vape-disposable.jpg +0 -0
- package/tenants/bigdvapor/public/img/brands/raz-vape-disposable.png +0 -0
- package/tenants/bigdvapor/public/img/hero-texas.jpg +0 -0
- package/tenants/bigdvapor/public/img/og.jpg +0 -0
- package/tenants/bigdvapor/public/img/pages/AdobeStock_202106312_web.jpg +0 -0
- package/tenants/bigdvapor/public/img/pages/CREW_web.jpg +0 -0
- package/tenants/bigdvapor/public/img/pages/Hero-Image.jpg +0 -0
- package/tenants/bigdvapor/public/img/tiles/adjust.png +0 -0
- package/tenants/bigdvapor/public/img/tiles/again.png +0 -0
- package/tenants/bigdvapor/public/img/tiles/al-fakher.png +0 -0
- package/tenants/bigdvapor/public/img/tiles/arro.png +0 -0
- package/tenants/bigdvapor/public/img/tiles/cookies.png +0 -0
- package/tenants/bigdvapor/public/img/tiles/core.png +0 -0
- package/tenants/bigdvapor/public/img/tiles/cyclone.png +0 -0
- package/tenants/bigdvapor/public/img/tiles/disposables.svg +23 -0
- package/tenants/bigdvapor/public/logo-wordmark.png +0 -0
- package/tenants/bigdvapor/public/pages/home.css +118 -0
- package/tenants/bigdvapor/public/pages/mkt.css +185 -0
- package/tenants/bigdvapor/public/pages/page.css +148 -0
- package/tenants/bigdvapor/public/themes/bigd-navy.css +73 -0
- package/tenants/bigdvapor/theme.json +12 -0
- package/tenants/giantvapes/content/chrome.html +152 -0
- package/tenants/giantvapes/content/chrome.json +94 -0
- package/tenants/giantvapes/content/home.html +194 -0
- package/tenants/giantvapes/content/home.json +50 -0
- package/tenants/giantvapes/content/pages/about.json +10 -0
- package/tenants/giantvapes/content/pages/privacy.json +6 -0
- package/tenants/giantvapes/content/pages/shipping-returns.json +6 -0
- package/tenants/giantvapes/content/pages-html/blogs/news.html +68 -0
- package/tenants/giantvapes/content/pages-html/pages/about-us.html +95 -0
- package/tenants/giantvapes/content/pages-html/pages/contact-us.html +68 -0
- package/tenants/giantvapes/content/pages-html/pages/privacy-policy.html +65 -0
- package/tenants/giantvapes/content/pages-html/pages/shipping-returns.html +77 -0
- package/tenants/giantvapes/content/themes/giant-navy.json +73 -0
- package/tenants/giantvapes/public/img/hero-suicide-bunny.jpg +0 -0
- package/tenants/giantvapes/public/img/hero.webp +0 -0
- package/tenants/giantvapes/public/img/og.jpg +0 -0
- package/tenants/giantvapes/public/logo-wordmark.png +0 -0
- package/tenants/giantvapes/public/pages/home.css +120 -0
- package/tenants/giantvapes/public/pages/mkt.css +185 -0
- package/tenants/giantvapes/public/pages/page.css +155 -0
- package/tenants/giantvapes/public/themes/giant-navy.css +76 -0
- package/tenants/giantvapes/theme.json +39 -0
- package/tenants/home/content/chrome.json +11 -0
- package/tenants/home/content/home.html +304 -0
- package/tenants/home/content/home.json +13 -0
- package/tenants/home/public/js/storefront.js +210 -0
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +212 -0
- package/tenants/home/theme.json +6 -0
- package/tenants/tokenoftrust/.tot/config.json +9 -0
- package/tenants/tokenoftrust/content/chrome.json +55 -0
- package/tenants/tokenoftrust/content/home.html +723 -0
- package/tenants/tokenoftrust/content/home.json +233 -0
- package/tenants/tokenoftrust/content/pages-html/careers.html +231 -0
- package/tenants/tokenoftrust/content/pages-html/company.html +275 -0
- package/tenants/tokenoftrust/content/pages-html/contact/contact-sales.html +188 -0
- package/tenants/tokenoftrust/content/pages-html/contact/startup-apply.html +206 -0
- package/tenants/tokenoftrust/content/pages-html/contact.html +178 -0
- package/tenants/tokenoftrust/content/pages-html/industries/adult-content-age-verification.html +286 -0
- package/tenants/tokenoftrust/content/pages-html/industries/cannabis.html +277 -0
- package/tenants/tokenoftrust/content/pages-html/pact-act-pricing.html +289 -0
- package/tenants/tokenoftrust/content/pages-html/pricing.html +483 -0
- package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-estimation.html +278 -0
- package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-gate.html +279 -0
- package/tenants/tokenoftrust/content/pages-html/product/age-assurance/age-verification.html +265 -0
- package/tenants/tokenoftrust/content/pages-html/product/age-assurance.html +173 -0
- package/tenants/tokenoftrust/content/pages-html/product/compliance/aml.html +214 -0
- package/tenants/tokenoftrust/content/pages-html/product/compliance/pact-act.html +226 -0
- package/tenants/tokenoftrust/content/pages-html/product/fraud-prevention.html +254 -0
- package/tenants/tokenoftrust/content/pages-html/product/social-profile-verification.html +212 -0
- package/tenants/tokenoftrust/content/pages-html/product/tax/excise-tax.html +213 -0
- package/tenants/tokenoftrust/content/pages-html/product/verification/aml.html +259 -0
- package/tenants/tokenoftrust/content/pages-html/product/verification/biometric-selfie.html +305 -0
- package/tenants/tokenoftrust/content/pages-html/product/verification/coverage.html +254 -0
- package/tenants/tokenoftrust/content/pages-html/product/verification/document-verification.html +306 -0
- package/tenants/tokenoftrust/content/pages-html/product/verification/electronic-identity-verification.html +304 -0
- package/tenants/tokenoftrust/content/pages-html/product/verification/government-id-verification.html +308 -0
- package/tenants/tokenoftrust/content/pages-html/product/verification.html +183 -0
- package/tenants/tokenoftrust/content/pages-html/product.html +228 -0
- package/tenants/tokenoftrust/content/pages-html/resources/integrations/bigcommerce-integration.html +220 -0
- package/tenants/tokenoftrust/content/pages-html/resources/integrations/shopify-integration.html +221 -0
- package/tenants/tokenoftrust/content/pages-html/resources/integrations/wordpress.html +220 -0
- package/tenants/tokenoftrust/content/pages-html/resources/integrations.html +172 -0
- package/tenants/tokenoftrust/content/pages-html/resources/refer-a-friend-program.html +317 -0
- package/tenants/tokenoftrust/content/pages-html/resources.html +177 -0
- package/tenants/tokenoftrust/content/pages-html/reviews.html +273 -0
- package/tenants/tokenoftrust/content/pages-html/solutions.html +292 -0
- package/tenants/tokenoftrust/content/pages-html/vlp-alcohol.html +289 -0
- package/tenants/tokenoftrust/content/pages-html/vlp-cigars-and-tobacco.html +286 -0
- package/tenants/tokenoftrust/content/pages-html/vlp-firearm-accessories.html +267 -0
- package/tenants/tokenoftrust/content/pages-html/vlp-peptides.html +273 -0
- package/tenants/tokenoftrust/content/themes/tot-navy.json +88 -0
- package/tenants/tokenoftrust/public/favicon.png +0 -0
- package/tenants/tokenoftrust/public/logo-icon.png +0 -0
- package/tenants/tokenoftrust/public/logo-wordmark.png +0 -0
- package/tenants/tokenoftrust/public/pages/company.css +108 -0
- package/tenants/tokenoftrust/public/pages/home.css +457 -0
- package/tenants/tokenoftrust/public/pages/mkt.css +482 -0
- package/tenants/tokenoftrust/public/pages/pricing.css +214 -0
- package/tenants/tokenoftrust/public/pages/solutions.css +66 -0
- package/tenants/tokenoftrust/public/themes/tot-navy-1.0.0.css +136 -0
- package/tenants/tokenoftrust/public/themes/tot-navy.css +358 -0
- package/tenants/tokenoftrust/theme.json +31 -0
- package/tsconfig.base.json +20 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToTClient — the storefront's SINGLE source of product truth.
|
|
3
|
+
*
|
|
4
|
+
* Two backends behind one interface:
|
|
5
|
+
* - D1ToTClient — the Cloudflare D1 edge read replica (FTS5 search + SQL
|
|
6
|
+
* facets), used when a CATALOG_DB binding is wired.
|
|
7
|
+
* - FixtureToTClient — reads packages/migration/out/catalog/*.json so the
|
|
8
|
+
* whole site renders offline with no creds.
|
|
9
|
+
*
|
|
10
|
+
* Public listings (PLP/search/featured) MUST exclude non-active products. The
|
|
11
|
+
* `trail-cap` fixture is a `draft` and is the canary: it must NOT appear on any
|
|
12
|
+
* public surface, but getProductByHandle still resolves it (so a direct/preview
|
|
13
|
+
* link works) — see DECISIONS.md.
|
|
14
|
+
*/
|
|
15
|
+
import type {
|
|
16
|
+
CatalogCollection,
|
|
17
|
+
CatalogProduct,
|
|
18
|
+
Facet,
|
|
19
|
+
ListProductsQuery,
|
|
20
|
+
ListProductsResponse,
|
|
21
|
+
Review,
|
|
22
|
+
TenantConfig,
|
|
23
|
+
} from "@tot/public-runtime";
|
|
24
|
+
import { buildFacets, applyQuery, paginate } from "./query.js";
|
|
25
|
+
import { D1ToTClient } from "./d1Client.js";
|
|
26
|
+
import { fromD1, type D1Like } from "../d1/catalog.js";
|
|
27
|
+
import { buildSearchIndex, type SearchIndex } from "../search/index.js";
|
|
28
|
+
import type { ToTClient } from "./totClientInterface.js";
|
|
29
|
+
|
|
30
|
+
// The contract lives in its own module so d1Client can implement it without a
|
|
31
|
+
// circular edge back to this file (which imports D1ToTClient for the factory).
|
|
32
|
+
// Re-exported so existing `import { ToTClient } from "./ToTClient"` sites work.
|
|
33
|
+
export type { ToTClient } from "./totClientInterface.js";
|
|
34
|
+
|
|
35
|
+
const isActive = (p: CatalogProduct) => p.status === "active";
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// FixtureToTClient — local JSON, used when no live catalog creds are present.
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
export class FixtureToTClient implements ToTClient {
|
|
42
|
+
constructor(
|
|
43
|
+
private readonly products: CatalogProduct[],
|
|
44
|
+
private readonly collections: CatalogCollection[],
|
|
45
|
+
private readonly scope: string,
|
|
46
|
+
private readonly reviews: Record<string, Review[]> = {},
|
|
47
|
+
) {}
|
|
48
|
+
|
|
49
|
+
async getReviews(handle: string): Promise<Review[]> {
|
|
50
|
+
return this.reviews[handle] ?? [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private scoped(): CatalogProduct[] {
|
|
54
|
+
// Fixtures are single-tenant ("pilot"); scope-filter anyway so the seam is
|
|
55
|
+
// honest for many-tenant.
|
|
56
|
+
return this.products.filter((p) => p.tenant_id === this.scope || true);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async listProducts(query: ListProductsQuery = {}): Promise<ListProductsResponse> {
|
|
60
|
+
const active = this.scoped().filter(isActive);
|
|
61
|
+
// Resolve collection membership for filtering.
|
|
62
|
+
let pool = active;
|
|
63
|
+
if (query.collection) {
|
|
64
|
+
const col = this.collections.find((c) => c.handle === query.collection);
|
|
65
|
+
const handles = new Set(col?.product_handles ?? []);
|
|
66
|
+
pool = active.filter((p) => handles.has(p.handle));
|
|
67
|
+
}
|
|
68
|
+
const facets: Facet[] = buildFacets(pool);
|
|
69
|
+
const filtered = applyQuery(pool, query);
|
|
70
|
+
const { items, page, pageSize, total } = paginate(filtered, query);
|
|
71
|
+
return { products: items, page, pageSize, total, facets };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async getProductByHandle(handle: string): Promise<CatalogProduct | null> {
|
|
75
|
+
// NOTE: resolves drafts too (preview/direct links). Callers gate visibility.
|
|
76
|
+
return this.scoped().find((p) => p.handle === handle) ?? null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async listCollections(): Promise<CatalogCollection[]> {
|
|
80
|
+
return [...this.collections].sort(
|
|
81
|
+
(a, b) => (a.position ?? 99) - (b.position ?? 99),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async getCollectionByHandle(handle: string): Promise<CatalogCollection | null> {
|
|
86
|
+
return this.collections.find((c) => c.handle === handle) ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getAllActiveProducts(): Promise<CatalogProduct[]> {
|
|
90
|
+
return this.scoped().filter(isActive);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private searchIndex?: SearchIndex;
|
|
94
|
+
async search(q: string, limit = 20): Promise<CatalogProduct[]> {
|
|
95
|
+
// Offline search floor: typo-tolerant Fuse over the bundled snapshot
|
|
96
|
+
// (memoized). The D1 path (D1ToTClient) uses FTS5 instead.
|
|
97
|
+
this.searchIndex ??= buildSearchIndex(this.scoped().filter(isActive));
|
|
98
|
+
return this.searchIndex.search(q, limit).map((h) => h.product);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async getFeatured(limit = 8): Promise<CatalogProduct[]> {
|
|
102
|
+
const active = this.scoped().filter(isActive);
|
|
103
|
+
const bestsellers = active.filter((p) => p.tags.includes("bestseller"));
|
|
104
|
+
const rest = active
|
|
105
|
+
.filter((p) => !p.tags.includes("bestseller"))
|
|
106
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
107
|
+
return [...bestsellers, ...rest].slice(0, limit);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Factory — D1 edge replica when bound, else the bundled fixture snapshot.
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
type FixtureBundle = {
|
|
116
|
+
products: CatalogProduct[];
|
|
117
|
+
collections: CatalogCollection[];
|
|
118
|
+
reviews: Record<string, Review[]>;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Per-tenant fixtures live under out/catalog/<tot_data_scope>/. Vite resolves
|
|
122
|
+
// these globs at build time and bundles every tenant's JSON, so the worker has
|
|
123
|
+
// no runtime filesystem dependency. reviews.json is OPTIONAL (only present after
|
|
124
|
+
// the migration's reviews step) — a tenant without it just gets {}.
|
|
125
|
+
const productGlobs = import.meta.glob<CatalogProduct[]>(
|
|
126
|
+
"../../../../../packages/migration/out/catalog/*/products.json",
|
|
127
|
+
{ import: "default" },
|
|
128
|
+
);
|
|
129
|
+
const collectionGlobs = import.meta.glob<CatalogCollection[]>(
|
|
130
|
+
"../../../../../packages/migration/out/catalog/*/collections.json",
|
|
131
|
+
{ import: "default" },
|
|
132
|
+
);
|
|
133
|
+
const reviewGlobs = import.meta.glob<Record<string, Review[]>>(
|
|
134
|
+
"../../../../../packages/migration/out/catalog/*/reviews.json",
|
|
135
|
+
{ import: "default" },
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const fixtureCacheByScope = new Map<string, FixtureBundle>();
|
|
139
|
+
|
|
140
|
+
/** Pick the glob entry for a tenant scope (matches .../catalog/<scope>/file). */
|
|
141
|
+
function pickByScope<T>(
|
|
142
|
+
globs: Record<string, () => Promise<T>>,
|
|
143
|
+
scope: string,
|
|
144
|
+
): (() => Promise<T>) | null {
|
|
145
|
+
const suffix = `/catalog/${scope}/`;
|
|
146
|
+
const key = Object.keys(globs).find((k) => k.includes(suffix));
|
|
147
|
+
return (key ? globs[key] : null) ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function loadFixtures(scope: string): Promise<FixtureBundle> {
|
|
151
|
+
const cached = fixtureCacheByScope.get(scope);
|
|
152
|
+
if (cached) return cached;
|
|
153
|
+
const productsLoader = pickByScope(productGlobs, scope);
|
|
154
|
+
const collectionsLoader = pickByScope(collectionGlobs, scope);
|
|
155
|
+
const reviewsLoader = pickByScope(reviewGlobs, scope);
|
|
156
|
+
const bundle: FixtureBundle = {
|
|
157
|
+
products: productsLoader ? await productsLoader() : [],
|
|
158
|
+
collections: collectionsLoader ? await collectionsLoader() : [],
|
|
159
|
+
reviews: reviewsLoader ? await reviewsLoader() : {},
|
|
160
|
+
};
|
|
161
|
+
fixtureCacheByScope.set(scope, bundle);
|
|
162
|
+
return bundle;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function createToTClient(
|
|
166
|
+
tenant: TenantConfig,
|
|
167
|
+
d1?: D1Like,
|
|
168
|
+
): Promise<ToTClient> {
|
|
169
|
+
// Edge read replica (epics/edge-read-replica.md): when a D1 binding is wired,
|
|
170
|
+
// serve reads from it (FTS5 search + SQL facets). Otherwise the bundled fixture
|
|
171
|
+
// snapshot renders the site offline. The tot20 HTTP catalog API has been retired,
|
|
172
|
+
// so the read path is fully edge-native.
|
|
173
|
+
if (d1) return new D1ToTClient(fromD1(d1));
|
|
174
|
+
|
|
175
|
+
const { products, collections, reviews } = await loadFixtures(tenant.tot_data_scope);
|
|
176
|
+
return new FixtureToTClient(products, collections, tenant.tot_data_scope, reviews);
|
|
177
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D1-backed ToTClient — the edge read-replica path (epics/edge-read-replica.md).
|
|
3
|
+
*
|
|
4
|
+
* Implements the same ToTClient interface as Fixture/Http, but every read is a D1
|
|
5
|
+
* query (FTS5 search, SQL faceted listing, row-blob retrieval) instead of an
|
|
6
|
+
* in-Worker scan or a backend round-trip. Selected by createToTClient when a D1
|
|
7
|
+
* binding is present. Takes a `Queryable` so it's testable against node:sqlite.
|
|
8
|
+
*/
|
|
9
|
+
import type {
|
|
10
|
+
CatalogCollection,
|
|
11
|
+
CatalogProduct,
|
|
12
|
+
ListProductsQuery,
|
|
13
|
+
ListProductsResponse,
|
|
14
|
+
Review,
|
|
15
|
+
} from "@tot/public-runtime";
|
|
16
|
+
import type { ToTClient } from "./totClientInterface.js";
|
|
17
|
+
import {
|
|
18
|
+
type Queryable,
|
|
19
|
+
searchProducts,
|
|
20
|
+
getProductByHandle,
|
|
21
|
+
listProducts,
|
|
22
|
+
listCollections,
|
|
23
|
+
getCollectionByHandle,
|
|
24
|
+
} from "../d1/catalog.js";
|
|
25
|
+
|
|
26
|
+
export class D1ToTClient implements ToTClient {
|
|
27
|
+
constructor(private readonly db: Queryable) {}
|
|
28
|
+
|
|
29
|
+
listProducts(query: ListProductsQuery = {}): Promise<ListProductsResponse> {
|
|
30
|
+
return listProducts(this.db, query);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getProductByHandle(handle: string): Promise<CatalogProduct | null> {
|
|
34
|
+
return getProductByHandle(this.db, handle);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
listCollections(): Promise<CatalogCollection[]> {
|
|
38
|
+
return listCollections(this.db);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getCollectionByHandle(handle: string): Promise<CatalogCollection | null> {
|
|
42
|
+
return getCollectionByHandle(this.db, handle);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
search(q: string, limit = 20): Promise<CatalogProduct[]> {
|
|
46
|
+
return searchProducts(this.db, q, limit);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async getFeatured(limit = 8): Promise<CatalogProduct[]> {
|
|
50
|
+
return (await listProducts(this.db, { sort: "featured", pageSize: limit })).products;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async getAllActiveProducts(): Promise<CatalogProduct[]> {
|
|
54
|
+
// Large page; listProducts already filters to active.
|
|
55
|
+
return (await listProducts(this.db, { pageSize: 100_000 })).products;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async getReviews(_handle: string): Promise<Review[]> {
|
|
59
|
+
// Phase 1 reviews are display-via-metafields; D1 carries none yet.
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side query engine for FixtureToTClient: sort, facet, filter, paginate.
|
|
3
|
+
* Mirrors the semantics tot20's /products endpoint implements server-side, so
|
|
4
|
+
* PLP behaviour is identical whichever backend is active.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
CatalogProduct,
|
|
8
|
+
Facet,
|
|
9
|
+
FacetValue,
|
|
10
|
+
ListProductsQuery,
|
|
11
|
+
} from "@tot/public-runtime";
|
|
12
|
+
|
|
13
|
+
/** Lowest variant price (the "from" price shown on cards). */
|
|
14
|
+
export function fromPrice(p: CatalogProduct): number {
|
|
15
|
+
return p.price_range.min;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isOnSale(p: CatalogProduct): boolean {
|
|
19
|
+
return p.variants.some(
|
|
20
|
+
(v) => v.compare_at_price != null && v.compare_at_price > v.price,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isAvailable(p: CatalogProduct): boolean {
|
|
25
|
+
return p.variants.some((v) => v.inventory.available);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isNew(p: CatalogProduct, days = 60): boolean {
|
|
29
|
+
if (p.tags.includes("new")) return true;
|
|
30
|
+
const created = Date.parse(p.created_at);
|
|
31
|
+
if (Number.isNaN(created)) return false;
|
|
32
|
+
return Date.now() - created < days * 24 * 60 * 60 * 1000;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Build facets from the (collection-scoped) pool BEFORE filters are applied. */
|
|
36
|
+
export function buildFacets(pool: CatalogProduct[]): Facet[] {
|
|
37
|
+
const optionValues = new Map<string, Map<string, number>>();
|
|
38
|
+
const tagCounts = new Map<string, number>();
|
|
39
|
+
let inStock = 0;
|
|
40
|
+
|
|
41
|
+
for (const p of pool) {
|
|
42
|
+
if (isAvailable(p)) inStock++;
|
|
43
|
+
for (const t of p.tags) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1);
|
|
44
|
+
for (const opt of p.options) {
|
|
45
|
+
const seen = new Set<string>();
|
|
46
|
+
for (const v of p.variants) {
|
|
47
|
+
const val = v.option_values[opt.name];
|
|
48
|
+
if (val && !seen.has(val)) seen.add(val);
|
|
49
|
+
}
|
|
50
|
+
const m = optionValues.get(opt.name) ?? new Map<string, number>();
|
|
51
|
+
for (const val of seen) m.set(val, (m.get(val) ?? 0) + 1);
|
|
52
|
+
optionValues.set(opt.name, m);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const facets: Facet[] = [];
|
|
57
|
+
for (const [name, m] of optionValues) {
|
|
58
|
+
facets.push({
|
|
59
|
+
name,
|
|
60
|
+
values: sortFacetValues([...m].map(([value, count]) => ({ value, count }))),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (tagCounts.size) {
|
|
64
|
+
facets.push({
|
|
65
|
+
name: "tags",
|
|
66
|
+
values: sortFacetValues(
|
|
67
|
+
[...tagCounts].map(([value, count]) => ({ value, count })),
|
|
68
|
+
),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
facets.push({
|
|
72
|
+
name: "availability",
|
|
73
|
+
values: [
|
|
74
|
+
{ value: "in-stock", count: inStock },
|
|
75
|
+
{ value: "all", count: pool.length },
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
return facets;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sortFacetValues(values: FacetValue[]): FacetValue[] {
|
|
82
|
+
// Size-like values get a sensible order; everything else alpha.
|
|
83
|
+
const sizeOrder = ["XS", "S", "M", "L", "XL", "XXL", "2XL", "3XL"];
|
|
84
|
+
return values.sort((a, b) => {
|
|
85
|
+
const ai = sizeOrder.indexOf(a.value.toUpperCase());
|
|
86
|
+
const bi = sizeOrder.indexOf(b.value.toUpperCase());
|
|
87
|
+
if (ai !== -1 && bi !== -1) return ai - bi;
|
|
88
|
+
return a.value.localeCompare(b.value);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Apply filters + sort (NOT pagination). */
|
|
93
|
+
export function applyQuery(
|
|
94
|
+
pool: CatalogProduct[],
|
|
95
|
+
query: ListProductsQuery,
|
|
96
|
+
): CatalogProduct[] {
|
|
97
|
+
let out = [...pool];
|
|
98
|
+
|
|
99
|
+
// Free text (PLP supports an inline q).
|
|
100
|
+
if (query.q) {
|
|
101
|
+
const needle = query.q.trim().toLowerCase();
|
|
102
|
+
out = out.filter((p) =>
|
|
103
|
+
[p.title, p.vendor, p.product_type, p.description_text, ...p.tags]
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
.some((s) => String(s).toLowerCase().includes(needle)),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Option-value facets: a product matches if ANY variant satisfies ALL chosen
|
|
110
|
+
// option groups (intra-group OR, inter-group AND).
|
|
111
|
+
if (query.filters) {
|
|
112
|
+
for (const [optName, chosen] of Object.entries(query.filters)) {
|
|
113
|
+
if (optName === "tags" || optName === "availability" || optName === "price")
|
|
114
|
+
continue;
|
|
115
|
+
const set = new Set(chosen);
|
|
116
|
+
out = out.filter((p) =>
|
|
117
|
+
p.variants.some((v) => {
|
|
118
|
+
const val = v.option_values[optName];
|
|
119
|
+
return val != null && set.has(val);
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const tagFilter = query.filters["tags"];
|
|
124
|
+
if (tagFilter?.length) {
|
|
125
|
+
const set = new Set(tagFilter);
|
|
126
|
+
out = out.filter((p) => p.tags.some((t) => set.has(t)));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (query.tags?.length) {
|
|
131
|
+
const set = new Set(query.tags);
|
|
132
|
+
out = out.filter((p) => p.tags.some((t) => set.has(t)));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (query.availability === "in-stock") {
|
|
136
|
+
out = out.filter(isAvailable);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (query.priceMin != null) out = out.filter((p) => p.price_range.max >= query.priceMin!);
|
|
140
|
+
if (query.priceMax != null) out = out.filter((p) => p.price_range.min <= query.priceMax!);
|
|
141
|
+
|
|
142
|
+
out = sortProducts(out, query.sort, pool);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function sortProducts(
|
|
147
|
+
list: CatalogProduct[],
|
|
148
|
+
sort: ListProductsQuery["sort"],
|
|
149
|
+
featuredOrder?: CatalogProduct[],
|
|
150
|
+
): CatalogProduct[] {
|
|
151
|
+
const out = [...list];
|
|
152
|
+
switch (sort) {
|
|
153
|
+
case "price-asc":
|
|
154
|
+
return out.sort((a, b) => fromPrice(a) - fromPrice(b));
|
|
155
|
+
case "price-desc":
|
|
156
|
+
return out.sort((a, b) => fromPrice(b) - fromPrice(a));
|
|
157
|
+
case "newest":
|
|
158
|
+
return out.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
159
|
+
case "title-asc":
|
|
160
|
+
return out.sort((a, b) => a.title.localeCompare(b.title));
|
|
161
|
+
case "featured":
|
|
162
|
+
default: {
|
|
163
|
+
// Featured = original pool order, with bestsellers floated up.
|
|
164
|
+
const idx = new Map(
|
|
165
|
+
(featuredOrder ?? list).map((p, i) => [p.id, i] as const),
|
|
166
|
+
);
|
|
167
|
+
return out.sort((a, b) => {
|
|
168
|
+
const aw = a.tags.includes("bestseller") ? 0 : 1;
|
|
169
|
+
const bw = b.tags.includes("bestseller") ? 0 : 1;
|
|
170
|
+
if (aw !== bw) return aw - bw;
|
|
171
|
+
return (idx.get(a.id) ?? 0) - (idx.get(b.id) ?? 0);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function paginate<T>(
|
|
178
|
+
items: T[],
|
|
179
|
+
query: ListProductsQuery,
|
|
180
|
+
): { items: T[]; page: number; pageSize: number; total: number } {
|
|
181
|
+
const pageSize = Math.max(1, query.pageSize ?? 12);
|
|
182
|
+
const total = items.length;
|
|
183
|
+
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
|
184
|
+
const page = Math.min(Math.max(1, query.page ?? 1), pageCount);
|
|
185
|
+
const start = (page - 1) * pageSize;
|
|
186
|
+
return { items: items.slice(start, start + pageSize), page, pageSize, total };
|
|
187
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ToTClient interface — the storefront's single product-truth contract.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from ToTClient.ts so the concrete backends (FixtureToTClient here,
|
|
5
|
+
* D1ToTClient in d1Client.ts) can both `implements ToTClient` without a circular
|
|
6
|
+
* module edge: d1Client imports only this contract, while ToTClient.ts imports
|
|
7
|
+
* D1ToTClient for its factory. ToTClient.ts re-exports this type, so existing
|
|
8
|
+
* `import type { ToTClient } from "./ToTClient"` call sites are unchanged.
|
|
9
|
+
*/
|
|
10
|
+
import type {
|
|
11
|
+
CatalogCollection,
|
|
12
|
+
CatalogProduct,
|
|
13
|
+
ListProductsQuery,
|
|
14
|
+
ListProductsResponse,
|
|
15
|
+
Review,
|
|
16
|
+
} from "@tot/public-runtime";
|
|
17
|
+
|
|
18
|
+
export interface ToTClient {
|
|
19
|
+
listProducts(query?: ListProductsQuery): Promise<ListProductsResponse>;
|
|
20
|
+
getProductByHandle(handle: string): Promise<CatalogProduct | null>;
|
|
21
|
+
listCollections(): Promise<CatalogCollection[]>;
|
|
22
|
+
getCollectionByHandle(handle: string): Promise<CatalogCollection | null>;
|
|
23
|
+
search(q: string, limit?: number): Promise<CatalogProduct[]>;
|
|
24
|
+
/** Curated/featured set for the homepage. Heuristic: bestsellers + newest. */
|
|
25
|
+
getFeatured(limit?: number): Promise<CatalogProduct[]>;
|
|
26
|
+
/** Full active catalog — used to build the per-tenant search index. */
|
|
27
|
+
getAllActiveProducts(): Promise<CatalogProduct[]>;
|
|
28
|
+
/** Full review content for a product (migrated from the store's reviews app). */
|
|
29
|
+
getReviews(handle: string): Promise<Review[]>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL <-> ListProductsQuery codec for URL-addressable PLP facets (brief §5).
|
|
3
|
+
* Filters live in the querystring so results are shareable + back-button safe.
|
|
4
|
+
*
|
|
5
|
+
* Encoding:
|
|
6
|
+
* ?sort=price-asc&page=2&availability=in-stock
|
|
7
|
+
* &opt.Color=Navy&opt.Color=Forest (repeated => OR within a facet)
|
|
8
|
+
* &opt.Size=M&tag=sale&priceMin=50&priceMax=200&q=wool
|
|
9
|
+
*/
|
|
10
|
+
import type { ListProductsQuery } from "@tot/public-runtime";
|
|
11
|
+
|
|
12
|
+
export function parseQuery(url: URL): ListProductsQuery {
|
|
13
|
+
const sp = url.searchParams;
|
|
14
|
+
const q: ListProductsQuery = {};
|
|
15
|
+
|
|
16
|
+
const sort = sp.get("sort");
|
|
17
|
+
if (
|
|
18
|
+
sort === "featured" ||
|
|
19
|
+
sort === "price-asc" ||
|
|
20
|
+
sort === "price-desc" ||
|
|
21
|
+
sort === "newest" ||
|
|
22
|
+
sort === "title-asc"
|
|
23
|
+
) {
|
|
24
|
+
q.sort = sort;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// NOTE: read raw params first — Number(null) === 0, which would silently
|
|
28
|
+
// inject priceMin/priceMax/page = 0 and filter everything out.
|
|
29
|
+
const num = (key: string): number | undefined => {
|
|
30
|
+
const raw = sp.get(key);
|
|
31
|
+
if (raw == null || raw === "") return undefined;
|
|
32
|
+
const n = Number(raw);
|
|
33
|
+
return Number.isFinite(n) ? n : undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const page = num("page");
|
|
37
|
+
if (page != null && page > 0) q.page = page;
|
|
38
|
+
const pageSize = num("pageSize");
|
|
39
|
+
if (pageSize != null && pageSize > 0) q.pageSize = pageSize;
|
|
40
|
+
|
|
41
|
+
const avail = sp.get("availability");
|
|
42
|
+
if (avail === "in-stock" || avail === "all") q.availability = avail;
|
|
43
|
+
|
|
44
|
+
const priceMin = num("priceMin");
|
|
45
|
+
if (priceMin != null) q.priceMin = priceMin;
|
|
46
|
+
const priceMax = num("priceMax");
|
|
47
|
+
if (priceMax != null) q.priceMax = priceMax;
|
|
48
|
+
|
|
49
|
+
const text = sp.get("q");
|
|
50
|
+
if (text) q.q = text;
|
|
51
|
+
|
|
52
|
+
const tags = sp.getAll("tag");
|
|
53
|
+
if (tags.length) q.tags = tags;
|
|
54
|
+
|
|
55
|
+
// Option facets: opt.<Name>=<value> (repeatable).
|
|
56
|
+
const filters: Record<string, string[]> = {};
|
|
57
|
+
for (const [key, value] of sp.entries()) {
|
|
58
|
+
if (key.startsWith("opt.")) {
|
|
59
|
+
const name = key.slice(4);
|
|
60
|
+
(filters[name] ??= []).push(value);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// tags also expressed as a facet for the facet UI.
|
|
64
|
+
if (tags.length) filters["tags"] = tags;
|
|
65
|
+
if (Object.keys(filters).length) q.filters = filters;
|
|
66
|
+
|
|
67
|
+
return q;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Build a querystring from a partial query, preserving repeated params. */
|
|
71
|
+
export function buildQueryString(
|
|
72
|
+
base: ListProductsQuery,
|
|
73
|
+
patch: Partial<ListProductsQuery> = {},
|
|
74
|
+
): string {
|
|
75
|
+
const q = { ...base, ...patch };
|
|
76
|
+
const sp = new URLSearchParams();
|
|
77
|
+
if (q.sort && q.sort !== "featured") sp.set("sort", q.sort);
|
|
78
|
+
if (q.page && q.page > 1) sp.set("page", String(q.page));
|
|
79
|
+
if (q.pageSize) sp.set("pageSize", String(q.pageSize));
|
|
80
|
+
if (q.availability && q.availability !== "all")
|
|
81
|
+
sp.set("availability", q.availability);
|
|
82
|
+
if (q.priceMin != null) sp.set("priceMin", String(q.priceMin));
|
|
83
|
+
if (q.priceMax != null) sp.set("priceMax", String(q.priceMax));
|
|
84
|
+
if (q.q) sp.set("q", q.q);
|
|
85
|
+
if (q.filters) {
|
|
86
|
+
for (const [name, values] of Object.entries(q.filters)) {
|
|
87
|
+
if (name === "tags") {
|
|
88
|
+
values.forEach((v) => sp.append("tag", v));
|
|
89
|
+
} else {
|
|
90
|
+
values.forEach((v) => sp.append(`opt.${name}`, v));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const s = sp.toString();
|
|
95
|
+
return s ? `?${s}` : "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Toggle a single facet value on/off, returning the new querystring (page reset). */
|
|
99
|
+
export function toggleFacet(
|
|
100
|
+
current: ListProductsQuery,
|
|
101
|
+
facet: string,
|
|
102
|
+
value: string,
|
|
103
|
+
): string {
|
|
104
|
+
const filters: Record<string, string[]> = {};
|
|
105
|
+
for (const [k, v] of Object.entries(current.filters ?? {})) filters[k] = [...v];
|
|
106
|
+
const list = filters[facet] ?? [];
|
|
107
|
+
filters[facet] = list.includes(value)
|
|
108
|
+
? list.filter((x) => x !== value)
|
|
109
|
+
: [...list, value];
|
|
110
|
+
if (!filters[facet].length) delete filters[facet];
|
|
111
|
+
const next: ListProductsQuery = { ...current, filters, page: 1 };
|
|
112
|
+
if (!Object.keys(filters).length) delete next.filters;
|
|
113
|
+
// tags facet mirrors q.tags
|
|
114
|
+
if (facet === "tags") {
|
|
115
|
+
next.tags = filters["tags"];
|
|
116
|
+
if (!next.tags?.length) delete next.tags;
|
|
117
|
+
}
|
|
118
|
+
return buildQueryString(next);
|
|
119
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wishlist / favorites — UI-only stub (brief §5: "favorites may be stubbed in
|
|
3
|
+
* UI only"). Persists a small product snapshot to localStorage so the heart and
|
|
4
|
+
* the /saved page work with zero backend. Phase 2 can swap this for an account-
|
|
5
|
+
* backed list without touching components (they only call these helpers + listen
|
|
6
|
+
* for the change event).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface SavedItem {
|
|
10
|
+
handle: string;
|
|
11
|
+
title: string;
|
|
12
|
+
price: number;
|
|
13
|
+
currency: string;
|
|
14
|
+
vendor?: string;
|
|
15
|
+
img?: string;
|
|
16
|
+
rehosted?: boolean;
|
|
17
|
+
seed: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const KEY = "tot:wishlist";
|
|
21
|
+
export const WISHLIST_EVENT = "tot:wishlist-changed";
|
|
22
|
+
|
|
23
|
+
export function readWishlist(): SavedItem[] {
|
|
24
|
+
try {
|
|
25
|
+
const raw = localStorage.getItem(KEY);
|
|
26
|
+
const arr = raw ? JSON.parse(raw) : [];
|
|
27
|
+
return Array.isArray(arr) ? (arr as SavedItem[]) : [];
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function write(items: SavedItem[]): void {
|
|
34
|
+
try {
|
|
35
|
+
localStorage.setItem(KEY, JSON.stringify(items));
|
|
36
|
+
} catch {
|
|
37
|
+
/* storage disabled — degrade silently */
|
|
38
|
+
}
|
|
39
|
+
window.dispatchEvent(
|
|
40
|
+
new CustomEvent(WISHLIST_EVENT, { detail: { count: items.length } }),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isSaved(handle: string): boolean {
|
|
45
|
+
return readWishlist().some((i) => i.handle === handle);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Toggle membership; returns the new saved state. */
|
|
49
|
+
export function toggleSaved(item: SavedItem): boolean {
|
|
50
|
+
const list = readWishlist();
|
|
51
|
+
const idx = list.findIndex((i) => i.handle === item.handle);
|
|
52
|
+
if (idx >= 0) {
|
|
53
|
+
list.splice(idx, 1);
|
|
54
|
+
write(list);
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
write([item, ...list].slice(0, 100));
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function count(): number {
|
|
62
|
+
return readWishlist().length;
|
|
63
|
+
}
|