@usequeek/theme-kit 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/LICENSE +1 -1
- package/README.md +275 -26
- package/api/collections.ts +14 -1
- package/api/orders.ts +15 -1
- package/apps/follow-card.tsx +5 -0
- package/apps/subscribe-form.tsx +2 -2
- package/components/auth/auth-flow.tsx +12 -0
- package/components/cart/cart-panel-controller.tsx +13 -0
- package/components/checkout/default-checkout-shell.tsx +5 -0
- package/components/checkout/policy-links.tsx +4 -0
- package/components/image.tsx +10 -0
- package/components/language-menu.css +349 -0
- package/components/language-menu.tsx +371 -0
- package/components/language-switcher.tsx +40 -8
- package/components/powered-by-queek.tsx +4 -3
- package/components/product-media-frame.tsx +5 -0
- package/components/product-metafields.tsx +9 -0
- package/components/variant-picker.css +4 -7
- package/edit-mode.tsx +4 -3
- package/hooks/use-address-autocomplete.ts +9 -0
- package/hooks/use-auth-flow.tsx +7 -12
- package/hooks/use-auth.ts +10 -0
- package/hooks/use-cart.ts +12 -0
- package/hooks/use-categories.ts +16 -2
- package/hooks/use-href.ts +9 -4
- package/hooks/use-posts.ts +16 -8
- package/hooks/use-product-detail.ts +25 -11
- package/hooks/use-product-questions.ts +16 -3
- package/hooks/use-products.ts +44 -22
- package/hooks/use-related-products.ts +17 -5
- package/hooks/use-resolve-coordinates.ts +9 -0
- package/hooks/use-reviews.ts +16 -2
- package/hooks/use-search.ts +10 -0
- package/hooks/use-shop-cart.ts +8 -0
- package/hooks/use-shop.ts +19 -6
- package/hooks/use-variant-selection.ts +23 -19
- package/index.ts +10 -11
- package/locales/en.default.json +4 -0
- package/navigation.tsx +2 -0
- package/package.json +25 -4
- package/page-renderer.tsx +11 -0
- package/provider.tsx +29 -3
- package/sdk/client.ts +183 -36
- package/sdk/error-handler.ts +17 -9
- package/sdk/queek-client.ts +11 -1
- package/sdk/server-store-client.ts +2 -2
- package/shared-blocks/embed.tsx +7 -3
- package/shared-blocks/manifest.ts +1 -1
- package/shared-blocks/product-qa.tsx +1 -1
- package/stores/address-modal-store.ts +1 -0
- package/stores/auth-modal-store.ts +1 -0
- package/stores/cart-panel-store.ts +1 -0
- package/stores/cart-store.ts +12 -7
- package/stores/checkout-store.ts +4 -4
- package/stores/location-store.ts +5 -0
- package/stores/orders-sheet-store.ts +1 -0
- package/stores/product-modal-store.ts +1 -0
- package/stores/search-modal-store.ts +1 -0
- package/stores/user-store.ts +1 -1
- package/strings/pseudo-locale.ts +1 -1
- package/theme-context.tsx +11 -1
- package/theme-mount.tsx +14 -0
- package/utils/format.ts +10 -1
- package/utils/menu-link.ts +8 -0
- package/utils/menu-placement.ts +91 -0
- package/utils/to-error.ts +4 -0
- package/utils/vendor-host.ts +1 -2
- package/scripts/verify-package.ts +0 -102
- package/tests/apps.test.tsx +0 -33
- package/tests/auth-email-errors.test.tsx +0 -55
- package/tests/currency-contract.test.ts +0 -85
- package/tests/default-metaobject-page.test.tsx +0 -56
- package/tests/display-format.test.ts +0 -403
- package/tests/fixtures/external-theme.tsx +0 -85
- package/tests/image-loading.test.tsx +0 -31
- package/tests/language-switcher.test.tsx +0 -350
- package/tests/list-pagination.test.tsx +0 -71
- package/tests/locale-switch.test.ts +0 -197
- package/tests/locale-transport.test.tsx +0 -321
- package/tests/manifest-parity.test.ts +0 -54
- package/tests/markdown-render.test.ts +0 -46
- package/tests/metaobjects-block.test.tsx +0 -70
- package/tests/navigation.test.tsx +0 -30
- package/tests/orders-walk.test.ts +0 -48
- package/tests/page-renderer-signature.test.tsx +0 -42
- package/tests/powered-by-queek.test.tsx +0 -65
- package/tests/powered-by-url.test.ts +0 -49
- package/tests/product-images.test.ts +0 -242
- package/tests/product-metafields.test.tsx +0 -154
- package/tests/public-surface.test.ts +0 -35
- package/tests/shop-query.test.ts +0 -64
- package/tests/shopper-chrome-strings.test.tsx +0 -271
- package/tests/storefront-client-scope.test.ts +0 -43
- package/tests/theme-hook-placement.test.tsx +0 -179
- package/tests/theme-strings-client.test.tsx +0 -310
- package/tests/theme-strings-pseudo.test.tsx +0 -171
- package/tests/theme-strings-typed.test.tsx +0 -97
- package/tests/theme-strings.test.tsx +0 -401
- package/tests/theme-typed-keys.fixture.ts +0 -76
- package/tests/use-shop-prefetch.test.tsx +0 -87
package/hooks/use-categories.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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) => {
|
package/hooks/use-posts.ts
CHANGED
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* or select an
|
|
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
|
|
25
|
-
*
|
|
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
|
}
|
package/hooks/use-products.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
151
|
-
* uses
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
*
|
|
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
|
-
*
|
|
161
|
-
*
|
|
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
|
|
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;
|
package/hooks/use-reviews.ts
CHANGED
|
@@ -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
|
}
|
package/hooks/use-search.ts
CHANGED
|
@@ -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[],
|
package/hooks/use-shop-cart.ts
CHANGED
|
@@ -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;
|