@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,324 @@
1
+ 'use client';
2
+
3
+ import type { Block } from './types/block';
4
+ import { useEditPreviewStore } from './stores/edit-preview-store';
5
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
6
+ import { useRouter } from 'next/navigation';
7
+ import { toJpeg } from 'html-to-image';
8
+
9
+ const MESSAGE_SOURCE = 'queek-storefront';
10
+ const MESSAGE_SOURCE_PARENT = 'queek-merchant';
11
+
12
+ // Longest edge of the captured JPEG, in CSS px. Keeps the resulting
13
+ // data-URI small (~50-150KB) so it can ride the qee chat POST body inline.
14
+ // TODO: upload the capture to media (media.usequeek.com) and send a URL
15
+ // instead of a data-URI once resume-persistence of attachments is needed.
16
+ const CAPTURE_MAX_EDGE = 1000;
17
+
18
+ async function captureBlock(el: HTMLElement): Promise<string> {
19
+ const rect = el.getBoundingClientRect();
20
+ const longest = Math.max(rect.width, rect.height) || 1;
21
+ const pixelRatio = Math.min(1, CAPTURE_MAX_EDGE / longest);
22
+ return toJpeg(el, {
23
+ quality: 0.85,
24
+ pixelRatio,
25
+ // Re-fetch cross-origin (CDN) images so html-to-image inlines them as
26
+ // data-URLs before rasterising — media.usequeek.com sends
27
+ // `access-control-allow-origin: *`, so the canvas stays untainted.
28
+ cacheBust: true,
29
+ fetchRequestInit: { mode: 'cors' },
30
+ });
31
+ }
32
+
33
+ let selectedIndex: number | null = null;
34
+ const listeners = new Set<(idx: number | null) => void>();
35
+
36
+ function setSelectedIndex(next: number | null): void {
37
+ selectedIndex = next;
38
+ listeners.forEach((fn) => fn(next));
39
+ }
40
+
41
+ function useSelectedIndex(): number | null {
42
+ const [value, setValue] = useState<number | null>(selectedIndex);
43
+ useEffect(() => {
44
+ listeners.add(setValue);
45
+ return () => {
46
+ listeners.delete(setValue);
47
+ };
48
+ }, []);
49
+ return value;
50
+ }
51
+
52
+ // Which sidebar tab the merchant is on — streamed in from the parent so the
53
+ // per-block overlay can offer the *other* one ("Section" while on AI, "AI"
54
+ // everywhere else) without the storefront needing its own tab state.
55
+ let activeTabIsAi = false;
56
+ const tabListeners = new Set<(isAi: boolean) => void>();
57
+
58
+ function setActiveTabIsAi(next: boolean): void {
59
+ activeTabIsAi = next;
60
+ tabListeners.forEach((fn) => fn(next));
61
+ }
62
+
63
+ function useActiveTabIsAi(): boolean {
64
+ const [value, setValue] = useState(activeTabIsAi);
65
+ useEffect(() => {
66
+ tabListeners.add(setValue);
67
+ return () => {
68
+ tabListeners.delete(setValue);
69
+ };
70
+ }, []);
71
+ return value;
72
+ }
73
+
74
+ function postBlockMessage(payload: Record<string, unknown>): void {
75
+ if (typeof window === 'undefined') return;
76
+
77
+ // `window.parent !== window` is true for the web dashboard's real
78
+ // `<iframe>` embed. The Queek mobile app instead loads the storefront as a
79
+ // top-level document inside a WebView, so that check alone left it with no
80
+ // way to receive block:click/block:add/block:delete — the storefront
81
+ // simply never posted. Mobile appends `&host=native` to the preview URL to
82
+ // opt in explicitly; the web embed doesn't set it and keeps working as
83
+ // before either way.
84
+ const isEmbedded = window.parent !== window || new URLSearchParams(location.search).get('host') === 'native';
85
+ if (!isEmbedded) return;
86
+
87
+ // Target origin '*' is fine — payload is non-sensitive block metadata;
88
+ // the merchant filters by `source` on receive.
89
+ window.parent.postMessage({ source: MESSAGE_SOURCE, ...payload }, '*');
90
+ }
91
+
92
+ let parentListenerInstalled = false;
93
+ function ensureParentListener(): void {
94
+ if (parentListenerInstalled || typeof window === 'undefined') return;
95
+ parentListenerInstalled = true;
96
+ window.addEventListener('message', (e: MessageEvent) => {
97
+ const data = e.data;
98
+ if (!data || typeof data !== 'object' || data.source !== MESSAGE_SOURCE_PARENT) return;
99
+ if (data.type === 'block:select') {
100
+ setSelectedIndex(typeof data.index === 'number' ? data.index : null);
101
+ } else if (data.type === 'tab:active') {
102
+ setActiveTabIsAi(Boolean(data.isAi));
103
+ } else if (data.type === 'blocks:set') {
104
+ // The merchant's current draft sections, whole. One message covers a field
105
+ // edit, a reorder, an insert and a delete — no index arithmetic to drift.
106
+ if (Array.isArray(data.blocks)) useEditPreviewStore.getState().setBlocks(data.blocks as Block[]);
107
+ }
108
+ });
109
+ // This whole module is a next/dynamic chunk, fetched separately from the
110
+ // main storefront bundle — it can finish loading AFTER the merchant's
111
+ // iframe `onLoad` (which fires on the MAIN document's load, unrelated to
112
+ // this chunk) already broadcast block:select/tab:active once. A message
113
+ // sent before this listener existed is just gone — postMessage doesn't
114
+ // queue for a not-yet-registered listener. Announcing readiness here lets
115
+ // the merchant re-send the current state on its own schedule instead of
116
+ // guessing ours, closing that race instead of hoping onLoad wins it.
117
+ postBlockMessage({ type: 'ready' });
118
+ }
119
+
120
+ export function ActiveBlock({
121
+ index,
122
+ type,
123
+ variant,
124
+ children,
125
+ }: {
126
+ index: number;
127
+ type: string;
128
+ variant?: string;
129
+ children: ReactNode;
130
+ }): JSX.Element {
131
+ const elRef = useRef<HTMLDivElement>(null);
132
+ const selected = useSelectedIndex();
133
+ const isSelected = selected === index;
134
+ const isAiTab = useActiveTabIsAi();
135
+ const [capturing, setCapturing] = useState(false);
136
+
137
+ useEffect(() => {
138
+ ensureParentListener();
139
+ }, []);
140
+
141
+ useEffect(() => {
142
+ if (!isSelected || !elRef.current) return;
143
+ elRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
144
+ }, [isSelected]);
145
+
146
+ // Screenshot THIS section locally (edit mode only — the whole component tree
147
+ // only renders inside the editor iframe) and hand the JPEG data-URI to the
148
+ // merchant, which turns it into a pending qee-chat attachment.
149
+ const captureToQee = async (): Promise<void> => {
150
+ const el = elRef.current;
151
+ if (!el || capturing) return;
152
+ setCapturing(true);
153
+ try {
154
+ const dataUrl = await captureBlock(el);
155
+ postBlockMessage({ type: 'block:captured', index, dataUrl });
156
+ } catch (err) {
157
+ postBlockMessage({
158
+ type: 'block:captured',
159
+ index,
160
+ error: err instanceof Error ? err.message : 'Capture failed',
161
+ });
162
+ } finally {
163
+ setCapturing(false);
164
+ }
165
+ };
166
+
167
+ return (
168
+ <div
169
+ ref={elRef}
170
+ data-qk-block-index={index}
171
+ data-qk-block-type={type}
172
+ data-qk-selected={isSelected ? '1' : undefined}
173
+ className="qk-editable-block"
174
+ onClick={(e) => {
175
+ e.stopPropagation();
176
+ postBlockMessage({ type: 'block:click', index, blockType: type, variant });
177
+ }}
178
+ >
179
+ {children}
180
+ <button
181
+ type="button"
182
+ className="qk-editable-capture"
183
+ title="Send this section to qee"
184
+ disabled={capturing}
185
+ onClick={(e) => {
186
+ e.stopPropagation();
187
+ void captureToQee();
188
+ }}
189
+ >
190
+ {capturing ? (
191
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
192
+ <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" strokeOpacity="0.25" />
193
+ <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
194
+ <animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="0.7s" repeatCount="indefinite" />
195
+ </path>
196
+ </svg>
197
+ ) : (
198
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true">
199
+ <path d="M4 8.5A1.5 1.5 0 0 1 5.5 7h1.2a1.5 1.5 0 0 0 1.25-.67l.6-.9A1.5 1.5 0 0 1 10.8 4.8h2.4a1.5 1.5 0 0 1 1.25.67l.6.9A1.5 1.5 0 0 0 16.3 7h1.2A1.5 1.5 0 0 1 19 8.5v8A1.5 1.5 0 0 1 17.5 18h-11A1.5 1.5 0 0 1 5 16.5v-8Z" stroke="currentColor" strokeWidth="2" strokeLinejoin="round" />
200
+ <circle cx="12" cy="12" r="2.75" stroke="currentColor" strokeWidth="2" />
201
+ </svg>
202
+ )}
203
+ </button>
204
+ <button
205
+ type="button"
206
+ className="qk-editable-add"
207
+ title="Add section below"
208
+ onClick={(e) => {
209
+ e.stopPropagation();
210
+ postBlockMessage({ type: 'block:add', insertIndex: index + 1 });
211
+ }}
212
+ >
213
+ +
214
+ </button>
215
+ <button
216
+ type="button"
217
+ className="qk-editable-delete"
218
+ title="Delete this section"
219
+ onClick={(e) => {
220
+ e.stopPropagation();
221
+ postBlockMessage({ type: 'block:delete', index });
222
+ }}
223
+ >
224
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
225
+ <path d="M5 7h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
226
+ <path d="M9 7V5.5A1.5 1.5 0 0 1 10.5 4h3A1.5 1.5 0 0 1 15 5.5V7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
227
+ <path d="M6.5 7 7.2 18.2A1.5 1.5 0 0 0 8.7 19.6h6.6a1.5 1.5 0 0 0 1.5-1.4L17.5 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
228
+ <path d="M10 10.5v6M14 10.5v6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
229
+ </svg>
230
+ </button>
231
+ {isAiTab ? (
232
+ // Only shown on the AI tab: the reverse direction (Sections -> AI)
233
+ // already exists as the capture button above, which does strictly
234
+ // more (attaches a screenshot) — a second "go to AI" affordance here
235
+ // would just be a redundant, confusing near-duplicate of it.
236
+ <button
237
+ type="button"
238
+ className="qk-editable-editsection"
239
+ title="Edit this section's settings"
240
+ onClick={(e) => {
241
+ e.stopPropagation();
242
+ postBlockMessage({ type: 'block:editSection', index });
243
+ }}
244
+ >
245
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
246
+ <rect x="4" y="4" width="16" height="6" rx="1.5" stroke="currentColor" strokeWidth="2" />
247
+ <rect x="4" y="14" width="16" height="6" rx="1.5" stroke="currentColor" strokeWidth="2" />
248
+ </svg>
249
+ </button>
250
+ ) : null}
251
+ </div>
252
+ );
253
+ }
254
+
255
+ export function ActiveRegion({
256
+ region,
257
+ children,
258
+ }: {
259
+ region: string;
260
+ children: ReactNode;
261
+ }): JSX.Element {
262
+ // `display: contents` (see storefront.css) — this wrapper draws no box, so it
263
+ // never disturbs the theme's sticky header / flex footer layout. The click
264
+ // still fires because the element stays in the DOM event path; hover outline
265
+ // is applied to the wrapped child via CSS.
266
+ return (
267
+ <div
268
+ data-qk-region={region}
269
+ className="qk-editable-region"
270
+ onClick={(e) => {
271
+ e.stopPropagation();
272
+ // The announcement bar lives inside the header region; every theme marks
273
+ // its container with a `*-announcement` class, so a click there
274
+ // deep-links straight to the Announcement editor. The `:not` excludes
275
+ // the `*-nav--has-announcement` modifier the <header> itself carries
276
+ // when the bar is enabled — otherwise plain nav clicks would match too.
277
+ const target =
278
+ region === 'header' &&
279
+ (e.target as HTMLElement | null)?.closest?.(
280
+ '[class*="announcement"]:not([class*="has-announcement"])',
281
+ )
282
+ ? 'announcement'
283
+ : region;
284
+ postBlockMessage({ type: 'region:click', region: target });
285
+ }}
286
+ >
287
+ {children}
288
+ </div>
289
+ );
290
+ }
291
+
292
+ export function ActiveBridge(): null {
293
+ const router = useRouter();
294
+
295
+ useEffect(() => {
296
+ // Suppress in-iframe navigation while editing — clicks on links and
297
+ // form submits inside the preview should not navigate the frame away.
298
+ const onClick = (e: MouseEvent) => {
299
+ const a = (e.target as HTMLElement | null)?.closest?.('a');
300
+ if (a) e.preventDefault();
301
+ };
302
+ const onSubmit = (e: Event) => e.preventDefault();
303
+ document.addEventListener('click', onClick, true);
304
+ document.addEventListener('submit', onSubmit, true);
305
+
306
+ const onMessage = (e: MessageEvent) => {
307
+ const data = e.data;
308
+ if (!data || typeof data !== 'object' || data.source !== MESSAGE_SOURCE_PARENT) return;
309
+ if (data.type === 'refresh') {
310
+ useEditPreviewStore.getState().markRefreshRequested();
311
+ router.refresh();
312
+ }
313
+ };
314
+ window.addEventListener('message', onMessage);
315
+
316
+ return () => {
317
+ document.removeEventListener('click', onClick, true);
318
+ document.removeEventListener('submit', onSubmit, true);
319
+ window.removeEventListener('message', onMessage);
320
+ };
321
+ }, [router]);
322
+
323
+ return null;
324
+ }
package/edit-mode.tsx ADDED
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState, type ReactNode } from 'react';
4
+ import dynamic from 'next/dynamic';
5
+
6
+ const QK_EDIT_FLAG = 'edit';
7
+
8
+ // Lightweight: reads `?edit=1` once on mount. Always shipped to the
9
+ // storefront bundle, but cheap (one URLSearchParams parse) and adds no
10
+ // runtime overhead for public visitors.
11
+ export function useEditMode(): boolean {
12
+ const [isEdit, setIsEdit] = useState(false);
13
+
14
+ useEffect(() => {
15
+ if (typeof window === 'undefined') return;
16
+ setIsEdit(new URLSearchParams(window.location.search).get(QK_EDIT_FLAG) === '1');
17
+ }, []);
18
+
19
+ return isEdit;
20
+ }
21
+
22
+ // The heavy edit-mode chunk (postMessage bridge, scroll-into-view, link
23
+ // suppression, router.refresh) is split into edit-mode-active.tsx and only
24
+ // loaded for users with `?edit=1`. Public visitors never download it.
25
+ const ActiveBlock = dynamic(
26
+ () => import('./edit-mode-active').then((m) => m.ActiveBlock),
27
+ { ssr: false, loading: () => null },
28
+ );
29
+
30
+ const ActiveBridge = dynamic(
31
+ () => import('./edit-mode-active').then((m) => m.ActiveBridge),
32
+ { ssr: false, loading: () => null },
33
+ );
34
+
35
+ const ActiveRegion = dynamic(
36
+ () => import('./edit-mode-active').then((m) => m.ActiveRegion),
37
+ { ssr: false, loading: () => null },
38
+ );
39
+
40
+ export function EditableBlock({
41
+ index,
42
+ type,
43
+ variant,
44
+ enabled,
45
+ children,
46
+ }: {
47
+ index: number;
48
+ type: string;
49
+ variant?: string;
50
+ enabled: boolean;
51
+ children: ReactNode;
52
+ }): JSX.Element {
53
+ if (!enabled) return <>{children}</>;
54
+ return (
55
+ <ActiveBlock index={index} type={type} variant={variant}>
56
+ {children}
57
+ </ActiveBlock>
58
+ );
59
+ }
60
+
61
+ // Static-region click-to-edit (header / footer). Self-gates on `?edit=1` like
62
+ // EditModeBridge, so public visitors render children untouched and never load
63
+ // the active chunk.
64
+ export function EditableRegion({
65
+ region,
66
+ children,
67
+ }: {
68
+ region: string;
69
+ children: ReactNode;
70
+ }): JSX.Element {
71
+ const isEdit = useEditMode();
72
+ if (!isEdit) return <>{children}</>;
73
+ return <ActiveRegion region={region}>{children}</ActiveRegion>;
74
+ }
75
+
76
+ export function EditModeBridge(): JSX.Element | null {
77
+ const isEdit = useEditMode();
78
+ if (!isEdit) return null;
79
+ return <ActiveBridge />;
80
+ }
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { useStorefront } from '../provider';
5
+ import { getQueekClient } from '../sdk/queek-client';
6
+
7
+ export interface AddressSuggestion {
8
+ description: string;
9
+ place_id: string;
10
+ }
11
+
12
+ const DEBOUNCE_MS = 300;
13
+
14
+ export function useAddressAutocomplete(query: string): {
15
+ suggestions: AddressSuggestion[];
16
+ loading: boolean;
17
+ } {
18
+ const { vendor } = useStorefront();
19
+ const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
20
+ const [loading, setLoading] = useState(false);
21
+
22
+ useEffect(() => {
23
+ if (!query || query.trim().length < 2) {
24
+ setSuggestions([]);
25
+ return;
26
+ }
27
+
28
+ let cancelled = false;
29
+ const timer = setTimeout(() => {
30
+ setLoading(true);
31
+ const client = getQueekClient(vendor.slug ?? undefined);
32
+ const path = `/client/address/autocomplete?query=${encodeURIComponent(query.trim())}`;
33
+
34
+ client
35
+ .get<AddressSuggestion[] | { data: AddressSuggestion[] }>(path)
36
+ .then((res) => {
37
+ if (cancelled) return;
38
+ // Backend returns a bare array (not wrapped in envelope). SDK returns
39
+ // whatever the server sent — handle both shapes defensively.
40
+ const list: unknown = Array.isArray(res)
41
+ ? res
42
+ : Array.isArray((res as { data?: unknown })?.data)
43
+ ? (res as { data: AddressSuggestion[] }).data
44
+ : [];
45
+ setSuggestions(list as AddressSuggestion[]);
46
+ })
47
+ .catch(() => {
48
+ if (!cancelled) setSuggestions([]);
49
+ })
50
+ .finally(() => {
51
+ if (!cancelled) setLoading(false);
52
+ });
53
+ }, DEBOUNCE_MS);
54
+
55
+ return () => {
56
+ cancelled = true;
57
+ clearTimeout(timer);
58
+ };
59
+ }, [query, vendor.slug]);
60
+
61
+ return { suggestions, loading };
62
+ }