@usequeek/theme-kit 0.1.17 → 0.1.19

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 CHANGED
@@ -42,6 +42,57 @@ Theme CSS ships with the package: `@usequeek/theme-kit/shared-blocks/core-blocks
42
42
  (framework-owned blocks), plus per-component CSS next to its component
43
43
  (`components/variant-picker.css`, `apps/apps.css`).
44
44
 
45
+ ## Theme strings
46
+
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.
50
+
51
+ - **Authoring contract.** `t(dictionaries, key, vars?, locale?)`: dot-path
52
+ lookup (`cart.title`), `{var}` interpolation (`{{`/`}}` escape to literal
53
+ braces; a missing var keeps its `{placeholder}`), CLDR plurals via
54
+ `Intl.PluralRules` — a key holding `{ one, two, few, many, other }`
55
+ (nested, or flat `key.one` … `key.other` suffix keys) selects by
56
+ `vars.count`, defaulting to `other`. Locale-aware `formatThemeNumber`,
57
+ `formatThemeDate` and `formatThemeMoney` replace hardcoded `en-GB`
58
+ 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.
62
+ - **Two surfaces.** Server (provider-free): the host supplies per-locale
63
+ loaders and calls
64
+ `createThemeStrings({ locale, loaders })` — `{ override, theme, core }`,
65
+ each `(locale) => Promise<dict | null>` — usually a per-locale dynamic
66
+ `import()` with a try/catch returning null when the file is absent — then
67
+ passes the bound `t` via props/closure. A throwing loader simply drops
68
+ out of the chain.
69
+ Client: `<StorefrontProvider strings={…}>` (optional, no-op when absent)
70
+ plus `useThemeStrings()` returning the same bound `t`; works inside
71
+ `ThemeMount`. The kit ships `locales/en.default.json` (cart, checkout,
72
+ auth, blog) as the last-resort English default.
73
+ - **Fallback.** Ordered dictionaries (merchant override → host-loaded
74
+ locale dictionaries → `manifest.strings` → kit core English default);
75
+ first hit wins. The last fallback is the English default *value* —
76
+ never the raw key, never "translation missing". Absent everywhere
77
+ renders `""` (and warns once per key in development only — an empty
78
+ string would otherwise leak internal key names into the UI).
79
+ - **Theme English travels with the theme.** A theme ships its own English
80
+ defaults in its manifest: `import strings from './locales/en.default.json'`
81
+ inside the theme, then `manifest: { …, strings }` (`ThemeManifest.strings`,
82
+ ENGLISH ONLY — never other locales). `useThemeStrings()` layers it
83
+ between the host's `strings` prop and the kit core English, so hosts that
84
+ mount a theme with no `strings` (e.g. theme previews) still render the
85
+ theme's English with zero host cooperation. Host dictionaries always win
86
+ where present; keys missing there fall back to manifest English, never
87
+ to `""`.
88
+ - **Per-locale loading.** `createThemeStrings` reads ONLY the active
89
+ locale's dictionaries, and the host passes ONLY that locale to the
90
+ provider — other `{lang}.json` files never enter the client bundle.
91
+ Only `locales/en.default.json` may be statically imported (by
92
+ `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.
95
+
45
96
  ## Consumers
46
97
 
47
98
  - **The Queek storefront** (`usequeek/queek-storefront`) installs this package
@@ -5,6 +5,7 @@ import { useRef, useState } from 'react';
5
5
  import Link from 'next/link';
6
6
  import type { CartShellProps } from '../../types/theme';
7
7
  import { useHref } from '../../hooks/use-href';
8
+ import { useThemeStrings } from '../../provider';
8
9
  import { useCheckoutStore } from '../../stores/checkout-store';
9
10
  import { useRelatedProducts } from '../../hooks/use-related-products';
10
11
  import { useCart } from '../../hooks/use-cart';
@@ -27,6 +28,7 @@ export function DefaultCartShell({
27
28
  onRemove,
28
29
  }: CartShellProps): JSX.Element {
29
30
  const href = useHref();
31
+ const t = useThemeStrings();
30
32
  const { addProduct } = useCart();
31
33
  const vendorNote = useCheckoutStore((s) => s.vendorNote);
32
34
  const setVendorNote = useCheckoutStore((s) => s.setVendorNote);
@@ -48,10 +50,10 @@ export function DefaultCartShell({
48
50
  if (empty) {
49
51
  return (
50
52
  <div className="core-cart-page">
51
- <h1 className="core-cart-page__title">Your cart</h1>
53
+ <h1 className="core-cart-page__title">{t('cart.title')}</h1>
52
54
  <div className="core-cart-page__empty">
53
- <p>Your cart is empty.</p>
54
- <Link href={href('/')} className="core-checkout__back-link">Browse products</Link>
55
+ <p>{t('cart.empty.message')}</p>
56
+ <Link href={href('/')} className="core-checkout__back-link">{t('cart.empty.browse')}</Link>
55
57
  </div>
56
58
  </div>
57
59
  );
@@ -59,7 +61,7 @@ export function DefaultCartShell({
59
61
 
60
62
  return (
61
63
  <div className="core-cart-page">
62
- <h1 className="core-cart-page__title">Your cart</h1>
64
+ <h1 className="core-cart-page__title">{t('cart.title')}</h1>
63
65
 
64
66
  <div className="core-cart-page__grid">
65
67
  <div className="core-cart-page__main">
@@ -74,19 +76,19 @@ export function DefaultCartShell({
74
76
  )}
75
77
  <div className="core-cart-page__item-info">
76
78
  <span className="core-cart-page__item-title">{item.title}</span>
77
- <span className="core-cart-page__item-price">{formatMoney(item.unit_price, currency)} each</span>
79
+ <span className="core-cart-page__item-price">{t('cart.item.each', { price: formatMoney(item.unit_price, currency) })}</span>
78
80
  </div>
79
81
  <div className="core-cart-page__item-qty">
80
- <button type="button" onClick={() => onDecrease(item.id, item.shop_id)} aria-label={`Decrease ${item.title}`}>
82
+ <button type="button" onClick={() => onDecrease(item.id, item.shop_id)} aria-label={t('cart.item.decrease', { title: item.title })}>
81
83
  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M2 6h8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
82
84
  </button>
83
85
  <span>{item.quantity}</span>
84
- <button type="button" onClick={() => onIncrease(item.id, item.shop_id)} aria-label={`Increase ${item.title}`}>
86
+ <button type="button" onClick={() => onIncrease(item.id, item.shop_id)} aria-label={t('cart.item.increase', { title: item.title })}>
85
87
  <svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden="true"><path d="M6 2v8M2 6h8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
86
88
  </button>
87
89
  </div>
88
90
  <span className="core-cart-page__item-total">{formatMoney(item.unit_price * item.quantity, currency)}</span>
89
- <button type="button" className="core-cart-page__item-remove" onClick={() => onRemove(item.id, item.shop_id)} aria-label={`Remove ${item.title}`}>
91
+ <button type="button" className="core-cart-page__item-remove" onClick={() => onRemove(item.id, item.shop_id)} aria-label={t('cart.item.remove', { title: item.title })}>
90
92
  <svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
91
93
  <path d="M4 6h12M8 6V4.5A1.5 1.5 0 019.5 3h1A1.5 1.5 0 0112 4.5V6m-6 0v9.5A1.5 1.5 0 007.5 17h5a1.5 1.5 0 001.5-1.5V6" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
92
94
  </svg>
@@ -99,12 +101,12 @@ export function DefaultCartShell({
99
101
  {related.length > 0 ? (
100
102
  <section className="core-cart-page__related">
101
103
  <div className="core-cart-page__related-head">
102
- <h2>You may also like</h2>
104
+ <h2>{t('cart.related.title')}</h2>
103
105
  <div className="core-cart-page__related-nav">
104
- <button type="button" onClick={() => scrollRelated(-1)} aria-label="Previous">
106
+ <button type="button" onClick={() => scrollRelated(-1)} aria-label={t('cart.related.prev')}>
105
107
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M10 13L5 8l5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
106
108
  </button>
107
- <button type="button" onClick={() => scrollRelated(1)} aria-label="Next">
109
+ <button type="button" onClick={() => scrollRelated(1)} aria-label={t('cart.related.next')}>
108
110
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M6 3l5 5-5 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /></svg>
109
111
  </button>
110
112
  </div>
@@ -125,13 +127,13 @@ export function DefaultCartShell({
125
127
  )}
126
128
  <div className="core-cart-page__related-info">
127
129
  <span className="core-cart-page__related-title">{product.title}</span>
128
- <span className="core-cart-page__related-price">{product.pricing.is_price_from ? 'From ' : ''}{formatMoney(product.pricing.sale_amount, currency)}</span>
130
+ <span className="core-cart-page__related-price">{product.pricing.is_price_from ? t('cart.related.from', { price: formatMoney(product.pricing.sale_amount, currency) }) : formatMoney(product.pricing.sale_amount, currency)}</span>
129
131
  </div>
130
132
  {productRequiresVariant(product) ? (
131
133
  <Link
132
134
  className="core-cart-page__related-add"
133
135
  href={href(`/products/${product.slug}`)}
134
- aria-label={`Choose options for ${product.title}`}
136
+ aria-label={t('cart.related.choose', { title: product.title })}
135
137
  >
136
138
  <svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
137
139
  <path d="M7 4l6 6-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
@@ -141,7 +143,7 @@ export function DefaultCartShell({
141
143
  <button
142
144
  type="button"
143
145
  className="core-cart-page__related-add"
144
- aria-label={`Add ${product.title} to cart`}
146
+ aria-label={t('cart.related.add', { title: product.title })}
145
147
  onClick={() => addProduct(product)}
146
148
  >
147
149
  <svg width="16" height="16" viewBox="0 0 20 20" fill="none" aria-hidden="true">
@@ -160,10 +162,10 @@ export function DefaultCartShell({
160
162
 
161
163
  <div className="core-cart-page__summary">
162
164
  <div className="core-cart-page__summary-card">
163
- <h2 className="core-cart-page__summary-title">Order summary</h2>
165
+ <h2 className="core-cart-page__summary-title">{t('cart.summary.title')}</h2>
164
166
 
165
167
  <button type="button" className="core-cart-page__note-toggle" onClick={() => setNoteOpen((v) => !v)}>
166
- Add order note
168
+ {t('cart.note.toggle')}
167
169
  <span aria-hidden="true">{noteOpen ? '−' : '+'}</span>
168
170
  </button>
169
171
  {noteOpen ? (
@@ -172,18 +174,18 @@ export function DefaultCartShell({
172
174
  rows={3}
173
175
  value={vendorNote}
174
176
  onChange={(event) => setVendorNote(event.target.value)}
175
- placeholder="Order instructions (optional)"
177
+ placeholder={t('cart.note.placeholder')}
176
178
  />
177
179
  ) : null}
178
180
 
179
181
  <div className="core-cart-page__subtotal">
180
- <span>Subtotal:</span>
182
+ <span>{t('cart.summary.subtotal')}</span>
181
183
  <strong>{formatMoney(total, currency)} {currency}</strong>
182
184
  </div>
183
185
 
184
186
  <label className="core-cart-page__terms">
185
187
  <input type="checkbox" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} />
186
- <span>I agree to the <Link href={href('/policies')}>terms and refund policy</Link></span>
188
+ <span>{t('cart.terms.agree')}<Link href={href('/policies')}>{t('cart.terms.policy')}</Link></span>
187
189
  </label>
188
190
 
189
191
  <Link
@@ -197,9 +199,9 @@ export function DefaultCartShell({
197
199
  <circle cx="8" cy="17" r="1" fill="currentColor" />
198
200
  <circle cx="14" cy="17" r="1" fill="currentColor" />
199
201
  </svg>
200
- Checkout
202
+ {t('cart.checkout.action')}
201
203
  </Link>
202
- <Link href={href('/')} className="core-cart-page__continue-link">Continue shopping</Link>
204
+ <Link href={href('/')} className="core-cart-page__continue-link">{t('cart.summary.continue')}</Link>
203
205
  </div>
204
206
  </div>
205
207
  </div>
package/index.ts CHANGED
@@ -49,5 +49,8 @@ export * from './utils/video-embed';
49
49
  export * from './utils/match-variant';
50
50
  export * from './utils/announcement';
51
51
 
52
+ // Theme strings — the pure/server-safe i18n core (no next/*, no React).
53
+ export * from './strings/theme-strings';
54
+
52
55
  // Rendering a theme's own demo.json — what makes local preview possible.
53
56
  export * from './demo';
@@ -0,0 +1,138 @@
1
+ {
2
+ "cart": {
3
+ "title": "Your cart",
4
+ "empty": {
5
+ "message": "Your cart is empty.",
6
+ "browse": "Browse products"
7
+ },
8
+ "item": {
9
+ "each": "{price} each",
10
+ "decrease": "Decrease {title}",
11
+ "increase": "Increase {title}",
12
+ "remove": "Remove {title}"
13
+ },
14
+ "related": {
15
+ "title": "You may also like",
16
+ "prev": "Previous",
17
+ "next": "Next",
18
+ "choose": "Choose options for {title}",
19
+ "add": "Add {title} to cart",
20
+ "from": "From {price}"
21
+ },
22
+ "summary": {
23
+ "title": "Order summary",
24
+ "subtotal": "Subtotal:",
25
+ "continue": "Continue shopping"
26
+ },
27
+ "note": {
28
+ "toggle": "Add order note",
29
+ "placeholder": "Order instructions (optional)"
30
+ },
31
+ "terms": {
32
+ "agree": "I agree to the ",
33
+ "policy": "terms and refund policy"
34
+ },
35
+ "checkout": {
36
+ "action": "Checkout"
37
+ }
38
+ },
39
+ "checkout": {
40
+ "empty": {
41
+ "message": "Your cart is empty.",
42
+ "browse": "Browse products"
43
+ },
44
+ "page": {
45
+ "back": "Continue shopping"
46
+ },
47
+ "contact": {
48
+ "label": "Contact",
49
+ "signin": "Sign in",
50
+ "signout": "Sign out",
51
+ "options": "Account options"
52
+ },
53
+ "delivery": {
54
+ "title": "How you'll get it"
55
+ },
56
+ "zone": {
57
+ "title": "Shipping zone"
58
+ },
59
+ "address": {
60
+ "title": "Delivery address"
61
+ },
62
+ "schedule": {
63
+ "title": "Schedule"
64
+ },
65
+ "payment": {
66
+ "title": "Payment",
67
+ "secure": "All transactions are secure and encrypted."
68
+ },
69
+ "summary": {
70
+ "title": "Order summary",
71
+ "empty": "Your cart is empty",
72
+ "subtotal": "Subtotal",
73
+ "proceed": "Proceed to checkout"
74
+ },
75
+ "tabs": {
76
+ "summary": "Summary",
77
+ "checkout": "Checkout"
78
+ },
79
+ "policy": {
80
+ "close": "Close",
81
+ "view": "View full page",
82
+ "list": "Store policies"
83
+ }
84
+ },
85
+ "auth": {
86
+ "login": {
87
+ "title": "Sign in",
88
+ "subtitle": "Enter your email and password",
89
+ "email": "Email address",
90
+ "password": "Password"
91
+ },
92
+ "email": {
93
+ "title": "Sign in",
94
+ "subtitle": "Enter your email to continue",
95
+ "or": "or",
96
+ "placeholder": "your@email.com"
97
+ },
98
+ "code": {
99
+ "title": "Enter code",
100
+ "sent": "{length}-digit code sent to {email}",
101
+ "digit": "Digit {index}"
102
+ },
103
+ "register": {
104
+ "title": "Create account",
105
+ "subtitle": "Fill in your details to get started",
106
+ "first": "First name",
107
+ "last": "Last name (optional)",
108
+ "email": "Email address",
109
+ "password": "Password (min 8 characters)"
110
+ },
111
+ "signup": {
112
+ "title": "Create your account",
113
+ "subtitle": "Just a few details to get you started",
114
+ "first": "First name",
115
+ "last": "Last name (optional)"
116
+ }
117
+ },
118
+ "blog": {
119
+ "pagination": {
120
+ "prev": "← Previous",
121
+ "next": "Next →",
122
+ "nav": "Blog pagination",
123
+ "page": "Page {current}"
124
+ },
125
+ "filter": {
126
+ "all": "All",
127
+ "nav": "Blog categories"
128
+ },
129
+ "share": {
130
+ "label": "Share",
131
+ "action": "Share",
132
+ "x": "Share on X",
133
+ "facebook": "Share on Facebook",
134
+ "whatsapp": "Share on WhatsApp",
135
+ "copy": "Copy link"
136
+ }
137
+ }
138
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequeek/theme-kit",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "description": "The framework a Queek storefront theme is built against.",
@@ -23,6 +23,7 @@
23
23
  "**/*.ts",
24
24
  "**/*.tsx",
25
25
  "**/*.css",
26
+ "locales/*.json",
26
27
  "README.md",
27
28
  "LICENSE"
28
29
  ],
package/provider.tsx CHANGED
@@ -9,7 +9,15 @@ import type { Product } from './types/product';
9
9
  import type { StorefrontConfig, VendorProfile } from './types/vendor';
10
10
  import { getBrandCssVariables } from './utils/brand';
11
11
  import { parseLocaleCode } from './utils/locale';
12
+ import {
13
+ defaultThemeStrings,
14
+ t as translateThemeStrings,
15
+ type ThemeStringsDictionary,
16
+ type ThemeStringsFn,
17
+ type ThemeStringsVars,
18
+ } from './strings/theme-strings';
12
19
  import { DesignTokenPreviewListener } from './design-token-preview';
20
+ import { useOptionalTheme } from './theme-context';
13
21
 
14
22
  export interface StorefrontPreviewData {
15
23
  products?: Product[];
@@ -40,6 +48,11 @@ interface StorefrontContextValue {
40
48
  /** Validated request locale (null = primary). Browser hooks read it via
41
49
  * `useStorefrontLocale()` and send `?locale=` when it is set. */
42
50
  locale: string | null;
51
+ /** Active locale's dictionaries (override, theme, core — the host merges and
52
+ * passes ONLY the active locale). Empty or omitted = English defaults only.
53
+ * Optional so hand-built context values (storefront scripts, tests) keep
54
+ * compiling without it. */
55
+ strings?: ThemeStringsDictionary[];
43
56
  }
44
57
 
45
58
  export const StorefrontContext = createContext<StorefrontContextValue | null>(null);
@@ -52,6 +65,7 @@ export function StorefrontProvider({
52
65
  basePath,
53
66
  pagesChrome,
54
67
  locale,
68
+ strings,
55
69
  children,
56
70
  }: {
57
71
  vendor: VendorProfile;
@@ -67,10 +81,27 @@ export function StorefrontProvider({
67
81
  * the host owns it, like `basePath`.
68
82
  */
69
83
  locale?: string | null;
84
+ /**
85
+ * Active locale's theme-string dictionaries (merchant override, theme
86
+ * locale, kit core locale — the host merges and passes ONLY the active
87
+ * locale, loaded per-locale so no other language ships to the client).
88
+ * Optional — absent means English defaults, and every kit component
89
+ * renders exactly as before. Existing props are untouched.
90
+ */
91
+ strings?: ThemeStringsDictionary | ThemeStringsDictionary[] | null;
70
92
  children: ReactNode;
71
93
  }): JSX.Element {
72
94
  const resolvedBasePath = basePath ?? `/${vendor.slug ?? ''}`;
73
95
  const resolvedLocale = parseLocaleCode(locale);
96
+ const resolvedStrings = useMemo(
97
+ () =>
98
+ !strings
99
+ ? []
100
+ : (Array.isArray(strings) ? strings : [strings]).filter(
101
+ (d): d is ThemeStringsDictionary => !!d,
102
+ ),
103
+ [strings],
104
+ );
74
105
  const value = useMemo(
75
106
  () => ({
76
107
  vendor,
@@ -80,8 +111,9 @@ export function StorefrontProvider({
80
111
  basePath: resolvedBasePath,
81
112
  pagesChrome: pagesChrome ?? {},
82
113
  locale: resolvedLocale,
114
+ strings: resolvedStrings,
83
115
  }),
84
- [config, menus, previewData, vendor, resolvedBasePath, pagesChrome, resolvedLocale],
116
+ [config, menus, previewData, vendor, resolvedBasePath, pagesChrome, resolvedLocale, resolvedStrings],
85
117
  );
86
118
  const brandRootRef = useRef<HTMLDivElement>(null);
87
119
 
@@ -120,3 +152,37 @@ export function useStorefrontLocale(): string | null {
120
152
 
121
153
  return context.locale ?? null;
122
154
  }
155
+
156
+ /**
157
+ * The client theme-string surface: the bound `t` for the active locale.
158
+ * Chain order: merchant/host override dictionaries (`strings` prop), then
159
+ * the mounted theme's `manifest.strings` (theme English — travels WITH the
160
+ * theme, so preview hosts that pass no `strings` still render English),
161
+ * then the kit core English default — never the raw key. Works in themes
162
+ * mounted by `ThemeMount` and in hosts that provide the theme directly
163
+ * (both expose it through the theme context). No `strings` prop and no
164
+ * manifest → the English defaults, so kit components render exactly as
165
+ * before and third-party themes keep working untranslated.
166
+ */
167
+ export function useThemeStrings(): ThemeStringsFn {
168
+ const context = useContext(StorefrontContext);
169
+
170
+ if (!context) {
171
+ throw new Error('useThemeStrings must be used within StorefrontProvider');
172
+ }
173
+
174
+ const theme = useOptionalTheme();
175
+ const manifestStrings = theme?.manifest?.strings ?? null;
176
+ const { strings, locale } = context;
177
+ return useMemo(
178
+ () =>
179
+ (key: string, vars?: ThemeStringsVars, overrideLocale?: string | null) =>
180
+ translateThemeStrings(
181
+ [...(strings ?? []), ...(manifestStrings ? [manifestStrings] : []), defaultThemeStrings],
182
+ key,
183
+ vars,
184
+ overrideLocale ?? locale ?? undefined,
185
+ ),
186
+ [strings, manifestStrings, locale],
187
+ );
188
+ }