@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
package/README.md CHANGED
@@ -1,30 +1,35 @@
1
1
  # @usequeek/theme-kit
2
2
 
3
3
  The framework a Queek storefront theme is built against: hooks, stores, headless
4
- flows (auth, cart, checkout), types and the block renderer.
5
-
6
- **Ships TypeScript source, not a build.** Most of its modules carry a
7
- `'use client'` directive, and that directive is the whole contract with the React
8
- Server Components boundary — bundlers routinely strip or hoist it when a library
9
- is pre-compiled, which turns a client module into a server one silently. The
10
- consumer transpiles instead (`transpilePackages: ['@usequeek/theme-kit']`), so the
11
- directives survive to the bundler that actually enforces them.
12
-
13
- Deep subpaths resolve off the filesystem — there is deliberately NO `exports`
14
- map. One was tried: with source-shipped `.ts`/`.tsx` an extensionless wildcard
15
- target does not resolve, and spelling out extensions cannot cover both. Legacy
16
- resolution under `moduleResolution: bundler` handles it. The cost is that the
17
- package has no subpath encapsulation, so what a theme may import is enforced by
18
- convention plus tests: `tests/public-surface.test.ts` here (barrels declare
19
- only modules that exist) and `core-public-surface.test.ts` in the storefront
20
- repo (barrels cover every module the themes actually import).
21
-
22
- Every module that annotates a return as `JSX.Element` does
23
- `import type { JSX } from 'react'` — React 19 removed the global `JSX`
24
- namespace, and 0.1.0 shipped broken for every external consumer by relying on
25
- the storefront app's `next-env.d.ts` to provide it. `scripts/verify-package.ts`
26
- packs the current source and typechecks a real theme against it from a sandbox,
27
- so that class of breakage fails here instead of after publishing.
4
+ flows (auth, cart, checkout), types and the block renderer. See the
5
+ [theme documentation](https://docs.usequeek.com/docs/themes) for how themes
6
+ work end to end.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @usequeek/theme-kit
12
+ ```
13
+
14
+ Peer dependencies: `next` (>=15), `react` and `react-dom` (>=19) and
15
+ `@queekai/client-sdk`.
16
+
17
+ The package ships TypeScript source, not a build. Most modules carry a
18
+ `'use client'` directive, and that directive is the whole contract with the
19
+ React Server Components boundary; bundlers routinely strip or hoist it when a
20
+ library is pre-compiled, which silently turns a client module into a server
21
+ one. The consumer transpiles the package instead, so the directives reach the
22
+ bundler that enforces them:
23
+
24
+ ```ts
25
+ // next.config.ts
26
+ export default { transpilePackages: ['@usequeek/theme-kit'] };
27
+ ```
28
+
29
+ Compiling a theme against it needs `moduleResolution: bundler` and
30
+ `jsx: react-jsx`. A vitest setup that renders kit components needs the package
31
+ inlined (`server.deps.inline: ['@usequeek/theme-kit']`) so the shipped `.tsx`
32
+ source is transformed like first-party code.
28
33
 
29
34
  ## Use
30
35
 
@@ -33,10 +38,16 @@ import { useCart } from '@usequeek/theme-kit/hooks/use-cart';
33
38
  import type { ThemeModule } from '@usequeek/theme-kit/types/theme';
34
39
  ```
35
40
 
36
- Deep paths, mirroring the old `@/lib/core/*`. Barrels exist (`.` and `./client`)
37
- to DECLARE the public surface — what a theme is allowed to build against — but
38
- importing through them pulls every client module into one chunk and was measured
39
- to break the app's build. Import the module you want.
41
+ Import the module you need by its deep path. The barrels (`.` and `./client`)
42
+ declare the public surface, meaning what a theme is allowed to build against,
43
+ but importing through them pulls every client module into one chunk and can
44
+ break an app's build.
45
+
46
+ There is no `exports` map: with source-shipped `.ts`/`.tsx`, an extensionless
47
+ wildcard target does not resolve, so subpaths resolve off the filesystem under
48
+ `moduleResolution: bundler`. The package therefore has no subpath
49
+ encapsulation; what a theme may import is the surface the barrels declare,
50
+ checked by `tests/public-surface.test.ts`.
40
51
 
41
52
  Theme CSS ships with the package: `@usequeek/theme-kit/shared-blocks/core-blocks.css`
42
53
  (framework-owned blocks), plus per-component CSS next to its component
@@ -44,9 +55,9 @@ Theme CSS ships with the package: `@usequeek/theme-kit/shared-blocks/core-blocks
44
55
 
45
56
  ## Theme strings
46
57
 
47
- The kit's Shopify-shaped UI-string mechanism (`strings/theme-strings.ts`,
48
- pure: no `next/*`, no React). Additive and backward-compatible — a theme or
49
- provider that passes nothing renders exactly as today, in English.
58
+ The kit's UI-string mechanism (`strings/theme-strings.ts`; pure, with no
59
+ `next/*` and no React). A theme or provider that passes nothing renders in
60
+ English. See also [Translating your theme](https://docs.usequeek.com/docs/themes/translations).
50
61
 
51
62
  - **Authoring contract.** `t(dictionaries, key, vars?, locale?)`: dot-path
52
63
  lookup (`cart.title`), `{var}` interpolation (`{{`/`}}` escape to literal
@@ -56,9 +67,9 @@ provider that passes nothing renders exactly as today, in English.
56
67
  `vars.count`, defaulting to `other`. Locale-aware `formatThemeNumber`,
57
68
  `formatThemeDate` and `formatThemeMoney` replace hardcoded `en-GB`
58
69
  formatting; an unknown locale falls back to English. Keys are dotted
59
- lowercase `scope.thing.state`, max **40 chars**; values max **1000 chars**:
60
- the backend overlay stores `<theme-slug>.<key>` in a varchar(64), so the
61
- key budget leaves 23 chars for the slug plus the dot.
70
+ lowercase `scope.thing.state`, max **40 chars**; values max **1000 chars**.
71
+ Merchant overrides are stored as `<theme-slug>.<key>` in a 64-character
72
+ field, so the key budget leaves 23 chars for the slug plus the dot.
62
73
  - **Two surfaces.** Server (provider-free): the host supplies per-locale
63
74
  loaders and calls
64
75
  `createThemeStrings({ locale, loaders })` — `{ override, theme, core }`,
@@ -90,20 +101,108 @@ provider that passes nothing renders exactly as today, in English.
90
101
  provider — other `{lang}.json` files never enter the client bundle.
91
102
  Only `locales/en.default.json` may be statically imported (by
92
103
  `strings/theme-strings.ts` itself); every other locale arrives via the
93
- host's dynamic `import()` per locale. `tests/theme-strings.test.tsx`
94
- gates this: any other static locale import fails the suite.
104
+ host's dynamic `import()` per locale.
105
+ - **Typed keys (optional, compile-time).** A theme opts in per call site —
106
+ untyped `t(key)` callers keep compiling unchanged.
107
+ `ThemeStringKey<typeof strings>` derives the key union from the theme's
108
+ English dictionary object (nested objects flatten to dotted keys, a
109
+ plural map counts as ONE key, flat literal dotted keys stay one key),
110
+ and `createThemeT` binds it to any `t`:
111
+
112
+ ```ts
113
+ import enDefault from './locales/en.default.json';
114
+ import { createThemeT } from '@usequeek/theme-kit/strings/theme-strings';
115
+
116
+ const { t } = await createThemeStrings({ locale });
117
+ const tt = createThemeT<typeof enDefault>(t); // server …
118
+ // … or client: const tt = createThemeT<typeof enDefault>(useThemeStrings());
119
+ tt('cart.title'); // ok
120
+ // tt('cart.titl'); // type error — typo keys fail, valid keys pass
121
+ ```
122
+
123
+ - **Pseudo-locale QA (dev/test only, never shipped).**
124
+ `pseudoLocalize(dictionary, options?)`
125
+ (`strings/pseudo-locale.ts`, pure: no `next/*`, no React) returns a
126
+ pseudo dictionary with identical keys and plural-map shape — every
127
+ value accent-mapped, ~30% longer, wrapped in `[!! … !!]` markers —
128
+ while `{placeholders}` and `{{`/`}}` escapes pass through and keys are
129
+ never touched. Use it two ways. As a dev-only locale, mount with the
130
+ pseudo dictionary as the `strings` prop: everything that went through
131
+ `t()` renders pseudo, so any text that remains plain English next to
132
+ pseudo text is a hard-coded (un-wrapped) string. In tests, render with
133
+ the pseudo dictionary and run the English-leak scan over the HTML:
134
+
135
+ ```tsx
136
+ import { pseudoLocalize, assertNoEnglishLeak } from '@usequeek/theme-kit/strings/pseudo-locale';
137
+
138
+ const pseudo = pseudoLocalize(enDefault, { rtl: true }); // spot-check RTL too
139
+ const html = renderToStaticMarkup(
140
+ <StorefrontProvider vendor={…} config={…} menus={[]} strings={pseudo}>
141
+ <MySection />
142
+ </StorefrontProvider>,
143
+ );
144
+ assertNoEnglishLeak(html, { allowlist: ['Queek'] }); // throws on any plain-English node
145
+ ```
146
+
147
+ `findEnglishLeaks(html, { allowlist, includeAttributes })` returns the
148
+ offending node texts instead of throwing; the scan covers visible text
149
+ nodes plus `aria-label`/`placeholder`/`title`/`alt` (opt out with
150
+ `includeAttributes: false`). Pseudo dictionaries are built on demand
151
+ in dev/test and never enter a client bundle.
152
+
153
+ ## Locale-aware dates, numbers and relative time
154
+
155
+ Dates, counts, money and relative time follow the SHOPPER's locale. English
156
+ renders in the British/Nigerian style — `2 October 2026`, never US
157
+ `October 2, 2026`.
158
+
159
+ - **One mapping.** `resolveIntlLocale(locale)` (`utils/locale.ts`, pure: no
160
+ `next/*`, no React) is the single canonical storefront-code → Intl-locale
161
+ mapping every formatter goes through. Missing/invalid codes and every
162
+ English variant (`en`, `en-GB`, `en-NG`, `en-US`, …) become `en-NG`;
163
+ everything else passes through (`fr`, `ar`, `pt-BR`, `zh-CN`, `yo`, `ha`,
164
+ `sw`, …). `en-NG` — not `en-GB` — because it renders NGN as `₦12,500`
165
+ where `en-GB` renders `NGN 12,500` (both print dates as `2 October 2026`;
166
+ checked on Node 22 with full ICU). `resolveSupportedLocale` falls back to
167
+ English when the runtime's ICU lacks a locale — formatting never throws.
168
+ - **What themes call.** `formatThemeDate(value, locale?, options?)`
169
+ (`strings/theme-strings.ts`) instead of a hardcoded-`'en-GB'`
170
+ `toLocaleDateString`: pass the storefront locale — client components via
171
+ `useStorefrontLocale()`, server components via the request locale the host
172
+ already holds. Siblings: `formatThemeNumber`, `formatThemeMoney`, and
173
+ `formatThemeRelativeTime(-1, 'day', locale)` (`hier`, `أمس`, `ontem`, …,
174
+ `numeric: 'auto'`). `formatThemeMoney` uses the currency's default
175
+ decimals (`₦5,000.00`); `formatMoney` shows decimals only when present
176
+ (`₦5,000`, `₦0.28`). The fixed-zone display path is `formatShopperDate`
177
+ (`utils/format.ts`, Africa/Lagos, `''` for invalid dates);
178
+ `formatCount`/`formatMoney` take an optional locale, defaulting to English
179
+ output. `PostMeta` (`components/blog/post-meta.tsx`) renders its byline
180
+ through the provider locale.
181
+ - **Fallbacks.** Unknown locale → English style; invalid date → `''`;
182
+ minimal-ICU runtimes render English rather than throwing.
183
+ - **Plural selection stays separate.** `canonicalThemeLocale`
184
+ (`strings/theme-strings.ts`) is for `t()` plural selection ONLY: it returns
185
+ a bare `'en'` tag, which is a US-order Intl tag. Never pass it to a date,
186
+ number, money or relative-time formatter — formatters use
187
+ `resolveIntlLocale` (en* → `en-NG`) instead.
188
+
189
+ ```tsx
190
+ import { useStorefrontLocale } from '@usequeek/theme-kit/provider';
191
+ import { formatThemeDate } from '@usequeek/theme-kit/strings/theme-strings';
192
+
193
+ const locale = useStorefrontLocale(); // null on the primary (English) locale
194
+ formatThemeDate(post.published_at, locale, { year: 'numeric', month: 'long', day: 'numeric' });
195
+ // en → "2 October 2026" · fr → "2 octobre 2026" · ar → "2 أكتوبر 2026"
196
+ ```
95
197
 
96
198
  ## Consumers
97
199
 
98
- - **The Queek storefront** (`usequeek/queek-storefront`) installs this package
99
- from the npm registry (`"@usequeek/theme-kit": "^x.y.z"`) and transpiles it
100
- via `transpilePackages`. Its vitest config inlines the package
101
- (`server.deps.inline: ['@usequeek/theme-kit']`) so the shipped `.tsx`
102
- source is transformed like first-party code.
103
- - **External theme developers** start from `packages/theme-starter` in the
104
- storefront repo, which builds standalone against a packed tarball of this
105
- source. Anyone testing a theme with vitest needs the same `inline` line;
106
- anyone compiling one needs `moduleResolution: bundler` + `jsx: react-jsx`.
200
+ - **Queek storefronts** install this package from npm
201
+ (`"@usequeek/theme-kit": "^x.y.z"`) and transpile it via
202
+ `transpilePackages`.
203
+ - **Theme developers** start from
204
+ [`usequeek/theme-starter`](https://github.com/usequeek/theme-starter), which
205
+ builds against this package.
107
206
 
108
207
  ## Custom data: metafields & metaobjects
109
208
 
@@ -158,7 +257,7 @@ export default function ProductPage({ product }: ProductPageProps) {
158
257
  ```
159
258
 
160
259
  - MUST: place `<AppBlocks target="product" productId={...} />` on the
161
- product page. `target` is product-only in phase 1 — no other slot exists.
260
+ product page. `target` is product-only: no other slot exists.
162
261
  `productId` scopes availability reads and deep links to the shown product;
163
262
  omit `vendorSlug` (the host resolves the store itself).
164
263
  - The host registers its placement component once through `AppBlocksProvider`
@@ -175,8 +274,7 @@ export default function ProductPage({ product }: ProductPageProps) {
175
274
 
176
275
  ## Attribution: Powered by Queek
177
276
 
178
- Every storefront is a billboard — every footer variant of every theme renders
179
- the core-owned attribution link:
277
+ Every theme footer renders the core-owned attribution link:
180
278
 
181
279
  ```tsx
182
280
  import { PoweredByQueek } from '@usequeek/theme-kit/components/powered-by-queek';
@@ -194,7 +292,8 @@ export default function Footer() {
194
292
  - MUST: place `<PoweredByQueek />` on every footer variant (`footers/*.tsx`,
195
293
  or `footer.tsx` where a theme has no `footers/` dir). The placement and
196
294
  spacing are the theme's — the component is the content, the footer owns
197
- where it sits. `theme-check` rule `theme/footer-shows-powered-by` enforces
295
+ where it sits. The theme check rule `theme/footer-shows-powered-by`
296
+ ([checks reference](https://docs.usequeek.com/docs/themes/checks)) enforces
198
297
  this, so a theme cannot ship without it.
199
298
  - Style only: the link emits `core-powered-by` with a
200
299
  `core-powered-by__brand` hook on the `Queek` wordmark; an optional
@@ -207,9 +306,8 @@ export default function Footer() {
207
306
  (`utm_source=powered_by&utm_medium=storefront&utm_campaign=<store slug>`,
208
307
  campaign omitted when the store has no slug); the component reads the slug
209
308
  from the storefront context, so themes pass nothing.
210
- - `hidden` (default false) takes the component's visibility from a prop as
211
- the seam for a possible future plan perk. There is no merchant-facing
212
- toggle for it in v1 — always render it visible.
309
+ - `hidden` (default false) takes the component's visibility from a prop.
310
+ There is no merchant-facing toggle for it — always render it visible.
213
311
 
214
312
  ## Locales
215
313
 
@@ -220,7 +318,7 @@ request locale on every catalogue/content transport, server and browser:
220
318
  - Server: `createServerStoreClient().get()` reads the incoming request's
221
319
  `x-queek-locale` header (set by the storefront proxy for published
222
320
  non-primary locales only — absent on the primary), appends `locale=<code>`
223
- to the backend URL, and suffixes every cache tag with `:locale:<code>` so
321
+ to the API URL, and suffixes every cache tag with `:locale:<code>` so
224
322
  locales never share a cache entry. `fetchShopPrefetch`, `fetchCollections`,
225
323
  `fetchCollection` and `fetchCollectionProducts` take the same behaviour plus
226
324
  an optional explicit override. No header → byte-identical requests and tags.
@@ -231,11 +329,96 @@ request locale on every catalogue/content transport, server and browser:
231
329
  `useProductQuestions` and `useProductDetail` send `?locale=` when it is set
232
330
  and key their client caches by it. No prop → unchanged.
233
331
 
234
- What this means for themes: nothing to handle. Never read the header, never
235
- append `locale` yourself, and never build a language switcher — locale
236
- routing, `basePath` prefixing and the switcher are core-owned in the
237
- storefront. Links built from `basePath` keep their locale prefix with zero
238
- theme changes.
332
+ What this means for themes: nothing to handle for the content itself. Never
333
+ read the header and never append `locale` yourself — locale routing and
334
+ `basePath` prefixing are core-owned in the storefront, and links built from
335
+ `basePath` keep their locale prefix with zero theme changes. To let shoppers
336
+ change language, place the kit's `LanguageSwitcher` (next section); do not
337
+ build your own URL logic.
338
+
339
+ ## Language switcher
340
+
341
+ A store can serve several languages. Its default language lives at the store
342
+ root; every other language, English included when it is not the default, lives
343
+ under its bare lower-case code (`/fr`, `/en`, `/zh-cn`). Changing language
344
+ keeps the page, the query string and the hash.
345
+
346
+ ```tsx
347
+ import { LanguageSwitcher } from '@usequeek/theme-kit/components/language-switcher';
348
+
349
+ <LanguageSwitcher /> // native <select> (default)
350
+ <LanguageSwitcher variant="inline-list" /> // every language as a link
351
+ ```
352
+
353
+ Place it unconditionally, in the header or the footer: it renders nothing on a
354
+ single-language store, outside a storefront, and until the host passes the
355
+ store's languages.
356
+
357
+ | Prop | Default | |
358
+ | --- | --- | --- |
359
+ | `className` | | Extra class on the root, to position or restyle it. |
360
+ | `variant` | `'select'` | `'select'`: one native dropdown (best keyboard, touch and screen-reader behaviour). `'inline-list'`: a link per language that wraps on narrow screens; suits a footer. |
361
+ | `showNativeName` | `true` | Label languages in their own language (`Français`) instead of English (`French`). |
362
+ | `showCode` | `false` | Append the code: `Français · FR`. |
363
+ | `children` | | Render-prop `(state) => ReactNode`: replaces the default control entirely (never called on a single-language store). `state` is `useLocales()` plus `label`, `labelFor(locale)` and `displayName(locale)`. |
364
+
365
+ **Tell the storefront the theme places it.** Set `languageSwitcher: 'theme'` in
366
+ the theme manifest. Without it the storefront keeps rendering its own floating
367
+ language control next to yours; with it the storefront renders none and the
368
+ shopper sees exactly one.
369
+
370
+ ```ts
371
+ // in the theme module
372
+ manifest: { /* … */ languageSwitcher: 'theme' } // ThemeManifest.languageSwitcher
373
+ ```
374
+
375
+ **Fully custom markup.** `useLocales()` (from
376
+ `@usequeek/theme-kit/hooks/use-locales`) returns:
377
+
378
+ | Field | |
379
+ | --- | --- |
380
+ | `locales` | The store's languages, default first. One entry, or none, on a single-language store. |
381
+ | `active` | The language the page renders in. |
382
+ | `defaultLocale` | The language served at the store root. |
383
+ | `hasMultiple` | `locales.length > 1`. |
384
+ | `hrefFor(locale)` | The current page in another language (takes a row or a code), e.g. `/fr/shop?sort=new`. The first render carries no query string or hash, so server and client markup match; they are added right after mount. |
385
+ | `switchTo(locale)` | Navigate to it, reading the live query string and hash. Use it for `onClick`/`onChange`. |
386
+
387
+ Each language is `{ locale, name, native_name, is_primary, is_default,
388
+ path_prefix, hreflang, html_lang, dir }`. Use `lang={l.html_lang}` and
389
+ `dir={l.dir}` on a label written in that language. The pure URL rules are also
390
+ exported (`localeHref`, `normalizeStoreLocales`, `defaultStoreLocale`,
391
+ `resolveActiveLocale` from `@usequeek/theme-kit/utils/locale-switch`).
392
+
393
+ **Strings.** `language.label` ("Language") names the control and
394
+ `language.named` ("Language: {name}") names the select and the current
395
+ language. Both are kit core strings with English defaults; a theme can
396
+ override them in its manifest `strings`, and merchants and translators in
397
+ their locale packs.
398
+
399
+ **Styling.** The default control reads only these tokens, each with a neutral
400
+ fallback, and uses logical properties, so it mirrors in right-to-left
401
+ storefronts and sits in a header or footer unstyled:
402
+
403
+ | Token | Used for | Fallback |
404
+ | --- | --- | --- |
405
+ | `--qn-text` | text and icon colour | inherited |
406
+ | `--qn-border` | select border | 22% of the text colour |
407
+ | `--qn-border-strong` | select border on hover | the text colour |
408
+ | `--qn-surface` | select background | transparent |
409
+ | `--qn-radius-sm` | corner radius | `6px` |
410
+ | `--qn-focus` | focus ring | the text colour |
411
+
412
+ Class names for deeper overrides: `qn-lang`, `qn-lang--select`,
413
+ `qn-lang--inline-list`, `qn-lang__select`, `qn-lang__list`, `qn-lang__link`.
414
+ Targets are at least 44px. The root carries `data-qn-language-switcher`.
415
+
416
+ **Host side.** The host passes the store's languages to
417
+ `StorefrontProvider` as the optional `locales` prop (the rows as it already
418
+ holds them; extra fields are ignored, and a row without `is_default` falls
419
+ back to `is_primary`). `basePath` must already carry the active language
420
+ prefix, as in the Locales section. No prop, or one language: no switcher, and
421
+ everything behaves as before.
239
422
 
240
423
  ## Links and navigation
241
424
 
@@ -244,42 +427,46 @@ import { Link, useRouter, usePathname } from '@usequeek/theme-kit/navigation';
244
427
  ```
245
428
 
246
429
  A theme links and navigates through these, never through `next/link` or
247
- `next/navigation`. Which framework runs the storefront is Queek's decision: the
248
- kit is the one place that knows it, so a change there is a kit release, not an
249
- update to every theme. `Link` is an `<a>` that moves inside the store without a
250
- full page load (`href`, `prefetch`, `replace`, plus any anchor attribute);
251
- `useRouter()` gives `push`, `replace`, `back` and `refresh`; `usePathname()`
252
- returns the current path. The theme check rejects a theme that imports `next`.
430
+ `next/navigation`. Which framework runs the storefront is Queek's decision, so
431
+ the kit is the one place that knows it, and a framework change is a kit
432
+ release, not an update to every theme. `Link` is an `<a>` that moves inside the
433
+ store without a full page load (`href`, `prefetch`, `replace`, plus any anchor
434
+ attribute); `useRouter()` gives `push`, `replace`, `back` and `refresh`;
435
+ `usePathname()` returns the current path. The theme check rejects a theme that
436
+ imports `next`.
253
437
 
254
438
  ## Boundary
255
439
 
256
- The kit must never import the storefront app, its themes, or `lib/storefront`.
257
- That one-way rule is what makes this package extractable at all. The reverse
258
- also holds for the framework: the kit may import `next`, a theme may not.
440
+ The kit never imports a host app or any theme. The reverse rule holds for the
441
+ framework: the kit may import `next`, a theme may not.
259
442
 
260
443
  ## Develop
261
444
 
262
445
  ```bash
263
446
  npm install
264
- npm run typecheck # tsc --noEmit, standalone — no storefront files
265
- npm test # vitest run
447
+ npm run typecheck # tsc --noEmit
448
+ npm test # public-text check, then vitest run
266
449
  npm run verify-package # pack current source, typecheck a real theme against it
267
450
  ```
268
451
 
452
+ `verify-package` packs the current source and typechecks a real theme against
453
+ it from a sandbox, so a break that only external consumers would see (for
454
+ example a module relying on the global `JSX` namespace, which React 19 removed)
455
+ fails here instead of after publishing.
456
+
269
457
  ## Release
270
458
 
271
- The package is public on npm; the repo is private. Publishing runs on tags via
272
- `.github/workflows/publish.yml` (needs the `NPM_TOKEN` secret):
459
+ Publishing runs on tags via `.github/workflows/publish.yml`, which needs the
460
+ `NPM_TOKEN` repository secret:
273
461
 
274
462
  ```bash
275
463
  # 1. Bump version in package.json and commit
276
- # 2. Tag and push the tag — the workflow typechecks, tests, verify-packages,
464
+ # 2. Tag and push the tag — the workflow typechecks, tests, runs verify-package,
277
465
  # then `npm publish --access public`:
278
- git tag v0.1.6
466
+ git tag vX.Y.Z
279
467
  git push origin main --tags
280
468
  ```
281
469
 
282
- The storefront then bumps its dependency to match the published version
283
- (`yarn add @usequeek/theme-kit@^0.1.6` + lockfile) and pushes separately —
284
- never the other way round: the app must only ever depend on a version that
285
- actually exists on the registry.
470
+ ## License
471
+
472
+ MIT
@@ -4,8 +4,8 @@ import type { Collection, Pagination } from '../types/page';
4
4
  import type { ApiResponse } from '../types/product';
5
5
  import { extractProducts, normalizeProduct } from '../utils/product-normalizer';
6
6
 
7
- /** Matches `VendorCollectionProductsRequest`'s validated `sort` values exactly
8
- * (queek_backend) — a value outside this list gets a 422 from the backend. */
7
+ /** The `sort` values the collection-products endpoint accepts; any other value
8
+ * gets a 422 from the API. */
9
9
  export type CollectionSort = 'newest' | 'popular' | 'price_low' | 'price_high';
10
10
 
11
11
  export interface FetchCollectionProductsOptions {
@@ -17,9 +17,9 @@ export interface FetchCollectionProductsOptions {
17
17
  locale?: string;
18
18
  }
19
19
 
20
- // SSR collection reads go through `/client/store/*` so ResolveClientContext forces
21
- // X-Platform=storefront (base prices, no marketplace commission) — never the
22
- // legacy v1 routes shared with the commission-bearing marketplace.
20
+ // SSR collection reads go through `/client/store/*`, which resolves the store and
21
+ // forces X-Platform=storefront (base prices, no marketplace commission) — never
22
+ // the legacy v1 routes shared with the marketplace.
23
23
  export async function fetchCollections(vendorSlug: string, storefrontOnly = true, locale?: string): Promise<Collection[]> {
24
24
  const client = createServerStoreClient(vendorSlug, await getRequestOrigin());
25
25
 
package/api/shop.ts CHANGED
@@ -6,7 +6,7 @@ import { parseShopResponse, shopRequestParams, type ShopPrefetch, type ShopQuery
6
6
  * The shop page's first page of products, fetched while the server renders
7
7
  * `/shop`, for `ShopPrefetchProvider` to hand to `useShop` — so the product grid
8
8
  * is in the HTML instead of appearing after the browser has run the JS and
9
- * fetched it (kili-foods' `/shop` main content landed at 2.8 s that way).
9
+ * fetched it.
10
10
  *
11
11
  * Same request and the same parsing as the browser's `useShop` (both come from
12
12
  * utils/shop-query). Reads go through `/client/store/*` like every SSR read, so
package/client.ts CHANGED
@@ -16,6 +16,7 @@ export * from './components/blog';
16
16
  export * from './components/image';
17
17
  export * from './components/product-metafields';
18
18
  export * from './components/powered-by-queek';
19
+ export * from './components/language-switcher';
19
20
  export * from './components/default-metaobject-page';
20
21
  export * from './components/product-media-frame';
21
22
  export * from './components/variant-picker';
@@ -30,6 +31,7 @@ export * from './apps/subscribe-form';
30
31
  export * from './api/collections';
31
32
  export * from './api/orders';
32
33
  export * from './hooks/use-href';
34
+ export * from './hooks/use-locales';
33
35
  export * from './hooks/use-auth';
34
36
  export * from './hooks/use-cart';
35
37
  export * from './hooks/use-shop-cart';
@@ -1,18 +1,20 @@
1
1
  'use client';
2
2
  import type { JSX } from 'react';
3
3
  import { useAuthFlow } from '../../hooks/use-auth-flow';
4
+ import { useThemeStrings } from '../../provider';
4
5
 
5
6
  export function AuthEmailLoginStep(): JSX.Element {
6
7
  const { email, setEmail, password, setPassword, submitEmailLogin, loading, error, goToEmailRegister, goToEmailOtp } = useAuthFlow();
8
+ const t = useThemeStrings();
7
9
 
8
10
  return (
9
11
  <div className="core-auth__step core-auth__step--email-login">
10
- <h2 className="core-auth__title">Sign in</h2>
11
- <p className="core-auth__sub">Enter your email and password</p>
12
+ <h2 className="core-auth__title">{t('auth.login.title')}</h2>
13
+ <p className="core-auth__sub">{t('auth.login.subtitle')}</p>
12
14
  <input
13
15
  type="email"
14
16
  className="core-input"
15
- placeholder="Email address"
17
+ placeholder={t('auth.login.email')}
16
18
  value={email}
17
19
  onChange={(e) => setEmail(e.target.value)}
18
20
  autoComplete="email"
@@ -21,7 +23,7 @@ export function AuthEmailLoginStep(): JSX.Element {
21
23
  <input
22
24
  type="password"
23
25
  className="core-input"
24
- placeholder="Password"
26
+ placeholder={t('auth.login.password')}
25
27
  value={password}
26
28
  onChange={(e) => setPassword(e.target.value)}
27
29
  onKeyDown={(e) => { if (e.key === 'Enter') void submitEmailLogin(); }}
@@ -34,13 +36,13 @@ export function AuthEmailLoginStep(): JSX.Element {
34
36
  onClick={() => void submitEmailLogin()}
35
37
  disabled={loading || !email.trim() || !password}
36
38
  >
37
- {loading ? 'Signing in…' : 'Sign in'}
39
+ {loading ? t('auth.login.signing') : t('auth.login.title')}
38
40
  </button>
39
41
  <button type="button" className="core-auth__link" onClick={goToEmailRegister}>
40
- Don&apos;t have an account? Create one
42
+ {t('auth.login.create')}
41
43
  </button>
42
44
  <button type="button" className="core-auth__link" onClick={goToEmailOtp}>
43
- Use an email code instead
45
+ {t('auth.login.code')}
44
46
  </button>
45
47
  </div>
46
48
  );
@@ -2,14 +2,16 @@
2
2
 
3
3
  import type { JSX } from 'react';
4
4
  import { useAuthFlow } from '../../hooks/use-auth-flow';
5
+ import { useThemeStrings } from '../../provider';
5
6
 
6
7
  export function AuthEmailOtpStep(): JSX.Element {
7
8
  const { email, setEmail, submitEmailRequestOtp, loading, error, initiateGoogleAuth, goToEmailLogin } = useAuthFlow();
9
+ const t = useThemeStrings();
8
10
 
9
11
  return (
10
12
  <div className="core-auth__step core-auth__step--email-otp">
11
- <h2 className="core-auth__title">Sign in</h2>
12
- <p className="core-auth__sub">Enter your email to continue</p>
13
+ <h2 className="core-auth__title">{t('auth.email.title')}</h2>
14
+ <p className="core-auth__sub">{t('auth.email.subtitle')}</p>
13
15
 
14
16
  <button
15
17
  type="button"
@@ -23,15 +25,15 @@ export function AuthEmailOtpStep(): JSX.Element {
23
25
  <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
24
26
  <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
25
27
  </svg>
26
- Continue with Google
28
+ {t('auth.email.google')}
27
29
  </button>
28
30
 
29
- <div className="core-auth__divider"><span>or</span></div>
31
+ <div className="core-auth__divider"><span>{t('auth.email.or')}</span></div>
30
32
 
31
33
  <input
32
34
  type="email"
33
35
  className="core-input"
34
- placeholder="your@email.com"
36
+ placeholder={t('auth.email.placeholder')}
35
37
  value={email}
36
38
  onChange={(e) => setEmail(e.target.value)}
37
39
  onKeyDown={(e) => { if (e.key === 'Enter') void submitEmailRequestOtp(); }}
@@ -45,10 +47,10 @@ export function AuthEmailOtpStep(): JSX.Element {
45
47
  onClick={() => void submitEmailRequestOtp()}
46
48
  disabled={loading || !email.trim()}
47
49
  >
48
- {loading ? 'Sending code…' : 'Continue'}
50
+ {loading ? t('auth.email.sending') : t('auth.email.continue')}
49
51
  </button>
50
52
  <button type="button" className="core-auth__link" onClick={goToEmailLogin}>
51
- Have a password? Sign in with it instead
53
+ {t('auth.email.password')}
52
54
  </button>
53
55
  </div>
54
56
  );
@@ -3,11 +3,13 @@
3
3
  import type { JSX } from 'react';
4
4
  import { useRef } from 'react';
5
5
  import { useAuthFlow } from '../../hooks/use-auth-flow';
6
+ import { useThemeStrings } from '../../provider';
6
7
 
7
8
  const OTP_LENGTH = 6;
8
9
 
9
10
  export function AuthEmailOtpVerifyStep(): JSX.Element {
10
11
  const { email, otp, setOtp, submitEmailVerifyOtp, submitEmailRequestOtp, goToEmailOtp, loading, error } = useAuthFlow();
12
+ const t = useThemeStrings();
11
13
  const refs = useRef<(HTMLInputElement | null)[]>([]);
12
14
 
13
15
  const focus = (i: number): void => {
@@ -40,9 +42,9 @@ export function AuthEmailOtpVerifyStep(): JSX.Element {
40
42
 
41
43
  return (
42
44
  <div className="core-auth__step core-auth__step--email-otp-verify">
43
- <h2 className="core-auth__title">Enter code</h2>
45
+ <h2 className="core-auth__title">{t('auth.code.title')}</h2>
44
46
  <p className="core-auth__sub">
45
- {OTP_LENGTH}-digit code sent to <strong>{email}</strong>
47
+ {t('auth.code.lead', { length: OTP_LENGTH })} <strong>{email}</strong>
46
48
  </p>
47
49
  <div className="core-auth-otp">
48
50
  {Array.from({ length: OTP_LENGTH }).map((_, i) => (
@@ -59,7 +61,7 @@ export function AuthEmailOtpVerifyStep(): JSX.Element {
59
61
  onFocus={(e) => e.target.select()}
60
62
  disabled={loading}
61
63
  autoComplete={i === 0 ? 'one-time-code' : 'off'}
62
- aria-label={`Digit ${i + 1}`}
64
+ aria-label={t('auth.code.digit', { index: i + 1 })}
63
65
  />
64
66
  ))}
65
67
  </div>
@@ -70,13 +72,13 @@ export function AuthEmailOtpVerifyStep(): JSX.Element {
70
72
  onClick={() => void submitEmailVerifyOtp()}
71
73
  disabled={loading || otp.length < OTP_LENGTH}
72
74
  >
73
- {loading ? 'Verifying…' : 'Verify'}
75
+ {loading ? t('auth.code.verifying') : t('auth.code.verify')}
74
76
  </button>
75
77
  <button type="button" className="core-auth__link" onClick={() => void submitEmailRequestOtp()}>
76
- Resend code
78
+ {t('auth.code.resend')}
77
79
  </button>
78
80
  <button type="button" className="core-auth__link" onClick={goToEmailOtp}>
79
- ← Change email
81
+ {t('auth.code.change')}
80
82
  </button>
81
83
  </div>
82
84
  );