@tokenoftrust/storefront-runner 2.4.80 → 2.4.82
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/src/components/blog/BlogPostArticle.astro +2 -0
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +13 -1
- package/apps/storefront/src/components/islands/checkoutVerificationFlow.ts +9 -0
- package/apps/storefront/src/lib/seo/documentTitle.ts +5 -5
- package/apps/storefront/src/pages/api/newsletter/[collection]/subscribe.ts +14 -6
- package/apps/storefront/src/pages/collections/[handle].astro +2 -2
- package/apps/storefront/src/pages/products/[handle].astro +2 -2
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +3 -3
- package/packages/public-runtime/src/declared-tenant-config.mjs +3 -3
- package/packages/public-runtime/src/tenant.ts +4 -4
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* the request.
|
|
9
9
|
*/
|
|
10
10
|
import Layout from "@/layouts/Layout.astro";
|
|
11
|
+
import { sourceSeoTitle } from "@/lib/seo/documentTitle";
|
|
11
12
|
import Breadcrumbs from "@/components/Breadcrumbs.astro";
|
|
12
13
|
import BlogPaywall from "@/components/blog/BlogPaywall.astro";
|
|
13
14
|
import ReactionBar from "@/components/blog/ReactionBar.astro";
|
|
@@ -26,6 +27,7 @@ const {
|
|
|
26
27
|
|
|
27
28
|
<Layout
|
|
28
29
|
title={post.seoTitle || post.title}
|
|
30
|
+
seoTitle={sourceSeoTitle(Astro.locals.tenant.seo, post.seoTitle)}
|
|
29
31
|
description={post.description}
|
|
30
32
|
path={path}
|
|
31
33
|
ogType="article"
|
|
@@ -26,6 +26,7 @@ import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
|
|
26
26
|
import type { CartComplianceDecision } from "@tot/public-runtime";
|
|
27
27
|
import {
|
|
28
28
|
clearStoredRef,
|
|
29
|
+
isStoreNotPublicRefusal,
|
|
29
30
|
parsePreflightResponse,
|
|
30
31
|
pollVerification,
|
|
31
32
|
readStoredRef,
|
|
@@ -91,6 +92,10 @@ type DialogPhase = VerificationPhase | "stalled";
|
|
|
91
92
|
const FAIL_CLOSED_MESSAGE =
|
|
92
93
|
"We couldn't reach our compliance check. For your safety, checkout is paused — please try again.";
|
|
93
94
|
|
|
95
|
+
/** The store-not-public gate refused the preflight: checkout needs a signed-in session. */
|
|
96
|
+
const STORE_NOT_PUBLIC_MESSAGE =
|
|
97
|
+
"This store isn't open to the public yet, so checkout is unavailable here. Sign in to the store to test checkout.";
|
|
98
|
+
|
|
94
99
|
export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props) {
|
|
95
100
|
const [pending, setPending] = useState<PendingLine | null>(null);
|
|
96
101
|
const [region, setRegion] = useState("");
|
|
@@ -110,6 +115,8 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
110
115
|
const [popupBlocked, setPopupBlocked] = useState(false);
|
|
111
116
|
/** Cancellation token for the in-flight poll loop (at most one at a time). */
|
|
112
117
|
const pollToken = useRef<{ cancelled: boolean } | null>(null);
|
|
118
|
+
/** Set by the last preflight POST when the store-not-public gate answered it. */
|
|
119
|
+
const gateRefused = useRef(false);
|
|
113
120
|
|
|
114
121
|
const stopPolling = useCallback(() => {
|
|
115
122
|
if (pollToken.current) pollToken.current.cancelled = true;
|
|
@@ -191,6 +198,7 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
191
198
|
opts: { ref: string | null; includeShopper: boolean },
|
|
192
199
|
): Promise<PreflightResponse | null> => {
|
|
193
200
|
const trimmedEmail = email.trim();
|
|
201
|
+
gateRefused.current = false;
|
|
194
202
|
try {
|
|
195
203
|
const res = await fetch(preflightUrl, {
|
|
196
204
|
method: "POST",
|
|
@@ -218,6 +226,10 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
218
226
|
: {}),
|
|
219
227
|
}),
|
|
220
228
|
});
|
|
229
|
+
if (isStoreNotPublicRefusal(res)) {
|
|
230
|
+
gateRefused.current = true;
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
221
233
|
return parsePreflightResponse(await res.json());
|
|
222
234
|
} catch {
|
|
223
235
|
return null;
|
|
@@ -336,7 +348,7 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
336
348
|
setLoading(false);
|
|
337
349
|
if (!res) {
|
|
338
350
|
// Fail closed: a regulated tenant never proceeds on an unreadable result.
|
|
339
|
-
setError(FAIL_CLOSED_MESSAGE);
|
|
351
|
+
setError(gateRefused.current ? STORE_NOT_PUBLIC_MESSAGE : FAIL_CLOSED_MESSAGE);
|
|
340
352
|
return;
|
|
341
353
|
}
|
|
342
354
|
apply(pending, res);
|
|
@@ -173,6 +173,15 @@ export interface PollDeps {
|
|
|
173
173
|
timeoutMs?: number;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* True when the login gate, not the preflight route, answered: the store is not
|
|
178
|
+
* public and this viewer holds no session (see `lib/auth/gatePage.ts`). Still fail
|
|
179
|
+
* closed — the caller only names the reason instead of reporting an outage.
|
|
180
|
+
*/
|
|
181
|
+
export function isStoreNotPublicRefusal(res: Pick<Response, "status" | "headers">): boolean {
|
|
182
|
+
return res.status === 401 && res.headers.get("x-tot-gate") === "store-not-public";
|
|
183
|
+
}
|
|
184
|
+
|
|
176
185
|
/**
|
|
177
186
|
* Re-read the server's decision until it resolves. The client NEVER decides the
|
|
178
187
|
* shopper passed — it only relays `decision.status === "clear"`.
|
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
* renders as written, any other is suffixed.
|
|
14
14
|
*/
|
|
15
15
|
/**
|
|
16
|
-
* A product's or
|
|
17
|
-
* the tenant declares `seo.
|
|
16
|
+
* A product's, collection's or blog post's own SEO title, as the `seoTitle` to render verbatim,
|
|
17
|
+
* when the tenant declares `seo.verbatimSeoTitles`; otherwise undefined, so it is suffixed.
|
|
18
18
|
*/
|
|
19
|
-
export function
|
|
20
|
-
seo: {
|
|
19
|
+
export function sourceSeoTitle(
|
|
20
|
+
seo: { verbatimSeoTitles?: boolean } | undefined,
|
|
21
21
|
ownSeoTitle: string | undefined,
|
|
22
22
|
): string | undefined {
|
|
23
|
-
return seo?.
|
|
23
|
+
return seo?.verbatimSeoTitles && ownSeoTitle?.trim() ? ownSeoTitle : undefined;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function documentTitle(title: string, seoTitle: string | undefined, storeName: string): string {
|
|
@@ -110,7 +110,7 @@ export const POST: APIRoute = async (context) => {
|
|
|
110
110
|
if (!d1) {
|
|
111
111
|
// No apps DB binding (local/dev without D1): can't persist. Don't fail the
|
|
112
112
|
// visitor — log and acknowledge so the flow is exercisable in dev.
|
|
113
|
-
console.log(`[newsletter] subscribe (no D1)
|
|
113
|
+
console.log(`[newsletter] subscribe not persisted (no D1) → ${tenantId}/${collection}`);
|
|
114
114
|
return ok(context.request);
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -133,14 +133,22 @@ export const POST: APIRoute = async (context) => {
|
|
|
133
133
|
// or unsubscribed address. Best-effort; a stub failure must not fail signup.
|
|
134
134
|
if (result.status === "pending") {
|
|
135
135
|
const origin = new URL(context.request.url).origin;
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
136
|
+
// The tenant's real routed prefix: a pinned preview's basePath has no API mirror.
|
|
137
|
+
const apiBasePath = context.locals?.apiBasePath ?? context.locals?.basePath ?? "";
|
|
138
|
+
const confirmUrl = `${origin}${apiBasePath}/api/newsletter/confirm?token=${result.token}`;
|
|
139
|
+
await sendConfirmationEmail({ email, collection, confirmUrl }).catch((err: unknown) => {
|
|
140
|
+
console.error(
|
|
141
|
+
`[newsletter] confirmation email failed tenant=${tenantId} collection=${collection}: ${err instanceof Error ? err.name : "unknown"}`,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
139
144
|
}
|
|
140
145
|
|
|
141
146
|
return ok(context.request);
|
|
142
|
-
} catch {
|
|
143
|
-
// Never 500 on a signup — acknowledge
|
|
147
|
+
} catch (err) {
|
|
148
|
+
// Never 500 on a signup — acknowledge, but never swallow the failure silently.
|
|
149
|
+
console.error(
|
|
150
|
+
`[newsletter] subscribe failed collection=${collection}: ${err instanceof Error ? err.name : "unknown"}`,
|
|
151
|
+
);
|
|
144
152
|
return ok(context.request);
|
|
145
153
|
}
|
|
146
154
|
};
|
|
@@ -19,7 +19,7 @@ import { parseQuery, buildQueryString } from "@/lib/url";
|
|
|
19
19
|
import { breadcrumbLd, itemListLd } from "@/lib/jsonld";
|
|
20
20
|
import { truncate } from "@/lib/format";
|
|
21
21
|
import { pageUrlForm } from "@/lib/seo/urlForm";
|
|
22
|
-
import {
|
|
22
|
+
import { sourceSeoTitle } from "@/lib/seo/documentTitle";
|
|
23
23
|
import { withBase } from "@/lib/basePath";
|
|
24
24
|
import { isBlankDescriptionHtml, sanitizeDescriptionHtml } from "@/lib/content/sanitizeDescriptionHtml";
|
|
25
25
|
|
|
@@ -92,7 +92,7 @@ if (query.priceMin != null || query.priceMax != null) {
|
|
|
92
92
|
|
|
93
93
|
<Layout
|
|
94
94
|
title={collection.seo.title || collection.title}
|
|
95
|
-
seoTitle={
|
|
95
|
+
seoTitle={sourceSeoTitle(tenant.seo, collection.seo.title)}
|
|
96
96
|
description={collection.seo.description || (desc ? truncate(desc, 160) : `Shop ${collection.title} at ${tenant.displayName}.`)}
|
|
97
97
|
path={basePath}
|
|
98
98
|
noindex={isEmptyCollection}
|
|
@@ -47,7 +47,7 @@ import { isUsableImageUrl } from "@/lib/placeholder";
|
|
|
47
47
|
import { withBase } from "@/lib/basePath";
|
|
48
48
|
import { productPageType, structuredDataProfile } from "@/lib/seo/structuredData";
|
|
49
49
|
import { pageUrlForm } from "@/lib/seo/urlForm";
|
|
50
|
-
import {
|
|
50
|
+
import { sourceSeoTitle } from "@/lib/seo/documentTitle";
|
|
51
51
|
import { shippingPolicyPath } from "@/lib/commerce/shippingPolicy";
|
|
52
52
|
import AppWidgetFrame from "@/components/apps/AppWidgetFrame.astro";
|
|
53
53
|
import { getRegistryService } from "@/lib/apps/registryAccess.js";
|
|
@@ -314,7 +314,7 @@ const structuredData = {
|
|
|
314
314
|
|
|
315
315
|
<Layout
|
|
316
316
|
title={product.seo.title || product.title}
|
|
317
|
-
seoTitle={
|
|
317
|
+
seoTitle={sourceSeoTitle(tenant.seo, product.seo.title)}
|
|
318
318
|
description={product.seo.description || truncate(product.description_text, 160)}
|
|
319
319
|
path={`/products/${product.handle}`}
|
|
320
320
|
ogType="product"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.82",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -31,7 +31,7 @@ export const FEATURE_KEYS = [
|
|
|
31
31
|
export const SITE_TYPES = ["commerce", "marketing"];
|
|
32
32
|
|
|
33
33
|
/** Every `seo` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
|
|
34
|
-
export const SEO_KEYS = Object.freeze(["trailingSlash", "
|
|
34
|
+
export const SEO_KEYS = Object.freeze(["trailingSlash", "verbatimSeoTitles", "structuredData"]);
|
|
35
35
|
|
|
36
36
|
/** Every `seo.structuredData` key. */
|
|
37
37
|
export const STRUCTURED_DATA_KEYS = Object.freeze(["graphs", "names", "reviews", "onlyOn"]);
|
|
@@ -86,8 +86,8 @@ export function seoDeclarationProblems(seo) {
|
|
|
86
86
|
if (source.trailingSlash !== undefined && typeof source.trailingSlash !== "boolean") {
|
|
87
87
|
problems.push("`seo.trailingSlash` must be true or false");
|
|
88
88
|
}
|
|
89
|
-
if (source.
|
|
90
|
-
problems.push("`seo.
|
|
89
|
+
if (source.verbatimSeoTitles !== undefined && typeof source.verbatimSeoTitles !== "boolean") {
|
|
90
|
+
problems.push("`seo.verbatimSeoTitles` must be true or false");
|
|
91
91
|
}
|
|
92
92
|
const sd = source.structuredData;
|
|
93
93
|
if (sd === undefined) return problems;
|
|
@@ -75,7 +75,7 @@ export const FEATURE_KEYS = Object.freeze([
|
|
|
75
75
|
export const SITE_TYPES_LIST = Object.freeze(["commerce", "marketing"]);
|
|
76
76
|
|
|
77
77
|
/** Every `seo` key (SeoDeclaration, tenant.ts). */
|
|
78
|
-
export const SEO_KEYS = Object.freeze(["trailingSlash", "
|
|
78
|
+
export const SEO_KEYS = Object.freeze(["trailingSlash", "verbatimSeoTitles", "structuredData"]);
|
|
79
79
|
|
|
80
80
|
/** Every `seo.structuredData` key. */
|
|
81
81
|
export const STRUCTURED_DATA_KEYS = Object.freeze(["graphs", "names", "reviews", "onlyOn"]);
|
|
@@ -132,8 +132,8 @@ export function seoDeclarationProblems(seo) {
|
|
|
132
132
|
if (source.trailingSlash !== undefined && typeof source.trailingSlash !== "boolean") {
|
|
133
133
|
problems.push("`seo.trailingSlash` must be true or false");
|
|
134
134
|
}
|
|
135
|
-
if (source.
|
|
136
|
-
problems.push("`seo.
|
|
135
|
+
if (source.verbatimSeoTitles !== undefined && typeof source.verbatimSeoTitles !== "boolean") {
|
|
136
|
+
problems.push("`seo.verbatimSeoTitles` must be true or false");
|
|
137
137
|
}
|
|
138
138
|
const sd = source.structuredData;
|
|
139
139
|
if (sd === undefined) return problems;
|
|
@@ -778,11 +778,11 @@ export interface SeoDeclaration {
|
|
|
778
778
|
*/
|
|
779
779
|
trailingSlash?: boolean;
|
|
780
780
|
/**
|
|
781
|
-
* A product's or
|
|
782
|
-
* written (a source that already ends its titles in "| Brand"). Absent or false ⇒ it is
|
|
783
|
-
* suffixed with the store name
|
|
781
|
+
* A product's, collection's or blog post's own SEO title is the complete `<title>`, rendered
|
|
782
|
+
* as written (a source that already ends its titles in "| Brand"). Absent or false ⇒ it is
|
|
783
|
+
* suffixed with the store name unless it already names the store.
|
|
784
784
|
*/
|
|
785
|
-
|
|
785
|
+
verbatimSeoTitles?: boolean;
|
|
786
786
|
/**
|
|
787
787
|
* The structured-data profile. When present it is the ONLY JSON-LD a declared page type
|
|
788
788
|
* emits: one `@graph` holding exactly the listed nodes, in order. A node whose data the
|