@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.
- package/CHANGELOG.md +45 -0
- package/LICENSE +1 -1
- package/README.md +164 -21
- 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-switcher.tsx +1 -0
- 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 +8 -13
- 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 +29 -8
- package/page-renderer.tsx +11 -0
- package/provider.tsx +29 -3
- package/sdk/client.ts +183 -36
- package/sdk/error-handler.ts +18 -10
- package/sdk/queek-client.ts +12 -2
- 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/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 -90
- package/tests/image-loading.test.tsx +0 -31
- package/tests/language-menu.test.tsx +0 -598
- 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/menu-placement.test.ts +0 -63
- 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-auth-flow.tsx
CHANGED
|
@@ -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 '@
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
275
|
-
console.error('[
|
|
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(
|
|
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),
|
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;
|