Add multi-language support (Norwegian, English, Nynorsk)

This commit is contained in:
steinhelge
2025-11-24 06:34:21 +01:00
parent 6417f1b8be
commit 3736ccc4f5
11 changed files with 521 additions and 25 deletions
+88 -1
View File
@@ -14,11 +14,13 @@
"bootstrap": "^5.3.8",
"date-fns": "^4.1.0",
"html5-qrcode": "^2.3.8",
"i18next": "^25.6.3",
"lucide-react": "^0.554.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.0",
"react-i18next": "^16.3.5",
"react-router-dom": "^7.9.6"
},
"devDependencies": {
@@ -3000,12 +3002,52 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/html5-qrcode": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
"license": "Apache-2.0"
},
"node_modules/i18next": {
"version": "25.6.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.3.tgz",
"integrity": "sha512-AEQvoPDljhp67a1+NsnG/Wb1Nh6YoSvtrmeEd24sfGn3uujCtXCF3cXpr7ulhMywKNFF7p3TX1u2j7y+caLOJg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3633,6 +3675,33 @@
"react": "^19.2.0"
}
},
"node_modules/react-i18next": {
"version": "16.3.5",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz",
"integrity": "sha512-F7Kglc+T0aE6W2rO5eCAFBEuWRpNb5IFmXOYEgztjZEuiuSLTe/xBIEG6Q3S0fbl8GXMNo+Q7gF8bpokFNWJww==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -3981,7 +4050,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -4078,6 +4147,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "7.2.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
@@ -4184,6 +4262,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+2
View File
@@ -16,11 +16,13 @@
"bootstrap": "^5.3.8",
"date-fns": "^4.1.0",
"html5-qrcode": "^2.3.8",
"i18next": "^25.6.3",
"lucide-react": "^0.554.0",
"qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-bootstrap": "^2.10.10",
"react-dom": "^19.2.0",
"react-i18next": "^16.3.5",
"react-router-dom": "^7.9.6"
},
"devDependencies": {
+15 -6
View File
@@ -1,8 +1,10 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Routes, Route, Link, useNavigate } from 'react-router-dom';
import { Navbar, Nav, Container, Button } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import { ProtectedRoute } from './components/ProtectedRoute';
import { LanguageSelector } from './components/LanguageSelector';
import { LoginPage } from './pages/LoginPage';
import EventsPage from './pages/admin/EventsPage';
import EventDetailPage from './pages/admin/EventDetailPage';
@@ -14,6 +16,7 @@ const queryClient = new QueryClient();
function NavbarContent() {
const { isAuthenticated, user, logout, hasRole } = useAuth();
const { t } = useTranslation();
const navigate = useNavigate();
const handleLogout = () => {
@@ -24,26 +27,32 @@ function NavbarContent() {
return (
<Navbar bg="primary" variant="dark" expand="lg" className="mb-4">
<Container>
<Navbar.Brand as={Link} to="/">Hospitality System</Navbar.Brand>
<Navbar.Brand as={Link} to="/">{t('nav.hospitalitySystem')}</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
{isAuthenticated && (
<>
<Nav className="me-auto">
{hasRole('Admin') && <Nav.Link as={Link} to="/">Admin</Nav.Link>}
{hasRole('Staff') && <Nav.Link as={Link} to="/staff">Staff Scanner</Nav.Link>}
{hasRole('Guest') && <Nav.Link as={Link} to="/guest">Guest View</Nav.Link>}
{hasRole('Admin') && <Nav.Link as={Link} to="/">{t('nav.admin')}</Nav.Link>}
{hasRole('Staff') && <Nav.Link as={Link} to="/staff">{t('nav.staffScanner')}</Nav.Link>}
{hasRole('Guest') && <Nav.Link as={Link} to="/guest">{t('nav.guestView')}</Nav.Link>}
</Nav>
<Nav>
<Navbar.Text className="me-3">
<LanguageSelector />
<Navbar.Text className="ms-3 me-3">
{user?.email} ({user?.roles.join(', ')})
</Navbar.Text>
<Button variant="outline-light" size="sm" onClick={handleLogout}>
Logout
{t('auth.logout')}
</Button>
</Nav>
</>
)}
{!isAuthenticated && (
<Nav className="ms-auto">
<LanguageSelector />
</Nav>
)}
</Navbar.Collapse>
</Container>
</Navbar>
@@ -0,0 +1,39 @@
import { Dropdown } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
const languages = [
{ code: 'nb', name: 'Norsk (Bokmål)', flag: '🇳🇴' },
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'nn', name: 'Nynorsk', flag: '🇳🇴' },
];
export const LanguageSelector = () => {
const { i18n } = useTranslation();
const currentLanguage = languages.find((lang) => lang.code === i18n.language) || languages[0];
const changeLanguage = (langCode: string) => {
i18n.changeLanguage(langCode);
localStorage.setItem('language', langCode);
};
return (
<Dropdown>
<Dropdown.Toggle variant="outline-light" size="sm" id="language-dropdown">
{currentLanguage.flag} {currentLanguage.name}
</Dropdown.Toggle>
<Dropdown.Menu>
{languages.map((lang) => (
<Dropdown.Item
key={lang.code}
active={lang.code === i18n.language}
onClick={() => changeLanguage(lang.code)}
>
{lang.flag} {lang.name}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};
+24
View File
@@ -0,0 +1,24 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import nb from './locales/nb.json';
import en from './locales/en.json';
import nn from './locales/nn.json';
const resources = {
nb: { translation: nb },
en: { translation: en },
nn: { translation: nn },
};
i18n
.use(initReactI18next)
.init({
resources,
lng: localStorage.getItem('language') || 'nb', // Default to Norwegian Bokmål
fallbackLng: 'nb',
interpolation: {
escapeValue: false, // React already escapes
},
});
export default i18n;
+110
View File
@@ -0,0 +1,110 @@
{
"common": {
"loading": "Loading...",
"error": "Error",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"create": "Create",
"update": "Update",
"search": "Search",
"close": "Close",
"back": "Back",
"next": "Next",
"submit": "Submit",
"confirm": "Confirm",
"yes": "Yes",
"no": "No"
},
"auth": {
"login": "Login",
"logout": "Logout",
"email": "Email",
"password": "Password",
"loginButton": "Login",
"loggingIn": "Logging in...",
"invalidCredentials": "Invalid email or password",
"testAccounts": "Test Accounts",
"admin": "Admin",
"staff": "Staff",
"guest": "Guest"
},
"nav": {
"admin": "Admin",
"staffScanner": "Staff Scanner",
"guestView": "Guest View",
"hospitalitySystem": "Hospitality System"
},
"events": {
"title": "Events",
"createEvent": "Create Event",
"eventName": "Event Name",
"location": "Location",
"startDate": "Start Date",
"endDate": "End Date",
"noEvents": "No events found",
"eventDetails": "Event Details",
"groups": "Groups",
"products": "Products",
"createGroup": "Create Group",
"createProduct": "Create Product"
},
"groups": {
"title": "Groups",
"groupName": "Group Name",
"contactPerson": "Contact Person",
"contactEmail": "Contact Email",
"people": "People",
"addPerson": "Add Person",
"groupDetails": "Group Details",
"noPeople": "No people in this group"
},
"people": {
"name": "Name",
"email": "Email",
"phone": "Phone",
"qrCode": "QR Code",
"copyQrCode": "Copy QR Code",
"qrCodeCopied": "QR Code copied!",
"assignQuota": "Assign Quota",
"quotas": "Quotas"
},
"products": {
"productName": "Product Name",
"productType": "Product Type",
"type": {
"drink": "Drink",
"meal": "Meal",
"access": "Access",
"special": "Special"
}
},
"quotas": {
"initialAmount": "Initial Amount",
"usedAmount": "Used Amount",
"remainingAmount": "Remaining Amount",
"remaining": "remaining",
"of": "of"
},
"scanner": {
"title": "Staff Scanner",
"enterQrCode": "Enter QR Code",
"qrCodePlaceholder": "Paste QR code here",
"lookupPerson": "Lookup Person",
"lookingUp": "Looking up...",
"personNotFound": "Person not found",
"personDetails": "Person Details",
"recordTransaction": "Record Transaction",
"use": "Use",
"transactionRecorded": "Transaction recorded!",
"insufficientQuota": "Insufficient quota"
},
"guest": {
"title": "My QR Code & Quotas",
"myQrCode": "My QR Code",
"myQuotas": "My Quotas",
"showQrCode": "Show this QR code to staff for scanning",
"noQuotas": "No quotas assigned",
"failedToLoad": "Failed to load your information. Please contact an administrator."
}
}
+110
View File
@@ -0,0 +1,110 @@
{
"common": {
"loading": "Laster...",
"error": "Feil",
"save": "Lagre",
"cancel": "Avbryt",
"delete": "Slett",
"create": "Opprett",
"update": "Oppdater",
"search": "Søk",
"close": "Lukk",
"back": "Tilbake",
"next": "Neste",
"submit": "Send inn",
"confirm": "Bekreft",
"yes": "Ja",
"no": "Nei"
},
"auth": {
"login": "Logg inn",
"logout": "Logg ut",
"email": "E-post",
"password": "Passord",
"loginButton": "Logg inn",
"loggingIn": "Logger inn...",
"invalidCredentials": "Ugyldig e-post eller passord",
"testAccounts": "Testkontoer",
"admin": "Admin",
"staff": "Personale",
"guest": "Gjest"
},
"nav": {
"admin": "Admin",
"staffScanner": "Personale Scanner",
"guestView": "Gjestevisning",
"hospitalitySystem": "Hospitality System"
},
"events": {
"title": "Arrangementer",
"createEvent": "Opprett arrangement",
"eventName": "Arrangementsnavn",
"location": "Sted",
"startDate": "Startdato",
"endDate": "Sluttdato",
"noEvents": "Ingen arrangementer funnet",
"eventDetails": "Arrangementsdetaljer",
"groups": "Grupper",
"products": "Produkter",
"createGroup": "Opprett gruppe",
"createProduct": "Opprett produkt"
},
"groups": {
"title": "Grupper",
"groupName": "Gruppenavn",
"contactPerson": "Kontaktperson",
"contactEmail": "Kontakt e-post",
"people": "Personer",
"addPerson": "Legg til person",
"groupDetails": "Gruppedetaljer",
"noPeople": "Ingen personer i denne gruppen"
},
"people": {
"name": "Navn",
"email": "E-post",
"phone": "Telefon",
"qrCode": "QR-kode",
"copyQrCode": "Kopier QR-kode",
"qrCodeCopied": "QR-kode kopiert!",
"assignQuota": "Tildel kvote",
"quotas": "Kvoter"
},
"products": {
"productName": "Produktnavn",
"productType": "Produkttype",
"type": {
"drink": "Drikke",
"meal": "Måltid",
"access": "Tilgang",
"special": "Spesiell"
}
},
"quotas": {
"initialAmount": "Opprinnelig mengde",
"usedAmount": "Brukt mengde",
"remainingAmount": "Gjenstående mengde",
"remaining": "gjenstående",
"of": "av"
},
"scanner": {
"title": "Personale Scanner",
"enterQrCode": "Skriv inn QR-kode",
"qrCodePlaceholder": "Lim inn QR-kode her",
"lookupPerson": "Slå opp person",
"lookingUp": "Slår opp...",
"personNotFound": "Person ikke funnet",
"personDetails": "Persondetaljer",
"recordTransaction": "Registrer transaksjon",
"use": "Bruk",
"transactionRecorded": "Transaksjon registrert!",
"insufficientQuota": "Utilstrekkelig kvote"
},
"guest": {
"title": "Min QR-kode og kvoter",
"myQrCode": "Min QR-kode",
"myQuotas": "Mine kvoter",
"showQrCode": "Vis denne QR-koden til personalet for skanning",
"noQuotas": "Ingen kvoter tildelt",
"failedToLoad": "Kunne ikke laste informasjonen din. Kontakt en administrator."
}
}
+110
View File
@@ -0,0 +1,110 @@
{
"common": {
"loading": "Lastar...",
"error": "Feil",
"save": "Lagre",
"cancel": "Avbryt",
"delete": "Slett",
"create": "Opprett",
"update": "Oppdater",
"search": "Søk",
"close": "Lukk",
"back": "Tilbake",
"next": "Neste",
"submit": "Send inn",
"confirm": "Stadfest",
"yes": "Ja",
"no": "Nei"
},
"auth": {
"login": "Logg inn",
"logout": "Logg ut",
"email": "E-post",
"password": "Passord",
"loginButton": "Logg inn",
"loggingIn": "Loggar inn...",
"invalidCredentials": "Ugyldig e-post eller passord",
"testAccounts": "Testkontoar",
"admin": "Admin",
"staff": "Personale",
"guest": "Gjest"
},
"nav": {
"admin": "Admin",
"staffScanner": "Personale Skannar",
"guestView": "Gjestevisning",
"hospitalitySystem": "Hospitality System"
},
"events": {
"title": "Arrangement",
"createEvent": "Opprett arrangement",
"eventName": "Arrangementnamn",
"location": "Stad",
"startDate": "Startdato",
"endDate": "Sluttdato",
"noEvents": "Ingen arrangement funne",
"eventDetails": "Arrangementsdetaljar",
"groups": "Grupper",
"products": "Produkt",
"createGroup": "Opprett gruppe",
"createProduct": "Opprett produkt"
},
"groups": {
"title": "Grupper",
"groupName": "Gruppenamn",
"contactPerson": "Kontaktperson",
"contactEmail": "Kontakt e-post",
"people": "Personar",
"addPerson": "Legg til person",
"groupDetails": "Gruppedetaljar",
"noPeople": "Ingen personar i denne gruppa"
},
"people": {
"name": "Namn",
"email": "E-post",
"phone": "Telefon",
"qrCode": "QR-kode",
"copyQrCode": "Kopier QR-kode",
"qrCodeCopied": "QR-kode kopiert!",
"assignQuota": "Tildel kvote",
"quotas": "Kvotar"
},
"products": {
"productName": "Produktnamn",
"productType": "Produkttype",
"type": {
"drink": "Drikke",
"meal": "Måltid",
"access": "Tilgang",
"special": "Spesiell"
}
},
"quotas": {
"initialAmount": "Opphavleg mengde",
"usedAmount": "Brukt mengde",
"remainingAmount": "Attståande mengde",
"remaining": "attståande",
"of": "av"
},
"scanner": {
"title": "Personale Skannar",
"enterQrCode": "Skriv inn QR-kode",
"qrCodePlaceholder": "Lim inn QR-kode her",
"lookupPerson": "Slå opp person",
"lookingUp": "Slår opp...",
"personNotFound": "Person ikkje funnen",
"personDetails": "Persondetaljar",
"recordTransaction": "Registrer transaksjon",
"use": "Bruk",
"transactionRecorded": "Transaksjon registrert!",
"insufficientQuota": "Utilstrekkeleg kvote"
},
"guest": {
"title": "Min QR-kode og kvotar",
"myQrCode": "Min QR-kode",
"myQuotas": "Mine kvotar",
"showQrCode": "Vis denne QR-koden til personalet for skanning",
"noQuotas": "Ingen kvotar tildelt",
"failedToLoad": "Kunne ikkje laste informasjonen din. Kontakt ein administrator."
}
}
+1
View File
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import 'bootstrap/dist/css/bootstrap.min.css'
import './index.css'
import './i18n' // Initialize i18n
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
+12 -10
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Container, Card, Form, Button, Alert } from 'react-bootstrap';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext';
export const LoginPage: React.FC = () => {
@@ -10,6 +11,7 @@ export const LoginPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { login } = useAuth();
const { t } = useTranslation();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -20,7 +22,7 @@ export const LoginPage: React.FC = () => {
await login({ email, password });
navigate('/');
} catch (err) {
setError('Invalid email or password');
setError(t('auth.invalidCredentials'));
} finally {
setLoading(false);
}
@@ -30,13 +32,13 @@ export const LoginPage: React.FC = () => {
<Container className="d-flex align-items-center justify-content-center" style={{ minHeight: '100vh' }}>
<Card style={{ maxWidth: '400px', width: '100%' }} className="shadow">
<Card.Body className="p-4">
<h2 className="text-center mb-4">Login</h2>
<h2 className="text-center mb-4">{t('auth.login')}</h2>
{error && <Alert variant="danger">{error}</Alert>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-3">
<Form.Label>Email</Form.Label>
<Form.Label>{t('auth.email')}</Form.Label>
<Form.Control
type="email"
value={email}
@@ -47,13 +49,13 @@ export const LoginPage: React.FC = () => {
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Password</Form.Label>
<Form.Label>{t('auth.password')}</Form.Label>
<Form.Control
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="Enter password"
placeholder={t('auth.password')}
/>
</Form.Group>
@@ -63,16 +65,16 @@ export const LoginPage: React.FC = () => {
className="w-100"
disabled={loading}
>
{loading ? 'Logging in...' : 'Login'}
{loading ? t('auth.loggingIn') : t('auth.loginButton')}
</Button>
</Form>
<div className="mt-4 text-center">
<small className="text-muted">
<strong>Test Accounts:</strong><br />
Admin: admin@example.com / Admin123!<br />
Staff: staff@example.com / Staff123!<br />
Guest: guest@example.com / Guest123!
<strong>{t('auth.testAccounts')}:</strong><br />
{t('auth.admin')}: admin@example.com / Admin123!<br />
{t('auth.staff')}: staff@example.com / Staff123!<br />
{t('auth.guest')}: guest@example.com / Guest123!
</small>
</div>
</Card.Body>
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { Container, Card, Alert, Spinner, ProgressBar } from 'react-bootstrap';
import { QRCodeSVG } from 'qrcode.react';
import { useTranslation } from 'react-i18next';
import { api } from '../../lib/api';
interface Quota {
@@ -20,6 +21,7 @@ export default function GuestQrPage() {
const [personData, setPersonData] = useState<PersonData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const { t } = useTranslation();
useEffect(() => {
// Auto-load guest's own data on mount
@@ -33,23 +35,23 @@ export default function GuestQrPage() {
setPersonData(response.data);
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to load your information. Make sure your account is linked to a person.');
setError(err.response?.data?.message || t('guest.failedToLoad'));
} finally {
setLoading(false);
}
};
fetchGuestData();
}, []);
}, [t]);
return (
<Container className="py-4">
<h2 className="mb-4">My QR Code & Quotas</h2>
<h2 className="mb-4">{t('guest.title')}</h2>
{loading && (
<div className="text-center py-5">
<Spinner animation="border" role="status">
<span className="visually-hidden">Loading...</span>
<span className="visually-hidden">{t('common.loading')}</span>
</Spinner>
</div>
)}
@@ -70,18 +72,18 @@ export default function GuestQrPage() {
</div>
<small className="text-muted d-block">
Show this QR code to staff for scanning
{t('guest.showQrCode')}
</small>
</Card.Body>
</Card>
<Card className="shadow-sm">
<Card.Header>
<h5 className="mb-0">My Quotas</h5>
<h5 className="mb-0">{t('guest.myQuotas')}</h5>
</Card.Header>
<Card.Body>
{personData.quotas.length === 0 ? (
<p className="text-muted mb-0">No quotas assigned</p>
<p className="text-muted mb-0">{t('guest.noQuotas')}</p>
) : (
<div className="d-grid gap-3">
{personData.quotas.map((quota, index) => (
@@ -89,7 +91,7 @@ export default function GuestQrPage() {
<div className="d-flex justify-content-between mb-2">
<strong>{quota.productName}</strong>
<span className="text-muted">
{quota.remainingAmount} / {quota.initialAmount} remaining
{quota.remainingAmount} / {quota.initialAmount} {t('quotas.remaining')}
</span>
</div>
<ProgressBar