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