@usequeek/theme-kit 0.1.23 → 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.
Files changed (100) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/LICENSE +1 -1
  3. package/README.md +164 -21
  4. package/api/collections.ts +14 -1
  5. package/api/orders.ts +15 -1
  6. package/apps/follow-card.tsx +5 -0
  7. package/apps/subscribe-form.tsx +2 -2
  8. package/components/auth/auth-flow.tsx +12 -0
  9. package/components/cart/cart-panel-controller.tsx +13 -0
  10. package/components/checkout/default-checkout-shell.tsx +5 -0
  11. package/components/checkout/policy-links.tsx +4 -0
  12. package/components/image.tsx +10 -0
  13. package/components/language-switcher.tsx +1 -0
  14. package/components/powered-by-queek.tsx +4 -3
  15. package/components/product-media-frame.tsx +5 -0
  16. package/components/product-metafields.tsx +9 -0
  17. package/components/variant-picker.css +4 -7
  18. package/edit-mode.tsx +4 -3
  19. package/hooks/use-address-autocomplete.ts +9 -0
  20. package/hooks/use-auth-flow.tsx +7 -12
  21. package/hooks/use-auth.ts +10 -0
  22. package/hooks/use-cart.ts +12 -0
  23. package/hooks/use-categories.ts +16 -2
  24. package/hooks/use-href.ts +9 -4
  25. package/hooks/use-posts.ts +16 -8
  26. package/hooks/use-product-detail.ts +25 -11
  27. package/hooks/use-product-questions.ts +16 -3
  28. package/hooks/use-products.ts +44 -22
  29. package/hooks/use-related-products.ts +17 -5
  30. package/hooks/use-resolve-coordinates.ts +9 -0
  31. package/hooks/use-reviews.ts +16 -2
  32. package/hooks/use-search.ts +10 -0
  33. package/hooks/use-shop-cart.ts +8 -0
  34. package/hooks/use-shop.ts +19 -6
  35. package/hooks/use-variant-selection.ts +23 -19
  36. package/index.ts +10 -11
  37. package/locales/en.default.json +4 -0
  38. package/navigation.tsx +2 -0
  39. package/package.json +25 -4
  40. package/page-renderer.tsx +11 -0
  41. package/provider.tsx +29 -3
  42. package/sdk/client.ts +183 -36
  43. package/sdk/error-handler.ts +17 -9
  44. package/sdk/queek-client.ts +11 -1
  45. package/sdk/server-store-client.ts +2 -2
  46. package/shared-blocks/embed.tsx +7 -3
  47. package/shared-blocks/manifest.ts +1 -1
  48. package/shared-blocks/product-qa.tsx +1 -1
  49. package/stores/address-modal-store.ts +1 -0
  50. package/stores/auth-modal-store.ts +1 -0
  51. package/stores/cart-panel-store.ts +1 -0
  52. package/stores/cart-store.ts +12 -7
  53. package/stores/checkout-store.ts +4 -4
  54. package/stores/location-store.ts +5 -0
  55. package/stores/orders-sheet-store.ts +1 -0
  56. package/stores/product-modal-store.ts +1 -0
  57. package/stores/search-modal-store.ts +1 -0
  58. package/stores/user-store.ts +1 -1
  59. package/strings/pseudo-locale.ts +1 -1
  60. package/theme-context.tsx +11 -1
  61. package/theme-mount.tsx +14 -0
  62. package/utils/format.ts +10 -1
  63. package/utils/menu-link.ts +8 -0
  64. package/utils/to-error.ts +4 -0
  65. package/utils/vendor-host.ts +1 -2
  66. package/scripts/verify-package.ts +0 -102
  67. package/tests/apps.test.tsx +0 -33
  68. package/tests/auth-email-errors.test.tsx +0 -55
  69. package/tests/currency-contract.test.ts +0 -85
  70. package/tests/default-metaobject-page.test.tsx +0 -56
  71. package/tests/display-format.test.ts +0 -403
  72. package/tests/fixtures/external-theme.tsx +0 -90
  73. package/tests/image-loading.test.tsx +0 -31
  74. package/tests/language-menu.test.tsx +0 -598
  75. package/tests/language-switcher.test.tsx +0 -350
  76. package/tests/list-pagination.test.tsx +0 -71
  77. package/tests/locale-switch.test.ts +0 -197
  78. package/tests/locale-transport.test.tsx +0 -321
  79. package/tests/manifest-parity.test.ts +0 -54
  80. package/tests/markdown-render.test.ts +0 -46
  81. package/tests/menu-placement.test.ts +0 -63
  82. package/tests/metaobjects-block.test.tsx +0 -70
  83. package/tests/navigation.test.tsx +0 -30
  84. package/tests/orders-walk.test.ts +0 -48
  85. package/tests/page-renderer-signature.test.tsx +0 -42
  86. package/tests/powered-by-queek.test.tsx +0 -65
  87. package/tests/powered-by-url.test.ts +0 -49
  88. package/tests/product-images.test.ts +0 -242
  89. package/tests/product-metafields.test.tsx +0 -154
  90. package/tests/public-surface.test.ts +0 -35
  91. package/tests/shop-query.test.ts +0 -64
  92. package/tests/shopper-chrome-strings.test.tsx +0 -271
  93. package/tests/storefront-client-scope.test.ts +0 -43
  94. package/tests/theme-hook-placement.test.tsx +0 -179
  95. package/tests/theme-strings-client.test.tsx +0 -310
  96. package/tests/theme-strings-pseudo.test.tsx +0 -171
  97. package/tests/theme-strings-typed.test.tsx +0 -97
  98. package/tests/theme-strings.test.tsx +0 -401
  99. package/tests/theme-typed-keys.fixture.ts +0 -76
  100. package/tests/use-shop-prefetch.test.tsx +0 -87
@@ -4,12 +4,22 @@ import { useMemo } from 'react';
4
4
  import type { Product } from '../types/product';
5
5
  import type { Collection } from '../types/page';
6
6
 
7
+ /** What {@link useSearch} returns: the matches to show in a search panel. */
7
8
  export interface SearchResults {
8
9
  products: Product[];
9
10
  categories: Array<[slug: string, name: string]>;
10
11
  collections: Collection[];
11
12
  }
12
13
 
14
+ /**
15
+ * Instant search over products and collections you already hold (no request).
16
+ * An empty query returns the first products and categories as suggestions.
17
+ * Matches are case-insensitive on product title, category name and collection
18
+ * name, capped at 5 products, 4 categories and 2 collections.
19
+ *
20
+ * @example
21
+ * const { products, categories, collections } = useSearch(query, allProducts, allCollections);
22
+ */
13
23
  export function useSearch(
14
24
  query: string,
15
25
  products: Product[],
@@ -3,6 +3,14 @@
3
3
  import { useMemo } from 'react';
4
4
  import { useCartStore } from '../stores/cart-store';
5
5
 
6
+ /**
7
+ * The cart lines for one shop, with the unit count and the total (in the
8
+ * currency's major unit, before delivery and discounts). Pass the shop id from
9
+ * `product.shop_id`; a missing id gives an empty cart.
10
+ *
11
+ * @example
12
+ * const { items, count, total } = useShopCart(product.shop_id);
13
+ */
6
14
  export function useShopCart(shopId: string | null | undefined): {
7
15
  items: ReturnType<typeof useCartStore.getState>['items'];
8
16
  count: number;
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
- * Shop catalogue data source. Hits the live products endpoint in production.
82
- * In preview (when the provider carries `previewData.products`) it filters,
83
- * sorts and paginates the demo catalogue in-memory so themes render real cards.
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
- * Page is controlled by the caller — pair with `useShopParams` for URL sync.
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
- * Core also holds one `next/headers` caller, which belongs in neither and is
15
- * deliberately unreachable from both.
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` fails the build outright: tooling that
19
- * imports theme components under tsx (a theme registry generator, for example)
20
- * would reach a gallery block through the barrel, which transitively pulls the
21
- * SDK, and the SDK does not resolve outside a bundler. It is the same reason a barrel would bloat every
22
- * theme chunk: the stores call `create()` at import time and package.json
23
- * declares no `sideEffects`, so webpack must treat every re-export as live and
24
- * cannot drop the unused ones. Themes keep importing the exact module they use;
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';
@@ -176,5 +176,9 @@
176
176
  "language": {
177
177
  "label": "Language",
178
178
  "named": "Language: {name}"
179
+ },
180
+ "embed": {
181
+ "placeholder": "Paste the link or embed code of the content you want to show",
182
+ "title": "Embedded content"
179
183
  }
180
184
  }
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.23",
3
+ "version": "0.1.24",
4
4
  "private": false,
5
5
  "license": "MIT",
6
- "description": "The framework a Queek storefront theme is built against.",
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('useStorefront must be used within StorefrontProvider');
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 must be used within StorefrontProvider');
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 must be used within StorefrontProvider');
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
- // SERVER-side only (SSR data fetching, server-to-server — never reaches the
4
- // browser, so the real absolute host is correct and required here).
5
- export const API_BASE_URL = process.env.QUEEK_API_URL ?? process.env.NEXT_PUBLIC_QUEEK_API_URL ?? 'http://127.0.0.1:8000/api/v1';
6
- // BROWSER-side only (createBrowserClient below, "for client components/hooks").
7
- // Hardcoded relative on purpose, same reasoning as queek-client.ts's
8
- // API_BASE_URL: the host app's `next.config` rewrite already proxies
9
- // /api/v1/client/:path* to the API server-side, so the browser never needs the
10
- // real host. This
11
- // used to read NEXT_PUBLIC_QUEEK_API_URL directly — when that's set to the
12
- // same absolute value as the server-side QUEEK_API_URL, every hook built on
13
- // createBrowserClient (products, shop, categories, reviews, related products,
14
- // product questions, QR collections) leaked api.usequeek.com straight into
15
- // the browser Network tab, bypassing the proxy entirely.
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 — a 404 on every URL of a live store
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(`Request failed with status ${status}`);
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 — Next's dev bundler (`setup-dev-bundler.js`,
66
- * `logErrorWithOriginalStack`) unconditionally does `err.message = ...` on
67
- * anything `instanceof Error` when rendering the dev overlay, which crashes
68
- * with "Cannot set property message of ... which has only a getter" and
69
- * cascades into unhandledRejection noise. Rewrapping into a plain, settable
70
- * Error here — the one place every SSR/browser fetch funnels through — stops
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(`Request to ${url} timed out after ${timeoutMs}ms`);
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(`Storefront may only call /v1/client/* routes — got "${path}". Use createServerStoreClient / createBrowserClient (which prefix /client/store).`);
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 — the
159
- // data cache is shared across every visitor, so caching either would serve one
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/hooks.
197
- * Uses client-store routes (/client/store/*) with Origin + X-Vendor-Slug vendor resolution.
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>;