@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.
Files changed (101) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/LICENSE +1 -1
  3. package/README.md +275 -26
  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-menu.css +349 -0
  14. package/components/language-menu.tsx +371 -0
  15. package/components/language-switcher.tsx +40 -8
  16. package/components/powered-by-queek.tsx +4 -3
  17. package/components/product-media-frame.tsx +5 -0
  18. package/components/product-metafields.tsx +9 -0
  19. package/components/variant-picker.css +4 -7
  20. package/edit-mode.tsx +4 -3
  21. package/hooks/use-address-autocomplete.ts +9 -0
  22. package/hooks/use-auth-flow.tsx +7 -12
  23. package/hooks/use-auth.ts +10 -0
  24. package/hooks/use-cart.ts +12 -0
  25. package/hooks/use-categories.ts +16 -2
  26. package/hooks/use-href.ts +9 -4
  27. package/hooks/use-posts.ts +16 -8
  28. package/hooks/use-product-detail.ts +25 -11
  29. package/hooks/use-product-questions.ts +16 -3
  30. package/hooks/use-products.ts +44 -22
  31. package/hooks/use-related-products.ts +17 -5
  32. package/hooks/use-resolve-coordinates.ts +9 -0
  33. package/hooks/use-reviews.ts +16 -2
  34. package/hooks/use-search.ts +10 -0
  35. package/hooks/use-shop-cart.ts +8 -0
  36. package/hooks/use-shop.ts +19 -6
  37. package/hooks/use-variant-selection.ts +23 -19
  38. package/index.ts +10 -11
  39. package/locales/en.default.json +4 -0
  40. package/navigation.tsx +2 -0
  41. package/package.json +25 -4
  42. package/page-renderer.tsx +11 -0
  43. package/provider.tsx +29 -3
  44. package/sdk/client.ts +183 -36
  45. package/sdk/error-handler.ts +17 -9
  46. package/sdk/queek-client.ts +11 -1
  47. package/sdk/server-store-client.ts +2 -2
  48. package/shared-blocks/embed.tsx +7 -3
  49. package/shared-blocks/manifest.ts +1 -1
  50. package/shared-blocks/product-qa.tsx +1 -1
  51. package/stores/address-modal-store.ts +1 -0
  52. package/stores/auth-modal-store.ts +1 -0
  53. package/stores/cart-panel-store.ts +1 -0
  54. package/stores/cart-store.ts +12 -7
  55. package/stores/checkout-store.ts +4 -4
  56. package/stores/location-store.ts +5 -0
  57. package/stores/orders-sheet-store.ts +1 -0
  58. package/stores/product-modal-store.ts +1 -0
  59. package/stores/search-modal-store.ts +1 -0
  60. package/stores/user-store.ts +1 -1
  61. package/strings/pseudo-locale.ts +1 -1
  62. package/theme-context.tsx +11 -1
  63. package/theme-mount.tsx +14 -0
  64. package/utils/format.ts +10 -1
  65. package/utils/menu-link.ts +8 -0
  66. package/utils/menu-placement.ts +91 -0
  67. package/utils/to-error.ts +4 -0
  68. package/utils/vendor-host.ts +1 -2
  69. package/scripts/verify-package.ts +0 -102
  70. package/tests/apps.test.tsx +0 -33
  71. package/tests/auth-email-errors.test.tsx +0 -55
  72. package/tests/currency-contract.test.ts +0 -85
  73. package/tests/default-metaobject-page.test.tsx +0 -56
  74. package/tests/display-format.test.ts +0 -403
  75. package/tests/fixtures/external-theme.tsx +0 -85
  76. package/tests/image-loading.test.tsx +0 -31
  77. package/tests/language-switcher.test.tsx +0 -350
  78. package/tests/list-pagination.test.tsx +0 -71
  79. package/tests/locale-switch.test.ts +0 -197
  80. package/tests/locale-transport.test.tsx +0 -321
  81. package/tests/manifest-parity.test.ts +0 -54
  82. package/tests/markdown-render.test.ts +0 -46
  83. package/tests/metaobjects-block.test.tsx +0 -70
  84. package/tests/navigation.test.tsx +0 -30
  85. package/tests/orders-walk.test.ts +0 -48
  86. package/tests/page-renderer-signature.test.tsx +0 -42
  87. package/tests/powered-by-queek.test.tsx +0 -65
  88. package/tests/powered-by-url.test.ts +0 -49
  89. package/tests/product-images.test.ts +0 -242
  90. package/tests/product-metafields.test.tsx +0 -154
  91. package/tests/public-surface.test.ts +0 -35
  92. package/tests/shop-query.test.ts +0 -64
  93. package/tests/shopper-chrome-strings.test.tsx +0 -271
  94. package/tests/storefront-client-scope.test.ts +0 -43
  95. package/tests/theme-hook-placement.test.tsx +0 -179
  96. package/tests/theme-strings-client.test.tsx +0 -310
  97. package/tests/theme-strings-pseudo.test.tsx +0 -171
  98. package/tests/theme-strings-typed.test.tsx +0 -97
  99. package/tests/theme-strings.test.tsx +0 -401
  100. package/tests/theme-typed-keys.fixture.ts +0 -76
  101. package/tests/use-shop-prefetch.test.tsx +0 -87
@@ -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;
@@ -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 { ReviewItem, ReviewsBlockData, ReviewsSummary } from '../types/block';
8
9
 
9
10
  interface ReviewsResponse {
@@ -62,10 +63,20 @@ function summarize(reviews: ReviewItem[]): ReviewsSummary {
62
63
  };
63
64
  }
64
65
 
66
+ /**
67
+ * Customer reviews for a `reviews` block: a list plus the rating summary
68
+ * (average and 1 to 5 breakdown). Scope, filters and sorting come from the
69
+ * block's own data. On failure the list is empty and `error` is set; the hook
70
+ * never throws.
71
+ *
72
+ * @example
73
+ * const { reviews, summary, isLoading } = useReviews({ scope: 'product', product_slug: 'oak-chair', limit: 6 });
74
+ */
65
75
  export function useReviews(options: ReviewsBlockData): {
66
76
  reviews: ReviewItem[];
67
77
  summary: ReviewsSummary;
68
78
  isLoading: boolean;
79
+ error: Error | null;
69
80
  } {
70
81
  const { vendor, previewData } = useStorefront();
71
82
  const locale = useStorefrontLocale();
@@ -73,6 +84,7 @@ export function useReviews(options: ReviewsBlockData): {
73
84
  const [reviews, setReviews] = useState<ReviewItem[]>(seeded);
74
85
  const [summary, setSummary] = useState<ReviewsSummary>(options._preview?.summary ?? summarize(seeded));
75
86
  const [isLoading, setIsLoading] = useState(!options._preview && !previewData);
87
+ const [error, setError] = useState<Error | null>(null);
76
88
 
77
89
  const {
78
90
  scope,
@@ -114,6 +126,7 @@ export function useReviews(options: ReviewsBlockData): {
114
126
 
115
127
  async function run(): Promise<void> {
116
128
  setIsLoading(true);
129
+ setError(null);
117
130
  try {
118
131
  let path = '/reviews';
119
132
  if (ids && ids.length > 0) {
@@ -136,10 +149,11 @@ export function useReviews(options: ReviewsBlockData): {
136
149
  setReviews(response.data.reviews ?? []);
137
150
  setSummary(response.data.summary ?? EMPTY_SUMMARY);
138
151
  }
139
- } catch {
152
+ } catch (caught) {
140
153
  if (!cancelled) {
141
154
  setReviews([]);
142
155
  setSummary(EMPTY_SUMMARY);
156
+ setError(toError(caught));
143
157
  }
144
158
  } finally {
145
159
  if (!cancelled) setIsLoading(false);
@@ -166,5 +180,5 @@ export function useReviews(options: ReviewsBlockData): {
166
180
  locale,
167
181
  ]);
168
182
 
169
- return { reviews, summary, isLoading };
183
+ return { reviews, summary, isLoading, error };
170
184
  }
@@ -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;