@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,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <MobileNav> — hamburger button + slide-in drawer for small screens.
|
|
3
|
+
*
|
|
4
|
+
* Interactive island (Preact). The parent Header renders this inside a
|
|
5
|
+
* `md:hidden` wrapper; we also add `md:hidden` defensively so it never shows on
|
|
6
|
+
* desktop even if mounted standalone.
|
|
7
|
+
*
|
|
8
|
+
* A11y (WCAG 2.2 AA): hamburger exposes aria-expanded/aria-controls; the drawer
|
|
9
|
+
* traps focus, closes on Escape and backdrop click, locks body scroll, and
|
|
10
|
+
* restores focus to the hamburger on close. Motion is guarded by
|
|
11
|
+
* prefers-reduced-motion.
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
|
14
|
+
|
|
15
|
+
interface NavChild {
|
|
16
|
+
label: string;
|
|
17
|
+
href: string;
|
|
18
|
+
}
|
|
19
|
+
interface NavItem {
|
|
20
|
+
label: string;
|
|
21
|
+
href: string;
|
|
22
|
+
children?: NavChild[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
nav: NavItem[];
|
|
27
|
+
announcement?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DRAWER_ID = "mobile-drawer";
|
|
31
|
+
|
|
32
|
+
function prefersReducedMotion(): boolean {
|
|
33
|
+
return (
|
|
34
|
+
typeof window !== "undefined" &&
|
|
35
|
+
typeof window.matchMedia === "function" &&
|
|
36
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default function MobileNav({ nav, announcement }: Props) {
|
|
41
|
+
const [open, setOpen] = useState(false);
|
|
42
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
43
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
44
|
+
const reduced = prefersReducedMotion();
|
|
45
|
+
|
|
46
|
+
const close = useCallback(() => setOpen(false), []);
|
|
47
|
+
|
|
48
|
+
// Body scroll lock while open.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!open) return;
|
|
51
|
+
const prev = document.body.style.overflow;
|
|
52
|
+
document.body.style.overflow = "hidden";
|
|
53
|
+
return () => {
|
|
54
|
+
document.body.style.overflow = prev;
|
|
55
|
+
};
|
|
56
|
+
}, [open]);
|
|
57
|
+
|
|
58
|
+
// Focus management: move focus into the panel on open, restore on close.
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (open) {
|
|
61
|
+
// Focus the first focusable element (the close button) once painted.
|
|
62
|
+
const id = window.requestAnimationFrame(() => {
|
|
63
|
+
const first = panelRef.current?.querySelector<HTMLElement>(
|
|
64
|
+
"[data-autofocus]",
|
|
65
|
+
);
|
|
66
|
+
first?.focus();
|
|
67
|
+
});
|
|
68
|
+
return () => window.cancelAnimationFrame(id);
|
|
69
|
+
}
|
|
70
|
+
// On close, return focus to the hamburger.
|
|
71
|
+
buttonRef.current?.focus();
|
|
72
|
+
return undefined;
|
|
73
|
+
}, [open]);
|
|
74
|
+
|
|
75
|
+
// Escape to close + focus trap (Tab cycling) while open.
|
|
76
|
+
const onKeyDown = useCallback(
|
|
77
|
+
(e: KeyboardEvent) => {
|
|
78
|
+
if (e.key === "Escape") {
|
|
79
|
+
e.stopPropagation();
|
|
80
|
+
close();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (e.key !== "Tab") return;
|
|
84
|
+
const panel = panelRef.current;
|
|
85
|
+
if (!panel) return;
|
|
86
|
+
const focusable = Array.from(
|
|
87
|
+
panel.querySelectorAll<HTMLElement>(
|
|
88
|
+
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
89
|
+
),
|
|
90
|
+
).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
|
91
|
+
if (focusable.length === 0) return;
|
|
92
|
+
const firstEl = focusable[0]!;
|
|
93
|
+
const lastEl = focusable[focusable.length - 1]!;
|
|
94
|
+
const active = document.activeElement as HTMLElement | null;
|
|
95
|
+
if (e.shiftKey) {
|
|
96
|
+
if (active === firstEl || !panel.contains(active)) {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
lastEl.focus();
|
|
99
|
+
}
|
|
100
|
+
} else if (active === lastEl) {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
firstEl.focus();
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
[close],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div class="md:hidden">
|
|
110
|
+
<button
|
|
111
|
+
ref={buttonRef}
|
|
112
|
+
type="button"
|
|
113
|
+
aria-label="Open menu"
|
|
114
|
+
aria-haspopup="dialog"
|
|
115
|
+
aria-expanded={open}
|
|
116
|
+
aria-controls={DRAWER_ID}
|
|
117
|
+
onClick={() => setOpen(true)}
|
|
118
|
+
class="inline-flex h-10 w-10 items-center justify-center rounded-md text-text transition-colors hover:bg-surface focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
|
119
|
+
>
|
|
120
|
+
<svg
|
|
121
|
+
width="22"
|
|
122
|
+
height="22"
|
|
123
|
+
viewBox="0 0 24 24"
|
|
124
|
+
fill="none"
|
|
125
|
+
stroke="currentColor"
|
|
126
|
+
stroke-width="1.75"
|
|
127
|
+
stroke-linecap="round"
|
|
128
|
+
aria-hidden="true"
|
|
129
|
+
>
|
|
130
|
+
<line x1="3" y1="6" x2="21" y2="6" />
|
|
131
|
+
<line x1="3" y1="12" x2="21" y2="12" />
|
|
132
|
+
<line x1="3" y1="18" x2="21" y2="18" />
|
|
133
|
+
</svg>
|
|
134
|
+
</button>
|
|
135
|
+
|
|
136
|
+
{open && (
|
|
137
|
+
<div
|
|
138
|
+
class="fixed inset-0 z-50"
|
|
139
|
+
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
|
|
140
|
+
onKeyDown={onKeyDown}
|
|
141
|
+
>
|
|
142
|
+
{/* Backdrop */}
|
|
143
|
+
<div
|
|
144
|
+
class={`absolute inset-0 bg-text/40 ${
|
|
145
|
+
reduced ? "" : "transition-opacity duration-200"
|
|
146
|
+
}`}
|
|
147
|
+
onClick={close}
|
|
148
|
+
aria-hidden="true"
|
|
149
|
+
/>
|
|
150
|
+
{/* Panel */}
|
|
151
|
+
<div
|
|
152
|
+
ref={panelRef}
|
|
153
|
+
id={DRAWER_ID}
|
|
154
|
+
role="dialog"
|
|
155
|
+
aria-modal="true"
|
|
156
|
+
aria-label="Site menu"
|
|
157
|
+
class="absolute inset-y-0 right-0 flex w-[85vw] max-w-sm flex-col overflow-y-auto bg-surface text-text shadow-xl"
|
|
158
|
+
style={
|
|
159
|
+
reduced
|
|
160
|
+
? undefined
|
|
161
|
+
: "transform:translateX(100%);animation:tot-slidein .22s ease-out forwards"
|
|
162
|
+
}
|
|
163
|
+
>
|
|
164
|
+
<div class="flex items-center justify-between border-b border-border px-5 py-4">
|
|
165
|
+
<span class="font-display text-sm font-medium uppercase tracking-[0.14em] text-muted">
|
|
166
|
+
Menu
|
|
167
|
+
</span>
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
data-autofocus
|
|
171
|
+
aria-label="Close menu"
|
|
172
|
+
onClick={close}
|
|
173
|
+
class="inline-flex h-10 w-10 items-center justify-center rounded-md text-text transition-colors hover:bg-border/40 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
|
174
|
+
>
|
|
175
|
+
<svg
|
|
176
|
+
width="22"
|
|
177
|
+
height="22"
|
|
178
|
+
viewBox="0 0 24 24"
|
|
179
|
+
fill="none"
|
|
180
|
+
stroke="currentColor"
|
|
181
|
+
stroke-width="1.75"
|
|
182
|
+
stroke-linecap="round"
|
|
183
|
+
aria-hidden="true"
|
|
184
|
+
>
|
|
185
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
186
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
187
|
+
</svg>
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{announcement && (
|
|
192
|
+
<p class="border-b border-border bg-primary px-5 py-2.5 text-center font-body text-sm text-primary-contrast">
|
|
193
|
+
{announcement}
|
|
194
|
+
</p>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
<nav aria-label="Mobile" class="flex-1 px-2 py-4">
|
|
198
|
+
<ul class="space-y-1">
|
|
199
|
+
{nav.map((item) => (
|
|
200
|
+
<li key={item.href}>
|
|
201
|
+
{item.children && item.children.length > 0 ? (
|
|
202
|
+
<div class="px-3 py-2">
|
|
203
|
+
<a
|
|
204
|
+
href={item.href}
|
|
205
|
+
class="block font-display text-base font-medium text-text hover:text-primary"
|
|
206
|
+
>
|
|
207
|
+
{item.label}
|
|
208
|
+
</a>
|
|
209
|
+
<ul class="mt-1 space-y-0.5 border-l border-border pl-3">
|
|
210
|
+
{item.children.map((child) => (
|
|
211
|
+
<li key={child.href}>
|
|
212
|
+
<a
|
|
213
|
+
href={child.href}
|
|
214
|
+
class="block rounded px-2 py-1.5 font-body text-sm text-muted hover:bg-border/40 hover:text-text"
|
|
215
|
+
>
|
|
216
|
+
{child.label}
|
|
217
|
+
</a>
|
|
218
|
+
</li>
|
|
219
|
+
))}
|
|
220
|
+
</ul>
|
|
221
|
+
</div>
|
|
222
|
+
) : (
|
|
223
|
+
<a
|
|
224
|
+
href={item.href}
|
|
225
|
+
class="block rounded px-3 py-2.5 font-display text-base font-medium text-text hover:bg-border/40 hover:text-primary"
|
|
226
|
+
>
|
|
227
|
+
{item.label}
|
|
228
|
+
</a>
|
|
229
|
+
)}
|
|
230
|
+
</li>
|
|
231
|
+
))}
|
|
232
|
+
</ul>
|
|
233
|
+
</nav>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <QuickView> — one global native <dialog> that any product card opens via a
|
|
3
|
+
* `[data-quickview]` button (event-delegated, so cards stay zero-JS). Shows a
|
|
4
|
+
* peek — image, price, colors, short description — and routes to the full PDP
|
|
5
|
+
* for variant selection. No cart action (Phase 1 browse-only). Native <dialog>
|
|
6
|
+
* gives us focus-trap + Esc + backdrop for free; we add backdrop-click + the
|
|
7
|
+
* reduced-motion-safe entrance via CSS.
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useRef, useState } from "preact/hooks";
|
|
10
|
+
import { formatPrice } from "@/lib/format";
|
|
11
|
+
import { placeholderDataUri, isUsableImageUrl } from "@/lib/placeholder";
|
|
12
|
+
import { withClientBase } from "@/lib/basePath";
|
|
13
|
+
|
|
14
|
+
interface QV {
|
|
15
|
+
handle: string;
|
|
16
|
+
title: string;
|
|
17
|
+
price: number;
|
|
18
|
+
currency: string;
|
|
19
|
+
vendor?: string;
|
|
20
|
+
desc?: string;
|
|
21
|
+
colors?: string[];
|
|
22
|
+
onSale?: boolean;
|
|
23
|
+
available?: boolean;
|
|
24
|
+
maxCompareAt?: number | null;
|
|
25
|
+
sku?: string | null;
|
|
26
|
+
images?: { url?: string; rehosted?: boolean; seed: string }[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface Props {
|
|
30
|
+
locale: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function imgSrc(im: { url?: string; rehosted?: boolean; seed: string }, title: string) {
|
|
34
|
+
return isUsableImageUrl(im.url, im.rehosted)
|
|
35
|
+
? im.url!
|
|
36
|
+
: placeholderDataUri({ seed: im.seed, width: 700, height: 875, label: title });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function QuickView({ locale }: Props) {
|
|
40
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
41
|
+
const [qv, setQv] = useState<QV | null>(null);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
function onClick(e: MouseEvent) {
|
|
45
|
+
const btn = (e.target as HTMLElement)?.closest?.("[data-quickview]");
|
|
46
|
+
if (!btn) return;
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
try {
|
|
49
|
+
setQv(JSON.parse(btn.getAttribute("data-quickview")!) as QV);
|
|
50
|
+
dialogRef.current?.showModal();
|
|
51
|
+
} catch {
|
|
52
|
+
/* malformed payload — ignore, card link still works */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
document.addEventListener("click", onClick);
|
|
56
|
+
return () => document.removeEventListener("click", onClick);
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const close = () => dialogRef.current?.close();
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<dialog
|
|
63
|
+
ref={dialogRef}
|
|
64
|
+
class="quickview m-auto w-[min(46rem,92vw)] rounded-[var(--radius-lg)] bg-[var(--color-surface)] p-0 text-[var(--color-text)] backdrop:bg-[color-mix(in_srgb,var(--color-text)_55%,transparent)]"
|
|
65
|
+
aria-label="Product quick view"
|
|
66
|
+
onClick={(e) => {
|
|
67
|
+
// Backdrop click closes (clicks inside the panel stop at the panel).
|
|
68
|
+
if (e.target === dialogRef.current) close();
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
{qv && (
|
|
72
|
+
<div class="grid gap-0 sm:grid-cols-2">
|
|
73
|
+
<div class="aspect-[4/5] overflow-hidden bg-[var(--color-border)] sm:rounded-l-[var(--radius-lg)]">
|
|
74
|
+
<img
|
|
75
|
+
src={imgSrc(qv.images?.[0] ?? { seed: qv.handle }, qv.title)}
|
|
76
|
+
alt={qv.title}
|
|
77
|
+
width={700}
|
|
78
|
+
height={875}
|
|
79
|
+
class="h-full w-full object-cover"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="relative p-6 sm:p-7">
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={close}
|
|
86
|
+
aria-label="Close quick view"
|
|
87
|
+
class="absolute right-3 top-3 grid h-9 w-9 place-items-center rounded-full text-[var(--color-muted)] hover:bg-[var(--color-bg)] hover:text-[var(--color-text)]"
|
|
88
|
+
>
|
|
89
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
|
90
|
+
<line x1="5" y1="5" x2="19" y2="19" /><line x1="19" y1="5" x2="5" y2="19" />
|
|
91
|
+
</svg>
|
|
92
|
+
</button>
|
|
93
|
+
|
|
94
|
+
{qv.vendor && (
|
|
95
|
+
<p class="font-mono text-[0.7rem] uppercase tracking-[0.14em] text-[var(--color-muted)]">
|
|
96
|
+
{qv.vendor}
|
|
97
|
+
</p>
|
|
98
|
+
)}
|
|
99
|
+
<h2 class="mt-1.5 font-display text-2xl font-semibold leading-tight">{qv.title}</h2>
|
|
100
|
+
|
|
101
|
+
<p class="mt-3 flex items-baseline gap-2">
|
|
102
|
+
<span class={`font-display text-xl tabular-nums ${qv.onSale ? "text-[var(--color-sale)]" : ""}`}>
|
|
103
|
+
{formatPrice(qv.price, qv.currency, locale)}
|
|
104
|
+
</span>
|
|
105
|
+
{qv.onSale && qv.maxCompareAt != null && (
|
|
106
|
+
<span class="text-sm text-[var(--color-muted)] line-through tabular-nums">
|
|
107
|
+
{formatPrice(qv.maxCompareAt, qv.currency, locale)}
|
|
108
|
+
</span>
|
|
109
|
+
)}
|
|
110
|
+
{qv.available === false && (
|
|
111
|
+
<span class="text-sm text-[var(--color-muted)]">· Sold out</span>
|
|
112
|
+
)}
|
|
113
|
+
</p>
|
|
114
|
+
|
|
115
|
+
{qv.colors && qv.colors.length > 0 && (
|
|
116
|
+
<p class="mt-3 text-sm text-[var(--color-muted)]">
|
|
117
|
+
{qv.colors.length} {qv.colors.length === 1 ? "color" : "colors"}: {qv.colors.join(", ")}
|
|
118
|
+
</p>
|
|
119
|
+
)}
|
|
120
|
+
|
|
121
|
+
{qv.desc && <p class="mt-4 text-sm leading-relaxed text-[var(--color-text)]">{qv.desc}</p>}
|
|
122
|
+
|
|
123
|
+
<a
|
|
124
|
+
href={withClientBase(`/products/${qv.handle}`)}
|
|
125
|
+
class="mt-6 inline-flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-primary)] px-5 py-2.5 font-display text-sm font-semibold text-[var(--color-primary-contrast)] transition-transform hover:gap-3"
|
|
126
|
+
>
|
|
127
|
+
View full details
|
|
128
|
+
<span aria-hidden="true">→</span>
|
|
129
|
+
</a>
|
|
130
|
+
{qv.sku && (
|
|
131
|
+
<p class="mt-4 font-mono text-[0.7rem] text-[var(--color-muted)]">SKU {qv.sku}</p>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</dialog>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recently viewed (Shopify-style discovery rail). A small client island that
|
|
3
|
+
* records the current product to localStorage on a PDP and renders the rest as
|
|
4
|
+
* a quiet strip. No server round-trip — snapshots are self-contained, so it
|
|
5
|
+
* works against fixtures or live ToT identically. Renders nothing until it has
|
|
6
|
+
* something to show (no layout shift, no empty state noise).
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useState } from "preact/hooks";
|
|
9
|
+
import { formatPrice } from "@/lib/format";
|
|
10
|
+
import { placeholderDataUri, isUsableImageUrl } from "@/lib/placeholder";
|
|
11
|
+
import { withClientBase } from "@/lib/basePath";
|
|
12
|
+
|
|
13
|
+
export interface RVItem {
|
|
14
|
+
handle: string;
|
|
15
|
+
title: string;
|
|
16
|
+
price: number;
|
|
17
|
+
currency: string;
|
|
18
|
+
vendor?: string;
|
|
19
|
+
img?: string;
|
|
20
|
+
rehosted?: boolean;
|
|
21
|
+
seed: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
/** When set (on a PDP), this product is recorded and excluded from the rail. */
|
|
26
|
+
current?: RVItem;
|
|
27
|
+
locale: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
max?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const KEY = "tot:recently-viewed";
|
|
33
|
+
const CAP = 12;
|
|
34
|
+
|
|
35
|
+
function read(): RVItem[] {
|
|
36
|
+
try {
|
|
37
|
+
const raw = localStorage.getItem(KEY);
|
|
38
|
+
if (!raw) return [];
|
|
39
|
+
const arr = JSON.parse(raw);
|
|
40
|
+
return Array.isArray(arr) ? (arr as RVItem[]) : [];
|
|
41
|
+
} catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default function RecentlyViewed({
|
|
47
|
+
current,
|
|
48
|
+
locale,
|
|
49
|
+
title = "Recently viewed",
|
|
50
|
+
max = 5,
|
|
51
|
+
}: Props) {
|
|
52
|
+
const [items, setItems] = useState<RVItem[]>([]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
let list = read();
|
|
56
|
+
if (current) {
|
|
57
|
+
list = [current, ...list.filter((i) => i.handle !== current.handle)].slice(
|
|
58
|
+
0,
|
|
59
|
+
CAP,
|
|
60
|
+
);
|
|
61
|
+
try {
|
|
62
|
+
localStorage.setItem(KEY, JSON.stringify(list));
|
|
63
|
+
} catch {
|
|
64
|
+
/* storage full / disabled — degrade silently */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Exclude the current product from what we *show*.
|
|
68
|
+
setItems(
|
|
69
|
+
list.filter((i) => i.handle !== current?.handle).slice(0, max),
|
|
70
|
+
);
|
|
71
|
+
}, [current?.handle]);
|
|
72
|
+
|
|
73
|
+
if (items.length === 0) return null;
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<section
|
|
77
|
+
class="container-prose border-t border-[var(--color-border)] py-12"
|
|
78
|
+
aria-label={title}
|
|
79
|
+
>
|
|
80
|
+
<p class="eyebrow eyebrow--tick mb-5">{title}</p>
|
|
81
|
+
<ul class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-5">
|
|
82
|
+
{items.map((p) => {
|
|
83
|
+
const src = isUsableImageUrl(p.img, p.rehosted)
|
|
84
|
+
? p.img!
|
|
85
|
+
: placeholderDataUri({
|
|
86
|
+
seed: p.seed,
|
|
87
|
+
width: 400,
|
|
88
|
+
height: 500,
|
|
89
|
+
label: p.title,
|
|
90
|
+
});
|
|
91
|
+
return (
|
|
92
|
+
<li>
|
|
93
|
+
<a href={withClientBase(`/products/${p.handle}`)} class="group block">
|
|
94
|
+
<div class="aspect-[4/5] overflow-hidden rounded-md bg-[var(--color-border)] ring-1 ring-[var(--color-border)]">
|
|
95
|
+
<img
|
|
96
|
+
src={src}
|
|
97
|
+
alt={p.title}
|
|
98
|
+
width={400}
|
|
99
|
+
height={500}
|
|
100
|
+
loading="lazy"
|
|
101
|
+
class="h-full w-full object-cover transition-transform duration-500 motion-safe:group-hover:scale-[1.03]"
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
{p.vendor && (
|
|
105
|
+
<p class="mt-2.5 font-mono text-[0.62rem] uppercase tracking-[0.14em] text-[var(--color-muted)]">
|
|
106
|
+
{p.vendor}
|
|
107
|
+
</p>
|
|
108
|
+
)}
|
|
109
|
+
<h3 class="mt-1 font-display text-[0.9rem] font-medium leading-snug transition-colors motion-safe:group-hover:text-[var(--color-primary)]">
|
|
110
|
+
{p.title}
|
|
111
|
+
</h3>
|
|
112
|
+
<p class="mt-1 font-display text-sm tabular-nums">
|
|
113
|
+
{formatPrice(p.price, p.currency, locale)}
|
|
114
|
+
</p>
|
|
115
|
+
</a>
|
|
116
|
+
</li>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
</ul>
|
|
120
|
+
</section>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <SavedList> — renders the wishlist from localStorage on the /saved page.
|
|
3
|
+
* Client-only (the list lives in the browser). Each item links to its PDP and
|
|
4
|
+
* can be removed inline; empty state invites browsing. Phase 2 swaps the store
|
|
5
|
+
* for an account-backed list without touching this component.
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useState } from "preact/hooks";
|
|
8
|
+
import {
|
|
9
|
+
readWishlist,
|
|
10
|
+
toggleSaved,
|
|
11
|
+
WISHLIST_EVENT,
|
|
12
|
+
type SavedItem,
|
|
13
|
+
} from "@/lib/wishlist";
|
|
14
|
+
import { formatPrice } from "@/lib/format";
|
|
15
|
+
import { placeholderDataUri, isUsableImageUrl } from "@/lib/placeholder";
|
|
16
|
+
import { withClientBase } from "@/lib/basePath";
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
locale: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function SavedList({ locale }: Props) {
|
|
23
|
+
const [items, setItems] = useState<SavedItem[] | null>(null);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const refresh = () => setItems(readWishlist());
|
|
27
|
+
refresh();
|
|
28
|
+
window.addEventListener(WISHLIST_EVENT, refresh);
|
|
29
|
+
return () => window.removeEventListener(WISHLIST_EVENT, refresh);
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
// null = pre-hydration (avoid a flash of the empty state).
|
|
33
|
+
if (items === null) return null;
|
|
34
|
+
|
|
35
|
+
if (items.length === 0) {
|
|
36
|
+
return (
|
|
37
|
+
<div class="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-16 text-center">
|
|
38
|
+
<p class="display-md">Nothing saved yet</p>
|
|
39
|
+
<p class="mx-auto mt-3 max-w-md text-[var(--color-muted)]">
|
|
40
|
+
Tap the heart on any product to keep it here for later. Saved items
|
|
41
|
+
live on this device.
|
|
42
|
+
</p>
|
|
43
|
+
<a
|
|
44
|
+
href={withClientBase("/collections/bestsellers")}
|
|
45
|
+
class="mt-6 inline-flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-primary)] px-5 py-2.5 font-display text-sm font-semibold text-[var(--color-primary-contrast)]"
|
|
46
|
+
>
|
|
47
|
+
Browse the shop <span aria-hidden="true">→</span>
|
|
48
|
+
</a>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<ul class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
|
55
|
+
{items.map((p) => {
|
|
56
|
+
const src = isUsableImageUrl(p.img, p.rehosted)
|
|
57
|
+
? p.img!
|
|
58
|
+
: placeholderDataUri({ seed: p.seed, width: 400, height: 500, label: p.title });
|
|
59
|
+
return (
|
|
60
|
+
<li class="group relative">
|
|
61
|
+
<div class="relative aspect-[4/5] overflow-hidden rounded-md bg-[var(--color-border)] ring-1 ring-[var(--color-border)]">
|
|
62
|
+
<img
|
|
63
|
+
src={src}
|
|
64
|
+
alt={p.title}
|
|
65
|
+
width={400}
|
|
66
|
+
height={500}
|
|
67
|
+
loading="lazy"
|
|
68
|
+
class="h-full w-full object-cover transition-transform duration-500 motion-safe:group-hover:scale-[1.03]"
|
|
69
|
+
/>
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
onClick={() => toggleSaved(p)}
|
|
73
|
+
aria-label={`Remove ${p.title} from saved`}
|
|
74
|
+
class="absolute right-2 top-2 z-10 grid h-9 w-9 place-items-center rounded-full bg-[color-mix(in_srgb,var(--color-surface)_88%,transparent)] text-[var(--color-sale)] backdrop-blur"
|
|
75
|
+
>
|
|
76
|
+
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
77
|
+
<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" />
|
|
78
|
+
</svg>
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
<a href={withClientBase(`/products/${p.handle}`)} class="mt-3 block after:absolute after:inset-0">
|
|
82
|
+
{p.vendor && (
|
|
83
|
+
<span class="block font-mono text-[0.62rem] uppercase tracking-[0.14em] text-[var(--color-muted)]">
|
|
84
|
+
{p.vendor}
|
|
85
|
+
</span>
|
|
86
|
+
)}
|
|
87
|
+
<span class="mt-1 block font-display text-[0.95rem] font-medium leading-snug">
|
|
88
|
+
{p.title}
|
|
89
|
+
</span>
|
|
90
|
+
<span class="mt-1 block font-display text-sm tabular-nums">
|
|
91
|
+
{formatPrice(p.price, p.currency, locale)}
|
|
92
|
+
</span>
|
|
93
|
+
</a>
|
|
94
|
+
</li>
|
|
95
|
+
);
|
|
96
|
+
})}
|
|
97
|
+
</ul>
|
|
98
|
+
);
|
|
99
|
+
}
|