@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 +41 -0
- package/components/cart/default-cart-shell.tsx +23 -21
- package/index.ts +3 -0
- package/locales/en.default.json +138 -0
- package/package.json +2 -1
- package/provider.tsx +58 -1
- package/strings/theme-strings.ts +356 -0
- package/tests/theme-strings-client.test.tsx +182 -0
- package/tests/theme-strings.test.tsx +396 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme strings — the kit's Shopify-shaped UI-string mechanism (slice K0).
|
|
3
|
+
*
|
|
4
|
+
* PURE AND SERVER-SAFE: this module imports nothing from `next/*` and nothing
|
|
5
|
+
* from React, so server components, Node tooling and the browser all share it
|
|
6
|
+
* (precedent: `utils/locale.ts`). Keep it that way — `tests/theme-strings*`
|
|
7
|
+
* fails a build that adds either import here.
|
|
8
|
+
*
|
|
9
|
+
* Two surfaces (plan decision 4):
|
|
10
|
+
* - Server: `createThemeStrings({ locale, loaders })` returns a bound `t`
|
|
11
|
+
* that a server component receives via props/closure. NO provider in the
|
|
12
|
+
* tree. The host supplies the loaders (per-locale dynamic imports) so the
|
|
13
|
+
* kit never bundles locale files.
|
|
14
|
+
* - Client: `StorefrontProvider` takes an optional `strings` prop and
|
|
15
|
+
* `useThemeStrings()` returns the same bound `t`. Absent prop = English
|
|
16
|
+
* defaults, byte-identical to the old hardcoded literals.
|
|
17
|
+
*
|
|
18
|
+
* Fallback (plan decision 3): the caller supplies an ordered list of
|
|
19
|
+
* dictionaries (merchant override, theme locale, kit core locale) and `t`
|
|
20
|
+
* returns the FIRST hit. The LAST entry is always the English default value —
|
|
21
|
+
* never the raw dotted key, never "translation missing". A key absent
|
|
22
|
+
* everywhere returns `''` (and warns once per key in development only).
|
|
23
|
+
*
|
|
24
|
+
* Key budget: dotted lowercase `scope.thing.state`, total length <= 40 chars.
|
|
25
|
+
* The backend overlay stores `<theme-slug>.<key>` in a varchar(64), so 40 for
|
|
26
|
+
* the key leaves 23 for the slug plus the dot separator. Values <= 1000 chars.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { parseLocaleCode } from '../utils/locale';
|
|
30
|
+
import enDefault from '../locales/en.default.json';
|
|
31
|
+
|
|
32
|
+
/** Interpolation variables: `{name}` in a value. `count` drives plurals. */
|
|
33
|
+
export type ThemeStringsVars = Record<string, string | number | boolean | null | undefined>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* CLDR plural variants for one key, using the `one/two/few/many/other`
|
|
37
|
+
* suffix convention. `other` is required (the fallback form); the rest are
|
|
38
|
+
* used only when `Intl.PluralRules` for the active locale selects them.
|
|
39
|
+
*/
|
|
40
|
+
export interface ThemeStringsPluralMap {
|
|
41
|
+
one?: string;
|
|
42
|
+
two?: string;
|
|
43
|
+
few?: string;
|
|
44
|
+
many?: string;
|
|
45
|
+
other: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ThemeStringsValue =
|
|
49
|
+
| string
|
|
50
|
+
| ThemeStringsPluralMap
|
|
51
|
+
| { [key: string]: ThemeStringsValue };
|
|
52
|
+
|
|
53
|
+
/** One dictionary: nested objects traversed by dot-path, or flat dotted keys. */
|
|
54
|
+
export type ThemeStringsDictionary = { [key: string]: ThemeStringsValue };
|
|
55
|
+
|
|
56
|
+
/** The bound translator both surfaces hand out. */
|
|
57
|
+
export type ThemeStringsFn = (
|
|
58
|
+
key: string,
|
|
59
|
+
vars?: ThemeStringsVars,
|
|
60
|
+
locale?: string | null,
|
|
61
|
+
) => string;
|
|
62
|
+
|
|
63
|
+
/** The kit's default English core dictionary (`locales/en.default.json`). */
|
|
64
|
+
export const defaultThemeStrings: ThemeStringsDictionary =
|
|
65
|
+
enDefault as ThemeStringsDictionary;
|
|
66
|
+
|
|
67
|
+
/** Maximum total key length — see the module docblock for the budget. */
|
|
68
|
+
export const THEME_STRING_KEY_MAX_LENGTH = 40;
|
|
69
|
+
|
|
70
|
+
/** Maximum value length. */
|
|
71
|
+
export const THEME_STRING_VALUE_MAX_LENGTH = 1000;
|
|
72
|
+
|
|
73
|
+
const PLURAL_FORMS = ['one', 'two', 'few', 'many', 'other'] as const;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Canonical locale for string selection. Unknown/unparseable codes become
|
|
77
|
+
* English, mirroring `parseLocaleCode` (and the backend's unknown-locale
|
|
78
|
+
* rule). A well-formed code the runtime's ICU does not know also becomes
|
|
79
|
+
* English, so plural selection never silently follows the wrong language.
|
|
80
|
+
*/
|
|
81
|
+
export function canonicalThemeLocale(locale: string | null | undefined): string {
|
|
82
|
+
const code = parseLocaleCode(locale) ?? 'en';
|
|
83
|
+
try {
|
|
84
|
+
return Intl.PluralRules.supportedLocalesOf([code]).length > 0 ? code : 'en';
|
|
85
|
+
} catch {
|
|
86
|
+
return 'en';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Text direction for a locale (`dir` attribute). There is no locale catalogue
|
|
92
|
+
* in the kit to read this from, so the base language decides: Arabic-script
|
|
93
|
+
* languages render `rtl`, everything else (including unknown codes) `ltr`.
|
|
94
|
+
*/
|
|
95
|
+
export function themeLocaleDir(locale: string | null | undefined): 'rtl' | 'ltr' {
|
|
96
|
+
const base = (parseLocaleCode(locale) ?? 'en').split('-')[0]?.toLowerCase() ?? 'en';
|
|
97
|
+
return RTL_BASE_LANGUAGES.has(base) ? 'rtl' : 'ltr';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const RTL_BASE_LANGUAGES: ReadonlySet<string> = new Set([
|
|
101
|
+
'ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi', 'ug', 'dv', 'ku',
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
105
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isPluralMap(value: unknown): value is ThemeStringsPluralMap {
|
|
109
|
+
if (!isRecord(value)) return false;
|
|
110
|
+
const keys = Object.keys(value);
|
|
111
|
+
return (
|
|
112
|
+
keys.length > 0 &&
|
|
113
|
+
keys.every((k) => (PLURAL_FORMS as readonly string[]).includes(k)) &&
|
|
114
|
+
keys.every((k) => typeof value[k] === 'string')
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Dot-path lookup that also accepts a flat literal dotted key. */
|
|
119
|
+
function lookupValue(
|
|
120
|
+
dictionary: ThemeStringsDictionary,
|
|
121
|
+
key: string,
|
|
122
|
+
): ThemeStringsValue | undefined {
|
|
123
|
+
if (Object.prototype.hasOwnProperty.call(dictionary, key)) {
|
|
124
|
+
return dictionary[key];
|
|
125
|
+
}
|
|
126
|
+
let node: ThemeStringsValue = dictionary;
|
|
127
|
+
for (const part of key.split('.')) {
|
|
128
|
+
if (!isRecord(node) || !Object.prototype.hasOwnProperty.call(node, part)) return undefined;
|
|
129
|
+
node = node[part] as ThemeStringsValue;
|
|
130
|
+
}
|
|
131
|
+
return node;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function pluralCount(vars: ThemeStringsVars | undefined): number | null {
|
|
135
|
+
const count = vars?.['count'];
|
|
136
|
+
return typeof count === 'number' && Number.isFinite(count) ? count : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function selectPluralForm(
|
|
140
|
+
map: ThemeStringsPluralMap,
|
|
141
|
+
vars: ThemeStringsVars | undefined,
|
|
142
|
+
locale: string,
|
|
143
|
+
): string {
|
|
144
|
+
const count = pluralCount(vars);
|
|
145
|
+
if (count === null) return map.other;
|
|
146
|
+
let form: string;
|
|
147
|
+
try {
|
|
148
|
+
form = new Intl.PluralRules(locale).select(count);
|
|
149
|
+
} catch {
|
|
150
|
+
form = new Intl.PluralRules('en').select(count);
|
|
151
|
+
}
|
|
152
|
+
return map[form as keyof ThemeStringsPluralMap] ?? map.other;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Suffix convention: `key.one` … `key.other` looked up as (flat or nested) keys. */
|
|
156
|
+
function lookupSuffixed(
|
|
157
|
+
dictionary: ThemeStringsDictionary,
|
|
158
|
+
key: string,
|
|
159
|
+
vars: ThemeStringsVars | undefined,
|
|
160
|
+
locale: string,
|
|
161
|
+
): string | undefined {
|
|
162
|
+
const count = pluralCount(vars);
|
|
163
|
+
let form = 'other';
|
|
164
|
+
if (count !== null) {
|
|
165
|
+
try {
|
|
166
|
+
form = new Intl.PluralRules(locale).select(count);
|
|
167
|
+
} catch {
|
|
168
|
+
form = new Intl.PluralRules('en').select(count);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const candidate of form === 'other' ? ['other'] : [form, 'other']) {
|
|
172
|
+
const value = lookupValue(dictionary, `${key}.${candidate}`);
|
|
173
|
+
if (typeof value === 'string') return interpolate(value, vars);
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `{var}` interpolation. A missing/nullish var keeps its `{placeholder}`
|
|
180
|
+
* (visible, greppable, never a crash); `{{` / `}}` escape to literal braces.
|
|
181
|
+
*/
|
|
182
|
+
export function interpolate(
|
|
183
|
+
template: string,
|
|
184
|
+
vars: ThemeStringsVars | undefined,
|
|
185
|
+
): string {
|
|
186
|
+
if (!template.includes('{') && !template.includes('}')) return template;
|
|
187
|
+
let out = '';
|
|
188
|
+
let i = 0;
|
|
189
|
+
while (i < template.length) {
|
|
190
|
+
const ch = template[i];
|
|
191
|
+
if (ch === '{') {
|
|
192
|
+
if (template[i + 1] === '{') {
|
|
193
|
+
out += '{';
|
|
194
|
+
i += 2;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const close = template.indexOf('}', i + 1);
|
|
198
|
+
const name = close === -1 ? '' : template.slice(i + 1, close);
|
|
199
|
+
if (close !== -1 && /^[A-Za-z0-9_]+$/.test(name)) {
|
|
200
|
+
const value = vars?.[name];
|
|
201
|
+
out += value === undefined || value === null ? template.slice(i, close + 1) : String(value);
|
|
202
|
+
i = close + 1;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
out += ch;
|
|
206
|
+
i += 1;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (ch === '}' && template[i + 1] === '}') {
|
|
210
|
+
out += '}';
|
|
211
|
+
i += 2;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
out += ch;
|
|
215
|
+
i += 1;
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const warnedKeys = new Set<string>();
|
|
221
|
+
|
|
222
|
+
function warnMissingKey(key: string): void {
|
|
223
|
+
if (typeof process !== 'undefined' && process.env['NODE_ENV'] === 'production') return;
|
|
224
|
+
if (warnedKeys.has(key)) return;
|
|
225
|
+
warnedKeys.add(key);
|
|
226
|
+
console.warn(`[theme-kit] missing theme string: "${key}" (no dictionary has it; rendered "")`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Test-only: reset the warn-once set between cases. */
|
|
230
|
+
export function resetThemeStringsWarnings(): void {
|
|
231
|
+
warnedKeys.clear();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Pure lookup: first hit across the ordered dictionaries wins
|
|
236
|
+
* (override, theme locale, kit core locale — the caller appends
|
|
237
|
+
* `defaultThemeStrings` LAST). Absent everywhere: `''` in production, plus a
|
|
238
|
+
* once-per-key `console.warn` in development. Never the raw key.
|
|
239
|
+
*/
|
|
240
|
+
export function t(
|
|
241
|
+
dictionaries: ThemeStringsDictionary | Array<ThemeStringsDictionary | null | undefined>,
|
|
242
|
+
key: string,
|
|
243
|
+
vars?: ThemeStringsVars,
|
|
244
|
+
locale?: string | null,
|
|
245
|
+
): string {
|
|
246
|
+
const list = Array.isArray(dictionaries) ? dictionaries : [dictionaries];
|
|
247
|
+
const canonical = canonicalThemeLocale(locale);
|
|
248
|
+
for (const dictionary of list) {
|
|
249
|
+
if (!dictionary) continue;
|
|
250
|
+
const value = lookupValue(dictionary, key);
|
|
251
|
+
if (typeof value === 'string') return interpolate(value, vars);
|
|
252
|
+
if (isPluralMap(value)) return interpolate(selectPluralForm(value, vars, canonical), vars);
|
|
253
|
+
const suffixed = lookupSuffixed(dictionary, key, vars, canonical);
|
|
254
|
+
if (suffixed !== undefined) return suffixed;
|
|
255
|
+
}
|
|
256
|
+
warnMissingKey(key);
|
|
257
|
+
return '';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Per-locale loader the host supplies — usually a dynamic `import()`. */
|
|
261
|
+
export type ThemeStringsLoader = (
|
|
262
|
+
locale: string,
|
|
263
|
+
) => Promise<ThemeStringsDictionary | null | undefined>;
|
|
264
|
+
|
|
265
|
+
export interface ThemeStringsLoaders {
|
|
266
|
+
/** Merchant overrides for the active locale (highest precedence). */
|
|
267
|
+
override?: ThemeStringsLoader;
|
|
268
|
+
/** Active theme's `{locale}.json`. */
|
|
269
|
+
theme?: ThemeStringsLoader;
|
|
270
|
+
/** Kit core pack for the active locale. */
|
|
271
|
+
core?: ThemeStringsLoader;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface BoundThemeStrings {
|
|
275
|
+
/** Bound to the active locale's dictionaries (+ English default last). */
|
|
276
|
+
t: ThemeStringsFn;
|
|
277
|
+
/** Validated requested locale (`en` when unknown) — what loaders were read with. */
|
|
278
|
+
locale: string;
|
|
279
|
+
/** Ordered dictionaries the bound `t` reads (override, theme, core, default). */
|
|
280
|
+
dictionaries: ThemeStringsDictionary[];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Server-safe, provider-free loader contract. Reads ONLY the active locale
|
|
285
|
+
* (one locale crosses to the client — never every `{lang}.json`), merges
|
|
286
|
+
* override → theme → core → English default, and returns a bound `t` for
|
|
287
|
+
* props/closure use. A loader that throws or returns null (e.g. a missing
|
|
288
|
+
* locale file) simply drops out of the chain — never an error.
|
|
289
|
+
*/
|
|
290
|
+
export async function createThemeStrings(options?: {
|
|
291
|
+
locale?: string | null;
|
|
292
|
+
loaders?: ThemeStringsLoaders;
|
|
293
|
+
}): Promise<BoundThemeStrings> {
|
|
294
|
+
const requested = parseLocaleCode(options?.locale) ?? 'en';
|
|
295
|
+
const loaders = options?.loaders;
|
|
296
|
+
const dictionaries: ThemeStringsDictionary[] = [];
|
|
297
|
+
for (const load of [loaders?.override, loaders?.theme, loaders?.core]) {
|
|
298
|
+
if (!load) continue;
|
|
299
|
+
try {
|
|
300
|
+
const dictionary = await load(requested);
|
|
301
|
+
if (dictionary && isRecord(dictionary)) {
|
|
302
|
+
dictionaries.push(dictionary as ThemeStringsDictionary);
|
|
303
|
+
}
|
|
304
|
+
} catch {
|
|
305
|
+
// Missing locale file et al: fall through to the next dictionary.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
dictionaries.push(defaultThemeStrings);
|
|
309
|
+
const active = requested;
|
|
310
|
+
return {
|
|
311
|
+
t: (key, vars, overrideLocale) =>
|
|
312
|
+
t(dictionaries, key, vars, overrideLocale ?? active),
|
|
313
|
+
locale: active,
|
|
314
|
+
dictionaries,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Locale-aware number formatting keyed by the active locale (unknown → English). */
|
|
319
|
+
export function formatThemeNumber(
|
|
320
|
+
value: number,
|
|
321
|
+
locale?: string | null,
|
|
322
|
+
options?: Intl.NumberFormatOptions,
|
|
323
|
+
): string {
|
|
324
|
+
return new Intl.NumberFormat(canonicalThemeLocale(locale), options).format(value);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Locale-aware date formatting keyed by the active locale (unknown → English). `''` if unparseable. */
|
|
328
|
+
export function formatThemeDate(
|
|
329
|
+
value: string | number | Date,
|
|
330
|
+
locale?: string | null,
|
|
331
|
+
options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' },
|
|
332
|
+
): string {
|
|
333
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
334
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
335
|
+
return new Intl.DateTimeFormat(canonicalThemeLocale(locale), options).format(date);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function normalizeMoneyCurrency(value: unknown): string {
|
|
339
|
+
return typeof value === 'string' && /^[A-Za-z]{3}$/.test(value.trim())
|
|
340
|
+
? value.trim().toUpperCase()
|
|
341
|
+
: 'NGN';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Locale-aware currency formatting keyed by the active locale (unknown → English). */
|
|
345
|
+
export function formatThemeMoney(
|
|
346
|
+
amount: number,
|
|
347
|
+
currency: unknown,
|
|
348
|
+
locale?: string | null,
|
|
349
|
+
options?: Omit<Intl.NumberFormatOptions, 'style' | 'currency'>,
|
|
350
|
+
): string {
|
|
351
|
+
return new Intl.NumberFormat(canonicalThemeLocale(locale), {
|
|
352
|
+
...options,
|
|
353
|
+
style: 'currency',
|
|
354
|
+
currency: normalizeMoneyCurrency(currency),
|
|
355
|
+
}).format(amount);
|
|
356
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import type { JSX, ReactNode } from 'react';
|
|
4
|
+
import { StorefrontProvider, useThemeStrings } from '../provider';
|
|
5
|
+
import { ThemeMount } from '../theme-mount';
|
|
6
|
+
import { DefaultCartShell } from '../components/cart/default-cart-shell';
|
|
7
|
+
import { defaultThemeStrings, t as translate, type ThemeStringsDictionary } from '../strings/theme-strings';
|
|
8
|
+
import type { CartItem } from '../types/cart';
|
|
9
|
+
import type { ThemeModule } from '../types/theme';
|
|
10
|
+
|
|
11
|
+
const FR_STRINGS: ThemeStringsDictionary = {
|
|
12
|
+
cart: {
|
|
13
|
+
title: 'Votre panier',
|
|
14
|
+
empty: { message: 'Votre panier est vide.', browse: 'Voir les produits' },
|
|
15
|
+
summary: { title: 'Résumé de commande' },
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function render(
|
|
20
|
+
children: ReactNode,
|
|
21
|
+
options?: { locale?: string | null; strings?: ThemeStringsDictionary | ThemeStringsDictionary[] | null },
|
|
22
|
+
): string {
|
|
23
|
+
return renderToStaticMarkup(
|
|
24
|
+
<StorefrontProvider
|
|
25
|
+
vendor={{ id: 'v1', slug: 'kili-foods', name: 'Kili Foods' } as never}
|
|
26
|
+
config={{} as never}
|
|
27
|
+
menus={[]}
|
|
28
|
+
{...(options?.locale !== undefined ? { locale: options.locale } : {})}
|
|
29
|
+
{...(options?.strings !== undefined ? { strings: options.strings } : {})}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</StorefrontProvider>,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Title(): JSX.Element {
|
|
37
|
+
const t = useThemeStrings();
|
|
38
|
+
return <h1>{t('cart.title')}</h1>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('client surface: StorefrontProvider strings + useThemeStrings (decision 4b)', () => {
|
|
42
|
+
it('renders the active locale through the hook', () => {
|
|
43
|
+
expect(render(<Title />, { locale: 'fr', strings: FR_STRINGS })).toContain('<h1>Votre panier</h1>');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('falls back per key to the English default VALUE', () => {
|
|
47
|
+
// `cart.checkout.action` is absent from the FR pack: English value, never the key.
|
|
48
|
+
function Action(): JSX.Element {
|
|
49
|
+
const t = useThemeStrings();
|
|
50
|
+
return <span>{t('cart.checkout.action')}</span>;
|
|
51
|
+
}
|
|
52
|
+
const html = render(<Action />, { locale: 'fr', strings: FR_STRINGS });
|
|
53
|
+
expect(html).toContain('<span>Checkout</span>');
|
|
54
|
+
expect(html).not.toContain('cart.checkout.action');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('is a no-op when the prop is absent (backward compat: English as today)', () => {
|
|
58
|
+
expect(render(<Title />)).toContain('<h1>Your cart</h1>');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('matches explicit English defaults exactly (prop changes nothing when absent)', () => {
|
|
62
|
+
const implicit = render(<Title />);
|
|
63
|
+
const explicit = render(<Title />, { strings: defaultThemeStrings });
|
|
64
|
+
expect(implicit).toBe(explicit);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('throws outside a provider, like every other kit hook', () => {
|
|
68
|
+
expect(() => renderToStaticMarkup(<Title />)).toThrow(
|
|
69
|
+
'useThemeStrings must be used within StorefrontProvider',
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('client surface through ThemeMount', () => {
|
|
75
|
+
function Header(): JSX.Element {
|
|
76
|
+
const t = useThemeStrings();
|
|
77
|
+
return <header>{t('cart.title')}</header>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const stubTheme = {
|
|
81
|
+
Layout: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
82
|
+
Header,
|
|
83
|
+
Footer: () => <footer />,
|
|
84
|
+
blocks: {},
|
|
85
|
+
getHeader: () => Header,
|
|
86
|
+
getFooter: () => (() => <footer />),
|
|
87
|
+
getBlock: () => (() => null),
|
|
88
|
+
pages: {},
|
|
89
|
+
manifest: { name: 'stub', slug: 'stub', description: '', version: '0' },
|
|
90
|
+
} as unknown as ThemeModule;
|
|
91
|
+
|
|
92
|
+
it('reaches themes mounted by ThemeMount', () => {
|
|
93
|
+
const html = render(
|
|
94
|
+
<ThemeMount
|
|
95
|
+
theme={stubTheme}
|
|
96
|
+
headerProps={{ menu: [], announcement: null }}
|
|
97
|
+
footerProps={{}}
|
|
98
|
+
>
|
|
99
|
+
<p>page</p>
|
|
100
|
+
</ThemeMount>,
|
|
101
|
+
{ locale: 'fr', strings: FR_STRINGS },
|
|
102
|
+
);
|
|
103
|
+
expect(html).toContain('<header>Votre panier</header>');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('migrated DefaultCartShell (identical English output)', () => {
|
|
108
|
+
const item: CartItem = {
|
|
109
|
+
id: 'li-1',
|
|
110
|
+
product_id: 'p-1',
|
|
111
|
+
shop_id: 's-1',
|
|
112
|
+
title: 'Egusi Soup',
|
|
113
|
+
image: null,
|
|
114
|
+
unit_price: 5000,
|
|
115
|
+
quantity: 2,
|
|
116
|
+
addons: [],
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const noop = (): void => {};
|
|
120
|
+
|
|
121
|
+
it('empty state renders the legacy literals with no strings prop', () => {
|
|
122
|
+
const html = render(
|
|
123
|
+
<DefaultCartShell
|
|
124
|
+
items={[]}
|
|
125
|
+
currency="NGN"
|
|
126
|
+
total={0}
|
|
127
|
+
empty
|
|
128
|
+
onIncrease={noop}
|
|
129
|
+
onDecrease={noop}
|
|
130
|
+
onRemove={noop}
|
|
131
|
+
/>,
|
|
132
|
+
);
|
|
133
|
+
expect(html).toContain('Your cart</h1>');
|
|
134
|
+
expect(html).toContain('Your cart is empty.');
|
|
135
|
+
expect(html).toContain('Browse products');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('populated state interpolates through t() with identical text', () => {
|
|
139
|
+
const html = render(
|
|
140
|
+
<DefaultCartShell
|
|
141
|
+
items={[item]}
|
|
142
|
+
currency="NGN"
|
|
143
|
+
total={10000}
|
|
144
|
+
empty={false}
|
|
145
|
+
onIncrease={noop}
|
|
146
|
+
onDecrease={noop}
|
|
147
|
+
onRemove={noop}
|
|
148
|
+
/>,
|
|
149
|
+
);
|
|
150
|
+
expect(html).toContain('each</span>');
|
|
151
|
+
expect(html).toContain('aria-label="Decrease Egusi Soup"');
|
|
152
|
+
expect(html).toContain('aria-label="Increase Egusi Soup"');
|
|
153
|
+
expect(html).toContain('aria-label="Remove Egusi Soup"');
|
|
154
|
+
expect(html).toContain('Order summary');
|
|
155
|
+
expect(html).toContain('Add order note');
|
|
156
|
+
// The note textarea (with its placeholder) renders only after the toggle
|
|
157
|
+
// opens it, so its key is asserted directly: same dictionary, same output.
|
|
158
|
+
expect(translate([defaultThemeStrings], 'cart.note.toggle')).toBe('Add order note');
|
|
159
|
+
expect(translate([defaultThemeStrings], 'cart.note.placeholder')).toBe('Order instructions (optional)');
|
|
160
|
+
expect(html).toContain('Subtotal:');
|
|
161
|
+
expect(html).toContain('terms and refund policy');
|
|
162
|
+
expect(html).toContain('Continue shopping');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('renders French when the provider carries the active locale', () => {
|
|
166
|
+
const html = render(
|
|
167
|
+
<DefaultCartShell
|
|
168
|
+
items={[]}
|
|
169
|
+
currency="NGN"
|
|
170
|
+
total={0}
|
|
171
|
+
empty
|
|
172
|
+
onIncrease={noop}
|
|
173
|
+
onDecrease={noop}
|
|
174
|
+
onRemove={noop}
|
|
175
|
+
/>,
|
|
176
|
+
{ locale: 'fr', strings: FR_STRINGS },
|
|
177
|
+
);
|
|
178
|
+
expect(html).toContain('Votre panier</h1>');
|
|
179
|
+
expect(html).toContain('Votre panier est vide.');
|
|
180
|
+
expect(html).toContain('Voir les produits');
|
|
181
|
+
});
|
|
182
|
+
});
|