@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,443 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* PDP — the conversion centerpiece (brief §5). Gallery + variant selector
|
|
4
|
+
* islands, breadcrumbs, structured specs from metafields, related products,
|
|
5
|
+
* badges, review-stars placeholder, disabled buy CTA (no cart in Phase 1),
|
|
6
|
+
* Product + BreadcrumbList JSON-LD.
|
|
7
|
+
*/
|
|
8
|
+
import Layout from "@/layouts/Layout.astro";
|
|
9
|
+
import Breadcrumbs from "@/components/Breadcrumbs.astro";
|
|
10
|
+
import Badge from "@/components/Badge.astro";
|
|
11
|
+
import ProductCard from "@/components/ProductCard.astro";
|
|
12
|
+
import Section from "@/components/Section.astro";
|
|
13
|
+
import ImageGallery from "@/components/islands/ImageGallery.tsx";
|
|
14
|
+
import VariantSelector from "@/components/islands/VariantSelector.tsx";
|
|
15
|
+
import RecentlyViewed from "@/components/islands/RecentlyViewed.tsx";
|
|
16
|
+
import NicotineWarning from "@/components/compliance/NicotineWarning.astro";
|
|
17
|
+
import { isOnSale, isNew, isAvailable, fromPrice } from "@/lib/tot/query";
|
|
18
|
+
import { buildVariantCartPayload } from "@tot/public-runtime";
|
|
19
|
+
import type { VariantCartPayload } from "@tot/public-runtime";
|
|
20
|
+
import { readCartSecret } from "@/lib/foxyCommerce";
|
|
21
|
+
import { truncate } from "@/lib/format";
|
|
22
|
+
import { productLd, breadcrumbLd } from "@/lib/jsonld";
|
|
23
|
+
import { isUsableImageUrl } from "@/lib/placeholder";
|
|
24
|
+
import { withBase } from "@/lib/basePath";
|
|
25
|
+
|
|
26
|
+
const { handle } = Astro.params;
|
|
27
|
+
const { tenant, tot, canonicalBase, basePath, commerce } = Astro.locals;
|
|
28
|
+
// Commerce-only route: marketing tenants have no product detail pages.
|
|
29
|
+
if (!Astro.locals.features.catalog) return new Response(null, { status: 404 });
|
|
30
|
+
const compliance = tenant.compliance;
|
|
31
|
+
|
|
32
|
+
if (!handle) return Astro.redirect(withBase(basePath, "/404"));
|
|
33
|
+
const product = await tot.getProductByHandle(handle);
|
|
34
|
+
|
|
35
|
+
// Draft canary: drafts resolve by handle (preview/direct link) but are noindex.
|
|
36
|
+
if (!product || product.status === "archived") {
|
|
37
|
+
return new Response(null, { status: 404 });
|
|
38
|
+
}
|
|
39
|
+
const isDraft = product.status !== "active";
|
|
40
|
+
|
|
41
|
+
// Related: same collection first, then shared-tag fallback, exclude self+drafts.
|
|
42
|
+
const related = await (async () => {
|
|
43
|
+
const out = new Map<string, typeof product>();
|
|
44
|
+
for (const colHandle of product.collections) {
|
|
45
|
+
const { products } = await tot.listProducts({ collection: colHandle, pageSize: 12 });
|
|
46
|
+
for (const p of products) if (p.handle !== product.handle) out.set(p.handle, p);
|
|
47
|
+
}
|
|
48
|
+
if (out.size < 4 && product.tags.length) {
|
|
49
|
+
const { products } = await tot.listProducts({ pageSize: 50 });
|
|
50
|
+
for (const p of products) {
|
|
51
|
+
if (p.handle === product.handle) continue;
|
|
52
|
+
if (p.tags.some((t) => product.tags.includes(t))) out.set(p.handle, p);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return [...out.values()].slice(0, 4);
|
|
56
|
+
})();
|
|
57
|
+
|
|
58
|
+
const crumbs = [
|
|
59
|
+
{ label: tenant.displayName, href: "/" },
|
|
60
|
+
...(product.collections[0]
|
|
61
|
+
? [{ label: titleCase(product.collections[0]), href: `/collections/${product.collections[0]}` }]
|
|
62
|
+
: []),
|
|
63
|
+
{ label: product.title, href: `/products/${product.handle}` },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function titleCase(s: string) {
|
|
67
|
+
return s.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Metafield reader.
|
|
71
|
+
const mf = (ns: string, key: string) =>
|
|
72
|
+
product.metafields.find((m) => m.namespace === ns && m.key === key)?.value;
|
|
73
|
+
|
|
74
|
+
// Reviews — REAL data from the store's reviews app (Stamped), pulled as metafields.
|
|
75
|
+
const reviewCount = Number(mf("stamped", "reviews_count") ?? mf("reviews", "count") ?? "0");
|
|
76
|
+
const reviewAvg = Number(mf("stamped", "reviews_average") ?? mf("reviews", "rating") ?? "0");
|
|
77
|
+
|
|
78
|
+
// Short description (merchant's concise summary) shown high on the PDP, like the
|
|
79
|
+
// source store. The longer description_html still renders below the buy column.
|
|
80
|
+
const shortDescription = mf("my_fields", "short_description") ?? mf("specs", "short_description");
|
|
81
|
+
|
|
82
|
+
// OG image = the real product image (Shopify CDN supports ?width=), sized for
|
|
83
|
+
// social. Falls back to the tenant brand OG only when there's no usable image.
|
|
84
|
+
const ogPrimary = product.images[0];
|
|
85
|
+
const ogImage =
|
|
86
|
+
ogPrimary && isUsableImageUrl(ogPrimary.url, ogPrimary.rehosted)
|
|
87
|
+
? `${ogPrimary.url}${ogPrimary.url.includes("?") ? "&" : "?"}width=1200`
|
|
88
|
+
: undefined;
|
|
89
|
+
|
|
90
|
+
// Product attributes shown the way the SOURCE STORE presents them: plain
|
|
91
|
+
// "Label: value" lines below the description (Flavors / VG % / Bottle Type),
|
|
92
|
+
// sourced from F:-prefixed tags + merchant my_fields. The compliance.* metafields
|
|
93
|
+
// are ToT-internal sync data and are NOT displayed — the source store doesn't
|
|
94
|
+
// show them either (they gate shipping/eligibility, not merchandising).
|
|
95
|
+
// Each flavor links to that flavor filtered within the product's collection —
|
|
96
|
+
// mirrors the source store's /collections/<handle>/<tag> pattern using our
|
|
97
|
+
// existing ?tag= PLP filter. General: any tag → a filtered listing; only the
|
|
98
|
+
// "F: " flavor-tag convention is merchant-specific (same as the display above).
|
|
99
|
+
const flavorCollection = product.collections[0];
|
|
100
|
+
const flavorTags = (product.tags ?? [])
|
|
101
|
+
.filter((t) => t.startsWith("F: "))
|
|
102
|
+
.map((t) => ({
|
|
103
|
+
label: t.slice(3).trim(),
|
|
104
|
+
href: flavorCollection
|
|
105
|
+
? `/collections/${flavorCollection}?tag=${encodeURIComponent(t)}`
|
|
106
|
+
: undefined,
|
|
107
|
+
}))
|
|
108
|
+
.filter((f) => f.label);
|
|
109
|
+
const vgPercentage = mf("my_fields", "vg_percentage");
|
|
110
|
+
const bottleType = mf("my_fields", "bottle_type");
|
|
111
|
+
|
|
112
|
+
// State sales restrictions (ToT compliance sync). Where this can't ship/sell.
|
|
113
|
+
let restrictedStates: string[] = [];
|
|
114
|
+
const restrictedRaw = mf("compliance", "restricted_states");
|
|
115
|
+
if (restrictedRaw) {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(restrictedRaw);
|
|
118
|
+
restrictedStates = Array.isArray(parsed) ? parsed : Object.keys(parsed);
|
|
119
|
+
} catch {
|
|
120
|
+
/* not JSON — ignore */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Legal/safety disclaimer (scraped from the store's own PDP by the migration),
|
|
125
|
+
// per product category — sub-ohm on salts, battery on devices, etc. Selected by
|
|
126
|
+
// product_type (or the compliance.product_category metafield); default fallback.
|
|
127
|
+
const chrome = await Astro.locals.content.getChrome();
|
|
128
|
+
const productCategory = product.product_type || mf("compliance", "product_category");
|
|
129
|
+
const disclaimer =
|
|
130
|
+
(productCategory && chrome.disclaimers?.[productCategory]) || chrome.disclaimer;
|
|
131
|
+
|
|
132
|
+
// Full review content (migrated from the store's reviews app). The aggregate
|
|
133
|
+
// (reviewAvg/reviewCount above) comes from metafields; this is the per-review text.
|
|
134
|
+
const reviews = await tot.getReviews(product.handle);
|
|
135
|
+
function reviewDate(iso?: string): string {
|
|
136
|
+
if (!iso) return "";
|
|
137
|
+
const d = new Date(iso);
|
|
138
|
+
return Number.isNaN(d.getTime())
|
|
139
|
+
? ""
|
|
140
|
+
: d.toLocaleDateString(tenant.locale, { year: "numeric", month: "short", day: "numeric" });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const onSale = isOnSale(product);
|
|
144
|
+
const available = isAvailable(product);
|
|
145
|
+
|
|
146
|
+
// FoxyCart add-to-cart (Track A). Pre-build each variant's cart payload server-
|
|
147
|
+
// side so the island can render a signed, tamper-proof form. In `signed` mode a
|
|
148
|
+
// missing secret means we render NO cart (never a tamperable form); the
|
|
149
|
+
// `unsigned-demo` mode needs no secret. Drafts still get a cart (preview links).
|
|
150
|
+
let foxyCart:
|
|
151
|
+
| { action: string; byVariantId: Record<string, VariantCartPayload> }
|
|
152
|
+
| undefined;
|
|
153
|
+
if (commerce) {
|
|
154
|
+
const secret = await readCartSecret(commerce);
|
|
155
|
+
if (commerce.mode !== "signed" || secret) {
|
|
156
|
+
const entries = await Promise.all(
|
|
157
|
+
product.variants.map(
|
|
158
|
+
async (v) =>
|
|
159
|
+
[
|
|
160
|
+
v.id,
|
|
161
|
+
await buildVariantCartPayload(product, v, {
|
|
162
|
+
mode: commerce.mode,
|
|
163
|
+
secret,
|
|
164
|
+
}),
|
|
165
|
+
] as const,
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
foxyCart = { action: commerce.cartUrl, byVariantId: Object.fromEntries(entries) };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const snapshot = {
|
|
173
|
+
handle: product.handle,
|
|
174
|
+
title: product.title,
|
|
175
|
+
price: fromPrice(product),
|
|
176
|
+
currency: product.price_range.currency,
|
|
177
|
+
vendor: product.vendor,
|
|
178
|
+
img: product.images[0]?.url,
|
|
179
|
+
rehosted: product.images[0]?.rehosted,
|
|
180
|
+
seed: product.images[0]?.id ?? product.handle,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const productJsonLd: Record<string, unknown> = productLd(product, tenant, canonicalBase);
|
|
184
|
+
// Enrich with real aggregate rating + reviews for rich results (SEO).
|
|
185
|
+
if (reviewCount > 0 && reviewAvg > 0) {
|
|
186
|
+
productJsonLd.aggregateRating = {
|
|
187
|
+
"@type": "AggregateRating",
|
|
188
|
+
ratingValue: reviewAvg,
|
|
189
|
+
reviewCount,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (reviews.length > 0) {
|
|
193
|
+
productJsonLd.review = reviews.slice(0, 10).map((r) => ({
|
|
194
|
+
"@type": "Review",
|
|
195
|
+
reviewRating: { "@type": "Rating", ratingValue: r.rating, bestRating: 5 },
|
|
196
|
+
author: { "@type": "Person", name: r.author },
|
|
197
|
+
...(r.title ? { name: r.title } : {}),
|
|
198
|
+
reviewBody: r.body,
|
|
199
|
+
...(r.date ? { datePublished: r.date } : {}),
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
const jsonLd = [productJsonLd, breadcrumbLd(crumbs, canonicalBase)];
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
<Layout
|
|
206
|
+
title={product.seo.title || product.title}
|
|
207
|
+
description={product.seo.description || truncate(product.description_text, 160)}
|
|
208
|
+
path={`/products/${product.handle}`}
|
|
209
|
+
ogType="product"
|
|
210
|
+
ogImage={ogImage}
|
|
211
|
+
noindex={isDraft}
|
|
212
|
+
jsonLd={jsonLd}
|
|
213
|
+
>
|
|
214
|
+
<div class="container-prose pt-6">
|
|
215
|
+
<Breadcrumbs crumbs={crumbs} />
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
{isDraft && (
|
|
219
|
+
<div class="container-prose">
|
|
220
|
+
<p class="mt-4 rounded-[var(--radius-md)] border border-[var(--color-accent)] bg-[color-mix(in_srgb,var(--color-accent)_8%,transparent)] px-4 py-3 text-sm text-[var(--color-text)]">
|
|
221
|
+
Draft preview — this product isn't published and won't appear in listings or search.
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
|
|
226
|
+
<div class="container-prose grid gap-10 py-8 md:grid-cols-2 lg:gap-16">
|
|
227
|
+
{/* Gallery (island) */}
|
|
228
|
+
<div>
|
|
229
|
+
<ImageGallery
|
|
230
|
+
client:idle
|
|
231
|
+
images={product.images}
|
|
232
|
+
title={product.title}
|
|
233
|
+
productHandle={product.handle}
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
{/* Buy column */}
|
|
238
|
+
<div class="md:pt-2">
|
|
239
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
240
|
+
{onSale && <Badge variant="sale" label="Sale" />}
|
|
241
|
+
{!onSale && isNew(product) && <Badge variant="new" label="New" />}
|
|
242
|
+
{!available && <Badge variant="soldout" label="Sold out" />}
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
{product.vendor && (
|
|
246
|
+
<p class="eyebrow mt-4">{product.vendor}</p>
|
|
247
|
+
)}
|
|
248
|
+
<h1 class="display-md mt-1.5">
|
|
249
|
+
{product.title}
|
|
250
|
+
</h1>
|
|
251
|
+
|
|
252
|
+
{/* Review badge — REAL aggregate (rating + count) from the store's reviews
|
|
253
|
+
app, mirrored to product metafields. Individual review content is not
|
|
254
|
+
migrated in Phase 1 (see LIMITATIONS.md / bonus round). */}
|
|
255
|
+
{reviewCount > 0 && reviewAvg > 0 && (
|
|
256
|
+
<div class="mt-3 flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
|
257
|
+
<span class="text-[var(--color-accent)]" aria-hidden="true">
|
|
258
|
+
{"★".repeat(Math.round(reviewAvg))}{"☆".repeat(5 - Math.round(reviewAvg))}
|
|
259
|
+
</span>
|
|
260
|
+
<span>{reviewAvg.toFixed(1)} · {reviewCount} {reviewCount === 1 ? "review" : "reviews"}</span>
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{shortDescription && (
|
|
265
|
+
<p class="mt-4 max-w-prose text-[1.02rem] leading-relaxed text-[var(--color-text)]">
|
|
266
|
+
{shortDescription}
|
|
267
|
+
</p>
|
|
268
|
+
)}
|
|
269
|
+
|
|
270
|
+
{compliance?.nicotineWarning && (
|
|
271
|
+
<div class="mt-6">
|
|
272
|
+
<NicotineWarning variant="block" />
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{restrictedStates.length > 0 && (
|
|
277
|
+
<div class="mt-4 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm" role="note">
|
|
278
|
+
<p class="font-display font-medium text-[var(--color-text)]">Where we can ship this</p>
|
|
279
|
+
<p class="mt-1 text-[var(--color-muted)]">
|
|
280
|
+
Due to state laws, this product can't ship to: {restrictedStates.join(", ")}.
|
|
281
|
+
</p>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{/* Variant selector island owns live price/SKU/availability + buy CTA. */}
|
|
286
|
+
<div class="mt-7">
|
|
287
|
+
<VariantSelector
|
|
288
|
+
client:load
|
|
289
|
+
variants={product.variants}
|
|
290
|
+
options={product.options}
|
|
291
|
+
currency={product.price_range.currency}
|
|
292
|
+
locale={tenant.locale}
|
|
293
|
+
foxyCart={foxyCart}
|
|
294
|
+
/>
|
|
295
|
+
<button
|
|
296
|
+
type="button"
|
|
297
|
+
class="wishlist-toggle group mt-3 inline-flex w-full items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] py-2.5 font-display text-sm font-medium transition-colors hover:border-[var(--color-sale)] hover:text-[var(--color-sale)] aria-pressed:border-[var(--color-sale)] aria-pressed:text-[var(--color-sale)]"
|
|
298
|
+
aria-pressed="false"
|
|
299
|
+
aria-label={`Save ${product.title}`}
|
|
300
|
+
data-wishlist={JSON.stringify(snapshot)}
|
|
301
|
+
>
|
|
302
|
+
<svg class="wishlist-heart h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">
|
|
303
|
+
<path d="M12 21s-7.5-4.6-10-9.2C.4 8.6 2 5 5.5 5 8 5 9.4 6.6 12 9c2.6-2.4 4-4 6.5-4C22 5 23.6 8.6 22 11.8 19.5 16.4 12 21 12 21z" />
|
|
304
|
+
</svg>
|
|
305
|
+
<span class="wl-label">Save for later</span>
|
|
306
|
+
</button>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
{/* Rich description + attribute lines — matches the source store's PDP:
|
|
310
|
+
the longer description, then plain "Label: value" lines (no spec table). */}
|
|
311
|
+
<div class="mt-9 border-t border-[var(--color-border)] pt-7">
|
|
312
|
+
<div class="rte text-[var(--color-text)]" set:html={product.description_html} />
|
|
313
|
+
{(flavorTags.length > 0 || vgPercentage || bottleType) && (
|
|
314
|
+
<div class="mt-5 space-y-1.5 text-sm leading-relaxed">
|
|
315
|
+
{flavorTags.length > 0 && (
|
|
316
|
+
<p>
|
|
317
|
+
<span class="text-[var(--color-muted)]">Flavors:</span>{" "}
|
|
318
|
+
{flavorTags.map((f, i) => (
|
|
319
|
+
<>
|
|
320
|
+
{i > 0 && <span class="text-[var(--color-border)]"> | </span>}
|
|
321
|
+
{f.href ? (
|
|
322
|
+
<a href={withBase(basePath, f.href)} class="link-accent">{f.label}</a>
|
|
323
|
+
) : (
|
|
324
|
+
<span>{f.label}</span>
|
|
325
|
+
)}
|
|
326
|
+
</>
|
|
327
|
+
))}
|
|
328
|
+
</p>
|
|
329
|
+
)}
|
|
330
|
+
{vgPercentage && (
|
|
331
|
+
<p><span class="text-[var(--color-muted)]">VG Percentage:</span> {vgPercentage}</p>
|
|
332
|
+
)}
|
|
333
|
+
{bottleType && (
|
|
334
|
+
<p><span class="text-[var(--color-muted)]">Bottle Type:</span> {bottleType}</p>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
{/* Safety & legal (scraped disclaimer) + Shipping & returns. The source
|
|
341
|
+
store shows no attribute/spec table — attributes appear as lines above. */}
|
|
342
|
+
<div class="mt-8 border-t border-[var(--color-border)]">
|
|
343
|
+
{disclaimer && (
|
|
344
|
+
<details class="accordion group">
|
|
345
|
+
<summary class="accordion__summary">
|
|
346
|
+
<span>Safety & legal</span>
|
|
347
|
+
<span class="accordion__chevron" aria-hidden="true">+</span>
|
|
348
|
+
</summary>
|
|
349
|
+
<div class="accordion__body rte text-sm leading-relaxed text-[var(--color-muted)]" set:html={disclaimer} />
|
|
350
|
+
</details>
|
|
351
|
+
)}
|
|
352
|
+
|
|
353
|
+
<details class="accordion group">
|
|
354
|
+
<summary class="accordion__summary">
|
|
355
|
+
<span>Shipping & returns</span>
|
|
356
|
+
<span class="accordion__chevron" aria-hidden="true">+</span>
|
|
357
|
+
</summary>
|
|
358
|
+
<div class="accordion__body text-sm leading-relaxed text-[var(--color-text)]">
|
|
359
|
+
<p>
|
|
360
|
+
Delivery times, rates, and returns are covered in our policy —
|
|
361
|
+
including the adult-signature (21+) requirement on every order.
|
|
362
|
+
</p>
|
|
363
|
+
<a
|
|
364
|
+
href={withBase(basePath, "/shipping-returns")}
|
|
365
|
+
class="link-accent mt-3 inline-block font-medium"
|
|
366
|
+
>
|
|
367
|
+
Shipping & returns policy
|
|
368
|
+
</a>
|
|
369
|
+
</div>
|
|
370
|
+
</details>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
{/* Customer reviews — real content migrated from the store's reviews app. */}
|
|
376
|
+
{reviews.length > 0 && (
|
|
377
|
+
<Section
|
|
378
|
+
title="Customer reviews"
|
|
379
|
+
eyebrow={`${reviewAvg.toFixed(1)} ★ · ${reviewCount || reviews.length} review${(reviewCount || reviews.length) === 1 ? "" : "s"}`}
|
|
380
|
+
>
|
|
381
|
+
<ul class="grid gap-5 md:grid-cols-2">
|
|
382
|
+
{reviews.map((r) => (
|
|
383
|
+
<li class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
|
|
384
|
+
<div class="flex items-center justify-between gap-3">
|
|
385
|
+
<span class="text-[var(--color-accent)]" aria-label={`${r.rating} out of 5 stars`}>
|
|
386
|
+
{"★".repeat(Math.round(r.rating))}<span class="text-[var(--color-border)]">{"★".repeat(5 - Math.round(r.rating))}</span>
|
|
387
|
+
</span>
|
|
388
|
+
{r.verifiedBuyer && (
|
|
389
|
+
<span class="font-mono text-[0.6rem] uppercase tracking-[0.12em] text-[var(--color-success)]">
|
|
390
|
+
Verified buyer
|
|
391
|
+
</span>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
{r.title && <p class="mt-2.5 font-display font-semibold leading-snug">{r.title}</p>}
|
|
395
|
+
<p class="mt-1.5 text-sm leading-relaxed text-[var(--color-text)]">{r.body}</p>
|
|
396
|
+
<p class="mt-3 text-xs text-[var(--color-muted)]">
|
|
397
|
+
{r.author}{reviewDate(r.date) ? ` · ${reviewDate(r.date)}` : ""}
|
|
398
|
+
</p>
|
|
399
|
+
</li>
|
|
400
|
+
))}
|
|
401
|
+
</ul>
|
|
402
|
+
</Section>
|
|
403
|
+
)}
|
|
404
|
+
|
|
405
|
+
{/* Related */}
|
|
406
|
+
{related.length > 0 && (
|
|
407
|
+
<Section title="You may also like" eyebrow="More from the line" tone="surface">
|
|
408
|
+
<div class="grid grid-cols-2 gap-5 lg:grid-cols-4">
|
|
409
|
+
{related.map((p, i) => (
|
|
410
|
+
<ProductCard product={p} position={i} list="related" />
|
|
411
|
+
))}
|
|
412
|
+
</div>
|
|
413
|
+
</Section>
|
|
414
|
+
)}
|
|
415
|
+
|
|
416
|
+
{/* Recently viewed (client island; records this product, shows the rest). */}
|
|
417
|
+
<RecentlyViewed
|
|
418
|
+
client:idle
|
|
419
|
+
locale={tenant.locale}
|
|
420
|
+
current={{
|
|
421
|
+
handle: product.handle,
|
|
422
|
+
title: product.title,
|
|
423
|
+
price: fromPrice(product),
|
|
424
|
+
currency: product.price_range.currency,
|
|
425
|
+
vendor: product.vendor,
|
|
426
|
+
img: product.images[0]?.url,
|
|
427
|
+
rehosted: product.images[0]?.rehosted,
|
|
428
|
+
seed: product.images[0]?.id ?? product.handle,
|
|
429
|
+
}}
|
|
430
|
+
/>
|
|
431
|
+
|
|
432
|
+
{/* PDP-view analytics event. Phase 1: dispatched to a window event that the
|
|
433
|
+
no-op sink (or a Phase 2 real sink) can subscribe to. No-op by default. */}
|
|
434
|
+
<script type="application/json" id="pdp-meta" set:html={JSON.stringify({ type: "pdp_view", handle: product.handle })}></script>
|
|
435
|
+
<script is:inline nonce={Astro.locals.cspNonce}>
|
|
436
|
+
try {
|
|
437
|
+
const el = document.getElementById("pdp-meta");
|
|
438
|
+
if (el?.textContent) {
|
|
439
|
+
window.dispatchEvent(new CustomEvent("tot:analytics", { detail: JSON.parse(el.textContent) }));
|
|
440
|
+
}
|
|
441
|
+
} catch {}
|
|
442
|
+
</script>
|
|
443
|
+
</Layout>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tenant robots.txt. Allow all, keep crawlers out of the parameterized
|
|
3
|
+
* /search results, and point at this tenant's sitemap (canonical base is
|
|
4
|
+
* resolved per-request from the Host).
|
|
5
|
+
*/
|
|
6
|
+
import type { APIContext } from "astro";
|
|
7
|
+
|
|
8
|
+
export async function GET(context: APIContext): Promise<Response> {
|
|
9
|
+
const base = context.locals.canonicalBase.replace(/\/$/, "");
|
|
10
|
+
|
|
11
|
+
const body = `User-agent: *
|
|
12
|
+
Allow: /
|
|
13
|
+
Disallow: /search
|
|
14
|
+
|
|
15
|
+
Sitemap: ${base}/sitemap.xml
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
return new Response(body, {
|
|
19
|
+
headers: {
|
|
20
|
+
"content-type": "text/plain; charset=utf-8",
|
|
21
|
+
"cache-control": "public, max-age=3600",
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* /saved — the wishlist page. Content is client-rendered from localStorage
|
|
4
|
+
* (SavedList island); noindex since it's per-device and has no stable content.
|
|
5
|
+
*/
|
|
6
|
+
import Layout from "@/layouts/Layout.astro";
|
|
7
|
+
import Breadcrumbs from "@/components/Breadcrumbs.astro";
|
|
8
|
+
import SavedList from "@/components/islands/SavedList.tsx";
|
|
9
|
+
|
|
10
|
+
const { tenant } = Astro.locals;
|
|
11
|
+
// Commerce-only route: marketing tenants have no saved-items surface.
|
|
12
|
+
if (!Astro.locals.features.savedItems) return new Response(null, { status: 404 });
|
|
13
|
+
const crumbs = [
|
|
14
|
+
{ label: tenant.displayName, href: "/" },
|
|
15
|
+
{ label: "Saved", href: "/saved" },
|
|
16
|
+
];
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<Layout
|
|
20
|
+
title="Saved items"
|
|
21
|
+
description="Products you've saved for later."
|
|
22
|
+
path="/saved"
|
|
23
|
+
noindex
|
|
24
|
+
>
|
|
25
|
+
<div class="container-prose pt-6">
|
|
26
|
+
<Breadcrumbs crumbs={crumbs} />
|
|
27
|
+
</div>
|
|
28
|
+
<div class="container-prose py-8">
|
|
29
|
+
<header class="mb-8" data-reveal>
|
|
30
|
+
<p class="eyebrow eyebrow--tick mb-3">Your list</p>
|
|
31
|
+
<h1 class="display-xl">Saved</h1>
|
|
32
|
+
</header>
|
|
33
|
+
<SavedList client:only="preact" locale={tenant.locale} />
|
|
34
|
+
</div>
|
|
35
|
+
</Layout>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* /search — dual-purpose route (brief §5 Search).
|
|
4
|
+
*
|
|
5
|
+
* 1. `?format=json` — returns `{ products: TypeaheadDoc[] }` for the header
|
|
6
|
+
* typeahead island. Search runs through the ToTClient (D1 FTS5 when bound, else
|
|
7
|
+
* a Fuse index over the snapshot) over ACTIVE products only (drafts never surface).
|
|
8
|
+
* 2. Otherwise — the full results page: a no-JS <form>, the echoed query, a
|
|
9
|
+
* result count, and a product grid. Results pages are `noindex` (standard
|
|
10
|
+
* practice — thin, parameterized, duplicative).
|
|
11
|
+
*/
|
|
12
|
+
import Layout from "@/layouts/Layout.astro";
|
|
13
|
+
import Breadcrumbs from "@/components/Breadcrumbs.astro";
|
|
14
|
+
import ProductCard from "@/components/ProductCard.astro";
|
|
15
|
+
import EmptyState from "@/components/EmptyState.astro";
|
|
16
|
+
import Section from "@/components/Section.astro";
|
|
17
|
+
import { toTypeaheadDoc } from "@/lib/search";
|
|
18
|
+
import { breadcrumbLd } from "@/lib/jsonld";
|
|
19
|
+
import { withBase } from "@/lib/basePath";
|
|
20
|
+
|
|
21
|
+
const { tenant, tot, canonicalBase, basePath } = Astro.locals;
|
|
22
|
+
// Commerce-only route (page + typeahead JSON): off for marketing tenants.
|
|
23
|
+
if (!Astro.locals.features.productSearch) return new Response(null, { status: 404 });
|
|
24
|
+
const url = new URL(Astro.request.url);
|
|
25
|
+
const q = (url.searchParams.get("q") ?? "").trim();
|
|
26
|
+
const isJson = url.searchParams.get("format") === "json";
|
|
27
|
+
|
|
28
|
+
// Search goes through the ToTClient: D1 FTS5 when a D1 binding is wired, else a
|
|
29
|
+
// Fuse index over the bundled snapshot (FixtureToTClient). It returns full
|
|
30
|
+
// products, so the HTML grid renders directly and the typeahead just projects to
|
|
31
|
+
// the compact shape. See epics/edge-read-replica.md.
|
|
32
|
+
|
|
33
|
+
// --- JSON path: powers the header typeahead island. Must return BEFORE render.
|
|
34
|
+
if (isJson) {
|
|
35
|
+
const docs = (await tot.search(q, 8)).map(toTypeaheadDoc);
|
|
36
|
+
return new Response(JSON.stringify({ products: docs }), {
|
|
37
|
+
headers: { "content-type": "application/json" },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// --- HTML path.
|
|
42
|
+
const results = q ? await tot.search(q, 24) : [];
|
|
43
|
+
const featured = q ? [] : await tot.getFeatured(8);
|
|
44
|
+
|
|
45
|
+
const crumbs = [
|
|
46
|
+
{ label: tenant.displayName, href: "/" },
|
|
47
|
+
{ label: "Search", href: "/search" },
|
|
48
|
+
];
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
<Layout
|
|
52
|
+
title={q ? `Search: ${q}` : "Search"}
|
|
53
|
+
description={`Search the ${tenant.displayName} catalog.`}
|
|
54
|
+
path="/search"
|
|
55
|
+
noindex={true}
|
|
56
|
+
jsonLd={breadcrumbLd(crumbs, canonicalBase)}
|
|
57
|
+
>
|
|
58
|
+
<div class="container-prose pt-6">
|
|
59
|
+
<Breadcrumbs crumbs={crumbs} />
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{/* Search hero + no-JS form (works without the island). */}
|
|
63
|
+
<header class="container-prose pb-2 pt-8">
|
|
64
|
+
<p class="eyebrow eyebrow--tick mb-3">Search</p>
|
|
65
|
+
<h1 class="display-xl">
|
|
66
|
+
{q ? <>Results for “{q}”</> : "What are you after?"}
|
|
67
|
+
</h1>
|
|
68
|
+
<form
|
|
69
|
+
method="get"
|
|
70
|
+
action={withBase(basePath, "/search")}
|
|
71
|
+
role="search"
|
|
72
|
+
class="mt-7 flex max-w-xl items-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-2 focus-within:border-[var(--color-primary)]"
|
|
73
|
+
>
|
|
74
|
+
<svg
|
|
75
|
+
width="18"
|
|
76
|
+
height="18"
|
|
77
|
+
viewBox="0 0 24 24"
|
|
78
|
+
fill="none"
|
|
79
|
+
stroke="currentColor"
|
|
80
|
+
stroke-width="2"
|
|
81
|
+
stroke-linecap="round"
|
|
82
|
+
aria-hidden="true"
|
|
83
|
+
class="shrink-0 text-[var(--color-muted)]"
|
|
84
|
+
>
|
|
85
|
+
<circle cx="11" cy="11" r="7"></circle>
|
|
86
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
87
|
+
</svg>
|
|
88
|
+
<label class="sr-only" for="search-input">Search products</label>
|
|
89
|
+
<input
|
|
90
|
+
id="search-input"
|
|
91
|
+
type="search"
|
|
92
|
+
name="q"
|
|
93
|
+
value={q}
|
|
94
|
+
autocomplete="off"
|
|
95
|
+
placeholder="Search products"
|
|
96
|
+
class="w-full min-w-0 bg-transparent font-body text-text placeholder:text-muted focus:outline-none"
|
|
97
|
+
/>
|
|
98
|
+
<button
|
|
99
|
+
type="submit"
|
|
100
|
+
class="shrink-0 rounded-full bg-[var(--color-primary)] px-5 py-1.5 font-display text-sm font-medium text-[var(--color-primary-contrast)] transition-transform hover:-translate-y-0.5"
|
|
101
|
+
>
|
|
102
|
+
Search
|
|
103
|
+
</button>
|
|
104
|
+
</form>
|
|
105
|
+
{q && (
|
|
106
|
+
<p class="mt-4 text-sm text-[var(--color-muted)]">
|
|
107
|
+
{results.length} {results.length === 1 ? "result" : "results"}
|
|
108
|
+
</p>
|
|
109
|
+
)}
|
|
110
|
+
</header>
|
|
111
|
+
|
|
112
|
+
{q ? (
|
|
113
|
+
results.length === 0 ? (
|
|
114
|
+
<EmptyState
|
|
115
|
+
title={`No matches for “${q}”`}
|
|
116
|
+
body="Nothing in the catalog answers to that — yet. Try a broader word, a category, or browse the full range."
|
|
117
|
+
ctaLabel="Browse collections"
|
|
118
|
+
ctaHref="/collections"
|
|
119
|
+
/>
|
|
120
|
+
) : (
|
|
121
|
+
<section class="bg-[var(--color-bg)] py-[clamp(2rem,5vw,3.5rem)]">
|
|
122
|
+
<div class="container-prose">
|
|
123
|
+
<div class="grid grid-cols-2 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
|
124
|
+
{results.map((p, i) => (
|
|
125
|
+
<ProductCard product={p} position={i} list="search" />
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</section>
|
|
130
|
+
)
|
|
131
|
+
) : (
|
|
132
|
+
<>
|
|
133
|
+
<EmptyState
|
|
134
|
+
title="Start with a word"
|
|
135
|
+
body="Search by name, maker, or material. Or wander through the collections below."
|
|
136
|
+
ctaLabel="Browse collections"
|
|
137
|
+
ctaHref="/collections"
|
|
138
|
+
/>
|
|
139
|
+
{featured.length > 0 && (
|
|
140
|
+
<Section title="While you're here" eyebrow="Featured" tone="surface">
|
|
141
|
+
<div class="grid grid-cols-2 gap-5 lg:grid-cols-4">
|
|
142
|
+
{featured.map((p, i) => (
|
|
143
|
+
<ProductCard product={p} position={i} list="search-featured" />
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
</Section>
|
|
147
|
+
)}
|
|
148
|
+
</>
|
|
149
|
+
)}
|
|
150
|
+
</Layout>
|