From 6962d2f328c35b7358afd5ed197544c33e83ecc0 Mon Sep 17 00:00:00 2001 From: sebo Date: Mon, 6 Jul 2026 16:58:40 +0200 Subject: [PATCH 1/9] Per-user language selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the UI language from a single deployment-wide file (LANGUAGE_FILE) to a per-user choice with a warp_lang cookie carrying it across login/logout. - Config: LANGUAGES (JSON array) + DEFAULT_LANGUAGE; LANGUAGE_FILE removed; startup validation (i18n.init_app) aborts on bad config. - warp/i18n.py: locale metadata lives in each i18n/.json (name/flag top-level fields); pure resolve(cookie, pref, default) — prefs win for logged-in, cookie for the login page, default fallback; always reads prefs at render so a stale cookie can't shadow a user pref (shared-device). - Bootstrap sync (centralized, auth-backend-agnostic): prefs non-NULL resets the cookie; NULL pref + valid cookie upserts into prefs (INSERT(login, language) on conflict — plain UPDATE no-ops for prefs-less users); invalid cookie deleted; NULL+no-cookie -> default, no cookie set. - Prefs: language column (NULL = Default/deployment fallback); schema is type-only with a runtime in-LANGUAGES check (400); wire value for Default is JSON null (empty string 400s); Set-Cookie set/deleted to mirror; calendar cache invalidated on change. - iCal feed resolves the owner's language; action page resolves the request; /debug/set_language repurposed to set DEFAULT_LANGUAGE at runtime (keeps the existing ical-language e2e scenario working). - Login top-bar: hand-rolled flag dropdown left of the theme toggle (the public bundle ships no Materialize JS); prefs modal gets a Materialize FormSelect Language row with a Default option; reload only if changed. - stamped server-side; flag SVGs from lipis/flag-icons (MIT). - Schema 18 -> 19 (user_prefs.language); 5 locale files gain name/flag + Language/Default phrases (284 keys each); docs updated; e2e container runs with WARP_LANGUAGES=["en","de"]. Tests: 21 new pytest (resolve, config validation, prefs/bootstrap sync); new e2e/settings/language.spec.ts. 137 pytest pass; npm run build OK; check_i18n.py clean. Full e2e run deferred to the push gate. See PLAN_language_selection.md (gitignored) for the design + divergences. Co-Authored-By: GLM 5.2 --- CONFIGURATION.md | 36 +++-- FEATURES.md | 8 +- README.md | 5 + containers/README.md | 6 +- containers/compose/compose.yaml | 3 +- e2e/README.md | 5 + e2e/fixtures.ts | 7 +- e2e/global-setup.ts | 4 + e2e/helpers/debug.ts | 8 +- e2e/tests/settings/ical-language.spec.ts | 11 +- e2e/tests/settings/language.spec.ts | 165 ++++++++++++++++++++++ js/app/modals/prefs.js | 27 +++- js/base/public.js | 39 ++++++ js/base/style.css | 43 ++++++ tests/test_config_languages.py | 88 ++++++++++++ tests/test_i18n_resolve.py | 64 +++++++++ tests/test_prefs_language.py | 160 ++++++++++++++++++++++ warp/__init__.py | 27 ++++ warp/config.py | 14 +- warp/db.py | 3 +- warp/debug.py | 19 ++- warp/i18n.py | 167 +++++++++++++++++++++++ warp/ical.py | 16 ++- warp/sql/migration_019_user_language.sql | 3 + warp/sql/schema.sql | 3 +- warp/static/i18n/de.json | 4 + warp/static/i18n/en.json | 4 + warp/static/i18n/es.json | 4 + warp/static/i18n/fr.json | 4 + warp/static/i18n/pl.json | 4 + warp/static/images/flags/de.svg | 1 + warp/static/images/flags/es.svg | 1 + warp/static/images/flags/fr.svg | 1 + warp/static/images/flags/gb.svg | 1 + warp/static/images/flags/pl.svg | 1 + warp/templates/base.html | 4 +- warp/templates/public_nav.html | 17 +++ warp/templates/spa.html | 9 ++ warp/view.py | 2 +- warp/xhr/bootstrap.py | 57 +++++++- warp/xhr/prefs.py | 33 ++++- 41 files changed, 1024 insertions(+), 54 deletions(-) create mode 100644 e2e/tests/settings/language.spec.ts create mode 100644 tests/test_config_languages.py create mode 100644 tests/test_i18n_resolve.py create mode 100644 tests/test_prefs_language.py create mode 100644 warp/i18n.py create mode 100644 warp/sql/migration_019_user_language.sql create mode 100644 warp/static/images/flags/de.svg create mode 100644 warp/static/images/flags/es.svg create mode 100644 warp/static/images/flags/fr.svg create mode 100644 warp/static/images/flags/gb.svg create mode 100644 warp/static/images/flags/pl.svg diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 20710b66..185633c3 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -59,7 +59,8 @@ environment: | `SECRET_KEY` | — | **yes**¹ | Cookie signing key | | `DATABASE_ARGS` | `{}` | no | Extra args for the psycopg3 driver | | `SESSION_LIFETIME` | `1` | no | Session duration in days | -| `LANGUAGE_FILE` | `i18n/en.json` | no | UI translation file | +| `LANGUAGES` | `["en"]` | no | JSON array of locale codes offered in the picker (subset of `en`/`de`/`fr`/`es`/`pl`) | +| `DEFAULT_LANGUAGE` | `en` | no | Fallback language (NULL user pref + no cookie). Must be listed in `LANGUAGES` | | `THEME_FILE` | `theme.css` | no | Colour theme stylesheet (`static/`-relative name, or absolute path/URL) | | `BASE_PATH` | *(empty)* | no | URL prefix WARP is mounted under, e.g. `/warp` (see [Mounting under a URL prefix](#mounting-under-a-url-prefix)) | | `WEEKS_IN_ADVANCE` | `1` | no | Weeks after current week available for booking | @@ -163,22 +164,33 @@ python -c 'from subprocess import run; print(run(["openssl","rand","16"],capture ## Language -The UI language is set globally for the instance — all users see the same language. +The UI language is a **per-user** choice: a language picker on the login +screen and in **Preferences** lets each user pick their own, stored in +`user_prefs.language` and carried across login/logout by the `warp_lang` +cookie. A deployment configures which languages are offered and the fallback: -| Language | `LANGUAGE_FILE` value | -| ----------------- | --------------------- | -| English (default) | `i18n/en.json` | -| German | `i18n/de.json` | -| French | `i18n/fr.json` | -| Spanish | `i18n/es.json` | -| Polish | `i18n/pl.json` | +| Setting | Default | Meaning | +| ------------------- | ------------ | ------- | +| `WARP_LANGUAGES` | `["en"]` | JSON array of locale codes offered in the picker (subset of `en`/`de`/`fr`/`es`/`pl`). The picker only renders when more than one is listed. | +| `WARP_DEFAULT_LANGUAGE` | `en` | Fallback language for users with no pref and no cookie. Must be listed in `WARP_LANGUAGES`. | ``` -WARP_LANGUAGE_FILE=i18n/de.json +WARP_LANGUAGES='["en","de","pl"]' +WARP_DEFAULT_LANGUAGE=en ``` -The iCal feed and action pages use the same language file for event summaries and -button labels. +Resolution precedence: **logged-in** users — `user_prefs.language` → `warp_lang` +cookie → `DEFAULT_LANGUAGE` (a stale cookie left by another user on a shared +device does not override your pref). **Login screen** (not logged in) — +`warp_lang` cookie → `DEFAULT_LANGUAGE`. The `Default` option in Preferences +maps to NULL (follow `DEFAULT_LANGUAGE`). + +> **Breaking change:** the former `WARP_LANGUAGE_FILE` (single deployment-wide +> file) is removed. Migrate by setting `WARP_LANGUAGES` (a JSON array) and +> `WARP_DEFAULT_LANGUAGE` instead. + +The iCal feed and action pages render in the owner's resolved language (a NULL +pref falls back to `DEFAULT_LANGUAGE`). --- diff --git a/FEATURES.md b/FEATURES.md index 8a4963fe..365f751d 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -668,9 +668,10 @@ All text on these pages is translated according to the deployment-wide language ## 20. Multi-Language Support - WARP supports **English, German, French, Spanish, and Polish**. -- The language is configured globally per instance via `LANGUAGE_FILE` (e.g., `i18n/de.json`). +- The language is a **per-user choice**: a flag picker on the login screen and a **Language** row in **Preferences** let each user pick their own. The choice is stored in `user_prefs.language` and carried across login/logout by the `warp_lang` cookie. A `Default` option follows the deployment's `DEFAULT_LANGUAGE`. +- Which languages a deployment offers is configured via `WARP_LANGUAGES` (JSON array); the fallback via `WARP_DEFAULT_LANGUAGE`. - All UI strings (buttons, labels, error messages, modal text) are translated. -- The iCal feed uses the same language for event summaries and action page text. +- The iCal feed and action pages render in the owner's resolved language. - Date pickers adapt to the locale (first day of week, month names, etc.). --- @@ -830,7 +831,8 @@ A zone admin can **release another user's booking** from the plan map by clickin | `MAX_MAP_SIZE` | 2 MB | Maximum zone map image size | | `MAX_CONTENT_LENGTH` | 5 MB | Maximum request body size | | `TIMEZONE` | auto-detect | Timezone label for iCal DTSTART/DTEND | -| `LANGUAGE_FILE` | `i18n/en.json` | UI language file | +| `LANGUAGES` | `["en"]` | JSON array of locale codes offered in the picker | +| `DEFAULT_LANGUAGE` | `en` | Fallback language (NULL user pref / no cookie) | | `SECRET_KEY` | — (required) | Key for signing session cookies | | `DATABASE_ADDRESS` | — (required) | Database host or `host:port` (port defaults to 5432) | | `DATABASE_NAME` | — (required) | Database name | diff --git a/README.md b/README.md index 961786fc..9ce73d64 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,11 @@ for writing new tests. # Other +## Third-party assets + +- **Material Icons** (Google) — `warp/static/material_icons/`, Apache 2.0. +- **Flag SVGs** — `warp/static/images/flags/`, from [lipis/flag-icons](https://github.com/lipis/flag-icons) (MIT). Used for the per-user language picker. + ## How can I support you Oh.. I was not expecting that, but you can send a beer via PayPal: https://paypal.me/sebo271 diff --git a/containers/README.md b/containers/README.md index b9050677..145897d7 100644 --- a/containers/README.md +++ b/containers/README.md @@ -165,7 +165,8 @@ LDAP, …) or any other feature, add the relevant `WARP_*` variables under | `warp_secret_key` secret | `mysecretkey` | A random secret — see [CONFIGURATION.md](../CONFIGURATION.md#secret-key) | | `warp_db_password` secret | `postgres_password` | A strong database password (used by both the DB and the app) | | `warp-app` image tag | `:latest` | A pinned version, e.g. `:v1.2.3` | -| `WARP_LANGUAGE_FILE` | `i18n/en.json` | Your preferred language (`de`/`fr`/`es`/`pl`) | +| `WARP_LANGUAGES` | `["en"]` | JSON array of locale codes offered in the picker (`en`/`de`/`fr`/`es`/`pl`) | +| `WARP_DEFAULT_LANGUAGE` | `en` | Fallback language (must be listed in `WARP_LANGUAGES`) | --- @@ -261,7 +262,8 @@ install the unit files under `~/.config/containers/systemd/` instead. 4. **Review the unit files** and adjust paths if you changed any of the directories above (`Volume=` lines in `warp-db.container` and `warp-revproxy.container`), the image reference in `warp-app.container`, and - `WARP_LANGUAGE_FILE` (`i18n/en.json`, also `de`/`fr`/`es`/`pl`). + `WARP_LANGUAGES` / `WARP_DEFAULT_LANGUAGE` (per-user language picker; see + CONFIGURATION.md). 5. **Install the unit files** into the Quadlet drop-in directory and reload: diff --git a/containers/compose/compose.yaml b/containers/compose/compose.yaml index 58f7a2ec..f90ade93 100644 --- a/containers/compose/compose.yaml +++ b/containers/compose/compose.yaml @@ -40,7 +40,8 @@ services: WARP_DATABASE_USER: "postgres" WARP_DATABASE_PASSWORD_FILE: /run/secrets/warp_db_password WARP_SECRET_KEY_FILE: /run/secrets/warp_secret_key - WARP_LANGUAGE_FILE: "i18n/en.json" + WARP_LANGUAGES: '["en"]' + WARP_DEFAULT_LANGUAGE: "en" # uWSGI endpoints, mirroring the Quadlet setup: disable the binary uWSGI # socket (empty value) so only the HTTP unix socket that Caddy proxies to # over the shared /run/warp volume remains. The HTTP socket already defaults diff --git a/e2e/README.md b/e2e/README.md index 5a9236f9..48c477c2 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -77,6 +77,11 @@ npm run report # open last HTML report test, so no manual cleanup is needed. Mind that jumping forward by a day or more expires every login session (`SESSION_LIFETIME`): log in again after advancing the clock, or `/xhr/*` calls silently redirect to `/login`. +- **Language**: the e2e container is started with + `WARP_LANGUAGES='["en","de"]'` and `WARP_DEFAULT_LANGUAGE=en`, so the + per-user language picker renders with two entries. `setLanguage(page, lang)` + (debug only) switches the deployment fallback `DEFAULT_LANGUAGE` at runtime + and clears the iCal feed cache; the fixture resets it to `en` between tests. - Because all tests share one database, the config pins `workers: 1` and `fullyParallel: false`. Do not turn parallelism on without giving each worker its own database. diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 47391400..62cd5654 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -20,9 +20,10 @@ async function resetServerClock(baseURL: string): Promise { } /** - * Reset the deployment language to English (see helpers/debug.ts setLanguage). - * LANGUAGE_FILE is process-global state; a test that switches language would - * otherwise poison every test after it. Tolerates 404 for a non-debug server. + * Reset the deployment fallback language to English (see helpers/debug.ts + * setLanguage). DEFAULT_LANGUAGE is process-global state; a test that switches + * it would otherwise poison every test after it. Tolerates 404 for a non-debug + * server. */ async function resetServerLanguage(baseURL: string): Promise { const resp = await fetch(`${baseURL}/debug/set_language`, { diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index fc3e384b..7dcc0743 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -89,6 +89,10 @@ export default async function globalSetup() { // The suite resets the database directly over TCP, so Postgres must bind // all interfaces inside the container (off by default — see Dockerfile_debug). '-e', 'EXPOSE_POSTGRES=1', + // Per-user language picker needs >1 configured language to render the + // dropdown; the ical-language spec also switches DEFAULT_LANGUAGE to 'de'. + '-e', "WARP_LANGUAGES=[\"en\",\"de\"]", + '-e', 'WARP_DEFAULT_LANGUAGE=en', IMAGE_TAG, ]); diff --git a/e2e/helpers/debug.ts b/e2e/helpers/debug.ts index 54d93d72..a5b895d6 100644 --- a/e2e/helpers/debug.ts +++ b/e2e/helpers/debug.ts @@ -33,10 +33,10 @@ export async function advanceDays(page: Page, days: number): Promise { } /** - * Switch the deployment language (debug only). Sets LANGUAGE_FILE and clears - * the iCal feed cache so the feed regenerates in the new language. - * `lang` is a short code ('de','en',...) resolved to `i18n/.json`, - * or pass a full path via the second arg. Reset between tests with 'en'. + * Switch the deployment fallback language (debug only). Sets DEFAULT_LANGUAGE + * (the per-user resolver falls back to it when a user has no pref) and clears + * the iCal feed cache so the feed regenerates in the new language. `lang` is a + * short code ('de','en',...); reset between tests with 'en'. */ export async function setLanguage(page: Page, lang: string, fullPath?: string): Promise { const languageFile = fullPath ?? `i18n/${lang}.json`; diff --git a/e2e/tests/settings/ical-language.spec.ts b/e2e/tests/settings/ical-language.spec.ts index 49636178..782f3f15 100644 --- a/e2e/tests/settings/ical-language.spec.ts +++ b/e2e/tests/settings/ical-language.spec.ts @@ -3,9 +3,10 @@ * * Feed event summaries (phrases: booking / missing / release) and the * action-page titles + button labels (Release seat? / Seat released / - * Confirm / Cancel …) are rendered from the deployment language file - * (LANGUAGE_FILE). Switched at runtime via the debug-only /debug/set_language - * endpoint; the per-test fixture resets LANGUAGE_FILE to English afterwards. + * Confirm / Cancel ...) are rendered from the resolved language. For a user + * with no language pref, that falls back to the deployment DEFAULT_LANGUAGE, + * switched at runtime via the debug-only /debug/set_language endpoint; the + * per-test fixture resets DEFAULT_LANGUAGE to English afterwards. */ import { test, expect } from '../../fixtures'; @@ -37,7 +38,7 @@ async function fetchIcal(page: Page, token: string): Promise { return parseIcal(await resp.text()); } -// ─── Feed summaries follow LANGUAGE_FILE ──────────────────────────────────── +// ─── Feed summaries follow the resolved language ──────────────────────── test.describe('iCal feed text follows the deployment language', () => { test('German: booking summary uses "Platz {name}" and reminders "Platz in … buchen"', async ({ page }) => { @@ -76,7 +77,7 @@ test.describe('iCal feed text follows the deployment language', () => { }); }); -// ─── Action-page text follows LANGUAGE_FILE ───────────────────────────────── +// ─── Action-page text follows the resolved language ─────────────────────── test.describe('iCal action-page text follows the deployment language', () => { test('German: release confirm page shows "Platz freigeben?" and Bestätigen/Abbrechen', async ({ page }) => { diff --git a/e2e/tests/settings/language.spec.ts b/e2e/tests/settings/language.spec.ts new file mode 100644 index 00000000..86c4ed49 --- /dev/null +++ b/e2e/tests/settings/language.spec.ts @@ -0,0 +1,165 @@ +/** + * Per-user language selection (see PLAN_language_selection.md). + * + * Coverage: + * - login screen flag dropdown: switching reloads + renders in the chosen + * language (assert a translated string); the warp_lang cookie is set; + * clicking the already-active flag does NOT reload (bounded negative). + * - Preferences: a Language row with a Default option; selecting a language + * and saving reloads + persists (DB backchannel); Default + Save stores NULL + * and clears the cookie; saving with no language change does not pin NULL. + * - precedence (shared device): a seeded user pref beats a stale cookie, and + * bootstrap resets the cookie to the pref. + * - default: no cookie + NULL pref renders the deployment default (en). + * + * The container runs with WARP_LANGUAGES='["en","de"]' (global-setup.ts). + */ + +import { test, expect } from '../../fixtures'; +import { logIn, expectLoggedIn } from '../../helpers/auth'; +import { USER1 } from '../../helpers/users'; +import { querySql } from '../../helpers/db'; +import { waitForViewReady } from '../../helpers/spa'; + +type Page = import('@playwright/test').Page; + +async function clearUserPrefs(login: string): Promise { + await querySql('DELETE FROM user_prefs WHERE login = $1', [login]); +} + +async function seedUserPref(login: string, language: string | null): Promise { + await querySql( + 'INSERT INTO user_prefs(login, language) VALUES($1,$2) ON CONFLICT(login) DO UPDATE SET language = $2', + [login, language], + ); +} + +async function prefLanguage(login: string): Promise { + const rows = await querySql('SELECT language FROM user_prefs WHERE login = $1', [login]); + return rows.rowCount ? rows.rows[0].language : null; +} + +// ─── Login screen dropdown ──────────────────────────────────────────────── + +test.describe('login screen language dropdown', () => { + + test('switching language reloads and renders the login button translated', async ({ page, context }) => { + await clearUserPrefs(USER1.login); + await page.goto('/login'); + await expect(page.locator('.lang-trigger')).toBeVisible(); + + // Default is English: the submit button reads "Login". + await expect(page.locator('button[type=submit]')).toHaveText('Login'); + + // Pick German from the dropdown. + await page.locator('.lang-trigger').click(); + await page.locator('.lang-dropdown a[data-lang="de"]').click(); + + // The click sets the cookie and reloads; after reload the button is German. + await expect(page.locator('button[type=submit]')).toHaveText('Anmelden'); + const cookies = await context.cookies(); + expect(cookies.find(c => c.name === 'warp_lang')?.value).toBe('de'); + }); + + test('clicking the already-active flag does not reload', async ({ page }) => { + await page.goto('/login'); + await page.locator('.lang-trigger').click(); + // English is active by default; clicking it must not navigate. + const navigations: number[] = []; + page.on('framenavigated', () => navigations.push(Date.now())); + await page.locator('.lang-dropdown a[data-lang="en"]').click(); + // Bounded wait: if no navigation fires within 1s, the negative holds. + await page.waitForTimeout(1000); + expect(navigations.length, 'no reload expected when choosing the active language').toBe(0); + }); +}); + +// ─── Preferences modal ──────────────────────────────────────────────────── + +async function openPrefs(page: Page): Promise { + await page.goto('/'); + await expectLoggedIn(page); + await page.locator('.dropdown-trigger[data-target="user_menu_dropdown"]').click(); + await page.locator('#user_menu_dropdown a', { hasText: 'Preferences' }).click(); + await expect(page.locator('#pref_modal')).toBeVisible(); + // The prefs GET populates the select; give it a beat. + await page.waitForTimeout(300); +} + +test.describe('Preferences: Language row', () => { + + test('selecting a language and saving reloads and persists', async ({ page }) => { + await clearUserPrefs(USER1.login); + await logIn(page, USER1); + await openPrefs(page); + + // Switch the Materialize select to German and save. + await page.locator('#pref_language').selectOption('de'); + await page.locator('#pref_save_btn').click(); + + // Reload lands us back in the SPA (booted) in German. + await waitForViewReady(page); + expect(await prefLanguage(USER1.login)).toBe('de'); + }); + + test('Default + Save stores NULL and clears the cookie', async ({ page, context }) => { + await seedUserPref(USER1.login, 'de'); + await logIn(page, USER1); + await openPrefs(page); + + // Set a cookie first so we can assert it is cleared. + await context.addCookies([{ name: 'warp_lang', value: 'de', url: page.url() }]); + + await page.locator('#pref_language').selectOption(''); + await page.locator('#pref_save_btn').click(); + await waitForViewReady(page); + + expect(await prefLanguage(USER1.login)).toBeNull(); + const cookies = await context.cookies(); + expect(cookies.find(c => c.name === 'warp_lang')).toBeUndefined(); + }); + + test('saving with no language change does not pin NULL', async ({ page }) => { + // NULL pref: a normal prefs save (toggle a feature) must keep language NULL. + await clearUserPrefs(USER1.login); + await logIn(page, USER1); + await openPrefs(page); + + const lever = page.locator('label:has(#pref_zone_show_seat_names) .lever'); + await lever.scrollIntoViewIfNeeded(); + await lever.click(); + await page.locator('#pref_save_btn').click(); + + await expect(page.locator('.toast', { hasText: 'Preferences saved' })).toBeVisible(); + // Language stays NULL (Default), not pinned to the deployment default. + expect(await prefLanguage(USER1.login)).toBeNull(); + }); +}); + +// ─── Precedence / shared device ─────────────────────────────────────────── + +test.describe('language precedence', () => { + + test('user pref beats a stale cookie; bootstrap resets the cookie', async ({ page, context }) => { + await seedUserPref(USER1.login, 'de'); + // A stale cookie from another user on the shared device. + await context.addCookies([{ name: 'warp_lang', value: 'en', url: 'http://localhost' }]); + + await logIn(page, USER1); + await expectLoggedIn(page); + + // The SPA renders in the user's pref (de), not the stale cookie (en). + await expect(page.locator('html')).toHaveAttribute('lang', 'de'); + // Bootstrap has reset the cookie to the pref. + const cookies = await context.cookies(); + expect(cookies.find(c => c.name === 'warp_lang')?.value).toBe('de'); + }); + + test('no cookie and NULL pref render the deployment default', async ({ page, context }) => { + await clearUserPrefs(USER1.login); + await context.clearCookies(); + await logIn(page, USER1); + await expectLoggedIn(page); + await expect(page.locator('html')).toHaveAttribute('lang', 'en'); + }); +}); \ No newline at end of file diff --git a/js/app/modals/prefs.js b/js/app/modals/prefs.js index 084c37d6..b1c2c338 100644 --- a/js/app/modals/prefs.js +++ b/js/app/modals/prefs.js @@ -23,6 +23,7 @@ export function initPrefs() { var showSeatNamesEl = document.getElementById('pref_zone_show_seat_names'); var showBookingPreviewEl = document.getElementById('pref_zone_show_booking_preview'); var showAssignedNamesEl = document.getElementById('pref_zone_show_assigned_names'); + var langSelectEl = document.getElementById('pref_language'); if (!prefModalEl || !planSelectEl || !daySelectEl || !saveBtn || !sliderEl) return; @@ -33,6 +34,7 @@ export function initPrefs() { var DEFAULT_TIME = [9 * 3600, 17 * 3600]; var loadedPrefs = null; + var loadedLang = null; // normalized: null = Default; tracked for reload-only-if-changed var slider = null; // The