@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.
@@ -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 < 3 ? "eager" : "lazy"}
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: failure,
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 + (failure ? ':' + failure : ''));
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
- unconfirmed: { count: number; oldestAt: string | null };
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
- if (status.unconfirmed.count > 0) {
151
- const n = status.unconfirmed.count;
151
+ const overdue = status.unconfirmed.count - status.unconfirmed.awaiting;
152
+ if (overdue > 0) {
152
153
  return {
153
154
  value: "Check",
154
- detail: `${n} checkout${n === 1 ? "" : "s"} in the last 7 days passed final cart with no payment confirmation received.`,
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: { count: waiting.length, oldestAt: waiting[0] ?? null },
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.7.9",
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@7";
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@7";
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",
@@ -15,6 +15,8 @@ export {
15
15
  BLOCK_PALETTE_CURRENT_CONTRACT,
16
16
  BLOCK_PALETTE_SUPPORTED_CONTRACTS,
17
17
  FEATURED_PRODUCT_PRESENTATIONS,
18
+ LATEST_POSTS_DEFAULT_LIMIT,
19
+ LATEST_POSTS_MAX_LIMIT,
18
20
  PROMO_TILE_PRESENTATIONS,
19
21
  THEME_HEADING_LEVELS,
20
22
  imageSourceUrlRefusal,
@@ -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
  }