@usequeek/theme-kit 0.1.20 → 0.1.21

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
@@ -92,6 +92,123 @@ provider that passes nothing renders exactly as today, in English.
92
92
  `strings/theme-strings.ts` itself); every other locale arrives via the
93
93
  host's dynamic `import()` per locale. `tests/theme-strings.test.tsx`
94
94
  gates this: any other static locale import fails the suite.
95
+ - **Typed keys (optional, compile-time).** A theme opts in per call site —
96
+ existing untyped `t(key)` callers keep compiling unchanged.
97
+ `ThemeStringKey<typeof strings>` derives the key union from the theme's
98
+ English dictionary object (nested objects flatten to dotted keys, a
99
+ plural map counts as ONE key, flat literal dotted keys stay one key),
100
+ and `createThemeT` binds it to any `t`:
101
+
102
+ ```ts
103
+ import enDefault from './locales/en.default.json';
104
+ import { createThemeT } from '@usequeek/theme-kit/strings/theme-strings';
105
+
106
+ const { t } = await createThemeStrings({ locale });
107
+ const tt = createThemeT<typeof enDefault>(t); // server …
108
+ // … or client: const tt = createThemeT<typeof enDefault>(useThemeStrings());
109
+ tt('cart.title'); // ok
110
+ // tt('cart.titl'); // type error — typo keys fail, valid keys pass
111
+ ```
112
+
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
+ - **Pseudo-locale QA (dev/test only, never shipped).**
117
+ `pseudoLocalize(dictionary, options?)`
118
+ (`strings/pseudo-locale.ts`, pure: no `next/*`, no React) returns a
119
+ pseudo dictionary with identical keys and plural-map shape — every
120
+ value accent-mapped, ~30% longer, wrapped in `[!! … !!]` markers —
121
+ while `{placeholders}` and `{{`/`}}` escapes pass through and keys are
122
+ never touched. Use it two ways. As a dev-only locale, mount with the
123
+ pseudo dictionary as the `strings` prop: everything that went through
124
+ `t()` renders pseudo, so any text that remains plain English next to
125
+ 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):
129
+
130
+ ```tsx
131
+ import { pseudoLocalize, assertNoEnglishLeak } from '@usequeek/theme-kit/strings/pseudo-locale';
132
+
133
+ const pseudo = pseudoLocalize(enDefault, { rtl: true }); // spot-check RTL too
134
+ const html = renderToStaticMarkup(
135
+ <StorefrontProvider vendor={…} config={…} menus={[]} strings={pseudo}>
136
+ <MySection />
137
+ </StorefrontProvider>,
138
+ );
139
+ assertNoEnglishLeak(html, { allowlist: ['Queek'] }); // throws on any plain-English node
140
+ ```
141
+
142
+ `findEnglishLeaks(html, { allowlist, includeAttributes })` returns the
143
+ offending node texts instead of throwing; the scan covers visible text
144
+ nodes plus `aria-label`/`placeholder`/`title`/`alt` (opt out with
145
+ `includeAttributes: false`). Pseudo dictionaries are built on demand
146
+ in dev/test and never enter a client bundle.
147
+
148
+ ## Locale-aware dates, numbers and relative time
149
+
150
+ Dates, counts, money and relative time follow the SHOPPER's locale (founder
151
+ 2/10/26). English renders in the British/Nigerian style — `2 October 2026`,
152
+ never US `October 2, 2026` — so English stores look exactly as before.
153
+
154
+ - **One mapping.** `resolveIntlLocale(locale)` (`utils/locale.ts`, pure: no
155
+ `next/*`, no React) is the single canonical storefront-code → Intl-locale
156
+ mapping every formatter goes through. Missing/invalid codes and every
157
+ English variant (`en`, `en-GB`, `en-NG`, `en-US`, …) become `en-NG`;
158
+ everything else passes through (`fr`, `ar`, `pt-BR`, `zh-CN`, `yo`, `ha`,
159
+ `sw`, …). `en-NG` — not `en-GB` — because only it reproduces current
160
+ output byte for byte: both print `2 October 2026`, but `en-GB` renders NGN
161
+ as `NGN 12,500` where the stores show `₦12,500` (verified on Node 22, full
162
+ ICU). `resolveSupportedLocale` falls back to English when the runtime's
163
+ ICU lacks a locale — formatting never throws.
164
+ - **What themes call.** `formatThemeDate(value, locale?, options?)`
165
+ (`strings/theme-strings.ts`) instead of a hardcoded-`'en-GB'`
166
+ `toLocaleDateString`: pass the storefront locale — client components via
167
+ `useStorefrontLocale()`, server components via the request locale the host
168
+ already holds. Siblings: `formatThemeNumber`, `formatThemeMoney`, and
169
+ `formatThemeRelativeTime(-1, 'day', locale)` (`hier`, `أمس`, `ontem`, …,
170
+ `numeric: 'auto'`). `formatThemeMoney` keeps the K0 currency-default
171
+ decimals (`₦5,000.00`); `formatMoney` shows decimals only when present
172
+ (`₦5,000`, `₦0.28`). The fixed-zone display path is `formatShopperDate`
173
+ (`utils/format.ts`, Africa/Lagos, `''` for invalid dates as before);
174
+ `formatCount`/`formatMoney` take an optional locale, defaulting to the
175
+ pinned English output. `PostMeta` (`components/blog/post-meta.tsx`) already
176
+ renders its byline through the provider locale.
177
+ - **Fallbacks.** Unknown locale → English style; invalid date → `''`;
178
+ minimal-ICU runtimes render English rather than throwing.
179
+ - **Plural selection stays separate.** `canonicalThemeLocale`
180
+ (`strings/theme-strings.ts`) is for `t()` plural selection ONLY: it returns
181
+ a bare `'en'` tag, which is a US-order Intl tag. Never pass it to a date,
182
+ number, money or relative-time formatter — formatters use
183
+ `resolveIntlLocale` (en* → `en-NG`) instead. A test fails the suite if any
184
+ formatter body ever calls it.
185
+ - **Shopify comparison.** Shopify's Liquid
186
+ [`date` filter](https://shopify.dev/docs/api/liquid/filters/date) formats
187
+ with explicit strftime strings, or locale-aware via named `format:` options
188
+ (`abbreviated_date`, `basic`, `date`, `date_at_time`, `default`, `on_date`)
189
+ whose patterns live in the theme's locale files (`date_formats`); the
190
+ [`money` filter](https://shopify.dev/docs/api/liquid/filters/money) renders
191
+ per the store's currency-formatting setting. This slice differs
192
+ intentionally: one code-level mapping (`resolveIntlLocale`) instead of
193
+ per-locale format files, every English variant pinned to `en-NG`
194
+ (British/Nigerian order, never US), and an English fallback when the
195
+ runtime's ICU lacks the locale. Number/relative-time have no Liquid
196
+ equivalent consulted — UNVERIFIED remainder, no parity claim made.
197
+
198
+ ```tsx
199
+ import { useStorefrontLocale } from '@usequeek/theme-kit/provider';
200
+ import { formatThemeDate } from '@usequeek/theme-kit/strings/theme-strings';
201
+
202
+ const locale = useStorefrontLocale(); // null on the primary (English) locale
203
+ formatThemeDate(post.published_at, locale, { year: 'numeric', month: 'long', day: 'numeric' });
204
+ // en → "2 October 2026" · fr → "2 octobre 2026" · ar → "2 أكتوبر 2026"
205
+ ```
206
+
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.
95
212
 
96
213
  ## Consumers
97
214
 
@@ -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">Sign in</h2>
11
- <p className="core-auth__sub">Enter your email and password</p>
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="Email address"
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="Password"
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 ? 'Signing in…' : 'Sign in'}
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
- Don&apos;t have an account? Create one
42
+ {t('auth.login.create')}
41
43
  </button>
42
44
  <button type="button" className="core-auth__link" onClick={goToEmailOtp}>
43
- Use an email code instead
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">Sign in</h2>
12
- <p className="core-auth__sub">Enter your email to continue</p>
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
- Continue with Google
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="your@email.com"
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 ? 'Sending code…' : 'Continue'}
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
- Have a password? Sign in with it instead
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">Enter code</h2>
45
+ <h2 className="core-auth__title">{t('auth.code.title')}</h2>
44
46
  <p className="core-auth__sub">
45
- {OTP_LENGTH}-digit code sent to <strong>{email}</strong>
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={`Digit ${i + 1}`}
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 ? 'Verifying…' : 'Verify'}
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
- Resend code
78
+ {t('auth.code.resend')}
77
79
  </button>
78
80
  <button type="button" className="core-auth__link" onClick={goToEmailOtp}>
79
- ← Change email
81
+ {t('auth.code.change')}
80
82
  </button>
81
83
  </div>
82
84
  );
@@ -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 AuthEmailRegisterStep(): JSX.Element {
6
7
  const { firstName, setFirstName, lastName, setLastName, email, setEmail, password, setPassword, submitEmailRegister, loading, error, goToEmailLogin } = useAuthFlow();
8
+ const t = useThemeStrings();
7
9
 
8
10
  return (
9
11
  <div className="core-auth__step core-auth__step--email-register">
10
- <h2 className="core-auth__title">Create account</h2>
11
- <p className="core-auth__sub">Fill in your details to get started</p>
12
+ <h2 className="core-auth__title">{t('auth.register.title')}</h2>
13
+ <p className="core-auth__sub">{t('auth.register.subtitle')}</p>
12
14
  <input
13
15
  type="text"
14
16
  className="core-input"
15
- placeholder="First name"
17
+ placeholder={t('auth.register.first')}
16
18
  value={firstName}
17
19
  onChange={(e) => setFirstName(e.target.value)}
18
20
  autoComplete="given-name"
@@ -21,7 +23,7 @@ export function AuthEmailRegisterStep(): JSX.Element {
21
23
  <input
22
24
  type="text"
23
25
  className="core-input"
24
- placeholder="Last name (optional)"
26
+ placeholder={t('auth.register.last')}
25
27
  value={lastName}
26
28
  onChange={(e) => setLastName(e.target.value)}
27
29
  autoComplete="family-name"
@@ -29,7 +31,7 @@ export function AuthEmailRegisterStep(): JSX.Element {
29
31
  <input
30
32
  type="email"
31
33
  className="core-input"
32
- placeholder="Email address"
34
+ placeholder={t('auth.register.email')}
33
35
  value={email}
34
36
  onChange={(e) => setEmail(e.target.value)}
35
37
  autoComplete="email"
@@ -37,7 +39,7 @@ export function AuthEmailRegisterStep(): JSX.Element {
37
39
  <input
38
40
  type="password"
39
41
  className="core-input"
40
- placeholder="Password (min 8 characters)"
42
+ placeholder={t('auth.register.password')}
41
43
  value={password}
42
44
  onChange={(e) => setPassword(e.target.value)}
43
45
  onKeyDown={(e) => { if (e.key === 'Enter') void submitEmailRegister(); }}
@@ -50,10 +52,10 @@ export function AuthEmailRegisterStep(): JSX.Element {
50
52
  onClick={() => void submitEmailRegister()}
51
53
  disabled={loading || !firstName.trim() || !email.trim() || password.length < 8}
52
54
  >
53
- {loading ? 'Creating account…' : 'Create account'}
55
+ {loading ? t('auth.register.creating') : t('auth.register.title')}
54
56
  </button>
55
57
  <button type="button" className="core-auth__link" onClick={goToEmailLogin}>
56
- Already have an account? Sign in
58
+ {t('auth.register.signin')}
57
59
  </button>
58
60
  </div>
59
61
  );
@@ -2,6 +2,7 @@
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 AuthSignupStep(): JSX.Element {
7
8
  const {
@@ -11,16 +12,17 @@ export function AuthSignupStep(): JSX.Element {
11
12
  loading, error,
12
13
  goToEmailOtp,
13
14
  } = useAuthFlow();
15
+ const t = useThemeStrings();
14
16
 
15
17
  return (
16
18
  <div className="core-auth__step core-auth__step--signup">
17
- <h2 className="core-auth__title">Create your account</h2>
18
- <p className="core-auth__sub">Just a few details to get you started</p>
19
+ <h2 className="core-auth__title">{t('auth.signup.title')}</h2>
20
+ <p className="core-auth__sub">{t('auth.signup.subtitle')}</p>
19
21
 
20
22
  <input
21
23
  type="text"
22
24
  className="core-input"
23
- placeholder="First name"
25
+ placeholder={t('auth.signup.first')}
24
26
  value={firstName}
25
27
  onChange={(e) => setFirstName(e.target.value)}
26
28
  autoComplete="given-name"
@@ -29,7 +31,7 @@ export function AuthSignupStep(): JSX.Element {
29
31
  <input
30
32
  type="text"
31
33
  className="core-input"
32
- placeholder="Last name (optional)"
34
+ placeholder={t('auth.signup.last')}
33
35
  value={lastName}
34
36
  onChange={(e) => setLastName(e.target.value)}
35
37
  autoComplete="family-name"
@@ -43,11 +45,11 @@ export function AuthSignupStep(): JSX.Element {
43
45
  onKeyDown={(e) => { if (e.key === 'Enter') void submitRegister(); }}
44
46
  disabled={loading || !firstName.trim()}
45
47
  >
46
- {loading ? 'Creating account…' : 'Create account'}
48
+ {loading ? t('auth.signup.creating') : t('auth.signup.create')}
47
49
  </button>
48
50
 
49
51
  <button type="button" className="core-auth__link" onClick={goToEmailOtp}>
50
- ← Use a different email
52
+ {t('auth.signup.different')}
51
53
  </button>
52
54
  </div>
53
55
  );
@@ -1,9 +1,9 @@
1
1
  'use client';
2
2
 
3
+ import { useStorefront, useThemeStrings } from '../../provider';
3
4
  import type { JSX } from 'react';
4
5
  import Link from 'next/link';
5
6
  import type { BlogCategory } from '../../types/page';
6
- import { useStorefront } from '../../provider';
7
7
 
8
8
  /**
9
9
  * Shared blog category browse bar. Links to `${basePath}/blog?category=slug`.
@@ -28,6 +28,7 @@ export function CategoryFilter({
28
28
  className?: string;
29
29
  }): JSX.Element | null {
30
30
  const { basePath } = useStorefront();
31
+ const t = useThemeStrings();
31
32
 
32
33
  if (!categories || categories.length === 0) {
33
34
  return null;
@@ -37,14 +38,14 @@ export function CategoryFilter({
37
38
  return (
38
39
  <nav
39
40
  className={`core-cat-tabs${className ? ` ${className}` : ''}`}
40
- aria-label="Blog categories"
41
+ aria-label={t('blog.filter.nav')}
41
42
  >
42
43
  <Link
43
44
  href={`${basePath}/blog`}
44
45
  className={`core-cat-tabs__item${!active ? ' is-active' : ''}`}
45
46
  aria-current={!active ? 'page' : undefined}
46
47
  >
47
- <span className="core-cat-tabs__label">All</span>
48
+ <span className="core-cat-tabs__label">{t('blog.filter.all')}</span>
48
49
  </Link>
49
50
  {categories.map((category) => {
50
51
  const isActive = active === category.slug;
@@ -67,13 +68,13 @@ export function CategoryFilter({
67
68
  }
68
69
 
69
70
  return (
70
- <nav className={`core-cat-filter${className ? ` ${className}` : ''}`} aria-label="Blog categories">
71
+ <nav className={`core-cat-filter${className ? ` ${className}` : ''}`} aria-label={t('blog.filter.nav')}>
71
72
  <Link
72
73
  href={`${basePath}/blog`}
73
74
  className={`core-cat-filter__item${!active ? ' is-active' : ''}`}
74
75
  aria-current={!active ? 'page' : undefined}
75
76
  >
76
- All
77
+ {t('blog.filter.all')}
77
78
  </Link>
78
79
  {categories.map((category) => {
79
80
  const isActive = active === category.slug;
@@ -1,9 +1,9 @@
1
1
  'use client';
2
2
 
3
+ import { useStorefront, useThemeStrings } from '../../provider';
3
4
  import type { JSX } from 'react';
4
5
  import Link from 'next/link';
5
6
  import type { Pagination } from '../../types/page';
6
- import { useStorefront } from '../../provider';
7
7
 
8
8
  /**
9
9
  * Shared blog pagination (Prev / "Page N" / Next). SSR page-based navigation
@@ -21,6 +21,7 @@ export function BlogPagination({
21
21
  className?: string;
22
22
  }): JSX.Element | null {
23
23
  const { basePath } = useStorefront();
24
+ const t = useThemeStrings();
24
25
 
25
26
  if (!pagination) {
26
27
  return null;
@@ -47,24 +48,24 @@ export function BlogPagination({
47
48
  };
48
49
 
49
50
  return (
50
- <nav className={`core-pagination${className ? ` ${className}` : ''}`} aria-label="Blog pagination">
51
+ <nav className={`core-pagination${className ? ` ${className}` : ''}`} aria-label={t('blog.pagination.nav')}>
51
52
  {hasPrev ? (
52
53
  <Link href={buildHref(current - 1)} className="core-pagination__link" rel="prev">
53
- ← Previous
54
+ {t('blog.pagination.prev')}
54
55
  </Link>
55
56
  ) : (
56
57
  <span className="core-pagination__link is-disabled" aria-disabled="true">
57
- ← Previous
58
+ {t('blog.pagination.prev')}
58
59
  </span>
59
60
  )}
60
- <span className="core-pagination__status">{`Page ${current}`}</span>
61
+ <span className="core-pagination__status">{t('blog.pagination.page', { current })}</span>
61
62
  {hasNext ? (
62
63
  <Link href={buildHref(current + 1)} className="core-pagination__link" rel="next">
63
- Next →
64
+ {t('blog.pagination.next')}
64
65
  </Link>
65
66
  ) : (
66
67
  <span className="core-pagination__link is-disabled" aria-disabled="true">
67
- Next →
68
+ {t('blog.pagination.next')}
68
69
  </span>
69
70
  )}
70
71
  </nav>
@@ -1,19 +1,25 @@
1
1
  'use client';
2
2
 
3
+ import { useStorefront, useThemeStrings } from '../../provider';
3
4
  import type { JSX } from 'react';
4
5
  import type { Post } from '../../types/page';
5
- import { useStorefront } from '../../provider';
6
6
  import { Image } from '../image';
7
7
  import { formatDisplayDate } from '../../utils/format';
8
+ import { resolveIntlLocale } from '../../utils/locale';
8
9
 
9
- function formatDate(value: string): string {
10
+ function formatDate(value: string, locale: string | null): string {
10
11
  const date = new Date(value);
11
12
  if (Number.isNaN(date.getTime())) {
12
13
  return '';
13
14
  }
14
- // en-US long form as before; the fixed zone keeps a post published near midnight
15
- // UTC on the same day on the server and in the browser.
16
- return formatDisplayDate(date, { year: 'numeric', month: 'long', day: 'numeric' }, 'en-US');
15
+ // Shopper's locale through the canonical mapping (English stays the
16
+ // British/Nigerian order, "15 February 2026"); the fixed zone keeps a post
17
+ // published near midnight UTC on the same day on the server and in the browser.
18
+ return formatDisplayDate(
19
+ date,
20
+ { year: 'numeric', month: 'long', day: 'numeric' },
21
+ resolveIntlLocale(locale),
22
+ );
17
23
  }
18
24
 
19
25
  /**
@@ -22,12 +28,13 @@ function formatDate(value: string): string {
22
28
  * style via `.core-post-meta*` selectors (colours inherit brand vars).
23
29
  */
24
30
  export function PostMeta({ post, className }: { post: Post; className?: string }): JSX.Element {
25
- const { vendor } = useStorefront();
31
+ const { vendor, locale } = useStorefront();
32
+ const t = useThemeStrings();
26
33
 
27
34
  const authorName = post.author?.name ?? vendor.name ?? null;
28
35
  const authorAvatar = post.author?.avatar_url ?? vendor.logo ?? null;
29
- const published = post.published_at ? formatDate(post.published_at) : '';
30
- const readingTime = post.reading_time && post.reading_time > 0 ? `${post.reading_time} min read` : '';
36
+ const published = post.published_at ? formatDate(post.published_at, locale) : '';
37
+ const readingTime = post.reading_time && post.reading_time > 0 ? t('blog.meta.reading', { count: post.reading_time }) : '';
31
38
 
32
39
  return (
33
40
  <div className={`core-post-meta${className ? ` ${className}` : ''}`}>
@@ -1,9 +1,9 @@
1
1
  'use client';
2
2
 
3
+ import { useStorefront, useThemeStrings } from '../../provider';
3
4
  import type { JSX } from 'react';
4
5
  import Link from 'next/link';
5
6
  import type { Post } from '../../types/page';
6
- import { useStorefront } from '../../provider';
7
7
  import { Image } from '../image';
8
8
 
9
9
  /**
@@ -13,13 +13,15 @@ import { Image } from '../image';
13
13
  export function RelatedPosts({
14
14
  posts,
15
15
  className,
16
- heading = 'Related posts',
16
+ heading,
17
17
  }: {
18
18
  posts?: Post[] | null;
19
19
  className?: string;
20
20
  heading?: string;
21
21
  }): JSX.Element | null {
22
22
  const { basePath } = useStorefront();
23
+ const t = useThemeStrings();
24
+ const title = heading ?? t('blog.related.title');
23
25
 
24
26
  if (!posts || posts.length === 0) {
25
27
  return null;
@@ -27,7 +29,7 @@ export function RelatedPosts({
27
29
 
28
30
  return (
29
31
  <section className={`core-related${className ? ` ${className}` : ''}`}>
30
- <h2 className="core-related__heading">{heading}</h2>
32
+ <h2 className="core-related__heading">{title}</h2>
31
33
  <div className="core-related__grid">
32
34
  {posts.map((post) => (
33
35
  <Link key={post.id ?? post.slug} href={`${basePath}/blog/${post.slug}`} className="core-related__card">
@@ -2,6 +2,7 @@
2
2
 
3
3
  import type { JSX } from 'react';
4
4
  import { useEffect, useState } from 'react';
5
+ import { useThemeStrings } from '../../provider';
5
6
 
6
7
  interface ShareButtonsProps {
7
8
  title: string;
@@ -18,6 +19,7 @@ export function ShareButtons({ title, className }: ShareButtonsProps): JSX.Eleme
18
19
  const [url, setUrl] = useState('');
19
20
  const [copied, setCopied] = useState(false);
20
21
  const [canNativeShare, setCanNativeShare] = useState(false);
22
+ const t = useThemeStrings();
21
23
 
22
24
  useEffect(() => {
23
25
  setUrl(window.location.href);
@@ -47,11 +49,11 @@ export function ShareButtons({ title, className }: ShareButtonsProps): JSX.Eleme
47
49
 
48
50
  return (
49
51
  <div className={`core-share${className ? ` ${className}` : ''}`}>
50
- <span className="core-share__label">Share</span>
52
+ <span className="core-share__label">{t('blog.share.label')}</span>
51
53
  <div className="core-share__actions">
52
54
  {canNativeShare ? (
53
- <button type="button" className="core-share__btn" onClick={nativeShare} aria-label="Share">
54
- Share
55
+ <button type="button" className="core-share__btn" onClick={nativeShare} aria-label={t('blog.share.action')}>
56
+ {t('blog.share.action')}
55
57
  </button>
56
58
  ) : null}
57
59
  <a
@@ -59,7 +61,7 @@ export function ShareButtons({ title, className }: ShareButtonsProps): JSX.Eleme
59
61
  href={`https://x.com/intent/post?url=${encodedUrl}&text=${encodedTitle}`}
60
62
  target="_blank"
61
63
  rel="noopener noreferrer"
62
- aria-label="Share on X"
64
+ aria-label={t('blog.share.x')}
63
65
  >
64
66
  X
65
67
  </a>
@@ -68,7 +70,7 @@ export function ShareButtons({ title, className }: ShareButtonsProps): JSX.Eleme
68
70
  href={`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`}
69
71
  target="_blank"
70
72
  rel="noopener noreferrer"
71
- aria-label="Share on Facebook"
73
+ aria-label={t('blog.share.facebook')}
72
74
  >
73
75
  Facebook
74
76
  </a>
@@ -77,12 +79,12 @@ export function ShareButtons({ title, className }: ShareButtonsProps): JSX.Eleme
77
79
  href={`https://wa.me/?text=${encodedTitle}%20${encodedUrl}`}
78
80
  target="_blank"
79
81
  rel="noopener noreferrer"
80
- aria-label="Share on WhatsApp"
82
+ aria-label={t('blog.share.whatsapp')}
81
83
  >
82
84
  WhatsApp
83
85
  </a>
84
- <button type="button" className="core-share__btn" onClick={copyLink} aria-label="Copy link">
85
- {copied ? 'Copied' : 'Copy link'}
86
+ <button type="button" className="core-share__btn" onClick={copyLink} aria-label={t('blog.share.copy')}>
87
+ {copied ? t('blog.share.copied') : t('blog.share.copy')}
86
88
  </button>
87
89
  </div>
88
90
  </div>