@tokenoftrust/storefront-runner 4.7.9 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/src/components/content/storefront/blog/PostGrid.astro +8 -4
- package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +20 -2
- package/apps/storefront/src/lib/content/shared/homeVisualParity.ts +69 -0
- package/apps/storefront/src/lib/content/storyblok/content-model.ts +21 -0
- package/apps/storefront/src/lib/orders/admin/orderSource.ts +26 -5
- package/apps/storefront/src/lib/orders/admin/services/orders.ts +121 -22
- package/apps/storefront/src/lib/orders/admin/viewmodels/orders.ts +7 -0
- package/apps/storefront/src/lib/orders/fixtures/orders.ts +2 -0
- package/apps/storefront/src/pages/index.astro +43 -1
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +42 -1
- package/packages/public-runtime/src/declared-tenant-config.d.mts +4 -1
- package/packages/public-runtime/src/declared-tenant-config.mjs +40 -1
- package/packages/public-runtime/src/extension-contract-values.d.mts +4 -1
- package/packages/public-runtime/src/extension-contract-values.mjs +6 -1
- package/packages/public-runtime/src/extension-contract.ts +2 -0
- package/packages/public-runtime/src/tenant.ts +6 -0
- package/scripts/tenant/validate.mjs +17 -0
|
@@ -10,9 +10,13 @@ interface Props {
|
|
|
10
10
|
posts: BlogPostSummary[];
|
|
11
11
|
basePath: string;
|
|
12
12
|
paths: CollectionPathsV1;
|
|
13
|
+
/** Card title level. An archive page uses h2; a section under its own h2 uses h3. */
|
|
14
|
+
headingLevel?: "h2" | "h3";
|
|
15
|
+
/** Leading cards whose image loads eagerly. Below-the-fold placements pass 0. */
|
|
16
|
+
eagerCount?: number;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
|
-
const { posts, basePath, paths } = Astro.props;
|
|
19
|
+
const { posts, basePath, paths, headingLevel = "h2", eagerCount = 3 } = Astro.props;
|
|
16
20
|
const href = (path: string) => `${basePath}${path}` || "/";
|
|
17
21
|
---
|
|
18
22
|
|
|
@@ -26,14 +30,14 @@ const href = (path: string) => `${basePath}${path}` || "/";
|
|
|
26
30
|
alt={post.featuredImage.alt}
|
|
27
31
|
width={post.featuredImage.width}
|
|
28
32
|
height={post.featuredImage.height}
|
|
29
|
-
loading={index <
|
|
33
|
+
loading={index < eagerCount ? "eager" : "lazy"}
|
|
30
34
|
decoding="async"
|
|
31
35
|
/>
|
|
32
36
|
) : <div class="image-placeholder" aria-hidden="true"></div>}
|
|
33
37
|
<div class="post-card-copy">
|
|
34
38
|
<p class="post-meta"><time datetime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}</time>{post.tags[0] && <> · {post.tags[0]}</>}</p>
|
|
35
39
|
<PostAccessBadge access={post.access} />
|
|
36
|
-
<h2>{post.title}</h2>
|
|
40
|
+
{headingLevel === "h3" ? <h3>{post.title}</h3> : <h2>{post.title}</h2>}
|
|
37
41
|
<p>{post.excerpt}</p>
|
|
38
42
|
<span>Read article <span aria-hidden="true">→</span></span>
|
|
39
43
|
</div>
|
|
@@ -50,7 +54,7 @@ const href = (path: string) => `${basePath}${path}` || "/";
|
|
|
50
54
|
.post-card img, .image-placeholder { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 12%, white), color-mix(in srgb, var(--color-accent) 24%, white)); }
|
|
51
55
|
.post-card-copy { display: flex; flex: 1; flex-direction: column; padding: 1.25rem; }
|
|
52
56
|
.post-meta { margin: 0 0 .7rem; color: var(--color-muted); font: 600 .72rem/1.45 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
|
|
53
|
-
.post-card h2 { font-size: 1.18rem; line-height: 1.25; }
|
|
57
|
+
.post-card h2, .post-card h3 { font-size: 1.18rem; line-height: 1.25; }
|
|
54
58
|
.post-card-copy > p:not(.post-meta) { display: -webkit-box; margin: .75rem 0 1rem; overflow: hidden; color: var(--color-muted); font-size: .92rem; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
|
|
55
59
|
.post-card-copy > span { margin-top: auto; color: var(--color-accent-ink); font-weight: 700; font-size: .88rem; }
|
|
56
60
|
@media (max-width: 980px) { .post-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
* carries only the reason, the cause, elapsed ms, whether the action was held,
|
|
36
36
|
* whether `FC.client` existed, Foxy's own full-page and bypass decisions, the
|
|
37
37
|
* document ready state and viewport/screen/body widths — never a URL or session.
|
|
38
|
+
* A CSP refusal raised by the drawer's own scripts is the cause when nothing else
|
|
39
|
+
* failed (`csp-eval`, `csp-inline`, `csp-resource`): the page policy admits no
|
|
40
|
+
* eval, so a store template that carries an inline script breaks the drawer.
|
|
38
41
|
*
|
|
39
42
|
* Every cart open waits for this store's Foxy session. The id comes from the
|
|
40
43
|
* same-origin session endpoint (an HttpOnly cookie the browser cannot read) and
|
|
@@ -271,6 +274,20 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
271
274
|
failure = cause;
|
|
272
275
|
runHeld();
|
|
273
276
|
}
|
|
277
|
+
// A drawer can report ready and still fail to render: the page CSP has no
|
|
278
|
+
// 'unsafe-eval', so a store template carrying an inline <script> (which the
|
|
279
|
+
// drawer's jQuery runs through eval) throws inside cart.render. A refusal raised
|
|
280
|
+
// by the drawer's own scripts is kept as the cause of a later fall-through.
|
|
281
|
+
var drawerOrigins = [cartOrigin];
|
|
282
|
+
try { drawerOrigins.push(new URL(src, document.baseURI).origin); } catch (e) {}
|
|
283
|
+
var cspRefusal = null;
|
|
284
|
+
document.addEventListener('securitypolicyviolation', function (event) {
|
|
285
|
+
var from = null;
|
|
286
|
+
try { from = new URL(event.sourceFile).origin; } catch (e) {}
|
|
287
|
+
if (!from || drawerOrigins.indexOf(from) === -1) return;
|
|
288
|
+
var blocked = event.blockedURI;
|
|
289
|
+
cspRefusal = 'csp-' + (blocked === 'eval' || blocked === 'inline' ? blocked : 'resource');
|
|
290
|
+
});
|
|
274
291
|
// A cart action reaching the window unhandled after the drawer settled navigates
|
|
275
292
|
// to the full cart page. Record why, with no URL or session in the record.
|
|
276
293
|
function fallthrough(kind, el) {
|
|
@@ -284,9 +301,10 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
284
301
|
return !!(node && node.classList && (node.classList.contains('fc-sidecart--bypass') || node.hasAttribute('data-fc-sidecart-bypass')));
|
|
285
302
|
}
|
|
286
303
|
var reason = state === 'ready' ? 'drawer-declined' : 'drawer-unavailable';
|
|
304
|
+
var cause = failure || cspRefusal;
|
|
287
305
|
var detail = {
|
|
288
306
|
reason: reason,
|
|
289
|
-
cause:
|
|
307
|
+
cause: cause,
|
|
290
308
|
kind: kind,
|
|
291
309
|
held: replaying === el,
|
|
292
310
|
elapsedMs: armedAt ? Date.now() - armedAt : null,
|
|
@@ -298,7 +316,7 @@ export function lazyCheckoutLoaderJs(options: LazyCheckoutLoaderOptions): string
|
|
|
298
316
|
screenWidth: window.screen ? window.screen.availWidth : null,
|
|
299
317
|
bodyWidth: body ? body.clientWidth : null
|
|
300
318
|
};
|
|
301
|
-
document.documentElement.setAttribute('data-cart-fallthrough', reason + (
|
|
319
|
+
document.documentElement.setAttribute('data-cart-fallthrough', reason + (cause ? ':' + cause : ''));
|
|
302
320
|
try { console.warn('[tot-cart] cart fell through to the cart page', detail); } catch (e) {}
|
|
303
321
|
try { window.dispatchEvent(new window.CustomEvent('tot:cart-fallthrough', { detail: detail })); } catch (e) {}
|
|
304
322
|
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
|
+
LATEST_POSTS_DEFAULT_LIMIT,
|
|
3
|
+
LATEST_POSTS_MAX_LIMIT,
|
|
2
4
|
isPageBlockImageReference,
|
|
3
5
|
isSafeStorefrontHref,
|
|
4
6
|
} from "@tot/public-runtime";
|
|
5
7
|
import { withBase } from "@/lib/publication/shared/basePath";
|
|
8
|
+
import { categorySlug } from "@/lib/content/blog/collection.js";
|
|
9
|
+
import type { BlogPostSummary } from "@/lib/content/blog/types.js";
|
|
6
10
|
import type {
|
|
7
11
|
CommerceTrustStripBlock,
|
|
8
12
|
EditorialBlock,
|
|
9
13
|
FaqBlock,
|
|
10
14
|
HomeBlock,
|
|
15
|
+
LatestPostsBlock,
|
|
11
16
|
MarketingBlock,
|
|
12
17
|
MembershipBandBlock,
|
|
13
18
|
NewsletterBlock,
|
|
@@ -142,6 +147,70 @@ export function editorialBlockView(block: EditorialBlock): EditorialBlockView {
|
|
|
142
147
|
};
|
|
143
148
|
}
|
|
144
149
|
|
|
150
|
+
export interface LatestPostsView {
|
|
151
|
+
eyebrow?: string;
|
|
152
|
+
title: string;
|
|
153
|
+
posts: BlogPostSummary[];
|
|
154
|
+
cta: { label: string; href: string } | null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const LATEST_POSTS_DEFAULT_TITLE = "Latest posts";
|
|
158
|
+
const LATEST_POSTS_DEFAULT_CTA_LABEL = "View all";
|
|
159
|
+
|
|
160
|
+
/** The card count a `latest_posts` block shows: a whole number from 1 to the cap, else the default. */
|
|
161
|
+
export function latestPostsLimit(value: unknown): number {
|
|
162
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1
|
|
163
|
+
? Math.min(value, LATEST_POSTS_MAX_LIMIT)
|
|
164
|
+
: LATEST_POSTS_DEFAULT_LIMIT;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The newest `limit` posts, newest first by `publishedAt` (an unreadable date sorts last, ties keep
|
|
169
|
+
* the collection's own order), narrowed to one tag when the block declares it. The caller passes
|
|
170
|
+
* the posts the live blog lists, so drafts and unlisted posts never reach this.
|
|
171
|
+
*/
|
|
172
|
+
export function selectLatestPosts(
|
|
173
|
+
posts: readonly BlogPostSummary[],
|
|
174
|
+
block: Pick<LatestPostsBlock, "limit" | "tag">,
|
|
175
|
+
): BlogPostSummary[] {
|
|
176
|
+
const tag = declaredText(block.tag);
|
|
177
|
+
const wanted = tag === undefined ? null : categorySlug(tag);
|
|
178
|
+
const time = (post: BlogPostSummary) => {
|
|
179
|
+
const ms = Date.parse(post.publishedAt);
|
|
180
|
+
return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms;
|
|
181
|
+
};
|
|
182
|
+
return posts
|
|
183
|
+
.filter((post) => wanted === null || post.tags.some((term) => categorySlug(term) === wanted))
|
|
184
|
+
.map((post, index) => ({ post, index, at: time(post) }))
|
|
185
|
+
.sort((a, b) => (a.at === b.at ? a.index - b.index : b.at > a.at ? 1 : -1))
|
|
186
|
+
.slice(0, latestPostsLimit(block.limit))
|
|
187
|
+
.map((entry) => entry.post);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The `latest_posts` section, or null when the tenant has no post to show (a section with a
|
|
192
|
+
* heading and no cards is worse than none). `blogIndexPath` is the blog index the collection
|
|
193
|
+
* resolves for the serving space, tenant-relative; the declared `ctaHref` replaces it.
|
|
194
|
+
*/
|
|
195
|
+
export function latestPostsView(
|
|
196
|
+
block: LatestPostsBlock,
|
|
197
|
+
posts: readonly BlogPostSummary[],
|
|
198
|
+
blogIndexPath: string,
|
|
199
|
+
basePath: string,
|
|
200
|
+
): LatestPostsView | null {
|
|
201
|
+
const selected = selectLatestPosts(posts, block);
|
|
202
|
+
if (selected.length === 0) return null;
|
|
203
|
+
const href = declaredText(block.ctaHref) ?? blogIndexPath;
|
|
204
|
+
return {
|
|
205
|
+
eyebrow: declaredText(block.eyebrow),
|
|
206
|
+
title: declaredText(block.title) ?? LATEST_POSTS_DEFAULT_TITLE,
|
|
207
|
+
posts: selected,
|
|
208
|
+
cta: isSafeStorefrontHref(href)
|
|
209
|
+
? { label: declaredText(block.ctaLabel) ?? LATEST_POSTS_DEFAULT_CTA_LABEL, href: withBase(basePath, href) }
|
|
210
|
+
: null,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
145
214
|
/**
|
|
146
215
|
* The editorial block's media column: the block image, else generated art when
|
|
147
216
|
* the block asks for it with `imageSeed`, else none — a text-only block.
|
|
@@ -26,9 +26,30 @@ export type HomeBlock = (
|
|
|
26
26
|
| EditorialBlock
|
|
27
27
|
| NewsletterBlock
|
|
28
28
|
| FaqBlock
|
|
29
|
+
| LatestPostsBlock
|
|
29
30
|
) &
|
|
30
31
|
BlockIdentity;
|
|
31
32
|
|
|
33
|
+
/**
|
|
34
|
+
* The newest posts of the tenant's own blog as cards, rendered into the static
|
|
35
|
+
* home page at publish. Every prop is optional: `{ "component": "latest_posts" }`
|
|
36
|
+
* is a complete block. A tenant with no listable post renders nothing.
|
|
37
|
+
*/
|
|
38
|
+
export interface LatestPostsBlock {
|
|
39
|
+
component: "latest_posts";
|
|
40
|
+
eyebrow?: string;
|
|
41
|
+
/** Section heading. Absent uses the platform default. */
|
|
42
|
+
title?: string;
|
|
43
|
+
/** Cards shown, newest first. Positive integer up to 12; absent uses 3. */
|
|
44
|
+
limit?: number;
|
|
45
|
+
/** Only posts carrying this tag (matched by slug, so casing and spacing are free). */
|
|
46
|
+
tag?: string;
|
|
47
|
+
/** "View all" button label. Absent uses the platform default. */
|
|
48
|
+
ctaLabel?: string;
|
|
49
|
+
/** "View all" button target, a same-storefront path. Absent targets the blog index. */
|
|
50
|
+
ctaHref?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
export interface CommerceTrustStripBlock {
|
|
33
54
|
component: "trust_strip";
|
|
34
55
|
items: TrustStripItem[];
|
|
@@ -45,7 +45,8 @@ export type OrderSourceGap = "no-tenant" | "stage-unknown" | "ledger-unavailable
|
|
|
45
45
|
export interface LedgerSyncStatus {
|
|
46
46
|
orders: number;
|
|
47
47
|
lastOrderAt: string | null;
|
|
48
|
-
|
|
48
|
+
/** Reservations from the last 7 days no payment confirmation has reached; `awaiting` is those still inside the grace window. */
|
|
49
|
+
unconfirmed: { count: number; oldestAt: string | null; awaiting: number };
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/** Which FoxyCart store takes this tenant's payments, or null when no store is selected. */
|
|
@@ -147,11 +148,18 @@ export function orderSyncHealth(source: OrderSource, status: LedgerSyncStatus |
|
|
|
147
148
|
if (source.kind === "not-connected") return { value: "Not connected", detail: orderSourceGapMessage(source.gap) };
|
|
148
149
|
if (source.kind === "partner-api") return { value: "Connected", detail: "Reads this store's checkout orders." };
|
|
149
150
|
if (!status) return { value: "Check", detail: "Order sync status could not be read. Reload to try again." };
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
const overdue = status.unconfirmed.count - status.unconfirmed.awaiting;
|
|
152
|
+
if (overdue > 0) {
|
|
152
153
|
return {
|
|
153
154
|
value: "Check",
|
|
154
|
-
detail: `${
|
|
155
|
+
detail: `${overdue} checkout${overdue === 1 ? "" : "s"} passed final cart more than ${PAYMENT_CONFIRMATION_GRACE_MINUTES} minutes ago with no payment confirmation received.`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (status.unconfirmed.awaiting > 0) {
|
|
159
|
+
const n = status.unconfirmed.awaiting;
|
|
160
|
+
return {
|
|
161
|
+
value: "Connected",
|
|
162
|
+
detail: `${n} checkout${n === 1 ? "" : "s"} passed final cart in the last ${PAYMENT_CONFIRMATION_GRACE_MINUTES} minutes; payment confirmation has not reached this store yet.`,
|
|
155
163
|
};
|
|
156
164
|
}
|
|
157
165
|
return {
|
|
@@ -283,6 +291,14 @@ function listLimit(limit: number | undefined): number {
|
|
|
283
291
|
/** A shopper who abandons at payment leaves a reservation too, so only recent ones are reported. */
|
|
284
292
|
export const UNCONFIRMED_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
285
293
|
|
|
294
|
+
/**
|
|
295
|
+
* A payment confirmation reaches the ledger after the shopper pays, not with the reservation. A
|
|
296
|
+
* reservation younger than this is awaiting one; an older one is reported as missing it. One
|
|
297
|
+
* measured delivery took over 7 minutes (2026-10-27), so the window leaves room beyond that.
|
|
298
|
+
*/
|
|
299
|
+
export const PAYMENT_CONFIRMATION_GRACE_MINUTES = 15;
|
|
300
|
+
const PAYMENT_CONFIRMATION_GRACE_MS = PAYMENT_CONFIRMATION_GRACE_MINUTES * 60 * 1000;
|
|
301
|
+
|
|
286
302
|
/**
|
|
287
303
|
* Paid orders and sync status over one tenant's ledger book, read once per request. A `scrappy`
|
|
288
304
|
* reservation is never listed as an order; one from the last seven days that no provider event
|
|
@@ -338,10 +354,15 @@ export function ledgerOrderBook(
|
|
|
338
354
|
.map((entry) => isoInstant(entry.placedAt))
|
|
339
355
|
.filter((at) => at >= since)
|
|
340
356
|
.sort();
|
|
357
|
+
const settled = new Date(now.getTime() - PAYMENT_CONFIRMATION_GRACE_MS).toISOString();
|
|
341
358
|
return {
|
|
342
359
|
orders: orders.length,
|
|
343
360
|
lastOrderAt: placed[placed.length - 1] ?? null,
|
|
344
|
-
unconfirmed: {
|
|
361
|
+
unconfirmed: {
|
|
362
|
+
count: waiting.length,
|
|
363
|
+
oldestAt: waiting[0] ?? null,
|
|
364
|
+
awaiting: waiting.filter((at) => at > settled).length,
|
|
365
|
+
},
|
|
345
366
|
};
|
|
346
367
|
};
|
|
347
368
|
|
|
@@ -49,13 +49,85 @@ import type {
|
|
|
49
49
|
OrderRelease,
|
|
50
50
|
OrderRow,
|
|
51
51
|
OrderShipState,
|
|
52
|
+
OrdersTodaySummary,
|
|
52
53
|
OrdersViewModel,
|
|
53
54
|
} from "../viewmodels/orders";
|
|
54
55
|
import type { CheckoutOrderClient } from "../orderClient";
|
|
56
|
+
import { DEFAULT_TIMEZONE } from "@tot/public-runtime/declared-tenant-config";
|
|
55
57
|
|
|
56
58
|
const EVIDENCE_STATES: readonly EvidenceState[] = ["fresh", "not-required", "UNKNOWN", "stale", "unreadable", "absent"];
|
|
57
59
|
const REFUND_PAYMENTS: ReadonlySet<PaymentState> = new Set(["refunded", "refund_requested", "refund_unknown"]);
|
|
58
60
|
|
|
61
|
+
/** `YYYY-MM-DD` of an instant on the store's calendar. `null` when the instant does not parse. */
|
|
62
|
+
export function storeDay(instant: string | Date, timeZone: string): string | null {
|
|
63
|
+
const at = typeof instant === "string" ? new Date(instant) : instant;
|
|
64
|
+
if (Number.isNaN(at.getTime())) return null;
|
|
65
|
+
const parts = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(at);
|
|
66
|
+
const part = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
|
|
67
|
+
return `${part("year")}-${part("month")}-${part("day")}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** An instant's clock time on the store's clock, e.g. `12:03 PM`. */
|
|
71
|
+
export function formatStoreTime(instant: string, timeZone: string): string {
|
|
72
|
+
const at = new Date(instant);
|
|
73
|
+
if (Number.isNaN(at.getTime())) return "";
|
|
74
|
+
return new Intl.DateTimeFormat("en-US", { timeZone, hour: "numeric", minute: "2-digit" }).format(at);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An instant's date and clock time on the store's clock, e.g. `Oct 27, 12:03 PM`. */
|
|
78
|
+
export function formatStoreDateTime(instant: string, timeZone: string): string {
|
|
79
|
+
const at = new Date(instant);
|
|
80
|
+
if (Number.isNaN(at.getTime())) return "";
|
|
81
|
+
return new Intl.DateTimeFormat("en-US", { timeZone, month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }).format(at);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** An instant's date on the store's calendar, e.g. `Oct 27`. */
|
|
85
|
+
export function formatStoreDate(instant: string, timeZone: string): string {
|
|
86
|
+
const at = new Date(instant);
|
|
87
|
+
if (Number.isNaN(at.getTime())) return "";
|
|
88
|
+
return new Intl.DateTimeFormat("en-US", { timeZone, month: "short", day: "numeric" }).format(at);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A store day (`YYYY-MM-DD`) as `Oct 27`. */
|
|
92
|
+
export function formatStoreDay(day: string): string {
|
|
93
|
+
const [y, m, d] = day.split("-").map(Number);
|
|
94
|
+
return new Intl.DateTimeFormat("en-US", { timeZone: "UTC", month: "short", day: "numeric" }).format(new Date(Date.UTC(y!, m! - 1, d!)));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The Today card when its orders could not be read: no figure is better than a wrong one. */
|
|
98
|
+
export function unknownOrdersToday(timeZone: string = DEFAULT_TIMEZONE): OrdersTodaySummary {
|
|
99
|
+
return { grossSales: 0, paidOrders: 0, averageOrder: 0, refunds: 0, oldestOpenAt: null, day: null, timeZone };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Totals for the orders placed on the store's current calendar day. `rows` may span any dates;
|
|
104
|
+
* only those whose placement falls on `now`'s day in `timeZone` count. `oldestOpenAt` is not a
|
|
105
|
+
* today figure: it is the oldest order of `rows` that has not shipped, on any day.
|
|
106
|
+
*/
|
|
107
|
+
export function summarizeOrdersToday(
|
|
108
|
+
rows: readonly CheckoutOrderSummary[],
|
|
109
|
+
open: readonly CheckoutOrderSummary[] | null,
|
|
110
|
+
now: Date,
|
|
111
|
+
timeZone: string,
|
|
112
|
+
): OrdersTodaySummary {
|
|
113
|
+
const day = storeDay(now, timeZone);
|
|
114
|
+
const today = rows.filter((r) => day !== null && storeDay(r.placedAt, timeZone) === day);
|
|
115
|
+
const paid = today.filter((r) => r.payment === "captured");
|
|
116
|
+
const cents = (rows: readonly CheckoutOrderSummary[]) => rows.reduce((sum, r) => sum + Math.round(r.total * 100), 0);
|
|
117
|
+
const grossSales = cents(paid) / 100;
|
|
118
|
+
const refunds = cents(today.filter((r) => r.payment === "refunded")) / 100;
|
|
119
|
+
const openAt = (open ?? []).filter((r) => r.fulfillment !== "completed").map((r) => r.placedAt).sort();
|
|
120
|
+
return {
|
|
121
|
+
grossSales,
|
|
122
|
+
paidOrders: paid.length,
|
|
123
|
+
averageOrder: paid.length ? Math.round((grossSales / paid.length) * 100) / 100 : 0,
|
|
124
|
+
refunds,
|
|
125
|
+
oldestOpenAt: openAt[0] ?? null,
|
|
126
|
+
day,
|
|
127
|
+
timeZone,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
59
131
|
export interface SourcedState<T extends string = string> {
|
|
60
132
|
state: T;
|
|
61
133
|
source: string;
|
|
@@ -491,6 +563,7 @@ export function mapOrdersToViewModel(
|
|
|
491
563
|
summaries: CheckoutOrderSummary[],
|
|
492
564
|
details: Record<string, CheckoutOrderDetail>,
|
|
493
565
|
scope: CommerceScope,
|
|
566
|
+
today: OrdersTodaySummary,
|
|
494
567
|
): OrdersViewModel {
|
|
495
568
|
const rows = summaries.map(mapSummaryToRow);
|
|
496
569
|
const detailsById: Record<string, OrderDetail> = {};
|
|
@@ -509,40 +582,23 @@ export function mapOrdersToViewModel(
|
|
|
509
582
|
};
|
|
510
583
|
}
|
|
511
584
|
|
|
512
|
-
const paidOrders = rows.filter((r) => r.payment === "captured");
|
|
513
|
-
const refunded = rows.filter((r) => r.payment === "refunded");
|
|
514
|
-
const grossSales = paidOrders.reduce((sum, r) => sum + r.total, 0);
|
|
515
|
-
const refunds = refunded.reduce((sum, r) => sum + r.total, 0);
|
|
516
|
-
|
|
517
585
|
return {
|
|
518
586
|
targetTenant,
|
|
519
587
|
queues: buildQueues(rows),
|
|
520
588
|
rows,
|
|
521
589
|
detailsById,
|
|
522
|
-
today
|
|
523
|
-
grossSales,
|
|
524
|
-
paidOrders: paidOrders.length,
|
|
525
|
-
averageOrder: paidOrders.length ? Math.round((grossSales / paidOrders.length) * 100) / 100 : 0,
|
|
526
|
-
refunds,
|
|
527
|
-
oldestOpenAt: rows.length ? rows[rows.length - 1]!.placedAt : null,
|
|
528
|
-
},
|
|
590
|
+
today,
|
|
529
591
|
};
|
|
530
592
|
}
|
|
531
593
|
|
|
532
|
-
export function emptyOrdersViewModel(targetTenant: string): OrdersViewModel {
|
|
594
|
+
export function emptyOrdersViewModel(targetTenant: string, timeZone: string = DEFAULT_TIMEZONE): OrdersViewModel {
|
|
533
595
|
const rows: OrderRow[] = [];
|
|
534
596
|
return {
|
|
535
597
|
targetTenant,
|
|
536
598
|
queues: buildQueues(rows),
|
|
537
599
|
rows,
|
|
538
600
|
detailsById: {},
|
|
539
|
-
today:
|
|
540
|
-
grossSales: 0,
|
|
541
|
-
paidOrders: 0,
|
|
542
|
-
averageOrder: 0,
|
|
543
|
-
refunds: 0,
|
|
544
|
-
oldestOpenAt: null,
|
|
545
|
-
},
|
|
601
|
+
today: unknownOrdersToday(timeZone),
|
|
546
602
|
};
|
|
547
603
|
}
|
|
548
604
|
|
|
@@ -551,6 +607,9 @@ export interface GetOrdersViewModelOptions {
|
|
|
551
607
|
scope: CommerceScope;
|
|
552
608
|
client: CheckoutOrderClient;
|
|
553
609
|
filters?: Parameters<CheckoutOrderClient["listOrders"]>[0];
|
|
610
|
+
/** The store's declared IANA time zone (`resolveStoreTimeZone`); its calendar decides what "today" is. */
|
|
611
|
+
timeZone: string;
|
|
612
|
+
now?: Date;
|
|
554
613
|
}
|
|
555
614
|
|
|
556
615
|
export type OrdersLoadStatus = "loaded" | "empty" | "error" | "not_configured";
|
|
@@ -578,6 +637,45 @@ export function parseOrderFilters(params: URLSearchParams): CheckoutOrderListFil
|
|
|
578
637
|
return f;
|
|
579
638
|
}
|
|
580
639
|
|
|
640
|
+
/** Most pages one whole-book read follows: 20 x 100 orders. */
|
|
641
|
+
const WHOLE_BOOK_PAGES = 20;
|
|
642
|
+
|
|
643
|
+
type WholeBook = { ok: true; orders: CheckoutOrderSummary[] } | { ok: false; reason: "unreadable" | "too-long" };
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Every order matching `filters`, page after page. Never a partial list: a book longer than
|
|
647
|
+
* `WHOLE_BOOK_PAGES` pages, or a page that cannot be read, is `ok: false`, because a total built
|
|
648
|
+
* from part of the book is the bug this exists to prevent.
|
|
649
|
+
*/
|
|
650
|
+
async function listWholeBook(client: CheckoutOrderClient, filters: CheckoutOrderListFilters): Promise<WholeBook> {
|
|
651
|
+
const orders: CheckoutOrderSummary[] = [];
|
|
652
|
+
let cursor: string | undefined;
|
|
653
|
+
for (let page = 0; page < WHOLE_BOOK_PAGES; page++) {
|
|
654
|
+
let read: Awaited<ReturnType<CheckoutOrderClient["listOrders"]>>;
|
|
655
|
+
try {
|
|
656
|
+
read = await client.listOrders({ ...filters, limit: 100, ...(cursor ? { cursor } : {}) });
|
|
657
|
+
} catch {
|
|
658
|
+
return { ok: false, reason: "unreadable" };
|
|
659
|
+
}
|
|
660
|
+
orders.push(...read.orders);
|
|
661
|
+
if (!read.page.nextCursor) return { ok: true, orders };
|
|
662
|
+
cursor = read.page.nextCursor;
|
|
663
|
+
}
|
|
664
|
+
return { ok: false, reason: "too-long" };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** The Today card, read from the whole book so a queue filter or a page boundary never changes it. */
|
|
668
|
+
async function readOrdersToday(client: CheckoutOrderClient, now: Date, timeZone: string): Promise<OrdersTodaySummary> {
|
|
669
|
+
// Two calendar days of slack: a store west of UTC starts its day up to a day behind UTC.
|
|
670
|
+
const since = new Date(now.getTime() - 48 * 60 * 60 * 1000).toISOString();
|
|
671
|
+
const [recent, open] = await Promise.all([
|
|
672
|
+
listWholeBook(client, { from: since }),
|
|
673
|
+
listWholeBook(client, { queue: "unfulfilled" }),
|
|
674
|
+
]);
|
|
675
|
+
if (!recent.ok) return unknownOrdersToday(timeZone);
|
|
676
|
+
return summarizeOrdersToday(recent.orders, open.ok ? open.orders : null, now, timeZone);
|
|
677
|
+
}
|
|
678
|
+
|
|
581
679
|
export async function getOrdersViewModelResult(opts: GetOrdersViewModelOptions): Promise<OrdersLoadResult> {
|
|
582
680
|
try {
|
|
583
681
|
const { orders, page } = await opts.client.listOrders(opts.filters);
|
|
@@ -591,13 +689,14 @@ export async function getOrdersViewModelResult(opts: GetOrdersViewModelOptions):
|
|
|
591
689
|
}
|
|
592
690
|
}),
|
|
593
691
|
);
|
|
692
|
+
const today = await readOrdersToday(opts.client, opts.now ?? new Date(), opts.timeZone);
|
|
594
693
|
return {
|
|
595
694
|
status: orders.length ? "loaded" : "empty",
|
|
596
|
-
vm: mapOrdersToViewModel(opts.targetTenant, orders, details, opts.scope),
|
|
695
|
+
vm: mapOrdersToViewModel(opts.targetTenant, orders, details, opts.scope, today),
|
|
597
696
|
page,
|
|
598
697
|
};
|
|
599
698
|
} catch {
|
|
600
|
-
return { status: "error", vm: emptyOrdersViewModel(opts.targetTenant), page: null };
|
|
699
|
+
return { status: "error", vm: emptyOrdersViewModel(opts.targetTenant, opts.timeZone), page: null };
|
|
601
700
|
}
|
|
602
701
|
}
|
|
603
702
|
|
|
@@ -110,11 +110,18 @@ export interface OrderQueueSummary {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
export interface OrdersTodaySummary {
|
|
113
|
+
/** Captured orders placed on the store's current day. */
|
|
113
114
|
grossSales: number;
|
|
114
115
|
paidOrders: number;
|
|
115
116
|
averageOrder: number;
|
|
117
|
+
/** Refunded orders placed on the store's current day. */
|
|
116
118
|
refunds: number;
|
|
119
|
+
/** The oldest order that has not shipped, on any day. Null when none is open or the book was not read whole. */
|
|
117
120
|
oldestOpenAt: string | null;
|
|
121
|
+
/** The store calendar day (`YYYY-MM-DD`) these figures cover; null when the orders could not be read. */
|
|
122
|
+
day: string | null;
|
|
123
|
+
/** The IANA zone that calendar is read in. */
|
|
124
|
+
timeZone: string;
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
export interface OrdersViewModel {
|
|
@@ -177,6 +177,8 @@ function computeOrdersViewModel(envelope: AdminTenantEnvelope): OrdersViewModel
|
|
|
177
177
|
averageOrder: paidOrders.length ? Math.round((grossSales / paidOrders.length) * 100) / 100 : 0,
|
|
178
178
|
refunds: 0,
|
|
179
179
|
oldestOpenAt,
|
|
180
|
+
day: null,
|
|
181
|
+
timeZone: "UTC",
|
|
180
182
|
},
|
|
181
183
|
};
|
|
182
184
|
}
|
|
@@ -16,6 +16,7 @@ import Img from "@/components/assets/storefront/Img.astro";
|
|
|
16
16
|
import PromoTiles from "@/components/content/storefront/content/PromoTiles.astro";
|
|
17
17
|
import TrustStrip from "@/components/content/storefront/content/TrustStrip.astro";
|
|
18
18
|
import Faq from "@/components/content/storefront/marketing/Faq.astro";
|
|
19
|
+
import PostGrid from "@/components/content/storefront/blog/PostGrid.astro";
|
|
19
20
|
import MarketingBlocks from "@/components/content/storefront/marketing/MarketingBlocks.astro";
|
|
20
21
|
import TierCards from "@/components/membership/storefront/membership/TierCards.astro";
|
|
21
22
|
import { withBase } from "@/lib/publication/shared/basePath";
|
|
@@ -41,11 +42,15 @@ import {
|
|
|
41
42
|
editorialBlockView,
|
|
42
43
|
editorialMediaView,
|
|
43
44
|
homeBlockId,
|
|
45
|
+
latestPostsView,
|
|
44
46
|
resolveCommerceStaticBlock,
|
|
45
47
|
type CommerceStaticResolved,
|
|
48
|
+
type LatestPostsView,
|
|
46
49
|
} from "@/lib/content/shared/homeVisualParity";
|
|
50
|
+
import { getBlogSettings, listBlogPosts } from "@/lib/content/blog/provider.js";
|
|
51
|
+
import { collectionPathsV1, blogPathOptsV1 } from "@/lib/content/blog/collectionPaths.js";
|
|
47
52
|
|
|
48
|
-
const { tenant, tot, content, basePath, apiBasePath, canonicalBase, features, capabilities } = Astro.locals;
|
|
53
|
+
const { tenant, tot, content, basePath, apiBasePath, canonicalBase, features, capabilities, publicationTimestamp } = Astro.locals;
|
|
49
54
|
const marketing = !features.catalog;
|
|
50
55
|
|
|
51
56
|
// Raw-HTML home (vendor-injected): serve the tenant's own self-contained
|
|
@@ -109,6 +114,7 @@ type Resolved = (
|
|
|
109
114
|
| { kind: "hero"; block: HeroBlock; product: CatalogProduct | null; instanceKey: string }
|
|
110
115
|
| { kind: "collections"; title?: string; cols: CatalogCollection[] }
|
|
111
116
|
| { kind: "products"; title?: string; products: CatalogProduct[]; presentation?: FeaturedProductsBlock["presentation"] }
|
|
117
|
+
| { kind: "latest_posts"; view: LatestPostsView }
|
|
112
118
|
| CommerceStaticResolved
|
|
113
119
|
) & { blockId: string };
|
|
114
120
|
|
|
@@ -124,6 +130,20 @@ async function resolveProducts(
|
|
|
124
130
|
);
|
|
125
131
|
}
|
|
126
132
|
|
|
133
|
+
// The blog is read only when a `latest_posts` block asks for it, through the same
|
|
134
|
+
// listing the blog index renders, so this static home and /blog never disagree.
|
|
135
|
+
const wantsLatestPosts = !marketing && home.blocks.some((block) => block.component === "latest_posts");
|
|
136
|
+
const blogPosts = wantsLatestPosts
|
|
137
|
+
? await listBlogPosts(tenant.tenant_id, content, publicationTimestamp)
|
|
138
|
+
: [];
|
|
139
|
+
const blogPaths = wantsLatestPosts
|
|
140
|
+
? collectionPathsV1(
|
|
141
|
+
Astro.locals,
|
|
142
|
+
"blog",
|
|
143
|
+
blogPathOptsV1(await getBlogSettings(tenant.tenant_id, content)),
|
|
144
|
+
)
|
|
145
|
+
: null;
|
|
146
|
+
|
|
127
147
|
const resolved: Resolved[] = [];
|
|
128
148
|
for (const [index, block] of (marketing ? [] : home.blocks).entries()) {
|
|
129
149
|
const blockId = homeBlockId(block, index);
|
|
@@ -153,6 +173,11 @@ for (const [index, block] of (marketing ? [] : home.blocks).entries()) {
|
|
|
153
173
|
resolved.push({ kind: "collections", title: block.title, cols, blockId });
|
|
154
174
|
break;
|
|
155
175
|
}
|
|
176
|
+
case "latest_posts": {
|
|
177
|
+
const view = blogPaths ? latestPostsView(block, blogPosts, blogPaths.index, basePath) : null;
|
|
178
|
+
if (view) resolved.push({ kind: "latest_posts", view, blockId });
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
156
181
|
case "featured_products": {
|
|
157
182
|
const products = await resolveProducts(
|
|
158
183
|
block.source,
|
|
@@ -364,6 +389,23 @@ const homeSectionWidgets = marketing
|
|
|
364
389
|
</Section>
|
|
365
390
|
);
|
|
366
391
|
}
|
|
392
|
+
if (r.kind === "latest_posts") {
|
|
393
|
+
return (
|
|
394
|
+
<Section title={r.view.title} eyebrow={r.view.eyebrow} tone="bg" blockId={r.blockId}>
|
|
395
|
+
<PostGrid posts={r.view.posts} basePath={basePath} paths={blogPaths!} headingLevel="h3" eagerCount={0} />
|
|
396
|
+
{r.view.cta && (
|
|
397
|
+
<div class="mt-9 text-center">
|
|
398
|
+
<a
|
|
399
|
+
href={r.view.cta.href}
|
|
400
|
+
class="inline-flex rounded-full bg-[var(--color-primary)] px-6 py-3 font-display text-sm font-medium text-[var(--color-primary-contrast)]"
|
|
401
|
+
>
|
|
402
|
+
{r.view.cta.label}
|
|
403
|
+
</a>
|
|
404
|
+
</div>
|
|
405
|
+
)}
|
|
406
|
+
</Section>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
367
409
|
if (r.kind === "faq") {
|
|
368
410
|
return (
|
|
369
411
|
<div data-block={r.blockId}>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.8.0",
|
|
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",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
export const TOT_CONFIG_KEYS = [
|
|
21
21
|
"$schema", "tenant", "scope", "mappings", "hostPlatform", "sample", "seed",
|
|
22
|
-
"displayName", "siteType", "compliance", "capabilities", "features", "embeds", "seo", "currency", "shipping",
|
|
22
|
+
"displayName", "siteType", "compliance", "capabilities", "features", "embeds", "seo", "currency", "timezone", "shipping",
|
|
23
23
|
];
|
|
24
24
|
// Keys other tooling has written that the platform does NOT read, with the key it does.
|
|
25
25
|
const RENAMED_CONFIG_KEYS = { appDomain: "scope", tenantId: "tenant" };
|
|
@@ -69,6 +69,42 @@ export function currencyDeclarationProblems(currency) {
|
|
|
69
69
|
: [`\`currency\` is ${JSON.stringify(currency)} — must be an uppercase ISO 4217 code such as "USD" or "CAD"`];
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** The IANA time zone a store's days are counted in when `.tot/config.json` declares none. */
|
|
73
|
+
export const DEFAULT_TIMEZONE = "UTC";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* What is wrong with a declared `timezone`, one sentence; `[]` when it is valid. A valid one is
|
|
77
|
+
* an IANA zone name the runtime's Intl knows, such as "America/Los_Angeles" or "UTC"; a numeric
|
|
78
|
+
* offset is not a zone, so "today" would drift across daylight-saving changes.
|
|
79
|
+
*
|
|
80
|
+
* @param {unknown} timezone
|
|
81
|
+
* @returns {string[]}
|
|
82
|
+
*/
|
|
83
|
+
export function timezoneDeclarationProblems(timezone) {
|
|
84
|
+
const named = typeof timezone === "string" && /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/.test(timezone);
|
|
85
|
+
if (named) {
|
|
86
|
+
try {
|
|
87
|
+
new Intl.DateTimeFormat("en-US", { timeZone: /** @type {string} */ (timezone) });
|
|
88
|
+
return [];
|
|
89
|
+
} catch {
|
|
90
|
+
/* not a zone the runtime knows */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [`\`timezone\` is ${JSON.stringify(timezone)} — must be an IANA time zone name such as "America/Los_Angeles" or "UTC"`];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The ONE place the time zone a store's days are counted in is resolved. A declared IANA zone
|
|
98
|
+
* wins; anything else (absent, wrong shape, unknown zone) is UTC. "Today", a daily total and an
|
|
99
|
+
* order's displayed time take the zone from here, never from the server's clock.
|
|
100
|
+
*
|
|
101
|
+
* @param {unknown} declared
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
export function resolveStoreTimeZone(declared) {
|
|
105
|
+
return timezoneDeclarationProblems(declared).length === 0 ? /** @type {string} */ (declared) : DEFAULT_TIMEZONE;
|
|
106
|
+
}
|
|
107
|
+
|
|
72
108
|
|
|
73
109
|
/** Every `seo` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
|
|
74
110
|
export const SEO_KEYS = Object.freeze(["trailingSlash", "verbatimSeoTitles", "structuredData"]);
|
|
@@ -737,6 +773,11 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
737
773
|
out.push(mk(ERROR, "config-currency", file, `${problem} — the store sells in ${DEFAULT_CURRENCY} until it is fixed`));
|
|
738
774
|
}
|
|
739
775
|
}
|
|
776
|
+
if (config.timezone !== undefined) {
|
|
777
|
+
for (const problem of timezoneDeclarationProblems(config.timezone)) {
|
|
778
|
+
out.push(mk(ERROR, "config-timezone", file, `${problem} — the store counts its days in ${DEFAULT_TIMEZONE} until it is fixed`));
|
|
779
|
+
}
|
|
780
|
+
}
|
|
740
781
|
if (config.seo !== undefined) {
|
|
741
782
|
for (const problem of seoDeclarationProblems(config.seo)) {
|
|
742
783
|
out.push(mk(ERROR, "config-seo", `${file} seo`, `${problem} — the whole \`seo\` declaration is ignored until it is fixed`));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Types for the dependency-free declared-tenant-config module (declared-tenant-config.mjs). */
|
|
2
2
|
import type { TenantConfig } from "./tenant.js";
|
|
3
3
|
|
|
4
|
-
export type DeclaredTenantConfigField = "displayName" | "siteType" | "compliance" | "capabilities" | "features" | "embeds" | "seo" | "currency" | "shipping";
|
|
4
|
+
export type DeclaredTenantConfigField = "displayName" | "siteType" | "compliance" | "capabilities" | "features" | "embeds" | "seo" | "currency" | "timezone" | "shipping";
|
|
5
5
|
export type DeclaredTenantConfig = Partial<Pick<TenantConfig, DeclaredTenantConfigField>>;
|
|
6
6
|
|
|
7
7
|
export const DECLARED_TENANT_CONFIG_FIELDS: readonly DeclaredTenantConfigField[];
|
|
@@ -13,6 +13,9 @@ export const DEFAULT_CURRENCY: "USD";
|
|
|
13
13
|
export const CURRENCY_CODES: readonly string[];
|
|
14
14
|
export function resolveStoreCurrency(declared: unknown): string;
|
|
15
15
|
export function currencyDeclarationProblems(currency: unknown): string[];
|
|
16
|
+
export const DEFAULT_TIMEZONE: "UTC";
|
|
17
|
+
export function resolveStoreTimeZone(declared: unknown): string;
|
|
18
|
+
export function timezoneDeclarationProblems(timezone: unknown): string[];
|
|
16
19
|
export const SEO_KEYS: readonly string[];
|
|
17
20
|
export const STRUCTURED_DATA_KEYS: readonly string[];
|
|
18
21
|
export const STRUCTURED_DATA_PAGE_TYPES: readonly import("./tenant.js").StructuredDataPageType[];
|
|
@@ -27,6 +27,7 @@ export const DECLARED_TENANT_CONFIG_FIELDS = Object.freeze([
|
|
|
27
27
|
"embeds",
|
|
28
28
|
"seo",
|
|
29
29
|
"currency",
|
|
30
|
+
"timezone",
|
|
30
31
|
"shipping",
|
|
31
32
|
]);
|
|
32
33
|
|
|
@@ -149,6 +150,42 @@ export function currencyDeclarationProblems(currency) {
|
|
|
149
150
|
: [`\`currency\` is ${JSON.stringify(currency)} — must be an uppercase ISO 4217 code such as "USD" or "CAD"`];
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
/** The IANA time zone a store's days are counted in when `.tot/config.json` declares none. */
|
|
154
|
+
export const DEFAULT_TIMEZONE = "UTC";
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* What is wrong with a declared `timezone`, one sentence; `[]` when it is valid. A valid one is
|
|
158
|
+
* an IANA zone name the runtime's Intl knows, such as "America/Los_Angeles" or "UTC"; a numeric
|
|
159
|
+
* offset is not a zone, so "today" would drift across daylight-saving changes.
|
|
160
|
+
*
|
|
161
|
+
* @param {unknown} timezone
|
|
162
|
+
* @returns {string[]}
|
|
163
|
+
*/
|
|
164
|
+
export function timezoneDeclarationProblems(timezone) {
|
|
165
|
+
const named = typeof timezone === "string" && /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/.test(timezone);
|
|
166
|
+
if (named) {
|
|
167
|
+
try {
|
|
168
|
+
new Intl.DateTimeFormat("en-US", { timeZone: /** @type {string} */ (timezone) });
|
|
169
|
+
return [];
|
|
170
|
+
} catch {
|
|
171
|
+
/* not a zone the runtime knows */
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return [`\`timezone\` is ${JSON.stringify(timezone)} — must be an IANA time zone name such as "America/Los_Angeles" or "UTC"`];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The ONE place the time zone a store's days are counted in is resolved. A declared IANA zone
|
|
179
|
+
* wins; anything else (absent, wrong shape, unknown zone) is UTC. "Today", a daily total and an
|
|
180
|
+
* order's displayed time take the zone from here, never from the server's clock.
|
|
181
|
+
*
|
|
182
|
+
* @param {unknown} declared
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
export function resolveStoreTimeZone(declared) {
|
|
186
|
+
return timezoneDeclarationProblems(declared).length === 0 ? /** @type {string} */ (declared) : DEFAULT_TIMEZONE;
|
|
187
|
+
}
|
|
188
|
+
|
|
152
189
|
const SITE_TYPES = new Set(SITE_TYPES_LIST);
|
|
153
190
|
|
|
154
191
|
const isPlainObject = (value) =>
|
|
@@ -343,7 +380,7 @@ export function declaredTenantConfig(config) {
|
|
|
343
380
|
/** @type {Record<string, unknown>} */
|
|
344
381
|
const out = {};
|
|
345
382
|
if (!isPlainObject(config)) return out;
|
|
346
|
-
const { displayName, siteType, compliance, capabilities, features, embeds, seo, currency, shipping } = /** @type {Record<string, unknown>} */ (config);
|
|
383
|
+
const { displayName, siteType, compliance, capabilities, features, embeds, seo, currency, timezone, shipping } = /** @type {Record<string, unknown>} */ (config);
|
|
347
384
|
// The store's name as visitors read it: the `<title>` suffix, og:site_name,
|
|
348
385
|
// Organization JSON-LD, breadcrumbs, the default blog heading, the copyright line.
|
|
349
386
|
if (typeof displayName === "string" && displayName.trim()) out.displayName = displayName.trim();
|
|
@@ -356,6 +393,8 @@ export function declaredTenantConfig(config) {
|
|
|
356
393
|
if (seo !== undefined && seoDeclarationProblems(seo).length === 0) out.seo = seo;
|
|
357
394
|
// Wrong-shaped or unknown codes are omitted: the store sells in USD (`tot validate` reports it).
|
|
358
395
|
if (currency !== undefined && currencyDeclarationProblems(currency).length === 0) out.currency = currency;
|
|
396
|
+
// Wrong-shaped or unknown zones are omitted: the store counts its days in UTC (`tot validate` reports it).
|
|
397
|
+
if (timezone !== undefined && timezoneDeclarationProblems(timezone).length === 0) out.timezone = timezone;
|
|
359
398
|
// The shipping charge: refused whole when any part is malformed (`tot validate` and the
|
|
360
399
|
// reconcile report it), so checkout keeps charging no shipping rather than a guess.
|
|
361
400
|
if (shipping !== undefined && shippingDeclarationProblems(shipping).length === 0) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const BLOCK_PALETTE_CURRENT_CONTRACT: "block-palette@
|
|
1
|
+
export declare const BLOCK_PALETTE_CURRENT_CONTRACT: "block-palette@8";
|
|
2
2
|
export declare const BLOCK_PALETTE_SUPPORTED_CONTRACTS: readonly [
|
|
3
3
|
"block-palette@1",
|
|
4
4
|
"block-palette@2",
|
|
@@ -7,8 +7,11 @@ export declare const BLOCK_PALETTE_SUPPORTED_CONTRACTS: readonly [
|
|
|
7
7
|
"block-palette@5",
|
|
8
8
|
"block-palette@6",
|
|
9
9
|
"block-palette@7",
|
|
10
|
+
"block-palette@8",
|
|
10
11
|
];
|
|
11
12
|
export declare const FEATURED_PRODUCT_PRESENTATIONS: readonly ["grid", "carousel"];
|
|
13
|
+
export declare const LATEST_POSTS_DEFAULT_LIMIT: 3;
|
|
14
|
+
export declare const LATEST_POSTS_MAX_LIMIT: 12;
|
|
12
15
|
export declare const PROMO_TILE_PRESENTATIONS: readonly [
|
|
13
16
|
"overlay",
|
|
14
17
|
"portrait-cards",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* runtime and repository Node tooling. The separately published CLI mirrors
|
|
6
6
|
* these values and pins that mirror with a parity test.
|
|
7
7
|
*/
|
|
8
|
-
export const BLOCK_PALETTE_CURRENT_CONTRACT = "block-palette@
|
|
8
|
+
export const BLOCK_PALETTE_CURRENT_CONTRACT = "block-palette@8";
|
|
9
9
|
|
|
10
10
|
export const BLOCK_PALETTE_SUPPORTED_CONTRACTS = Object.freeze([
|
|
11
11
|
"block-palette@1",
|
|
@@ -14,11 +14,16 @@ export const BLOCK_PALETTE_SUPPORTED_CONTRACTS = Object.freeze([
|
|
|
14
14
|
"block-palette@4",
|
|
15
15
|
"block-palette@5",
|
|
16
16
|
"block-palette@6",
|
|
17
|
+
"block-palette@7",
|
|
17
18
|
BLOCK_PALETTE_CURRENT_CONTRACT,
|
|
18
19
|
]);
|
|
19
20
|
|
|
20
21
|
export const FEATURED_PRODUCT_PRESENTATIONS = Object.freeze(["grid", "carousel"]);
|
|
21
22
|
|
|
23
|
+
/** `latest_posts` card count: the default when `limit` is absent, and the most a block may show. */
|
|
24
|
+
export const LATEST_POSTS_DEFAULT_LIMIT = 3;
|
|
25
|
+
export const LATEST_POSTS_MAX_LIMIT = 12;
|
|
26
|
+
|
|
22
27
|
export const PROMO_TILE_PRESENTATIONS = Object.freeze([
|
|
23
28
|
"overlay",
|
|
24
29
|
"portrait-cards",
|
|
@@ -524,6 +524,12 @@ export interface TenantConfig {
|
|
|
524
524
|
* (declared-tenant-config.mjs), never as a literal.
|
|
525
525
|
*/
|
|
526
526
|
currency: string;
|
|
527
|
+
/**
|
|
528
|
+
* The IANA time zone the store's days are counted in: the Orders "Today" total and the order
|
|
529
|
+
* times the admin shows. UTC unless `.tot/config.json` declares another; read it through
|
|
530
|
+
* `resolveStoreTimeZone` (declared-tenant-config.mjs), never as a literal.
|
|
531
|
+
*/
|
|
532
|
+
timezone?: string;
|
|
527
533
|
locale: string;
|
|
528
534
|
/** Explicit publication spaces for this tenant, including one canonical root. */
|
|
529
535
|
publicationSpaces?: readonly PublicationSpaceManifestV1[];
|
|
@@ -34,6 +34,7 @@ import { listTenantDirIds, tenantDirSegments } from "../lib/tenant-dirs.mjs";
|
|
|
34
34
|
import {
|
|
35
35
|
BLOCK_PALETTE_SUPPORTED_CONTRACTS,
|
|
36
36
|
FEATURED_PRODUCT_PRESENTATIONS,
|
|
37
|
+
LATEST_POSTS_MAX_LIMIT,
|
|
37
38
|
PROMO_TILE_PRESENTATIONS,
|
|
38
39
|
THEME_HEADING_LEVELS,
|
|
39
40
|
isPageBlockImageReference,
|
|
@@ -364,6 +365,7 @@ const KNOWN_BLOCKS = new Set([
|
|
|
364
365
|
"membership_band",
|
|
365
366
|
"editorial",
|
|
366
367
|
"newsletter",
|
|
368
|
+
"latest_posts",
|
|
367
369
|
"marketing_hero",
|
|
368
370
|
"trust_bar",
|
|
369
371
|
"split_compare",
|
|
@@ -584,6 +586,20 @@ function validateEditorial(block, file, at, out) {
|
|
|
584
586
|
}
|
|
585
587
|
}
|
|
586
588
|
|
|
589
|
+
function validateLatestPosts(block, file, at, out) {
|
|
590
|
+
if (block.limit !== undefined && (!Number.isInteger(block.limit) || block.limit < 1 || block.limit > LATEST_POSTS_MAX_LIMIT)) {
|
|
591
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.limit must be a whole number from 1 to ${LATEST_POSTS_MAX_LIMIT}`));
|
|
592
|
+
}
|
|
593
|
+
for (const prop of ["eyebrow", "title", "tag", "ctaLabel", "ctaHref"]) {
|
|
594
|
+
if (block[prop] !== undefined && (typeof block[prop] !== "string" || !block[prop].trim())) {
|
|
595
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.${prop} must be a non-empty string`));
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (typeof block.ctaHref === "string" && block.ctaHref.trim() && !isSafeStorefrontHref(block.ctaHref)) {
|
|
599
|
+
out.push(mk(ERROR, "navigation-href", file, `${at}.ctaHref must be a same-storefront root-relative path`));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
587
603
|
function validateHomeDoc(doc, file, tenantId) {
|
|
588
604
|
const out = [];
|
|
589
605
|
if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
@@ -648,6 +664,7 @@ function validateHomeDoc(doc, file, tenantId) {
|
|
|
648
664
|
if (component === "promo_tiles") validatePromoTiles(block, file, at, out, tenantId);
|
|
649
665
|
if (component === "trust_strip") validateTrustStrip(block, file, at, out, tenantId);
|
|
650
666
|
if (component === "faq") validateFaq(block, file, at, out);
|
|
667
|
+
if (component === "latest_posts") validateLatestPosts(block, file, at, out);
|
|
651
668
|
});
|
|
652
669
|
return out;
|
|
653
670
|
}
|