@tokenoftrust/storefront-runner 2.0.17 → 2.0.18
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/env.d.ts +18 -0
- package/apps/storefront/migrations-apps/0012_test_destinations.sql +29 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +1 -1
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +365 -49
- package/apps/storefront/src/components/islands/checkoutVerificationFlow.ts +251 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +18 -7
- package/apps/storefront/src/config/devTenantSeed.ts +24 -1
- package/apps/storefront/src/config/evidenceTenantRoster.ts +25 -0
- package/apps/storefront/src/config/publicationSpaceConfig.ts +25 -0
- package/apps/storefront/src/config/resolver.ts +5 -0
- package/apps/storefront/src/config/tenantAnalyticsFiles.ts +43 -0
- package/apps/storefront/src/config/tenants.ts +11 -0
- package/apps/storefront/src/layouts/Layout.astro +5 -1
- package/apps/storefront/src/lib/activity/killSwitch.ts +1 -1
- package/apps/storefront/src/lib/admin/consentSettingsClient.ts +113 -0
- package/apps/storefront/src/lib/apps/consentConfigRead.ts +24 -0
- package/apps/storefront/src/lib/apps/gtmMigrationDraft.ts +103 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
- package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
- package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
- package/apps/storefront/src/lib/env.ts +13 -111
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
- package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
- package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
- package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
- package/apps/storefront/src/middleware/candidatePin.ts +35 -0
- package/apps/storefront/src/middleware/controlPlane.ts +270 -0
- package/apps/storefront/src/middleware/index.ts +100 -796
- package/apps/storefront/src/middleware/publicRender.ts +43 -0
- package/apps/storefront/src/middleware/renderChain.ts +685 -0
- package/apps/storefront/src/middleware/routeBranch.ts +160 -0
- package/apps/storefront/src/middleware/staticBundle.ts +109 -0
- package/apps/storefront/src/pages/[...slug].astro +1 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
- package/apps/storefront/src/pages/index.astro +1 -0
- package/apps/storefront/vitest.config.ts +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
- package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
- package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
- package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
- package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
- package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
- package/packages/public-runtime/src/activity/catalog.ts +1 -1
- package/packages/public-runtime/src/candidate-index.ts +6 -0
- package/packages/public-runtime/src/customization-reconcile.ts +8 -0
- package/packages/public-runtime/src/html-cache-policy.ts +57 -0
- package/packages/public-runtime/src/index.ts +7 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
- package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
- package/packages/public-runtime/src/integration/render-phases.ts +8 -3
- package/packages/public-runtime/src/publication-artifact.ts +203 -0
- package/packages/public-runtime/src/publication-space.ts +853 -0
- package/packages/public-runtime/src/publication-version.ts +587 -0
- package/packages/public-runtime/src/route-tier.ts +257 -0
- package/packages/public-runtime/src/static-bundle.ts +18 -5
- package/packages/public-runtime/src/tenant-assets.ts +85 -17
- package/packages/public-runtime/src/tenant.ts +70 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
- package/scripts/dev/ai-edit.mjs +16 -3
- package/scripts/dev/port-check.mjs +18 -3
- package/scripts/dev/publish.mjs +26 -3
- package/scripts/tenant/validate.mjs +14 -1
package/apps/storefront/env.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ import type {
|
|
|
10
10
|
BrowserEnvironment,
|
|
11
11
|
CompiledIntegrationCsp,
|
|
12
12
|
EffectivePlan,
|
|
13
|
+
ConsentSnapshot,
|
|
14
|
+
ResolvedRouteTierV1,
|
|
13
15
|
} from "@tot/public-runtime";
|
|
14
16
|
import type { ToTClient } from "@/lib/tot/ToTClient";
|
|
15
17
|
import type { ContentProvider } from "@/lib/storyblok/provider";
|
|
@@ -104,6 +106,12 @@ declare global {
|
|
|
104
106
|
interface Locals {
|
|
105
107
|
/** Tenant resolved from the request Host by src/middleware. */
|
|
106
108
|
tenant: TenantConfig;
|
|
109
|
+
/**
|
|
110
|
+
* THE route-tier answer for this request (ADR 0013), resolved once by the
|
|
111
|
+
* middleware from the TENANT-RELATIVE path and used to pick which branch
|
|
112
|
+
* served it. Downstream code reads this rather than re-deriving a tier.
|
|
113
|
+
*/
|
|
114
|
+
routeTier: ResolvedRouteTierV1;
|
|
107
115
|
/**
|
|
108
116
|
* Effective capability flags for the resolved tenant (commerce defaults
|
|
109
117
|
* unless siteType:"marketing"/explicit features). Chrome, layout islands,
|
|
@@ -157,6 +165,16 @@ declare global {
|
|
|
157
165
|
* wired; carries no secret and no nonce.
|
|
158
166
|
*/
|
|
159
167
|
integrationPlan: EffectivePlan;
|
|
168
|
+
/**
|
|
169
|
+
* The tenant's actual initial consent snapshot — computed once by the
|
|
170
|
+
* middleware from its saved {@link TenantConsentConfig} (ext-admin-03) via
|
|
171
|
+
* the neutral consent broker (ext-policy-03), or the fail-closed
|
|
172
|
+
* essential-only default when the tenant has never configured one. BOTH
|
|
173
|
+
* document mounts (Layout.astro + rawHtmlResponse) inline THIS value as
|
|
174
|
+
* `consent.defaults`, so a shopper's pre-interaction eligibility matches
|
|
175
|
+
* what the tenant actually approved — never a hardcoded default.
|
|
176
|
+
*/
|
|
177
|
+
consentSnapshot: ConsentSnapshot;
|
|
160
178
|
/**
|
|
161
179
|
* Resolved cart/checkout facts for this request (loader src, cart URL,
|
|
162
180
|
* mode). Present only for commerce tenants with a checkout store;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
-- A tenant-owned, NON-LIVE-ONLY destination a developer registers to prove
|
|
2
|
+
-- exactly-once delivery for a new marketing action before wiring a real vendor
|
|
3
|
+
-- adapter. `environment` is never `live` — enforced by the managed-analytics
|
|
4
|
+
-- service, not by this table.
|
|
5
|
+
CREATE TABLE `test_destinations` (
|
|
6
|
+
`destination_id` text PRIMARY KEY NOT NULL,
|
|
7
|
+
`tenant_id` text NOT NULL,
|
|
8
|
+
`environment` text NOT NULL,
|
|
9
|
+
`created_at` text NOT NULL
|
|
10
|
+
);
|
|
11
|
+
--> statement-breakpoint
|
|
12
|
+
CREATE INDEX `idx_test_destinations_tenant`
|
|
13
|
+
ON `test_destinations` (`tenant_id`);
|
|
14
|
+
--> statement-breakpoint
|
|
15
|
+
-- The delivered-event ledger a test destination reads back. Keyed by
|
|
16
|
+
-- (destination_id, event_id): a retried/duplicate delivery of the same event id
|
|
17
|
+
-- is `INSERT OR IGNORE`, so the row a caller reads back always reflects the
|
|
18
|
+
-- FIRST delivery only — the proof that delivery landed exactly once.
|
|
19
|
+
CREATE TABLE `test_destination_deliveries` (
|
|
20
|
+
`destination_id` text NOT NULL,
|
|
21
|
+
`event_id` text NOT NULL,
|
|
22
|
+
`tenant_id` text NOT NULL,
|
|
23
|
+
`action_name` text NOT NULL,
|
|
24
|
+
`delivered_at` text NOT NULL,
|
|
25
|
+
PRIMARY KEY(`destination_id`, `event_id`)
|
|
26
|
+
);
|
|
27
|
+
--> statement-breakpoint
|
|
28
|
+
CREATE INDEX `idx_test_destination_deliveries_tenant`
|
|
29
|
+
ON `test_destination_deliveries` (`tenant_id`);
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* view counts). Tenant/collection-generic: mounts on any post route.
|
|
10
10
|
*/
|
|
11
11
|
import { getReactionCounts, REACTIONS, type Reaction } from "@/lib/blog/reactions.js";
|
|
12
|
-
import { readKv } from "@/lib/
|
|
12
|
+
import { readKv } from "@/lib/runtimeEnv";
|
|
13
13
|
import { withBase } from "@/lib/basePath";
|
|
14
14
|
|
|
15
15
|
interface Props {
|
|
@@ -11,6 +11,16 @@
|
|
|
11
11
|
* unconfigured verification) STOPS it and shows why. Fail-closed by construction:
|
|
12
12
|
* if the preflight itself errors on a regulated tenant, we do NOT hand off.
|
|
13
13
|
*
|
|
14
|
+
* On a tenant wired to the LIVE Token of Trust verification provider, preflight
|
|
15
|
+
* additionally echoes a `verification` block (ref / provider status / continuation
|
|
16
|
+
* URL). That block's presence — and only that — branches this dialog into the live
|
|
17
|
+
* trip: collect the shopper's email, start the verification, open the provider's
|
|
18
|
+
* continuation URL in a new tab, then re-read the SERVER's decision on an interval
|
|
19
|
+
* until it clears or denies. Sandbox / no-provider tenants never receive the block
|
|
20
|
+
* and see exactly the flow they always did. The client never decides a pass:
|
|
21
|
+
* `decision.status === "clear"` from the server is the only thing that releases the
|
|
22
|
+
* handoff, here as everywhere else. See `checkoutVerificationFlow.ts`.
|
|
23
|
+
*
|
|
14
24
|
* This is progressive enhancement: the underlying form still posts the real
|
|
15
25
|
* (server-pre-signed) cart fields. If this island never hydrates, the form submits
|
|
16
26
|
* as before — so the gate hardens the flow without being a single point of failure
|
|
@@ -20,6 +30,17 @@
|
|
|
20
30
|
*/
|
|
21
31
|
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
|
22
32
|
import type { CartComplianceDecision } from "@tot/public-runtime";
|
|
33
|
+
import {
|
|
34
|
+
clearStoredRef,
|
|
35
|
+
parsePreflightResponse,
|
|
36
|
+
pollVerification,
|
|
37
|
+
readStoredRef,
|
|
38
|
+
verificationPhase,
|
|
39
|
+
verificationRefKey,
|
|
40
|
+
writeStoredRef,
|
|
41
|
+
type PreflightResponse,
|
|
42
|
+
type VerificationPhase,
|
|
43
|
+
} from "./checkoutVerificationFlow.js";
|
|
23
44
|
|
|
24
45
|
interface Props {
|
|
25
46
|
/** Absolute (base-path-aware) URL of the preflight endpoint. */
|
|
@@ -70,6 +91,12 @@ interface PendingLine {
|
|
|
70
91
|
title: string;
|
|
71
92
|
}
|
|
72
93
|
|
|
94
|
+
/** Shopper-facing phase, extended with the two paused states polling can end in. */
|
|
95
|
+
type DialogPhase = VerificationPhase | "stalled";
|
|
96
|
+
|
|
97
|
+
const FAIL_CLOSED_MESSAGE =
|
|
98
|
+
"We couldn't reach our compliance check. For your safety, checkout is paused — please try again.";
|
|
99
|
+
|
|
73
100
|
export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props) {
|
|
74
101
|
const [pending, setPending] = useState<PendingLine | null>(null);
|
|
75
102
|
const [region, setRegion] = useState("");
|
|
@@ -79,6 +106,22 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
79
106
|
const [error, setError] = useState<string | null>(null);
|
|
80
107
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
81
108
|
|
|
109
|
+
// --- Live-provider verification trip (inert on sandbox / no-provider tenants).
|
|
110
|
+
const [phase, setPhase] = useState<DialogPhase>("none");
|
|
111
|
+
const [email, setEmail] = useState("");
|
|
112
|
+
const [givenName, setGivenName] = useState("");
|
|
113
|
+
const [familyName, setFamilyName] = useState("");
|
|
114
|
+
const [verificationRef, setVerificationRef] = useState<string | null>(null);
|
|
115
|
+
const [continuationUrl, setContinuationUrl] = useState<string | null>(null);
|
|
116
|
+
const [popupBlocked, setPopupBlocked] = useState(false);
|
|
117
|
+
/** Cancellation token for the in-flight poll loop (at most one at a time). */
|
|
118
|
+
const pollToken = useRef<{ cancelled: boolean } | null>(null);
|
|
119
|
+
|
|
120
|
+
const stopPolling = useCallback(() => {
|
|
121
|
+
if (pollToken.current) pollToken.current.cancelled = true;
|
|
122
|
+
pollToken.current = null;
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
82
125
|
// Intercept managed-cart form submits (capture phase, before the native POST).
|
|
83
126
|
useEffect(() => {
|
|
84
127
|
function onSubmit(e: Event) {
|
|
@@ -91,16 +134,23 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
91
134
|
e.stopPropagation();
|
|
92
135
|
const qtyInput = form.querySelector<HTMLInputElement>('input[name$="quantity" i], input[name="quantity"]');
|
|
93
136
|
const quantity = Math.max(1, parseInt(qtyInput?.value ?? "1", 10) || 1);
|
|
94
|
-
|
|
137
|
+
const line: PendingLine = {
|
|
95
138
|
form,
|
|
96
139
|
handle: form.dataset.productHandle ?? "",
|
|
97
140
|
sku: form.dataset.variantSku || undefined,
|
|
98
141
|
variantId: form.dataset.variantId || undefined,
|
|
99
142
|
quantity,
|
|
100
143
|
title: form.dataset.productTitle ?? "this item",
|
|
101
|
-
}
|
|
144
|
+
};
|
|
145
|
+
setPending(line);
|
|
102
146
|
setDecision(null);
|
|
103
147
|
setError(null);
|
|
148
|
+
setPhase("none");
|
|
149
|
+
setContinuationUrl(null);
|
|
150
|
+
setPopupBlocked(false);
|
|
151
|
+
// A reload mid-trip must not lose the correlation: replay the stored ref so
|
|
152
|
+
// the server re-reads THIS attempt's verification instead of starting over.
|
|
153
|
+
setVerificationRef(readStoredRef(verificationRefKey(preflightUrl, line)));
|
|
104
154
|
let pre = false;
|
|
105
155
|
try {
|
|
106
156
|
pre = localStorage.getItem(AGE_KEY) === "yes";
|
|
@@ -112,17 +162,21 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
112
162
|
}
|
|
113
163
|
document.addEventListener("submit", onSubmit, true);
|
|
114
164
|
return () => document.removeEventListener("submit", onSubmit, true);
|
|
115
|
-
}, []);
|
|
165
|
+
}, [preflightUrl]);
|
|
116
166
|
|
|
117
167
|
const close = useCallback(() => {
|
|
168
|
+
stopPolling();
|
|
118
169
|
setPending(null);
|
|
119
170
|
setDecision(null);
|
|
120
171
|
setError(null);
|
|
121
172
|
setLoading(false);
|
|
173
|
+
setPhase("none");
|
|
122
174
|
document.documentElement.style.overflow = "";
|
|
123
|
-
}, []);
|
|
175
|
+
}, [stopPolling]);
|
|
124
176
|
|
|
125
177
|
const proceed = useCallback((line: PendingLine) => {
|
|
178
|
+
stopPolling();
|
|
179
|
+
clearStoredRef(verificationRefKey(preflightUrl, line));
|
|
126
180
|
line.form.dataset.complianceCleared = "1";
|
|
127
181
|
document.documentElement.style.overflow = "";
|
|
128
182
|
setPending(null);
|
|
@@ -130,54 +184,194 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
130
184
|
// managed-cart loader takes over the (still server-pre-signed) POST.
|
|
131
185
|
if (typeof line.form.requestSubmit === "function") line.form.requestSubmit();
|
|
132
186
|
else line.form.submit();
|
|
133
|
-
}, []);
|
|
187
|
+
}, [preflightUrl, stopPolling]);
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* One preflight POST. `null` for a transport error or any body we cannot fully
|
|
191
|
+
* read — the caller treats both as fail-closed.
|
|
192
|
+
*/
|
|
193
|
+
const send = useCallback(
|
|
194
|
+
async (
|
|
195
|
+
line: PendingLine,
|
|
196
|
+
opts: { ref: string | null; includeShopper: boolean },
|
|
197
|
+
): Promise<PreflightResponse | null> => {
|
|
198
|
+
const trimmedEmail = email.trim();
|
|
199
|
+
try {
|
|
200
|
+
const res = await fetch(preflightUrl, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "content-type": "application/json" },
|
|
203
|
+
body: JSON.stringify({
|
|
204
|
+
lines: [
|
|
205
|
+
{
|
|
206
|
+
handle: line.handle,
|
|
207
|
+
sku: line.sku,
|
|
208
|
+
variantId: line.variantId,
|
|
209
|
+
quantity: line.quantity,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
destination: region ? { region, country: "US" } : null,
|
|
213
|
+
ageAffirmed: affirmed,
|
|
214
|
+
...(opts.ref ? { verificationRef: opts.ref } : {}),
|
|
215
|
+
...(opts.includeShopper && trimmedEmail
|
|
216
|
+
? {
|
|
217
|
+
shopper: {
|
|
218
|
+
email: trimmedEmail,
|
|
219
|
+
...(givenName.trim() ? { givenName: givenName.trim() } : {}),
|
|
220
|
+
...(familyName.trim() ? { familyName: familyName.trim() } : {}),
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
: {}),
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
return parsePreflightResponse(await res.json());
|
|
227
|
+
} catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
[preflightUrl, region, affirmed, email, givenName, familyName],
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Wait for the shopper to finish on their own device by RE-READING the server's
|
|
236
|
+
* decision. The client is never told "done" by the provider widget — it asks.
|
|
237
|
+
*/
|
|
238
|
+
const startPolling = useCallback(
|
|
239
|
+
(line: PendingLine, ref: string) => {
|
|
240
|
+
stopPolling();
|
|
241
|
+
const token = { cancelled: false };
|
|
242
|
+
pollToken.current = token;
|
|
243
|
+
void pollVerification({
|
|
244
|
+
poll: () => send(line, { ref, includeShopper: false }),
|
|
245
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
246
|
+
now: () => Date.now(),
|
|
247
|
+
cancelled: () => token.cancelled,
|
|
248
|
+
}).then((outcome) => {
|
|
249
|
+
if (token.cancelled) return;
|
|
250
|
+
pollToken.current = null;
|
|
251
|
+
switch (outcome.kind) {
|
|
252
|
+
case "cleared":
|
|
253
|
+
setDecision(outcome.response.decision);
|
|
254
|
+
proceed(line);
|
|
255
|
+
return;
|
|
256
|
+
case "denied":
|
|
257
|
+
setDecision(outcome.response.decision);
|
|
258
|
+
setPhase("denied");
|
|
259
|
+
clearStoredRef(verificationRefKey(preflightUrl, line));
|
|
260
|
+
setVerificationRef(null);
|
|
261
|
+
return;
|
|
262
|
+
case "halted":
|
|
263
|
+
// Verification settled but the cart still isn't clear (another block,
|
|
264
|
+
// or a decision polling can't move). Stop, show it, stay re-checkable.
|
|
265
|
+
setDecision(outcome.response.decision);
|
|
266
|
+
setPhase(
|
|
267
|
+
verificationPhase(outcome.response) === "cleared" ? "cleared" : "stalled",
|
|
268
|
+
);
|
|
269
|
+
return;
|
|
270
|
+
case "error":
|
|
271
|
+
// Fail closed: an unreadable poll never proceeds; it pauses.
|
|
272
|
+
setError(FAIL_CLOSED_MESSAGE);
|
|
273
|
+
setPhase("stalled");
|
|
274
|
+
return;
|
|
275
|
+
case "timeout":
|
|
276
|
+
setPhase("stalled");
|
|
277
|
+
return;
|
|
278
|
+
default:
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
[send, proceed, preflightUrl, stopPolling],
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const apply = useCallback(
|
|
287
|
+
(line: PendingLine, res: PreflightResponse) => {
|
|
288
|
+
const key = verificationRefKey(preflightUrl, line);
|
|
289
|
+
const meta = res.verification;
|
|
290
|
+
const ref = meta?.ref ?? null;
|
|
291
|
+
if (ref) {
|
|
292
|
+
setVerificationRef(ref);
|
|
293
|
+
writeStoredRef(key, ref);
|
|
294
|
+
}
|
|
295
|
+
if (meta?.continuationUrl) setContinuationUrl(meta.continuationUrl);
|
|
296
|
+
|
|
297
|
+
setDecision(res.decision);
|
|
298
|
+
// Only the server's own verdict releases the handoff.
|
|
299
|
+
if (res.decision.status === "clear") {
|
|
300
|
+
proceed(line);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const next = verificationPhase(res);
|
|
305
|
+
setPhase(next);
|
|
306
|
+
if (next === "denied") {
|
|
307
|
+
clearStoredRef(key);
|
|
308
|
+
setVerificationRef(null);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (next === "continue" && meta?.continuationUrl && ref) {
|
|
312
|
+
// The provider's own embedded workflow, opened in a new tab: no
|
|
313
|
+
// third-party framing/cookie surprises, and nothing here assumes it can
|
|
314
|
+
// observe completion — the poll below asks the server instead.
|
|
315
|
+
let opened: Window | null = null;
|
|
316
|
+
try {
|
|
317
|
+
opened = window.open(meta.continuationUrl, "_blank", "noopener,noreferrer");
|
|
318
|
+
} catch {
|
|
319
|
+
opened = null;
|
|
320
|
+
}
|
|
321
|
+
setPopupBlocked(!opened);
|
|
322
|
+
startPolling(line, ref);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (next === "waiting" && ref) startPolling(line, ref);
|
|
326
|
+
},
|
|
327
|
+
[preflightUrl, proceed, startPolling],
|
|
328
|
+
);
|
|
134
329
|
|
|
135
330
|
const check = useCallback(async () => {
|
|
136
331
|
if (!pending) return;
|
|
332
|
+
stopPolling();
|
|
137
333
|
setLoading(true);
|
|
138
334
|
setError(null);
|
|
139
335
|
setDecision(null);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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);
|
|
336
|
+
setPopupBlocked(false);
|
|
337
|
+
const res = await send(pending, {
|
|
338
|
+
ref: verificationRef,
|
|
339
|
+
includeShopper: phase === "collect" || phase === "stalled",
|
|
340
|
+
});
|
|
341
|
+
setLoading(false);
|
|
342
|
+
if (!res) {
|
|
343
|
+
// Fail closed: a regulated tenant never proceeds on an unreadable result.
|
|
344
|
+
setError(FAIL_CLOSED_MESSAGE);
|
|
345
|
+
return;
|
|
174
346
|
}
|
|
175
|
-
|
|
347
|
+
apply(pending, res);
|
|
348
|
+
}, [pending, send, apply, verificationRef, phase, stopPolling]);
|
|
349
|
+
|
|
350
|
+
/** Abandon a denied attempt and start a brand-new verification (new ref). */
|
|
351
|
+
const restartVerification = useCallback(() => {
|
|
352
|
+
stopPolling();
|
|
353
|
+
setVerificationRef(null);
|
|
354
|
+
setContinuationUrl(null);
|
|
355
|
+
setDecision(null);
|
|
356
|
+
setError(null);
|
|
357
|
+
setPhase("collect");
|
|
358
|
+
if (pending) clearStoredRef(verificationRefKey(preflightUrl, pending));
|
|
359
|
+
}, [pending, preflightUrl, stopPolling]);
|
|
176
360
|
|
|
177
361
|
if (!pending) return null;
|
|
178
362
|
|
|
179
363
|
const blocks = decision?.blocks ?? [];
|
|
180
364
|
const adultSignature = decision?.requirements.adultSignature;
|
|
365
|
+
const waiting = phase === "continue" || phase === "waiting";
|
|
366
|
+
const primaryLabel = loading
|
|
367
|
+
? "Checking…"
|
|
368
|
+
: waiting
|
|
369
|
+
? "Waiting for verification…"
|
|
370
|
+
: phase === "collect"
|
|
371
|
+
? "Start verification"
|
|
372
|
+
: phase === "stalled"
|
|
373
|
+
? "Check verification status"
|
|
374
|
+
: "Continue to checkout";
|
|
181
375
|
|
|
182
376
|
return (
|
|
183
377
|
<div
|
|
@@ -236,10 +430,64 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
236
430
|
/>
|
|
237
431
|
<span>I confirm I am {minAge} or older.</span>
|
|
238
432
|
</label>
|
|
433
|
+
|
|
434
|
+
{/*
|
|
435
|
+
LIVE provider only. Rendered strictly when the server has told us a
|
|
436
|
+
verification is required AND unresolved for this attempt — sandbox /
|
|
437
|
+
no-provider tenants stay on the flow above, untouched.
|
|
438
|
+
*/}
|
|
439
|
+
{phase === "collect" && (
|
|
440
|
+
<div class="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-bg)] p-4">
|
|
441
|
+
<p class="text-sm text-[var(--color-text)]">
|
|
442
|
+
This order needs a one-time age verification. Enter your email and
|
|
443
|
+
we'll open a secure step you can complete on this device.
|
|
444
|
+
</p>
|
|
445
|
+
<label class="flex flex-col gap-1.5">
|
|
446
|
+
<span class="font-mono text-xs uppercase tracking-[0.12em] text-[var(--color-muted)]">
|
|
447
|
+
Email
|
|
448
|
+
</span>
|
|
449
|
+
<input
|
|
450
|
+
type="email"
|
|
451
|
+
required
|
|
452
|
+
autocomplete="email"
|
|
453
|
+
name="tot-verification-email"
|
|
454
|
+
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2.5 text-[var(--color-text)]"
|
|
455
|
+
value={email}
|
|
456
|
+
onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
|
|
457
|
+
/>
|
|
458
|
+
</label>
|
|
459
|
+
<div class="flex flex-wrap gap-3">
|
|
460
|
+
<label class="flex min-w-[9rem] flex-1 flex-col gap-1.5">
|
|
461
|
+
<span class="font-mono text-xs uppercase tracking-[0.12em] text-[var(--color-muted)]">
|
|
462
|
+
First name (optional)
|
|
463
|
+
</span>
|
|
464
|
+
<input
|
|
465
|
+
type="text"
|
|
466
|
+
autocomplete="given-name"
|
|
467
|
+
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2.5 text-[var(--color-text)]"
|
|
468
|
+
value={givenName}
|
|
469
|
+
onInput={(e) => setGivenName((e.target as HTMLInputElement).value)}
|
|
470
|
+
/>
|
|
471
|
+
</label>
|
|
472
|
+
<label class="flex min-w-[9rem] flex-1 flex-col gap-1.5">
|
|
473
|
+
<span class="font-mono text-xs uppercase tracking-[0.12em] text-[var(--color-muted)]">
|
|
474
|
+
Last name (optional)
|
|
475
|
+
</span>
|
|
476
|
+
<input
|
|
477
|
+
type="text"
|
|
478
|
+
autocomplete="family-name"
|
|
479
|
+
class="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2.5 text-[var(--color-text)]"
|
|
480
|
+
value={familyName}
|
|
481
|
+
onInput={(e) => setFamilyName((e.target as HTMLInputElement).value)}
|
|
482
|
+
/>
|
|
483
|
+
</label>
|
|
484
|
+
</div>
|
|
485
|
+
</div>
|
|
486
|
+
)}
|
|
239
487
|
</div>
|
|
240
488
|
|
|
241
489
|
{/* Decision surface */}
|
|
242
|
-
{blocks.length > 0 && (
|
|
490
|
+
{blocks.length > 0 && phase !== "denied" && (
|
|
243
491
|
<div
|
|
244
492
|
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
493
|
role="alert"
|
|
@@ -257,6 +505,64 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
257
505
|
</div>
|
|
258
506
|
)}
|
|
259
507
|
|
|
508
|
+
{/* Live verification trip */}
|
|
509
|
+
{(phase === "continue" || phase === "waiting") && (
|
|
510
|
+
<div class="mt-5 rounded-[var(--radius-md)] border border-[var(--color-border)] px-4 py-3">
|
|
511
|
+
<p class="font-display text-sm font-semibold text-[var(--color-text)]">
|
|
512
|
+
Finish your age verification
|
|
513
|
+
</p>
|
|
514
|
+
{phase === "continue" && continuationUrl && (
|
|
515
|
+
<p class="mt-1.5 text-sm text-[var(--color-text)]">
|
|
516
|
+
{popupBlocked
|
|
517
|
+
? "Your browser blocked the verification window — open it here:"
|
|
518
|
+
: "We opened a secure verification step in a new tab. If you don't see it:"}{" "}
|
|
519
|
+
<a
|
|
520
|
+
href={continuationUrl}
|
|
521
|
+
target="_blank"
|
|
522
|
+
rel="noopener noreferrer"
|
|
523
|
+
class="underline"
|
|
524
|
+
onClick={() => setPopupBlocked(false)}
|
|
525
|
+
>
|
|
526
|
+
open verification
|
|
527
|
+
</a>
|
|
528
|
+
.
|
|
529
|
+
</p>
|
|
530
|
+
)}
|
|
531
|
+
<p class="mt-1.5 text-sm text-[var(--color-muted)]">
|
|
532
|
+
Keep this page open — checkout continues automatically once your
|
|
533
|
+
verification is confirmed.
|
|
534
|
+
</p>
|
|
535
|
+
</div>
|
|
536
|
+
)}
|
|
537
|
+
|
|
538
|
+
{phase === "denied" && (
|
|
539
|
+
<div
|
|
540
|
+
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"
|
|
541
|
+
role="alert"
|
|
542
|
+
>
|
|
543
|
+
<p class="font-display text-sm font-semibold text-[var(--color-sale)]">
|
|
544
|
+
Verification didn't pass
|
|
545
|
+
</p>
|
|
546
|
+
<p class="mt-1.5 text-sm text-[var(--color-text)]">
|
|
547
|
+
We couldn't confirm you meet the {minAge}+ requirement for this
|
|
548
|
+
order, so it can't proceed. If you believe this is a mistake you can
|
|
549
|
+
start a new verification.
|
|
550
|
+
</p>
|
|
551
|
+
</div>
|
|
552
|
+
)}
|
|
553
|
+
|
|
554
|
+
{phase === "stalled" && !error && blocks.length === 0 && (
|
|
555
|
+
<div
|
|
556
|
+
class="mt-5 rounded-[var(--radius-md)] border border-[var(--color-border)] px-4 py-3"
|
|
557
|
+
role="status"
|
|
558
|
+
>
|
|
559
|
+
<p class="text-sm text-[var(--color-text)]">
|
|
560
|
+
We stopped waiting for your verification. Checkout is paused — once
|
|
561
|
+
you've finished the verification step, check again.
|
|
562
|
+
</p>
|
|
563
|
+
</div>
|
|
564
|
+
)}
|
|
565
|
+
|
|
260
566
|
{error && (
|
|
261
567
|
<p
|
|
262
568
|
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)]"
|
|
@@ -273,14 +579,24 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
273
579
|
)}
|
|
274
580
|
|
|
275
581
|
<div class="mt-6 flex flex-wrap gap-3">
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
582
|
+
{phase === "denied" ? (
|
|
583
|
+
<button
|
|
584
|
+
type="button"
|
|
585
|
+
onClick={restartVerification}
|
|
586
|
+
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"
|
|
587
|
+
>
|
|
588
|
+
Start a new verification
|
|
589
|
+
</button>
|
|
590
|
+
) : (
|
|
591
|
+
<button
|
|
592
|
+
type="button"
|
|
593
|
+
disabled={loading || waiting || (phase === "collect" && !email.trim())}
|
|
594
|
+
onClick={check}
|
|
595
|
+
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"
|
|
596
|
+
>
|
|
597
|
+
{primaryLabel}
|
|
598
|
+
</button>
|
|
599
|
+
)}
|
|
284
600
|
<button
|
|
285
601
|
type="button"
|
|
286
602
|
onClick={close}
|