@usequeek/theme-kit 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/LICENSE +1 -1
  3. package/README.md +275 -26
  4. package/api/collections.ts +14 -1
  5. package/api/orders.ts +15 -1
  6. package/apps/follow-card.tsx +5 -0
  7. package/apps/subscribe-form.tsx +2 -2
  8. package/components/auth/auth-flow.tsx +12 -0
  9. package/components/cart/cart-panel-controller.tsx +13 -0
  10. package/components/checkout/default-checkout-shell.tsx +5 -0
  11. package/components/checkout/policy-links.tsx +4 -0
  12. package/components/image.tsx +10 -0
  13. package/components/language-menu.css +349 -0
  14. package/components/language-menu.tsx +371 -0
  15. package/components/language-switcher.tsx +40 -8
  16. package/components/powered-by-queek.tsx +4 -3
  17. package/components/product-media-frame.tsx +5 -0
  18. package/components/product-metafields.tsx +9 -0
  19. package/components/variant-picker.css +4 -7
  20. package/edit-mode.tsx +4 -3
  21. package/hooks/use-address-autocomplete.ts +9 -0
  22. package/hooks/use-auth-flow.tsx +7 -12
  23. package/hooks/use-auth.ts +10 -0
  24. package/hooks/use-cart.ts +12 -0
  25. package/hooks/use-categories.ts +16 -2
  26. package/hooks/use-href.ts +9 -4
  27. package/hooks/use-posts.ts +16 -8
  28. package/hooks/use-product-detail.ts +25 -11
  29. package/hooks/use-product-questions.ts +16 -3
  30. package/hooks/use-products.ts +44 -22
  31. package/hooks/use-related-products.ts +17 -5
  32. package/hooks/use-resolve-coordinates.ts +9 -0
  33. package/hooks/use-reviews.ts +16 -2
  34. package/hooks/use-search.ts +10 -0
  35. package/hooks/use-shop-cart.ts +8 -0
  36. package/hooks/use-shop.ts +19 -6
  37. package/hooks/use-variant-selection.ts +23 -19
  38. package/index.ts +10 -11
  39. package/locales/en.default.json +4 -0
  40. package/navigation.tsx +2 -0
  41. package/package.json +25 -4
  42. package/page-renderer.tsx +11 -0
  43. package/provider.tsx +29 -3
  44. package/sdk/client.ts +183 -36
  45. package/sdk/error-handler.ts +17 -9
  46. package/sdk/queek-client.ts +11 -1
  47. package/sdk/server-store-client.ts +2 -2
  48. package/shared-blocks/embed.tsx +7 -3
  49. package/shared-blocks/manifest.ts +1 -1
  50. package/shared-blocks/product-qa.tsx +1 -1
  51. package/stores/address-modal-store.ts +1 -0
  52. package/stores/auth-modal-store.ts +1 -0
  53. package/stores/cart-panel-store.ts +1 -0
  54. package/stores/cart-store.ts +12 -7
  55. package/stores/checkout-store.ts +4 -4
  56. package/stores/location-store.ts +5 -0
  57. package/stores/orders-sheet-store.ts +1 -0
  58. package/stores/product-modal-store.ts +1 -0
  59. package/stores/search-modal-store.ts +1 -0
  60. package/stores/user-store.ts +1 -1
  61. package/strings/pseudo-locale.ts +1 -1
  62. package/theme-context.tsx +11 -1
  63. package/theme-mount.tsx +14 -0
  64. package/utils/format.ts +10 -1
  65. package/utils/menu-link.ts +8 -0
  66. package/utils/menu-placement.ts +91 -0
  67. package/utils/to-error.ts +4 -0
  68. package/utils/vendor-host.ts +1 -2
  69. package/scripts/verify-package.ts +0 -102
  70. package/tests/apps.test.tsx +0 -33
  71. package/tests/auth-email-errors.test.tsx +0 -55
  72. package/tests/currency-contract.test.ts +0 -85
  73. package/tests/default-metaobject-page.test.tsx +0 -56
  74. package/tests/display-format.test.ts +0 -403
  75. package/tests/fixtures/external-theme.tsx +0 -85
  76. package/tests/image-loading.test.tsx +0 -31
  77. package/tests/language-switcher.test.tsx +0 -350
  78. package/tests/list-pagination.test.tsx +0 -71
  79. package/tests/locale-switch.test.ts +0 -197
  80. package/tests/locale-transport.test.tsx +0 -321
  81. package/tests/manifest-parity.test.ts +0 -54
  82. package/tests/markdown-render.test.ts +0 -46
  83. package/tests/metaobjects-block.test.tsx +0 -70
  84. package/tests/navigation.test.tsx +0 -30
  85. package/tests/orders-walk.test.ts +0 -48
  86. package/tests/page-renderer-signature.test.tsx +0 -42
  87. package/tests/powered-by-queek.test.tsx +0 -65
  88. package/tests/powered-by-url.test.ts +0 -49
  89. package/tests/product-images.test.ts +0 -242
  90. package/tests/product-metafields.test.tsx +0 -154
  91. package/tests/public-surface.test.ts +0 -35
  92. package/tests/shop-query.test.ts +0 -64
  93. package/tests/shopper-chrome-strings.test.tsx +0 -271
  94. package/tests/storefront-client-scope.test.ts +0 -43
  95. package/tests/theme-hook-placement.test.tsx +0 -179
  96. package/tests/theme-strings-client.test.tsx +0 -310
  97. package/tests/theme-strings-pseudo.test.tsx +0 -171
  98. package/tests/theme-strings-typed.test.tsx +0 -97
  99. package/tests/theme-strings.test.tsx +0 -401
  100. package/tests/theme-typed-keys.fixture.ts +0 -76
  101. package/tests/use-shop-prefetch.test.tsx +0 -87
@@ -0,0 +1,371 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useLayoutEffect,
8
+ useRef,
9
+ useState,
10
+ type JSX,
11
+ type KeyboardEvent,
12
+ type MouseEvent,
13
+ } from 'react';
14
+ import type { LocalesState } from '../hooks/use-locales';
15
+ import type { StoreLocale } from '../utils/locale-switch';
16
+ import { computePlacement, type MenuAlign, type MenuSide } from '../utils/menu-placement';
17
+ import './language-menu.css';
18
+
19
+ /** Below this width the menu is a bottom sheet. The same value is in language-menu.css. */
20
+ export const LANGUAGE_MENU_SHEET_QUERY = '(max-width: 639.98px)';
21
+
22
+ /** More languages than this: the root gets `data-many="true"` (a theme may use two columns). */
23
+ const MANY_LANGUAGES = 10;
24
+ const TYPEAHEAD_RESET_MS = 500;
25
+ const LOCK_CLASS = 'qn-lang-lock';
26
+
27
+ type Mode = 'popover' | 'sheet';
28
+ type Focus = 'selected' | 'last';
29
+
30
+ export interface LanguageMenuProps {
31
+ state: LocalesState & { open: boolean; setOpen: (open: boolean) => void; label: string };
32
+ rootClass: string;
33
+ showCode: boolean;
34
+ side: MenuSide;
35
+ align: MenuAlign;
36
+ /** The language's name as shown (native or English). */
37
+ nameOf: (locale: StoreLocale) => string;
38
+ /** "Language: <shown name>" — the trigger's accessible name. */
39
+ triggerLabelFor: (locale: StoreLocale) => string;
40
+ }
41
+
42
+ function codeOf(locale: StoreLocale): string {
43
+ return locale.locale.toUpperCase();
44
+ }
45
+
46
+ function isSheetViewport(): boolean {
47
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
48
+ ? window.matchMedia(LANGUAGE_MENU_SHEET_QUERY).matches
49
+ : false;
50
+ }
51
+
52
+ function isRtl(element: Element): boolean {
53
+ const attr = element.closest('[dir]')?.getAttribute('dir');
54
+ if (attr === 'rtl' || attr === 'ltr') return attr === 'rtl';
55
+ return getComputedStyle(element).direction === 'rtl';
56
+ }
57
+
58
+ function isPlainPrimaryClick(event: MouseEvent): boolean {
59
+ return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
60
+ }
61
+
62
+ /**
63
+ * The `menu` variant of `LanguageSwitcher`: a disclosure button and a list of
64
+ * language links — a popover on wide screens, a bottom sheet on narrow ones.
65
+ * The items navigate, so they are plain links (`aria-current` marks the
66
+ * current one), not listbox options. Arrow keys, Home/End and typeahead move
67
+ * focus between the links; Enter follows the focused link natively.
68
+ *
69
+ * The wide/narrow LAYOUT is CSS only (one media query), so the markup is the
70
+ * same in both and server and client agree. JavaScript asks the media query
71
+ * only while the menu is opening or open: to lock page scroll behind a sheet
72
+ * and to run collision handling for a popover.
73
+ */
74
+ export function LanguageMenu({
75
+ state,
76
+ rootClass,
77
+ showCode,
78
+ side,
79
+ align,
80
+ nameOf,
81
+ triggerLabelFor,
82
+ }: LanguageMenuProps): JSX.Element {
83
+ const { locales, active, hrefFor, switchTo, open, setOpen, label } = state;
84
+ const uid = useId();
85
+ const popoverId = `${uid}-popover`;
86
+ const titleId = `${uid}-title`;
87
+
88
+ const rootRef = useRef<HTMLDivElement>(null);
89
+ const triggerRef = useRef<HTMLButtonElement>(null);
90
+ const popoverRef = useRef<HTMLDivElement>(null);
91
+ const optionRefs = useRef<Array<HTMLAnchorElement | null>>([]);
92
+ const pendingFocus = useRef<Focus>('selected');
93
+ const typeahead = useRef({ text: '', timer: 0 });
94
+
95
+ const [mode, setMode] = useState<Mode>('popover');
96
+ const [placement, setPlacement] = useState<{ side: MenuSide; align: MenuAlign; shift: number; available: number | null }>({
97
+ side,
98
+ align,
99
+ shift: 0,
100
+ available: null,
101
+ });
102
+ const activeIndex = Math.max(0, locales.findIndex((locale) => locale.locale === active.locale));
103
+ const [rovingIndex, setRovingIndex] = useState(activeIndex);
104
+
105
+ const placed = open && mode === 'popover' ? placement : { side, align, shift: 0, available: null };
106
+
107
+ const close = useCallback(
108
+ (returnFocus: boolean) => {
109
+ setOpen(false);
110
+ if (returnFocus) triggerRef.current?.focus();
111
+ },
112
+ [setOpen],
113
+ );
114
+
115
+ const openMenu = useCallback(
116
+ (focus: Focus) => {
117
+ pendingFocus.current = focus;
118
+ setMode(isSheetViewport() ? 'sheet' : 'popover');
119
+ setRovingIndex(focus === 'last' ? locales.length - 1 : activeIndex);
120
+ setOpen(true);
121
+ },
122
+ [setOpen, locales.length, activeIndex],
123
+ );
124
+
125
+ const focusOption = useCallback(
126
+ (index: number) => {
127
+ const count = locales.length;
128
+ if (count === 0) return;
129
+ const next = (index + count) % count;
130
+ setRovingIndex(next);
131
+ optionRefs.current[next]?.focus();
132
+ },
133
+ [locales.length],
134
+ );
135
+
136
+ const select = useCallback(
137
+ (locale: StoreLocale) => {
138
+ close(true);
139
+ if (locale.locale !== active.locale) switchTo(locale);
140
+ },
141
+ [close, active.locale, switchTo],
142
+ );
143
+
144
+ // Focus moves into the list on open.
145
+ useEffect(() => {
146
+ if (!open) return;
147
+ const index = pendingFocus.current === 'last' ? locales.length - 1 : activeIndex;
148
+ const option = optionRefs.current[index];
149
+ option?.focus();
150
+ option?.scrollIntoView?.({ block: 'nearest' });
151
+ // Only when the menu opens.
152
+ // eslint-disable-next-line react-hooks/exhaustive-deps
153
+ }, [open]);
154
+
155
+ // Collision handling for the popover (plain measurements, no portal).
156
+ useLayoutEffect(() => {
157
+ if (!open || mode !== 'popover') return;
158
+ const root = rootRef.current;
159
+ const trigger = triggerRef.current;
160
+ const popover = popoverRef.current;
161
+ if (!root || !trigger || !popover) return;
162
+
163
+ const run = () => {
164
+ const t = trigger.getBoundingClientRect();
165
+ const p = popover.getBoundingClientRect();
166
+ const next = computePlacement({
167
+ trigger: { top: t.top, right: t.right, bottom: t.bottom, left: t.left },
168
+ popover: { width: p.width, height: p.height },
169
+ viewport: { width: window.innerWidth, height: window.innerHeight },
170
+ side,
171
+ align,
172
+ dir: isRtl(root) ? 'rtl' : 'ltr',
173
+ });
174
+ setPlacement((prev) =>
175
+ prev.side === next.side && prev.align === next.align && prev.shift === next.shift && prev.available === next.available
176
+ ? prev
177
+ : next,
178
+ );
179
+ };
180
+ run();
181
+ window.addEventListener('resize', run);
182
+ return () => window.removeEventListener('resize', run);
183
+ }, [open, mode, side, align, locales.length]);
184
+
185
+ // Follow the viewport crossing the breakpoint while open.
186
+ useEffect(() => {
187
+ if (!open || typeof window.matchMedia !== 'function') return;
188
+ const query = window.matchMedia(LANGUAGE_MENU_SHEET_QUERY);
189
+ const onChange = () => setMode(query.matches ? 'sheet' : 'popover');
190
+ query.addEventListener?.('change', onChange);
191
+ return () => query.removeEventListener?.('change', onChange);
192
+ }, [open]);
193
+
194
+ // A sheet locks page scroll behind it.
195
+ useEffect(() => {
196
+ if (!open || mode !== 'sheet') return;
197
+ document.body.classList.add(LOCK_CLASS);
198
+ return () => document.body.classList.remove(LOCK_CLASS);
199
+ }, [open, mode]);
200
+
201
+ // Outside tap or click closes without taking focus.
202
+ useEffect(() => {
203
+ if (!open) return;
204
+ const onPointerDown = (event: Event) => {
205
+ if (rootRef.current && event.target instanceof Node && !rootRef.current.contains(event.target)) close(false);
206
+ };
207
+ document.addEventListener('pointerdown', onPointerDown);
208
+ return () => document.removeEventListener('pointerdown', onPointerDown);
209
+ }, [open, close]);
210
+
211
+ useEffect(() => () => window.clearTimeout(typeahead.current.timer), []);
212
+
213
+ const onTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
214
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
215
+ event.preventDefault();
216
+ const focus: Focus = event.key === 'ArrowUp' ? 'last' : 'selected';
217
+ if (!open) return openMenu(focus);
218
+ focusOption(focus === 'last' ? locales.length - 1 : activeIndex);
219
+ };
220
+
221
+ const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
222
+ if (!open) return;
223
+ if (event.key === 'Escape') {
224
+ event.preventDefault();
225
+ return close(true);
226
+ }
227
+ if (event.key === 'Tab') return close(false);
228
+
229
+ const target = event.target instanceof Element ? event.target.closest('a') : null;
230
+ const index = target ? optionRefs.current.indexOf(target as HTMLAnchorElement) : -1;
231
+ if (index < 0) return;
232
+
233
+ switch (event.key) {
234
+ case 'ArrowDown':
235
+ event.preventDefault();
236
+ return focusOption(index + 1);
237
+ case 'ArrowUp':
238
+ event.preventDefault();
239
+ return focusOption(index - 1);
240
+ case 'Home':
241
+ event.preventDefault();
242
+ return focusOption(0);
243
+ case 'End':
244
+ event.preventDefault();
245
+ return focusOption(locales.length - 1);
246
+ case ' ':
247
+ event.preventDefault();
248
+ return select(locales[index]!);
249
+ default:
250
+ break;
251
+ }
252
+
253
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
254
+ const buffer = typeahead.current;
255
+ buffer.text += event.key.toLocaleLowerCase();
256
+ window.clearTimeout(buffer.timer);
257
+ buffer.timer = window.setTimeout(() => {
258
+ buffer.text = '';
259
+ }, TYPEAHEAD_RESET_MS);
260
+ const found = locales.findIndex(
261
+ (locale) =>
262
+ nameOf(locale).toLocaleLowerCase().startsWith(buffer.text) ||
263
+ locale.name.toLocaleLowerCase().startsWith(buffer.text) ||
264
+ locale.locale.toLocaleLowerCase().startsWith(buffer.text),
265
+ );
266
+ if (found >= 0) focusOption(found);
267
+ }
268
+ };
269
+
270
+ const dataState = open ? 'open' : 'closed';
271
+ const popoverStyle: Record<string, string> = {};
272
+ if (placed.shift) popoverStyle['--qn-lang-shift'] = `${placed.shift}px`;
273
+ if (placed.available !== null) popoverStyle['--qn-lang-avail'] = `${placed.available}px`;
274
+
275
+ return (
276
+ <div
277
+ ref={rootRef}
278
+ className={rootClass}
279
+ data-qn-language-switcher=""
280
+ data-variant="menu"
281
+ data-state={dataState}
282
+ data-side={placed.side}
283
+ data-align={placed.align}
284
+ data-mode={open ? mode : undefined}
285
+ data-count={locales.length}
286
+ data-many={locales.length > MANY_LANGUAGES ? 'true' : undefined}
287
+ data-show-code={showCode ? 'true' : undefined}
288
+ onKeyDown={onRootKeyDown}
289
+ >
290
+ <button
291
+ ref={triggerRef}
292
+ type="button"
293
+ className="qn-lang__trigger"
294
+ data-part="trigger"
295
+ aria-expanded={open}
296
+ aria-controls={popoverId}
297
+ aria-label={triggerLabelFor(active)}
298
+ onClick={() => (open ? close(false) : openMenu('selected'))}
299
+ onKeyDown={onTriggerKeyDown}
300
+ >
301
+ <span className="qn-lang__trigger-label" data-part="trigger-label">
302
+ {nameOf(active)}
303
+ </span>
304
+ <span className="qn-lang__trigger-code" data-part="trigger-code">
305
+ {codeOf(active)}
306
+ </span>
307
+ <svg className="qn-lang__chevron" data-part="chevron" aria-hidden="true" viewBox="0 0 10 10" focusable="false">
308
+ <path d="M1 3.5 5 7.5 9 3.5" />
309
+ </svg>
310
+ </button>
311
+ <div className="qn-lang__scrim" data-part="scrim" aria-hidden="true" hidden={!open} onClick={() => close(false)} />
312
+ <div
313
+ ref={popoverRef}
314
+ id={popoverId}
315
+ className="qn-lang__popover"
316
+ data-part="popover"
317
+ data-state={dataState}
318
+ hidden={!open}
319
+ style={popoverStyle}
320
+ >
321
+ <div className="qn-lang__handle" data-part="handle" aria-hidden="true" />
322
+ <div id={titleId} className="qn-lang__title" data-part="title">
323
+ {label}
324
+ </div>
325
+ <ul className="qn-lang__options" data-part="list" aria-labelledby={titleId}>
326
+ {locales.map((locale, index) => {
327
+ const current = locale.locale === active.locale;
328
+ return (
329
+ <li key={locale.locale} className="qn-lang__item" data-part="item">
330
+ <a
331
+ ref={(node) => {
332
+ optionRefs.current[index] = node;
333
+ }}
334
+ className="qn-lang__option"
335
+ data-part="option"
336
+ data-current={current ? 'true' : undefined}
337
+ href={hrefFor(locale)}
338
+ lang={locale.html_lang}
339
+ hrefLang={locale.hreflang}
340
+ aria-current={current ? 'true' : undefined}
341
+ tabIndex={index === rovingIndex ? 0 : -1}
342
+ onFocus={() => setRovingIndex(index)}
343
+ onClick={(event) => {
344
+ // Modified clicks keep the browser default (new tab, copy link).
345
+ if (event.defaultPrevented || !isPlainPrimaryClick(event)) return;
346
+ event.preventDefault();
347
+ select(locale);
348
+ }}
349
+ >
350
+ <span className="qn-lang__name" data-part="name">
351
+ <bdi lang={locale.html_lang} dir={locale.dir === 'rtl' ? 'rtl' : undefined}>
352
+ {nameOf(locale)}
353
+ </bdi>
354
+ </span>
355
+ <span className="qn-lang__code" data-part="code">
356
+ {codeOf(locale)}
357
+ </span>
358
+ <span className="qn-lang__mark" data-part="mark" aria-hidden="true">
359
+ <svg viewBox="0 0 16 16" focusable="false">
360
+ <path d="m3 8.5 3.2 3.2L13 5" />
361
+ </svg>
362
+ </span>
363
+ </a>
364
+ </li>
365
+ );
366
+ })}
367
+ </ul>
368
+ </div>
369
+ </div>
370
+ );
371
+ }
@@ -1,21 +1,30 @@
1
1
  'use client';
2
2
 
3
- import { useContext, type JSX, type MouseEvent, type ReactNode } from 'react';
3
+ import { useContext, useState, type JSX, type MouseEvent, type ReactNode } from 'react';
4
4
  import { StorefrontContext, useThemeStrings } from '../provider';
5
5
  import { Link } from '../navigation';
6
6
  import { useLocales, type LocalesState } from '../hooks/use-locales';
7
7
  import type { StoreLocale } from '../utils/locale-switch';
8
+ import type { MenuAlign, MenuSide } from '../utils/menu-placement';
9
+ import { LanguageMenu } from './language-menu';
8
10
  import './language-switcher.css';
9
11
 
12
+ /** Props for `LanguageSwitcher`. The README lists each prop with its default. */
10
13
  export interface LanguageSwitcherProps {
11
14
  /** Extra class on the root, to position or restyle the control. */
12
15
  className?: string;
13
16
  /**
14
- * `'select'` (default): one native dropdown — the best keyboard, touch and
15
- * screen-reader behaviour for free. `'inline-list'`: every language as a
16
- * link, wrapping onto new lines on narrow screens; suits a footer.
17
+ * `'menu'`: a trigger button and a list of language links — a popover on
18
+ * wide screens, a bottom sheet on narrow ones; fully restylable through
19
+ * documented parts and `--qn-lang-*` variables. `'select'` (default, legacy):
20
+ * one native dropdown. `'inline-list'`: every language as a link, wrapping
21
+ * onto new lines on narrow screens; suits a footer with up to four languages.
17
22
  */
18
- variant?: 'select' | 'inline-list';
23
+ variant?: 'select' | 'inline-list' | 'menu';
24
+ /** `menu` only: open below (`'bottom'`, default) or above (`'top'`, for footers) the trigger. Flips on its own when it does not fit. */
25
+ side?: MenuSide;
26
+ /** `menu` only: line the popover up with the trigger's inline `'end'` (default) or `'start'` edge. */
27
+ align?: MenuAlign;
19
28
  /** Append the language code, e.g. `Français · FR`. Default false. */
20
29
  showCode?: boolean;
21
30
  /** Label each language in its own language (`Français`) rather than English (`French`). Default true. */
@@ -32,6 +41,9 @@ export interface LanguageSwitcherProps {
32
41
  export interface LanguageSwitcherState extends LocalesState {
33
42
  /** "Language" — the group label. */
34
43
  label: string;
44
+ /** Whether the `menu` variant is open. A custom UI may drive it with `setOpen`. */
45
+ open: boolean;
46
+ setOpen: (open: boolean) => void;
35
47
  /** "Language: French" — names the control and the current language. */
36
48
  labelFor: (locale: StoreLocale) => string;
37
49
  /** The text to show for one language, honouring `showNativeName` / `showCode`. */
@@ -60,13 +72,16 @@ export function LanguageSwitcher(props: LanguageSwitcherProps): JSX.Element | nu
60
72
  function LanguageSwitcherInner({
61
73
  className,
62
74
  variant = 'select',
75
+ side = 'bottom',
76
+ align = 'end',
63
77
  showCode = false,
64
78
  showNativeName = true,
65
79
  children,
66
80
  }: LanguageSwitcherProps): JSX.Element | null {
67
81
  const t = useThemeStrings();
68
- const state = useLocales();
69
- const { locales, active, hasMultiple, hrefFor, switchTo } = state;
82
+ const base = useLocales();
83
+ const [open, setOpen] = useState(false);
84
+ const { locales, active, hasMultiple, hrefFor, switchTo } = base;
70
85
 
71
86
  if (!hasMultiple) return null;
72
87
 
@@ -77,10 +92,27 @@ function LanguageSwitcherInner({
77
92
  return showCode ? `${name} · ${locale.locale.toUpperCase()}` : name;
78
93
  };
79
94
 
80
- if (children) return <>{children({ ...state, label, labelFor, displayName })}</>;
95
+ const state: LanguageSwitcherState = { ...base, label, open, setOpen, labelFor, displayName };
96
+
97
+ if (children) return <>{children(state)}</>;
81
98
 
82
99
  const rootClass = ['qn-lang', `qn-lang--${variant}`, className].filter(Boolean).join(' ');
83
100
 
101
+ if (variant === 'menu') {
102
+ const nameOf = (locale: StoreLocale): string => (showNativeName ? locale.native_name : locale.name);
103
+ return (
104
+ <LanguageMenu
105
+ state={state}
106
+ rootClass={rootClass}
107
+ showCode={showCode}
108
+ side={side}
109
+ align={align}
110
+ nameOf={nameOf}
111
+ triggerLabelFor={(locale) => t('language.named', { name: nameOf(locale) })}
112
+ />
113
+ );
114
+ }
115
+
84
116
  if (variant === 'inline-list') {
85
117
  return (
86
118
  <nav className={rootClass} aria-label={label} data-qn-language-switcher="" data-variant="inline-list">
@@ -4,6 +4,7 @@ import { useContext, type JSX } from 'react';
4
4
  import { StorefrontContext } from '../provider';
5
5
  import { poweredByQueekUrl } from '../utils/powered-by';
6
6
 
7
+ /** Props for {@link PoweredByQueek}. */
7
8
  export interface PoweredByQueekProps {
8
9
  /** Extra class so a footer can position the link — styling stays theme-owned. */
9
10
  className?: string;
@@ -15,11 +16,11 @@ export interface PoweredByQueekProps {
15
16
  }
16
17
 
17
18
  /**
18
- * Core-owned storefront attribution — the `ProductMetafields` pattern: themes
19
- * import-and-place it, never re-implement it.
19
+ * The storefront attribution link, owned by the kit: themes import and place
20
+ * it, and never re-implement it.
20
21
  *
21
22
  * Required on every footer variant of every theme (enforced by the
22
- * `theme/footer-shows-powered-by` rule of the theme check). The link
23
+ * `theme/footer-shows-powered-by` rule of `npm run check`). The link
23
24
  * target (`https://usequeek.com/business`) and `rel` are the contract —
24
25
  * do not change them. The href is tagged `utm_source=powered_by`,
25
26
  * `utm_medium=storefront`, `utm_campaign=<store slug>` via `poweredByQueekUrl`
@@ -19,6 +19,11 @@ interface ProductMediaFrameProps {
19
19
  intent?: ImageIntent;
20
20
  }
21
21
 
22
+ /**
23
+ * One frame of a product's media gallery: an image through `Image`, or a video.
24
+ * Pass a frame from `buildMediaFrames(product)`. `variant="thumbnail"` renders a
25
+ * muted video with no controls, so the browser shows its first frame.
26
+ */
22
27
  export function ProductMediaFrame({
23
28
  frame,
24
29
  className,
@@ -7,10 +7,12 @@ import type {
7
7
  } from '../types/product';
8
8
  import { formatDisplayDate } from '../utils/format';
9
9
 
10
+ /** Merchant-set labels and types for custom fields, keyed by field key. Passed as `metafieldDefinitions` on the product page. */
10
11
  export interface ProductMetafieldDefinitionLabels {
11
12
  [key: string]: { name: string; type: string };
12
13
  }
13
14
 
15
+ /** Props for {@link ProductMetafields}. */
14
16
  export interface ProductMetafieldsProps {
15
17
  product: Product;
16
18
  definitions?: ProductMetafieldDefinitionLabels;
@@ -205,6 +207,13 @@ export function renderValue(value: MetafieldValue, fieldKey: string, label: stri
205
207
  return renderScalar(value as MetafieldScalar, fieldKey, label, type);
206
208
  }
207
209
 
210
+ /**
211
+ * Place this on the product page to show the product's custom fields.
212
+ * Renders nothing when there are none to show.
213
+ *
214
+ * @example
215
+ * <ProductMetafields product={product} definitions={metafieldDefinitions} />
216
+ */
208
217
  export function ProductMetafields({ product, definitions }: ProductMetafieldsProps): JSX.Element | null {
209
218
  const metafields = product.metafields && typeof product.metafields === 'object' ? product.metafields : {};
210
219
  const visible = Object.entries(metafields).filter(([, value]) => isVisibleValue(value as MetafieldValue));
@@ -1,11 +1,8 @@
1
1
  /* ============================================================
2
- VariantPicker — shared option-selection UI (QR ordering sheet,
3
- default theme's quick-view modal). Prefix: qk-variant-.
4
- Reads --brand-* directly (not a context-scoped token set like
5
- --qr- or --qka-) so it renders correctly regardless of which
6
- surface mounts it. Mirrors quick-add-page.css's qka-option/qka-swatch
7
- rules — same visual language, kept as a separate small file since
8
- this component is reused outside that page's scope.
2
+ VariantPicker: shared option-selection UI (in-store ordering sheet,
3
+ quick-view modal). Class prefix: qk-variant-.
4
+ Reads --brand-* directly, not a surface-scoped token set, so it renders
5
+ correctly whichever surface mounts it.
9
6
  ============================================================ */
10
7
 
11
8
  .qk-variant-option {
package/edit-mode.tsx CHANGED
@@ -6,9 +6,10 @@ import dynamic from 'next/dynamic';
6
6
 
7
7
  const QK_EDIT_FLAG = 'edit';
8
8
 
9
- // Lightweight: reads `?edit=1` once on mount. Always shipped to the
10
- // storefront bundle, but cheap (one URLSearchParams parse) and adds no
11
- // runtime overhead for public visitors.
9
+ /**
10
+ * True when the page was opened by the merchant editor (`?edit=1`). Read once
11
+ * on mount. Cheap enough to ship to every visitor: one query-string parse.
12
+ */
12
13
  export function useEditMode(): boolean {
13
14
  const [isEdit, setIsEdit] = useState(false);
14
15
 
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react';
4
4
  import { useStorefront } from '../provider';
5
5
  import { getQueekClient } from '../sdk/queek-client';
6
6
 
7
+ /** One address suggestion. Pass `place_id` to `useResolveCoordinates` to get its position. */
7
8
  export interface AddressSuggestion {
8
9
  description: string;
9
10
  place_id: string;
@@ -11,6 +12,14 @@ export interface AddressSuggestion {
11
12
 
12
13
  const DEBOUNCE_MS = 300;
13
14
 
15
+ /**
16
+ * Address suggestions for what the shopper has typed, after a 300 ms pause.
17
+ * Nothing is requested for fewer than two characters. A failed request clears
18
+ * the suggestions rather than throwing, so the field stays usable.
19
+ *
20
+ * @example
21
+ * const { suggestions, loading } = useAddressAutocomplete(text);
22
+ */
14
23
  export function useAddressAutocomplete(query: string): {
15
24
  suggestions: AddressSuggestion[];
16
25
  loading: boolean;
@@ -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
- const text = await res.text().catch(() => '');
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
- // eslint-disable-next-line no-console
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
- // eslint-disable-next-line no-console
275
- console.error('[google-auth] initiate failed', err);
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('useAuthFlow must be used within an AuthFlowProvider');
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),