Skip to content

Commit 5596623

Browse files
committed
Render flags for languages, with custom upload for non-ISO locales
Standard locales now render real SVG flags (via country-flag-icons) in the public LanguagePicker, the admin translation tabs, and the Lingue settings page — replacing emoji that rendered as letter pairs on Windows. Custom locales (e.g. vec) keep the uppercase code-chip fallback by default, and admins can now upload a per-locale flag image from the Languages settings page. Uploads land in R2 under images/settings/flag-<code>-*.{jpg,png,webp}, with old keys cleaned up on re-upload and on delete.
1 parent 42f7ee6 commit 5596623

19 files changed

Lines changed: 534 additions & 49 deletions

.pii-allowlist

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
^https?://(www\.)?keepachangelog\.com(/.*)?$
66
^https?://(www\.)?nextjs\.org(/.*)?$
77
^https?://github\.com/vekexasia/risto-menu(/.*)?$
8+
# RFC 2606 reserved example domains used in test fixtures.
9+
^https?://([a-z0-9-]+\.)*example\.(com|org|net)(/.*)?$
10+
# test.local fake hostname used in worker integration tests.
11+
^https?://test\.local(/.*)?$
12+
test\.local
813

914
# ── Drizzle snapshot identifiers ──
1015
# drizzle generates a fresh UUID for each migration snapshot's `id`/`prevId`.
@@ -35,3 +40,6 @@
3540
(?i)^prosecco$
3641
(?i)^merlot$
3742
(?i)^carbonara$
43+
# Italian regions used in custom-locale fixtures (not personal names).
44+
(?i)^veneto$
45+
(?i)^v[èe]neto$
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { describe, it, expect, beforeAll } from 'vitest';
2+
import { createApp } from '../app';
3+
import { makeEnv } from './helpers';
4+
import { createTestDb, makeDbEnv, seedSettings, signTestJwt, installJwksMock } from './helpers/db';
5+
import type { Env } from '../types';
6+
7+
beforeAll(() => installJwksMock());
8+
9+
const ADMIN_UID = 'admin-1';
10+
11+
type StoredObject = { body: ArrayBuffer; contentType?: string };
12+
13+
function makeFakeBucket() {
14+
const store = new Map<string, StoredObject>();
15+
const bucket = {
16+
async put(key: string, value: ArrayBuffer, opts?: { httpMetadata?: { contentType?: string } }) {
17+
store.set(key, { body: value, contentType: opts?.httpMetadata?.contentType });
18+
return { key };
19+
},
20+
async delete(key: string) {
21+
store.delete(key);
22+
},
23+
async get(key: string) {
24+
const o = store.get(key);
25+
return o ? { body: o.body, httpMetadata: { contentType: o.contentType } } : null;
26+
},
27+
_store: store,
28+
};
29+
return bucket as unknown as R2Bucket & { _store: Map<string, StoredObject> };
30+
}
31+
32+
function flagKeys(bucket: { _store: Map<string, StoredObject> }): string[] {
33+
return Array.from(bucket._store.keys()).filter((k) => k.startsWith('images/settings/flag-'));
34+
}
35+
36+
function makeJpeg(extraBytes = 100): ArrayBuffer {
37+
const buf = new Uint8Array(3 + extraBytes);
38+
buf[0] = 0xff; buf[1] = 0xd8; buf[2] = 0xff;
39+
return buf.buffer;
40+
}
41+
42+
async function adminEnv() {
43+
const db = createTestDb();
44+
seedSettings(db);
45+
const bucket = makeFakeBucket();
46+
const env = makeDbEnv(db, {
47+
ADMIN_EMAILS: ADMIN_UID,
48+
PUBLIC_MENU_BUCKET: bucket,
49+
R2_PUBLIC_URL: 'https://cdn.example.com',
50+
} as Partial<Env>);
51+
const token = await signTestJwt(ADMIN_UID);
52+
return { db, env, bucket, headers: { 'Cf-Access-Jwt-Assertion': token } };
53+
}
54+
55+
async function fetchApp(path: string, init: RequestInit, env: Env): Promise<Response> {
56+
const app = createApp();
57+
return app.fetch(new Request(`https://test.local${path}`, init), env);
58+
}
59+
60+
async function setCustomLocales(env: Env, headers: Record<string, string>, locales: { code: string; name: string; flagUrl?: string | null }[]) {
61+
const res = await fetchApp('/admin/settings', {
62+
method: 'PUT',
63+
headers: { 'Content-Type': 'application/json', ...headers },
64+
body: JSON.stringify({ customLocales: locales }),
65+
}, env);
66+
expect(res.status).toBe(200);
67+
}
68+
69+
describe('POST /admin/locale-flag/:code', () => {
70+
it('uploads a flag and stores the URL on the matching custom locale', async () => {
71+
const { env, headers, bucket } = await adminEnv();
72+
await setCustomLocales(env, headers, [{ code: 'vec', name: 'Veneto' }]);
73+
74+
const res = await fetchApp('/admin/locale-flag/vec', {
75+
method: 'POST',
76+
headers: { 'Content-Type': 'image/jpeg', ...headers },
77+
body: makeJpeg(),
78+
}, env);
79+
80+
expect(res.status).toBe(200);
81+
const body = await res.json() as { ok: true; flagUrl: string };
82+
expect(body.flagUrl).toMatch(/^https:\/\/cdn\.example\.com\/images\/settings\/flag-vec-\d+\.jpg$/);
83+
expect(flagKeys(bucket)).toHaveLength(1);
84+
85+
const settings = await fetchApp('/admin/settings', { method: 'GET', headers }, env);
86+
const settingsBody = await settings.json() as { customLocales?: { code: string; flagUrl?: string }[] };
87+
const vec = settingsBody.customLocales?.find((l) => l.code === 'vec');
88+
expect(vec?.flagUrl).toBe(body.flagUrl);
89+
});
90+
91+
it('rejects an invalid locale code', async () => {
92+
const { env, headers } = await adminEnv();
93+
const res = await fetchApp('/admin/locale-flag/UPPER', {
94+
method: 'POST',
95+
headers: { 'Content-Type': 'image/jpeg', ...headers },
96+
body: makeJpeg(),
97+
}, env);
98+
expect(res.status).toBe(400);
99+
});
100+
101+
it('returns 404 when the custom locale does not exist', async () => {
102+
const { env, headers } = await adminEnv();
103+
const res = await fetchApp('/admin/locale-flag/zzz', {
104+
method: 'POST',
105+
headers: { 'Content-Type': 'image/jpeg', ...headers },
106+
body: makeJpeg(),
107+
}, env);
108+
expect(res.status).toBe(404);
109+
});
110+
111+
it('rejects non-image bodies with 415', async () => {
112+
const { env, headers } = await adminEnv();
113+
await setCustomLocales(env, headers, [{ code: 'vec', name: 'Veneto' }]);
114+
const garbage = new Uint8Array(16).fill(0xAB).buffer;
115+
const res = await fetchApp('/admin/locale-flag/vec', {
116+
method: 'POST',
117+
headers: { 'Content-Type': 'image/jpeg', ...headers },
118+
body: garbage,
119+
}, env);
120+
expect(res.status).toBe(415);
121+
});
122+
123+
it('deletes the previous R2 object on re-upload', async () => {
124+
const { env, headers, bucket } = await adminEnv();
125+
await setCustomLocales(env, headers, [{ code: 'vec', name: 'Veneto' }]);
126+
127+
const first = await fetchApp('/admin/locale-flag/vec', {
128+
method: 'POST',
129+
headers: { 'Content-Type': 'image/jpeg', ...headers },
130+
body: makeJpeg(),
131+
}, env);
132+
expect(first.status).toBe(200);
133+
expect(flagKeys(bucket)).toHaveLength(1);
134+
const firstKey = flagKeys(bucket)[0];
135+
136+
await new Promise((r) => setTimeout(r, 2));
137+
138+
const second = await fetchApp('/admin/locale-flag/vec', {
139+
method: 'POST',
140+
headers: { 'Content-Type': 'image/jpeg', ...headers },
141+
body: makeJpeg(),
142+
}, env);
143+
expect(second.status).toBe(200);
144+
expect(flagKeys(bucket)).toHaveLength(1);
145+
const secondKey = flagKeys(bucket)[0];
146+
expect(secondKey).not.toBe(firstKey);
147+
});
148+
149+
it('requires admin auth', async () => {
150+
const db = createTestDb();
151+
seedSettings(db);
152+
const bucket = makeFakeBucket();
153+
const env = makeDbEnv(db, {
154+
ADMIN_EMAILS: 'someone-else',
155+
PUBLIC_MENU_BUCKET: bucket,
156+
R2_PUBLIC_URL: 'https://cdn.example.com',
157+
} as Partial<Env>);
158+
void makeEnv;
159+
160+
const res = await fetchApp('/admin/locale-flag/vec', {
161+
method: 'POST',
162+
headers: { 'Content-Type': 'image/jpeg' },
163+
body: makeJpeg(),
164+
}, env);
165+
expect([401, 403]).toContain(res.status);
166+
});
167+
});
168+
169+
describe('DELETE /admin/locale-flag/:code', () => {
170+
it('clears flagUrl and removes the R2 object', async () => {
171+
const { env, headers, bucket } = await adminEnv();
172+
await setCustomLocales(env, headers, [{ code: 'vec', name: 'Veneto' }]);
173+
174+
const upload = await fetchApp('/admin/locale-flag/vec', {
175+
method: 'POST',
176+
headers: { 'Content-Type': 'image/jpeg', ...headers },
177+
body: makeJpeg(),
178+
}, env);
179+
expect(upload.status).toBe(200);
180+
expect(flagKeys(bucket)).toHaveLength(1);
181+
182+
const del = await fetchApp('/admin/locale-flag/vec', { method: 'DELETE', headers }, env);
183+
expect(del.status).toBe(200);
184+
expect(flagKeys(bucket)).toHaveLength(0);
185+
186+
const settings = await fetchApp('/admin/settings', { method: 'GET', headers }, env);
187+
const body = await settings.json() as { customLocales?: { code: string; flagUrl?: string | null }[] };
188+
expect(body.customLocales?.find((l) => l.code === 'vec')?.flagUrl ?? null).toBeNull();
189+
});
190+
});

backend/src/db/schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export const settings = sqliteTable('settings', {
5959
aiChatEnabled: integer('ai_chat_enabled', { mode: 'boolean' }).default(false).notNull(),
6060
enabledLocales: jsonColumn<string[] | null>('enabled_locales'),
6161
disabledLocales: jsonColumn<string[] | null>('disabled_locales'),
62-
customLocales: jsonColumn<{ code: string; name: string }[] | null>('custom_locales'),
62+
customLocales: jsonColumn<{ code: string; name: string; flagUrl?: string | null }[] | null>('custom_locales'),
6363
publicationState: text('publication_state').default('draft').notNull(),
6464
...timestamps,
6565
});

backend/src/routes/admin.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,73 @@ admin.post('/promotion-image', ...base, async (c) => {
586586
return c.json({ ok: true, imageUrl: upload.imageUrl });
587587
});
588588

589+
admin.post('/locale-flag/:code', ...base, async (c) => {
590+
const code = c.req.param('code');
591+
if (!/^[a-z0-9-]{2,10}$/.test(code)) {
592+
return c.json({ error: 'Invalid locale code' }, 400);
593+
}
594+
595+
const db = c.get('db');
596+
const [row] = await db
597+
.select({ customLocales: schema.settings.customLocales })
598+
.from(schema.settings)
599+
.where(eq(schema.settings.id, 1))
600+
.limit(1);
601+
const list = row?.customLocales ?? [];
602+
const target = list.find((l) => l.code === code);
603+
if (!target) return c.json({ error: 'Custom locale not found' }, 404);
604+
605+
const upload = await uploadSettingsImage(c, `flag-${code}`);
606+
if ('response' in upload) return upload.response;
607+
608+
const bucket = c.env.PUBLIC_MENU_BUCKET;
609+
if (target.flagUrl && bucket) {
610+
const oldKey = r2KeyFromUrl(target.flagUrl);
611+
if (oldKey) await bucket.delete(oldKey);
612+
}
613+
614+
const updated = list.map((l) => l.code === code ? { ...l, flagUrl: upload.imageUrl } : l);
615+
await db
616+
.update(schema.settings)
617+
.set({ customLocales: updated, updatedAt: Date.now() })
618+
.where(eq(schema.settings.id, 1));
619+
620+
await refreshPublicCatalog(c);
621+
return c.json({ ok: true, flagUrl: upload.imageUrl });
622+
});
623+
624+
admin.delete('/locale-flag/:code', ...base, async (c) => {
625+
const code = c.req.param('code');
626+
if (!/^[a-z0-9-]{2,10}$/.test(code)) {
627+
return c.json({ error: 'Invalid locale code' }, 400);
628+
}
629+
630+
const db = c.get('db');
631+
const [row] = await db
632+
.select({ customLocales: schema.settings.customLocales })
633+
.from(schema.settings)
634+
.where(eq(schema.settings.id, 1))
635+
.limit(1);
636+
const list = row?.customLocales ?? [];
637+
const target = list.find((l) => l.code === code);
638+
if (!target) return c.json({ error: 'Custom locale not found' }, 404);
639+
640+
const bucket = c.env.PUBLIC_MENU_BUCKET;
641+
if (target.flagUrl && bucket) {
642+
const oldKey = r2KeyFromUrl(target.flagUrl);
643+
if (oldKey) await bucket.delete(oldKey);
644+
}
645+
646+
const updated = list.map((l) => l.code === code ? { ...l, flagUrl: null } : l);
647+
await db
648+
.update(schema.settings)
649+
.set({ customLocales: updated, updatedAt: Date.now() })
650+
.where(eq(schema.settings.id, 1));
651+
652+
await refreshPublicCatalog(c);
653+
return c.json({ ok: true });
654+
});
655+
589656
// ── Analytics ────────────────────────────────────────────────────────
590657

591658
admin.get('/analytics', ...base, async (c) => {

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/schemas/src/admin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export const UpdateSettingsBodySchema = z.object({
1515
aiChatEnabled: z.boolean().optional(),
1616
enabledLocales: z.array(z.string()).nullable().optional(),
1717
disabledLocales: z.array(z.string()).nullable().optional(),
18-
customLocales: z.array(z.object({ code: z.string().min(2).max(10).regex(/^[a-z0-9-]+$/), name: z.string().min(1).max(50) })).nullable().optional(),
18+
customLocales: z.array(z.object({ code: z.string().min(2).max(10).regex(/^[a-z0-9-]+$/), name: z.string().min(1).max(50), flagUrl: z.string().url().nullable().optional() })).nullable().optional(),
1919
});
2020
export type UpdateSettingsBody = z.infer<typeof UpdateSettingsBodySchema>;
2121

packages/schemas/src/catalog.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export const CatalogRestaurantSchema = z.object({
9898
aiChat: z.boolean(),
9999
enabledLocales: z.array(z.string()).nullable().optional(),
100100
disabledLocales: z.array(z.string()).nullable().optional(),
101-
customLocales: z.array(z.object({ code: z.string(), name: z.string() })).nullable().optional(),
101+
customLocales: z.array(z.object({ code: z.string(), name: z.string(), flagUrl: z.string().nullable().optional() })).nullable().optional(),
102102
}).optional(),
103103
});
104104
export type CatalogRestaurant = z.infer<typeof CatalogRestaurantSchema>;

web/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
"@dnd-kit/utilities": "^3.2.2",
2828
"@headlessui/react": "^2.2.9",
2929
"@hookform/resolvers": "^5.2.2",
30+
"@menu/schemas": "*",
3031
"clsx": "^2.1.1",
32+
"country-flag-icons": "^1.6.16",
3133
"date-fns": "^4.1.0",
3234
"framer-motion": "^12.25.0",
3335
"next": "^16.1.1",
@@ -38,8 +40,7 @@
3840
"react-intersection-observer": "^10.0.0",
3941
"tailwind-merge": "^3.4.0",
4042
"zod": "^4.3.5",
41-
"zustand": "^5.0.9",
42-
"@menu/schemas": "*"
43+
"zustand": "^5.0.9"
4344
},
4445
"devDependencies": {
4546
"@opennextjs/cloudflare": "^1.0.0",

0 commit comments

Comments
 (0)