@usequeek/theme-kit 0.1.20 → 0.1.22
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/README.md +264 -77
- package/api/collections.ts +5 -5
- package/api/shop.ts +1 -1
- package/client.ts +2 -0
- package/components/auth/auth-email-login-step.tsx +9 -7
- package/components/auth/auth-email-otp-step.tsx +9 -7
- package/components/auth/auth-email-otp-verify-step.tsx +8 -6
- package/components/auth/auth-email-register-step.tsx +10 -8
- package/components/auth/auth-signup-step.tsx +8 -6
- package/components/blog/category-filter.tsx +6 -5
- package/components/blog/pagination.tsx +8 -7
- package/components/blog/post-meta.tsx +15 -8
- package/components/blog/related-posts.tsx +5 -3
- package/components/blog/share-buttons.tsx +10 -8
- package/components/checkout/checkout-contact.tsx +9 -7
- package/components/checkout/default-checkout-shell.tsx +17 -15
- package/components/checkout/policy-links.tsx +5 -3
- package/components/image.tsx +5 -5
- package/components/language-switcher.css +131 -0
- package/components/language-switcher.tsx +144 -0
- package/components/markdown.tsx +1 -1
- package/components/powered-by-queek.tsx +4 -4
- package/components/product-media-frame.tsx +1 -1
- package/components/product-metafields.tsx +2 -3
- package/components/variant-picker.tsx +6 -9
- package/demo.ts +10 -14
- package/design-token-preview.tsx +0 -2
- package/edit-mode-active.tsx +3 -4
- package/hooks/use-announcement.ts +1 -1
- package/hooks/use-auth-flow.tsx +19 -18
- package/hooks/use-locales.ts +107 -0
- package/hooks/use-posts.ts +1 -1
- package/hooks/use-product-detail.ts +1 -2
- package/hooks/use-product-sections.ts +4 -6
- package/hooks/use-products.ts +5 -8
- package/index.ts +16 -15
- package/locales/en.default.json +49 -7
- package/next-reference.d.ts +2 -4
- package/package.json +3 -2
- package/page-renderer.tsx +5 -6
- package/provider.tsx +22 -2
- package/scripts/verify-package.ts +6 -8
- package/sdk/client.ts +13 -12
- package/sdk/error-handler.ts +1 -1
- package/sdk/platform.ts +6 -8
- package/sdk/queek-client.ts +11 -12
- package/sdk/server-store-client.ts +13 -17
- package/shared-blocks/metaobjects.tsx +1 -1
- package/stores/cart-store.ts +2 -2
- package/stores/checkout-store.ts +1 -1
- package/stores/edit-preview-store.ts +5 -6
- package/stores/user-store.ts +4 -5
- package/strings/pseudo-locale.ts +263 -0
- package/strings/theme-strings.ts +185 -20
- package/tests/auth-email-errors.test.tsx +55 -0
- package/tests/currency-contract.test.ts +2 -3
- package/tests/display-format.test.ts +356 -2
- package/tests/fixtures/external-theme.tsx +18 -2
- package/tests/language-switcher.test.tsx +350 -0
- package/tests/list-pagination.test.tsx +8 -8
- package/tests/locale-switch.test.ts +197 -0
- package/tests/locale-transport.test.tsx +3 -3
- package/tests/markdown-render.test.ts +5 -6
- package/tests/orders-walk.test.ts +5 -5
- package/tests/page-renderer-signature.test.tsx +1 -1
- package/tests/powered-by-queek.test.tsx +3 -3
- package/tests/powered-by-url.test.ts +2 -2
- package/tests/public-surface.test.ts +1 -4
- package/tests/shopper-chrome-strings.test.tsx +271 -0
- package/tests/storefront-client-scope.test.ts +5 -5
- package/tests/theme-hook-placement.test.tsx +179 -0
- package/tests/theme-strings-client.test.tsx +5 -5
- package/tests/theme-strings-pseudo.test.tsx +171 -0
- package/tests/theme-strings-typed.test.tsx +97 -0
- package/tests/theme-strings.test.tsx +10 -7
- package/tests/theme-typed-keys.fixture.ts +76 -0
- package/tests/use-shop-prefetch.test.tsx +1 -1
- package/theme-mount.tsx +6 -11
- package/types/block.ts +7 -7
- package/types/media.ts +8 -9
- package/types/product.ts +8 -12
- package/types/theme.ts +26 -25
- package/types/vendor.ts +10 -12
- package/utils/announcement.ts +3 -3
- package/utils/benefit-icon.tsx +9 -11
- package/utils/brand.ts +7 -10
- package/utils/format.ts +100 -14
- package/utils/image-srcset.ts +2 -2
- package/utils/locale-switch.ts +208 -0
- package/utils/locale.ts +56 -5
- package/utils/menu-link.ts +1 -1
- package/utils/product-media.ts +1 -1
- package/utils/product-normalizer.ts +2 -2
- package/utils/vendor-host.ts +7 -11
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
LanguageSwitcher — the kit's language control. Prefix: qn-lang.
|
|
3
|
+
Colours, radius and type come from the --qn-* tokens, each with a
|
|
4
|
+
neutral fallback, so it sits in a header or a footer without theme
|
|
5
|
+
CSS. Logical properties only: it mirrors itself in RTL storefronts.
|
|
6
|
+
============================================================ */
|
|
7
|
+
|
|
8
|
+
.qn-lang {
|
|
9
|
+
display: inline-flex;
|
|
10
|
+
align-items: center;
|
|
11
|
+
gap: 8px;
|
|
12
|
+
max-inline-size: 100%;
|
|
13
|
+
color: var(--qn-text, inherit);
|
|
14
|
+
font: inherit;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.qn-lang__icon {
|
|
18
|
+
inline-size: 18px;
|
|
19
|
+
block-size: 18px;
|
|
20
|
+
flex: none;
|
|
21
|
+
fill: none;
|
|
22
|
+
stroke: currentColor;
|
|
23
|
+
stroke-width: 1.6;
|
|
24
|
+
opacity: 0.75;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* ── select ─────────────────────────────────────────────── */
|
|
28
|
+
|
|
29
|
+
.qn-lang__field {
|
|
30
|
+
position: relative;
|
|
31
|
+
display: inline-flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
min-inline-size: 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.qn-lang__select {
|
|
37
|
+
appearance: none;
|
|
38
|
+
min-inline-size: 44px;
|
|
39
|
+
min-block-size: 44px;
|
|
40
|
+
max-inline-size: min(100%, 16rem);
|
|
41
|
+
padding-block: 8px;
|
|
42
|
+
padding-inline: 12px 34px;
|
|
43
|
+
border: 1px solid var(--qn-border, color-mix(in srgb, currentColor 22%, transparent));
|
|
44
|
+
border-radius: var(--qn-radius-sm, 6px);
|
|
45
|
+
background-color: var(--qn-surface, transparent);
|
|
46
|
+
color: inherit;
|
|
47
|
+
font: inherit;
|
|
48
|
+
line-height: 1.25;
|
|
49
|
+
text-overflow: ellipsis;
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* The chevron is an element, not a background, so it follows the inline-end
|
|
54
|
+
edge in both directions. */
|
|
55
|
+
.qn-lang__chevron {
|
|
56
|
+
position: absolute;
|
|
57
|
+
inset-inline-end: 12px;
|
|
58
|
+
inline-size: 10px;
|
|
59
|
+
block-size: 10px;
|
|
60
|
+
fill: none;
|
|
61
|
+
stroke: currentColor;
|
|
62
|
+
stroke-width: 1.8;
|
|
63
|
+
pointer-events: none;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.qn-lang__select:hover {
|
|
67
|
+
border-color: var(--qn-border-strong, currentColor);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.qn-lang__select:focus-visible {
|
|
71
|
+
outline: 2px solid var(--qn-focus, currentColor);
|
|
72
|
+
outline-offset: 2px;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.qn-lang__select option {
|
|
76
|
+
color: initial;
|
|
77
|
+
background: initial;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/* ── inline-list ────────────────────────────────────────── */
|
|
81
|
+
|
|
82
|
+
.qn-lang--inline-list {
|
|
83
|
+
display: block;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.qn-lang__list {
|
|
87
|
+
display: flex;
|
|
88
|
+
flex-wrap: wrap;
|
|
89
|
+
align-items: center;
|
|
90
|
+
gap: 4px 8px;
|
|
91
|
+
margin: 0;
|
|
92
|
+
padding: 0;
|
|
93
|
+
list-style: none;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.qn-lang__link {
|
|
97
|
+
display: inline-flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
min-inline-size: 44px;
|
|
100
|
+
min-block-size: 44px;
|
|
101
|
+
padding-inline: 10px;
|
|
102
|
+
border-radius: var(--qn-radius-sm, 6px);
|
|
103
|
+
color: inherit;
|
|
104
|
+
font: inherit;
|
|
105
|
+
text-align: center;
|
|
106
|
+
text-decoration: none;
|
|
107
|
+
justify-content: center;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.qn-lang__link:hover {
|
|
111
|
+
text-decoration: underline;
|
|
112
|
+
text-underline-offset: 3px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.qn-lang__link[aria-current='true'] {
|
|
116
|
+
font-weight: 600;
|
|
117
|
+
text-decoration: underline;
|
|
118
|
+
text-underline-offset: 3px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.qn-lang__link:focus-visible {
|
|
122
|
+
outline: 2px solid var(--qn-focus, currentColor);
|
|
123
|
+
outline-offset: 2px;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
127
|
+
.qn-lang__select,
|
|
128
|
+
.qn-lang__link {
|
|
129
|
+
transition: border-color 0.15s ease;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useContext, type JSX, type MouseEvent, type ReactNode } from 'react';
|
|
4
|
+
import { StorefrontContext, useThemeStrings } from '../provider';
|
|
5
|
+
import { Link } from '../navigation';
|
|
6
|
+
import { useLocales, type LocalesState } from '../hooks/use-locales';
|
|
7
|
+
import type { StoreLocale } from '../utils/locale-switch';
|
|
8
|
+
import './language-switcher.css';
|
|
9
|
+
|
|
10
|
+
export interface LanguageSwitcherProps {
|
|
11
|
+
/** Extra class on the root, to position or restyle the control. */
|
|
12
|
+
className?: string;
|
|
13
|
+
/**
|
|
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
|
+
*/
|
|
18
|
+
variant?: 'select' | 'inline-list';
|
|
19
|
+
/** Append the language code, e.g. `Français · FR`. Default false. */
|
|
20
|
+
showCode?: boolean;
|
|
21
|
+
/** Label each language in its own language (`Français`) rather than English (`French`). Default true. */
|
|
22
|
+
showNativeName?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Take over the presentation. Called with the languages, the active one
|
|
25
|
+
* and `hrefFor` / `switchTo`; whatever it returns renders instead of the
|
|
26
|
+
* default control. Still renders nothing on a single-language store.
|
|
27
|
+
*/
|
|
28
|
+
children?: (state: LanguageSwitcherState) => ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What the render-prop receives: the `useLocales()` state plus the translated labels. */
|
|
32
|
+
export interface LanguageSwitcherState extends LocalesState {
|
|
33
|
+
/** "Language" — the group label. */
|
|
34
|
+
label: string;
|
|
35
|
+
/** "Language: French" — names the control and the current language. */
|
|
36
|
+
labelFor: (locale: StoreLocale) => string;
|
|
37
|
+
/** The text to show for one language, honouring `showNativeName` / `showCode`. */
|
|
38
|
+
displayName: (locale: StoreLocale) => string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The storefront's language control, placed by the theme where it wants it
|
|
43
|
+
* (a header, a footer, a menu). Renders nothing outside a storefront or when
|
|
44
|
+
* the store has a single language, so it is safe to place unconditionally.
|
|
45
|
+
*
|
|
46
|
+
* Switching keeps the page, the query string and the hash. Labels come from
|
|
47
|
+
* the theme strings (`language.label`, `language.named`) with English
|
|
48
|
+
* defaults. Colours and radius come from the `--qn-*` tokens; the layout is
|
|
49
|
+
* direction-agnostic, so it mirrors in right-to-left storefronts.
|
|
50
|
+
*
|
|
51
|
+
* A theme that places this sets `languageSwitcher: 'theme'` in its manifest,
|
|
52
|
+
* so the storefront does not also render its own floating control.
|
|
53
|
+
*/
|
|
54
|
+
export function LanguageSwitcher(props: LanguageSwitcherProps): JSX.Element | null {
|
|
55
|
+
const inStorefront = useContext(StorefrontContext) !== null;
|
|
56
|
+
if (!inStorefront) return null;
|
|
57
|
+
return <LanguageSwitcherInner {...props} />;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function LanguageSwitcherInner({
|
|
61
|
+
className,
|
|
62
|
+
variant = 'select',
|
|
63
|
+
showCode = false,
|
|
64
|
+
showNativeName = true,
|
|
65
|
+
children,
|
|
66
|
+
}: LanguageSwitcherProps): JSX.Element | null {
|
|
67
|
+
const t = useThemeStrings();
|
|
68
|
+
const state = useLocales();
|
|
69
|
+
const { locales, active, hasMultiple, hrefFor, switchTo } = state;
|
|
70
|
+
|
|
71
|
+
if (!hasMultiple) return null;
|
|
72
|
+
|
|
73
|
+
const label = t('language.label');
|
|
74
|
+
const labelFor = (locale: StoreLocale): string => t('language.named', { name: locale.name });
|
|
75
|
+
const displayName = (locale: StoreLocale): string => {
|
|
76
|
+
const name = showNativeName ? locale.native_name : locale.name;
|
|
77
|
+
return showCode ? `${name} · ${locale.locale.toUpperCase()}` : name;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (children) return <>{children({ ...state, label, labelFor, displayName })}</>;
|
|
81
|
+
|
|
82
|
+
const rootClass = ['qn-lang', `qn-lang--${variant}`, className].filter(Boolean).join(' ');
|
|
83
|
+
|
|
84
|
+
if (variant === 'inline-list') {
|
|
85
|
+
return (
|
|
86
|
+
<nav className={rootClass} aria-label={label} data-qn-language-switcher="" data-variant="inline-list">
|
|
87
|
+
<ul className="qn-lang__list">
|
|
88
|
+
{locales.map((locale) => {
|
|
89
|
+
const isActive = locale.locale === active.locale;
|
|
90
|
+
return (
|
|
91
|
+
<li key={locale.locale}>
|
|
92
|
+
<Link
|
|
93
|
+
className="qn-lang__link"
|
|
94
|
+
href={hrefFor(locale)}
|
|
95
|
+
prefetch={false}
|
|
96
|
+
lang={locale.html_lang}
|
|
97
|
+
hrefLang={locale.hreflang}
|
|
98
|
+
aria-current={isActive ? 'true' : undefined}
|
|
99
|
+
aria-label={isActive ? labelFor(locale) : undefined}
|
|
100
|
+
onClick={(event: MouseEvent<HTMLAnchorElement>) => {
|
|
101
|
+
// Plain clicks go through `switchTo`, which reads the live
|
|
102
|
+
// query string; modified clicks keep the browser default.
|
|
103
|
+
if (event.defaultPrevented || event.button !== 0) return;
|
|
104
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
switchTo(locale);
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
{displayName(locale)}
|
|
110
|
+
</Link>
|
|
111
|
+
</li>
|
|
112
|
+
);
|
|
113
|
+
})}
|
|
114
|
+
</ul>
|
|
115
|
+
</nav>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div className={rootClass} role="group" aria-label={label} data-qn-language-switcher="" data-variant="select">
|
|
121
|
+
<svg className="qn-lang__icon" aria-hidden="true" viewBox="0 0 24 24" focusable="false">
|
|
122
|
+
<circle cx="12" cy="12" r="9" />
|
|
123
|
+
<path d="M3.5 12h17M12 3c2.3 2.4 3.5 5.4 3.5 9s-1.2 6.6-3.5 9c-2.3-2.4-3.5-5.4-3.5-9S9.7 5.4 12 3Z" />
|
|
124
|
+
</svg>
|
|
125
|
+
<span className="qn-lang__field">
|
|
126
|
+
<select
|
|
127
|
+
className="qn-lang__select"
|
|
128
|
+
aria-label={labelFor(active)}
|
|
129
|
+
value={active.locale}
|
|
130
|
+
onChange={(event) => switchTo(event.target.value)}
|
|
131
|
+
>
|
|
132
|
+
{locales.map((locale) => (
|
|
133
|
+
<option key={locale.locale} value={locale.locale} lang={locale.html_lang} dir={locale.dir}>
|
|
134
|
+
{displayName(locale)}
|
|
135
|
+
</option>
|
|
136
|
+
))}
|
|
137
|
+
</select>
|
|
138
|
+
<svg className="qn-lang__chevron" aria-hidden="true" viewBox="0 0 10 10" focusable="false">
|
|
139
|
+
<path d="M1 3.5 5 7.5 9 3.5" />
|
|
140
|
+
</svg>
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
package/components/markdown.tsx
CHANGED
|
@@ -16,7 +16,7 @@ import { Image } from './image';
|
|
|
16
16
|
* Inline markdown images are routed through the core <Image> component so the
|
|
17
17
|
* Smart Placeholder / fallback system applies (never a bare, unoptimized
|
|
18
18
|
* <img>). Short-form snippets (footers, product blurbs) keep using the
|
|
19
|
-
* lightweight `renderMarkdown` helper in `
|
|
19
|
+
* lightweight `renderMarkdown` helper in `utils/markdown`.
|
|
20
20
|
*/
|
|
21
21
|
function MarkdownImage({ src, alt }: ComponentPropsWithoutRef<'img'>): JSX.Element | null {
|
|
22
22
|
if (!src || typeof src !== 'string') {
|
|
@@ -8,8 +8,8 @@ export interface PoweredByQueekProps {
|
|
|
8
8
|
/** Extra class so a footer can position the link — styling stays theme-owned. */
|
|
9
9
|
className?: string;
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Lets a caller hide the link from a prop. Defaults to false; themes always
|
|
12
|
+
* render it visible — there is no merchant-facing toggle for it.
|
|
13
13
|
*/
|
|
14
14
|
hidden?: boolean;
|
|
15
15
|
}
|
|
@@ -18,8 +18,8 @@ export interface PoweredByQueekProps {
|
|
|
18
18
|
* Core-owned storefront attribution — the `ProductMetafields` pattern: themes
|
|
19
19
|
* import-and-place it, never re-implement it.
|
|
20
20
|
*
|
|
21
|
-
* Required on every footer variant of every theme (enforced by
|
|
22
|
-
*
|
|
21
|
+
* 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
23
|
* target (`https://usequeek.com/business`) and `rel` are the contract —
|
|
24
24
|
* do not change them. The href is tagged `utm_source=powered_by`,
|
|
25
25
|
* `utm_medium=storefront`, `utm_campaign=<store slug>` via `poweredByQueekUrl`
|
|
@@ -41,7 +41,7 @@ export function ProductMediaFrame({
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
// Thumbnail strips render at ~64-90px across every theme — the `thumb` tier
|
|
44
|
-
// (a fixed-src
|
|
44
|
+
// (a fixed-src tier that is never part of a srcset) is exactly right there.
|
|
45
45
|
const resolvedIntent: ImageIntent = intent ?? (variant === 'thumbnail' ? 'avatar' : 'hero');
|
|
46
46
|
|
|
47
47
|
return (
|
|
@@ -23,8 +23,7 @@ export interface ProductMetafieldsProps {
|
|
|
23
23
|
* Renders the product's presented metafields (all types), with linked
|
|
24
24
|
* metaobject entries as cards. Renders nothing when there are no visible
|
|
25
25
|
* values. Markup carries `core-metafields*` classes so each theme styles it
|
|
26
|
-
* in its own `theme.css`
|
|
27
|
-
* lives in core.
|
|
26
|
+
* in its own `theme.css` — no theme CSS ships in the kit.
|
|
28
27
|
*/
|
|
29
28
|
|
|
30
29
|
function isEntry(value: unknown): value is MetaobjectEntry {
|
|
@@ -95,7 +94,7 @@ function renderScalar(value: MetafieldScalar, key: string, label: string, type:
|
|
|
95
94
|
|
|
96
95
|
if (type === 'media_id') {
|
|
97
96
|
return /^https?:\/\//i.test(text) ? (
|
|
98
|
-
// Media values arrive as resolved URLs
|
|
97
|
+
// Media values arrive as resolved URLs — plain <img>:
|
|
99
98
|
// this is core, not a theme, so the themes' <Image> rule doesn't apply.
|
|
100
99
|
<img className="core-metafields__image" src={text} alt={label} loading="lazy" />
|
|
101
100
|
) : (
|
|
@@ -14,17 +14,14 @@ interface VariantPickerProps {
|
|
|
14
14
|
/**
|
|
15
15
|
* Renders a product's option groups as color swatch / image swatch / text
|
|
16
16
|
* buttons — standard e-commerce priority: color_code first, else a
|
|
17
|
-
* representative image, else plain text.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* instead of a third reimplementation. Pair with `matchVariant()`
|
|
22
|
-
* (lib/core/utils/match-variant.ts) to resolve `selectedOptions` into a real
|
|
17
|
+
* representative image, else plain text. Shared by every surface that needs
|
|
18
|
+
* an option picker (quick-add, the QR ordering sheet, a theme's quick-view
|
|
19
|
+
* modal) instead of each re-implementing it. Pair with `matchVariant()`
|
|
20
|
+
* (utils/match-variant.ts) to resolve `selectedOptions` into a real
|
|
23
21
|
* `ProductVariant`, and gate the add action on that being non-null before
|
|
24
22
|
* calling `addProduct`/`addItemWithAddons` — a variant-enabled product added
|
|
25
|
-
* without one fails the cart save server-side
|
|
26
|
-
*
|
|
27
|
-
* is no valid no-selection state to fall back to.
|
|
23
|
+
* without one fails the cart save server-side rather than merely being "no
|
|
24
|
+
* variant"; there is no valid no-selection state to fall back to.
|
|
28
25
|
*/
|
|
29
26
|
export function VariantPicker({ product, selectedOptions, onSelect, className }: VariantPickerProps): JSX.Element | null {
|
|
30
27
|
if (product.options.length === 0) return null;
|
package/demo.ts
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Turns a theme's `demo.json` into the props its components expect.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* storefront's own preview routes, so they existed only here — a theme could be
|
|
9
|
-
* written outside this repo and never shown running against its own data.
|
|
4
|
+
* Shipped with the kit so a theme developer can render the demo data every
|
|
5
|
+
* theme carries in its `demo.json`: these are the functions that resolve its
|
|
6
|
+
* block references into real products, categories, posts and collections, so a
|
|
7
|
+
* theme can be previewed running against its own data.
|
|
10
8
|
*
|
|
11
|
-
* Pure and type-only by construction: no `fs`, no `next`, no app imports
|
|
12
|
-
*
|
|
13
|
-
* work identically in a developer's preview and in this repo's preview routes.
|
|
9
|
+
* Pure and type-only by construction: no `fs`, no `next`, no app imports, so
|
|
10
|
+
* these work identically in any preview.
|
|
14
11
|
*/
|
|
15
12
|
import type { Block, ReviewItem } from './types/block';
|
|
16
13
|
import type { Menu } from './types/menu';
|
|
@@ -81,11 +78,10 @@ export function getEnrichedPage<T extends Page>(page: T, demo: PreviewDemoData):
|
|
|
81
78
|
}
|
|
82
79
|
|
|
83
80
|
/**
|
|
84
|
-
* `demo.json`'s `posts` array
|
|
85
|
-
* `
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* assuming every theme's data is current.
|
|
81
|
+
* `demo.json`'s `posts` array must mirror the shape of a store's blog posts
|
|
82
|
+
* (`content: Block[]`, `cover_image_url`). Some themes authored it before that
|
|
83
|
+
* convention settled, so this tolerates the older shapes instead of assuming
|
|
84
|
+
* every theme's data is current.
|
|
89
85
|
*/
|
|
90
86
|
function normalizePost(raw: Record<string, unknown>): Post {
|
|
91
87
|
const legacyMedia = raw.media as { image?: string; thumbnail?: string } | undefined;
|
package/design-token-preview.tsx
CHANGED
|
@@ -58,8 +58,6 @@ export function attachDesignTokenPreviewListener(target: StylableTarget): () =>
|
|
|
58
58
|
* onto the brand-root element's inline style, overlaying (not replacing)
|
|
59
59
|
* the SSR vars from `getBrandCssVariables`. Never persisted — a normal
|
|
60
60
|
* navigation/refresh reloads the real config.
|
|
61
|
-
*
|
|
62
|
-
* Frozen protocol: queek_backend `.agent/.tmp/live-preview-protocol.md`.
|
|
63
61
|
*/
|
|
64
62
|
export function DesignTokenPreviewListener({
|
|
65
63
|
targetRef,
|
package/edit-mode-active.tsx
CHANGED
|
@@ -11,9 +11,8 @@ const MESSAGE_SOURCE = 'queek-storefront';
|
|
|
11
11
|
const MESSAGE_SOURCE_PARENT = 'queek-merchant';
|
|
12
12
|
|
|
13
13
|
// Longest edge of the captured JPEG, in CSS px. Keeps the resulting
|
|
14
|
-
// data-URI small (~50-150KB) so it can ride
|
|
15
|
-
//
|
|
16
|
-
// instead of a data-URI once resume-persistence of attachments is needed.
|
|
14
|
+
// data-URI small (~50-150KB) so it can ride a Qee chat request body inline
|
|
15
|
+
// instead of being uploaded and referenced by URL.
|
|
17
16
|
const CAPTURE_MAX_EDGE = 1000;
|
|
18
17
|
|
|
19
18
|
async function captureBlock(el: HTMLElement): Promise<string> {
|
|
@@ -146,7 +145,7 @@ export function ActiveBlock({
|
|
|
146
145
|
|
|
147
146
|
// Screenshot THIS section locally (edit mode only — the whole component tree
|
|
148
147
|
// only renders inside the editor iframe) and hand the JPEG data-URI to the
|
|
149
|
-
// merchant, which
|
|
148
|
+
// merchant, which attaches it to a Qee chat message.
|
|
150
149
|
const captureToQee = async (): Promise<void> => {
|
|
151
150
|
const el = elRef.current;
|
|
152
151
|
if (!el || capturing) return;
|
|
@@ -8,7 +8,7 @@ const ROTATE_EVERY_MS = 6000;
|
|
|
8
8
|
* Turns the stored announcement into the rotating one every header receives:
|
|
9
9
|
* `text` is the message currently showing (so a header that only ever read
|
|
10
10
|
* `text` rotates with no change), `messages`/`index`/`next`/`prev` let a
|
|
11
|
-
* header draw its own pager
|
|
11
|
+
* header draw its own pager. Auto-advances while there is more than
|
|
12
12
|
* one message; a manual step restarts the clock so the message just chosen
|
|
13
13
|
* gets its full turn.
|
|
14
14
|
*/
|
package/hooks/use-auth-flow.tsx
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import type { JSX } from 'react';
|
|
4
4
|
import { createContext, useContext, useState, type ReactNode } from 'react';
|
|
5
5
|
import { QueekSdkError, type EmailVerifyOtpResponse } from '@queekai/client-sdk';
|
|
6
|
-
import { useStorefront } from '../provider';
|
|
6
|
+
import { useStorefront, useThemeStrings } from '../provider';
|
|
7
7
|
import { getQueekClient, setAuthTokens, QUEEK_AUTH_URL } from '../sdk/queek-client';
|
|
8
8
|
import { useAuthModalStore } from '../stores/auth-modal-store';
|
|
9
9
|
import { useUserStore } from '../stores/user-store';
|
|
@@ -63,32 +63,33 @@ interface PasswordAuthData {
|
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
function mapEmailError(err: unknown): string {
|
|
66
|
+
export function mapEmailError(err: unknown, t: (key: string) => string): string {
|
|
67
67
|
if (err instanceof QueekSdkError) {
|
|
68
68
|
switch (err.code) {
|
|
69
69
|
case 'invalid_credentials':
|
|
70
|
-
return '
|
|
70
|
+
return t('auth.error.credentials');
|
|
71
71
|
case 'login_mode_mismatch':
|
|
72
|
-
return '
|
|
72
|
+
return t('auth.error.method');
|
|
73
73
|
case 'account_already_exists':
|
|
74
|
-
return '
|
|
74
|
+
return t('auth.error.exists');
|
|
75
75
|
case 'phone_already_exists':
|
|
76
|
-
return '
|
|
76
|
+
return t('auth.error.phone');
|
|
77
77
|
case 'otp_resend_too_soon':
|
|
78
|
-
return '
|
|
78
|
+
return t('auth.error.resend');
|
|
79
79
|
case 'invalid_otp':
|
|
80
|
-
return '
|
|
80
|
+
return t('auth.error.code');
|
|
81
81
|
default:
|
|
82
|
-
return err.message || '
|
|
82
|
+
return err.message || t('auth.error.generic');
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
-
return '
|
|
85
|
+
return t('auth.error.generic');
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
const AuthFlowContext = createContext<AuthFlowValue | null>(null);
|
|
89
89
|
|
|
90
90
|
export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Element {
|
|
91
91
|
const { vendor } = useStorefront();
|
|
92
|
+
const t = useThemeStrings();
|
|
92
93
|
const closeModal = useAuthModalStore((s) => s.close);
|
|
93
94
|
const setUser = useUserStore((s) => s.setUser);
|
|
94
95
|
|
|
@@ -131,7 +132,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
|
|
|
131
132
|
setOtp('');
|
|
132
133
|
setStep('email-otp-verify');
|
|
133
134
|
} catch (err) {
|
|
134
|
-
setError(mapEmailError(err));
|
|
135
|
+
setError(mapEmailError(err, t));
|
|
135
136
|
} finally {
|
|
136
137
|
setLoading(false);
|
|
137
138
|
}
|
|
@@ -156,7 +157,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
|
|
|
156
157
|
applyUser(data.user);
|
|
157
158
|
closeModal();
|
|
158
159
|
} catch (err) {
|
|
159
|
-
setError(mapEmailError(err));
|
|
160
|
+
setError(mapEmailError(err, t));
|
|
160
161
|
setOtp('');
|
|
161
162
|
} finally {
|
|
162
163
|
setLoading(false);
|
|
@@ -167,7 +168,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
|
|
|
167
168
|
|
|
168
169
|
const submitRegister = async (): Promise<void> => {
|
|
169
170
|
if (!firstName.trim()) {
|
|
170
|
-
setError('
|
|
171
|
+
setError(t('auth.error.firstname'));
|
|
171
172
|
return;
|
|
172
173
|
}
|
|
173
174
|
setError(null);
|
|
@@ -183,7 +184,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
|
|
|
183
184
|
applyUser(res.user);
|
|
184
185
|
closeModal();
|
|
185
186
|
} catch (err) {
|
|
186
|
-
setError(err instanceof QueekSdkError ? err.message : '
|
|
187
|
+
setError(err instanceof QueekSdkError ? err.message : t('auth.error.register'));
|
|
187
188
|
} finally {
|
|
188
189
|
setLoading(false);
|
|
189
190
|
}
|
|
@@ -206,7 +207,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
|
|
|
206
207
|
applyUser(res.data.user);
|
|
207
208
|
closeModal();
|
|
208
209
|
} catch (err) {
|
|
209
|
-
setError(mapEmailError(err));
|
|
210
|
+
setError(mapEmailError(err, t));
|
|
210
211
|
} finally {
|
|
211
212
|
setLoading(false);
|
|
212
213
|
}
|
|
@@ -230,7 +231,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
|
|
|
230
231
|
applyUser(res.data.user);
|
|
231
232
|
closeModal();
|
|
232
233
|
} catch (err) {
|
|
233
|
-
setError(mapEmailError(err));
|
|
234
|
+
setError(mapEmailError(err, t));
|
|
234
235
|
} finally {
|
|
235
236
|
setLoading(false);
|
|
236
237
|
}
|
|
@@ -254,7 +255,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
|
|
|
254
255
|
console.warn('[google-auth] redirect endpoint returned non-2xx', res.status, text);
|
|
255
256
|
throw new Error(`Failed to initiate Google auth (HTTP ${res.status}).`);
|
|
256
257
|
}
|
|
257
|
-
//
|
|
258
|
+
// Response envelope: { status, message, data: { url } }
|
|
258
259
|
const body = (await res.json()) as {
|
|
259
260
|
data?: { url?: string; redirect_url?: string };
|
|
260
261
|
url?: string;
|
|
@@ -272,7 +273,7 @@ export function AuthFlowProvider({ children }: { children: ReactNode }): JSX.Ele
|
|
|
272
273
|
} catch (err) {
|
|
273
274
|
// eslint-disable-next-line no-console
|
|
274
275
|
console.error('[google-auth] initiate failed', err);
|
|
275
|
-
setError('
|
|
276
|
+
setError(t('auth.error.google'));
|
|
276
277
|
} finally {
|
|
277
278
|
setLoading(false);
|
|
278
279
|
}
|