@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 +26 -0
- package/apps/follow-card.tsx +2 -1
- package/components/blog/post-meta.tsx +4 -1
- package/components/image.tsx +11 -0
- package/components/product-metafields.tsx +2 -1
- package/hooks/use-shop-params.ts +3 -9
- package/hooks/use-shop.ts +63 -51
- package/package.json +1 -1
- package/page-renderer.tsx +13 -1
- package/shared-blocks/product-qa.tsx +3 -9
- package/shared-blocks/reviews.tsx +3 -9
- package/tests/display-format.test.ts +49 -0
- package/tests/image-loading.test.tsx +31 -0
- package/tests/page-renderer-signature.test.tsx +42 -0
- package/tests/shop-query.test.ts +58 -0
- package/tests/use-shop-prefetch.test.tsx +86 -0
- package/utils/format.ts +28 -1
- package/utils/shop-query.ts +114 -0
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
|
+
}
|
package/apps/follow-card.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
/**
|
package/components/image.tsx
CHANGED
|
@@ -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 :
|
|
110
|
+
{Number.isNaN(time) ? text : formatDisplayDate(time, {})}
|
|
110
111
|
</p>
|
|
111
112
|
);
|
|
112
113
|
}
|
package/hooks/use-shop-params.ts
CHANGED
|
@@ -2,9 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback } from 'react';
|
|
4
4
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
5
|
-
import type
|
|
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
|
-
|
|
33
|
-
const keyword = searchParams.get(
|
|
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
|
|
5
|
+
import type { Product, ApiResponse } from '../types/product';
|
|
6
6
|
import { createBrowserClient } from '../sdk/client';
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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:
|
|
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 ??
|
|
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
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
});
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
182
|
+
if (!cancelled) setIsLoading(false);
|
|
171
183
|
}
|
|
172
184
|
}
|
|
173
185
|
|
|
174
186
|
void run();
|
|
175
187
|
|
|
176
188
|
return () => {
|
|
177
|
-
|
|
189
|
+
cancelled = true;
|
|
178
190
|
};
|
|
179
|
-
}, [isPreview, vendor.slug,
|
|
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
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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
|
+
}
|