@usequeek/theme-kit 0.1.21 → 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 (81) hide show
  1. package/README.md +187 -117
  2. package/api/collections.ts +5 -5
  3. package/api/shop.ts +1 -1
  4. package/client.ts +2 -0
  5. package/components/checkout/checkout-contact.tsx +3 -3
  6. package/components/image.tsx +5 -5
  7. package/components/language-switcher.css +131 -0
  8. package/components/language-switcher.tsx +144 -0
  9. package/components/markdown.tsx +1 -1
  10. package/components/powered-by-queek.tsx +4 -4
  11. package/components/product-media-frame.tsx +1 -1
  12. package/components/product-metafields.tsx +2 -3
  13. package/components/variant-picker.tsx +6 -9
  14. package/demo.ts +10 -14
  15. package/design-token-preview.tsx +0 -2
  16. package/edit-mode-active.tsx +3 -4
  17. package/hooks/use-announcement.ts +1 -1
  18. package/hooks/use-auth-flow.tsx +1 -1
  19. package/hooks/use-locales.ts +107 -0
  20. package/hooks/use-posts.ts +1 -1
  21. package/hooks/use-product-detail.ts +1 -2
  22. package/hooks/use-product-sections.ts +4 -6
  23. package/hooks/use-products.ts +5 -8
  24. package/index.ts +13 -16
  25. package/locales/en.default.json +4 -0
  26. package/next-reference.d.ts +2 -4
  27. package/package.json +3 -2
  28. package/page-renderer.tsx +5 -6
  29. package/provider.tsx +22 -2
  30. package/scripts/verify-package.ts +6 -8
  31. package/sdk/client.ts +13 -12
  32. package/sdk/error-handler.ts +1 -1
  33. package/sdk/platform.ts +6 -8
  34. package/sdk/queek-client.ts +11 -12
  35. package/sdk/server-store-client.ts +13 -17
  36. package/shared-blocks/metaobjects.tsx +1 -1
  37. package/stores/cart-store.ts +2 -2
  38. package/stores/checkout-store.ts +1 -1
  39. package/stores/edit-preview-store.ts +5 -6
  40. package/stores/user-store.ts +4 -5
  41. package/strings/pseudo-locale.ts +3 -4
  42. package/strings/theme-strings.ts +9 -7
  43. package/tests/auth-email-errors.test.tsx +2 -2
  44. package/tests/currency-contract.test.ts +2 -3
  45. package/tests/display-format.test.ts +17 -25
  46. package/tests/fixtures/external-theme.tsx +18 -2
  47. package/tests/language-switcher.test.tsx +350 -0
  48. package/tests/list-pagination.test.tsx +8 -8
  49. package/tests/locale-switch.test.ts +197 -0
  50. package/tests/locale-transport.test.tsx +3 -3
  51. package/tests/markdown-render.test.ts +5 -6
  52. package/tests/orders-walk.test.ts +5 -5
  53. package/tests/page-renderer-signature.test.tsx +1 -1
  54. package/tests/powered-by-queek.test.tsx +3 -3
  55. package/tests/powered-by-url.test.ts +2 -2
  56. package/tests/public-surface.test.ts +1 -4
  57. package/tests/shopper-chrome-strings.test.tsx +5 -6
  58. package/tests/storefront-client-scope.test.ts +5 -5
  59. package/tests/theme-hook-placement.test.tsx +2 -2
  60. package/tests/theme-strings-client.test.tsx +5 -5
  61. package/tests/theme-strings-pseudo.test.tsx +1 -1
  62. package/tests/theme-strings-typed.test.tsx +1 -1
  63. package/tests/theme-strings.test.tsx +8 -11
  64. package/tests/use-shop-prefetch.test.tsx +1 -1
  65. package/theme-mount.tsx +6 -11
  66. package/types/block.ts +7 -7
  67. package/types/media.ts +8 -9
  68. package/types/product.ts +8 -12
  69. package/types/theme.ts +26 -25
  70. package/types/vendor.ts +10 -12
  71. package/utils/announcement.ts +3 -3
  72. package/utils/benefit-icon.tsx +9 -11
  73. package/utils/brand.ts +7 -10
  74. package/utils/format.ts +5 -7
  75. package/utils/image-srcset.ts +2 -2
  76. package/utils/locale-switch.ts +208 -0
  77. package/utils/locale.ts +10 -11
  78. package/utils/menu-link.ts +1 -1
  79. package/utils/product-media.ts +1 -1
  80. package/utils/product-normalizer.ts +2 -2
  81. 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,10 +101,9 @@ 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.
95
105
  - **Typed keys (optional, compile-time).** A theme opts in per call site —
96
- existing untyped `t(key)` callers keep compiling unchanged.
106
+ untyped `t(key)` callers keep compiling unchanged.
97
107
  `ThemeStringKey<typeof strings>` derives the key union from the theme's
98
108
  English dictionary object (nested objects flatten to dotted keys, a
99
109
  plural map counts as ONE key, flat literal dotted keys stay one key),
@@ -110,9 +120,6 @@ provider that passes nothing renders exactly as today, in English.
110
120
  // tt('cart.titl'); // type error — typo keys fail, valid keys pass
111
121
  ```
112
122
 
113
- (`tests/theme-typed-keys.fixture.ts` proves this under
114
- `npm run typecheck`: valid keys pass, typo/unknown/plural-form keys are
115
- `@ts-expect-error` errors.)
116
123
  - **Pseudo-locale QA (dev/test only, never shipped).**
117
124
  `pseudoLocalize(dictionary, options?)`
118
125
  (`strings/pseudo-locale.ts`, pure: no `next/*`, no React) returns a
@@ -123,9 +130,7 @@ provider that passes nothing renders exactly as today, in English.
123
130
  pseudo dictionary as the `strings` prop: everything that went through
124
131
  `t()` renders pseudo, so any text that remains plain English next to
125
132
  pseudo text is a hard-coded (un-wrapped) string. In tests, render with
126
- the pseudo dictionary and run the English-leak scan over the HTML —
127
- the reusable primitive theme tests import (the storefront and
128
- theme-tools import it from the same pure module):
133
+ the pseudo dictionary and run the English-leak scan over the HTML:
129
134
 
130
135
  ```tsx
131
136
  import { pseudoLocalize, assertNoEnglishLeak } from '@usequeek/theme-kit/strings/pseudo-locale';
@@ -147,53 +152,39 @@ provider that passes nothing renders exactly as today, in English.
147
152
 
148
153
  ## Locale-aware dates, numbers and relative time
149
154
 
150
- Dates, counts, money and relative time follow the SHOPPER's locale (founder
151
- 2/10/26). English renders in the British/Nigerian style — `2 October 2026`,
152
- never US `October 2, 2026` — so English stores look exactly as before.
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`.
153
158
 
154
159
  - **One mapping.** `resolveIntlLocale(locale)` (`utils/locale.ts`, pure: no
155
160
  `next/*`, no React) is the single canonical storefront-code → Intl-locale
156
161
  mapping every formatter goes through. Missing/invalid codes and every
157
162
  English variant (`en`, `en-GB`, `en-NG`, `en-US`, …) become `en-NG`;
158
163
  everything else passes through (`fr`, `ar`, `pt-BR`, `zh-CN`, `yo`, `ha`,
159
- `sw`, …). `en-NG` — not `en-GB` — because only it reproduces current
160
- output byte for byte: both print `2 October 2026`, but `en-GB` renders NGN
161
- as `NGN 12,500` where the stores show `₦12,500` (verified on Node 22, full
162
- ICU). `resolveSupportedLocale` falls back to English when the runtime's
163
- ICU lacks a locale — formatting never throws.
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.
164
168
  - **What themes call.** `formatThemeDate(value, locale?, options?)`
165
169
  (`strings/theme-strings.ts`) instead of a hardcoded-`'en-GB'`
166
170
  `toLocaleDateString`: pass the storefront locale — client components via
167
171
  `useStorefrontLocale()`, server components via the request locale the host
168
172
  already holds. Siblings: `formatThemeNumber`, `formatThemeMoney`, and
169
173
  `formatThemeRelativeTime(-1, 'day', locale)` (`hier`, `أمس`, `ontem`, …,
170
- `numeric: 'auto'`). `formatThemeMoney` keeps the K0 currency-default
174
+ `numeric: 'auto'`). `formatThemeMoney` uses the currency's default
171
175
  decimals (`₦5,000.00`); `formatMoney` shows decimals only when present
172
176
  (`₦5,000`, `₦0.28`). The fixed-zone display path is `formatShopperDate`
173
- (`utils/format.ts`, Africa/Lagos, `''` for invalid dates as before);
174
- `formatCount`/`formatMoney` take an optional locale, defaulting to the
175
- pinned English output. `PostMeta` (`components/blog/post-meta.tsx`) already
176
- renders its byline through the provider locale.
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.
177
181
  - **Fallbacks.** Unknown locale → English style; invalid date → `''`;
178
182
  minimal-ICU runtimes render English rather than throwing.
179
183
  - **Plural selection stays separate.** `canonicalThemeLocale`
180
184
  (`strings/theme-strings.ts`) is for `t()` plural selection ONLY: it returns
181
185
  a bare `'en'` tag, which is a US-order Intl tag. Never pass it to a date,
182
186
  number, money or relative-time formatter — formatters use
183
- `resolveIntlLocale` (en* → `en-NG`) instead. A test fails the suite if any
184
- formatter body ever calls it.
185
- - **Shopify comparison.** Shopify's Liquid
186
- [`date` filter](https://shopify.dev/docs/api/liquid/filters/date) formats
187
- with explicit strftime strings, or locale-aware via named `format:` options
188
- (`abbreviated_date`, `basic`, `date`, `date_at_time`, `default`, `on_date`)
189
- whose patterns live in the theme's locale files (`date_formats`); the
190
- [`money` filter](https://shopify.dev/docs/api/liquid/filters/money) renders
191
- per the store's currency-formatting setting. This slice differs
192
- intentionally: one code-level mapping (`resolveIntlLocale`) instead of
193
- per-locale format files, every English variant pinned to `en-NG`
194
- (British/Nigerian order, never US), and an English fallback when the
195
- runtime's ICU lacks the locale. Number/relative-time have no Liquid
196
- equivalent consulted — UNVERIFIED remainder, no parity claim made.
187
+ `resolveIntlLocale` (en* → `en-NG`) instead.
197
188
 
198
189
  ```tsx
199
190
  import { useStorefrontLocale } from '@usequeek/theme-kit/provider';
@@ -204,23 +195,14 @@ formatThemeDate(post.published_at, locale, { year: 'numeric', month: 'long', day
204
195
  // en → "2 October 2026" · fr → "2 octobre 2026" · ar → "2 أكتوبر 2026"
205
196
  ```
206
197
 
207
- `tests/display-format.test.ts` pins this: every existing English string is
208
- byte-identical with or without a locale, `fr`/`ar`/`pt-BR`/`zh-CN`/`yo`/
209
- `ha`/`sw` outputs are asserted against real `Intl` output (with the
210
- English fallback when ICU lacks a locale), and a scan fails the suite if a
211
- hardcoded English locale ever reaches a formatter again.
212
-
213
198
  ## Consumers
214
199
 
215
- - **The Queek storefront** (`usequeek/queek-storefront`) installs this package
216
- from the npm registry (`"@usequeek/theme-kit": "^x.y.z"`) and transpiles it
217
- via `transpilePackages`. Its vitest config inlines the package
218
- (`server.deps.inline: ['@usequeek/theme-kit']`) so the shipped `.tsx`
219
- source is transformed like first-party code.
220
- - **External theme developers** start from `packages/theme-starter` in the
221
- storefront repo, which builds standalone against a packed tarball of this
222
- source. Anyone testing a theme with vitest needs the same `inline` line;
223
- 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.
224
206
 
225
207
  ## Custom data: metafields & metaobjects
226
208
 
@@ -275,7 +257,7 @@ export default function ProductPage({ product }: ProductPageProps) {
275
257
  ```
276
258
 
277
259
  - MUST: place `<AppBlocks target="product" productId={...} />` on the
278
- product page. `target` is product-only in phase 1 — no other slot exists.
260
+ product page. `target` is product-only: no other slot exists.
279
261
  `productId` scopes availability reads and deep links to the shown product;
280
262
  omit `vendorSlug` (the host resolves the store itself).
281
263
  - The host registers its placement component once through `AppBlocksProvider`
@@ -292,8 +274,7 @@ export default function ProductPage({ product }: ProductPageProps) {
292
274
 
293
275
  ## Attribution: Powered by Queek
294
276
 
295
- Every storefront is a billboard — every footer variant of every theme renders
296
- the core-owned attribution link:
277
+ Every theme footer renders the core-owned attribution link:
297
278
 
298
279
  ```tsx
299
280
  import { PoweredByQueek } from '@usequeek/theme-kit/components/powered-by-queek';
@@ -311,7 +292,8 @@ export default function Footer() {
311
292
  - MUST: place `<PoweredByQueek />` on every footer variant (`footers/*.tsx`,
312
293
  or `footer.tsx` where a theme has no `footers/` dir). The placement and
313
294
  spacing are the theme's — the component is the content, the footer owns
314
- 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
315
297
  this, so a theme cannot ship without it.
316
298
  - Style only: the link emits `core-powered-by` with a
317
299
  `core-powered-by__brand` hook on the `Queek` wordmark; an optional
@@ -324,9 +306,8 @@ export default function Footer() {
324
306
  (`utm_source=powered_by&utm_medium=storefront&utm_campaign=<store slug>`,
325
307
  campaign omitted when the store has no slug); the component reads the slug
326
308
  from the storefront context, so themes pass nothing.
327
- - `hidden` (default false) takes the component's visibility from a prop as
328
- the seam for a possible future plan perk. There is no merchant-facing
329
- 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.
330
311
 
331
312
  ## Locales
332
313
 
@@ -337,7 +318,7 @@ request locale on every catalogue/content transport, server and browser:
337
318
  - Server: `createServerStoreClient().get()` reads the incoming request's
338
319
  `x-queek-locale` header (set by the storefront proxy for published
339
320
  non-primary locales only — absent on the primary), appends `locale=<code>`
340
- 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
341
322
  locales never share a cache entry. `fetchShopPrefetch`, `fetchCollections`,
342
323
  `fetchCollection` and `fetchCollectionProducts` take the same behaviour plus
343
324
  an optional explicit override. No header → byte-identical requests and tags.
@@ -348,11 +329,96 @@ request locale on every catalogue/content transport, server and browser:
348
329
  `useProductQuestions` and `useProductDetail` send `?locale=` when it is set
349
330
  and key their client caches by it. No prop → unchanged.
350
331
 
351
- What this means for themes: nothing to handle. Never read the header, never
352
- append `locale` yourself, and never build a language switcher — locale
353
- routing, `basePath` prefixing and the switcher are core-owned in the
354
- storefront. Links built from `basePath` keep their locale prefix with zero
355
- 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.
356
422
 
357
423
  ## Links and navigation
358
424
 
@@ -361,42 +427,46 @@ import { Link, useRouter, usePathname } from '@usequeek/theme-kit/navigation';
361
427
  ```
362
428
 
363
429
  A theme links and navigates through these, never through `next/link` or
364
- `next/navigation`. Which framework runs the storefront is Queek's decision: the
365
- kit is the one place that knows it, so a change there is a kit release, not an
366
- update to every theme. `Link` is an `<a>` that moves inside the store without a
367
- full page load (`href`, `prefetch`, `replace`, plus any anchor attribute);
368
- `useRouter()` gives `push`, `replace`, `back` and `refresh`; `usePathname()`
369
- 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`.
370
437
 
371
438
  ## Boundary
372
439
 
373
- The kit must never import the storefront app, its themes, or `lib/storefront`.
374
- That one-way rule is what makes this package extractable at all. The reverse
375
- 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.
376
442
 
377
443
  ## Develop
378
444
 
379
445
  ```bash
380
446
  npm install
381
- npm run typecheck # tsc --noEmit, standalone — no storefront files
382
- npm test # vitest run
447
+ npm run typecheck # tsc --noEmit
448
+ npm test # public-text check, then vitest run
383
449
  npm run verify-package # pack current source, typecheck a real theme against it
384
450
  ```
385
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
+
386
457
  ## Release
387
458
 
388
- The package is public on npm; the repo is private. Publishing runs on tags via
389
- `.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:
390
461
 
391
462
  ```bash
392
463
  # 1. Bump version in package.json and commit
393
- # 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,
394
465
  # then `npm publish --access public`:
395
- git tag v0.1.6
466
+ git tag vX.Y.Z
396
467
  git push origin main --tags
397
468
  ```
398
469
 
399
- The storefront then bumps its dependency to match the published version
400
- (`yarn add @usequeek/theme-kit@^0.1.6` + lockfile) and pushes separately —
401
- never the other way round: the app must only ever depend on a version that
402
- 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';
@@ -8,9 +8,9 @@ import { Image } from '../image';
8
8
 
9
9
  /**
10
10
  * First section of the checkout form — shows who's checking out. Ordering
11
- * requires auth (`canCheckout` in checkout-controller.tsx), so unlike
12
- * Shopify's guest-friendly email field this is purely an identity
13
- * confirmation: avatar + email when signed in, a sign-in prompt otherwise.
11
+ * requires auth (`canCheckout` in checkout-controller.tsx), so this is purely
12
+ * an identity confirmation, not an email field: avatar + email when signed in,
13
+ * a sign-in prompt otherwise.
14
14
  */
15
15
  export function CheckoutContactSection(): JSX.Element {
16
16
  const { user, isAuthenticated, openAuthModal, logout } = useAuth();
@@ -35,11 +35,11 @@ type ImageProps = ImgHTMLAttributes<HTMLImageElement> & {
35
35
  fallback?: string;
36
36
  placeholder?: PlaceholderProp;
37
37
  /**
38
- * The backend's srcset-ready tier map (product.media.image_variants,
38
+ * The API's srcset-ready tier map (product.media.image_variants,
39
39
  * vendor.logo_variants, collection.image_variants, mediaIndex[url], …).
40
- * Purely additive: when absent — as it is at every call site that hasn't been
41
- * migrated, and whenever the backend legitimately sends null (off-host image,
42
- * SVG, legacy upload) — this component behaves exactly as it did before.
40
+ * Purely additive: when absent — and whenever the API legitimately sends null
41
+ * (off-host image, SVG, legacy upload) — this component renders the plain
42
+ * image URL.
43
43
  */
44
44
  variants?: ImageVariants | null;
45
45
  /**
@@ -133,7 +133,7 @@ export function Image({ fallback, placeholder, variants, intent = 'raw', onError
133
133
  // image is invisible until hover. Eager, a server-rendered grid of 24 cards
134
134
  // made React preload the first ~10 (also into the 103 Early Hints header) and
135
135
  // fetch every image at once, racing the page's render-blocking CSS: /shop's
136
- // first paint went 1.8 s → 2.9 s on Slow 4G (24/9/26). Lazy is next/image's
136
+ // first paint went from 1.8 s to 2.9 s on Slow 4G. Lazy is next/image's
137
137
  // default as well. A theme whose card IS the page's main image passes
138
138
  // loading="eager" or fetchPriority="high".
139
139
  const lazyByDefault = (intent === 'card' || intent === 'card2x') && props.fetchPriority !== 'high';