@usequeek/theme-kit 0.1.19 → 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 +121 -1
- 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 +6 -4
- package/components/checkout/default-checkout-shell.tsx +17 -15
- package/components/checkout/policy-links.tsx +5 -3
- package/components/powered-by-queek.tsx +12 -3
- package/hooks/use-auth-flow.tsx +18 -17
- package/index.ts +4 -0
- package/locales/en.default.json +45 -7
- package/package.json +1 -1
- package/strings/pseudo-locale.ts +264 -0
- package/strings/theme-strings.ts +178 -15
- package/tests/auth-email-errors.test.tsx +55 -0
- package/tests/display-format.test.ts +364 -2
- package/tests/powered-by-queek.test.tsx +31 -3
- package/tests/powered-by-url.test.ts +49 -0
- package/tests/shopper-chrome-strings.test.tsx +272 -0
- package/tests/theme-hook-placement.test.tsx +179 -0
- package/tests/theme-strings-pseudo.test.tsx +171 -0
- package/tests/theme-strings-typed.test.tsx +97 -0
- package/tests/theme-strings.test.tsx +8 -2
- package/tests/theme-typed-keys.fixture.ts +76 -0
- package/utils/format.ts +97 -9
- package/utils/locale.ts +52 -0
- package/utils/powered-by.ts +31 -0
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
|
|
|
@@ -203,7 +320,10 @@ export default function Footer() {
|
|
|
203
320
|
it — style the classes in your theme.
|
|
204
321
|
- The href (`https://usequeek.com/business`), `target="_blank"` and
|
|
205
322
|
`rel="noopener noreferrer"` are the contract — never re-implement the
|
|
206
|
-
markup by hand.
|
|
323
|
+
markup by hand. The href is tagged for attribution by `poweredByQueekUrl`
|
|
324
|
+
(`utm_source=powered_by&utm_medium=storefront&utm_campaign=<store slug>`,
|
|
325
|
+
campaign omitted when the store has no slug); the component reads the slug
|
|
326
|
+
from the storefront context, so themes pass nothing.
|
|
207
327
|
- `hidden` (default false) takes the component's visibility from a prop as
|
|
208
328
|
the seam for a possible future plan perk. There is no merchant-facing
|
|
209
329
|
toggle for it in v1 — always render it visible.
|
|
@@ -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
|
);
|
|
@@ -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">
|
|
11
|
-
<p className="core-auth__sub">
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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 ? '
|
|
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
|
-
|
|
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">
|
|
18
|
-
<p className="core-auth__sub">
|
|
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=
|
|
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=
|
|
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 ? '
|
|
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
|
-
|
|
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=
|
|
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">
|
|
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=
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
54
|
+
{t('blog.pagination.prev')}
|
|
54
55
|
</Link>
|
|
55
56
|
) : (
|
|
56
57
|
<span className="core-pagination__link is-disabled" aria-disabled="true">
|
|
57
|
-
|
|
58
|
+
{t('blog.pagination.prev')}
|
|
58
59
|
</span>
|
|
59
60
|
)}
|
|
60
|
-
<span className="core-pagination__status">{
|
|
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
|
-
|
|
64
|
+
{t('blog.pagination.next')}
|
|
64
65
|
</Link>
|
|
65
66
|
) : (
|
|
66
67
|
<span className="core-pagination__link is-disabled" aria-disabled="true">
|
|
67
|
-
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
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 ?
|
|
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
|
|
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">{
|
|
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">
|
|
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=
|
|
54
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
85
|
-
{copied ? '
|
|
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>
|