@tokenoftrust/storefront-runner 1.3.4-rc.4 → 1.4.1
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/apps/storefront/astro.config.mjs +15 -0
- package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
- package/apps/storefront/drizzle.config.apps.ts +13 -0
- package/apps/storefront/env.d.ts +10 -0
- package/apps/storefront/migrations/README.md +13 -7
- package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
- package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
- package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
- package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +20 -0
- package/apps/storefront/package.json +12 -1
- package/apps/storefront/perf/README.md +64 -0
- package/apps/storefront/perf/assert-budgets.ts +159 -0
- package/apps/storefront/playwright.config.ts +23 -0
- package/apps/storefront/public/js/dashboard-apps.js +173 -0
- package/apps/storefront/public/shared/commerce-marketing.css +221 -0
- package/apps/storefront/src/components/CollectionCard.astro +1 -0
- package/apps/storefront/src/components/ProductCard.astro +1 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +3562 -0
- package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
- package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
- package/apps/storefront/src/components/content/Callout.astro +75 -0
- package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
- package/apps/storefront/src/components/content/ProseSections.astro +121 -0
- package/apps/storefront/src/components/content/ProseToc.astro +34 -0
- package/apps/storefront/src/components/content/RichText.astro +44 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
- package/apps/storefront/src/components/home/Hero.astro +14 -0
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
- package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
- package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
- package/apps/storefront/src/config/storeName.ts +34 -0
- package/apps/storefront/src/layouts/Layout.astro +97 -23
- package/apps/storefront/src/lib/activity/alerts.ts +428 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
- package/apps/storefront/src/lib/activity/deployVersion.ts +127 -0
- package/apps/storefront/src/lib/activity/ingest.ts +188 -0
- package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
- package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
- package/apps/storefront/src/lib/activity/query.ts +403 -0
- package/apps/storefront/src/lib/activity/recordActivity.ts +105 -0
- package/apps/storefront/src/lib/activity/store.ts +122 -0
- package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
- package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
- package/apps/storefront/src/lib/analytics/budgets.json +69 -0
- package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
- package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
- package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
- package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
- package/apps/storefront/src/lib/apps/adminService.ts +106 -0
- package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
- package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
- package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
- package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
- package/apps/storefront/src/lib/apps/credentials.ts +153 -0
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
- package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
- package/apps/storefront/src/lib/apps/registryService.ts +579 -0
- package/apps/storefront/src/lib/apps/scopes.ts +79 -0
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
- package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
- package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +219 -0
- package/apps/storefront/src/lib/auth/route.ts +60 -5
- package/apps/storefront/src/lib/auth/session.ts +8 -0
- package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
- package/apps/storefront/src/lib/blog/provider.ts +39 -0
- package/apps/storefront/src/lib/blog/types.ts +26 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
- package/apps/storefront/src/lib/chrome/model.ts +11 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
- package/apps/storefront/src/lib/content/callout.ts +78 -0
- package/apps/storefront/src/lib/content/index.ts +27 -0
- package/apps/storefront/src/lib/content/needsReview.ts +58 -0
- package/apps/storefront/src/lib/content/prose.ts +186 -0
- package/apps/storefront/src/lib/content/richtext.ts +76 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
- package/apps/storefront/src/lib/content-edit/client.ts +70 -14
- package/apps/storefront/src/lib/d1/catalog.ts +12 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +267 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
- package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
- package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
- package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
- package/apps/storefront/src/lib/dev/vcBinding.ts +80 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
- package/apps/storefront/src/lib/env.ts +63 -0
- package/apps/storefront/src/lib/jsonld.ts +12 -17
- package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
- package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
- package/apps/storefront/src/lib/publish/domainState.ts +351 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +369 -0
- package/apps/storefront/src/lib/rawChrome.ts +34 -3
- package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
- package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
- package/apps/storefront/src/lib/subscription/model.ts +114 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
- package/apps/storefront/src/lib/tot/query.ts +32 -0
- package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
- package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
- package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
- package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
- package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
- package/apps/storefront/src/lib/webhooks/events.ts +98 -0
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
- package/apps/storefront/src/middleware/index.ts +45 -13
- package/apps/storefront/src/pages/404.astro +21 -9
- package/apps/storefront/src/pages/[...slug].astro +36 -2
- package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
- package/apps/storefront/src/pages/admin.astro +183 -9
- package/apps/storefront/src/pages/api/activity.ts +137 -0
- package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +86 -0
- package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
- package/apps/storefront/src/pages/api/apps/admin/install.ts +125 -0
- package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +79 -0
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +80 -0
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +98 -0
- package/apps/storefront/src/pages/api/apps/admin/update.ts +126 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +70 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +183 -0
- package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +162 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
- package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
- package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
- package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
- package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
- package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +74 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
- package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
- package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
- package/apps/storefront/src/pages/auth/login.astro +202 -45
- package/apps/storefront/src/pages/auth/magic.astro +75 -43
- package/apps/storefront/src/pages/blog/[slug].astro +107 -0
- package/apps/storefront/src/pages/blog/index.astro +98 -0
- package/apps/storefront/src/pages/capabilities.astro +8 -0
- package/apps/storefront/src/pages/cockpit.astro +470 -69
- package/apps/storefront/src/pages/collections/[handle].astro +8 -0
- package/apps/storefront/src/pages/collections/index.astro +10 -2
- package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
- package/apps/storefront/src/pages/index.astro +57 -0
- package/apps/storefront/src/pages/llms.txt.ts +31 -10
- package/apps/storefront/src/pages/products/[handle].astro +100 -9
- package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
- package/apps/storefront/src/pages/style-guide/index.astro +10 -0
- package/apps/storefront/src/styles/fonts.css +54 -0
- package/apps/storefront/src/styles/global.css +22 -2
- package/apps/storefront/src/themes/schema.ts +3 -25
- package/apps/storefront/tsconfig.json +1 -1
- package/apps/storefront/vitest.config.ts +4 -1
- package/package.json +1 -1
- package/packages/public-runtime/src/activity/README.md +146 -0
- package/packages/public-runtime/src/activity/catalog.ts +501 -0
- package/packages/public-runtime/src/activity/event.ts +168 -0
- package/packages/public-runtime/src/activity/index.ts +16 -0
- package/packages/public-runtime/src/activity/redaction.ts +263 -0
- package/packages/public-runtime/src/candidate-index.ts +324 -0
- package/packages/public-runtime/src/checkout.ts +94 -2
- package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
- package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
- package/packages/public-runtime/src/compliance/index.ts +22 -0
- package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
- package/packages/public-runtime/src/compliance/profile.ts +198 -0
- package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
- package/packages/public-runtime/src/compliance/verification.ts +81 -0
- package/packages/public-runtime/src/csp.ts +27 -3
- package/packages/public-runtime/src/customization-reconcile.ts +35 -0
- package/packages/public-runtime/src/customization-runtime.ts +8 -0
- package/packages/public-runtime/src/customization-versioning.ts +17 -0
- package/packages/public-runtime/src/extension-contract.ts +2 -1
- package/packages/public-runtime/src/hash.ts +25 -0
- package/packages/public-runtime/src/index.ts +12 -0
- package/packages/public-runtime/src/membership.ts +353 -0
- package/packages/public-runtime/src/product.ts +24 -2
- package/packages/public-runtime/src/review-trust-proof.ts +194 -0
- package/packages/public-runtime/src/tenant-assets.ts +40 -5
- package/packages/public-runtime/src/tenant.ts +236 -0
- package/packages/public-runtime/src/widget-postmessage.ts +205 -0
- package/scripts/dev/publish.mjs +158 -0
- package/scripts/dev/transient-files.mjs +2 -1
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/pages/storefront.css +23 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <CheckoutComplianceGate> — the storefront's pre-handoff compliance ENFORCEMENT
|
|
3
|
+
* seam for regulated (age/nicotine) tenants.
|
|
4
|
+
*
|
|
5
|
+
* Checkout completion happens in the EXTERNAL managed cart, so the last moment the
|
|
6
|
+
* storefront controls is the add-to-cart submit. This island intercepts that
|
|
7
|
+
* submit for any `form[data-managed-cart]`, collects the ship-to state + 21+
|
|
8
|
+
* affirmation, and asks the server-authoritative gate (`/api/compliance/preflight`,
|
|
9
|
+
* F12) whether the cart may proceed. Only a `clear` decision releases the handoff;
|
|
10
|
+
* a hard block (restricted destination, flavored ban, per-order limit, failed /
|
|
11
|
+
* unconfigured verification) STOPS it and shows why. Fail-closed by construction:
|
|
12
|
+
* if the preflight itself errors on a regulated tenant, we do NOT hand off.
|
|
13
|
+
*
|
|
14
|
+
* This is progressive enhancement: the underlying form still posts the real
|
|
15
|
+
* (server-pre-signed) cart fields. If this island never hydrates, the form submits
|
|
16
|
+
* as before — so the gate hardens the flow without being a single point of failure
|
|
17
|
+
* for rendering the buy button. The REAL production enforcement is defense-in-depth
|
|
18
|
+
* in the managed-cart pre-cart webhook (Track B); this is the storefront-side check
|
|
19
|
+
* and the demo/preview's visible compliance checkpoint.
|
|
20
|
+
*/
|
|
21
|
+
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
|
22
|
+
import type { CartComplianceDecision } from "@tot/public-runtime";
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
/** Absolute (base-path-aware) URL of the preflight endpoint. */
|
|
26
|
+
preflightUrl: string;
|
|
27
|
+
/** Minimum purchase age for the tenant (display only; the server enforces). */
|
|
28
|
+
minAge: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const AGE_KEY = "tot:age-affirmed";
|
|
32
|
+
|
|
33
|
+
// US states/territories that can be a ship-to region. The server owns which are
|
|
34
|
+
// prohibited; this list is only the picker's options.
|
|
35
|
+
const US_REGIONS: Array<{ code: string; name: string }> = [
|
|
36
|
+
{ code: "AL", name: "Alabama" }, { code: "AK", name: "Alaska" },
|
|
37
|
+
{ code: "AZ", name: "Arizona" }, { code: "AR", name: "Arkansas" },
|
|
38
|
+
{ code: "CA", name: "California" }, { code: "CO", name: "Colorado" },
|
|
39
|
+
{ code: "CT", name: "Connecticut" }, { code: "DE", name: "Delaware" },
|
|
40
|
+
{ code: "DC", name: "District of Columbia" }, { code: "FL", name: "Florida" },
|
|
41
|
+
{ code: "GA", name: "Georgia" }, { code: "HI", name: "Hawaii" },
|
|
42
|
+
{ code: "ID", name: "Idaho" }, { code: "IL", name: "Illinois" },
|
|
43
|
+
{ code: "IN", name: "Indiana" }, { code: "IA", name: "Iowa" },
|
|
44
|
+
{ code: "KS", name: "Kansas" }, { code: "KY", name: "Kentucky" },
|
|
45
|
+
{ code: "LA", name: "Louisiana" }, { code: "ME", name: "Maine" },
|
|
46
|
+
{ code: "MD", name: "Maryland" }, { code: "MA", name: "Massachusetts" },
|
|
47
|
+
{ code: "MI", name: "Michigan" }, { code: "MN", name: "Minnesota" },
|
|
48
|
+
{ code: "MS", name: "Mississippi" }, { code: "MO", name: "Missouri" },
|
|
49
|
+
{ code: "MT", name: "Montana" }, { code: "NE", name: "Nebraska" },
|
|
50
|
+
{ code: "NV", name: "Nevada" }, { code: "NH", name: "New Hampshire" },
|
|
51
|
+
{ code: "NJ", name: "New Jersey" }, { code: "NM", name: "New Mexico" },
|
|
52
|
+
{ code: "NY", name: "New York" }, { code: "NC", name: "North Carolina" },
|
|
53
|
+
{ code: "ND", name: "North Dakota" }, { code: "OH", name: "Ohio" },
|
|
54
|
+
{ code: "OK", name: "Oklahoma" }, { code: "OR", name: "Oregon" },
|
|
55
|
+
{ code: "PA", name: "Pennsylvania" }, { code: "RI", name: "Rhode Island" },
|
|
56
|
+
{ code: "SC", name: "South Carolina" }, { code: "SD", name: "South Dakota" },
|
|
57
|
+
{ code: "TN", name: "Tennessee" }, { code: "TX", name: "Texas" },
|
|
58
|
+
{ code: "UT", name: "Utah" }, { code: "VT", name: "Vermont" },
|
|
59
|
+
{ code: "VA", name: "Virginia" }, { code: "WA", name: "Washington" },
|
|
60
|
+
{ code: "WV", name: "West Virginia" }, { code: "WI", name: "Wisconsin" },
|
|
61
|
+
{ code: "WY", name: "Wyoming" },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
interface PendingLine {
|
|
65
|
+
form: HTMLFormElement;
|
|
66
|
+
handle: string;
|
|
67
|
+
sku?: string;
|
|
68
|
+
variantId?: string;
|
|
69
|
+
quantity: number;
|
|
70
|
+
title: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props) {
|
|
74
|
+
const [pending, setPending] = useState<PendingLine | null>(null);
|
|
75
|
+
const [region, setRegion] = useState("");
|
|
76
|
+
const [affirmed, setAffirmed] = useState(false);
|
|
77
|
+
const [loading, setLoading] = useState(false);
|
|
78
|
+
const [decision, setDecision] = useState<CartComplianceDecision | null>(null);
|
|
79
|
+
const [error, setError] = useState<string | null>(null);
|
|
80
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
81
|
+
|
|
82
|
+
// Intercept managed-cart form submits (capture phase, before the native POST).
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
function onSubmit(e: Event) {
|
|
85
|
+
const form = e.target;
|
|
86
|
+
if (!(form instanceof HTMLFormElement)) return;
|
|
87
|
+
if (!form.matches("form[data-managed-cart]")) return;
|
|
88
|
+
// Already cleared by the gate → let this submission through.
|
|
89
|
+
if (form.dataset.complianceCleared === "1") return;
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
e.stopPropagation();
|
|
92
|
+
const qtyInput = form.querySelector<HTMLInputElement>('input[name$="quantity" i], input[name="quantity"]');
|
|
93
|
+
const quantity = Math.max(1, parseInt(qtyInput?.value ?? "1", 10) || 1);
|
|
94
|
+
setPending({
|
|
95
|
+
form,
|
|
96
|
+
handle: form.dataset.productHandle ?? "",
|
|
97
|
+
sku: form.dataset.variantSku || undefined,
|
|
98
|
+
variantId: form.dataset.variantId || undefined,
|
|
99
|
+
quantity,
|
|
100
|
+
title: form.dataset.productTitle ?? "this item",
|
|
101
|
+
});
|
|
102
|
+
setDecision(null);
|
|
103
|
+
setError(null);
|
|
104
|
+
let pre = false;
|
|
105
|
+
try {
|
|
106
|
+
pre = localStorage.getItem(AGE_KEY) === "yes";
|
|
107
|
+
} catch {
|
|
108
|
+
/* storage disabled */
|
|
109
|
+
}
|
|
110
|
+
setAffirmed(pre);
|
|
111
|
+
document.documentElement.style.overflow = "hidden";
|
|
112
|
+
}
|
|
113
|
+
document.addEventListener("submit", onSubmit, true);
|
|
114
|
+
return () => document.removeEventListener("submit", onSubmit, true);
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
const close = useCallback(() => {
|
|
118
|
+
setPending(null);
|
|
119
|
+
setDecision(null);
|
|
120
|
+
setError(null);
|
|
121
|
+
setLoading(false);
|
|
122
|
+
document.documentElement.style.overflow = "";
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
const proceed = useCallback((line: PendingLine) => {
|
|
126
|
+
line.form.dataset.complianceCleared = "1";
|
|
127
|
+
document.documentElement.style.overflow = "";
|
|
128
|
+
setPending(null);
|
|
129
|
+
// requestSubmit re-runs submit; our listener now passes it through, and the
|
|
130
|
+
// managed-cart loader takes over the (still server-pre-signed) POST.
|
|
131
|
+
if (typeof line.form.requestSubmit === "function") line.form.requestSubmit();
|
|
132
|
+
else line.form.submit();
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
const check = useCallback(async () => {
|
|
136
|
+
if (!pending) return;
|
|
137
|
+
setLoading(true);
|
|
138
|
+
setError(null);
|
|
139
|
+
setDecision(null);
|
|
140
|
+
try {
|
|
141
|
+
const res = await fetch(preflightUrl, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "content-type": "application/json" },
|
|
144
|
+
body: JSON.stringify({
|
|
145
|
+
lines: [
|
|
146
|
+
{
|
|
147
|
+
handle: pending.handle,
|
|
148
|
+
sku: pending.sku,
|
|
149
|
+
variantId: pending.variantId,
|
|
150
|
+
quantity: pending.quantity,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
destination: region ? { region, country: "US" } : null,
|
|
154
|
+
ageAffirmed: affirmed,
|
|
155
|
+
}),
|
|
156
|
+
});
|
|
157
|
+
const data = (await res.json()) as { decision?: CartComplianceDecision };
|
|
158
|
+
const d = data.decision;
|
|
159
|
+
if (!d) {
|
|
160
|
+
// Fail closed: a regulated tenant never proceeds on an unreadable result.
|
|
161
|
+
setError(
|
|
162
|
+
"We couldn't confirm this order can ship. Please try again in a moment.",
|
|
163
|
+
);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
setDecision(d);
|
|
167
|
+
if (d.status === "clear") proceed(pending);
|
|
168
|
+
} catch {
|
|
169
|
+
setError(
|
|
170
|
+
"We couldn't reach our compliance check. For your safety, checkout is paused — please try again.",
|
|
171
|
+
);
|
|
172
|
+
} finally {
|
|
173
|
+
setLoading(false);
|
|
174
|
+
}
|
|
175
|
+
}, [pending, preflightUrl, region, affirmed, proceed]);
|
|
176
|
+
|
|
177
|
+
if (!pending) return null;
|
|
178
|
+
|
|
179
|
+
const blocks = decision?.blocks ?? [];
|
|
180
|
+
const adultSignature = decision?.requirements.adultSignature;
|
|
181
|
+
|
|
182
|
+
return (
|
|
183
|
+
<div
|
|
184
|
+
class="fixed inset-0 z-[110] grid place-items-center bg-[color-mix(in_srgb,var(--color-text)_75%,transparent)] p-5 backdrop-blur-sm"
|
|
185
|
+
role="dialog"
|
|
186
|
+
aria-modal="true"
|
|
187
|
+
aria-labelledby="cgate-title"
|
|
188
|
+
onClick={(e) => {
|
|
189
|
+
if (e.target === e.currentTarget) close();
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
<div
|
|
193
|
+
ref={dialogRef}
|
|
194
|
+
class="w-[min(32rem,100%)] rounded-[var(--radius-lg)] bg-[var(--color-surface)] p-7 text-left shadow-[var(--shadow-lg)]"
|
|
195
|
+
>
|
|
196
|
+
<p class="eyebrow eyebrow--tick mb-3 w-fit">Compliance check</p>
|
|
197
|
+
<h2 id="cgate-title" class="display-sm">
|
|
198
|
+
Confirm before checkout
|
|
199
|
+
</h2>
|
|
200
|
+
<p class="mt-2 text-sm text-[var(--color-muted)]">
|
|
201
|
+
This is an age-restricted order. We confirm your age and where it can
|
|
202
|
+
legally ship before sending you to secure checkout.
|
|
203
|
+
</p>
|
|
204
|
+
|
|
205
|
+
<div class="mt-5 flex flex-col gap-4">
|
|
206
|
+
<label class="flex flex-col gap-1.5">
|
|
207
|
+
<span class="font-mono text-xs uppercase tracking-[0.12em] text-[var(--color-muted)]">
|
|
208
|
+
Ship to state
|
|
209
|
+
</span>
|
|
210
|
+
<select
|
|
211
|
+
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2.5 text-[var(--color-text)]"
|
|
212
|
+
value={region}
|
|
213
|
+
onChange={(e) => {
|
|
214
|
+
setRegion((e.target as HTMLSelectElement).value);
|
|
215
|
+
setDecision(null);
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
<option value="">Select a state…</option>
|
|
219
|
+
{US_REGIONS.map((r) => (
|
|
220
|
+
<option key={r.code} value={r.code}>
|
|
221
|
+
{r.name}
|
|
222
|
+
</option>
|
|
223
|
+
))}
|
|
224
|
+
</select>
|
|
225
|
+
</label>
|
|
226
|
+
|
|
227
|
+
<label class="flex items-start gap-2.5 text-sm text-[var(--color-text)]">
|
|
228
|
+
<input
|
|
229
|
+
type="checkbox"
|
|
230
|
+
class="mt-0.5 h-4 w-4"
|
|
231
|
+
checked={affirmed}
|
|
232
|
+
onChange={(e) => {
|
|
233
|
+
setAffirmed((e.target as HTMLInputElement).checked);
|
|
234
|
+
setDecision(null);
|
|
235
|
+
}}
|
|
236
|
+
/>
|
|
237
|
+
<span>I confirm I am {minAge} or older.</span>
|
|
238
|
+
</label>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
{/* Decision surface */}
|
|
242
|
+
{blocks.length > 0 && (
|
|
243
|
+
<div
|
|
244
|
+
class="mt-5 rounded-[var(--radius-md)] border border-[var(--color-sale)] bg-[color-mix(in_srgb,var(--color-sale)_8%,transparent)] px-4 py-3"
|
|
245
|
+
role="alert"
|
|
246
|
+
>
|
|
247
|
+
<p class="font-display text-sm font-semibold text-[var(--color-sale)]">
|
|
248
|
+
{decision?.status === "blocked"
|
|
249
|
+
? "This order can't proceed"
|
|
250
|
+
: "One more step"}
|
|
251
|
+
</p>
|
|
252
|
+
<ul class="mt-1.5 list-disc space-y-1 pl-5 text-sm text-[var(--color-text)]">
|
|
253
|
+
{blocks.map((b, i) => (
|
|
254
|
+
<li key={i}>{b.message}</li>
|
|
255
|
+
))}
|
|
256
|
+
</ul>
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
{error && (
|
|
261
|
+
<p
|
|
262
|
+
class="mt-5 rounded-[var(--radius-md)] border border-[var(--color-sale)] bg-[color-mix(in_srgb,var(--color-sale)_8%,transparent)] px-4 py-3 text-sm text-[var(--color-text)]"
|
|
263
|
+
role="alert"
|
|
264
|
+
>
|
|
265
|
+
{error}
|
|
266
|
+
</p>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
{adultSignature && decision?.status !== "blocked" && (
|
|
270
|
+
<p class="mt-4 text-xs text-[var(--color-muted)]">
|
|
271
|
+
Adult signature (21+) is required on delivery for this order.
|
|
272
|
+
</p>
|
|
273
|
+
)}
|
|
274
|
+
|
|
275
|
+
<div class="mt-6 flex flex-wrap gap-3">
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
disabled={loading}
|
|
279
|
+
onClick={check}
|
|
280
|
+
class="rounded-full bg-[var(--color-primary)] px-6 py-3 font-display text-sm font-medium uppercase tracking-[0.08em] text-[var(--color-primary-contrast)] transition-opacity hover:opacity-90 disabled:opacity-60"
|
|
281
|
+
>
|
|
282
|
+
{loading ? "Checking…" : "Continue to checkout"}
|
|
283
|
+
</button>
|
|
284
|
+
<button
|
|
285
|
+
type="button"
|
|
286
|
+
onClick={close}
|
|
287
|
+
class="rounded-full border border-[var(--color-border)] px-6 py-3 font-display text-sm font-medium text-[var(--color-text)] transition-colors hover:border-[var(--color-text)]"
|
|
288
|
+
>
|
|
289
|
+
Cancel
|
|
290
|
+
</button>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
17
17
|
import type { CatalogImage } from "@tot/public-runtime";
|
|
18
18
|
import { placeholderDataUri, isUsableImageUrl } from "@/lib/placeholder";
|
|
19
|
+
import { resolveImage } from "@/lib/imageAttrs";
|
|
20
|
+
import { cfImageUrl } from "@/lib/cfImage";
|
|
19
21
|
|
|
20
22
|
interface Props {
|
|
21
23
|
images: CatalogImage[];
|
|
@@ -27,27 +29,47 @@ interface Props {
|
|
|
27
29
|
const FALLBACK_W = 1000;
|
|
28
30
|
const FALLBACK_H = 1250;
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
const isShopifyCdnUrl = (url: string): boolean =>
|
|
33
|
+
/\/\/cdn\.shopify\.com\//.test(url) || /\/cdn\/shop\//.test(url);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Main-image src + responsive srcset. Delegates to the SHARED `resolveImage`
|
|
37
|
+
* decision (same as <Img> on the PLP) so rehosted (R2) images go through
|
|
38
|
+
* Cloudflare Image Resizing — `?width=` is a no-op on the R2 bucket, so the old
|
|
39
|
+
* per-island srcset silently served full-size bytes at every breakpoint.
|
|
40
|
+
*/
|
|
41
|
+
function mainImage(img: CatalogImage, title: string): { src: string; srcset?: string } {
|
|
42
|
+
return resolveImage({
|
|
43
|
+
src: img.url,
|
|
44
|
+
rehosted: img.rehosted,
|
|
34
45
|
width: img.width ?? FALLBACK_W,
|
|
35
46
|
height: img.height ?? FALLBACK_H,
|
|
47
|
+
seed: img.id || title,
|
|
36
48
|
label: title,
|
|
37
49
|
});
|
|
38
50
|
}
|
|
39
51
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Thumbnail src — a single small (160px, 2x of the 80px strip) request, so a
|
|
54
|
+
* thumbnail never downloads a full-size photo. Rehosted images resize via
|
|
55
|
+
* Cloudflare Image Resizing; referenced Shopify images via their native
|
|
56
|
+
* `?width=`; dead fixture URLs draw a deterministic placeholder.
|
|
57
|
+
*/
|
|
58
|
+
const THUMB_WIDTH = 160;
|
|
59
|
+
function thumbSrc(img: CatalogImage, title: string): string {
|
|
60
|
+
if (!isUsableImageUrl(img.url, img.rehosted)) {
|
|
61
|
+
return placeholderDataUri({
|
|
62
|
+
seed: img.id || title,
|
|
63
|
+
width: img.width ?? FALLBACK_W,
|
|
64
|
+
height: img.height ?? FALLBACK_H,
|
|
65
|
+
label: title,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (img.rehosted) return cfImageUrl(img.url, { width: THUMB_WIDTH });
|
|
69
|
+
if (isShopifyCdnUrl(img.url)) {
|
|
70
|
+
return `${img.url}${img.url.includes("?") ? "&" : "?"}width=${THUMB_WIDTH}`;
|
|
71
|
+
}
|
|
72
|
+
return img.url;
|
|
51
73
|
}
|
|
52
74
|
|
|
53
75
|
function prefersReducedMotion(): boolean {
|
|
@@ -156,6 +178,7 @@ export default function ImageGallery({
|
|
|
156
178
|
const w = current.width ?? FALLBACK_W;
|
|
157
179
|
const h = current.height ?? FALLBACK_H;
|
|
158
180
|
const isLcp = index === 0 && !interacted;
|
|
181
|
+
const main = mainImage(current, title);
|
|
159
182
|
|
|
160
183
|
return (
|
|
161
184
|
<div class="flex flex-col gap-3 md:flex-row-reverse md:gap-4">
|
|
@@ -172,9 +195,9 @@ export default function ImageGallery({
|
|
|
172
195
|
style={`aspect-ratio:${w}/${h};background:var(--color-border)`}
|
|
173
196
|
>
|
|
174
197
|
<img
|
|
175
|
-
src={
|
|
176
|
-
srcset={
|
|
177
|
-
sizes="(min-width: 768px) 45vw, 100vw"
|
|
198
|
+
src={main.src}
|
|
199
|
+
srcset={main.srcset}
|
|
200
|
+
sizes={main.srcset ? "(min-width: 768px) 45vw, 100vw" : undefined}
|
|
178
201
|
alt={current.alt ?? title}
|
|
179
202
|
width={w}
|
|
180
203
|
height={h}
|
|
@@ -216,9 +239,7 @@ export default function ImageGallery({
|
|
|
216
239
|
style={`aspect-ratio:${tw}/${th};background:var(--color-border)`}
|
|
217
240
|
>
|
|
218
241
|
<img
|
|
219
|
-
src={
|
|
220
|
-
srcset={srcsetFor(img)}
|
|
221
|
-
sizes="80px"
|
|
242
|
+
src={thumbSrc(img, title)}
|
|
222
243
|
alt=""
|
|
223
244
|
width={tw}
|
|
224
245
|
height={th}
|
|
@@ -22,8 +22,10 @@ import type {
|
|
|
22
22
|
CatalogVariant,
|
|
23
23
|
ProductPurchaseOption,
|
|
24
24
|
VariantCartPayload,
|
|
25
|
+
MembershipTierRule,
|
|
26
|
+
MembershipStackingPolicy,
|
|
25
27
|
} from "@tot/public-runtime";
|
|
26
|
-
import {
|
|
28
|
+
import { priceForPurchaseOption } from "@tot/public-runtime";
|
|
27
29
|
import { formatPrice } from "@/lib/format";
|
|
28
30
|
|
|
29
31
|
interface OptionDef {
|
|
@@ -37,6 +39,10 @@ interface Props {
|
|
|
37
39
|
options: OptionDef[];
|
|
38
40
|
currency: string;
|
|
39
41
|
locale?: string;
|
|
42
|
+
/** Product handle/title — surfaced as form data-attributes so the checkout
|
|
43
|
+
* compliance gate (regulated tenants) can resolve the cart line server-side. */
|
|
44
|
+
productHandle?: string;
|
|
45
|
+
productTitle?: string;
|
|
40
46
|
/**
|
|
41
47
|
* Managed add-to-cart (Track A). Server pre-builds a signed cart payload per
|
|
42
48
|
* variant; the island renders the selected variant's payload as a real checkout
|
|
@@ -48,6 +54,15 @@ interface Props {
|
|
|
48
54
|
purchaseOptions: ProductPurchaseOption[];
|
|
49
55
|
byVariantId: Record<string, Record<string, VariantCartPayload>>;
|
|
50
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* Membership pricing (F10). Present only when an eligible member is resolved
|
|
59
|
+
* for this request (Phase 2 shopper login). Undefined → list pricing only,
|
|
60
|
+
* which is the Phase-1 anonymous behavior.
|
|
61
|
+
*/
|
|
62
|
+
pricing?: {
|
|
63
|
+
membershipRule: MembershipTierRule;
|
|
64
|
+
stacking: MembershipStackingPolicy;
|
|
65
|
+
};
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
const LOW_STOCK_THRESHOLD = 5;
|
|
@@ -70,7 +85,10 @@ export default function VariantSelector({
|
|
|
70
85
|
options,
|
|
71
86
|
currency,
|
|
72
87
|
locale,
|
|
88
|
+
productHandle,
|
|
89
|
+
productTitle,
|
|
73
90
|
checkoutCart,
|
|
91
|
+
pricing,
|
|
74
92
|
}: Props) {
|
|
75
93
|
const sortedOptions = useMemo(
|
|
76
94
|
() => [...options].sort((a, b) => a.position - b.position),
|
|
@@ -201,20 +219,35 @@ export default function VariantSelector({
|
|
|
201
219
|
}
|
|
202
220
|
}
|
|
203
221
|
|
|
204
|
-
|
|
205
|
-
|
|
222
|
+
// Compose member + subscription pricing (F10). The pricing context carries the
|
|
223
|
+
// resolved member tier only when an eligible member was resolved server-side;
|
|
224
|
+
// otherwise it is a non-member and list pricing stands.
|
|
225
|
+
const pricingCtx = pricing
|
|
226
|
+
? {
|
|
227
|
+
member: { isMember: true, tierId: pricing.membershipRule.tierId },
|
|
228
|
+
program: {
|
|
229
|
+
enabled: true,
|
|
230
|
+
tiers: [pricing.membershipRule],
|
|
231
|
+
stacking: pricing.stacking,
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
: { member: { isMember: false } };
|
|
235
|
+
const effectiveOption: ProductPurchaseOption =
|
|
236
|
+
selectedPurchaseOption ?? { id: "one_time", kind: "one_time", label: "One-time purchase" };
|
|
237
|
+
const priced = selected
|
|
238
|
+
? priceForPurchaseOption(selected.price, effectiveOption, pricingCtx)
|
|
206
239
|
: undefined;
|
|
240
|
+
const selectedPrice = priced?.price;
|
|
241
|
+
const memberApplied = priced?.appliedDiscounts.includes("member") ?? false;
|
|
207
242
|
const compareAt = selected?.compare_at_price;
|
|
208
|
-
const
|
|
209
|
-
selectedPrice !== undefined && selected && selectedPrice < selected.price
|
|
210
|
-
? selected.price
|
|
211
|
-
: undefined;
|
|
243
|
+
const discountCompareAt = priced?.compareAt;
|
|
212
244
|
const displayCompareAt =
|
|
213
|
-
|
|
214
|
-
? compareAt
|
|
215
|
-
|
|
245
|
+
selectedPrice !== undefined
|
|
246
|
+
? compareAt !== undefined && compareAt > selectedPrice
|
|
247
|
+
? compareAt
|
|
248
|
+
: discountCompareAt
|
|
249
|
+
: undefined;
|
|
216
250
|
const showCompare =
|
|
217
|
-
selected !== undefined &&
|
|
218
251
|
selectedPrice !== undefined &&
|
|
219
252
|
displayCompareAt !== undefined &&
|
|
220
253
|
displayCompareAt > selectedPrice;
|
|
@@ -249,6 +282,14 @@ export default function VariantSelector({
|
|
|
249
282
|
{formatPrice(displayCompareAt!, currency, locale)}
|
|
250
283
|
</span>
|
|
251
284
|
)}
|
|
285
|
+
{memberApplied && (
|
|
286
|
+
<span
|
|
287
|
+
data-member-price
|
|
288
|
+
class="rounded-full bg-accent px-2 py-0.5 font-mono text-[0.68rem] uppercase tracking-[0.08em] text-primary-contrast"
|
|
289
|
+
>
|
|
290
|
+
{pricing?.membershipRule.label ?? "Member"} price
|
|
291
|
+
</span>
|
|
292
|
+
)}
|
|
252
293
|
</div>
|
|
253
294
|
<p class={`font-mono text-xs uppercase tracking-[0.08em] ${stockClass}`}>
|
|
254
295
|
{stockLabel}
|
|
@@ -412,7 +453,16 @@ export default function VariantSelector({
|
|
|
412
453
|
placeholder. */}
|
|
413
454
|
{checkoutCart ? (
|
|
414
455
|
<div class="flex flex-col gap-1.5">
|
|
415
|
-
<form
|
|
456
|
+
<form
|
|
457
|
+
action={checkoutCart.action}
|
|
458
|
+
method="post"
|
|
459
|
+
target="_top"
|
|
460
|
+
data-managed-cart
|
|
461
|
+
data-product-handle={productHandle}
|
|
462
|
+
data-product-title={productTitle}
|
|
463
|
+
data-variant-id={selected?.id}
|
|
464
|
+
data-variant-sku={selected?.sku}
|
|
465
|
+
>
|
|
416
466
|
{canBuy &&
|
|
417
467
|
cartPayload!.hidden.map((f) => (
|
|
418
468
|
<input key={f.name} type="hidden" name={f.name} value={f.value} />
|
|
@@ -71,7 +71,7 @@ const clearHref =
|
|
|
71
71
|
<a
|
|
72
72
|
href={href}
|
|
73
73
|
rel="nofollow"
|
|
74
|
-
aria-
|
|
74
|
+
aria-current={isSel ? "true" : undefined}
|
|
75
75
|
class:list={[
|
|
76
76
|
"flex items-center justify-between gap-3 rounded-[var(--radius-sm)] px-2 py-1 transition-colors",
|
|
77
77
|
isSel
|
|
@@ -123,7 +123,7 @@ const clearHref =
|
|
|
123
123
|
<a
|
|
124
124
|
href={href}
|
|
125
125
|
rel="nofollow"
|
|
126
|
-
aria-
|
|
126
|
+
aria-current={isSel ? "true" : undefined}
|
|
127
127
|
class:list={[
|
|
128
128
|
"flex items-center justify-between gap-3 rounded-[var(--radius-sm)] px-2 py-1 transition-colors",
|
|
129
129
|
isSel
|
|
@@ -50,6 +50,7 @@ const { entries, eyebrow, heading, subhead } = Astro.props;
|
|
|
50
50
|
<li>
|
|
51
51
|
<a
|
|
52
52
|
href={entry.href}
|
|
53
|
+
data-manage-action={entry.action}
|
|
53
54
|
class="flex min-h-[44px] flex-col rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 transition-colors hover:border-[var(--color-primary)]"
|
|
54
55
|
>
|
|
55
56
|
<span class="font-display text-base font-semibold text-[var(--color-text)]">
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tenant compliance RULE DATA — the data-driven substance the enforcement
|
|
3
|
+
* engine (`@tot/public-runtime` compliance) evaluates. Keeping the rules here as
|
|
4
|
+
* DATA (not `if` branches in code) is the whole point: a jurisdiction change is a
|
|
5
|
+
* data edit reviewed against the ruleset shape, not a code change.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ LEGAL PROVENANCE: the state rules below are a REPRESENTATIVE starter set for
|
|
8
|
+
* the pilot vape tenants, not legal advice and not a guaranteed-current list. The
|
|
9
|
+
* authoritative flavor-ban / no-ship / per-order-limit / excise data and the PACT
|
|
10
|
+
* Act monthly FILING are the merchant's and their counsel's responsibility. Each
|
|
11
|
+
* ruleset carries `source` + `updatedAt` so this boundary stays explicit. Replace
|
|
12
|
+
* this data with the merchant's counsel-reviewed ruleset before a real go-live.
|
|
13
|
+
*
|
|
14
|
+
* The live verification PROVIDER is intentionally NOT set here — it is operational
|
|
15
|
+
* config resolved by `resolveTenantRuleset` from env (`TOT_VERIFICATION_PROVIDER`).
|
|
16
|
+
* A regulated tenant with no provider configured FAILS CLOSED (checkout blocks,
|
|
17
|
+
* evidence is not promotable), which is the correct safe default.
|
|
18
|
+
*/
|
|
19
|
+
import type { ComplianceRuleset } from "@tot/public-runtime";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Shared vape/ENDS rule template. `tenantId` is filled per tenant. The state list
|
|
23
|
+
* models three enforcement axes as data: full no-ship, flavored-ENDS ban, and a
|
|
24
|
+
* per-order unit cap. `verification.provider` is deliberately omitted (see header).
|
|
25
|
+
*/
|
|
26
|
+
function vapeRuleset(tenantId: string): ComplianceRuleset {
|
|
27
|
+
return {
|
|
28
|
+
tenantId,
|
|
29
|
+
minAge: 21,
|
|
30
|
+
verification: {
|
|
31
|
+
required: true,
|
|
32
|
+
minAge: 21,
|
|
33
|
+
// provider: set at resolve time from operational config (fail-closed if absent).
|
|
34
|
+
},
|
|
35
|
+
excise: {
|
|
36
|
+
required: true,
|
|
37
|
+
handling: "managed-cart",
|
|
38
|
+
note:
|
|
39
|
+
"State vapor excise materializes in the managed cart via the pre-cart " +
|
|
40
|
+
"webhook + tot_excise_tax coupon (Track B). If that path is unavailable, " +
|
|
41
|
+
"set handling: 'unavailable' so checkout fails closed rather than shipping untaxed.",
|
|
42
|
+
},
|
|
43
|
+
pactAct: {
|
|
44
|
+
enabled: true,
|
|
45
|
+
adultSignatureRequired: true,
|
|
46
|
+
// PACT Act bars USPS from carrying ENDS; carrier selection is merchant-external.
|
|
47
|
+
prohibitedCarriers: ["USPS"],
|
|
48
|
+
reportingRequired: true,
|
|
49
|
+
},
|
|
50
|
+
// A conservative federal-floor per-order cap; states below can tighten it.
|
|
51
|
+
defaultPerOrderUnitLimit: 10,
|
|
52
|
+
states: [
|
|
53
|
+
// --- Full DTC no-ship (state prohibits/does not license DTC vapor shipment) ---
|
|
54
|
+
{ region: "UT", banAll: true, note: "UT restricts DTC vapor shipment" },
|
|
55
|
+
{ region: "VT", banAll: true, note: "VT prohibits DTC e-cigarette shipment to consumers" },
|
|
56
|
+
{ region: "AR", banAll: true, note: "AR restricts DTC vapor shipment" },
|
|
57
|
+
// --- Flavored-ENDS bans (tobacco/menthol still permitted) ---
|
|
58
|
+
{ region: "MA", banFlavored: true, note: "MA flavored tobacco/ENDS ban" },
|
|
59
|
+
{ region: "NY", banFlavored: true, note: "NY flavored e-cigarette ban" },
|
|
60
|
+
{ region: "NJ", banFlavored: true, note: "NJ flavored vape ban" },
|
|
61
|
+
{ region: "RI", banFlavored: true, note: "RI flavored ENDS ban" },
|
|
62
|
+
{ region: "CA", banFlavored: true, note: "CA SB-793 flavored tobacco ban" },
|
|
63
|
+
// --- Tighter per-order caps ---
|
|
64
|
+
{ region: "ME", perOrderUnitLimit: 5, adultSignatureRequired: true, note: "ME per-order limit" },
|
|
65
|
+
{ region: "CT", perOrderUnitLimit: 5, note: "CT per-order limit" },
|
|
66
|
+
],
|
|
67
|
+
updatedAt: "2026-07-23",
|
|
68
|
+
source:
|
|
69
|
+
"ToT storefront pilot starter ruleset (representative, NOT legal advice). " +
|
|
70
|
+
"Merchant + counsel own the authoritative rule data and PACT filings.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Ruleset data keyed by tenant_id. Regulated tenants MUST have an entry. */
|
|
75
|
+
export const COMPLIANCE_RULESETS: Record<string, ComplianceRuleset> = {
|
|
76
|
+
"sample-store.com": vapeRuleset("sample-store.com"),
|
|
77
|
+
"sample-store-2.net": vapeRuleset("sample-store-2.net"),
|
|
78
|
+
"sample-store-3.com": vapeRuleset("sample-store-3.com"),
|
|
79
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort friendly store name for a tenant's appDomain, used to humanize the
|
|
3
|
+
* developer magic-link invite — the email subject/body ("Your invitation to …")
|
|
4
|
+
* and the /auth/magic "Signing in to …" card, which otherwise fall back to the
|
|
5
|
+
* bare appDomain (the "generic" tell in the deployed invite UX).
|
|
6
|
+
*
|
|
7
|
+
* Resolves via the SAME canonical TenantResolver the middleware uses on every
|
|
8
|
+
* request (config/resolver), so the name is only ever the tenant's real
|
|
9
|
+
* displayName — never fabricated. Returns undefined when no tenant resolves or it
|
|
10
|
+
* carries no displayName; callers then omit `storeName` and the flow shows the
|
|
11
|
+
* appDomain as before. NEVER throws: a display-label lookup must not be able to
|
|
12
|
+
* break sending (or resending) an invite.
|
|
13
|
+
*
|
|
14
|
+
* Lives in the config layer (not lib) so this appDomain→displayName lookup can
|
|
15
|
+
* consume the CONCRETE resolver behind the `tenancy-resolver-fence`
|
|
16
|
+
* (.dependency-cruiser.cjs) — mirroring `config/adminTargetTenant` and
|
|
17
|
+
* `config/previewTenantLookup`, the other sanctioned resolver-consuming seams —
|
|
18
|
+
* so a single-tenant extraction stays a drop-in (the resolver is swapped, not the
|
|
19
|
+
* callers).
|
|
20
|
+
*/
|
|
21
|
+
import { getTenantResolver } from "@/config/resolver";
|
|
22
|
+
|
|
23
|
+
export async function resolveStoreName(appDomain: string): Promise<string | undefined> {
|
|
24
|
+
const domain = appDomain.trim();
|
|
25
|
+
if (!domain) return undefined;
|
|
26
|
+
try {
|
|
27
|
+
const resolver = await getTenantResolver();
|
|
28
|
+
const tenant = await resolver.resolveStrict(domain);
|
|
29
|
+
const name = tenant?.displayName?.trim();
|
|
30
|
+
return name || undefined;
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|