@usequeek/theme-kit 0.1.22 → 0.1.24
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/CHANGELOG.md +37 -0
- package/LICENSE +1 -1
- package/README.md +275 -26
- package/api/collections.ts +14 -1
- package/api/orders.ts +15 -1
- package/apps/follow-card.tsx +5 -0
- package/apps/subscribe-form.tsx +2 -2
- package/components/auth/auth-flow.tsx +12 -0
- package/components/cart/cart-panel-controller.tsx +13 -0
- package/components/checkout/default-checkout-shell.tsx +5 -0
- package/components/checkout/policy-links.tsx +4 -0
- package/components/image.tsx +10 -0
- package/components/language-menu.css +349 -0
- package/components/language-menu.tsx +371 -0
- package/components/language-switcher.tsx +40 -8
- package/components/powered-by-queek.tsx +4 -3
- package/components/product-media-frame.tsx +5 -0
- package/components/product-metafields.tsx +9 -0
- package/components/variant-picker.css +4 -7
- package/edit-mode.tsx +4 -3
- package/hooks/use-address-autocomplete.ts +9 -0
- package/hooks/use-auth-flow.tsx +7 -12
- package/hooks/use-auth.ts +10 -0
- package/hooks/use-cart.ts +12 -0
- package/hooks/use-categories.ts +16 -2
- package/hooks/use-href.ts +9 -4
- package/hooks/use-posts.ts +16 -8
- package/hooks/use-product-detail.ts +25 -11
- package/hooks/use-product-questions.ts +16 -3
- package/hooks/use-products.ts +44 -22
- package/hooks/use-related-products.ts +17 -5
- package/hooks/use-resolve-coordinates.ts +9 -0
- package/hooks/use-reviews.ts +16 -2
- package/hooks/use-search.ts +10 -0
- package/hooks/use-shop-cart.ts +8 -0
- package/hooks/use-shop.ts +19 -6
- package/hooks/use-variant-selection.ts +23 -19
- package/index.ts +10 -11
- package/locales/en.default.json +4 -0
- package/navigation.tsx +2 -0
- package/package.json +25 -4
- package/page-renderer.tsx +11 -0
- package/provider.tsx +29 -3
- package/sdk/client.ts +183 -36
- package/sdk/error-handler.ts +17 -9
- package/sdk/queek-client.ts +11 -1
- package/sdk/server-store-client.ts +2 -2
- package/shared-blocks/embed.tsx +7 -3
- package/shared-blocks/manifest.ts +1 -1
- package/shared-blocks/product-qa.tsx +1 -1
- package/stores/address-modal-store.ts +1 -0
- package/stores/auth-modal-store.ts +1 -0
- package/stores/cart-panel-store.ts +1 -0
- package/stores/cart-store.ts +12 -7
- package/stores/checkout-store.ts +4 -4
- package/stores/location-store.ts +5 -0
- package/stores/orders-sheet-store.ts +1 -0
- package/stores/product-modal-store.ts +1 -0
- package/stores/search-modal-store.ts +1 -0
- package/stores/user-store.ts +1 -1
- package/strings/pseudo-locale.ts +1 -1
- package/theme-context.tsx +11 -1
- package/theme-mount.tsx +14 -0
- package/utils/format.ts +10 -1
- package/utils/menu-link.ts +8 -0
- package/utils/menu-placement.ts +91 -0
- package/utils/to-error.ts +4 -0
- package/utils/vendor-host.ts +1 -2
- package/scripts/verify-package.ts +0 -102
- package/tests/apps.test.tsx +0 -33
- package/tests/auth-email-errors.test.tsx +0 -55
- package/tests/currency-contract.test.ts +0 -85
- package/tests/default-metaobject-page.test.tsx +0 -56
- package/tests/display-format.test.ts +0 -403
- package/tests/fixtures/external-theme.tsx +0 -85
- package/tests/image-loading.test.tsx +0 -31
- package/tests/language-switcher.test.tsx +0 -350
- package/tests/list-pagination.test.tsx +0 -71
- package/tests/locale-switch.test.ts +0 -197
- package/tests/locale-transport.test.tsx +0 -321
- package/tests/manifest-parity.test.ts +0 -54
- package/tests/markdown-render.test.ts +0 -46
- package/tests/metaobjects-block.test.tsx +0 -70
- package/tests/navigation.test.tsx +0 -30
- package/tests/orders-walk.test.ts +0 -48
- package/tests/page-renderer-signature.test.tsx +0 -42
- package/tests/powered-by-queek.test.tsx +0 -65
- package/tests/powered-by-url.test.ts +0 -49
- package/tests/product-images.test.ts +0 -242
- package/tests/product-metafields.test.tsx +0 -154
- package/tests/public-surface.test.ts +0 -35
- package/tests/shop-query.test.ts +0 -64
- package/tests/shopper-chrome-strings.test.tsx +0 -271
- package/tests/storefront-client-scope.test.ts +0 -43
- package/tests/theme-hook-placement.test.tsx +0 -179
- package/tests/theme-strings-client.test.tsx +0 -310
- package/tests/theme-strings-pseudo.test.tsx +0 -171
- package/tests/theme-strings-typed.test.tsx +0 -97
- package/tests/theme-strings.test.tsx +0 -401
- package/tests/theme-typed-keys.fixture.ts +0 -76
- package/tests/use-shop-prefetch.test.tsx +0 -87
package/hooks/use-shop.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { useStorefront, useStorefrontLocale } from '../provider';
|
|
|
5
5
|
import type { Product, ApiResponse } from '../types/product';
|
|
6
6
|
import { createBrowserClient } from '../sdk/client';
|
|
7
7
|
import { localeCacheKeySegment, withLocaleQuery } from '../utils/locale';
|
|
8
|
+
import { toError } from '../utils/to-error';
|
|
8
9
|
import {
|
|
9
10
|
SHOP_DEFAULT_PER_PAGE,
|
|
10
11
|
normalizeShopQuery,
|
|
@@ -78,11 +79,18 @@ function filterProducts(
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
82
|
+
* The shop page's catalogue: one page of products for a category, keyword,
|
|
83
|
+
* sort and page number. Reads the live products endpoint in production. In
|
|
84
|
+
* preview (when the provider carries `previewData.products`) it filters, sorts
|
|
85
|
+
* and paginates the demo catalogue in memory, so themes render real cards.
|
|
84
86
|
*
|
|
85
|
-
*
|
|
87
|
+
* The page is controlled by the caller; pair this with `useShopParams` to keep
|
|
88
|
+
* it in the URL. On failure the previous page of products stays on screen and
|
|
89
|
+
* `error` is set; the hook never throws.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* const { categorySlug, keyword, sort, page } = useShopParams();
|
|
93
|
+
* const { products, isLoading, pagination, error } = useShop({ categorySlug, keyword, sort, page });
|
|
86
94
|
*/
|
|
87
95
|
export function useShop(options?: {
|
|
88
96
|
categorySlug?: string | null;
|
|
@@ -94,6 +102,7 @@ export function useShop(options?: {
|
|
|
94
102
|
products: Product[];
|
|
95
103
|
isLoading: boolean;
|
|
96
104
|
pagination: ShopPagination;
|
|
105
|
+
error: Error | null;
|
|
97
106
|
} {
|
|
98
107
|
const { vendor, previewData } = useStorefront();
|
|
99
108
|
const locale = useStorefrontLocale();
|
|
@@ -123,6 +132,7 @@ export function useShop(options?: {
|
|
|
123
132
|
|
|
124
133
|
const [products, setProducts] = useState<Product[]>(seed?.products ?? []);
|
|
125
134
|
const [isLoading, setIsLoading] = useState(!isPreview && !seed);
|
|
135
|
+
const [error, setError] = useState<Error | null>(null);
|
|
126
136
|
const [pagination, setPagination] = useState<ShopPagination>(seed?.pagination ?? { ...EMPTY_PAGINATION, perPage });
|
|
127
137
|
// The key whose results are already in state. Seeded with the prefetch so
|
|
128
138
|
// the first effect run does not refetch the page the server just rendered
|
|
@@ -166,6 +176,7 @@ export function useShop(options?: {
|
|
|
166
176
|
// a newer one (filters B) had reset it, overwriting B's products.
|
|
167
177
|
let cancelled = false;
|
|
168
178
|
setIsLoading(true);
|
|
179
|
+
setError(null);
|
|
169
180
|
|
|
170
181
|
const client = createBrowserClient(vendor.slug ?? undefined);
|
|
171
182
|
const current = JSON.parse(queryKey) as typeof query;
|
|
@@ -182,6 +193,8 @@ export function useShop(options?: {
|
|
|
182
193
|
setPagination(parsed.pagination);
|
|
183
194
|
setProducts(parsed.products);
|
|
184
195
|
loadedQueryRef.current = fetchKey;
|
|
196
|
+
} catch (caught) {
|
|
197
|
+
if (!cancelled) setError(toError(caught));
|
|
185
198
|
} finally {
|
|
186
199
|
if (!cancelled) setIsLoading(false);
|
|
187
200
|
}
|
|
@@ -195,8 +208,8 @@ export function useShop(options?: {
|
|
|
195
208
|
}, [isPreview, vendor.slug, queryKey, fetchKey, locale]);
|
|
196
209
|
|
|
197
210
|
if (isPreview && previewResult) {
|
|
198
|
-
return { products: previewResult.products, isLoading: false, pagination: previewResult.pagination };
|
|
211
|
+
return { products: previewResult.products, isLoading: false, pagination: previewResult.pagination, error: null };
|
|
199
212
|
}
|
|
200
213
|
|
|
201
|
-
return { products, isLoading, pagination };
|
|
214
|
+
return { products, isLoading, pagination, error };
|
|
202
215
|
}
|
|
@@ -25,25 +25,6 @@ export interface VariantSelection {
|
|
|
25
25
|
activeHasDiscount: boolean;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
* The option-selection state machine every theme's product page used to
|
|
30
|
-
* hand-roll — six near-identical copies, all of which gated their add button
|
|
31
|
-
* on stock and addons but never on a resolved variant.
|
|
32
|
-
*
|
|
33
|
-
* Two things it fixes that the copies got wrong:
|
|
34
|
-
*
|
|
35
|
-
* 1. It seeds from a real variant (first in-stock, else first) rather than
|
|
36
|
-
* from the first value of each option independently. On a sparse matrix —
|
|
37
|
-
* Red/S is not stocked but Red/M and Blue/S are — first-value-of-each
|
|
38
|
-
* produces a combination that matches nothing, so the page opened with an
|
|
39
|
-
* unresolvable selection.
|
|
40
|
-
* 2. It exposes `variantSatisfied`, so a selection that resolves to nothing
|
|
41
|
-
* (mid-change, or that sparse seed) disables the add instead of sending a
|
|
42
|
-
* line the backend rejects.
|
|
43
|
-
*
|
|
44
|
-
* Themes keep their own option markup and call `selectOption` — core owns the
|
|
45
|
-
* state, the theme owns the design.
|
|
46
|
-
*/
|
|
47
28
|
/**
|
|
48
29
|
* The opening selection. Seeded from a variant that actually exists, so the
|
|
49
30
|
* initial state is always resolvable — preferring one in stock, since opening
|
|
@@ -85,6 +66,29 @@ export function resolveVariantState(
|
|
|
85
66
|
};
|
|
86
67
|
}
|
|
87
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The option-selection state machine every theme's product page used to
|
|
71
|
+
* hand-roll, and which gated the add button on stock and add-ons but never on a
|
|
72
|
+
* resolved variant.
|
|
73
|
+
*
|
|
74
|
+
* It does two things a hand-rolled version tends to get wrong:
|
|
75
|
+
*
|
|
76
|
+
* 1. It seeds from a real variant (first in-stock, else first) rather than
|
|
77
|
+
* from the first value of each option independently. On a sparse matrix —
|
|
78
|
+
* Red/S is not stocked but Red/M and Blue/S are — first-value-of-each
|
|
79
|
+
* produces a combination that matches nothing, so the page opened with an
|
|
80
|
+
* unresolvable selection.
|
|
81
|
+
* 2. It exposes `variantSatisfied`, so a selection that resolves to nothing
|
|
82
|
+
* (mid-change, or that sparse seed) disables the add instead of sending a
|
|
83
|
+
* line the backend rejects.
|
|
84
|
+
*
|
|
85
|
+
* Themes keep their own option markup and call `selectOption`: the kit owns the
|
|
86
|
+
* state, the theme owns the design.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* const { selectedOptions, selectOption, variantSatisfied, activePrice } = useVariantSelection(product);
|
|
90
|
+
* <button disabled={!variantSatisfied || !activeInStock}>Add to cart</button>
|
|
91
|
+
*/
|
|
88
92
|
export function useVariantSelection(product: Product): VariantSelection {
|
|
89
93
|
const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>(() => seedVariantOptions(product));
|
|
90
94
|
|
package/index.ts
CHANGED
|
@@ -11,19 +11,18 @@
|
|
|
11
11
|
* Two entries because the split is real, not cosmetic: this one is types and
|
|
12
12
|
* pure functions, importable from anywhere including node tooling; `client.ts`
|
|
13
13
|
* is React state and components and needs a client boundary at the consumer.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Modules that call `next/headers` (`sdk/server-store-client.ts` and the
|
|
15
|
+
* `api/collections.ts` and `api/shop.ts` fetchers built on it) are server-only:
|
|
16
|
+
* import them from server components, never from a client module.
|
|
16
17
|
*
|
|
17
18
|
* THESE ARE A DECLARATION, NOT AN IMPORT PATH. Do not migrate themes onto them.
|
|
18
|
-
* Pointing a theme's files at `client.ts`
|
|
19
|
-
* imports theme components under tsx (a theme registry generator, for
|
|
20
|
-
* would reach a gallery block through the barrel, which transitively
|
|
21
|
-
* SDK, and the SDK does not resolve outside a bundler.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* these barrels stay the machine-checkable statement of what is in the public
|
|
26
|
-
* surface.
|
|
19
|
+
* Pointing a theme's files at `client.ts` can fail the build outright: tooling
|
|
20
|
+
* that imports theme components under `tsx` (a theme registry generator, for
|
|
21
|
+
* example) would reach a gallery block through the barrel, which transitively
|
|
22
|
+
* pulls the SDK, and the SDK does not resolve outside a bundler. A barrel also
|
|
23
|
+
* makes every client module reachable from one entry, which bloats each theme
|
|
24
|
+
* chunk. Themes import the exact module they use; these barrels stay the
|
|
25
|
+
* machine-checkable statement of what is in the public surface.
|
|
27
26
|
*/
|
|
28
27
|
|
|
29
28
|
export * from './types/theme';
|
package/locales/en.default.json
CHANGED
package/navigation.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import NextLink from 'next/link';
|
|
|
15
15
|
import { usePathname as useFrameworkPathname, useRouter as useFrameworkRouter } from 'next/navigation';
|
|
16
16
|
import { useMemo, type AnchorHTMLAttributes, type ReactNode, type Ref } from 'react';
|
|
17
17
|
|
|
18
|
+
/** Props for {@link Link}: any anchor attribute except `href`, plus `prefetch` and `replace`. */
|
|
18
19
|
export interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
|
|
19
20
|
/** A path inside the store (`menuItemToHref()`, `useHref()`) or a full URL. */
|
|
20
21
|
href: string;
|
|
@@ -31,6 +32,7 @@ export function Link({ href, prefetch, replace, ...anchor }: LinkProps) {
|
|
|
31
32
|
return <NextLink href={href} prefetch={prefetch} replace={replace} {...anchor} />;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/** What {@link useRouter} returns. */
|
|
34
36
|
export interface Router {
|
|
35
37
|
/** Go to `href`, adding a history entry. */
|
|
36
38
|
push(href: string): void;
|
package/package.json
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usequeek/theme-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"description": "The
|
|
6
|
+
"description": "The runtime a Queek storefront theme is built on: hooks, stores, page renderer, navigation, shared blocks and locale strings.",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"queek",
|
|
9
|
+
"storefront",
|
|
10
|
+
"theme",
|
|
11
|
+
"themes",
|
|
12
|
+
"react",
|
|
13
|
+
"nextjs",
|
|
14
|
+
"commerce",
|
|
15
|
+
"ecommerce"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://docs.usequeek.com/docs/themes",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/usequeek/theme-kit/issues"
|
|
20
|
+
},
|
|
21
|
+
"author": "Queekx Technology LTD",
|
|
7
22
|
"repository": {
|
|
8
23
|
"type": "git",
|
|
9
24
|
"url": "git+https://github.com/usequeek/theme-kit.git"
|
|
@@ -25,13 +40,19 @@
|
|
|
25
40
|
"**/*.css",
|
|
26
41
|
"locales/*.json",
|
|
27
42
|
"README.md",
|
|
28
|
-
"LICENSE"
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"CHANGELOG.md",
|
|
45
|
+
"!tests",
|
|
46
|
+
"!scripts"
|
|
29
47
|
],
|
|
30
48
|
"peerDependencies": {
|
|
31
49
|
"next": ">=15",
|
|
32
50
|
"react": ">=19",
|
|
33
51
|
"react-dom": ">=19",
|
|
34
|
-
"@queekai/client-sdk": "
|
|
52
|
+
"@queekai/client-sdk": "^0.3.1 || ^0.4.0"
|
|
53
|
+
},
|
|
54
|
+
"engines": {
|
|
55
|
+
"node": ">=22"
|
|
35
56
|
},
|
|
36
57
|
"dependencies": {
|
|
37
58
|
"zustand": "^5.0.11",
|
package/page-renderer.tsx
CHANGED
|
@@ -65,6 +65,17 @@ function signatureReplacer(_key: string, value: unknown): unknown {
|
|
|
65
65
|
return value;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Renders a page's blocks in order, choosing each block's theme variant (or
|
|
70
|
+
* the kit's shared block). Block data arrives already resolved: a theme never
|
|
71
|
+
* fetches it. While a merchant edits, a draft section that cannot render shows
|
|
72
|
+
* a short "can't be previewed yet" notice instead of blanking the page.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* export default function HomePage({ blocks }: HomePageProps) {
|
|
76
|
+
* return <PageRenderer blocks={blocks} />;
|
|
77
|
+
* }
|
|
78
|
+
*/
|
|
68
79
|
export function PageRenderer({ blocks }: { blocks: Block[] }): JSX.Element {
|
|
69
80
|
const draftBlocks = useEditPreviewStore((state) => state.blocks);
|
|
70
81
|
const reconcileDraftBlocks = useEditPreviewStore((state) => state.reconcile);
|
package/provider.tsx
CHANGED
|
@@ -61,8 +61,23 @@ interface StorefrontContextValue {
|
|
|
61
61
|
locales?: StoreLocale[];
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** The context behind {@link StorefrontProvider}. Read it through the hooks; use it directly only to test for a provider without throwing. */
|
|
64
65
|
export const StorefrontContext = createContext<StorefrontContextValue | null>(null);
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Gives every kit hook and component the store they render for: its profile,
|
|
69
|
+
* configuration, menus, locale, languages and strings. The Queek storefront
|
|
70
|
+
* mounts it for every theme, so a theme never renders it. Render it yourself
|
|
71
|
+
* only in a test, a preview or a script.
|
|
72
|
+
*
|
|
73
|
+
* `basePath` defaults to `/<vendor.slug>`; pass `''` for a store served on its
|
|
74
|
+
* own domain. Brand design tokens become CSS variables on a wrapping element.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* <StorefrontProvider vendor={vendor} config={config} menus={[]}>
|
|
78
|
+
* <MySection />
|
|
79
|
+
* </StorefrontProvider>
|
|
80
|
+
*/
|
|
66
81
|
export function StorefrontProvider({
|
|
67
82
|
vendor,
|
|
68
83
|
config,
|
|
@@ -147,11 +162,22 @@ export function StorefrontProvider({
|
|
|
147
162
|
);
|
|
148
163
|
}
|
|
149
164
|
|
|
165
|
+
/**
|
|
166
|
+
* The current store: `vendor`, `config`, `menus`, `basePath`, `locale` and the
|
|
167
|
+
* rest of what the host passed to {@link StorefrontProvider}.
|
|
168
|
+
*
|
|
169
|
+
* @throws {Error} when called outside a `StorefrontProvider`.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* const { vendor, config } = useStorefront();
|
|
173
|
+
*/
|
|
150
174
|
export function useStorefront(): StorefrontContextValue {
|
|
151
175
|
const context = useContext(StorefrontContext);
|
|
152
176
|
|
|
153
177
|
if (!context) {
|
|
154
|
-
throw new Error(
|
|
178
|
+
throw new Error(
|
|
179
|
+
'useStorefront() was called outside <StorefrontProvider>. Render the component inside StorefrontProvider (the Queek storefront does this for every theme). In a test, wrap it yourself.',
|
|
180
|
+
);
|
|
155
181
|
}
|
|
156
182
|
|
|
157
183
|
return context;
|
|
@@ -167,7 +193,7 @@ export function useStorefrontLocale(): string | null {
|
|
|
167
193
|
const context = useContext(StorefrontContext);
|
|
168
194
|
|
|
169
195
|
if (!context) {
|
|
170
|
-
throw new Error('useStorefrontLocale
|
|
196
|
+
throw new Error('useStorefrontLocale() was called outside <StorefrontProvider>. Render the component inside StorefrontProvider.');
|
|
171
197
|
}
|
|
172
198
|
|
|
173
199
|
return context.locale ?? null;
|
|
@@ -188,7 +214,7 @@ export function useThemeStrings(): ThemeStringsFn {
|
|
|
188
214
|
const context = useContext(StorefrontContext);
|
|
189
215
|
|
|
190
216
|
if (!context) {
|
|
191
|
-
throw new Error('useThemeStrings
|
|
217
|
+
throw new Error('useThemeStrings() was called outside <StorefrontProvider>. Render the component inside StorefrontProvider.');
|
|
192
218
|
}
|
|
193
219
|
|
|
194
220
|
const theme = useOptionalTheme();
|
package/sdk/client.ts
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
import { getActivePlatform } from './platform';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
3
|
+
const LOCAL_API_URL = 'http://127.0.0.1:8000/api/v1';
|
|
4
|
+
const API_URL_FROM_ENV = process.env.QUEEK_API_URL ?? process.env.NEXT_PUBLIC_QUEEK_API_URL;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Origin of the Queek API for SERVER-side reads (server rendering and
|
|
8
|
+
* server-to-server calls; this value never reaches the browser).
|
|
9
|
+
*
|
|
10
|
+
* Set `QUEEK_API_URL` (or `NEXT_PUBLIC_QUEEK_API_URL`) in the storefront host's
|
|
11
|
+
* environment. When neither is set the kit falls back to a local development
|
|
12
|
+
* address and warns once in production builds.
|
|
13
|
+
*/
|
|
14
|
+
export const API_BASE_URL = API_URL_FROM_ENV ?? LOCAL_API_URL;
|
|
15
|
+
|
|
16
|
+
// BROWSER-side only (createBrowserClient below, for client components and hooks).
|
|
17
|
+
// Always same-origin relative: the host app's `next.config` rewrite proxies
|
|
18
|
+
// /api/v1/client/:path* to the API, so the browser never needs the API's real
|
|
19
|
+
// host. Reading NEXT_PUBLIC_QUEEK_API_URL here would leak that host into every
|
|
20
|
+
// browser request whenever the variable holds the same absolute value as the
|
|
21
|
+
// server-side QUEEK_API_URL.
|
|
16
22
|
const PUBLIC_API_BASE_URL = '/api/v1';
|
|
17
23
|
|
|
24
|
+
let warnedAboutLocalApiUrl = false;
|
|
25
|
+
|
|
26
|
+
function warnIfApiUrlUnset(baseUrl: string): void {
|
|
27
|
+
if (warnedAboutLocalApiUrl || baseUrl !== LOCAL_API_URL || API_URL_FROM_ENV !== undefined) return;
|
|
28
|
+
if (process.env.NODE_ENV !== 'production') return;
|
|
29
|
+
warnedAboutLocalApiUrl = true;
|
|
30
|
+
console.warn(
|
|
31
|
+
`[theme-kit] QUEEK_API_URL is not set, so server-side reads go to ${LOCAL_API_URL}. ` +
|
|
32
|
+
'Set QUEEK_API_URL to your Queek API origin in the storefront host environment.',
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
export interface RequestOptions extends RequestInit {
|
|
19
37
|
query?: Record<string, string | number | boolean | string[] | null | undefined>;
|
|
20
38
|
/** Abort budget. Omit to leave the request unbounded (browser default). */
|
|
@@ -42,41 +60,123 @@ export interface RequestCachePolicy {
|
|
|
42
60
|
const BROWSER_GET_TIMEOUT_MS = 15_000;
|
|
43
61
|
const BROWSER_POST_TIMEOUT_MS = 25_000;
|
|
44
62
|
|
|
63
|
+
/** Stable machine-readable code for a failed request. Branch on this, not on the message. */
|
|
64
|
+
export type ApiErrorCode =
|
|
65
|
+
| 'unauthenticated'
|
|
66
|
+
| 'forbidden'
|
|
67
|
+
| 'not_found'
|
|
68
|
+
| 'rate_limited'
|
|
69
|
+
| 'client_error'
|
|
70
|
+
| 'server_error'
|
|
71
|
+
| 'timeout';
|
|
72
|
+
|
|
73
|
+
function codeForStatus(status: number): ApiErrorCode {
|
|
74
|
+
if (status === 401) return 'unauthenticated';
|
|
75
|
+
if (status === 403) return 'forbidden';
|
|
76
|
+
if (status === 404) return 'not_found';
|
|
77
|
+
if (status === 429) return 'rate_limited';
|
|
78
|
+
return status >= 500 ? 'server_error' : 'client_error';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function nextStepForStatus(status: number, retryAfterSeconds: number | null): string {
|
|
82
|
+
if (status === 401) return 'The customer session is missing or has expired. Ask the customer to sign in again.';
|
|
83
|
+
if (status === 403) return 'This session is not allowed to do that on this store.';
|
|
84
|
+
if (status === 404) return 'The resource does not exist, or it is not visible on this store. Check the slug or id.';
|
|
85
|
+
if (status === 429) {
|
|
86
|
+
return retryAfterSeconds === null
|
|
87
|
+
? 'Too many requests. Wait a moment, then retry.'
|
|
88
|
+
: `Too many requests. Retry after ${retryAfterSeconds} seconds.`;
|
|
89
|
+
}
|
|
90
|
+
if (status >= 500) return 'The Queek API could not complete the request. Retry shortly; if it keeps failing, quote the request id to Queek support.';
|
|
91
|
+
return 'The API rejected the request. Check the inputs; `detail` carries the API message when it sent one.';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The request path without its query string, so messages and logs never carry search terms or tokens. */
|
|
95
|
+
function pathOnly(url: string): string {
|
|
96
|
+
const end = url.search(/[?#]/);
|
|
97
|
+
return end === -1 ? url : url.slice(0, end);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Extra context an API failure can carry. All fields are optional. */
|
|
101
|
+
export interface ApiRequestErrorDetails {
|
|
102
|
+
/** The API's request id (`x-request-id`), for support tickets. */
|
|
103
|
+
requestId?: string | null;
|
|
104
|
+
/** Seconds the API asked the caller to wait (`Retry-After`), when it sent one. */
|
|
105
|
+
retryAfterSeconds?: number | null;
|
|
106
|
+
/** The API's own error message, when its body carried one. */
|
|
107
|
+
detail?: string | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
45
110
|
/**
|
|
111
|
+
* The API answered with a non-2xx status.
|
|
112
|
+
*
|
|
46
113
|
* Carries the HTTP status so callers can tell a genuine "this does not exist"
|
|
47
114
|
* (404) apart from an infrastructure failure (5xx, timeout, network). That
|
|
48
115
|
* distinction is load-bearing in a host's vendor layout: 404 must render
|
|
49
|
-
* not-found, while an API blip must NOT
|
|
50
|
-
* is how storefronts get deindexed.
|
|
116
|
+
* not-found, while an API blip must NOT, because a 404 on every URL of a live
|
|
117
|
+
* store is how storefronts get deindexed.
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* try {
|
|
121
|
+
* await client.get('/products/oak-chair');
|
|
122
|
+
* } catch (error) {
|
|
123
|
+
* if (error instanceof ApiRequestError && error.code === 'not_found') return null;
|
|
124
|
+
* throw error;
|
|
125
|
+
* }
|
|
51
126
|
*/
|
|
52
127
|
export class ApiRequestError extends Error {
|
|
128
|
+
/** Stable machine code derived from the status. */
|
|
129
|
+
readonly code: ApiErrorCode;
|
|
130
|
+
/** True when repeating the same request later can succeed (429 and 5xx). */
|
|
131
|
+
readonly retryable: boolean;
|
|
132
|
+
readonly requestId: string | null;
|
|
133
|
+
readonly retryAfterSeconds: number | null;
|
|
134
|
+
readonly detail: string | null;
|
|
135
|
+
|
|
53
136
|
constructor(
|
|
54
137
|
public readonly status: number,
|
|
55
138
|
public readonly url: string,
|
|
139
|
+
{ requestId = null, retryAfterSeconds = null, detail = null }: ApiRequestErrorDetails = {},
|
|
56
140
|
) {
|
|
57
|
-
super(
|
|
141
|
+
super(
|
|
142
|
+
`Request to ${pathOnly(url)} failed with status ${status}. ${nextStepForStatus(status, retryAfterSeconds)}` +
|
|
143
|
+
(requestId ? ` (request id ${requestId})` : ''),
|
|
144
|
+
);
|
|
58
145
|
this.name = 'ApiRequestError';
|
|
146
|
+
this.code = codeForStatus(status);
|
|
147
|
+
this.retryable = status === 429 || status >= 500;
|
|
148
|
+
this.requestId = requestId;
|
|
149
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
150
|
+
this.detail = detail;
|
|
59
151
|
}
|
|
60
152
|
}
|
|
61
153
|
|
|
62
154
|
/**
|
|
155
|
+
* The request did not finish inside its time budget and was aborted.
|
|
156
|
+
*
|
|
63
157
|
* Thrown in place of the raw `DOMException` that `AbortSignal.timeout()`
|
|
64
158
|
* produces. `DOMException` passes `instanceof Error` but its `message` is a
|
|
65
|
-
* getter with no setter
|
|
66
|
-
* `
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* that crash without touching the abort/timeout semantics themselves.
|
|
159
|
+
* getter with no setter, and Next's dev bundler unconditionally assigns
|
|
160
|
+
* `err.message` on anything `instanceof Error` when rendering its overlay,
|
|
161
|
+
* which crashes with "Cannot set property message of ... which has only a
|
|
162
|
+
* getter". Rewrapping into a plain, settable Error here, the one place every
|
|
163
|
+
* server and browser fetch funnels through, avoids that without changing the
|
|
164
|
+
* abort semantics. The original `DOMException` stays on `cause`.
|
|
72
165
|
*/
|
|
73
166
|
export class ApiTimeoutError extends Error {
|
|
167
|
+
readonly code: ApiErrorCode = 'timeout';
|
|
168
|
+
/** A timed-out read can be repeated. A timed-out write may already have reached the API: do not repeat it blindly. */
|
|
169
|
+
readonly retryable = true;
|
|
170
|
+
|
|
74
171
|
constructor(
|
|
75
172
|
public readonly url: string,
|
|
76
173
|
public readonly timeoutMs: number,
|
|
77
174
|
{ cause }: { cause?: unknown } = {},
|
|
78
175
|
) {
|
|
79
|
-
super(
|
|
176
|
+
super(
|
|
177
|
+
`Request to ${pathOnly(url)} timed out after ${timeoutMs}ms. ` +
|
|
178
|
+
'Retry a read. For a write, check whether it was applied before sending it again.',
|
|
179
|
+
);
|
|
80
180
|
this.name = 'ApiTimeoutError';
|
|
81
181
|
this.cause = cause;
|
|
82
182
|
}
|
|
@@ -94,6 +194,10 @@ export class ApiTimeoutError extends Error {
|
|
|
94
194
|
* `status` is read structurally rather than via `instanceof`: the class is
|
|
95
195
|
* emitted once per bundler layer, so an `instanceof` check across the RSC/SSR
|
|
96
196
|
* boundary can be silently false.
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* const product = await client.get<Product>(`/products/${slug}`).catch(swallowNotFound);
|
|
200
|
+
* if (!product) notFound();
|
|
97
201
|
*/
|
|
98
202
|
export function swallowNotFound(error: unknown): null {
|
|
99
203
|
if ((error as { status?: number } | null)?.status === 404) {
|
|
@@ -134,16 +238,48 @@ function withQuery(path: string, query?: RequestOptions['query']): string {
|
|
|
134
238
|
return queryString === '' ? path : `${path}?${queryString}`;
|
|
135
239
|
}
|
|
136
240
|
|
|
241
|
+
function readRetryAfterSeconds(response: Response): number | null {
|
|
242
|
+
const raw = response.headers.get('retry-after');
|
|
243
|
+
if (raw === null) return null;
|
|
244
|
+
const seconds = Number(raw);
|
|
245
|
+
return Number.isFinite(seconds) && seconds >= 0 ? Math.ceil(seconds) : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function readErrorDetail(response: Response): Promise<string | null> {
|
|
249
|
+
try {
|
|
250
|
+
const body = (await response.json()) as { message?: unknown } | null;
|
|
251
|
+
return typeof body?.message === 'string' ? body.message.slice(0, 300) : null;
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* One JSON request to the Queek API. Everything the kit fetches goes through here.
|
|
259
|
+
*
|
|
260
|
+
* Only `/client/*` paths are accepted: that scope resolves the store and forces
|
|
261
|
+
* the sales channel, so prices are always the storefront's. A write is never
|
|
262
|
+
* retried: an aborted POST may already have reached the API, and sending it
|
|
263
|
+
* again could duplicate an order or a payment.
|
|
264
|
+
*
|
|
265
|
+
* @throws {ApiRequestError} on a non-2xx response (read `code`, `status`, `requestId`).
|
|
266
|
+
* @throws {ApiTimeoutError} when `timeoutMs` elapses first.
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* const info = await request<{ data: { name: string } }>(API_BASE_URL, '/client/store/info', {
|
|
270
|
+
* timeoutMs: 8000,
|
|
271
|
+
* });
|
|
272
|
+
*/
|
|
137
273
|
export async function request<T>(baseUrl: string, path: string, options: RequestOptions & { credentials?: RequestCredentials } = {}, extraHeaders?: Record<string, string>): Promise<T> {
|
|
138
|
-
// HARD RULE: the storefront only ever calls the `/v1/client/*` scope. That scope
|
|
139
|
-
// resolves the vendor AND forces the platform (storefront/instore_qr), so prices
|
|
140
|
-
// are always the storefront's (no marketplace commission). Legacy v1 routes
|
|
141
|
-
// (/products, /vendors/*, /categories) skip that resolution → wrong platform →
|
|
142
|
-
// wrong price. Never call them from here.
|
|
143
274
|
if (!path.startsWith('/client/')) {
|
|
144
|
-
throw new Error(
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Refused to call "${path}": storefront code may only call /client/* routes, which resolve the store and force the storefront sales channel. ` +
|
|
277
|
+
'Use createServerStoreClient (server) or createBrowserClient (browser); both add the /client/store prefix.',
|
|
278
|
+
);
|
|
145
279
|
}
|
|
146
280
|
|
|
281
|
+
warnIfApiUrlUnset(baseUrl);
|
|
282
|
+
|
|
147
283
|
const { query, timeoutMs, cachePolicy, ...init } = options;
|
|
148
284
|
const url = `${baseUrl}${withQuery(path, query)}`;
|
|
149
285
|
|
|
@@ -155,9 +291,9 @@ export async function request<T>(baseUrl: string, path: string, options: Request
|
|
|
155
291
|
};
|
|
156
292
|
|
|
157
293
|
// Only idempotent, unauthenticated reads may be cached. A POST must always
|
|
158
|
-
// reach the origin, and a bearer token means the response is per-user
|
|
159
|
-
// data cache is shared across every visitor, so caching either would serve
|
|
160
|
-
// customer's data to another.
|
|
294
|
+
// reach the origin, and a bearer token means the response is per-user: the
|
|
295
|
+
// data cache is shared across every visitor, so caching either would serve
|
|
296
|
+
// one customer's data to another.
|
|
161
297
|
const method = (init.method ?? 'GET').toUpperCase();
|
|
162
298
|
const cacheable = method === 'GET' && headers.Authorization === undefined && !!cachePolicy;
|
|
163
299
|
|
|
@@ -182,7 +318,11 @@ export async function request<T>(baseUrl: string, path: string, options: Request
|
|
|
182
318
|
}
|
|
183
319
|
|
|
184
320
|
if (!response.ok) {
|
|
185
|
-
throw new ApiRequestError(response.status, url
|
|
321
|
+
throw new ApiRequestError(response.status, url, {
|
|
322
|
+
requestId: response.headers.get('x-request-id'),
|
|
323
|
+
retryAfterSeconds: readRetryAfterSeconds(response),
|
|
324
|
+
detail: await readErrorDetail(response),
|
|
325
|
+
});
|
|
186
326
|
}
|
|
187
327
|
|
|
188
328
|
return response.json() as Promise<T>;
|
|
@@ -193,8 +333,15 @@ export async function request<T>(baseUrl: string, path: string, options: Request
|
|
|
193
333
|
// is always forced server-side. Do NOT reintroduce a raw-v1 client here.
|
|
194
334
|
|
|
195
335
|
/**
|
|
196
|
-
* Browser-side client for client components
|
|
197
|
-
*
|
|
336
|
+
* Browser-side client for client components and hooks.
|
|
337
|
+
*
|
|
338
|
+
* Calls the same-origin `/api/v1/client/store/*` routes, resolving the store from
|
|
339
|
+
* the page origin plus the `X-Vendor-Slug` header. Reads time out after 15 s and
|
|
340
|
+
* writes after 25 s. Failures throw `ApiRequestError` or `ApiTimeoutError`.
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* const client = createBrowserClient(vendor.slug);
|
|
344
|
+
* const { data } = await client.get<{ data: Product[] }>('/products', { per_page: 8 });
|
|
198
345
|
*/
|
|
199
346
|
export function createBrowserClient(vendorSlug?: string, token?: string): {
|
|
200
347
|
get: <T>(path: string, query?: RequestOptions['query']) => Promise<T>;
|