@tokenoftrust/storefront-runner 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +58 -0
- package/README.md +42 -0
- package/apps/storefront/astro.config.mjs +66 -0
- package/apps/storefront/dev-plugins/dev-loop-state.mjs +147 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +238 -0
- package/apps/storefront/env.d.ts +162 -0
- package/apps/storefront/migrations/0000_baseline.sql +54 -0
- package/apps/storefront/migrations/0001_products_fts.sql +9 -0
- package/apps/storefront/migrations/README.md +54 -0
- package/apps/storefront/migrations/meta/0000_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/0001_snapshot.json +347 -0
- package/apps/storefront/migrations/meta/_journal.json +20 -0
- package/apps/storefront/package.json +42 -0
- package/apps/storefront/public/brand/northwind-logo-dark.svg +4 -0
- package/apps/storefront/public/brand/northwind-logo-light.svg +4 -0
- package/apps/storefront/public/brand/northwind-og.svg +13 -0
- package/apps/storefront/public/favicon.svg +5 -0
- package/apps/storefront/public/js/dashboard-team.js +147 -0
- package/apps/storefront/public/js/dashboard-vendor-search.js +115 -0
- package/apps/storefront/public/shared/commerce-chrome.css +369 -0
- package/apps/storefront/src/components/Badge.astro +34 -0
- package/apps/storefront/src/components/Breadcrumbs.astro +45 -0
- package/apps/storefront/src/components/CollectionCard.astro +56 -0
- package/apps/storefront/src/components/EmptyState.astro +55 -0
- package/apps/storefront/src/components/Img.astro +68 -0
- package/apps/storefront/src/components/ProductCard.astro +228 -0
- package/apps/storefront/src/components/Section.astro +39 -0
- package/apps/storefront/src/components/Seo.astro +70 -0
- package/apps/storefront/src/components/WidgetFrame.astro +34 -0
- package/apps/storefront/src/components/admin/AdminAIWorkflowsTab.astro +531 -0
- package/apps/storefront/src/components/admin/AdminCustomersTab.astro +597 -0
- package/apps/storefront/src/components/admin/AdminFoxyCartSettingsPanel.astro +227 -0
- package/apps/storefront/src/components/admin/AdminFulfillmentTab.astro +465 -0
- package/apps/storefront/src/components/admin/AdminProductsTab.astro +901 -0
- package/apps/storefront/src/components/admin/AdminReportingTab.astro +435 -0
- package/apps/storefront/src/components/admin/AdminSettingsTab.astro +782 -0
- package/apps/storefront/src/components/admin/orders/OrdersWorkspace.astro +1223 -0
- package/apps/storefront/src/components/auth/AuthBadge.astro +44 -0
- package/apps/storefront/src/components/chrome/AnnouncementBar.astro +108 -0
- package/apps/storefront/src/components/chrome/MegaMenu.astro +26 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +201 -0
- package/apps/storefront/src/components/chrome/SiteFooter.astro +201 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +234 -0
- package/apps/storefront/src/components/chrome/UtilityNav.astro +79 -0
- package/apps/storefront/src/components/commerce/ArticleCard.astro +242 -0
- package/apps/storefront/src/components/commerce/BrandGrid.astro +175 -0
- package/apps/storefront/src/components/commerce/FoxyLoader.astro +23 -0
- package/apps/storefront/src/components/commerce/FoxySidecartStyles.astro +61 -0
- package/apps/storefront/src/components/commerce/PriceDisplay.astro +91 -0
- package/apps/storefront/src/components/commerce/ProductCarousel.astro +197 -0
- package/apps/storefront/src/components/commerce/ProductGrid.astro +75 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +58 -0
- package/apps/storefront/src/components/commerce/ReviewList.astro +54 -0
- package/apps/storefront/src/components/commerce/ReviewSummary.astro +44 -0
- package/apps/storefront/src/components/commerce/TagFilterCloud.astro +149 -0
- package/apps/storefront/src/components/compliance/AdultSignatureNotice.astro +37 -0
- package/apps/storefront/src/components/compliance/ComplianceNotice.astro +99 -0
- package/apps/storefront/src/components/compliance/ExciseTaxNotice.astro +45 -0
- package/apps/storefront/src/components/compliance/NicotineWarning.astro +33 -0
- package/apps/storefront/src/components/compliance/PactActNotice.astro +35 -0
- package/apps/storefront/src/components/compliance/Prop65Warning.astro +80 -0
- package/apps/storefront/src/components/compliance/PurchaseLimitNotice.astro +38 -0
- package/apps/storefront/src/components/compliance/ShippingRestrictionNotice.astro +35 -0
- package/apps/storefront/src/components/compliance/StateEligibilityNotice.astro +53 -0
- package/apps/storefront/src/components/home/Hero.astro +164 -0
- package/apps/storefront/src/components/islands/AgeGate.tsx +109 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +237 -0
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +73 -0
- package/apps/storefront/src/components/islands/MobileNav.tsx +239 -0
- package/apps/storefront/src/components/islands/QuickView.tsx +138 -0
- package/apps/storefront/src/components/islands/RecentlyViewed.tsx +122 -0
- package/apps/storefront/src/components/islands/SavedList.tsx +99 -0
- package/apps/storefront/src/components/islands/SearchTypeahead.tsx +248 -0
- package/apps/storefront/src/components/islands/VariantSelector.tsx +375 -0
- package/apps/storefront/src/components/marketing/CardGrid.astro +72 -0
- package/apps/storefront/src/components/marketing/CodeSample.astro +20 -0
- package/apps/storefront/src/components/marketing/CtaBand.astro +24 -0
- package/apps/storefront/src/components/marketing/Faq.astro +37 -0
- package/apps/storefront/src/components/marketing/Integrations.astro +63 -0
- package/apps/storefront/src/components/marketing/MarketingBlocks.astro +53 -0
- package/apps/storefront/src/components/marketing/MarketingCtas.astro +48 -0
- package/apps/storefront/src/components/marketing/MarketingHero.astro +72 -0
- package/apps/storefront/src/components/marketing/ProofStrip.astro +37 -0
- package/apps/storefront/src/components/marketing/SplitCompare.astro +53 -0
- package/apps/storefront/src/components/marketing/Steps.astro +36 -0
- package/apps/storefront/src/components/marketing/Testimonials.astro +38 -0
- package/apps/storefront/src/components/marketing/TrustBar.astro +22 -0
- package/apps/storefront/src/components/membership/IllustrativeLabel.astro +56 -0
- package/apps/storefront/src/components/membership/TierCards.astro +164 -0
- package/apps/storefront/src/components/membership/TierComparisonTable.astro +148 -0
- package/apps/storefront/src/components/plp/ActiveFilters.astro +79 -0
- package/apps/storefront/src/components/plp/FacetSidebar.astro +199 -0
- package/apps/storefront/src/components/plp/Pagination.astro +99 -0
- package/apps/storefront/src/components/plp/SortSelect.astro +64 -0
- package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +95 -0
- package/apps/storefront/src/components/subscription/HowItWorksSteps.astro +67 -0
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +67 -0
- package/apps/storefront/src/components/subscription/SavingsExplainer.astro +101 -0
- package/apps/storefront/src/config/adminTargetTenant.ts +28 -0
- package/apps/storefront/src/config/devTenantSeed.ts +78 -0
- package/apps/storefront/src/config/devTenants.ts +16 -0
- package/apps/storefront/src/config/kvTenantResolver.ts +87 -0
- package/apps/storefront/src/config/resolver.ts +102 -0
- package/apps/storefront/src/config/tenants.ts +87 -0
- package/apps/storefront/src/layouts/Layout.astro +368 -0
- package/apps/storefront/src/lib/admin/README.md +122 -0
- package/apps/storefront/src/lib/admin/ai/approvalMode.ts +21 -0
- package/apps/storefront/src/lib/admin/ai/askEnvelope.ts +48 -0
- package/apps/storefront/src/lib/admin/ai/audit.ts +14 -0
- package/apps/storefront/src/lib/admin/ai/index.ts +6 -0
- package/apps/storefront/src/lib/admin/ai/operationalRuns.ts +155 -0
- package/apps/storefront/src/lib/admin/ai/recommendation.ts +20 -0
- package/apps/storefront/src/lib/admin/checkoutStyleInput.ts +172 -0
- package/apps/storefront/src/lib/admin/checkoutStyleTarget.ts +65 -0
- package/apps/storefront/src/lib/admin/envelope.ts +10 -0
- package/apps/storefront/src/lib/admin/evidence/evidenceReference.ts +32 -0
- package/apps/storefront/src/lib/admin/memoizeByEnvelope.ts +27 -0
- package/apps/storefront/src/lib/admin/orderClient.ts +146 -0
- package/apps/storefront/src/lib/admin/seed.ts +54 -0
- package/apps/storefront/src/lib/admin/services/mock/aiWorkflows.ts +59 -0
- package/apps/storefront/src/lib/admin/services/mock/customers.ts +86 -0
- package/apps/storefront/src/lib/admin/services/mock/evidence.ts +99 -0
- package/apps/storefront/src/lib/admin/services/mock/foxyCartSettings.ts +78 -0
- package/apps/storefront/src/lib/admin/services/mock/fulfillment.ts +62 -0
- package/apps/storefront/src/lib/admin/services/mock/index.ts +12 -0
- package/apps/storefront/src/lib/admin/services/mock/orders.ts +184 -0
- package/apps/storefront/src/lib/admin/services/mock/products.ts +105 -0
- package/apps/storefront/src/lib/admin/services/mock/reporting.ts +51 -0
- package/apps/storefront/src/lib/admin/services/mock/settings.ts +70 -0
- package/apps/storefront/src/lib/admin/services/mock/tenants.ts +29 -0
- package/apps/storefront/src/lib/admin/services/orders.ts +300 -0
- package/apps/storefront/src/lib/admin/viewmodels/aiWorkflows.ts +67 -0
- package/apps/storefront/src/lib/admin/viewmodels/customers.ts +56 -0
- package/apps/storefront/src/lib/admin/viewmodels/fulfillment.ts +49 -0
- package/apps/storefront/src/lib/admin/viewmodels/orders.ts +92 -0
- package/apps/storefront/src/lib/admin/viewmodels/products.ts +74 -0
- package/apps/storefront/src/lib/admin/viewmodels/reporting.ts +34 -0
- package/apps/storefront/src/lib/admin/viewmodels/settings.ts +64 -0
- package/apps/storefront/src/lib/adminTenantEnvelope.ts +90 -0
- package/apps/storefront/src/lib/analytics/index.ts +47 -0
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +136 -0
- package/apps/storefront/src/lib/auth/devAuth.ts +90 -0
- package/apps/storefront/src/lib/auth/gatePage.ts +81 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +341 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +119 -0
- package/apps/storefront/src/lib/auth/route.ts +146 -0
- package/apps/storefront/src/lib/auth/session.ts +203 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +503 -0
- package/apps/storefront/src/lib/basePath.ts +41 -0
- package/apps/storefront/src/lib/cfImage.ts +55 -0
- package/apps/storefront/src/lib/chrome/model.ts +244 -0
- package/apps/storefront/src/lib/chrome/parseConfig.ts +34 -0
- package/apps/storefront/src/lib/colors.ts +97 -0
- package/apps/storefront/src/lib/commerce/foxyCartSettings.ts +253 -0
- package/apps/storefront/src/lib/content-edit/applyDiff.ts +91 -0
- package/apps/storefront/src/lib/content-edit/client.ts +230 -0
- package/apps/storefront/src/lib/content-edit/index.ts +14 -0
- package/apps/storefront/src/lib/content-edit/route.ts +25 -0
- package/apps/storefront/src/lib/content-edit/types.ts +19 -0
- package/apps/storefront/src/lib/cspStaticHashes.ts +24 -0
- package/apps/storefront/src/lib/d1/catalog.ts +366 -0
- package/apps/storefront/src/lib/dashboard/staffAdmission.ts +43 -0
- package/apps/storefront/src/lib/dashboard/staffVendorAccess.ts +449 -0
- package/apps/storefront/src/lib/dashboard/tenantSelection.ts +136 -0
- package/apps/storefront/src/lib/dashboard/vendorQuery.ts +61 -0
- package/apps/storefront/src/lib/demoGate.ts +113 -0
- package/apps/storefront/src/lib/dev/activityIngestToken.ts +78 -0
- package/apps/storefront/src/lib/dev/activityStore.ts +72 -0
- package/apps/storefront/src/lib/dev/ai-widget-client.js +308 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +14 -0
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +155 -0
- package/apps/storefront/src/lib/dev/devDraft.ts +133 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +169 -0
- package/apps/storefront/src/lib/dev/livePreview.ts +77 -0
- package/apps/storefront/src/lib/dev/runtimeStore.ts +120 -0
- package/apps/storefront/src/lib/dev/storeStatus.ts +142 -0
- package/apps/storefront/src/lib/diag/redact.ts +20 -0
- package/apps/storefront/src/lib/edgeCache.ts +128 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +62 -0
- package/apps/storefront/src/lib/env.ts +60 -0
- package/apps/storefront/src/lib/fixtures/sampleProducts.ts +130 -0
- package/apps/storefront/src/lib/format.ts +38 -0
- package/apps/storefront/src/lib/foxyCommerce.ts +49 -0
- package/apps/storefront/src/lib/imageAttrs.ts +55 -0
- package/apps/storefront/src/lib/jsonld.ts +166 -0
- package/apps/storefront/src/lib/maintenance.ts +71 -0
- package/apps/storefront/src/lib/membership/model.ts +97 -0
- package/apps/storefront/src/lib/memoryKv.ts +32 -0
- package/apps/storefront/src/lib/messaging/messagesClient.ts +143 -0
- package/apps/storefront/src/lib/placeholder.ts +99 -0
- package/apps/storefront/src/lib/previewReload.ts +125 -0
- package/apps/storefront/src/lib/priceBuckets.ts +33 -0
- package/apps/storefront/src/lib/rawChrome.ts +505 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +136 -0
- package/apps/storefront/src/lib/search/index.ts +74 -0
- package/apps/storefront/src/lib/sections.ts +129 -0
- package/apps/storefront/src/lib/securityHeaders.ts +31 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +289 -0
- package/apps/storefront/src/lib/storyblok/provider.ts +607 -0
- package/apps/storefront/src/lib/subscription/model.ts +68 -0
- package/apps/storefront/src/lib/tenantRouting.ts +91 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +177 -0
- package/apps/storefront/src/lib/tot/d1Client.ts +62 -0
- package/apps/storefront/src/lib/tot/query.ts +249 -0
- package/apps/storefront/src/lib/tot/totClientInterface.ts +30 -0
- package/apps/storefront/src/lib/url.ts +142 -0
- package/apps/storefront/src/lib/wishlist.ts +63 -0
- package/apps/storefront/src/middleware/index.ts +455 -0
- package/apps/storefront/src/pages/404.astro +61 -0
- package/apps/storefront/src/pages/[...slug].astro +90 -0
- package/apps/storefront/src/pages/admin.astro +1675 -0
- package/apps/storefront/src/pages/api/admin/orders/[id].ts +53 -0
- package/apps/storefront/src/pages/api/admin/orders.ts +71 -0
- package/apps/storefront/src/pages/api/auth/logout.ts +21 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +107 -0
- package/apps/storefront/src/pages/api/auth/send.ts +85 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +135 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +44 -0
- package/apps/storefront/src/pages/api/checkout-style.css.ts +43 -0
- package/apps/storefront/src/pages/api/cli/policy.ts +70 -0
- package/apps/storefront/src/pages/api/content-edit/ai-edit.ts +164 -0
- package/apps/storefront/src/pages/api/csp-report.ts +25 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +124 -0
- package/apps/storefront/src/pages/api/dashboard/vendor-search.ts +120 -0
- package/apps/storefront/src/pages/api/dev/activity.ts +124 -0
- package/apps/storefront/src/pages/api/dev/cli-signin-code.ts +73 -0
- package/apps/storefront/src/pages/api/dev/diff.ts +68 -0
- package/apps/storefront/src/pages/api/dev/file.ts +57 -0
- package/apps/storefront/src/pages/api/dev/locate.ts +125 -0
- package/apps/storefront/src/pages/api/dev/publish.ts +128 -0
- package/apps/storefront/src/pages/api/dev/tree.ts +70 -0
- package/apps/storefront/src/pages/api/dev/write.ts +116 -0
- package/apps/storefront/src/pages/api/newsletter.ts +104 -0
- package/apps/storefront/src/pages/api/request-access.ts +172 -0
- package/apps/storefront/src/pages/auth/login.astro +149 -0
- package/apps/storefront/src/pages/auth/magic.astro +105 -0
- package/apps/storefront/src/pages/capabilities.astro +292 -0
- package/apps/storefront/src/pages/collections/[handle].astro +177 -0
- package/apps/storefront/src/pages/collections/index.astro +59 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +100 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/team.astro +154 -0
- package/apps/storefront/src/pages/dashboard/index.astro +160 -0
- package/apps/storefront/src/pages/index.astro +290 -0
- package/apps/storefront/src/pages/llms.txt.ts +92 -0
- package/apps/storefront/src/pages/products/[handle].astro +402 -0
- package/apps/storefront/src/pages/robots.txt.ts +24 -0
- package/apps/storefront/src/pages/saved.astro +35 -0
- package/apps/storefront/src/pages/search.astro +150 -0
- package/apps/storefront/src/pages/sitemap.xml.ts +78 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +1280 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +229 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +144 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +116 -0
- package/apps/storefront/src/pages/style-guide/index.astro +98 -0
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +57 -0
- package/apps/storefront/src/styles/global.css +292 -0
- package/apps/storefront/src/themes/reference.ts +68 -0
- package/apps/storefront/src/themes/schema.ts +111 -0
- package/apps/storefront/src/themes/tenants/index.ts +51 -0
- package/apps/storefront/tsconfig.json +16 -0
- package/apps/storefront/vitest.config.ts +22 -0
- package/package.json +26 -0
- package/packages/public-runtime/LICENSE +58 -0
- package/packages/public-runtime/package.json +22 -0
- package/packages/public-runtime/src/binary-path.ts +68 -0
- package/packages/public-runtime/src/capabilities.ts +89 -0
- package/packages/public-runtime/src/catalog-api.ts +81 -0
- package/packages/public-runtime/src/catalog-d1.ts +192 -0
- package/packages/public-runtime/src/checkout-style-publish.ts +200 -0
- package/packages/public-runtime/src/checkout-style.ts +375 -0
- package/packages/public-runtime/src/csp.ts +109 -0
- package/packages/public-runtime/src/customization-preview.ts +197 -0
- package/packages/public-runtime/src/customization-reconcile.ts +63 -0
- package/packages/public-runtime/src/customization-runtime.ts +157 -0
- package/packages/public-runtime/src/customization-scripts.ts +64 -0
- package/packages/public-runtime/src/customization-versioning.ts +95 -0
- package/packages/public-runtime/src/foxy-orders.ts +136 -0
- package/packages/public-runtime/src/foxy.ts +196 -0
- package/packages/public-runtime/src/hot-standby-readiness.ts +140 -0
- package/packages/public-runtime/src/index.ts +34 -0
- package/packages/public-runtime/src/product.ts +159 -0
- package/packages/public-runtime/src/search.ts +45 -0
- package/packages/public-runtime/src/tenant-assets.ts +444 -0
- package/packages/public-runtime/src/tenant.ts +384 -0
- package/packages/public-runtime/tsconfig.json +8 -0
- package/packages/public-runtime/vitest.config.ts +8 -0
- package/pnpm-workspace.yaml +7 -0
- package/scripts/build/copy-tenant-assets.mjs +69 -0
- package/scripts/dev/ai-edit.mjs +183 -0
- package/scripts/dev/checkout-watch.mjs +116 -0
- package/scripts/dev/file-browser.mjs +159 -0
- package/scripts/dev/file-writer.mjs +141 -0
- package/scripts/dev/git-status.mjs +171 -0
- package/scripts/dev/locate-handler.mjs +163 -0
- package/scripts/dev/locate.mjs +112 -0
- package/scripts/dev/port-check.mjs +48 -0
- package/scripts/dev/safe-path.mjs +109 -0
- package/scripts/dev/shot.mjs +46 -0
- package/scripts/dev/tenant-source-root.mjs +76 -0
- package/scripts/dev/unified-diff.mjs +81 -0
- package/scripts/tot-dev.mjs +376 -0
- package/tenants/README.md +14 -0
- package/tenants/home/content/chrome.json +7 -0
- package/tenants/home/content/home.html +58 -0
- package/tenants/home/content/home.json +11 -0
- package/tenants/home/public/js/storefront.js +210 -0
- package/tenants/home/public/logo-wordmark.png +0 -0
- package/tenants/home/public/pages/storefront.css +212 -0
- package/tenants/home/theme.json +6 -0
- package/tsconfig.base.json +20 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* State/jurisdiction sale-eligibility DISPLAY notice. Presentational only — it
|
|
4
|
+
* SHOWS the tenant-configured eligibility messaging (which states a product is /
|
|
5
|
+
* isn't available in); it does NOT decide whether a given cart/address is
|
|
6
|
+
* eligible — that computation stays product-owned (decision
|
|
7
|
+
* compliance-widgets-platform-owned). Renders NOTHING when no eligibility data
|
|
8
|
+
* is supplied, so absent config never shows a placeholder.
|
|
9
|
+
*
|
|
10
|
+
* Message precedence: an explicit `note` wins; otherwise a sentence is composed
|
|
11
|
+
* from `restrictedStates` ("Not available for sale in: …") or, failing that,
|
|
12
|
+
* `eligibleStates` ("Available for sale in select states only: …").
|
|
13
|
+
*/
|
|
14
|
+
import type { StateEligibilityConfig } from "@tot/public-runtime";
|
|
15
|
+
import ComplianceNotice from "./ComplianceNotice.astro";
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
/** From tenant.compliance.stateEligibility. */
|
|
19
|
+
config?: StateEligibilityConfig;
|
|
20
|
+
variant?: "bar" | "block" | "inline" | "footer";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { config, variant = "block" } = Astro.props;
|
|
24
|
+
|
|
25
|
+
function compose(c: StateEligibilityConfig): string | null {
|
|
26
|
+
if (c.note && c.note.trim()) return c.note.trim();
|
|
27
|
+
const restricted = (c.restrictedStates ?? []).filter(Boolean);
|
|
28
|
+
if (restricted.length > 0) {
|
|
29
|
+
return `Not available for sale or shipment in: ${restricted.join(", ")}.`;
|
|
30
|
+
}
|
|
31
|
+
const eligible = (c.eligibleStates ?? []).filter(Boolean);
|
|
32
|
+
if (eligible.length > 0) {
|
|
33
|
+
return `Available for sale and shipment in select states only: ${eligible.join(", ")}.`;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const message = config ? compose(config) : null;
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
message && (
|
|
43
|
+
<ComplianceNotice
|
|
44
|
+
variant={variant}
|
|
45
|
+
tone="warning"
|
|
46
|
+
hook="state-eligibility"
|
|
47
|
+
label="State eligibility notice"
|
|
48
|
+
symbol="⚠"
|
|
49
|
+
>
|
|
50
|
+
{message}
|
|
51
|
+
</ComplianceNotice>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Homepage hero — the SIGNATURE moment (brief §6: "hero is a thesis", spend
|
|
4
|
+
* boldness in ONE place). Two treatments, by the tenant's brand.heroVariant:
|
|
5
|
+
* - "bold" (default): full-bleed primary background, light contrast text.
|
|
6
|
+
* - "light": hero on the page background, brand color as an accent on the
|
|
7
|
+
* headline + CTA — matches merchants whose own site is a light page with
|
|
8
|
+
* color accents. The brand adopter sets "light" when it captures a hero image.
|
|
9
|
+
* The feature image is the merchant's own hero slide (block.image) when adopted,
|
|
10
|
+
* else the hero collection's first product.
|
|
11
|
+
*/
|
|
12
|
+
import type { HeroBlock } from "@/lib/storyblok/content-model";
|
|
13
|
+
import type { CatalogProduct } from "@tot/public-runtime";
|
|
14
|
+
import Img from "@/components/Img.astro";
|
|
15
|
+
import { withBase } from "@/lib/basePath";
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
block: HeroBlock;
|
|
19
|
+
featureProduct?: CatalogProduct | null;
|
|
20
|
+
}
|
|
21
|
+
const { block, featureProduct } = Astro.props;
|
|
22
|
+
const { basePath } = Astro.locals;
|
|
23
|
+
const light = (Astro.locals.tenant?.theme_tokens?.brand?.heroVariant ?? "bold") === "light";
|
|
24
|
+
const prodImg = featureProduct?.images[0];
|
|
25
|
+
const heroImg = block.image;
|
|
26
|
+
const hasImage = Boolean(heroImg || (prodImg && featureProduct));
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
<section
|
|
30
|
+
class:list={[
|
|
31
|
+
"relative overflow-hidden",
|
|
32
|
+
light
|
|
33
|
+
? "bg-[var(--color-bg)] text-[var(--color-text)]"
|
|
34
|
+
: "bg-[var(--color-primary)] text-[var(--color-primary-contrast)]",
|
|
35
|
+
]}
|
|
36
|
+
>
|
|
37
|
+
<div
|
|
38
|
+
class:list={[
|
|
39
|
+
"container-prose grid items-center gap-10 py-[clamp(3.5rem,9vw,7rem)]",
|
|
40
|
+
hasImage && "md:grid-cols-[1.1fr_0.9fr]",
|
|
41
|
+
]}
|
|
42
|
+
>
|
|
43
|
+
<div data-reveal>
|
|
44
|
+
{
|
|
45
|
+
block.eyebrow && (
|
|
46
|
+
<p
|
|
47
|
+
class:list={[
|
|
48
|
+
"eyebrow eyebrow--tick mb-6",
|
|
49
|
+
light
|
|
50
|
+
? "!text-[var(--color-muted)]"
|
|
51
|
+
: "!text-[color-mix(in_srgb,var(--color-primary-contrast)_72%,transparent)]",
|
|
52
|
+
]}
|
|
53
|
+
>
|
|
54
|
+
{block.eyebrow}
|
|
55
|
+
</p>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
<h1 class="display-hero">
|
|
59
|
+
<span class:list={["block", light && "text-[var(--color-primary)]"]}>{block.headline}</span>
|
|
60
|
+
{
|
|
61
|
+
block.headlineAccent && (
|
|
62
|
+
<span class="relative block w-fit text-[var(--color-accent)]">
|
|
63
|
+
{block.headlineAccent}
|
|
64
|
+
<span
|
|
65
|
+
aria-hidden="true"
|
|
66
|
+
class="absolute -bottom-1 left-0 h-[3px] w-full origin-left bg-[var(--color-accent)]/55"
|
|
67
|
+
/>
|
|
68
|
+
</span>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
</h1>
|
|
72
|
+
{
|
|
73
|
+
block.subhead && (
|
|
74
|
+
<p
|
|
75
|
+
class:list={[
|
|
76
|
+
"mt-7 max-w-prose text-[1.1rem] leading-relaxed",
|
|
77
|
+
light
|
|
78
|
+
? "text-[var(--color-muted)]"
|
|
79
|
+
: "text-[color-mix(in_srgb,var(--color-primary-contrast)_82%,transparent)]",
|
|
80
|
+
]}
|
|
81
|
+
>
|
|
82
|
+
{block.subhead}
|
|
83
|
+
</p>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
{
|
|
87
|
+
block.ctaLabel && block.ctaHref && (
|
|
88
|
+
<a
|
|
89
|
+
href={withBase(basePath, block.ctaHref)}
|
|
90
|
+
class="group mt-9 inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-7 py-3 font-display text-[0.95rem] font-semibold text-white transition-transform duration-200 hover:-translate-y-0.5 focus-visible:-translate-y-0.5"
|
|
91
|
+
>
|
|
92
|
+
{block.ctaLabel}
|
|
93
|
+
<span aria-hidden="true" class="transition-transform duration-200 group-hover:translate-x-1">
|
|
94
|
+
→
|
|
95
|
+
</span>
|
|
96
|
+
</a>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
heroImg ? (
|
|
103
|
+
<div class="relative" data-reveal>
|
|
104
|
+
<div
|
|
105
|
+
class:list={[
|
|
106
|
+
"overflow-hidden rounded-[var(--radius-lg)] shadow-[var(--shadow-lg)] ring-1",
|
|
107
|
+
light ? "ring-[var(--color-border)]" : "ring-white/10",
|
|
108
|
+
]}
|
|
109
|
+
>
|
|
110
|
+
<img
|
|
111
|
+
src={heroImg}
|
|
112
|
+
alt={block.imageAlt ?? block.headline}
|
|
113
|
+
loading="eager"
|
|
114
|
+
class="h-full w-full object-cover"
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
) : (
|
|
119
|
+
prodImg &&
|
|
120
|
+
featureProduct && (
|
|
121
|
+
<div class="relative" data-reveal>
|
|
122
|
+
<div
|
|
123
|
+
class:list={[
|
|
124
|
+
"overflow-hidden rounded-[var(--radius-lg)] shadow-[var(--shadow-lg)] ring-1",
|
|
125
|
+
light ? "ring-[var(--color-border)]" : "ring-white/10",
|
|
126
|
+
]}
|
|
127
|
+
>
|
|
128
|
+
<Img
|
|
129
|
+
src={prodImg.url}
|
|
130
|
+
rehosted={prodImg.rehosted}
|
|
131
|
+
seed={prodImg.id ?? featureProduct.handle}
|
|
132
|
+
label={featureProduct.title}
|
|
133
|
+
alt={prodImg.alt ?? featureProduct.title}
|
|
134
|
+
width={prodImg.width ?? 1000}
|
|
135
|
+
height={prodImg.height ?? 1250}
|
|
136
|
+
priority={true}
|
|
137
|
+
sizes="(min-width: 768px) 42vw, 100vw"
|
|
138
|
+
class="h-full w-full object-cover"
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
<a
|
|
142
|
+
href={withBase(basePath, `/products/${featureProduct.handle}`)}
|
|
143
|
+
class="absolute bottom-4 left-4 right-4 flex items-center justify-between rounded-[var(--radius-md)] bg-[var(--color-surface)]/95 px-4 py-3 text-[var(--color-text)] backdrop-blur"
|
|
144
|
+
>
|
|
145
|
+
<span class="font-display text-sm font-medium">{featureProduct.title}</span>
|
|
146
|
+
<span class="eyebrow !text-[var(--color-accent)]">Shop →</span>
|
|
147
|
+
</a>
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<div
|
|
155
|
+
aria-hidden="true"
|
|
156
|
+
class:list={[
|
|
157
|
+
"pointer-events-none absolute inset-x-0 bottom-0 h-px",
|
|
158
|
+
light
|
|
159
|
+
? "bg-[var(--color-border)]"
|
|
160
|
+
: "bg-[color-mix(in_srgb,var(--color-primary-contrast)_18%,transparent)]",
|
|
161
|
+
]}
|
|
162
|
+
>
|
|
163
|
+
</div>
|
|
164
|
+
</section>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <AgeGate> — soft 21+ affirmation shown once per device for regulated tenants.
|
|
3
|
+
*
|
|
4
|
+
* IMPORTANT: this is the storefront's front-door affirmation only. The REAL
|
|
5
|
+
* identity/age verification is Token of Trust's product and is wired at a later
|
|
6
|
+
* phase (the "Yes" path is where that handoff will live). We deliberately do not
|
|
7
|
+
* reimplement verification here. Affirmation is remembered in localStorage.
|
|
8
|
+
*/
|
|
9
|
+
import { useEffect, useRef, useState } from "preact/hooks";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
minAge: number;
|
|
13
|
+
brand: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const KEY = "tot:age-affirmed";
|
|
17
|
+
|
|
18
|
+
export default function AgeGate({ minAge, brand }: Props) {
|
|
19
|
+
const [state, setState] = useState<"hidden" | "ask" | "denied">("hidden");
|
|
20
|
+
const yesRef = useRef<HTMLButtonElement>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let affirmed = false;
|
|
24
|
+
try {
|
|
25
|
+
affirmed = localStorage.getItem(KEY) === "yes";
|
|
26
|
+
} catch {
|
|
27
|
+
/* storage disabled — show the gate */
|
|
28
|
+
}
|
|
29
|
+
if (!affirmed) {
|
|
30
|
+
setState("ask");
|
|
31
|
+
document.documentElement.style.overflow = "hidden";
|
|
32
|
+
}
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
// preventScroll: the gate is a fixed overlay, but its host <div> is appended
|
|
37
|
+
// to the end of <body> (see IsolatedAgeGate). A plain focus() would scroll the
|
|
38
|
+
// underlying page down to that host — leaving the page at the bottom once the
|
|
39
|
+
// gate is dismissed. preventScroll keeps the a11y focus without the jump.
|
|
40
|
+
if (state === "ask") yesRef.current?.focus({ preventScroll: true });
|
|
41
|
+
if (state === "hidden") document.documentElement.style.overflow = "";
|
|
42
|
+
}, [state]);
|
|
43
|
+
|
|
44
|
+
const affirm = () => {
|
|
45
|
+
try {
|
|
46
|
+
localStorage.setItem(KEY, "yes");
|
|
47
|
+
} catch {
|
|
48
|
+
/* ignore */
|
|
49
|
+
}
|
|
50
|
+
setState("hidden");
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (state === "hidden") return null;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
class="fixed inset-0 z-[100] grid place-items-center bg-[color-mix(in_srgb,var(--color-text)_75%,transparent)] p-5 backdrop-blur-sm"
|
|
58
|
+
role="dialog"
|
|
59
|
+
aria-modal="true"
|
|
60
|
+
aria-labelledby="agegate-title"
|
|
61
|
+
>
|
|
62
|
+
<div class="w-[min(30rem,100%)] rounded-[var(--radius-lg)] bg-[var(--color-surface)] p-7 text-center shadow-[var(--shadow-lg)]">
|
|
63
|
+
{state === "ask" ? (
|
|
64
|
+
<>
|
|
65
|
+
<p class="eyebrow eyebrow--tick mx-auto mb-4 w-fit">Age verification</p>
|
|
66
|
+
<h2 id="agegate-title" class="display-md">
|
|
67
|
+
Are you {minAge} or older?
|
|
68
|
+
</h2>
|
|
69
|
+
<p class="mx-auto mt-3 max-w-sm text-[var(--color-muted)]">
|
|
70
|
+
{brand} sells age-restricted products. You must be {minAge}+ to enter.
|
|
71
|
+
By continuing you confirm your age — we verify it for real at checkout.
|
|
72
|
+
</p>
|
|
73
|
+
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-center">
|
|
74
|
+
<button
|
|
75
|
+
ref={yesRef}
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={affirm}
|
|
78
|
+
class="rounded-[var(--radius-md)] bg-[var(--color-primary)] px-6 py-3 font-display text-sm font-semibold text-[var(--color-primary-contrast)]"
|
|
79
|
+
>
|
|
80
|
+
Yes, I'm {minAge} or older
|
|
81
|
+
</button>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
onClick={() => setState("denied")}
|
|
85
|
+
class="rounded-[var(--radius-md)] border border-[var(--color-border)] px-6 py-3 font-display text-sm font-medium text-[var(--color-text)] hover:border-[var(--color-text)]"
|
|
86
|
+
>
|
|
87
|
+
No
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
</>
|
|
91
|
+
) : (
|
|
92
|
+
<>
|
|
93
|
+
<h2 id="agegate-title" class="display-md">Come back when you're {minAge}</h2>
|
|
94
|
+
<p class="mx-auto mt-3 max-w-sm text-[var(--color-muted)]">
|
|
95
|
+
You must be {minAge} or older to view this store. Please close this tab.
|
|
96
|
+
</p>
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => setState("ask")}
|
|
100
|
+
class="link-accent mt-6 inline-block font-display text-sm font-medium"
|
|
101
|
+
>
|
|
102
|
+
I entered my age incorrectly
|
|
103
|
+
</button>
|
|
104
|
+
</>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <ImageGallery> — PDP image gallery (main image + thumbnail strip).
|
|
3
|
+
*
|
|
4
|
+
* Interactive island (Preact). Thumbnails swap the main image; the main image
|
|
5
|
+
* has a tasteful click-to-toggle zoom (reduced-motion safe). The first image is
|
|
6
|
+
* the LCP candidate (eager / high priority / sync decode) until the user
|
|
7
|
+
* interacts.
|
|
8
|
+
*
|
|
9
|
+
* Cross-island wiring: listens on `window` for a `variant:image` CustomEvent
|
|
10
|
+
* `{ detail: { imageId } }` (dispatched by <VariantSelector>) and switches to
|
|
11
|
+
* the matching image when present.
|
|
12
|
+
*
|
|
13
|
+
* CLS-free: explicit width/height + aspect-ratio on every image. Dead fixture
|
|
14
|
+
* CDN URLs fall back to a deterministic placeholder.
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
17
|
+
import type { CatalogImage } from "@tot/public-runtime";
|
|
18
|
+
import { placeholderDataUri, isUsableImageUrl } from "@/lib/placeholder";
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
images: CatalogImage[];
|
|
22
|
+
title: string;
|
|
23
|
+
productHandle: string;
|
|
24
|
+
activeImageId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const FALLBACK_W = 1000;
|
|
28
|
+
const FALLBACK_H = 1250;
|
|
29
|
+
|
|
30
|
+
function srcFor(img: CatalogImage, title: string): string {
|
|
31
|
+
if (isUsableImageUrl(img.url, img.rehosted)) return img.url;
|
|
32
|
+
return placeholderDataUri({
|
|
33
|
+
seed: img.id || title,
|
|
34
|
+
width: img.width ?? FALLBACK_W,
|
|
35
|
+
height: img.height ?? FALLBACK_H,
|
|
36
|
+
label: title,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Responsive srcset for CDNs that accept `?width=` (our rehost target + Shopify's
|
|
41
|
+
// image CDN), so referenced Shopify images are responsive without rehosting.
|
|
42
|
+
const GALLERY_WIDTHS = [320, 480, 640, 960, 1280, 1600];
|
|
43
|
+
function srcsetFor(img: CatalogImage): string | undefined {
|
|
44
|
+
if (!isUsableImageUrl(img.url, img.rehosted)) return undefined;
|
|
45
|
+
const cdn = img.rehosted || /\/\/cdn\.shopify\.com\//.test(img.url) || /\/cdn\/shop\//.test(img.url);
|
|
46
|
+
if (!cdn) return undefined;
|
|
47
|
+
const max = (img.width ?? FALLBACK_W) * 2;
|
|
48
|
+
return GALLERY_WIDTHS.filter((w) => w <= max)
|
|
49
|
+
.map((w) => `${img.url}${img.url.includes("?") ? "&" : "?"}width=${w} ${w}w`)
|
|
50
|
+
.join(", ");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function prefersReducedMotion(): boolean {
|
|
54
|
+
return (
|
|
55
|
+
typeof window !== "undefined" &&
|
|
56
|
+
typeof window.matchMedia === "function" &&
|
|
57
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function ImageGallery({
|
|
62
|
+
images,
|
|
63
|
+
title,
|
|
64
|
+
productHandle,
|
|
65
|
+
activeImageId,
|
|
66
|
+
}: Props) {
|
|
67
|
+
const ordered = useMemo(
|
|
68
|
+
() => [...images].sort((a, b) => a.position - b.position),
|
|
69
|
+
[images],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const initialIndex = useMemo(() => {
|
|
73
|
+
if (activeImageId) {
|
|
74
|
+
const i = ordered.findIndex((im) => im.id === activeImageId);
|
|
75
|
+
if (i >= 0) return i;
|
|
76
|
+
}
|
|
77
|
+
return 0;
|
|
78
|
+
}, [ordered, activeImageId]);
|
|
79
|
+
|
|
80
|
+
const [index, setIndex] = useState(initialIndex);
|
|
81
|
+
const [zoomed, setZoomed] = useState(false);
|
|
82
|
+
// After the first interaction the LCP hint no longer applies to the new image.
|
|
83
|
+
const [interacted, setInteracted] = useState(false);
|
|
84
|
+
const reduced = prefersReducedMotion();
|
|
85
|
+
const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
86
|
+
|
|
87
|
+
const select = useCallback(
|
|
88
|
+
(i: number) => {
|
|
89
|
+
setIndex(i);
|
|
90
|
+
setZoomed(false);
|
|
91
|
+
setInteracted(true);
|
|
92
|
+
},
|
|
93
|
+
[],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// Cross-island: variant selector drives the active image.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
function onVariantImage(e: Event) {
|
|
99
|
+
const detail = (e as CustomEvent<{ imageId?: string }>).detail;
|
|
100
|
+
if (!detail?.imageId) return;
|
|
101
|
+
const i = ordered.findIndex((im) => im.id === detail.imageId);
|
|
102
|
+
if (i >= 0) {
|
|
103
|
+
setIndex(i);
|
|
104
|
+
setZoomed(false);
|
|
105
|
+
setInteracted(true);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
window.addEventListener("variant:image", onVariantImage);
|
|
109
|
+
return () => window.removeEventListener("variant:image", onVariantImage);
|
|
110
|
+
}, [ordered]);
|
|
111
|
+
|
|
112
|
+
// Keyboard nav on the thumbnail strip (roving via arrow keys).
|
|
113
|
+
const onThumbKeyDown = useCallback(
|
|
114
|
+
(e: KeyboardEvent, i: number) => {
|
|
115
|
+
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
const next = (i + 1) % ordered.length;
|
|
118
|
+
select(next);
|
|
119
|
+
thumbRefs.current[next]?.focus();
|
|
120
|
+
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
const prev = i <= 0 ? ordered.length - 1 : i - 1;
|
|
123
|
+
select(prev);
|
|
124
|
+
thumbRefs.current[prev]?.focus();
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
[ordered.length, select],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (ordered.length === 0) {
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
class="w-full overflow-hidden rounded-md"
|
|
134
|
+
style={`aspect-ratio:${FALLBACK_W}/${FALLBACK_H};background:var(--color-border)`}
|
|
135
|
+
>
|
|
136
|
+
<img
|
|
137
|
+
src={placeholderDataUri({
|
|
138
|
+
seed: productHandle,
|
|
139
|
+
width: FALLBACK_W,
|
|
140
|
+
height: FALLBACK_H,
|
|
141
|
+
label: title,
|
|
142
|
+
})}
|
|
143
|
+
alt={title}
|
|
144
|
+
width={FALLBACK_W}
|
|
145
|
+
height={FALLBACK_H}
|
|
146
|
+
decoding="sync"
|
|
147
|
+
fetchpriority="high"
|
|
148
|
+
loading="eager"
|
|
149
|
+
class="h-full w-full object-cover"
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const current = ordered[index] ?? ordered[0]!;
|
|
156
|
+
const w = current.width ?? FALLBACK_W;
|
|
157
|
+
const h = current.height ?? FALLBACK_H;
|
|
158
|
+
const isLcp = index === 0 && !interacted;
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div class="flex flex-col gap-3 md:flex-row-reverse md:gap-4">
|
|
162
|
+
{/* Main image */}
|
|
163
|
+
<div class="md:flex-1">
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
aria-label={zoomed ? "Zoom out" : "Zoom in"}
|
|
167
|
+
aria-pressed={zoomed}
|
|
168
|
+
onClick={() => setZoomed((z) => !z)}
|
|
169
|
+
class={`group block w-full overflow-hidden rounded-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary ${
|
|
170
|
+
zoomed ? "cursor-zoom-out" : "cursor-zoom-in"
|
|
171
|
+
}`}
|
|
172
|
+
style={`aspect-ratio:${w}/${h};background:var(--color-border)`}
|
|
173
|
+
>
|
|
174
|
+
<img
|
|
175
|
+
src={srcFor(current, title)}
|
|
176
|
+
srcset={srcsetFor(current)}
|
|
177
|
+
sizes="(min-width: 768px) 45vw, 100vw"
|
|
178
|
+
alt={current.alt ?? title}
|
|
179
|
+
width={w}
|
|
180
|
+
height={h}
|
|
181
|
+
loading={isLcp ? "eager" : "lazy"}
|
|
182
|
+
decoding={isLcp ? "sync" : "async"}
|
|
183
|
+
fetchpriority={isLcp ? "high" : "auto"}
|
|
184
|
+
class={`h-full w-full object-cover ${
|
|
185
|
+
reduced ? "" : "transition-transform duration-300"
|
|
186
|
+
} ${zoomed ? "scale-150" : "scale-100"}`}
|
|
187
|
+
/>
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{/* Thumbnail strip */}
|
|
192
|
+
{ordered.length > 1 && (
|
|
193
|
+
<ul
|
|
194
|
+
aria-label="Product images"
|
|
195
|
+
class="flex gap-2 overflow-x-auto md:w-20 md:flex-col md:overflow-visible"
|
|
196
|
+
>
|
|
197
|
+
{ordered.map((img, i) => {
|
|
198
|
+
const tw = img.width ?? FALLBACK_W;
|
|
199
|
+
const th = img.height ?? FALLBACK_H;
|
|
200
|
+
return (
|
|
201
|
+
<li key={img.id} class="shrink-0">
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
ref={(el) => {
|
|
205
|
+
thumbRefs.current[i] = el;
|
|
206
|
+
}}
|
|
207
|
+
aria-label={`View image ${i + 1}`}
|
|
208
|
+
aria-pressed={i === index}
|
|
209
|
+
onClick={() => select(i)}
|
|
210
|
+
onKeyDown={(e) => onThumbKeyDown(e, i)}
|
|
211
|
+
class={`block h-16 w-16 overflow-hidden rounded-md border-2 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary md:h-20 md:w-20 ${
|
|
212
|
+
i === index
|
|
213
|
+
? "border-primary"
|
|
214
|
+
: "border-transparent hover:border-border"
|
|
215
|
+
}`}
|
|
216
|
+
style={`aspect-ratio:${tw}/${th};background:var(--color-border)`}
|
|
217
|
+
>
|
|
218
|
+
<img
|
|
219
|
+
src={srcFor(img, title)}
|
|
220
|
+
srcset={srcsetFor(img)}
|
|
221
|
+
sizes="80px"
|
|
222
|
+
alt=""
|
|
223
|
+
width={tw}
|
|
224
|
+
height={th}
|
|
225
|
+
loading="lazy"
|
|
226
|
+
decoding="async"
|
|
227
|
+
class="h-full w-full object-cover"
|
|
228
|
+
/>
|
|
229
|
+
</button>
|
|
230
|
+
</li>
|
|
231
|
+
);
|
|
232
|
+
})}
|
|
233
|
+
</ul>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <IsolatedAgeGate> — mounts the 21+ affirmation gate inside a **closed shadow
|
|
3
|
+
* root** so no tenant CSS or JavaScript can select, restyle, or hide it.
|
|
4
|
+
*
|
|
5
|
+
* Why a wrapper instead of rendering <AgeGate> directly: a closed shadow root
|
|
6
|
+
* also blocks the platform's own global stylesheet, so we clone the document's
|
|
7
|
+
* stylesheets into the shadow root (Tailwind utilities + fonts). Theme tokens
|
|
8
|
+
* are CSS custom properties on :root, which *do* inherit across the shadow
|
|
9
|
+
* boundary, so the gate keeps the tenant's colors.
|
|
10
|
+
*
|
|
11
|
+
* The host element lives in light DOM (the only attack surface), so a
|
|
12
|
+
* MutationObserver re-asserts it: if tenant code removes it or sets it
|
|
13
|
+
* display:none/hidden, it is restored. The gate's *contents* are unreachable
|
|
14
|
+
* (closed shadow). This makes "customize anything except what keeps you legal"
|
|
15
|
+
* a technical guarantee, not a policy.
|
|
16
|
+
*/
|
|
17
|
+
import { render } from "preact";
|
|
18
|
+
import { useEffect } from "preact/hooks";
|
|
19
|
+
import AgeGate from "./AgeGate";
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
minAge: number;
|
|
23
|
+
brand: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const HOST_MARKER = "data-tot-compliance";
|
|
27
|
+
|
|
28
|
+
export default function IsolatedAgeGate({ minAge, brand }: Props) {
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
// Guard against an accidental double-mount (e.g. HMR).
|
|
31
|
+
if (document.querySelector(`[${HOST_MARKER}]`)) return;
|
|
32
|
+
|
|
33
|
+
const host = document.createElement("div");
|
|
34
|
+
host.setAttribute(HOST_MARKER, "");
|
|
35
|
+
const shadow = host.attachShadow({ mode: "closed" });
|
|
36
|
+
|
|
37
|
+
// Bring platform styles into the shadow root via the external stylesheet
|
|
38
|
+
// <link>s only (Tailwind utilities; build externalizes all CSS). We do NOT
|
|
39
|
+
// clone the inline theme <style>: cloning strips its CSP nonce, and it's
|
|
40
|
+
// unneeded — CSS custom properties (the theme tokens on :root) inherit
|
|
41
|
+
// across the shadow boundary, so the gate keeps the tenant's colors anyway.
|
|
42
|
+
document
|
|
43
|
+
.querySelectorAll('link[rel="stylesheet"]')
|
|
44
|
+
.forEach((link) => shadow.appendChild(link.cloneNode(true)));
|
|
45
|
+
|
|
46
|
+
const mount = document.createElement("div");
|
|
47
|
+
shadow.appendChild(mount);
|
|
48
|
+
render(<AgeGate minAge={minAge} brand={brand} />, mount);
|
|
49
|
+
document.body.appendChild(host);
|
|
50
|
+
|
|
51
|
+
// Re-assert: restore the host if tenant code detaches or hides it.
|
|
52
|
+
const ensure = () => {
|
|
53
|
+
if (!host.isConnected) document.body.appendChild(host);
|
|
54
|
+
if (host.style.display === "none") host.style.display = "";
|
|
55
|
+
if (host.style.visibility === "hidden") host.style.visibility = "";
|
|
56
|
+
if (host.hasAttribute("hidden")) host.removeAttribute("hidden");
|
|
57
|
+
};
|
|
58
|
+
const observer = new MutationObserver(ensure);
|
|
59
|
+
observer.observe(document.body, { childList: true });
|
|
60
|
+
observer.observe(host, {
|
|
61
|
+
attributes: true,
|
|
62
|
+
attributeFilter: ["style", "class", "hidden"],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return () => {
|
|
66
|
+
observer.disconnect();
|
|
67
|
+
render(null, mount);
|
|
68
|
+
host.remove();
|
|
69
|
+
};
|
|
70
|
+
}, [minAge, brand]);
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|