@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.
- package/LICENSE +58 -0
- package/README.md +42 -0
- package/apps/storefront/astro.config.mjs +66 -0
- package/apps/storefront/dev-plugins/dev-loop-state.mjs +147 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +238 -0
- package/apps/storefront/env.d.ts +162 -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 +42 -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/public/shared/commerce-chrome.css +369 -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 +55 -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/admin/AdminAIWorkflowsTab.astro +531 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +597 -0
- package/apps/storefront/src/components/admin/AdminFoxyCartSettingsPanel.astro +227 -0
- package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +465 -0
- package/apps/storefront/src/components/admin/AdminProductsTab.astro +901 -0
- package/apps/storefront/src/components/admin/AdminReportingTab.astro +435 -0
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +782 -0
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +1223 -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 +201 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +201 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +234 -0
- package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
- package/apps/storefront/src/components/commerce/ArticleCard.astro +242 -0
- package/apps/storefront/src/components/commerce/BrandGrid.astro +175 -0
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
- package/apps/storefront/src/components/commerce/FoxySidecartStyles.astro +61 -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/commerce/ReviewList.astro +54 -0
- package/apps/storefront/src/components/commerce/ReviewSummary.astro +44 -0
- package/apps/storefront/src/components/commerce/TagFilterCloud.astro +149 -0
- package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +37 -0
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +99 -0
- package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +45 -0
- package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
- package/apps/storefront/src/components/compliance/PactActNotice.astro +35 -0
- package/apps/storefront/src/components/compliance/Prop65Warning.astro +80 -0
- package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +38 -0
- package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +35 -0
- package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +53 -0
- package/apps/storefront/src/components/home/Hero.astro +164 -0
- package/apps/storefront/src/components/islands/AgeGate.tsx +109 -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 +375 -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/membership/IllustrativeLabel.astro +56 -0
- package/apps/storefront/src/components/membership/TierCards.astro +164 -0
- package/apps/storefront/src/components/membership/TierComparisonTable.astro +148 -0
- package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
- package/apps/storefront/src/components/plp/FacetSidebar.astro +199 -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/components/style-guide/StyleGuideNav.astro +95 -0
- package/apps/storefront/src/components/subscription/HowItWorksSteps.astro +67 -0
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +67 -0
- package/apps/storefront/src/components/subscription/SavingsExplainer.astro +101 -0
- package/apps/storefront/src/config/adminTargetTenant.ts +28 -0
- package/apps/storefront/src/config/devTenantSeed.ts +78 -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 +102 -0
- package/apps/storefront/src/config/tenants.ts +87 -0
- package/apps/storefront/src/layouts/Layout.astro +368 -0
- package/apps/storefront/src/lib/admin/README.md +122 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +21 -0
- package/apps/storefront/src/lib/admin/ai/askEnvelope.ts +48 -0
- package/apps/storefront/src/lib/admin/ai/audit.ts +14 -0
- package/apps/storefront/src/lib/admin/ai/index.ts +6 -0
- package/apps/storefront/src/lib/admin/ai/operationalRuns.ts +155 -0
- package/apps/storefront/src/lib/admin/ai/recommendation.ts +20 -0
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +172 -0
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +65 -0
- package/apps/storefront/src/lib/admin/envelope.ts +10 -0
- package/apps/storefront/src/lib/admin/evidence/evidenceReference.ts +32 -0
- package/apps/storefront/src/lib/admin/memoizeByEnvelope.ts +27 -0
- package/apps/storefront/src/lib/admin/orderClient.ts +146 -0
- package/apps/storefront/src/lib/admin/seed.ts +54 -0
- package/apps/storefront/src/lib/admin/services/mock/aiWorkflows.ts +59 -0
- package/apps/storefront/src/lib/admin/services/mock/customers.ts +86 -0
- package/apps/storefront/src/lib/admin/services/mock/evidence.ts +99 -0
- package/apps/storefront/src/lib/admin/services/mock/foxyCartSettings.ts +78 -0
- package/apps/storefront/src/lib/admin/services/mock/fulfillment.ts +62 -0
- package/apps/storefront/src/lib/admin/services/mock/index.ts +12 -0
- package/apps/storefront/src/lib/admin/services/mock/orders.ts +184 -0
- package/apps/storefront/src/lib/admin/services/mock/products.ts +105 -0
- package/apps/storefront/src/lib/admin/services/mock/reporting.ts +51 -0
- package/apps/storefront/src/lib/admin/services/mock/settings.ts +70 -0
- package/apps/storefront/src/lib/admin/services/mock/tenants.ts +29 -0
- package/apps/storefront/src/lib/admin/services/orders.ts +300 -0
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +67 -0
- package/apps/storefront/src/lib/admin/viewmodels/customers.ts +56 -0
- package/apps/storefront/src/lib/admin/viewmodels/fulfillment.ts +49 -0
- package/apps/storefront/src/lib/admin/viewmodels/orders.ts +92 -0
- package/apps/storefront/src/lib/admin/viewmodels/products.ts +74 -0
- package/apps/storefront/src/lib/admin/viewmodels/reporting.ts +34 -0
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +64 -0
- package/apps/storefront/src/lib/adminTenantEnvelope.ts +90 -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 +119 -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 +244 -0
- package/apps/storefront/src/lib/chrome/parseConfig.ts +34 -0
- package/apps/storefront/src/lib/colors.ts +97 -0
- package/apps/storefront/src/lib/commerce/foxyCartSettings.ts +253 -0
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +91 -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 +366 -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/activityIngestToken.ts +78 -0
- package/apps/storefront/src/lib/dev/activityStore.ts +72 -0
- package/apps/storefront/src/lib/dev/ai-widget-client.js +308 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +14 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +155 -0
- package/apps/storefront/src/lib/dev/devDraft.ts +133 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +169 -0
- package/apps/storefront/src/lib/dev/livePreview.ts +77 -0
- package/apps/storefront/src/lib/dev/runtimeStore.ts +120 -0
- package/apps/storefront/src/lib/dev/storeStatus.ts +142 -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 +38 -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 +166 -0
- package/apps/storefront/src/lib/maintenance.ts +71 -0
- package/apps/storefront/src/lib/membership/model.ts +97 -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/priceBuckets.ts +33 -0
- package/apps/storefront/src/lib/rawChrome.ts +505 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +136 -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 +607 -0
- package/apps/storefront/src/lib/subscription/model.ts +68 -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 +249 -0
- package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
- package/apps/storefront/src/lib/url.ts +142 -0
- package/apps/storefront/src/lib/wishlist.ts +63 -0
- package/apps/storefront/src/middleware/index.ts +455 -0
- package/apps/storefront/src/pages/404.astro +61 -0
- package/apps/storefront/src/pages/[...slug].astro +90 -0
- package/apps/storefront/src/pages/admin.astro +1675 -0
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +53 -0
- package/apps/storefront/src/pages/api/admin/orders.ts +71 -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/checkout-style.css.ts +43 -0
- package/apps/storefront/src/pages/api/cli/policy.ts +70 -0
- package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +164 -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/activity.ts +124 -0
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +73 -0
- package/apps/storefront/src/pages/api/dev/diff.ts +68 -0
- package/apps/storefront/src/pages/api/dev/file.ts +57 -0
- package/apps/storefront/src/pages/api/dev/locate.ts +125 -0
- package/apps/storefront/src/pages/api/dev/publish.ts +128 -0
- package/apps/storefront/src/pages/api/dev/tree.ts +70 -0
- package/apps/storefront/src/pages/api/dev/write.ts +116 -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 +177 -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/index.astro +290 -0
- package/apps/storefront/src/pages/llms.txt.ts +92 -0
- package/apps/storefront/src/pages/products/[handle].astro +402 -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 +1280 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +229 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +144 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +116 -0
- package/apps/storefront/src/pages/style-guide/index.astro +98 -0
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
- package/apps/storefront/src/styles/global.css +292 -0
- package/apps/storefront/src/themes/reference.ts +68 -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/package.json +26 -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/checkout-style-publish.ts +200 -0
- package/packages/public-runtime/src/checkout-style.ts +375 -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-orders.ts +136 -0
- package/packages/public-runtime/src/foxy.ts +196 -0
- package/packages/public-runtime/src/hot-standby-readiness.ts +140 -0
- package/packages/public-runtime/src/index.ts +34 -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 +384 -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/dev/ai-edit.mjs +183 -0
- package/scripts/dev/checkout-watch.mjs +116 -0
- package/scripts/dev/file-browser.mjs +159 -0
- package/scripts/dev/file-writer.mjs +141 -0
- package/scripts/dev/git-status.mjs +171 -0
- package/scripts/dev/locate-handler.mjs +163 -0
- package/scripts/dev/locate.mjs +112 -0
- package/scripts/dev/port-check.mjs +48 -0
- package/scripts/dev/safe-path.mjs +109 -0
- package/scripts/dev/shot.mjs +46 -0
- package/scripts/dev/tenant-source-root.mjs +76 -0
- package/scripts/dev/unified-diff.mjs +81 -0
- package/scripts/tot-dev.mjs +376 -0
- package/tenants/README.md +14 -0
- package/tenants/home/content/chrome.json +7 -0
- package/tenants/home/content/home.html +58 -0
- package/tenants/home/content/home.json +11 -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/tsconfig.base.json +20 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant routing resolution: Host first, then a `/<domain>/*` path-prefix
|
|
3
|
+
* fallback for hosts that don't resolve to a tenant on their own.
|
|
4
|
+
*
|
|
5
|
+
* Precedence (host-first — matches the prod contract where a custom hostname
|
|
6
|
+
* authoritatively owns a tenant; the path form is a DNS-free escape hatch for
|
|
7
|
+
* localhost/preview):
|
|
8
|
+
*
|
|
9
|
+
* 1. Host strictly claims a tenant -> use it, no base path.
|
|
10
|
+
* 2. Host is unknown AND the first path
|
|
11
|
+
* segment looks like a hostname that
|
|
12
|
+
* strictly claims a tenant -> use it; the segment becomes the
|
|
13
|
+
* base path and is stripped from
|
|
14
|
+
* the rendered route.
|
|
15
|
+
* 3. Otherwise -> registry default (resolveByHost).
|
|
16
|
+
*
|
|
17
|
+
* Example: on localhost (which claims no tenant), GET
|
|
18
|
+
* /store2.tokenoftrust.com/products/foo
|
|
19
|
+
* resolves tenant "store2", basePath "/store2.tokenoftrust.com", and renders
|
|
20
|
+
* the route "/products/foo". A bare localhost/products falls through to the
|
|
21
|
+
* default tenant because "products" isn't a hostname.
|
|
22
|
+
*/
|
|
23
|
+
import type { TenantConfig, TenantResolver } from "@tot/public-runtime";
|
|
24
|
+
|
|
25
|
+
export interface TenantRouting {
|
|
26
|
+
tenant: TenantConfig | null;
|
|
27
|
+
/** "" when host-resolved; "/<domain>" when path-resolved. */
|
|
28
|
+
basePath: string;
|
|
29
|
+
/** Route to render with the tenant prefix stripped (leading slash, no query). */
|
|
30
|
+
renderPath: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A path segment is treated as a tenant-domain candidate only if it looks like
|
|
35
|
+
* a hostname (has a dot, no "@" or other path-ish junk). Real first segments
|
|
36
|
+
* — products, collections, saved, search — never contain a dot, so there is no
|
|
37
|
+
* collision with genuine routes.
|
|
38
|
+
*/
|
|
39
|
+
export function looksLikeHost(segment: string): boolean {
|
|
40
|
+
return (
|
|
41
|
+
segment.length > 0 &&
|
|
42
|
+
segment.includes(".") &&
|
|
43
|
+
!segment.startsWith(".") &&
|
|
44
|
+
!segment.endsWith(".") &&
|
|
45
|
+
!segment.includes("@") &&
|
|
46
|
+
!segment.includes(" ")
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Normalize a path: always leading slash, never a trailing slash (except "/"). */
|
|
51
|
+
function normalizePath(path: string): string {
|
|
52
|
+
const withSlash = path.startsWith("/") ? path : `/${path}`;
|
|
53
|
+
const trimmed = withSlash.replace(/\/+$/, "");
|
|
54
|
+
return trimmed === "" ? "/" : trimmed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function resolveTenantRouting(
|
|
58
|
+
host: string,
|
|
59
|
+
pathname: string,
|
|
60
|
+
resolver: TenantResolver,
|
|
61
|
+
): Promise<TenantRouting> {
|
|
62
|
+
// 1. Host wins when it explicitly owns a tenant.
|
|
63
|
+
const byHost = await resolver.resolveStrict(host);
|
|
64
|
+
if (byHost) {
|
|
65
|
+
return { tenant: byHost, basePath: "", renderPath: normalizePath(pathname) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Unknown host -> try a /<domain>/* path prefix.
|
|
69
|
+
const segments = pathname.replace(/^\/+/, "").split("/");
|
|
70
|
+
const firstRaw = segments[0] ?? "";
|
|
71
|
+
let first = firstRaw;
|
|
72
|
+
try {
|
|
73
|
+
first = decodeURIComponent(firstRaw);
|
|
74
|
+
} catch {
|
|
75
|
+
/* leave as-is if it isn't valid percent-encoding */
|
|
76
|
+
}
|
|
77
|
+
if (looksLikeHost(first)) {
|
|
78
|
+
const byPath = await resolver.resolveStrict(first);
|
|
79
|
+
if (byPath) {
|
|
80
|
+
return {
|
|
81
|
+
tenant: byPath,
|
|
82
|
+
basePath: `/${first}`,
|
|
83
|
+
renderPath: normalizePath(segments.slice(1).join("/")),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 3. Fall back to the registry default for this (unknown) host.
|
|
89
|
+
const fallback = await resolver.resolveByHost(host);
|
|
90
|
+
return { tenant: fallback, basePath: "", renderPath: normalizePath(pathname) };
|
|
91
|
+
}
|
|
@@ -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, query);
|
|
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,249 @@
|
|
|
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
|
+
import { PRICE_BUCKETS, bucketForPrice } from "../priceBuckets.js";
|
|
13
|
+
|
|
14
|
+
/** Lowest variant price (the "from" price shown on cards). */
|
|
15
|
+
export function fromPrice(p: CatalogProduct): number {
|
|
16
|
+
return p.price_range.min;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isOnSale(p: CatalogProduct): boolean {
|
|
20
|
+
return p.variants.some(
|
|
21
|
+
(v) => v.compare_at_price != null && v.compare_at_price > v.price,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isAvailable(p: CatalogProduct): boolean {
|
|
26
|
+
return p.variants.some((v) => v.inventory.available);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isNew(p: CatalogProduct, days = 60): boolean {
|
|
30
|
+
if (p.tags.includes("new")) return true;
|
|
31
|
+
const created = Date.parse(p.created_at);
|
|
32
|
+
if (Number.isNaN(created)) return false;
|
|
33
|
+
return Date.now() - created < days * 24 * 60 * 60 * 1000;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build facets for the (collection-scoped) pool, with LIVE counts: each
|
|
38
|
+
* group's value counts reflect every OTHER active filter (all groups but its
|
|
39
|
+
* own, plus q/availability/price), so selecting a value narrows the other
|
|
40
|
+
* groups' counts while still letting the user multi-select/deselect within
|
|
41
|
+
* the group they're looking at. Values known to the pool but narrowed to zero
|
|
42
|
+
* by other filters still appear (count: 0) rather than vanishing, so the
|
|
43
|
+
* facet list stays stable while filtering.
|
|
44
|
+
*/
|
|
45
|
+
export function buildFacets(
|
|
46
|
+
pool: CatalogProduct[],
|
|
47
|
+
query: ListProductsQuery = {},
|
|
48
|
+
): Facet[] {
|
|
49
|
+
const optionValues = new Map<string, Set<string>>();
|
|
50
|
+
const tagNames = new Set<string>();
|
|
51
|
+
for (const p of pool) {
|
|
52
|
+
for (const t of p.tags) tagNames.add(t);
|
|
53
|
+
for (const opt of p.options) {
|
|
54
|
+
const set = optionValues.get(opt.name) ?? new Set<string>();
|
|
55
|
+
for (const v of p.variants) {
|
|
56
|
+
const val = v.option_values[opt.name];
|
|
57
|
+
if (val) set.add(val);
|
|
58
|
+
}
|
|
59
|
+
optionValues.set(opt.name, set);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const facets: Facet[] = [];
|
|
64
|
+
for (const [name, allValues] of optionValues) {
|
|
65
|
+
facets.push(buildOptionFacet(name, allValues, applyFilters(pool, query, name)));
|
|
66
|
+
}
|
|
67
|
+
if (tagNames.size) {
|
|
68
|
+
facets.push(buildTagFacet(tagNames, applyFilters(pool, query, "tags")));
|
|
69
|
+
}
|
|
70
|
+
facets.push(buildAvailabilityFacet(applyFilters(pool, query, "availability")));
|
|
71
|
+
facets.push(buildPriceFacet(applyFilters(pool, query, "price")));
|
|
72
|
+
return facets;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildOptionFacet(
|
|
76
|
+
name: string,
|
|
77
|
+
allValues: Set<string>,
|
|
78
|
+
scoped: CatalogProduct[],
|
|
79
|
+
): Facet {
|
|
80
|
+
const counts = new Map<string, number>([...allValues].map((v) => [v, 0]));
|
|
81
|
+
for (const p of scoped) {
|
|
82
|
+
const seen = new Set<string>();
|
|
83
|
+
for (const v of p.variants) {
|
|
84
|
+
const val = v.option_values[name];
|
|
85
|
+
if (val && !seen.has(val)) seen.add(val);
|
|
86
|
+
}
|
|
87
|
+
for (const val of seen) counts.set(val, (counts.get(val) ?? 0) + 1);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
name,
|
|
91
|
+
values: sortFacetValues([...counts].map(([value, count]) => ({ value, count }))),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildTagFacet(allTagNames: Set<string>, scoped: CatalogProduct[]): Facet {
|
|
96
|
+
const counts = new Map<string, number>([...allTagNames].map((t) => [t, 0]));
|
|
97
|
+
for (const p of scoped) for (const t of p.tags) counts.set(t, (counts.get(t) ?? 0) + 1);
|
|
98
|
+
return {
|
|
99
|
+
name: "tags",
|
|
100
|
+
values: sortFacetValues([...counts].map(([value, count]) => ({ value, count }))),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildAvailabilityFacet(scoped: CatalogProduct[]): Facet {
|
|
105
|
+
const inStock = scoped.filter(isAvailable).length;
|
|
106
|
+
return {
|
|
107
|
+
name: "availability",
|
|
108
|
+
values: [
|
|
109
|
+
{ value: "in-stock", count: inStock },
|
|
110
|
+
{ value: "all", count: scoped.length },
|
|
111
|
+
],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildPriceFacet(scoped: CatalogProduct[]): Facet {
|
|
116
|
+
const counts = new Map<string, number>(PRICE_BUCKETS.map((b) => [b.key, 0]));
|
|
117
|
+
for (const p of scoped) {
|
|
118
|
+
const bucket = bucketForPrice(fromPrice(p));
|
|
119
|
+
counts.set(bucket.key, (counts.get(bucket.key) ?? 0) + 1);
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
name: "price",
|
|
123
|
+
values: PRICE_BUCKETS.map((b) => ({ value: b.key, count: counts.get(b.key) ?? 0 })),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sortFacetValues(values: FacetValue[]): FacetValue[] {
|
|
128
|
+
// Size-like values get a sensible order; everything else alpha.
|
|
129
|
+
const sizeOrder = ["XS", "S", "M", "L", "XL", "XXL", "2XL", "3XL"];
|
|
130
|
+
return values.sort((a, b) => {
|
|
131
|
+
const ai = sizeOrder.indexOf(a.value.toUpperCase());
|
|
132
|
+
const bi = sizeOrder.indexOf(b.value.toUpperCase());
|
|
133
|
+
if (ai !== -1 && bi !== -1) return ai - bi;
|
|
134
|
+
return a.value.localeCompare(b.value);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Apply every active filter EXCEPT the named group (used to compute that
|
|
140
|
+
* group's own live facet counts). Pass no `except` to apply everything (the
|
|
141
|
+
* normal result-set path).
|
|
142
|
+
*/
|
|
143
|
+
function applyFilters(
|
|
144
|
+
pool: CatalogProduct[],
|
|
145
|
+
query: ListProductsQuery,
|
|
146
|
+
except?: string,
|
|
147
|
+
): CatalogProduct[] {
|
|
148
|
+
let out = [...pool];
|
|
149
|
+
|
|
150
|
+
// Free text (PLP supports an inline q) — always applied, never excludable.
|
|
151
|
+
if (query.q) {
|
|
152
|
+
const needle = query.q.trim().toLowerCase();
|
|
153
|
+
out = out.filter((p) =>
|
|
154
|
+
[p.title, p.vendor, p.product_type, p.description_text, ...p.tags]
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.some((s) => String(s).toLowerCase().includes(needle)),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Option-value facets: a product matches if ANY variant satisfies ALL chosen
|
|
161
|
+
// option groups (intra-group OR, inter-group AND).
|
|
162
|
+
if (query.filters) {
|
|
163
|
+
for (const [optName, chosen] of Object.entries(query.filters)) {
|
|
164
|
+
if (optName === "tags" || optName === "availability" || optName === "price")
|
|
165
|
+
continue;
|
|
166
|
+
if (optName === except) continue;
|
|
167
|
+
const set = new Set(chosen);
|
|
168
|
+
out = out.filter((p) =>
|
|
169
|
+
p.variants.some((v) => {
|
|
170
|
+
const val = v.option_values[optName];
|
|
171
|
+
return val != null && set.has(val);
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
const tagFilter = query.filters["tags"];
|
|
176
|
+
if (tagFilter?.length && except !== "tags") {
|
|
177
|
+
const set = new Set(tagFilter);
|
|
178
|
+
out = out.filter((p) => p.tags.some((t) => set.has(t)));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (query.tags?.length && except !== "tags") {
|
|
183
|
+
const set = new Set(query.tags);
|
|
184
|
+
out = out.filter((p) => p.tags.some((t) => set.has(t)));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (except !== "availability" && query.availability === "in-stock") {
|
|
188
|
+
out = out.filter(isAvailable);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (except !== "price") {
|
|
192
|
+
if (query.priceMin != null) out = out.filter((p) => p.price_range.max >= query.priceMin!);
|
|
193
|
+
if (query.priceMax != null) out = out.filter((p) => p.price_range.min <= query.priceMax!);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Apply filters + sort (NOT pagination). */
|
|
200
|
+
export function applyQuery(
|
|
201
|
+
pool: CatalogProduct[],
|
|
202
|
+
query: ListProductsQuery,
|
|
203
|
+
): CatalogProduct[] {
|
|
204
|
+
const out = applyFilters(pool, query);
|
|
205
|
+
return sortProducts(out, query.sort, pool);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function sortProducts(
|
|
209
|
+
list: CatalogProduct[],
|
|
210
|
+
sort: ListProductsQuery["sort"],
|
|
211
|
+
featuredOrder?: CatalogProduct[],
|
|
212
|
+
): CatalogProduct[] {
|
|
213
|
+
const out = [...list];
|
|
214
|
+
switch (sort) {
|
|
215
|
+
case "price-asc":
|
|
216
|
+
return out.sort((a, b) => fromPrice(a) - fromPrice(b));
|
|
217
|
+
case "price-desc":
|
|
218
|
+
return out.sort((a, b) => fromPrice(b) - fromPrice(a));
|
|
219
|
+
case "newest":
|
|
220
|
+
return out.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
221
|
+
case "title-asc":
|
|
222
|
+
return out.sort((a, b) => a.title.localeCompare(b.title));
|
|
223
|
+
case "featured":
|
|
224
|
+
default: {
|
|
225
|
+
// Featured = original pool order, with bestsellers floated up.
|
|
226
|
+
const idx = new Map(
|
|
227
|
+
(featuredOrder ?? list).map((p, i) => [p.id, i] as const),
|
|
228
|
+
);
|
|
229
|
+
return out.sort((a, b) => {
|
|
230
|
+
const aw = a.tags.includes("bestseller") ? 0 : 1;
|
|
231
|
+
const bw = b.tags.includes("bestseller") ? 0 : 1;
|
|
232
|
+
if (aw !== bw) return aw - bw;
|
|
233
|
+
return (idx.get(a.id) ?? 0) - (idx.get(b.id) ?? 0);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function paginate<T>(
|
|
240
|
+
items: T[],
|
|
241
|
+
query: ListProductsQuery,
|
|
242
|
+
): { items: T[]; page: number; pageSize: number; total: number } {
|
|
243
|
+
const pageSize = Math.max(1, query.pageSize ?? 12);
|
|
244
|
+
const total = items.length;
|
|
245
|
+
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
|
246
|
+
const page = Math.min(Math.max(1, query.page ?? 1), pageCount);
|
|
247
|
+
const start = (page - 1) * pageSize;
|
|
248
|
+
return { items: items.slice(start, start + pageSize), page, pageSize, total };
|
|
249
|
+
}
|
|
@@ -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
|
+
}
|