feat(web): i18n with react-i18next — Albanian default, English second

Add react-i18next with two key-parity-checked catalogs (sq default/fallback, en).
Active language driven by the logged-in user's stored preference (applied after
/me resolves); SQ/EN toggle in the header persists via PUT /api/auth/language.
Translate the booth (screen, pay/exit modal, active sessions, snapshots, status),
Login, ShiftControl, SiteSettings, PermitManager, TariffComposer.

SetupWizard deferred (its content is server-provided; needs backend catalog i18n).
This commit is contained in:
2026-06-18 11:47:39 +02:00
parent 445bca0bf6
commit 14c83e182a
19 changed files with 840 additions and 201 deletions
+4 -2
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { fetchSnapshots, snapshotImageUrl } from "../api.js";
@@ -7,6 +8,7 @@ import { fetchSnapshots, snapshotImageUrl } from "../api.js";
// served with a long immutable cache); clicking one enlarges it. Read-only.
export function SnapshotStrip({ identity }: { identity: string }) {
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ["snapshots", identity],
queryFn: () => fetchSnapshots(identity),
@@ -16,8 +18,8 @@ export function SnapshotStrip({ identity }: { identity: string }) {
const shots = data?.snapshots ?? [];
if (isLoading) return <div className="text-[11px] text-term-muted">loading snapshots…</div>;
if (shots.length === 0) return <div className="text-[11px] text-term-muted">no snapshots</div>;
if (isLoading) return <div className="text-[11px] text-term-muted">{t("pay.loadingSnapshots")}</div>;
if (shots.length === 0) return <div className="text-[11px] text-term-muted">{t("pay.noSnapshots")}</div>;
return (
<>
+7 -5
View File
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { useLiveStore, type WsStatus } from "../lib/live-store.js";
// Small live-connection indicator for the booth chrome: a coloured dot + label
@@ -8,20 +9,21 @@ const COLOR: Record<WsStatus, string> = {
connecting: "bg-term-amber",
closed: "bg-term-red",
};
const LABEL: Record<WsStatus, string> = {
open: "LIVE",
connecting: "CONNECTING",
closed: "OFFLINE",
const LABEL_KEY: Record<WsStatus, string> = {
open: "status.live",
connecting: "status.connecting",
closed: "status.offline",
};
export function StatusDot() {
const { t } = useTranslation();
const status = useLiveStore((s) => s.status);
return (
<span className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-term-muted">
<span
className={`inline-block h-2 w-2 rounded-full ${COLOR[status]} ${status === "open" ? "" : "animate-pulse"}`}
/>
{LABEL[status]}
{t(LABEL_KEY[status])}
</span>
);
}