@usequeek/theme-kit 0.1.20 → 0.1.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +264 -77
- package/api/collections.ts +5 -5
- package/api/shop.ts +1 -1
- package/client.ts +2 -0
- package/components/auth/auth-email-login-step.tsx +9 -7
- package/components/auth/auth-email-otp-step.tsx +9 -7
- package/components/auth/auth-email-otp-verify-step.tsx +8 -6
- package/components/auth/auth-email-register-step.tsx +10 -8
- package/components/auth/auth-signup-step.tsx +8 -6
- package/components/blog/category-filter.tsx +6 -5
- package/components/blog/pagination.tsx +8 -7
- package/components/blog/post-meta.tsx +15 -8
- package/components/blog/related-posts.tsx +5 -3
- package/components/blog/share-buttons.tsx +10 -8
- package/components/checkout/checkout-contact.tsx +9 -7
- package/components/checkout/default-checkout-shell.tsx +17 -15
- package/components/checkout/policy-links.tsx +5 -3
- package/components/image.tsx +5 -5
- package/components/language-switcher.css +131 -0
- package/components/language-switcher.tsx +144 -0
- package/components/markdown.tsx +1 -1
- package/components/powered-by-queek.tsx +4 -4
- package/components/product-media-frame.tsx +1 -1
- package/components/product-metafields.tsx +2 -3
- package/components/variant-picker.tsx +6 -9
- package/demo.ts +10 -14
- package/design-token-preview.tsx +0 -2
- package/edit-mode-active.tsx +3 -4
- package/hooks/use-announcement.ts +1 -1
- package/hooks/use-auth-flow.tsx +19 -18
- package/hooks/use-locales.ts +107 -0
- package/hooks/use-posts.ts +1 -1
- package/hooks/use-product-detail.ts +1 -2
- package/hooks/use-product-sections.ts +4 -6
- package/hooks/use-products.ts +5 -8
- package/index.ts +16 -15
- package/locales/en.default.json +49 -7
- package/next-reference.d.ts +2 -4
- package/package.json +3 -2
- package/page-renderer.tsx +5 -6
- package/provider.tsx +22 -2
- package/scripts/verify-package.ts +6 -8
- package/sdk/client.ts +13 -12
- package/sdk/error-handler.ts +1 -1
- package/sdk/platform.ts +6 -8
- package/sdk/queek-client.ts +11 -12
- package/sdk/server-store-client.ts +13 -17
- package/shared-blocks/metaobjects.tsx +1 -1
- package/stores/cart-store.ts +2 -2
- package/stores/checkout-store.ts +1 -1
- package/stores/edit-preview-store.ts +5 -6
- package/stores/user-store.ts +4 -5
- package/strings/pseudo-locale.ts +263 -0
- package/strings/theme-strings.ts +185 -20
- package/tests/auth-email-errors.test.tsx +55 -0
- package/tests/currency-contract.test.ts +2 -3
- package/tests/display-format.test.ts +356 -2
- package/tests/fixtures/external-theme.tsx +18 -2
- package/tests/language-switcher.test.tsx +350 -0
- package/tests/list-pagination.test.tsx +8 -8
- package/tests/locale-switch.test.ts +197 -0
- package/tests/locale-transport.test.tsx +3 -3
- package/tests/markdown-render.test.ts +5 -6
- package/tests/orders-walk.test.ts +5 -5
- package/tests/page-renderer-signature.test.tsx +1 -1
- package/tests/powered-by-queek.test.tsx +3 -3
- package/tests/powered-by-url.test.ts +2 -2
- package/tests/public-surface.test.ts +1 -4
- package/tests/shopper-chrome-strings.test.tsx +271 -0
- package/tests/storefront-client-scope.test.ts +5 -5
- package/tests/theme-hook-placement.test.tsx +179 -0
- package/tests/theme-strings-client.test.tsx +5 -5
- package/tests/theme-strings-pseudo.test.tsx +171 -0
- package/tests/theme-strings-typed.test.tsx +97 -0
- package/tests/theme-strings.test.tsx +10 -7
- package/tests/theme-typed-keys.fixture.ts +76 -0
- package/tests/use-shop-prefetch.test.tsx +1 -1
- package/theme-mount.tsx +6 -11
- package/types/block.ts +7 -7
- package/types/media.ts +8 -9
- package/types/product.ts +8 -12
- package/types/theme.ts +26 -25
- package/types/vendor.ts +10 -12
- package/utils/announcement.ts +3 -3
- package/utils/benefit-icon.tsx +9 -11
- package/utils/brand.ts +7 -10
- package/utils/format.ts +100 -14
- package/utils/image-srcset.ts +2 -2
- package/utils/locale-switch.ts +208 -0
- package/utils/locale.ts +56 -5
- package/utils/menu-link.ts +1 -1
- package/utils/product-media.ts +1 -1
- package/utils/product-normalizer.ts +2 -2
- package/utils/vendor-host.ts +7 -11
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,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.
|
|
94
|
-
|
|
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
|
-
- **
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
211
|
-
|
|
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
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
248
|
-
kit is the one place that knows it,
|
|
249
|
-
update to every theme. `Link` is an `<a>` that moves inside the
|
|
250
|
-
full page load (`href`, `prefetch`, `replace`, plus any anchor
|
|
251
|
-
`useRouter()` gives `push`, `replace`, `back` and `refresh`;
|
|
252
|
-
returns the current path. The theme check rejects a theme that
|
|
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
|
|
257
|
-
|
|
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
|
|
265
|
-
npm test
|
|
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
|
-
|
|
272
|
-
|
|
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-
|
|
464
|
+
# 2. Tag and push the tag — the workflow typechecks, tests, runs verify-package,
|
|
277
465
|
# then `npm publish --access public`:
|
|
278
|
-
git tag
|
|
466
|
+
git tag vX.Y.Z
|
|
279
467
|
git push origin main --tags
|
|
280
468
|
```
|
|
281
469
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
actually exists on the registry.
|
|
470
|
+
## License
|
|
471
|
+
|
|
472
|
+
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';
|
|
@@ -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">
|
|
11
|
-
<p className="core-auth__sub">
|
|
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=
|
|
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=
|
|
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 ? '
|
|
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
|
-
|
|
42
|
+
{t('auth.login.create')}
|
|
41
43
|
</button>
|
|
42
44
|
<button type="button" className="core-auth__link" onClick={goToEmailOtp}>
|
|
43
|
-
|
|
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">
|
|
12
|
-
<p className="core-auth__sub">
|
|
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
|
-
|
|
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=
|
|
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 ? '
|
|
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
|
-
|
|
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">
|
|
45
|
+
<h2 className="core-auth__title">{t('auth.code.title')}</h2>
|
|
44
46
|
<p className="core-auth__sub">
|
|
45
|
-
{
|
|
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={
|
|
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 ? '
|
|
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
|
-
|
|
78
|
+
{t('auth.code.resend')}
|
|
77
79
|
</button>
|
|
78
80
|
<button type="button" className="core-auth__link" onClick={goToEmailOtp}>
|
|
79
|
-
|
|
81
|
+
{t('auth.code.change')}
|
|
80
82
|
</button>
|
|
81
83
|
</div>
|
|
82
84
|
);
|