@usequeek/theme-kit 0.1.23 → 0.1.25

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 +45 -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 +8 -13
  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 +29 -8
  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 +18 -10
  44. package/sdk/queek-client.ts +12 -2
  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
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { JSX } from 'react';
4
4
  import { createContext, useContext, useState, type ReactNode } from 'react';
5
- import { QueekSdkError, type EmailVerifyOtpResponse } from '@queekai/client-sdk';
5
+ import { QueekSdkError, type EmailVerifyOtpResponse } from '@usequeek/client-sdk';
6
6
  import { useStorefront, useThemeStrings } from '../provider';
7
7
  import { getQueekClient, setAuthTokens, QUEEK_AUTH_URL } from '../sdk/queek-client';
8
8
  import { useAuthModalStore } from '../stores/auth-modal-store';
@@ -246,14 +246,9 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
246
246
  const returnTo = typeof window !== 'undefined' ? window.location.href : '';
247
247
  const slug = vendor.slug ?? '';
248
248
  const url = `${QUEEK_AUTH_URL}/oauth/google/start?channel=storefront&vendor_slug=${encodeURIComponent(slug)}&return_to=${encodeURIComponent(returnTo)}`;
249
- // eslint-disable-next-line no-console
250
- console.info('[google-auth] requesting redirect URL from', url);
251
249
  const res = await fetch(url);
252
250
  if (!res.ok) {
253
- const text = await res.text().catch(() => '');
254
- // eslint-disable-next-line no-console
255
- console.warn('[google-auth] redirect endpoint returned non-2xx', res.status, text);
256
- throw new Error(`Failed to initiate Google auth (HTTP ${res.status}).`);
251
+ throw new Error(`The Queek sign-in service answered HTTP ${res.status} when starting Google sign-in. Retry, or use email sign-in.`);
257
252
  }
258
253
  // Response envelope: { status, message, data: { url } }
259
254
  const body = (await res.json()) as {
@@ -266,13 +261,11 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
266
261
  if (redirectUrl) {
267
262
  window.location.href = redirectUrl;
268
263
  } else {
269
- // eslint-disable-next-line no-console
270
- console.warn('[google-auth] no redirect URL in response body', body);
271
- throw new Error('No redirect URL returned.');
264
+ throw new Error('The Queek sign-in service did not return a Google redirect URL.');
272
265
  }
273
266
  } catch (err) {
274
- // eslint-disable-next-line no-console
275
- console.error('[google-auth] initiate failed', err);
267
+ // The shopper sees the translated message; the cause stays in the console for the theme developer.
268
+ console.error('[theme-kit] could not start Google sign-in:', err);
276
269
  setError(t('auth.error.google'));
277
270
  } finally {
278
271
  setLoading(false);
@@ -342,7 +335,9 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
342
335
  export function useAuthFlow(): AuthFlowValue {
343
336
  const ctx = useContext(AuthFlowContext);
344
337
  if (!ctx) {
345
- throw new Error('useAuthFlow must be used within an AuthFlowProvider');
338
+ throw new Error(
339
+ 'useAuthFlow() was called outside <AuthFlowProvider>. Render the auth step components inside <AuthFlow> (or wrap them in <AuthFlowProvider>).',
340
+ );
346
341
  }
347
342
  return ctx;
348
343
  }
package/hooks/use-auth.ts CHANGED
@@ -3,6 +3,16 @@
3
3
  import { useAuthModalStore } from '../stores/auth-modal-store';
4
4
  import { useUserStore } from '../stores/user-store';
5
5
 
6
+ /**
7
+ * The signed-in customer and the sign-in modal controls.
8
+ *
9
+ * `user` is null and `isAuthenticated` false until the shopper signs in.
10
+ * `openAuthModal('signup')` opens the kit's sign-in flow on the sign-up step.
11
+ *
12
+ * @example
13
+ * const { user, isAuthenticated, openAuthModal, logout } = useAuth();
14
+ * return isAuthenticated ? <button onClick={logout}>Sign out</button> : <button onClick={() => openAuthModal()}>Sign in</button>;
15
+ */
6
16
  export function useAuth() {
7
17
  return {
8
18
  user: useUserStore((state) => state.user),
package/hooks/use-cart.ts CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  import { useCartStore } from '../stores/cart-store';
4
4
 
5
+ /**
6
+ * The shopper's cart and the actions that change it. The cart is kept in the
7
+ * browser (per sales channel) and survives a reload. `addProduct` refuses a
8
+ * product that needs a variant when none is given and tells the shopper to
9
+ * choose an option; otherwise it adds the line and shows an "Added to cart" toast.
10
+ *
11
+ * Use {@link useShopCart} for the lines and totals of a single shop.
12
+ *
13
+ * @example
14
+ * const { items, addProduct, setQuantity } = useCart();
15
+ * <button onClick={() => addProduct(product, 1, matchedVariant)}>Add to cart</button>
16
+ */
5
17
  export function useCart() {
6
18
  return {
7
19
  items: useCartStore((state) => state.items),
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
4
4
  import { useStorefront, useStorefrontLocale } from '../provider';
5
5
  import { createBrowserClient } from '../sdk/client';
6
6
  import { withLocaleQuery } from '../utils/locale';
7
+ import { toError } from '../utils/to-error';
7
8
 
8
9
  export interface Category {
9
10
  id: string;
@@ -37,17 +38,28 @@ function flattenCategories(items: Category[]): Category[] {
37
38
  return out;
38
39
  }
39
40
 
41
+ /**
42
+ * The store's categories (collections), flattened from any depth. Pass `ids`
43
+ * to pick specific ones, kept in the order you gave; pass `limit` to cap the
44
+ * count. On failure the list is empty and `error` is set; the hook never
45
+ * throws.
46
+ *
47
+ * @example
48
+ * const { categories } = useCategories({ limit: 6 });
49
+ */
40
50
  export function useCategories(options?: {
41
51
  ids?: string[];
42
52
  limit?: number;
43
53
  }): {
44
54
  categories: Category[];
45
55
  isLoading: boolean;
56
+ error: Error | null;
46
57
  } {
47
58
  const { previewData, vendor } = useStorefront();
48
59
  const locale = useStorefrontLocale();
49
60
  const [categories, setCategories] = useState<Category[]>([]);
50
61
  const [isLoading, setIsLoading] = useState(true);
62
+ const [error, setError] = useState<Error | null>(null);
51
63
 
52
64
  const idsKey = options?.ids?.join(',') ?? '';
53
65
  const limit = options?.limit;
@@ -59,6 +71,7 @@ export function useCategories(options?: {
59
71
 
60
72
  async function run(): Promise<void> {
61
73
  setIsLoading(true);
74
+ setError(null);
62
75
 
63
76
  try {
64
77
  if (Array.isArray(previewCategories)) {
@@ -95,9 +108,10 @@ export function useCategories(options?: {
95
108
  setCategories(typeof limit === 'number' ? items.slice(0, limit) : items);
96
109
  setIsLoading(false);
97
110
  }
98
- } catch {
111
+ } catch (caught) {
99
112
  if (!cancelled) {
100
113
  setCategories([]);
114
+ setError(toError(caught));
101
115
  setIsLoading(false);
102
116
  }
103
117
  }
@@ -110,5 +124,5 @@ export function useCategories(options?: {
110
124
  };
111
125
  }, [idsKey, limit, previewData?.categories, vendor.slug, locale]);
112
126
 
113
- return { categories, isLoading };
127
+ return { categories, isLoading, error };
114
128
  }
package/hooks/use-href.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  import { useStorefront } from '../provider';
2
2
 
3
3
  /**
4
- * Returns a function that builds storefront-relative hrefs.
5
- * On subdomain: href("/shop") → "/shop"
6
- * On path-based: href("/shop") → "/vendor-slug/shop"
4
+ * Returns a function that turns a store path into a link target. A store served
5
+ * on its own domain keeps `/shop` as `/shop`; a store served under a path
6
+ * prefix gets `/<store-slug>/shop`. Anything that does not start with `/`
7
+ * (a full URL, `#anchor`, `mailto:`) is returned unchanged.
8
+ *
9
+ * @example
10
+ * const href = useHref();
11
+ * <Link href={href('/shop')}>Shop</Link>
7
12
  */
8
- export function useHref() {
13
+ export function useHref(): (path: string) => string {
9
14
  const { basePath } = useStorefront();
10
15
 
11
16
  return (path: string) => {
@@ -6,19 +6,25 @@ import type { ApiResponse } from '../types/product';
6
6
  import type { Post } from '../types/page';
7
7
  import { createBrowserClient } from '../sdk/client';
8
8
  import { withLocaleQuery } from '../utils/locale';
9
+ import { toError } from '../utils/to-error';
9
10
 
10
11
  /**
11
- * Client-side blog-post listing, for blocks embedded on an arbitrary page
12
- * (e.g. the 'blog' home-page block) — mirrors useProducts' shape/pattern.
13
- * The blog LISTING page itself (pages/blog.tsx) gets its posts via SSR props
14
- * instead (`fetchPosts` in the host's page loader); this hook is for anywhere
15
- * else a theme needs a live post list without page-level SSR data.
12
+ * A live list of blog posts, for a block on any page (such as the home page
13
+ * `blog` block). Takes the same shape as {@link useProducts}: `isLoading`,
14
+ * plus `error` when the request fails.
15
+ *
16
+ * The blog listing page itself receives its posts as server-rendered props;
17
+ * use this hook everywhere else a theme needs posts without page-level data.
18
+ *
19
+ * @example
20
+ * const { posts, isLoading } = usePosts({ category: 'news', limit: 3 });
16
21
  */
17
- export function usePosts(options?: { category?: string | null; limit?: number }): { posts: Post[]; isLoading: boolean } {
22
+ export function usePosts(options?: { category?: string | null; limit?: number }): { posts: Post[]; isLoading: boolean; error: Error | null } {
18
23
  const { previewData, vendor } = useStorefront();
19
24
  const locale = useStorefrontLocale();
20
25
  const [posts, setPosts] = useState<Post[]>([]);
21
26
  const [isLoading, setIsLoading] = useState(true);
27
+ const [error, setError] = useState<Error | null>(null);
22
28
 
23
29
  useEffect(() => {
24
30
  let cancelled = false;
@@ -26,6 +32,7 @@ export function usePosts(options?: { category?: string | null; limit?: number })
26
32
 
27
33
  async function run(): Promise<void> {
28
34
  setIsLoading(true);
35
+ setError(null);
29
36
 
30
37
  try {
31
38
  if (Array.isArray(previewPosts)) {
@@ -55,9 +62,10 @@ export function usePosts(options?: { category?: string | null; limit?: number })
55
62
  if (!cancelled) {
56
63
  setPosts(response.data ?? []);
57
64
  }
58
- } catch {
65
+ } catch (caught) {
59
66
  if (!cancelled) {
60
67
  setPosts([]);
68
+ setError(toError(caught));
61
69
  }
62
70
  } finally {
63
71
  if (!cancelled) {
@@ -73,5 +81,5 @@ export function usePosts(options?: { category?: string | null; limit?: number })
73
81
  };
74
82
  }, [options?.category, options?.limit, previewData?.posts, vendor.slug, locale]);
75
83
 
76
- return { posts, isLoading };
84
+ return { posts, isLoading, error };
77
85
  }
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
4
4
  import { getQueekClient } from '../sdk/queek-client';
5
5
  import { withLocalePath } from '../utils/locale';
6
6
  import { normalizeProduct } from '../utils/product-normalizer';
7
+ import { toError } from '../utils/to-error';
7
8
  import type { Product, LegacyProductLike } from '../types/product';
8
9
 
9
10
  interface UseProductDetailResult {
@@ -11,18 +12,26 @@ interface UseProductDetailResult {
11
12
  product: Product | null;
12
13
  /** True only while a fetch the caller should show a loading state for is in flight. */
13
14
  loadingDetail: boolean;
15
+ /** The last detail request's failure, or null. `product` is still the list product then. */
16
+ error: Error | null;
14
17
  }
15
18
 
16
19
  /**
17
- * The product LIST/collection endpoint never returns `addons`, `options`, or
18
- * `variants` — those only exist on the single-product "view" response.
19
- * Any quick-view/quick-add modal opened from a product card only has the
20
- * list-shaped object, so it must re-fetch full detail before it can render
21
- * or select an addon/option — rendering `listProduct.addons` directly always
22
- * shows nothing, regardless of CSS or wiring.
20
+ * Complete a product you already hold from a list. The list and collection
21
+ * endpoints never return `addons`, `options` or `variants`; only the
22
+ * single-product response has them. A quick-view or quick-add modal opened
23
+ * from a product card must therefore fetch the full product before it can
24
+ * show or select an add-on or option.
23
25
  *
24
- * `isOpen` gates the fetch so a modal closed and reopened on a different
25
- * product re-fetches instead of showing stale detail.
26
+ * `isOpen` gates the fetch, so a modal closed and reopened on another product
27
+ * fetches again instead of showing stale detail. `product` is `listProduct`
28
+ * until the full product arrives, and stays `listProduct` if the request fails
29
+ * (see `error`).
30
+ *
31
+ * For a product you only know by slug, use `useProductBySlug`.
32
+ *
33
+ * @example
34
+ * const { product, loadingDetail } = useProductDetail(card, isModalOpen, vendor.slug, useStorefrontLocale());
26
35
  */
27
36
  export function useProductDetail(
28
37
  listProduct: Product | null,
@@ -39,15 +48,18 @@ export function useProductDetail(
39
48
  ): UseProductDetailResult {
40
49
  const [fullProduct, setFullProduct] = useState<Product | null>(null);
41
50
  const [loadingDetail, setLoadingDetail] = useState(false);
51
+ const [error, setError] = useState<Error | null>(null);
42
52
 
43
53
  useEffect(() => {
44
54
  if (!isOpen || !listProduct) {
45
55
  setFullProduct(null);
46
56
  setLoadingDetail(false);
57
+ setError(null);
47
58
  return;
48
59
  }
49
60
 
50
61
  let cancelled = false;
62
+ setError(null);
51
63
  const needsDetail = listProduct.variants_count > 0 || listProduct.addons.length > 0;
52
64
  if (needsDetail) {
53
65
  setLoadingDetail(true);
@@ -55,7 +67,7 @@ export function useProductDetail(
55
67
 
56
68
  const client = getQueekClient(vendorSlug ?? undefined);
57
69
  client
58
- .get<{ data?: LegacyProductLike } | LegacyProductLike>(withLocalePath(`/client/store/products/${listProduct.slug}`, locale))
70
+ .get<{ data?: LegacyProductLike } | LegacyProductLike>(withLocalePath(`/client/store/products/${encodeURIComponent(listProduct.slug)}`, locale))
59
71
  .then((res) => {
60
72
  if (cancelled) return;
61
73
  const raw = (res as { data?: LegacyProductLike })?.data ?? res;
@@ -63,7 +75,9 @@ export function useProductDetail(
63
75
  setFullProduct(normalizeProduct(raw as LegacyProductLike));
64
76
  }
65
77
  })
66
- .catch(() => {})
78
+ .catch((caught: unknown) => {
79
+ if (!cancelled) setError(toError(caught));
80
+ })
67
81
  .finally(() => {
68
82
  if (!cancelled) setLoadingDetail(false);
69
83
  });
@@ -73,5 +87,5 @@ export function useProductDetail(
73
87
  };
74
88
  }, [isOpen, listProduct, vendorSlug, locale]);
75
89
 
76
- return { product: fullProduct ?? listProduct, loadingDetail };
90
+ return { product: fullProduct ?? listProduct, loadingDetail, error };
77
91
  }
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
4
4
  import { useStorefront, useStorefrontLocale } from '../provider';
5
5
  import { createBrowserClient } from '../sdk/client';
6
6
  import { withLocaleQuery } from '../utils/locale';
7
+ import { toError } from '../utils/to-error';
7
8
  import type { ProductQaBlockData, ProductQuestionItem } from '../types/block';
8
9
 
9
10
  interface ProductQuestionsResponse {
@@ -16,14 +17,24 @@ interface ProductQuestionsResponse {
16
17
  next_cursor?: string | null;
17
18
  }
18
19
 
20
+ /**
21
+ * Published questions and answers for one product, for a `product-qa` block.
22
+ * Returns no questions when the block has no `product_slug`. On failure the
23
+ * list is empty and `error` is set; the hook never throws.
24
+ *
25
+ * @example
26
+ * const { questions, isLoading } = useProductQuestions({ product_slug: 'oak-chair', limit: 5 });
27
+ */
19
28
  export function useProductQuestions(options: ProductQaBlockData): {
20
29
  questions: ProductQuestionItem[];
21
30
  isLoading: boolean;
31
+ error: Error | null;
22
32
  } {
23
33
  const { vendor, previewData } = useStorefront();
24
34
  const locale = useStorefrontLocale();
25
35
  const [questions, setQuestions] = useState<ProductQuestionItem[]>(options._preview?.questions ?? []);
26
36
  const [isLoading, setIsLoading] = useState(!options._preview && !previewData);
37
+ const [error, setError] = useState<Error | null>(null);
27
38
 
28
39
  const { product_slug, limit } = options;
29
40
 
@@ -51,17 +62,19 @@ export function useProductQuestions(options: ProductQaBlockData): {
51
62
 
52
63
  async function run(): Promise<void> {
53
64
  setIsLoading(true);
65
+ setError(null);
54
66
  try {
55
67
  const response = await client.get<ProductQuestionsResponse>(
56
- `/products/${product_slug}/questions`,
68
+ `/products/${encodeURIComponent(product_slug)}/questions`,
57
69
  withLocaleQuery(query, locale),
58
70
  );
59
71
  if (!cancelled) {
60
72
  setQuestions(response.data.questions ?? []);
61
73
  }
62
- } catch {
74
+ } catch (caught) {
63
75
  if (!cancelled) {
64
76
  setQuestions([]);
77
+ setError(toError(caught));
65
78
  }
66
79
  } finally {
67
80
  if (!cancelled) setIsLoading(false);
@@ -76,5 +89,5 @@ export function useProductQuestions(options: ProductQaBlockData): {
76
89
  // eslint-disable-next-line react-hooks/exhaustive-deps
77
90
  }, [vendor.slug, previewData, product_slug, limit, locale]);
78
91
 
79
- return { questions, isLoading };
92
+ return { questions, isLoading, error };
80
93
  }
@@ -7,8 +7,10 @@ import type { ApiResponse, LegacyProductLike, ProductListMeta } from '../types/p
7
7
  import { createBrowserClient } from '../sdk/client';
8
8
  import { withLocaleQuery } from '../utils/locale';
9
9
  import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
10
+ import { toError } from '../utils/to-error';
10
11
 
11
- type ProductQuery = {
12
+ /** Filters for {@link useProducts}. */
13
+ export type ProductQuery = {
12
14
  collection?: string | null;
13
15
  ids?: string[];
14
16
  limit?: number;
@@ -42,15 +44,29 @@ function resolvePreviewProducts(products: Product[], options: ProductQuery): Pro
42
44
  return typeof options.limit === 'number' ? next.slice(0, options.limit) : next;
43
45
  }
44
46
 
47
+ /**
48
+ * A list of the store's products, fetched in the browser (or filtered from the
49
+ * seeded catalogue in preview).
50
+ *
51
+ * Pass `undefined` instead of an options object when the caller already holds
52
+ * the products: the hook then does nothing and reports `isLoading: false`.
53
+ * On failure the products already shown are kept and `error` is set; the hook
54
+ * never throws.
55
+ *
56
+ * @example
57
+ * const { products, isLoading, error } = useProducts({ collection: 'chairs', limit: 8, sort: 'popular' });
58
+ * if (error) return <p>We could not load products. Try again.</p>;
59
+ */
45
60
  export function useProducts(options?: ProductQuery & {
46
61
  /** Backs the "video showcase" products variant. Deliberately incompatible
47
62
  * with `collection` (that path calls a different, collection-scoped
48
63
  * endpoint this filter isn't wired into) — has_video is ignored whenever
49
64
  * collection is also set. */
50
65
  hasVideo?: boolean;
51
- }): { products: Product[]; isLoading: boolean } {
66
+ }): { products: Product[]; isLoading: boolean; error: Error | null } {
52
67
  const { previewData, vendor } = useStorefront();
53
68
  const locale = useStorefrontLocale();
69
+ const [error, setError] = useState<Error | null>(null);
54
70
  const hasPreviewProducts = Array.isArray(previewData?.products);
55
71
  const [products, setProducts] = useState<Product[]>(() => (
56
72
  options && hasPreviewProducts ? resolvePreviewProducts(previewData.products!, options) : []
@@ -88,12 +104,13 @@ export function useProducts(options?: ProductQuery & {
88
104
  }
89
105
 
90
106
  setIsLoading(true);
107
+ setError(null);
91
108
 
92
109
  let resolvedProducts: Product[];
93
110
 
94
111
  if (options?.collection) {
95
112
  const response = await client.get<ApiResponse<unknown>>(
96
- `/collections/${options.collection}/products`,
113
+ `/collections/${encodeURIComponent(options.collection)}/products`,
97
114
  withLocaleQuery(undefined, locale),
98
115
  );
99
116
  resolvedProducts = extractProducts(response.data).map((p) => normalizeProduct(p));
@@ -129,6 +146,10 @@ export function useProducts(options?: ProductQuery & {
129
146
  if (!cancelled) {
130
147
  setProducts(next);
131
148
  }
149
+ } catch (caught) {
150
+ // Keep the products already on screen; surface the failure instead of
151
+ // leaving an unhandled rejection.
152
+ if (!cancelled) setError(toError(caught));
132
153
  } finally {
133
154
  if (!cancelled) {
134
155
  setIsLoading(false);
@@ -143,31 +164,30 @@ export function useProducts(options?: ProductQuery & {
143
164
  };
144
165
  }, [hasOptions, options?.collection, options?.ids, options?.limit, options?.sort, options?.hasVideo, previewData?.products, vendor.slug, locale]);
145
166
 
146
- return { products, isLoading };
167
+ return { products, isLoading, error };
147
168
  }
148
169
 
149
170
  /**
150
- * Full product detail (the same `/products/{slug}` request the PDP itself
151
- * uses), including the full ordered detail `media.images` list — the
152
- * list/collection endpoints `useProducts` calls only ever return a capped
153
- * `media.images` list plus `media.{image,thumbnail}`. Single-product spotlight
154
- * blocks (e.g. a `featured` variant) need this instead of substituting
155
- * image/thumbnail as a fake 2-image list.
156
- */
157
- /**
158
- * Fetch one product by SLUG.
171
+ * Fetch one product by slug: the same `/products/{slug}` request the product
172
+ * page uses, including the full ordered `media.images` list. The list
173
+ * endpoints behind {@link useProducts} return only a capped image list, so a
174
+ * single-product spotlight block needs this hook instead.
175
+ *
176
+ * Different from `useProductDetail` (hooks/use-product-detail.ts), which
177
+ * completes a product you already hold from a list. Pass `null` or
178
+ * `undefined` to skip the request. `product` is null while loading, when the
179
+ * product does not exist, and when the request failed (`error` tells the last
180
+ * two apart).
159
181
  *
160
- * Distinct from `useProductDetail` in hooks/use-product-detail.ts, which
161
- * enriches a product you ALREADY hold from a list payload (that one takes the
162
- * list product, an enabled flag and a vendor slug). Both were called
163
- * `useProductDetail` until the public surface was written down and the clash
164
- * surfaced — same name, unrelated signatures, one import path apart.
182
+ * @example
183
+ * const { product, isLoading, error } = useProductBySlug('oak-chair');
165
184
  */
166
- export function useProductBySlug(slug: string | null | undefined): { product: Product | null; isLoading: boolean } {
185
+ export function useProductBySlug(slug: string | null | undefined): { product: Product | null; isLoading: boolean; error: Error | null } {
167
186
  const { previewData, vendor } = useStorefront();
168
187
  const locale = useStorefrontLocale();
169
188
  const [product, setProduct] = useState<Product | null>(null);
170
189
  const [isLoading, setIsLoading] = useState(!!slug);
190
+ const [error, setError] = useState<Error | null>(null);
171
191
 
172
192
  useEffect(() => {
173
193
  if (!slug) {
@@ -190,16 +210,18 @@ export function useProductBySlug(slug: string | null | undefined): { product: Pr
190
210
 
191
211
  async function run(): Promise<void> {
192
212
  setIsLoading(true);
213
+ setError(null);
193
214
  try {
194
- const response = await client.get<ApiResponse<LegacyProductLike>>(`/products/${slug}`, withLocaleQuery(undefined, locale));
215
+ const response = await client.get<ApiResponse<LegacyProductLike>>(`/products/${encodeURIComponent(slug as string)}`, withLocaleQuery(undefined, locale));
195
216
  const products = extractProducts(response.data);
196
217
  const payload = products[0] ?? response.data;
197
218
  if (!cancelled) {
198
219
  setProduct(payload ? normalizeProduct(payload as LegacyProductLike) : null);
199
220
  }
200
- } catch {
221
+ } catch (caught) {
201
222
  if (!cancelled) {
202
223
  setProduct(null);
224
+ setError(toError(caught));
203
225
  }
204
226
  } finally {
205
227
  if (!cancelled) {
@@ -215,5 +237,5 @@ export function useProductBySlug(slug: string | null | undefined): { product: Pr
215
237
  };
216
238
  }, [slug, previewData?.products, vendor.slug, locale]);
217
239
 
218
- return { product, isLoading };
240
+ return { product, isLoading, error };
219
241
  }
@@ -7,21 +7,32 @@ import type { ApiResponse } from '../types/product';
7
7
  import { createBrowserClient } from '../sdk/client';
8
8
  import { withLocaleQuery } from '../utils/locale';
9
9
  import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
10
+ import { toError } from '../utils/to-error';
10
11
 
12
+ /**
13
+ * Products related to the one being viewed (up to `limit`, default 4). The
14
+ * list stays empty when the request fails; `error` tells you why, and a theme
15
+ * can simply hide the section.
16
+ *
17
+ * @example
18
+ * const { products } = useRelatedProducts(product.slug, 4);
19
+ */
11
20
  export function useRelatedProducts(
12
21
  currentSlug: string,
13
22
  limit = 4,
14
- ): { products: Product[]; isLoading: boolean } {
23
+ ): { products: Product[]; isLoading: boolean; error: Error | null } {
15
24
  const { vendor, previewData } = useStorefront();
16
25
  const locale = useStorefrontLocale();
17
26
  const [products, setProducts] = useState<Product[]>([]);
18
27
  const [isLoading, setIsLoading] = useState(true);
28
+ const [error, setError] = useState<Error | null>(null);
19
29
 
20
30
  useEffect(() => {
21
31
  let cancelled = false;
22
32
 
23
33
  async function run(): Promise<void> {
24
34
  setIsLoading(true);
35
+ setError(null);
25
36
  try {
26
37
  if (previewData?.products) {
27
38
  const others = (previewData.products as Product[])
@@ -33,7 +44,7 @@ export function useRelatedProducts(
33
44
 
34
45
  const client = createBrowserClient(vendor.slug ?? undefined);
35
46
  const response = await client.get<ApiResponse<unknown>>(
36
- `/products/${currentSlug}/related`,
47
+ `/products/${encodeURIComponent(currentSlug)}/related`,
37
48
  withLocaleQuery({ limit }, locale),
38
49
  );
39
50
 
@@ -43,8 +54,9 @@ export function useRelatedProducts(
43
54
  .slice(0, limit);
44
55
 
45
56
  if (!cancelled) setProducts(normalized);
46
- } catch {
47
- // Non-critical — silently fail
57
+ } catch (caught) {
58
+ // Non-critical: the section can hide itself. The failure is on `error`.
59
+ if (!cancelled) setError(toError(caught));
48
60
  } finally {
49
61
  if (!cancelled) setIsLoading(false);
50
62
  }
@@ -54,5 +66,5 @@ export function useRelatedProducts(
54
66
  return () => { cancelled = true; };
55
67
  }, [currentSlug, limit, vendor.slug, previewData?.products, locale]);
56
68
 
57
- return { products, isLoading };
69
+ return { products, isLoading, error };
58
70
  }
@@ -14,6 +14,15 @@ interface CoordinatesResponse {
14
14
  state_id?: string | null;
15
15
  }
16
16
 
17
+ /**
18
+ * Turns a chosen address suggestion into coordinates and saves it as the
19
+ * shopper's delivery location. `resolve` returns true when a location was
20
+ * saved and false when the API gave no usable position or the request failed.
21
+ *
22
+ * @example
23
+ * const { resolve, resolving } = useResolveCoordinates();
24
+ * const ok = await resolve(suggestion.place_id, suggestion.description);
25
+ */
17
26
  export function useResolveCoordinates(): {
18
27
  resolve: (placeId: string, description: string) => Promise<boolean>;
19
28
  resolving: boolean;