fix: parse raw public translation responses in banner
Some checks failed
CI / Banner Lint & Typecheck (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / API Lint (push) Has been cancelled
CI / API Tests (push) Has been cancelled
CI / Scanner Lint (push) Has been cancelled
CI / Scanner Tests (push) Has been cancelled
CI / Banner Tests (push) Has been cancelled
CI / Banner Build (push) Has been cancelled
CI / Admin UI Typecheck (push) Has been cancelled
CI / Admin UI Tests (push) Has been cancelled
CI / Admin UI Build (push) Has been cancelled

The public translations endpoint returns the raw strings dictionary, but
the banner expected a wrapped { strings } response and therefore fell back
to English for Thai translations.

Update fetchTranslations() to accept the raw public API shape while keeping
compatibility with wrapped responses. Make default_language optional in the
banner SiteConfig type so old/mocked configs still typecheck.
This commit is contained in:
Kunthawat Greethong
2026-06-15 21:32:06 +07:00
parent 27a3e777ae
commit cc707f4887
3 changed files with 26 additions and 11 deletions

View File

@@ -106,9 +106,9 @@ describe('i18n', () => {
json: () => Promise.resolve(mockTranslations), json: () => Promise.resolve(mockTranslations),
})); }));
const result = await fetchTranslations('https://cdn.example.com', 'fr'); const result = await fetchTranslations('https://api.example.com', 'site-123', 'fr');
expect(fetch).toHaveBeenCalledWith('https://cdn.example.com/translations-fr.json'); expect(fetch).toHaveBeenCalledWith('https://api.example.com/api/v1/translations/site-123/fr');
expect(result).toEqual(mockTranslations); expect(result).toEqual(mockTranslations);
vi.unstubAllGlobals(); vi.unstubAllGlobals();
@@ -117,7 +117,7 @@ describe('i18n', () => {
it('should return null on 404', async () => { it('should return null on 404', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 })); vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
const result = await fetchTranslations('https://cdn.example.com', 'zz'); const result = await fetchTranslations('https://api.example.com', 'site-123', 'zz');
expect(result).toBeNull(); expect(result).toBeNull();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
@@ -126,7 +126,7 @@ describe('i18n', () => {
it('should return null on network error', async () => { it('should return null on network error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error'))); vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const result = await fetchTranslations('https://cdn.example.com', 'fr'); const result = await fetchTranslations('https://api.example.com', 'site-123', 'fr');
expect(result).toBeNull(); expect(result).toBeNull();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
@@ -140,7 +140,23 @@ describe('i18n', () => {
expect(t.acceptAll).toBe(DEFAULT_TRANSLATIONS.acceptAll); expect(t.acceptAll).toBe(DEFAULT_TRANSLATIONS.acceptAll);
}); });
it('should merge remote translations over defaults', async () => { it('should merge raw public API translations over defaults', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ title: 'เราใช้คุกกี้', acceptAll: 'ยอมรับทั้งหมด' }),
}));
const t = await loadTranslations('https://api.example.com', 'site-123', 'th');
expect(t.title).toBe('เราใช้คุกกี้');
expect(t.acceptAll).toBe('ยอมรับทั้งหมด');
// Missing keys should fall back to English
expect(t.rejectAll).toBe(DEFAULT_TRANSLATIONS.rejectAll);
vi.unstubAllGlobals();
});
it('should also accept legacy wrapped translation responses', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve({ strings: { title: 'Wir verwenden Cookies', acceptAll: 'Alle akzeptieren' } }), json: () => Promise.resolve({ strings: { title: 'Wir verwenden Cookies', acceptAll: 'Alle akzeptieren' } }),
@@ -150,8 +166,6 @@ describe('i18n', () => {
expect(t.title).toBe('Wir verwenden Cookies'); expect(t.title).toBe('Wir verwenden Cookies');
expect(t.acceptAll).toBe('Alle akzeptieren'); expect(t.acceptAll).toBe('Alle akzeptieren');
// Missing keys should fall back to English
expect(t.rejectAll).toBe(DEFAULT_TRANSLATIONS.rejectAll);
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });

View File

@@ -95,9 +95,10 @@ export async function fetchTranslations(
try { try {
const resp = await fetch(`${apiBase}/api/v1/translations/${siteId}/${locale}`); const resp = await fetch(`${apiBase}/api/v1/translations/${siteId}/${locale}`);
if (!resp.ok) return null; if (!resp.ok) return null;
// API returns { strings: { ... } } // Public API returns the raw strings dict. Accept a wrapped `{ strings }`
const data = (await resp.json()) as { strings?: Partial<TranslationStrings> }; // shape too for backwards compatibility with older mocks/clients.
return data.strings ?? null; const data = await resp.json() as Partial<TranslationStrings> | { strings?: Partial<TranslationStrings> };
return 'strings' in data && data.strings ? data.strings : data as Partial<TranslationStrings>;
} catch { } catch {
return null; return null;
} }

View File

@@ -80,7 +80,7 @@ export interface SiteConfig {
gcm_default: Record<string, 'granted' | 'denied'> | null; gcm_default: Record<string, 'granted' | 'denied'> | null;
shopify_privacy_enabled: boolean; shopify_privacy_enabled: boolean;
banner_config: BannerConfig | null; banner_config: BannerConfig | null;
default_language: string | null; default_language?: string | null;
privacy_policy_url: string | null; privacy_policy_url: string | null;
terms_url: string | null; terms_url: string | null;
consent_expiry_days: number; consent_expiry_days: number;