@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.
Files changed (94) hide show
  1. package/README.md +264 -77
  2. package/api/collections.ts +5 -5
  3. package/api/shop.ts +1 -1
  4. package/client.ts +2 -0
  5. package/components/auth/auth-email-login-step.tsx +9 -7
  6. package/components/auth/auth-email-otp-step.tsx +9 -7
  7. package/components/auth/auth-email-otp-verify-step.tsx +8 -6
  8. package/components/auth/auth-email-register-step.tsx +10 -8
  9. package/components/auth/auth-signup-step.tsx +8 -6
  10. package/components/blog/category-filter.tsx +6 -5
  11. package/components/blog/pagination.tsx +8 -7
  12. package/components/blog/post-meta.tsx +15 -8
  13. package/components/blog/related-posts.tsx +5 -3
  14. package/components/blog/share-buttons.tsx +10 -8
  15. package/components/checkout/checkout-contact.tsx +9 -7
  16. package/components/checkout/default-checkout-shell.tsx +17 -15
  17. package/components/checkout/policy-links.tsx +5 -3
  18. package/components/image.tsx +5 -5
  19. package/components/language-switcher.css +131 -0
  20. package/components/language-switcher.tsx +144 -0
  21. package/components/markdown.tsx +1 -1
  22. package/components/powered-by-queek.tsx +4 -4
  23. package/components/product-media-frame.tsx +1 -1
  24. package/components/product-metafields.tsx +2 -3
  25. package/components/variant-picker.tsx +6 -9
  26. package/demo.ts +10 -14
  27. package/design-token-preview.tsx +0 -2
  28. package/edit-mode-active.tsx +3 -4
  29. package/hooks/use-announcement.ts +1 -1
  30. package/hooks/use-auth-flow.tsx +19 -18
  31. package/hooks/use-locales.ts +107 -0
  32. package/hooks/use-posts.ts +1 -1
  33. package/hooks/use-product-detail.ts +1 -2
  34. package/hooks/use-product-sections.ts +4 -6
  35. package/hooks/use-products.ts +5 -8
  36. package/index.ts +16 -15
  37. package/locales/en.default.json +49 -7
  38. package/next-reference.d.ts +2 -4
  39. package/package.json +3 -2
  40. package/page-renderer.tsx +5 -6
  41. package/provider.tsx +22 -2
  42. package/scripts/verify-package.ts +6 -8
  43. package/sdk/client.ts +13 -12
  44. package/sdk/error-handler.ts +1 -1
  45. package/sdk/platform.ts +6 -8
  46. package/sdk/queek-client.ts +11 -12
  47. package/sdk/server-store-client.ts +13 -17
  48. package/shared-blocks/metaobjects.tsx +1 -1
  49. package/stores/cart-store.ts +2 -2
  50. package/stores/checkout-store.ts +1 -1
  51. package/stores/edit-preview-store.ts +5 -6
  52. package/stores/user-store.ts +4 -5
  53. package/strings/pseudo-locale.ts +263 -0
  54. package/strings/theme-strings.ts +185 -20
  55. package/tests/auth-email-errors.test.tsx +55 -0
  56. package/tests/currency-contract.test.ts +2 -3
  57. package/tests/display-format.test.ts +356 -2
  58. package/tests/fixtures/external-theme.tsx +18 -2
  59. package/tests/language-switcher.test.tsx +350 -0
  60. package/tests/list-pagination.test.tsx +8 -8
  61. package/tests/locale-switch.test.ts +197 -0
  62. package/tests/locale-transport.test.tsx +3 -3
  63. package/tests/markdown-render.test.ts +5 -6
  64. package/tests/orders-walk.test.ts +5 -5
  65. package/tests/page-renderer-signature.test.tsx +1 -1
  66. package/tests/powered-by-queek.test.tsx +3 -3
  67. package/tests/powered-by-url.test.ts +2 -2
  68. package/tests/public-surface.test.ts +1 -4
  69. package/tests/shopper-chrome-strings.test.tsx +271 -0
  70. package/tests/storefront-client-scope.test.ts +5 -5
  71. package/tests/theme-hook-placement.test.tsx +179 -0
  72. package/tests/theme-strings-client.test.tsx +5 -5
  73. package/tests/theme-strings-pseudo.test.tsx +171 -0
  74. package/tests/theme-strings-typed.test.tsx +97 -0
  75. package/tests/theme-strings.test.tsx +10 -7
  76. package/tests/theme-typed-keys.fixture.ts +76 -0
  77. package/tests/use-shop-prefetch.test.tsx +1 -1
  78. package/theme-mount.tsx +6 -11
  79. package/types/block.ts +7 -7
  80. package/types/media.ts +8 -9
  81. package/types/product.ts +8 -12
  82. package/types/theme.ts +26 -25
  83. package/types/vendor.ts +10 -12
  84. package/utils/announcement.ts +3 -3
  85. package/utils/benefit-icon.tsx +9 -11
  86. package/utils/brand.ts +7 -10
  87. package/utils/format.ts +100 -14
  88. package/utils/image-srcset.ts +2 -2
  89. package/utils/locale-switch.ts +208 -0
  90. package/utils/locale.ts +56 -5
  91. package/utils/menu-link.ts +1 -1
  92. package/utils/product-media.ts +1 -1
  93. package/utils/product-normalizer.ts +2 -2
  94. 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
+ }
@@ -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 `lib/core/utils/markdown`.
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
- * Reserved for a future plan perk (paid removal). Defaults to false and is
12
- * always rendered in v1 — there is no merchant-facing toggle for it.
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 `theme-check`
22
- * rule `theme/footer-shows-powered-by` in the storefront repo). The link
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, never-in-a-srcset tier by contract) is exactly right there.
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` (framework-owned styling contract) — no 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 (media contract) — plain <img>:
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. Extracted from quick-add-page.tsx
18
- * (the channel/chat add-to-cart flow, the one place that already got this
19
- * right) so the other two surfaces missing a picker entirely — the QR
20
- * ordering sheet and the default theme's quick-view modal — can reuse it
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 (DiscountService's
26
- * VariantSelectionException) rather than merely being "no variant"; there
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
- * SHIPPED WITH THE KIT because without it a theme developer has demo data and
5
- * no way to render it. THEME.md already requires every theme to carry a
6
- * demo.json; these are the functions that resolve its block references into
7
- * real products, categories, posts and collections. They lived in the
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. That
12
- * is what made moving it a rename rather than a rewrite, and it is why these
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 is the documented, spec-correct source (see
85
- * `themes/THEME.md` — "must mirror the vendor bootstrap shape") — a few
86
- * themes authored it before the `content: Block[]` / `cover_image_url`
87
- * convention settled, so this tolerates the older shapes instead of
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;
@@ -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,
@@ -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 the qee chat POST body inline.
15
- // TODO: upload the capture to media (media.usequeek.com) and send a URL
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 turns it into a pending qee-chat attachment.
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 (roast). Auto-advances while there is more than
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
  */
@@ -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 'Wrong email or password. Please try again.';
70
+ return t('auth.error.credentials');
71
71
  case 'login_mode_mismatch':
72
- return 'This account uses a different sign-in method (Google or phone).';
72
+ return t('auth.error.method');
73
73
  case 'account_already_exists':
74
- return 'Email already registered. Please sign in instead.';
74
+ return t('auth.error.exists');
75
75
  case 'phone_already_exists':
76
- return 'Phone number already in use.';
76
+ return t('auth.error.phone');
77
77
  case 'otp_resend_too_soon':
78
- return 'Please wait before requesting another code.';
78
+ return t('auth.error.resend');
79
79
  case 'invalid_otp':
80
- return 'Incorrect code. Please try again.';
80
+ return t('auth.error.code');
81
81
  default:
82
- return err.message || 'Something went wrong. Please try again.';
82
+ return err.message || t('auth.error.generic');
83
83
  }
84
84
  }
85
- return 'Something went wrong. Please try again.';
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('Please enter your first name.');
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 : 'Registration failed. Try again.');
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
- // Laravel envelope: { status, message, data: { url } }
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('Could not connect to Google sign-in. Please try again.');
276
+ setError(t('auth.error.google'));
276
277
  } finally {
277
278
  setLoading(false);
278
279
  }