@usequeek/theme-kit 0.1.17 → 0.1.18

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,47 @@ 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 (override → theme → core → English
74
+ default); first hit wins. The last fallback is the English default
75
+ *value* — never the raw key, never "translation missing". Absent
76
+ everywhere renders `""` (and warns once per key in development only —
77
+ an empty string would otherwise leak internal key names into the UI).
78
+ - **Per-locale loading.** `createThemeStrings` reads ONLY the active
79
+ locale's dictionaries, and the host passes ONLY that locale to the
80
+ provider — other `{lang}.json` files never enter the client bundle.
81
+ Only `locales/en.default.json` may be statically imported (by
82
+ `strings/theme-strings.ts` itself); every other locale arrives via the
83
+ host's dynamic `import()` per locale. `tests/theme-strings.test.tsx`
84
+ gates this: any other static locale import fails the suite.
85
+
45
86
  ## Consumers
46
87
 
47
88
  - **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.18",
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,6 +9,13 @@ 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';
13
20
 
14
21
  export interface StorefrontPreviewData {
@@ -40,6 +47,9 @@ interface StorefrontContextValue {
40
47
  /** Validated request locale (null = primary). Browser hooks read it via
41
48
  * `useStorefrontLocale()` and send `?locale=` when it is set. */
42
49
  locale: string | null;
50
+ /** Active locale's dictionaries (override, theme, core — the host merges and
51
+ * passes ONLY the active locale). Empty = English defaults only. */
52
+ strings: ThemeStringsDictionary[];
43
53
  }
44
54
 
45
55
  export const StorefrontContext = createContext<StorefrontContextValue | null>(null);
@@ -52,6 +62,7 @@ export function StorefrontProvider({
52
62
  basePath,
53
63
  pagesChrome,
54
64
  locale,
65
+ strings,
55
66
  children,
56
67
  }: {
57
68
  vendor: VendorProfile;
@@ -67,10 +78,27 @@ export function StorefrontProvider({
67
78
  * the host owns it, like `basePath`.
68
79
  */
69
80
  locale?: string | null;
81
+ /**
82
+ * Active locale's theme-string dictionaries (merchant override, theme
83
+ * locale, kit core locale — the host merges and passes ONLY the active
84
+ * locale, loaded per-locale so no other language ships to the client).
85
+ * Optional — absent means English defaults, and every kit component
86
+ * renders exactly as before. Existing props are untouched.
87
+ */
88
+ strings?: ThemeStringsDictionary | ThemeStringsDictionary[] | null;
70
89
  children: ReactNode;
71
90
  }): JSX.Element {
72
91
  const resolvedBasePath = basePath ?? `/${vendor.slug ?? ''}`;
73
92
  const resolvedLocale = parseLocaleCode(locale);
93
+ const resolvedStrings = useMemo(
94
+ () =>
95
+ !strings
96
+ ? []
97
+ : (Array.isArray(strings) ? strings : [strings]).filter(
98
+ (d): d is ThemeStringsDictionary => !!d,
99
+ ),
100
+ [strings],
101
+ );
74
102
  const value = useMemo(
75
103
  () => ({
76
104
  vendor,
@@ -80,8 +108,9 @@ export function StorefrontProvider({
80
108
  basePath: resolvedBasePath,
81
109
  pagesChrome: pagesChrome ?? {},
82
110
  locale: resolvedLocale,
111
+ strings: resolvedStrings,
83
112
  }),
84
- [config, menus, previewData, vendor, resolvedBasePath, pagesChrome, resolvedLocale],
113
+ [config, menus, previewData, vendor, resolvedBasePath, pagesChrome, resolvedLocale, resolvedStrings],
85
114
  );
86
115
  const brandRootRef = useRef<HTMLDivElement>(null);
87
116
 
@@ -120,3 +149,31 @@ export function useStorefrontLocale(): string | null {
120
149
 
121
150
  return context.locale ?? null;
122
151
  }
152
+
153
+ /**
154
+ * The client theme-string surface: the bound `t` for the active locale
155
+ * (`strings` prop, then the English default — never the raw key). Works in
156
+ * themes mounted by `ThemeMount` (which renders inside the host's provider).
157
+ * No `strings` prop → the English defaults, so kit components render exactly
158
+ * as before and third-party themes keep working untranslated.
159
+ */
160
+ export function useThemeStrings(): ThemeStringsFn {
161
+ const context = useContext(StorefrontContext);
162
+
163
+ if (!context) {
164
+ throw new Error('useThemeStrings must be used within StorefrontProvider');
165
+ }
166
+
167
+ const { strings, locale } = context;
168
+ return useMemo(
169
+ () =>
170
+ (key: string, vars?: ThemeStringsVars, overrideLocale?: string | null) =>
171
+ translateThemeStrings(
172
+ [...strings, defaultThemeStrings],
173
+ key,
174
+ vars,
175
+ overrideLocale ?? locale ?? undefined,
176
+ ),
177
+ [strings, locale],
178
+ );
179
+ }