@usequeek/theme-kit 0.1.0

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 (120) hide show
  1. package/README.md +37 -0
  2. package/api/collections.ts +93 -0
  3. package/api/orders.ts +23 -0
  4. package/apps/analytics-events.ts +32 -0
  5. package/apps/apps.css +613 -0
  6. package/apps/follow-card.tsx +192 -0
  7. package/apps/gtag.ts +19 -0
  8. package/apps/subscribe-form.tsx +124 -0
  9. package/client.ts +52 -0
  10. package/components/auth/auth-email-login-step.tsx +46 -0
  11. package/components/auth/auth-email-otp-step.tsx +54 -0
  12. package/components/auth/auth-email-otp-verify-step.tsx +82 -0
  13. package/components/auth/auth-email-register-step.tsx +59 -0
  14. package/components/auth/auth-flow.tsx +26 -0
  15. package/components/auth/auth-signup-step.tsx +53 -0
  16. package/components/blog/category-filter.tsx +92 -0
  17. package/components/blog/index.ts +12 -0
  18. package/components/blog/pagination.tsx +73 -0
  19. package/components/blog/post-categories.tsx +42 -0
  20. package/components/blog/post-meta.tsx +49 -0
  21. package/components/blog/related-posts.tsx +54 -0
  22. package/components/blog/share-buttons.tsx +89 -0
  23. package/components/cart/cart-panel-controller.tsx +67 -0
  24. package/components/cart/default-cart-shell.tsx +207 -0
  25. package/components/checkout/checkout-contact.tsx +84 -0
  26. package/components/checkout/default-checkout-shell.tsx +177 -0
  27. package/components/checkout/policy-links.tsx +98 -0
  28. package/components/image.tsx +149 -0
  29. package/components/markdown.tsx +54 -0
  30. package/components/product-media-frame.tsx +56 -0
  31. package/components/variant-picker.css +84 -0
  32. package/components/variant-picker.tsx +107 -0
  33. package/design-token-preview.tsx +81 -0
  34. package/edit-mode-active.tsx +324 -0
  35. package/edit-mode.tsx +80 -0
  36. package/hooks/use-address-autocomplete.ts +62 -0
  37. package/hooks/use-auth-flow.tsx +346 -0
  38. package/hooks/use-auth.ts +17 -0
  39. package/hooks/use-cart.ts +14 -0
  40. package/hooks/use-categories.ts +112 -0
  41. package/hooks/use-collection-params.ts +67 -0
  42. package/hooks/use-href.ts +15 -0
  43. package/hooks/use-posts.ts +75 -0
  44. package/hooks/use-product-detail.ts +69 -0
  45. package/hooks/use-product-questions.ts +77 -0
  46. package/hooks/use-product-sections.ts +165 -0
  47. package/hooks/use-products.ts +216 -0
  48. package/hooks/use-related-products.ts +56 -0
  49. package/hooks/use-resolve-coordinates.ts +48 -0
  50. package/hooks/use-reviews.ts +167 -0
  51. package/hooks/use-search.ts +55 -0
  52. package/hooks/use-shop-cart.ts +31 -0
  53. package/hooks/use-shop-params.ts +68 -0
  54. package/hooks/use-shop.ts +186 -0
  55. package/hooks/use-variant-selection.ts +98 -0
  56. package/index.ts +49 -0
  57. package/package.json +41 -0
  58. package/page-renderer.tsx +365 -0
  59. package/provider.tsx +91 -0
  60. package/sdk/client.ts +221 -0
  61. package/sdk/error-handler.ts +37 -0
  62. package/sdk/platform.ts +78 -0
  63. package/sdk/queek-client.ts +104 -0
  64. package/sdk/reauth-guard.ts +27 -0
  65. package/sdk/server-store-client.ts +165 -0
  66. package/shared-blocks/button.tsx +41 -0
  67. package/shared-blocks/callout.tsx +35 -0
  68. package/shared-blocks/content-default.tsx +23 -0
  69. package/shared-blocks/core-blocks.css +1792 -0
  70. package/shared-blocks/divider.tsx +17 -0
  71. package/shared-blocks/embed.tsx +40 -0
  72. package/shared-blocks/faq.tsx +44 -0
  73. package/shared-blocks/image.tsx +29 -0
  74. package/shared-blocks/index.ts +24 -0
  75. package/shared-blocks/manifest.ts +76 -0
  76. package/shared-blocks/product-qa.tsx +203 -0
  77. package/shared-blocks/quote.tsx +20 -0
  78. package/shared-blocks/reviews.tsx +436 -0
  79. package/shared-blocks/table.tsx +45 -0
  80. package/shared-blocks/video.tsx +60 -0
  81. package/stores/address-modal-store.ts +15 -0
  82. package/stores/auth-modal-store.ts +17 -0
  83. package/stores/cart-panel-store.ts +17 -0
  84. package/stores/cart-store.ts +172 -0
  85. package/stores/checkout-store.ts +167 -0
  86. package/stores/edit-preview-store.ts +61 -0
  87. package/stores/location-store.ts +41 -0
  88. package/stores/orders-sheet-store.ts +21 -0
  89. package/stores/policy-modal-store.ts +15 -0
  90. package/stores/product-modal-store.ts +18 -0
  91. package/stores/search-modal-store.ts +17 -0
  92. package/stores/toast-store.ts +46 -0
  93. package/stores/user-store.ts +88 -0
  94. package/theme-context.tsx +28 -0
  95. package/theme-variant-resolver.ts +44 -0
  96. package/types/block.ts +292 -0
  97. package/types/cart.ts +21 -0
  98. package/types/media.ts +55 -0
  99. package/types/menu.ts +27 -0
  100. package/types/order.ts +119 -0
  101. package/types/page.ts +86 -0
  102. package/types/product.ts +279 -0
  103. package/types/theme.ts +380 -0
  104. package/types/vendor.ts +243 -0
  105. package/utils/addon-selection.ts +31 -0
  106. package/utils/benefit-icon.tsx +145 -0
  107. package/utils/brand-palette.ts +64 -0
  108. package/utils/brand.ts +518 -0
  109. package/utils/contact-location.ts +44 -0
  110. package/utils/format.ts +64 -0
  111. package/utils/hero-content.ts +35 -0
  112. package/utils/image-srcset.ts +157 -0
  113. package/utils/markdown.tsx +237 -0
  114. package/utils/match-variant.ts +31 -0
  115. package/utils/menu-link.ts +41 -0
  116. package/utils/placeholder-resolver.ts +184 -0
  117. package/utils/product-media.ts +77 -0
  118. package/utils/product-normalizer.ts +323 -0
  119. package/utils/vendor-host.ts +173 -0
  120. package/utils/video-embed.ts +38 -0
@@ -0,0 +1,69 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { getQueekClient } from '../sdk/queek-client';
5
+ import { normalizeProduct } from '../utils/product-normalizer';
6
+ import type { Product, LegacyProductLike } from '../types/product';
7
+
8
+ interface UseProductDetailResult {
9
+ /** `listProduct` until the full fetch resolves, then the fetched detail. Null if `listProduct` is null. */
10
+ product: Product | null;
11
+ /** True only while a fetch the caller should show a loading state for is in flight. */
12
+ loadingDetail: boolean;
13
+ }
14
+
15
+ /**
16
+ * The product LIST/collection endpoint never returns `addons`, `options`, or
17
+ * `variants` — those only exist on the single-product "view" response
18
+ * (`ProductResource::buildBasePayload` vs `buildViewPayload`, queek_backend).
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.
23
+ *
24
+ * `isOpen` gates the fetch so a modal closed and reopened on a different
25
+ * product re-fetches instead of showing stale detail.
26
+ */
27
+ export function useProductDetail(
28
+ listProduct: Product | null,
29
+ isOpen: boolean,
30
+ vendorSlug?: string | null,
31
+ ): UseProductDetailResult {
32
+ const [fullProduct, setFullProduct] = useState<Product | null>(null);
33
+ const [loadingDetail, setLoadingDetail] = useState(false);
34
+
35
+ useEffect(() => {
36
+ if (!isOpen || !listProduct) {
37
+ setFullProduct(null);
38
+ setLoadingDetail(false);
39
+ return;
40
+ }
41
+
42
+ let cancelled = false;
43
+ const needsDetail = listProduct.variants_count > 0 || listProduct.addons.length > 0;
44
+ if (needsDetail) {
45
+ setLoadingDetail(true);
46
+ }
47
+
48
+ const client = getQueekClient(vendorSlug ?? undefined);
49
+ client
50
+ .get<{ data?: LegacyProductLike } | LegacyProductLike>(`/client/store/products/${listProduct.slug}`)
51
+ .then((res) => {
52
+ if (cancelled) return;
53
+ const raw = (res as { data?: LegacyProductLike })?.data ?? res;
54
+ if (raw && typeof raw === 'object' && 'id' in raw) {
55
+ setFullProduct(normalizeProduct(raw as LegacyProductLike));
56
+ }
57
+ })
58
+ .catch(() => {})
59
+ .finally(() => {
60
+ if (!cancelled) setLoadingDetail(false);
61
+ });
62
+
63
+ return () => {
64
+ cancelled = true;
65
+ };
66
+ }, [isOpen, listProduct, vendorSlug]);
67
+
68
+ return { product: fullProduct ?? listProduct, loadingDetail };
69
+ }
@@ -0,0 +1,77 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useStorefront } from '../provider';
5
+ import { createBrowserClient } from '../sdk/client';
6
+ import type { ProductQaBlockData, ProductQuestionItem } from '../types/block';
7
+
8
+ interface ProductQuestionsResponse {
9
+ status: string;
10
+ data: {
11
+ product: { id: string; slug: string; title: string };
12
+ questions: ProductQuestionItem[];
13
+ pagination: { current_page: number; per_page: number; has_more: boolean };
14
+ };
15
+ }
16
+
17
+ export function useProductQuestions(options: ProductQaBlockData): {
18
+ questions: ProductQuestionItem[];
19
+ isLoading: boolean;
20
+ } {
21
+ const { vendor, previewData } = useStorefront();
22
+ const [questions, setQuestions] = useState<ProductQuestionItem[]>(options._preview?.questions ?? []);
23
+ const [isLoading, setIsLoading] = useState(!options._preview && !previewData);
24
+
25
+ const { product_slug, limit } = options;
26
+
27
+ useEffect(() => {
28
+ // Preview mode: use inline preview data if provided, otherwise empty.
29
+ if (previewData) {
30
+ if (options._preview) {
31
+ setQuestions(options._preview.questions ?? []);
32
+ }
33
+ setIsLoading(false);
34
+ return;
35
+ }
36
+
37
+ if (!product_slug) {
38
+ setQuestions([]);
39
+ setIsLoading(false);
40
+ return;
41
+ }
42
+
43
+ let cancelled = false;
44
+ const client = createBrowserClient(vendor.slug ?? undefined);
45
+
46
+ const query: Record<string, string | number> = {};
47
+ if (limit) query.limit = limit;
48
+
49
+ async function run(): Promise<void> {
50
+ setIsLoading(true);
51
+ try {
52
+ const response = await client.get<ProductQuestionsResponse>(
53
+ `/products/${product_slug}/questions`,
54
+ query,
55
+ );
56
+ if (!cancelled) {
57
+ setQuestions(response.data.questions ?? []);
58
+ }
59
+ } catch {
60
+ if (!cancelled) {
61
+ setQuestions([]);
62
+ }
63
+ } finally {
64
+ if (!cancelled) setIsLoading(false);
65
+ }
66
+ }
67
+
68
+ void run();
69
+
70
+ return () => {
71
+ cancelled = true;
72
+ };
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ }, [vendor.slug, previewData, product_slug, limit]);
75
+
76
+ return { questions, isLoading };
77
+ }
@@ -0,0 +1,165 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useStorefront } from '../provider';
5
+ import type { Product, ApiResponse, LegacyProductLike } from '../types/product';
6
+ import { createBrowserClient } from '../sdk/client';
7
+ import { getActivePlatform } from '../sdk/platform';
8
+ import { normalizeProduct } from '../utils/product-normalizer';
9
+
10
+ export interface ProductSection {
11
+ category_id: string;
12
+ title: string;
13
+ products: Product[];
14
+ view_more?: boolean;
15
+ }
16
+
17
+ /** A collection referenced by a smart "rollup" tab's own collection_id rule
18
+ * (see MenuGroupCollections) — its own product ids, ready to list with no
19
+ * extra request. */
20
+ export interface MenuGroupItem {
21
+ id: string;
22
+ name: string;
23
+ product_ids: string[];
24
+ }
25
+
26
+ /** A QR menu tab (Food/Drinks-style) — the vendor's own collection, resolved
27
+ * server-side into its product ids directly. Two shapes: 'products' (a
28
+ * manual collection, or a smart collection whose rule doesn't reference
29
+ * other collections) lists its own products immediately; 'collections' (a
30
+ * smart collection whose rule DOES reference other collections — a browsing
31
+ * umbrella, not a product filter) offers those referenced collections as
32
+ * tiles first. Every id here is already present in this same response's
33
+ * flat product pool, so resolving a tap is a pure client-side lookup — see
34
+ * VendorProductController::resolveQrTabGroups (queek_backend). */
35
+ export type MenuGroup =
36
+ | { id: string; name: string; type: 'products'; product_ids: string[] }
37
+ | { id: string; name: string; type: 'collections'; items: MenuGroupItem[] };
38
+
39
+ interface RawSection {
40
+ title: string;
41
+ category_id?: string;
42
+ data: LegacyProductLike[];
43
+ view_more?: boolean;
44
+ }
45
+
46
+ interface SectionsResult {
47
+ sections: ProductSection[];
48
+ groups: MenuGroup[];
49
+ }
50
+
51
+ const EMPTY_RESULT: SectionsResult = { sections: [], groups: [] };
52
+
53
+ const sectionsCache = new Map<string, SectionsResult>();
54
+ const inflightSections = new Map<string, Promise<SectionsResult>>();
55
+
56
+ export function useProductSections(): {
57
+ sections: ProductSection[];
58
+ groups: MenuGroup[];
59
+ isLoading: boolean;
60
+ } {
61
+ const { previewData, vendor } = useStorefront();
62
+ // Cache per vendor AND platform — the grouped catalogue can differ between
63
+ // storefront and instore_qr (the /qr page).
64
+ const cacheKey = `${vendor.slug ?? ''}::${getActivePlatform()}`;
65
+ const [result, setResult] = useState<SectionsResult>(() => sectionsCache.get(cacheKey) ?? EMPTY_RESULT);
66
+ const [isLoading, setIsLoading] = useState(!sectionsCache.has(cacheKey));
67
+
68
+ useEffect(() => {
69
+ let cancelled = false;
70
+ const previewProducts = previewData?.products;
71
+
72
+ if (Array.isArray(previewProducts)) {
73
+ const grouped = new Map<string, ProductSection>();
74
+
75
+ previewProducts.forEach((product) => {
76
+ const primaryCategory = product.categories[0];
77
+ const key = primaryCategory?.slug ?? '_all';
78
+ const title = primaryCategory?.name ?? 'All Products';
79
+
80
+ if (!grouped.has(key)) {
81
+ grouped.set(key, {
82
+ category_id: primaryCategory?.id ?? key,
83
+ title,
84
+ products: [],
85
+ });
86
+ }
87
+
88
+ grouped.get(key)?.products.push(product);
89
+ });
90
+
91
+ if (!cancelled) {
92
+ setResult({ sections: Array.from(grouped.values()), groups: [] });
93
+ setIsLoading(false);
94
+ }
95
+ return;
96
+ }
97
+
98
+ if (sectionsCache.has(cacheKey)) {
99
+ setResult(sectionsCache.get(cacheKey) ?? EMPTY_RESULT);
100
+ setIsLoading(false);
101
+ return;
102
+ }
103
+
104
+ setIsLoading(true);
105
+
106
+ // Deduplicate: reuse in-flight promise if another mount already started fetching.
107
+ let promise = inflightSections.get(cacheKey);
108
+ if (!promise) {
109
+ const client = createBrowserClient(vendor.slug ?? undefined);
110
+ promise = client
111
+ .get<ApiResponse<unknown>>('/products')
112
+ .then((response) => {
113
+ const data = response.data;
114
+ // instore_qr only — backend attaches this under meta.groups, never
115
+ // inside `data` (see resolveQrTabGroups): sectioned/menu-mode `data`
116
+ // is shared with the mobile app's response shape, so groups had to
117
+ // land somewhere additive instead.
118
+ const rawGroups = (response.meta as { groups?: MenuGroup[] } | undefined)?.groups;
119
+ const groups = Array.isArray(rawGroups) ? rawGroups : [];
120
+
121
+ if (!Array.isArray(data)) return { sections: [], groups };
122
+
123
+ const first = data[0] as Record<string, unknown> | undefined;
124
+ const isGrouped = first && Array.isArray(first.data) && typeof first.title === 'string';
125
+
126
+ if (isGrouped) {
127
+ const sections = (data as RawSection[]).map((group, idx) => ({
128
+ category_id: group.category_id ?? `section-${idx}`,
129
+ title: group.title,
130
+ products: group.data.map((p) => normalizeProduct(p)),
131
+ view_more: group.view_more ?? false,
132
+ }));
133
+ return { sections, groups };
134
+ }
135
+
136
+ const products = (data as LegacyProductLike[]).map((p) => normalizeProduct(p));
137
+ return {
138
+ sections: [{
139
+ category_id: '_all',
140
+ title: 'All Products',
141
+ products,
142
+ }],
143
+ groups: [],
144
+ };
145
+ })
146
+ .catch(() => EMPTY_RESULT)
147
+ .finally(() => {
148
+ inflightSections.delete(cacheKey);
149
+ });
150
+ inflightSections.set(cacheKey, promise);
151
+ }
152
+
153
+ promise.then((result) => {
154
+ sectionsCache.set(cacheKey, result);
155
+ if (!cancelled) {
156
+ setResult(result);
157
+ setIsLoading(false);
158
+ }
159
+ });
160
+
161
+ return () => { cancelled = true; };
162
+ }, [previewData?.products, vendor.slug, cacheKey]);
163
+
164
+ return { sections: result.sections, groups: result.groups, isLoading };
165
+ }
@@ -0,0 +1,216 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useStorefront } from '../provider';
5
+ import type { Product } from '../types/product';
6
+ import type { ApiResponse, LegacyProductLike, ProductListMeta } from '../types/product';
7
+ import { createBrowserClient } from '../sdk/client';
8
+ import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
9
+
10
+ type ProductQuery = {
11
+ collection?: string | null;
12
+ ids?: string[];
13
+ limit?: number;
14
+ sort?: 'latest' | 'popular' | 'price_low' | 'price_high';
15
+ hasVideo?: boolean;
16
+ };
17
+
18
+ function resolvePreviewProducts(products: Product[], options: ProductQuery): Product[] {
19
+ let next = [...products];
20
+
21
+ if (options.collection) {
22
+ next = next.filter((product) => product.categories.some((category) => category.slug === options.collection));
23
+ }
24
+
25
+ if (options.ids && options.ids.length > 0) {
26
+ next = next.filter((product) => options.ids?.includes(product.id));
27
+ }
28
+
29
+ if (options.hasVideo) {
30
+ next = next.filter((product) => Boolean(product.media.video_url));
31
+ }
32
+
33
+ if (options.sort === 'popular') {
34
+ next.sort((left, right) => right.review_summary.rating - left.review_summary.rating);
35
+ } else if (options.sort === 'price_low') {
36
+ next.sort((left, right) => left.pricing.sale_amount - right.pricing.sale_amount);
37
+ } else if (options.sort === 'price_high') {
38
+ next.sort((left, right) => right.pricing.sale_amount - left.pricing.sale_amount);
39
+ }
40
+
41
+ return typeof options.limit === 'number' ? next.slice(0, options.limit) : next;
42
+ }
43
+
44
+ export function useProducts(options?: ProductQuery & {
45
+ /** Backs the "video showcase" products variant. Deliberately incompatible
46
+ * with `collection` (that path calls a different, collection-scoped
47
+ * endpoint this filter isn't wired into) — has_video is ignored whenever
48
+ * collection is also set. */
49
+ hasVideo?: boolean;
50
+ }): { products: Product[]; isLoading: boolean } {
51
+ const { previewData, vendor } = useStorefront();
52
+ const hasPreviewProducts = Array.isArray(previewData?.products);
53
+ const [products, setProducts] = useState<Product[]>(() => (
54
+ options && hasPreviewProducts ? resolvePreviewProducts(previewData.products!, options) : []
55
+ ));
56
+ const [isLoading, setIsLoading] = useState(() => !options || !hasPreviewProducts);
57
+ // `undefined` is the caller's signal that it already has product data (e.g.
58
+ // server-seeded via `hydrateProductBlocks`, or a theme block's own
59
+ // `initialProducts`) and this hook has nothing to do — every call site in
60
+ // the codebase passes `undefined` specifically to mean that. Read outside
61
+ // the effect, as a plain boolean, so the effect keeps depending on
62
+ // `options`' individual fields rather than the object itself (a new object
63
+ // literal every render would otherwise re-run the fetch on every render).
64
+ const hasOptions = options !== undefined;
65
+
66
+ useEffect(() => {
67
+ if (!hasOptions) {
68
+ // Without this guard the hook fetched anyway and threw the result
69
+ // away, wasting a request on every mount.
70
+ setIsLoading(false);
71
+ return;
72
+ }
73
+
74
+ let cancelled = false;
75
+ const previewProducts = previewData?.products;
76
+ const client = createBrowserClient(vendor.slug ?? undefined);
77
+
78
+ async function run(): Promise<void> {
79
+ try {
80
+ if (Array.isArray(previewProducts)) {
81
+ if (!cancelled) {
82
+ setProducts(resolvePreviewProducts(previewProducts, options!));
83
+ setIsLoading(false);
84
+ }
85
+ return;
86
+ }
87
+
88
+ setIsLoading(true);
89
+
90
+ let resolvedProducts: Product[];
91
+
92
+ if (options?.collection) {
93
+ const response = await client.get<ApiResponse<unknown>>(
94
+ `/collections/${options.collection}/products`,
95
+ );
96
+ resolvedProducts = extractProducts(response.data).map((p) => normalizeProduct(p));
97
+ } else {
98
+ const query: Record<string, string | number | string[]> = {};
99
+ if (options?.sort) query.sort = options.sort;
100
+ if (options?.limit) query.per_page = options.limit;
101
+ if (options?.hasVideo) query.has_video = 1;
102
+ // Ask the backend for exactly these products (whereIn) instead of
103
+ // fetching an arbitrary `per_page`-sized page of the default listing
104
+ // and hoping the requested ids happen to land in it — with a small
105
+ // limit (bundle/shop-the-look/featured's 1-3) they almost never did,
106
+ // silently dropping hand-picked products from the storefront.
107
+ if (options?.ids && options.ids.length > 0) query.ids = options.ids;
108
+
109
+ const response = await client.get<ApiResponse<unknown, ProductListMeta>>('/products', query);
110
+ resolvedProducts = extractProducts(response.data).map((p) => normalizeProduct(p));
111
+ }
112
+
113
+ let next = resolvedProducts;
114
+
115
+ if (options?.collection && options?.ids && options.ids.length > 0) {
116
+ // The collection endpoint has no ids filter of its own — this branch
117
+ // fetches the full collection unbounded, so a client-side filter here
118
+ // is safe (nothing was truncated before it ran).
119
+ next = next.filter((product) => options.ids?.includes(product.id));
120
+ }
121
+
122
+ if (typeof options?.limit === 'number') {
123
+ next = next.slice(0, options.limit);
124
+ }
125
+
126
+ if (!cancelled) {
127
+ setProducts(next);
128
+ }
129
+ } finally {
130
+ if (!cancelled) {
131
+ setIsLoading(false);
132
+ }
133
+ }
134
+ }
135
+
136
+ void run();
137
+
138
+ return () => {
139
+ cancelled = true;
140
+ };
141
+ }, [hasOptions, options?.collection, options?.ids, options?.limit, options?.sort, options?.hasVideo, previewData?.products, vendor.slug]);
142
+
143
+ return { products, isLoading };
144
+ }
145
+
146
+ /**
147
+ * Full product detail (the same `/products/{slug}` request the PDP itself
148
+ * uses), including the real `media.gallery` — the list/collection endpoints
149
+ * `useProducts` calls only ever return `media.{image,thumbnail}` (see
150
+ * Customer\ProductResource::buildBaseMediaPayload vs buildDetailMediaPayload
151
+ * in queek_backend). Single-product spotlight blocks (e.g. glow's `featured`
152
+ * variant) need this instead of substituting image/thumbnail as a fake
153
+ * 2-image gallery.
154
+ */
155
+ /**
156
+ * Fetch one product by SLUG.
157
+ *
158
+ * Distinct from `useProductDetail` in hooks/use-product-detail.ts, which
159
+ * enriches a product you ALREADY hold from a list payload (that one takes the
160
+ * list product, an enabled flag and a vendor slug). Both were called
161
+ * `useProductDetail` until the public surface was written down and the clash
162
+ * surfaced — same name, unrelated signatures, one import path apart.
163
+ */
164
+ export function useProductBySlug(slug: string | null | undefined): { product: Product | null; isLoading: boolean } {
165
+ const { previewData, vendor } = useStorefront();
166
+ const [product, setProduct] = useState<Product | null>(null);
167
+ const [isLoading, setIsLoading] = useState(!!slug);
168
+
169
+ useEffect(() => {
170
+ if (!slug) {
171
+ setProduct(null);
172
+ setIsLoading(false);
173
+ return;
174
+ }
175
+
176
+ // Preview/demo data has no separate detail endpoint to call — the list
177
+ // entry it already has is as good as it gets.
178
+ const previewProducts = previewData?.products;
179
+ if (Array.isArray(previewProducts)) {
180
+ setProduct(previewProducts.find((p) => p.slug === slug) ?? null);
181
+ setIsLoading(false);
182
+ return;
183
+ }
184
+
185
+ let cancelled = false;
186
+ const client = createBrowserClient(vendor.slug ?? undefined);
187
+
188
+ async function run(): Promise<void> {
189
+ setIsLoading(true);
190
+ try {
191
+ const response = await client.get<ApiResponse<LegacyProductLike>>(`/products/${slug}`);
192
+ const products = extractProducts(response.data);
193
+ const payload = products[0] ?? response.data;
194
+ if (!cancelled) {
195
+ setProduct(payload ? normalizeProduct(payload as LegacyProductLike) : null);
196
+ }
197
+ } catch {
198
+ if (!cancelled) {
199
+ setProduct(null);
200
+ }
201
+ } finally {
202
+ if (!cancelled) {
203
+ setIsLoading(false);
204
+ }
205
+ }
206
+ }
207
+
208
+ void run();
209
+
210
+ return () => {
211
+ cancelled = true;
212
+ };
213
+ }, [slug, previewData?.products, vendor.slug]);
214
+
215
+ return { product, isLoading };
216
+ }
@@ -0,0 +1,56 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useStorefront } from '../provider';
5
+ import type { Product } from '../types/product';
6
+ import type { ApiResponse } from '../types/product';
7
+ import { createBrowserClient } from '../sdk/client';
8
+ import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
9
+
10
+ export function useRelatedProducts(
11
+ currentSlug: string,
12
+ limit = 4,
13
+ ): { products: Product[]; isLoading: boolean } {
14
+ const { vendor, previewData } = useStorefront();
15
+ const [products, setProducts] = useState<Product[]>([]);
16
+ const [isLoading, setIsLoading] = useState(true);
17
+
18
+ useEffect(() => {
19
+ let cancelled = false;
20
+
21
+ async function run(): Promise<void> {
22
+ setIsLoading(true);
23
+ try {
24
+ if (previewData?.products) {
25
+ const others = (previewData.products as Product[])
26
+ .filter((p) => p.slug !== currentSlug)
27
+ .slice(0, limit);
28
+ if (!cancelled) setProducts(others);
29
+ return;
30
+ }
31
+
32
+ const client = createBrowserClient(vendor.slug ?? undefined);
33
+ const response = await client.get<ApiResponse<unknown>>(
34
+ `/products/${currentSlug}/related`,
35
+ { limit },
36
+ );
37
+
38
+ const raw = extractProducts(response.data);
39
+ const normalized = raw
40
+ .map((p) => normalizeProduct(p as Parameters<typeof normalizeProduct>[0]))
41
+ .slice(0, limit);
42
+
43
+ if (!cancelled) setProducts(normalized);
44
+ } catch {
45
+ // Non-critical — silently fail
46
+ } finally {
47
+ if (!cancelled) setIsLoading(false);
48
+ }
49
+ }
50
+
51
+ void run();
52
+ return () => { cancelled = true; };
53
+ }, [currentSlug, limit, vendor.slug, previewData?.products]);
54
+
55
+ return { products, isLoading };
56
+ }
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useStorefront } from '../provider';
5
+ import { getQueekClient } from '../sdk/queek-client';
6
+ import { useLocationStore } from '../stores/location-store';
7
+
8
+ interface CoordinatesResponse {
9
+ lat?: number;
10
+ lng?: number;
11
+ latitude?: number;
12
+ longitude?: number;
13
+ formatted_address?: string;
14
+ state_id?: string | null;
15
+ }
16
+
17
+ export function useResolveCoordinates(): {
18
+ resolve: (placeId: string, description: string) => Promise<boolean>;
19
+ resolving: boolean;
20
+ } {
21
+ const { vendor } = useStorefront();
22
+ const setLocation = useLocationStore((s) => s.setLocation);
23
+ const [resolving, setResolving] = useState(false);
24
+
25
+ const resolve = async (placeId: string, description: string): Promise<boolean> => {
26
+ setResolving(true);
27
+ try {
28
+ const client = getQueekClient(vendor.slug ?? undefined);
29
+ const res = await client.post<CoordinatesResponse>('/client/geo/resolve', {
30
+ place_id: placeId,
31
+ });
32
+ const data = (res?.data ?? {}) as CoordinatesResponse;
33
+ const lat = data.lat ?? data.latitude;
34
+ const lng = data.lng ?? data.longitude;
35
+ if (typeof lat === 'number' && typeof lng === 'number') {
36
+ setLocation(lat, lng, data.formatted_address ?? description, data.state_id ?? null);
37
+ return true;
38
+ }
39
+ return false;
40
+ } catch {
41
+ return false;
42
+ } finally {
43
+ setResolving(false);
44
+ }
45
+ };
46
+
47
+ return { resolve, resolving };
48
+ }