@usequeek/theme-kit 0.1.8 → 0.1.9

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/api/shop.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { createServerStoreClient, getRequestOrigin } from '../sdk/server-store-client';
2
+ import type { ApiResponse } from '../types/product';
3
+ import { parseShopResponse, shopRequestParams, type ShopPrefetch, type ShopQuery } from '../utils/shop-query';
4
+
5
+ /**
6
+ * The shop page's first page of products, fetched while the server renders
7
+ * `/shop`, for `ShopPrefetchProvider` to hand to `useShop` — so the product grid
8
+ * is in the HTML instead of appearing after the browser has run the JS and
9
+ * fetched it (kili-foods' `/shop` main content landed at 2.8 s that way).
10
+ *
11
+ * Same request and the same parsing as the browser's `useShop` (both come from
12
+ * utils/shop-query). Reads go through `/client/store/*` like every SSR read, so
13
+ * pricing is the storefront's, never the marketplace's.
14
+ *
15
+ * Returns null on ANY failure: the page must never break over a head start —
16
+ * `useShop` then fetches in the browser exactly as it did before.
17
+ */
18
+ export async function fetchShopPrefetch(vendorSlug: string, query: ShopQuery): Promise<ShopPrefetch | null> {
19
+ try {
20
+ const client = createServerStoreClient(vendorSlug, await getRequestOrigin());
21
+ const response = await client.get<ApiResponse<unknown, Record<string, unknown>>>('/products', shopRequestParams(query));
22
+ return { query, ...parseShopResponse(response, query) };
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
@@ -6,6 +6,7 @@ import { useStorefront } from '../provider';
6
6
  import { useUserStore } from '../stores/user-store';
7
7
  import { useAuthModalStore } from '../stores/auth-modal-store';
8
8
  import { getQueekClient } from '../sdk/queek-client';
9
+ import { formatCount } from '../utils/format';
9
10
 
10
11
  interface FollowData {
11
12
  following: boolean;
@@ -165,7 +166,7 @@ export function FollowCard({ display }: { display?: 'floating' | 'inline' } = {}
165
166
  </p>
166
167
  {followersCount > 0 && (
167
168
  <p className="core-follow-card__count">
168
- {followersCount.toLocaleString()} {followersCount === 1 ? 'follower' : 'followers'}
169
+ {formatCount(followersCount)} {followersCount === 1 ? 'follower' : 'followers'}
169
170
  </p>
170
171
  )}
171
172
  </div>
@@ -4,13 +4,16 @@ import type { JSX } from 'react';
4
4
  import type { Post } from '../../types/page';
5
5
  import { useStorefront } from '../../provider';
6
6
  import { Image } from '../image';
7
+ import { formatDisplayDate } from '../../utils/format';
7
8
 
8
9
  function formatDate(value: string): string {
9
10
  const date = new Date(value);
10
11
  if (Number.isNaN(date.getTime())) {
11
12
  return '';
12
13
  }
13
- return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
14
+ // en-US long form as before; the fixed zone keeps a post published near midnight
15
+ // UTC on the same day on the server and in the browser.
16
+ return formatDisplayDate(date, { year: 'numeric', month: 'long', day: 'numeric' }, 'en-US');
14
17
  }
15
18
 
16
19
  /**
@@ -129,9 +129,20 @@ export function Image({ fallback, placeholder, variants, intent = 'raw', onError
129
129
  effectiveSizes = undefined;
130
130
  }
131
131
 
132
+ // Cards sit in grids and rails, mostly below the fold — and a card's hover
133
+ // image is invisible until hover. Eager, a server-rendered grid of 24 cards
134
+ // made React preload the first ~10 (also into the 103 Early Hints header) and
135
+ // fetch every image at once, racing the page's render-blocking CSS: /shop's
136
+ // first paint went 1.8 s → 2.9 s on Slow 4G (24/9/26). Lazy is next/image's
137
+ // default as well. A theme whose card IS the page's main image passes
138
+ // loading="eager" or fetchPriority="high".
139
+ const lazyByDefault = (intent === 'card' || intent === 'card2x') && props.fetchPriority !== 'high';
140
+ const loading = props.loading ?? (lazyByDefault ? 'lazy' : undefined);
141
+
132
142
  return (
133
143
  <img
134
144
  {...props}
145
+ loading={loading}
135
146
  src={effectiveSrc}
136
147
  srcSet={effectiveSrcSet}
137
148
  sizes={effectiveSizes}
@@ -5,6 +5,7 @@ import type {
5
5
  MetaobjectEntry,
6
6
  Product,
7
7
  } from '../types/product';
8
+ import { formatDisplayDate } from '../utils/format';
8
9
 
9
10
  export interface ProductMetafieldDefinitionLabels {
10
11
  [key: string]: { name: string; type: string };
@@ -106,7 +107,7 @@ function renderScalar(value: MetafieldScalar, key: string, label: string, type:
106
107
  const time = new Date(text).getTime();
107
108
  return (
108
109
  <p className="core-metafields__text">
109
- {Number.isNaN(time) ? text : new Date(time).toLocaleDateString()}
110
+ {Number.isNaN(time) ? text : formatDisplayDate(time, {})}
110
111
  </p>
111
112
  );
112
113
  }
@@ -2,9 +2,7 @@
2
2
 
3
3
  import { useCallback } from 'react';
4
4
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
5
- import type { ShopSort } from './use-shop';
6
-
7
- const SORTS: ShopSort[] = ['latest', 'popular', 'price_low', 'price_high'];
5
+ import { parseShopSearchParams, type ShopSort } from '../utils/shop-query';
8
6
 
9
7
  export interface ShopParams {
10
8
  categorySlug: string | null;
@@ -29,12 +27,8 @@ export function useShopParams(): ShopParams {
29
27
  const pathname = usePathname();
30
28
  const searchParams = useSearchParams();
31
29
 
32
- const categorySlug = searchParams.get('category');
33
- const keyword = searchParams.get('q') ?? '';
34
- const sortRaw = searchParams.get('sort');
35
- const sort: ShopSort = SORTS.includes(sortRaw as ShopSort) ? (sortRaw as ShopSort) : 'latest';
36
- const pageRaw = Number(searchParams.get('page'));
37
- const page = Number.isFinite(pageRaw) && pageRaw > 1 ? Math.floor(pageRaw) : 1;
30
+ // Shared with the server's /shop prefetch, so both read the URL identically.
31
+ const { categorySlug, keyword, sort, page } = parseShopSearchParams((key) => searchParams.get(key));
38
32
 
39
33
  const apply = useCallback(
40
34
  (next: { category?: string | null; q?: string | null; sort?: ShopSort; page?: number }) => {
package/hooks/use-shop.ts CHANGED
@@ -1,33 +1,47 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { createContext, createElement, useContext, useEffect, useMemo, useRef, useState, type JSX, type ReactNode } from 'react';
4
4
  import { useStorefront } from '../provider';
5
- import type { Product, ApiResponse, LegacyProductLike } from '../types/product';
5
+ import type { Product, ApiResponse } from '../types/product';
6
6
  import { createBrowserClient } from '../sdk/client';
7
- import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
8
-
9
- export type ShopSort = 'latest' | 'popular' | 'price_low' | 'price_high';
10
-
11
- export interface ShopPagination {
12
- currentPage: number;
13
- lastPage: number;
14
- perPage: number;
15
- total: number;
16
- from: number | null;
17
- to: number | null;
18
- hasMore: boolean;
19
- }
7
+ import {
8
+ SHOP_DEFAULT_PER_PAGE,
9
+ normalizeShopQuery,
10
+ parseShopResponse,
11
+ sameShopQuery,
12
+ shopRequestParams,
13
+ type ShopPagination,
14
+ type ShopPrefetch,
15
+ type ShopSort,
16
+ } from '../utils/shop-query';
17
+
18
+ // Types live in utils/shop-query (shared with the server fetch); re-exported so
19
+ // every existing `import type { ShopSort } from '.../hooks/use-shop'` keeps working.
20
+ export type { ShopPagination, ShopPrefetch, ShopQuery, ShopSort } from '../utils/shop-query';
20
21
 
21
22
  const EMPTY_PAGINATION: ShopPagination = {
22
23
  currentPage: 1,
23
24
  lastPage: 1,
24
- perPage: 24,
25
+ perPage: SHOP_DEFAULT_PER_PAGE,
25
26
  total: 0,
26
27
  from: null,
27
28
  to: null,
28
29
  hasMore: false,
29
30
  };
30
31
 
32
+ const ShopPrefetchContext = createContext<ShopPrefetch | null>(null);
33
+
34
+ /**
35
+ * Hands `useShop` a page the server already fetched (`fetchShopPrefetch`), so
36
+ * the product grid renders in the server HTML. A `useShop` call uses it only
37
+ * when its query matches exactly — same filters, sort, page and page size —
38
+ * and otherwise fetches as it always has. Themes never touch it; the storefront
39
+ * wraps its `/shop` route.
40
+ */
41
+ export function ShopPrefetchProvider({ value, children }: { value: ShopPrefetch | null; children: ReactNode }): JSX.Element {
42
+ return createElement(ShopPrefetchContext.Provider, { value }, children);
43
+ }
44
+
31
45
  function sortProducts(list: Product[], sort: ShopSort): Product[] {
32
46
  const copy = [...list];
33
47
  switch (sort) {
@@ -85,23 +99,27 @@ export function useShop(options?: {
85
99
  pagination: ShopPagination;
86
100
  } {
87
101
  const { vendor, previewData } = useStorefront();
102
+ const prefetch = useContext(ShopPrefetchContext);
88
103
 
89
- const perPage = options?.perPage ?? 24;
104
+ const perPage = options?.perPage ?? SHOP_DEFAULT_PER_PAGE;
90
105
  const categorySlug = options?.categorySlug ?? null;
91
106
  const keyword = options?.keyword ?? null;
92
107
  const sort = options?.sort ?? 'latest';
93
108
  const page = options?.page && options.page > 0 ? options.page : 1;
109
+ const query = normalizeShopQuery({ categorySlug, keyword, sort, page, perPage });
94
110
 
95
111
  const previewProducts = previewData?.products;
96
112
  const isPreview = Array.isArray(previewProducts);
97
113
 
98
- const [products, setProducts] = useState<Product[]>([]);
99
- const [isLoading, setIsLoading] = useState(!isPreview);
100
- const [pagination, setPagination] = useState<ShopPagination>({
101
- ...EMPTY_PAGINATION,
102
- perPage,
103
- });
104
- const cancelRef = useRef<boolean>(false);
114
+ // The server's page, when it is exactly this query (see ShopPrefetchProvider).
115
+ const seed = !isPreview && prefetch && sameShopQuery(prefetch.query, query) ? prefetch : null;
116
+
117
+ const [products, setProducts] = useState<Product[]>(seed?.products ?? []);
118
+ const [isLoading, setIsLoading] = useState(!isPreview && !seed);
119
+ const [pagination, setPagination] = useState<ShopPagination>(seed?.pagination ?? { ...EMPTY_PAGINATION, perPage });
120
+ // The query whose results are already in state. Seeded with the prefetch so
121
+ // the first effect run does not refetch the page the server just rendered.
122
+ const loadedQueryRef = useRef<string | null>(seed ? JSON.stringify(seed.query) : null);
105
123
 
106
124
  // Preview: derive everything synchronously from the demo catalogue.
107
125
  const previewResult = useMemo(() => {
@@ -130,53 +148,47 @@ export function useShop(options?: {
130
148
  }, [isPreview, previewProducts, categorySlug, keyword, sort, page, perPage]);
131
149
 
132
150
  // Production: fetch the live catalogue.
151
+ const queryKey = JSON.stringify(query);
133
152
  useEffect(() => {
134
153
  if (isPreview) return undefined;
154
+ // Already showing this query — the server's prefetch, or a fetch that
155
+ // finished. (Clear any loading state a cancelled in-between query left.)
156
+ if (loadedQueryRef.current === queryKey) {
157
+ setIsLoading(false);
158
+ return undefined;
159
+ }
135
160
 
136
- cancelRef.current = false;
161
+ // Per-run flag: a shared ref let a slow earlier fetch (filters A) land after
162
+ // a newer one (filters B) had reset it, overwriting B's products.
163
+ let cancelled = false;
137
164
  setIsLoading(true);
138
165
 
139
166
  const client = createBrowserClient(vendor.slug ?? undefined);
140
- const query: Record<string, string | number> = { per_page: perPage, page };
141
- if (categorySlug) query.category_slug = categorySlug;
142
- if (keyword?.trim()) query.keyword = keyword.trim();
143
- if (sort) query.sort = sort;
167
+ const current = JSON.parse(queryKey) as typeof query;
144
168
 
145
169
  async function run(): Promise<void> {
146
170
  try {
147
171
  const response = await client.get<ApiResponse<unknown, Record<string, unknown>>>(
148
172
  '/products',
149
- query,
150
- );
151
- if (cancelRef.current) return;
152
-
153
- const meta = (response.meta ?? {}) as Record<string, unknown>;
154
- const nestedMeta = (meta.pagination ?? meta) as Record<string, unknown>;
155
- setPagination({
156
- currentPage: (nestedMeta.current_page as number) ?? page,
157
- lastPage: (nestedMeta.last_page as number) ?? 1,
158
- perPage: (nestedMeta.per_page as number) ?? perPage,
159
- total: (nestedMeta.total as number) ?? 0,
160
- from: (nestedMeta.from as number | null) ?? null,
161
- to: (nestedMeta.to as number | null) ?? null,
162
- hasMore: (nestedMeta.has_more as boolean) ?? false,
163
- });
164
- setProducts(
165
- extractProducts(response.data).map((item) =>
166
- normalizeProduct(item as LegacyProductLike),
167
- ),
173
+ shopRequestParams(current),
168
174
  );
175
+ if (cancelled) return;
176
+
177
+ const parsed = parseShopResponse(response, current);
178
+ setPagination(parsed.pagination);
179
+ setProducts(parsed.products);
180
+ loadedQueryRef.current = queryKey;
169
181
  } finally {
170
- if (!cancelRef.current) setIsLoading(false);
182
+ if (!cancelled) setIsLoading(false);
171
183
  }
172
184
  }
173
185
 
174
186
  void run();
175
187
 
176
188
  return () => {
177
- cancelRef.current = true;
189
+ cancelled = true;
178
190
  };
179
- }, [isPreview, vendor.slug, categorySlug, keyword, sort, perPage, page]);
191
+ }, [isPreview, vendor.slug, queryKey]);
180
192
 
181
193
  if (isPreview && previewResult) {
182
194
  return { products: previewResult.products, isLoading: false, pagination: previewResult.pagination };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequeek/theme-kit",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "The framework a Queek storefront theme is built against.",
package/page-renderer.tsx CHANGED
@@ -58,13 +58,25 @@ class DraftSectionBoundary extends Component<
58
58
  }
59
59
  }
60
60
 
61
+ /** JSON.stringify replacer that drops promises/thenables and functions. */
62
+ function signatureReplacer(_key: string, value: unknown): unknown {
63
+ if (typeof value === 'function') return undefined;
64
+ if (value !== null && typeof value === 'object' && typeof (value as { then?: unknown }).then === 'function') return undefined;
65
+ return value;
66
+ }
67
+
61
68
  export function PageRenderer({ blocks }: { blocks: Block[] }): JSX.Element {
62
69
  const draftBlocks = useEditPreviewStore((state) => state.blocks);
63
70
  const reconcileDraftBlocks = useEditPreviewStore((state) => state.reconcile);
64
71
 
65
72
  // A new server payload is authoritative — drop the streamed draft so the
66
73
  // debounced save reconciles and a drifted preview cannot outlive a refresh.
67
- const serverSignature = useMemo(() => JSON.stringify(blocks), [blocks]);
74
+ // Fingerprint of the page DATA only. Blocks can carry values that are not data:
75
+ // the storefront attaches streamed `productsPromise`/`metaobjectsPromise`, which
76
+ // arrive here as React Flight thenables referencing the whole RSC response —
77
+ // stringifying those threw "Converting circular structure to JSON" during
78
+ // server rendering (dev) and serialised Flight internals on every render (prod).
79
+ const serverSignature = useMemo(() => JSON.stringify(blocks, signatureReplacer), [blocks]);
68
80
  useEffect(() => {
69
81
  reconcileDraftBlocks();
70
82
  }, [serverSignature, reconcileDraftBlocks]);
@@ -8,6 +8,7 @@ import { useProductQuestions } from '../hooks/use-product-questions';
8
8
  import { useAuth } from '../hooks/use-auth';
9
9
  import { useStorefront } from '../provider';
10
10
  import { getQueekClient } from '../sdk/queek-client';
11
+ import { formatDisplayDate } from '../utils/format';
11
12
 
12
13
  const MAX_QUESTION_LENGTH = 1000;
13
14
 
@@ -192,13 +193,6 @@ function initialsFor(name: string): string {
192
193
  }
193
194
 
194
195
  function formatDate(iso: string): string {
195
- try {
196
- return new Date(iso).toLocaleDateString(undefined, {
197
- month: 'short',
198
- day: 'numeric',
199
- year: 'numeric',
200
- });
201
- } catch {
202
- return '';
203
- }
196
+ // Fixed locale + zone: this block renders on the server too (see formatDisplayDate).
197
+ return formatDisplayDate(iso);
204
198
  }
@@ -6,6 +6,7 @@ import type { ReviewItem, ReviewsBlockData, ReviewsSummary } from '../types/bloc
6
6
  import { Image } from '../components/image';
7
7
  import { useReviews } from '../hooks/use-reviews';
8
8
  import { useStorefront } from '../provider';
9
+ import { formatDisplayDate } from '../utils/format';
9
10
 
10
11
  /**
11
12
  * Framework-owned reviews block. Renders verified, platform-generated reviews
@@ -425,13 +426,6 @@ function initialsFor(name: string): string {
425
426
  }
426
427
 
427
428
  function formatDate(iso: string): string {
428
- try {
429
- return new Date(iso).toLocaleDateString(undefined, {
430
- month: 'short',
431
- day: 'numeric',
432
- year: 'numeric',
433
- });
434
- } catch {
435
- return '';
436
- }
429
+ // Fixed locale + zone: this block renders on the server too (see formatDisplayDate).
430
+ return formatDisplayDate(iso);
437
431
  }
@@ -0,0 +1,49 @@
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { DISPLAY_LOCALE, DISPLAY_TIME_ZONE, formatCount, formatDisplayDate, formatMoney } from '../utils/format';
5
+
6
+ /**
7
+ * Storefront pages render on the server first. A date formatted with the
8
+ * runtime's default locale printed "Feb 14, 2026" on the server (Node: en-US,
9
+ * UTC) and "14 Feb 2026" in a Nigerian browser; React then failed hydration
10
+ * (#418) and re-rendered the whole page on the client — seen live on every
11
+ * store page with a reviews block. Everything the kit displays is formatted in
12
+ * one fixed locale and zone instead.
13
+ */
14
+ describe('display formatting is the same on the server and in the browser', () => {
15
+ it('uses one fixed locale and zone', () => {
16
+ expect(DISPLAY_LOCALE).toBe('en-NG');
17
+ expect(DISPLAY_TIME_ZONE).toBe('Africa/Lagos');
18
+ });
19
+
20
+ it('formats dates in Lagos time, whatever the machine is set to', () => {
21
+ expect(formatDisplayDate('2026-02-14T10:00:00Z')).toBe('14 Feb 2026');
22
+ // 23:30 UTC is already the 15th in Lagos (UTC+1) — the server (UTC) and the
23
+ // browser (Lagos) used to print different days for this review.
24
+ expect(formatDisplayDate('2026-02-14T23:30:00Z')).toBe('15 Feb 2026');
25
+ expect(formatDisplayDate('2026-02-14', {})).toBe('14/02/2026');
26
+ expect(formatDisplayDate('2026-02-14T23:30:00Z', { year: 'numeric', month: 'long', day: 'numeric' }, 'en-US')).toBe('February 15, 2026');
27
+ expect(formatDisplayDate('not a date')).toBe('');
28
+ });
29
+
30
+ it('formats counts and money with fixed grouping', () => {
31
+ expect(formatCount(1234567)).toBe('1,234,567');
32
+ expect(formatMoney(12500)).toBe('₦12,500');
33
+ });
34
+
35
+ it('no kit source formats with the runtime default locale', () => {
36
+ const ROOT = resolve(__dirname, '..');
37
+ const walk = (dir: string): string[] => readdirSync(dir).flatMap((entry) => {
38
+ const path = join(dir, entry);
39
+ if (entry === 'node_modules' || entry === 'tests' || entry.startsWith('.')) return [];
40
+ if (statSync(path).isDirectory()) return walk(path);
41
+ return /\.(ts|tsx)$/.test(entry) ? [path] : [];
42
+ });
43
+ const offenders = walk(ROOT).flatMap((file) => {
44
+ const source = readFileSync(file, 'utf8');
45
+ return /\.toLocale(?:Date|Time)?String\(\s*(?:undefined\s*[,)]|\))/.test(source) ? [relative(ROOT, file)] : [];
46
+ });
47
+ expect(offenders).toEqual([]);
48
+ });
49
+ });
@@ -0,0 +1,31 @@
1
+ import type { JSX } from 'react';
2
+ import { renderToStaticMarkup } from 'react-dom/server';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { Image } from '../components/image';
5
+
6
+ /**
7
+ * Card images are lazy by default. Server-rendered product grids (the /shop
8
+ * prefetch, streamed product blocks) otherwise made React preload the first
9
+ * ~10 card images and fetch all of them at once, racing the page's CSS — /shop
10
+ * first paint 1.8 s → 2.9 s on Slow 4G. Heroes and everything else unchanged.
11
+ */
12
+ describe('Image loading defaults', () => {
13
+ const html = (el: JSX.Element) => renderToStaticMarkup(el);
14
+
15
+ it('lazy-loads card images by default', () => {
16
+ expect(html(<Image src="/p.jpg" alt="" intent="card" />)).toContain('loading="lazy"');
17
+ expect(html(<Image src="/p.jpg" alt="" intent="card2x" />)).toContain('loading="lazy"');
18
+ });
19
+
20
+ it('respects an explicit choice, and fetchPriority="high"', () => {
21
+ expect(html(<Image src="/p.jpg" alt="" intent="card" loading="eager" />)).toContain('loading="eager"');
22
+ expect(html(<Image src="/p.jpg" alt="" intent="card" fetchPriority="high" />)).not.toContain('loading=');
23
+ });
24
+
25
+ it('leaves heroes, avatars and untyped images as they were', () => {
26
+ for (const intent of ['hero', 'avatar', 'modal', 'raw'] as const) {
27
+ expect(html(<Image src="/p.jpg" alt="" intent={intent} />)).not.toContain('loading=');
28
+ }
29
+ expect(html(<Image src="/p.jpg" alt="" />)).not.toContain('loading=');
30
+ });
31
+ });
@@ -0,0 +1,42 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { StorefrontContext } from '../provider';
4
+ import { ThemeProvider } from '../theme-context';
5
+ import { PageRenderer } from '../page-renderer';
6
+ import type { Block } from '../types/block';
7
+ import type { ThemeModule } from '../types/theme';
8
+
9
+ /**
10
+ * The storefront attaches streamed `productsPromise` / `metaobjectsPromise` to
11
+ * page blocks. On the server they arrive as React Flight thenables that point
12
+ * back at the whole RSC response. PageRenderer's change-detection signature
13
+ * JSON-stringified the blocks, and that threw "Converting circular structure to
14
+ * JSON" during server rendering — every real store with a products block fell
15
+ * back to client-only rendering in `next dev`.
16
+ */
17
+
18
+ function circularThenable(): unknown {
19
+ const response: Record<string, unknown> = { _chunks: new Map() };
20
+ response._weakResponse = { response };
21
+ return { status: 'pending', value: null, reason: response, then: () => undefined };
22
+ }
23
+
24
+ describe('PageRenderer', () => {
25
+ it('server-renders blocks that carry a streamed (circular) promise', () => {
26
+ const blocks = [
27
+ { type: 'content', data: { markdown: 'Fresh meals, delivered' }, productsPromise: circularThenable(), metaobjectsPromise: circularThenable() },
28
+ ] as unknown as Block[];
29
+ const theme = { getBlock: () => () => null } as unknown as ThemeModule;
30
+ const store = { vendor: { id: 'v1', slug: 'kili-foods' }, config: {}, menus: [], basePath: '', pagesChrome: {} };
31
+
32
+ const html = renderToStaticMarkup(
33
+ <StorefrontContext.Provider value={store as never}>
34
+ <ThemeProvider theme={theme}>
35
+ <PageRenderer blocks={blocks} />
36
+ </ThemeProvider>
37
+ </StorefrontContext.Provider>,
38
+ );
39
+
40
+ expect(html).toContain('Fresh meals, delivered');
41
+ });
42
+ });
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ SHOP_DEFAULT_PER_PAGE,
4
+ normalizeShopQuery,
5
+ parseShopResponse,
6
+ parseShopSearchParams,
7
+ sameShopQuery,
8
+ shopRequestParams,
9
+ } from '../utils/shop-query';
10
+
11
+ /**
12
+ * The server's /shop prefetch only saves the browser a fetch when both sides
13
+ * build the same query — these are the rules both share.
14
+ */
15
+ describe('shop query (shared by useShop and the server prefetch)', () => {
16
+ it('applies the same defaults useShop always has', () => {
17
+ expect(normalizeShopQuery()).toEqual({ categorySlug: null, keyword: null, sort: 'latest', page: 1, perPage: SHOP_DEFAULT_PER_PAGE });
18
+ expect(SHOP_DEFAULT_PER_PAGE).toBe(24);
19
+ });
20
+
21
+ it('treats a blank or padded keyword like the request does', () => {
22
+ expect(normalizeShopQuery({ keyword: ' ' }).keyword).toBeNull();
23
+ expect(normalizeShopQuery({ keyword: ' rice ' }).keyword).toBe('rice');
24
+ expect(sameShopQuery(normalizeShopQuery({ keyword: '' }), normalizeShopQuery({ keyword: null }))).toBe(true);
25
+ });
26
+
27
+ it('distinguishes every field that changes the page fetched', () => {
28
+ const base = normalizeShopQuery({ categorySlug: 'soups', keyword: 'egusi', sort: 'popular', page: 2, perPage: 24 });
29
+ for (const change of [{ categorySlug: 'rice' }, { keyword: 'okra' }, { sort: 'price_low' as const }, { page: 3 }, { perPage: 8 }]) {
30
+ expect(sameShopQuery(base, normalizeShopQuery({ ...base, ...change }))).toBe(false);
31
+ }
32
+ expect(sameShopQuery(base, normalizeShopQuery({ ...base }))).toBe(true);
33
+ });
34
+
35
+ it('reads the /shop URL exactly as useShopParams always has', () => {
36
+ const read = (qs: string) => parseShopSearchParams((key) => new URLSearchParams(qs).get(key));
37
+ expect(read('')).toEqual({ categorySlug: null, keyword: '', sort: 'latest', page: 1 });
38
+ expect(read('category=soups&q=egusi&sort=price_high&page=3')).toEqual({ categorySlug: 'soups', keyword: 'egusi', sort: 'price_high', page: 3 });
39
+ expect(read('sort=cheapest&page=0').sort).toBe('latest');
40
+ expect(read('page=abc').page).toBe(1);
41
+ expect(read('page=2.7').page).toBe(2);
42
+ });
43
+
44
+ it('sends the params the storefront products endpoint expects', () => {
45
+ expect(shopRequestParams(normalizeShopQuery())).toEqual({ per_page: 24, page: 1, sort: 'latest' });
46
+ expect(shopRequestParams(normalizeShopQuery({ categorySlug: 'soups', keyword: ' egusi ', sort: 'popular', page: 2 })))
47
+ .toEqual({ per_page: 24, page: 2, category_slug: 'soups', keyword: 'egusi', sort: 'popular' });
48
+ });
49
+
50
+ it('parses nested and flat pagination meta', () => {
51
+ const query = normalizeShopQuery({ page: 2 });
52
+ const nested = parseShopResponse({ data: [], meta: { pagination: { current_page: 2, last_page: 5, per_page: 24, total: 110, from: 25, to: 48, has_more: true } } } as never, query);
53
+ expect(nested.pagination).toEqual({ currentPage: 2, lastPage: 5, perPage: 24, total: 110, from: 25, to: 48, hasMore: true });
54
+ const flat = parseShopResponse({ data: [], meta: { current_page: 1, last_page: 1, total: 3 } } as never, normalizeShopQuery());
55
+ expect(flat.pagination).toMatchObject({ currentPage: 1, lastPage: 1, total: 3, hasMore: false, perPage: 24 });
56
+ expect(parseShopResponse({ data: [] } as never, query).pagination.currentPage).toBe(2);
57
+ });
58
+ });
@@ -0,0 +1,86 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server';
2
+ import { describe, expect, it } from 'vitest';
3
+ import type { JSX, ReactNode } from 'react';
4
+ import { StorefrontContext } from '../provider';
5
+ import { ShopPrefetchProvider, useShop } from '../hooks/use-shop';
6
+ import { normalizeShopQuery, type ShopPrefetch } from '../utils/shop-query';
7
+ import type { Product } from '../types/product';
8
+
9
+ /**
10
+ * The server renders `/shop` with the first page already fetched. What matters
11
+ * is what that server render contains: with a matching prefetch the grid is in
12
+ * the HTML and nothing is loading; with anything else, useShop behaves exactly
13
+ * as before (empty + loading, then fetches in the browser).
14
+ */
15
+
16
+ function product(id: string, title: string): Product {
17
+ return { id, title, slug: id } as unknown as Product;
18
+ }
19
+
20
+ const PREFETCH: ShopPrefetch = {
21
+ query: normalizeShopQuery({ categorySlug: 'soups', keyword: 'egusi', sort: 'popular', page: 1, perPage: 24 }),
22
+ products: [product('p1', 'Egusi Soup'), product('p2', 'Ogbono Soup')],
23
+ pagination: { currentPage: 1, lastPage: 1, perPage: 24, total: 2, from: 1, to: 2, hasMore: false },
24
+ };
25
+
26
+ function Grid(props: Parameters<typeof useShop>[0]): JSX.Element {
27
+ const { products, isLoading, pagination } = useShop(props);
28
+ return (
29
+ <ul data-loading={String(isLoading)} data-total={pagination.total}>
30
+ {products.map((p) => <li key={p.id}>{p.title}</li>)}
31
+ </ul>
32
+ );
33
+ }
34
+
35
+ function render(children: ReactNode, opts: { prefetch?: ShopPrefetch | null; preview?: Product[] } = {}): string {
36
+ const store = {
37
+ vendor: { id: 'v1', slug: 'kili-foods', name: 'Kili Foods' },
38
+ config: {},
39
+ menus: [],
40
+ previewData: opts.preview ? { products: opts.preview } : undefined,
41
+ basePath: '',
42
+ pagesChrome: {},
43
+ };
44
+ return renderToStaticMarkup(
45
+ <StorefrontContext.Provider value={store as never}>
46
+ <ShopPrefetchProvider value={opts.prefetch ?? null}>{children}</ShopPrefetchProvider>
47
+ </StorefrontContext.Provider>,
48
+ );
49
+ }
50
+
51
+ describe('useShop + ShopPrefetchProvider', () => {
52
+ it('renders the prefetched page when the query matches exactly', () => {
53
+ const html = render(<Grid categorySlug="soups" keyword=" egusi " sort="popular" page={1} perPage={24} />, { prefetch: PREFETCH });
54
+ expect(html).toContain('data-loading="false"');
55
+ expect(html).toContain('data-total="2"');
56
+ expect(html).toContain('Egusi Soup');
57
+ expect(html).toContain('Ogbono Soup');
58
+ });
59
+
60
+ it('ignores the prefetch for any other query (e.g. a product page\'s "popular" rail)', () => {
61
+ for (const other of [
62
+ { sort: 'popular' as const, perPage: 8, page: 1 },
63
+ { categorySlug: 'soups', keyword: 'egusi', sort: 'latest' as const },
64
+ { categorySlug: 'soups', keyword: 'egusi', sort: 'popular' as const, page: 2 },
65
+ ]) {
66
+ const html = render(<Grid {...other} />, { prefetch: PREFETCH });
67
+ expect(html).toContain('data-loading="true"');
68
+ expect(html).not.toContain('Egusi Soup');
69
+ }
70
+ });
71
+
72
+ it('behaves exactly as before with no provider value', () => {
73
+ const html = render(<Grid categorySlug="soups" keyword="egusi" sort="popular" />);
74
+ expect(html).toContain('data-loading="true"');
75
+ expect(html).toContain('data-total="0"');
76
+ });
77
+
78
+ it('preview mode keeps using the demo catalogue, never the prefetch', () => {
79
+ const html = render(<Grid categorySlug="soups" keyword="egusi" sort="popular" />, {
80
+ prefetch: PREFETCH,
81
+ preview: [{ ...product('d1', 'Demo Egusi'), categories: [{ slug: 'soups' }], excerpt: '', pricing: { sale_amount: 1 }, review_summary: { review_count: 0 } } as unknown as Product],
82
+ });
83
+ expect(html).toContain('Demo Egusi');
84
+ expect(html).not.toContain('Ogbono Soup');
85
+ });
86
+ });
package/utils/format.ts CHANGED
@@ -15,6 +15,33 @@
15
15
  * shows decimals only when the amount genuinely has them — ₦5,000 stays
16
16
  * ₦5,000, ₦0.28 now correctly shows as ₦0.28.
17
17
  */
18
+ /**
19
+ * The locale and zone every date/number the kit DISPLAYS is formatted in.
20
+ * Fixed on purpose: storefront pages render on the server first, and a runtime
21
+ * default prints differently there (Node in UTC, en-US) than in the shopper's
22
+ * browser (Africa/Lagos, en-NG/en-GB) — "Feb 14, 2026" vs "14 Feb 2026". React
23
+ * then fails hydration and re-renders the whole page on the client. Queek's
24
+ * stores and shoppers are Nigerian, so that is the one answer both sides give.
25
+ */
26
+ export const DISPLAY_LOCALE = 'en-NG';
27
+ export const DISPLAY_TIME_ZONE = 'Africa/Lagos';
28
+
29
+ /** A date for display (default "14 Feb 2026"), identical on server and browser. '' if unparseable. */
30
+ export function formatDisplayDate(
31
+ value: string | number | Date,
32
+ options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' },
33
+ locale: string = DISPLAY_LOCALE,
34
+ ): string {
35
+ const date = value instanceof Date ? value : new Date(value);
36
+ if (Number.isNaN(date.getTime())) return '';
37
+ return date.toLocaleDateString(locale, { ...options, timeZone: DISPLAY_TIME_ZONE });
38
+ }
39
+
40
+ /** A count for display ("1,234"), identical on server and browser. */
41
+ export function formatCount(value: number): string {
42
+ return value.toLocaleString(DISPLAY_LOCALE);
43
+ }
44
+
18
45
  export function formatMoney(amount: number, currency = 'NGN'): string {
19
46
  try {
20
47
  return new Intl.NumberFormat('en-NG', {
@@ -24,7 +51,7 @@ export function formatMoney(amount: number, currency = 'NGN'): string {
24
51
  maximumFractionDigits: 2,
25
52
  }).format(amount);
26
53
  } catch {
27
- return `${currency} ${amount.toLocaleString()}`;
54
+ return `${currency} ${amount.toLocaleString(DISPLAY_LOCALE)}`;
28
55
  }
29
56
  }
30
57
 
@@ -0,0 +1,114 @@
1
+ import type { ApiResponse, LegacyProductLike, Product } from '../types/product';
2
+ import { extractProducts, normalizeProduct } from './product-normalizer';
3
+
4
+ /**
5
+ * The shop catalogue query, in one place for both sides that run it: `useShop`
6
+ * in the browser and `fetchShopPrefetch` on the server. The server only saves
7
+ * the browser a fetch when the two would ask for exactly the same thing, so
8
+ * the parsing, the request params and the response handling cannot be allowed
9
+ * to drift — they live here, pure, with no React and no server imports.
10
+ */
11
+
12
+ export type ShopSort = 'latest' | 'popular' | 'price_low' | 'price_high';
13
+
14
+ export const SHOP_SORTS: readonly ShopSort[] = ['latest', 'popular', 'price_low', 'price_high'];
15
+
16
+ /** What every theme's shop page asks for, and `useShop`'s default. */
17
+ export const SHOP_DEFAULT_PER_PAGE = 24;
18
+
19
+ export interface ShopPagination {
20
+ currentPage: number;
21
+ lastPage: number;
22
+ perPage: number;
23
+ total: number;
24
+ from: number | null;
25
+ to: number | null;
26
+ hasMore: boolean;
27
+ }
28
+
29
+ /** A shop query with every default applied — two equal queries fetch the same page. */
30
+ export interface ShopQuery {
31
+ categorySlug: string | null;
32
+ keyword: string | null;
33
+ sort: ShopSort;
34
+ page: number;
35
+ perPage: number;
36
+ }
37
+
38
+ /** The first page the server already fetched, handed to `useShop` via `ShopPrefetchProvider`. */
39
+ export interface ShopPrefetch {
40
+ query: ShopQuery;
41
+ products: Product[];
42
+ pagination: ShopPagination;
43
+ }
44
+
45
+ export function normalizeShopQuery(input?: {
46
+ categorySlug?: string | null;
47
+ keyword?: string | null;
48
+ sort?: ShopSort | null;
49
+ page?: number | null;
50
+ perPage?: number | null;
51
+ }): ShopQuery {
52
+ const keyword = input?.keyword?.trim() ?? '';
53
+ const page = input?.page && input.page > 0 ? Math.floor(input.page) : 1;
54
+ return {
55
+ categorySlug: input?.categorySlug || null,
56
+ keyword: keyword === '' ? null : keyword,
57
+ sort: input?.sort && SHOP_SORTS.includes(input.sort) ? input.sort : 'latest',
58
+ page,
59
+ perPage: input?.perPage && input.perPage > 0 ? input.perPage : SHOP_DEFAULT_PER_PAGE,
60
+ };
61
+ }
62
+
63
+ export function sameShopQuery(a: ShopQuery, b: ShopQuery): boolean {
64
+ return a.categorySlug === b.categorySlug && a.keyword === b.keyword && a.sort === b.sort && a.page === b.page && a.perPage === b.perPage;
65
+ }
66
+
67
+ /**
68
+ * The `/shop` URL's filters — `category`, `q`, `sort`, `page` — exactly as
69
+ * `useShopParams` reads them. `get` is `URLSearchParams.get` or equivalent.
70
+ */
71
+ export function parseShopSearchParams(get: (key: string) => string | null): {
72
+ categorySlug: string | null;
73
+ keyword: string;
74
+ sort: ShopSort;
75
+ page: number;
76
+ } {
77
+ const sortRaw = get('sort');
78
+ const pageRaw = Number(get('page'));
79
+ return {
80
+ categorySlug: get('category'),
81
+ keyword: get('q') ?? '',
82
+ sort: SHOP_SORTS.includes(sortRaw as ShopSort) ? (sortRaw as ShopSort) : 'latest',
83
+ page: Number.isFinite(pageRaw) && pageRaw > 1 ? Math.floor(pageRaw) : 1,
84
+ };
85
+ }
86
+
87
+ /** Query params for `GET /products` (the storefront `/client/store` scope). */
88
+ export function shopRequestParams(query: ShopQuery): Record<string, string | number> {
89
+ const params: Record<string, string | number> = { per_page: query.perPage, page: query.page };
90
+ if (query.categorySlug) params.category_slug = query.categorySlug;
91
+ if (query.keyword) params.keyword = query.keyword;
92
+ params.sort = query.sort;
93
+ return params;
94
+ }
95
+
96
+ export function parseShopResponse(
97
+ response: ApiResponse<unknown, Record<string, unknown>>,
98
+ query: ShopQuery,
99
+ ): { products: Product[]; pagination: ShopPagination } {
100
+ const meta = (response.meta ?? {}) as Record<string, unknown>;
101
+ const nestedMeta = (meta.pagination ?? meta) as Record<string, unknown>;
102
+ return {
103
+ pagination: {
104
+ currentPage: (nestedMeta.current_page as number) ?? query.page,
105
+ lastPage: (nestedMeta.last_page as number) ?? 1,
106
+ perPage: (nestedMeta.per_page as number) ?? query.perPage,
107
+ total: (nestedMeta.total as number) ?? 0,
108
+ from: (nestedMeta.from as number | null) ?? null,
109
+ to: (nestedMeta.to as number | null) ?? null,
110
+ hasMore: (nestedMeta.has_more as boolean) ?? false,
111
+ },
112
+ products: extractProducts(response.data).map((item) => normalizeProduct(item as LegacyProductLike)),
113
+ };
114
+ }