@usequeek/theme-kit 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -117
- package/api/collections.ts +5 -5
- package/api/shop.ts +1 -1
- package/client.ts +2 -0
- package/components/checkout/checkout-contact.tsx +3 -3
- package/components/image.tsx +5 -5
- package/components/language-menu.css +349 -0
- package/components/language-menu.tsx +371 -0
- package/components/language-switcher.css +131 -0
- package/components/language-switcher.tsx +175 -0
- package/components/markdown.tsx +1 -1
- package/components/powered-by-queek.tsx +4 -4
- package/components/product-media-frame.tsx +1 -1
- package/components/product-metafields.tsx +2 -3
- package/components/variant-picker.tsx +6 -9
- package/demo.ts +10 -14
- package/design-token-preview.tsx +0 -2
- package/edit-mode-active.tsx +3 -4
- package/hooks/use-announcement.ts +1 -1
- package/hooks/use-auth-flow.tsx +1 -1
- package/hooks/use-locales.ts +107 -0
- package/hooks/use-posts.ts +1 -1
- package/hooks/use-product-detail.ts +1 -2
- package/hooks/use-product-sections.ts +4 -6
- package/hooks/use-products.ts +5 -8
- package/index.ts +13 -16
- package/locales/en.default.json +4 -0
- package/next-reference.d.ts +2 -4
- package/package.json +3 -2
- package/page-renderer.tsx +5 -6
- package/provider.tsx +22 -2
- package/scripts/verify-package.ts +6 -8
- package/sdk/client.ts +13 -12
- package/sdk/error-handler.ts +1 -1
- package/sdk/platform.ts +6 -8
- package/sdk/queek-client.ts +11 -12
- package/sdk/server-store-client.ts +13 -17
- package/shared-blocks/metaobjects.tsx +1 -1
- package/stores/cart-store.ts +2 -2
- package/stores/checkout-store.ts +1 -1
- package/stores/edit-preview-store.ts +5 -6
- package/stores/user-store.ts +4 -5
- package/strings/pseudo-locale.ts +3 -4
- package/strings/theme-strings.ts +9 -7
- package/tests/auth-email-errors.test.tsx +2 -2
- package/tests/currency-contract.test.ts +2 -3
- package/tests/display-format.test.ts +17 -25
- package/tests/fixtures/external-theme.tsx +23 -2
- package/tests/language-menu.test.tsx +598 -0
- package/tests/language-switcher.test.tsx +350 -0
- package/tests/list-pagination.test.tsx +8 -8
- package/tests/locale-switch.test.ts +197 -0
- package/tests/locale-transport.test.tsx +3 -3
- package/tests/markdown-render.test.ts +5 -6
- package/tests/menu-placement.test.ts +63 -0
- package/tests/orders-walk.test.ts +5 -5
- package/tests/page-renderer-signature.test.tsx +1 -1
- package/tests/powered-by-queek.test.tsx +3 -3
- package/tests/powered-by-url.test.ts +2 -2
- package/tests/public-surface.test.ts +1 -4
- package/tests/shopper-chrome-strings.test.tsx +5 -6
- package/tests/storefront-client-scope.test.ts +5 -5
- package/tests/theme-hook-placement.test.tsx +2 -2
- package/tests/theme-strings-client.test.tsx +5 -5
- package/tests/theme-strings-pseudo.test.tsx +1 -1
- package/tests/theme-strings-typed.test.tsx +1 -1
- package/tests/theme-strings.test.tsx +8 -11
- package/tests/use-shop-prefetch.test.tsx +1 -1
- package/theme-mount.tsx +6 -11
- package/types/block.ts +7 -7
- package/types/media.ts +8 -9
- package/types/product.ts +8 -12
- package/types/theme.ts +26 -25
- package/types/vendor.ts +10 -12
- package/utils/announcement.ts +3 -3
- package/utils/benefit-icon.tsx +9 -11
- package/utils/brand.ts +7 -10
- package/utils/format.ts +5 -7
- package/utils/image-srcset.ts +2 -2
- package/utils/locale-switch.ts +208 -0
- package/utils/locale.ts +10 -11
- package/utils/menu-link.ts +1 -1
- package/utils/menu-placement.ts +91 -0
- package/utils/product-media.ts +1 -1
- package/utils/product-normalizer.ts +2 -2
- 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
package
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
importing through them pulls every client module into one chunk and
|
|
39
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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`
|
|
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
|
|
174
|
-
`formatCount`/`formatMoney` take an optional locale, defaulting to
|
|
175
|
-
|
|
176
|
-
|
|
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.
|
|
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
|
-
- **
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
328
|
-
|
|
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
|
|
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,202 @@ 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
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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 variant="menu" /> // button + popover (sheet on phones) — recommended
|
|
350
|
+
<LanguageSwitcher variant="menu" side="top" /> // a footer: opens upward
|
|
351
|
+
<LanguageSwitcher variant="inline-list" /> // every language as a link (footer, up to four)
|
|
352
|
+
<LanguageSwitcher /> // native <select> — legacy
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Place it unconditionally, in the header or the footer: it renders nothing on a
|
|
356
|
+
single-language store, outside a storefront, and until the host passes the
|
|
357
|
+
store's languages.
|
|
358
|
+
|
|
359
|
+
| Prop | Default | |
|
|
360
|
+
| --- | --- | --- |
|
|
361
|
+
| `className` | | Extra class on the root, to position or restyle it. |
|
|
362
|
+
| `variant` | `'select'` | `'menu'`: a trigger button and a list of language links (below). `'inline-list'`: a link per language that wraps on narrow screens. `'select'`: one native dropdown, **legacy** — it works and is unchanged, but its open list is the operating system's menu and cannot be styled; prefer `menu`. |
|
|
363
|
+
| `side` | `'bottom'` | `menu` only. `'top'` opens above the trigger, for footers. Flips by itself when the preferred side does not fit. |
|
|
364
|
+
| `align` | `'end'` | `menu` only. Line the popover up with the trigger's inline `'end'` or `'start'` edge (mirrors in right-to-left). Flips and shifts to stay inside the viewport. |
|
|
365
|
+
| `showNativeName` | `true` | Label languages in their own language (`Français`) instead of English (`French`). |
|
|
366
|
+
| `showCode` | `false` | Show the language code (`FR`) next to the name. |
|
|
367
|
+
| `children` | | Render-prop `(state) => ReactNode`: replaces the default control entirely (never called on a single-language store). `state` is `useLocales()` plus `label`, `labelFor(locale)`, `displayName(locale)`, `open` and `setOpen`. |
|
|
368
|
+
|
|
369
|
+
### The `menu` variant
|
|
370
|
+
|
|
371
|
+
A real `<button>` opens a list of language **links** (`<a href>` built from the
|
|
372
|
+
current page, so open-in-new-tab and copy-link work). On screens wider than
|
|
373
|
+
640px it is a popover under the trigger; below 640px it becomes a bottom sheet
|
|
374
|
+
with a scrim, safe-area padding, and page scroll locked while it is open. The
|
|
375
|
+
wide/narrow layout switch is CSS only (one media query), so the markup is the
|
|
376
|
+
same on both and server and client agree; JavaScript only consults the media
|
|
377
|
+
query while the menu opens, to lock scroll and to place the popover.
|
|
378
|
+
With more than four languages in a footer, use `menu` with `side="top"` rather
|
|
379
|
+
than `inline-list`. 2 to 20 languages are fine: the list scrolls past about
|
|
380
|
+
eight rows, and a long list can use two columns on wide screens (below).
|
|
381
|
+
|
|
382
|
+
It is the *disclosure* pattern, not a listbox, because choosing an item
|
|
383
|
+
navigates: the button has `aria-expanded` and `aria-controls` and the name
|
|
384
|
+
"Language: English"; the list is a `<ul>` labelled by its title; the current
|
|
385
|
+
language's link has `aria-current="true"`; there are no `option` roles.
|
|
386
|
+
|
|
387
|
+
| Key | |
|
|
388
|
+
| --- | --- |
|
|
389
|
+
| Enter, Space, ArrowDown on the button | Open, focus on the current language |
|
|
390
|
+
| ArrowUp on the button | Open, focus on the last language |
|
|
391
|
+
| ArrowDown / ArrowUp in the list | Next / previous link (wraps) |
|
|
392
|
+
| Home / End | First / last link |
|
|
393
|
+
| Typing letters | Jump to the language whose name or code starts with them (500 ms buffer) |
|
|
394
|
+
| Enter on a link | Follow it (native); Space does the same |
|
|
395
|
+
| Escape | Close and return focus to the button |
|
|
396
|
+
| Tab | Close the menu |
|
|
397
|
+
| Click outside, scrim tap | Close |
|
|
398
|
+
|
|
399
|
+
Only the focused link is in the tab order (roving tabindex), so Tab leaves the
|
|
400
|
+
list and closes it. Choosing a language closes the menu, returns focus to the
|
|
401
|
+
button and navigates (a modified click — new tab, copy link — is left to the
|
|
402
|
+
browser). Motion respects `prefers-reduced-motion` (an 80 ms fade only).
|
|
403
|
+
|
|
404
|
+
**Styling surface.** Every part has a class and a `data-part` attribute; the
|
|
405
|
+
root also carries state attributes:
|
|
406
|
+
|
|
407
|
+
| Part | Class | `data-part` |
|
|
408
|
+
| --- | --- | --- |
|
|
409
|
+
| Root | `qn-lang qn-lang--menu` | `data-qn-language-switcher`, `data-variant="menu"` |
|
|
410
|
+
| Trigger button | `qn-lang__trigger` | `trigger` |
|
|
411
|
+
| Trigger name / code / chevron | `qn-lang__trigger-label` / `__trigger-code` / `__chevron` | `trigger-label` / `trigger-code` / `chevron` |
|
|
412
|
+
| Scrim (sheet) | `qn-lang__scrim` | `scrim` |
|
|
413
|
+
| Popover / sheet | `qn-lang__popover` | `popover` |
|
|
414
|
+
| Sheet grabber | `qn-lang__handle` | `handle` |
|
|
415
|
+
| Group label | `qn-lang__title` | `title` |
|
|
416
|
+
| List / item | `qn-lang__options` / `__item` | `list` / `item` |
|
|
417
|
+
| Link (current: `aria-current="true"`, `data-current="true"`) | `qn-lang__option` | `option` |
|
|
418
|
+
| Name / code / current marker | `qn-lang__name` / `__code` / `__mark` | `name` / `code` / `mark` |
|
|
419
|
+
|
|
420
|
+
Root attributes: `data-state="open|closed"`, `data-side="bottom|top"`,
|
|
421
|
+
`data-align="start|end"`, `data-mode="popover|sheet"` (while open),
|
|
422
|
+
`data-count="<n>"`, `data-many="true"` (more than ten languages),
|
|
423
|
+
`data-show-code="true"`. The code spans are always rendered and hidden by
|
|
424
|
+
default, so a theme can show a code-only trigger with
|
|
425
|
+
`.my-header [data-part='trigger-label'] { display: none }` plus
|
|
426
|
+
`.my-header [data-part='trigger-code'] { display: inline }`.
|
|
427
|
+
|
|
428
|
+
| Variable | Default | Controls |
|
|
429
|
+
| --- | --- | --- |
|
|
430
|
+
| `--qn-lang-surface` | `--qn-surface` | popover / sheet background |
|
|
431
|
+
| `--qn-lang-ink` | `--qn-text` | text colour |
|
|
432
|
+
| `--qn-lang-muted` | `--qn-text-muted` | title and code colour |
|
|
433
|
+
| `--qn-lang-border` | `--qn-border` | hairlines |
|
|
434
|
+
| `--qn-lang-radius` | `--qn-radius-md` (8px) | trigger / popover corners |
|
|
435
|
+
| `--qn-lang-item-radius` | `--qn-radius-sm` (6px) | link corners |
|
|
436
|
+
| `--qn-lang-item-hover` / `--qn-lang-current-bg` | 6% ink / none | hover and current-link backgrounds |
|
|
437
|
+
| `--qn-lang-accent` | `--qn-accent` | current marker colour |
|
|
438
|
+
| `--qn-lang-focus` | `--qn-focus` | focus ring |
|
|
439
|
+
| `--qn-lang-shadow` | subtle | popover shadow |
|
|
440
|
+
| `--qn-lang-row-height` / `--qn-lang-sheet-row-height` | 44px / 52px | link height (popover / sheet) |
|
|
441
|
+
| `--qn-lang-gap` / `--qn-lang-pad` | 0 / 4px | space between links / inside the popover |
|
|
442
|
+
| `--qn-lang-offset` | 8px | gap between trigger and popover |
|
|
443
|
+
| `--qn-lang-popover-width` | 240px | popover width |
|
|
444
|
+
| `--qn-lang-many-width` / `--qn-lang-many-columns` | popover width / 1 | width and columns when `data-many="true"` (set `--qn-lang-many-columns: 2` and a wider width for two columns) |
|
|
445
|
+
| `--qn-lang-list-max` | 8.5 rows | height at which the list scrolls |
|
|
446
|
+
| `--qn-lang-sheet-radius` | 16px | sheet top corners |
|
|
447
|
+
| `--qn-lang-scrim` | 40% black | scrim colour |
|
|
448
|
+
| `--qn-lang-z` | 50 | z-index |
|
|
449
|
+
| `--qn-lang-duration` / `--qn-lang-ease` | 160ms / ease-out curve | open motion |
|
|
450
|
+
|
|
451
|
+
Each variable falls back to the matching `--qn-*` token, then a neutral
|
|
452
|
+
value, and is never set by the kit itself — set it on the root, on an
|
|
453
|
+
ancestor, or on `:root`. The sheet breakpoint (640px) is fixed.
|
|
454
|
+
|
|
455
|
+
Restyling recipe — no override of internals needed:
|
|
456
|
+
|
|
457
|
+
```css
|
|
458
|
+
.site-header [data-qn-language-switcher] {
|
|
459
|
+
--qn-lang-surface: #f3f0e9;
|
|
460
|
+
--qn-lang-ink: #1b2029;
|
|
461
|
+
--qn-lang-radius: 0;
|
|
462
|
+
--qn-lang-item-radius: 0;
|
|
463
|
+
--qn-lang-popover-width: 280px;
|
|
464
|
+
--qn-lang-sheet-radius: 0;
|
|
465
|
+
--qn-lang-duration: 120ms;
|
|
466
|
+
}
|
|
467
|
+
.site-header [data-part='trigger'] { border: 0; letter-spacing: 0.12em; text-transform: uppercase; }
|
|
468
|
+
.site-header [data-part='option'][aria-current='true'] { font-style: italic; }
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
**Tell the storefront the theme places it.** Set `languageSwitcher: 'theme'` in
|
|
472
|
+
the theme manifest. Without it the storefront keeps rendering its own floating
|
|
473
|
+
language control next to yours; with it the storefront renders none and the
|
|
474
|
+
shopper sees exactly one.
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
// in the theme module
|
|
478
|
+
manifest: { /* … */ languageSwitcher: 'theme' } // ThemeManifest.languageSwitcher
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
**Fully custom markup.** `useLocales()` (from
|
|
482
|
+
`@usequeek/theme-kit/hooks/use-locales`) returns:
|
|
483
|
+
|
|
484
|
+
| Field | |
|
|
485
|
+
| --- | --- |
|
|
486
|
+
| `locales` | The store's languages, default first. One entry, or none, on a single-language store. |
|
|
487
|
+
| `active` | The language the page renders in. |
|
|
488
|
+
| `defaultLocale` | The language served at the store root. |
|
|
489
|
+
| `hasMultiple` | `locales.length > 1`. |
|
|
490
|
+
| `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. |
|
|
491
|
+
| `switchTo(locale)` | Navigate to it, reading the live query string and hash. Use it for `onClick`/`onChange`. |
|
|
492
|
+
|
|
493
|
+
Each language is `{ locale, name, native_name, is_primary, is_default,
|
|
494
|
+
path_prefix, hreflang, html_lang, dir }`. Use `lang={l.html_lang}` and
|
|
495
|
+
`dir={l.dir}` on a label written in that language. The pure URL rules are also
|
|
496
|
+
exported (`localeHref`, `normalizeStoreLocales`, `defaultStoreLocale`,
|
|
497
|
+
`resolveActiveLocale` from `@usequeek/theme-kit/utils/locale-switch`).
|
|
498
|
+
|
|
499
|
+
**Strings.** `language.label` ("Language") names the control and
|
|
500
|
+
`language.named` ("Language: {name}") names the select and the current
|
|
501
|
+
language. Both are kit core strings with English defaults; a theme can
|
|
502
|
+
override them in its manifest `strings`, and merchants and translators in
|
|
503
|
+
their locale packs.
|
|
504
|
+
|
|
505
|
+
**Styling.** The default control reads only these tokens, each with a neutral
|
|
506
|
+
fallback, and uses logical properties, so it mirrors in right-to-left
|
|
507
|
+
storefronts and sits in a header or footer unstyled:
|
|
508
|
+
|
|
509
|
+
| Token | Used for | Fallback |
|
|
510
|
+
| --- | --- | --- |
|
|
511
|
+
| `--qn-text` | text and icon colour | inherited |
|
|
512
|
+
| `--qn-border` | select border | 22% of the text colour |
|
|
513
|
+
| `--qn-border-strong` | select border on hover | the text colour |
|
|
514
|
+
| `--qn-surface` | select background | transparent |
|
|
515
|
+
| `--qn-radius-sm` | corner radius | `6px` |
|
|
516
|
+
| `--qn-focus` | focus ring | the text colour |
|
|
517
|
+
|
|
518
|
+
Class names for deeper overrides: `qn-lang`, `qn-lang--select`,
|
|
519
|
+
`qn-lang--inline-list`, `qn-lang__select`, `qn-lang__list`, `qn-lang__link`.
|
|
520
|
+
Targets are at least 44px. The root carries `data-qn-language-switcher`.
|
|
521
|
+
|
|
522
|
+
**Host side.** The host passes the store's languages to
|
|
523
|
+
`StorefrontProvider` as the optional `locales` prop (the rows as it already
|
|
524
|
+
holds them; extra fields are ignored, and a row without `is_default` falls
|
|
525
|
+
back to `is_primary`). `basePath` must already carry the active language
|
|
526
|
+
prefix, as in the Locales section. No prop, or one language: no switcher, and
|
|
527
|
+
everything behaves as before.
|
|
356
528
|
|
|
357
529
|
## Links and navigation
|
|
358
530
|
|
|
@@ -361,42 +533,46 @@ import { Link, useRouter, usePathname } from '@usequeek/theme-kit/navigation';
|
|
|
361
533
|
```
|
|
362
534
|
|
|
363
535
|
A theme links and navigates through these, never through `next/link` or
|
|
364
|
-
`next/navigation`. Which framework runs the storefront is Queek's decision
|
|
365
|
-
kit is the one place that knows it,
|
|
366
|
-
update to every theme. `Link` is an `<a>` that moves inside the
|
|
367
|
-
full page load (`href`, `prefetch`, `replace`, plus any anchor
|
|
368
|
-
`useRouter()` gives `push`, `replace`, `back` and `refresh`;
|
|
369
|
-
returns the current path. The theme check rejects a theme that
|
|
536
|
+
`next/navigation`. Which framework runs the storefront is Queek's decision, so
|
|
537
|
+
the kit is the one place that knows it, and a framework change is a kit
|
|
538
|
+
release, not an update to every theme. `Link` is an `<a>` that moves inside the
|
|
539
|
+
store without a full page load (`href`, `prefetch`, `replace`, plus any anchor
|
|
540
|
+
attribute); `useRouter()` gives `push`, `replace`, `back` and `refresh`;
|
|
541
|
+
`usePathname()` returns the current path. The theme check rejects a theme that
|
|
542
|
+
imports `next`.
|
|
370
543
|
|
|
371
544
|
## Boundary
|
|
372
545
|
|
|
373
|
-
The kit
|
|
374
|
-
|
|
375
|
-
also holds for the framework: the kit may import `next`, a theme may not.
|
|
546
|
+
The kit never imports a host app or any theme. The reverse rule holds for the
|
|
547
|
+
framework: the kit may import `next`, a theme may not.
|
|
376
548
|
|
|
377
549
|
## Develop
|
|
378
550
|
|
|
379
551
|
```bash
|
|
380
552
|
npm install
|
|
381
|
-
npm run typecheck
|
|
382
|
-
npm test
|
|
553
|
+
npm run typecheck # tsc --noEmit
|
|
554
|
+
npm test # public-text check, then vitest run
|
|
383
555
|
npm run verify-package # pack current source, typecheck a real theme against it
|
|
384
556
|
```
|
|
385
557
|
|
|
558
|
+
`verify-package` packs the current source and typechecks a real theme against
|
|
559
|
+
it from a sandbox, so a break that only external consumers would see (for
|
|
560
|
+
example a module relying on the global `JSX` namespace, which React 19 removed)
|
|
561
|
+
fails here instead of after publishing.
|
|
562
|
+
|
|
386
563
|
## Release
|
|
387
564
|
|
|
388
|
-
|
|
389
|
-
|
|
565
|
+
Publishing runs on tags via `.github/workflows/publish.yml`, which needs the
|
|
566
|
+
`NPM_TOKEN` repository secret:
|
|
390
567
|
|
|
391
568
|
```bash
|
|
392
569
|
# 1. Bump version in package.json and commit
|
|
393
|
-
# 2. Tag and push the tag — the workflow typechecks, tests, verify-
|
|
570
|
+
# 2. Tag and push the tag — the workflow typechecks, tests, runs verify-package,
|
|
394
571
|
# then `npm publish --access public`:
|
|
395
|
-
git tag
|
|
572
|
+
git tag vX.Y.Z
|
|
396
573
|
git push origin main --tags
|
|
397
574
|
```
|
|
398
575
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
actually exists on the registry.
|
|
576
|
+
## License
|
|
577
|
+
|
|
578
|
+
MIT
|
package/api/collections.ts
CHANGED
|
@@ -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
|
-
/**
|
|
8
|
-
*
|
|
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
|
|
21
|
-
// X-Platform=storefront (base prices, no marketplace commission) — never
|
|
22
|
-
// legacy v1 routes shared with the
|
|
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
|
|
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
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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();
|