diff --git a/src/hospitality-web/package-lock.json b/src/hospitality-web/package-lock.json index 576105d..af09c03 100644 --- a/src/hospitality-web/package-lock.json +++ b/src/hospitality-web/package-lock.json @@ -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", diff --git a/src/hospitality-web/package.json b/src/hospitality-web/package.json index b4546a2..5ea2eca 100644 --- a/src/hospitality-web/package.json +++ b/src/hospitality-web/package.json @@ -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": { diff --git a/src/hospitality-web/src/App.tsx b/src/hospitality-web/src/App.tsx index eb77dad..23f2329 100644 --- a/src/hospitality-web/src/App.tsx +++ b/src/hospitality-web/src/App.tsx @@ -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 ( - Hospitality System + {t('nav.hospitalitySystem')} {isAuthenticated && ( <> )} + {!isAuthenticated && ( + + )} diff --git a/src/hospitality-web/src/components/LanguageSelector.tsx b/src/hospitality-web/src/components/LanguageSelector.tsx new file mode 100644 index 0000000..dad601d --- /dev/null +++ b/src/hospitality-web/src/components/LanguageSelector.tsx @@ -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 ( + + + {currentLanguage.flag} {currentLanguage.name} + + + + {languages.map((lang) => ( + changeLanguage(lang.code)} + > + {lang.flag} {lang.name} + + ))} + + + ); +}; diff --git a/src/hospitality-web/src/i18n.ts b/src/hospitality-web/src/i18n.ts new file mode 100644 index 0000000..9784143 --- /dev/null +++ b/src/hospitality-web/src/i18n.ts @@ -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; diff --git a/src/hospitality-web/src/locales/en.json b/src/hospitality-web/src/locales/en.json new file mode 100644 index 0000000..f6b87ef --- /dev/null +++ b/src/hospitality-web/src/locales/en.json @@ -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." + } +} \ No newline at end of file diff --git a/src/hospitality-web/src/locales/nb.json b/src/hospitality-web/src/locales/nb.json new file mode 100644 index 0000000..c035d8f --- /dev/null +++ b/src/hospitality-web/src/locales/nb.json @@ -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." + } +} \ No newline at end of file diff --git a/src/hospitality-web/src/locales/nn.json b/src/hospitality-web/src/locales/nn.json new file mode 100644 index 0000000..a45feb5 --- /dev/null +++ b/src/hospitality-web/src/locales/nn.json @@ -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." + } +} \ No newline at end of file diff --git a/src/hospitality-web/src/main.tsx b/src/hospitality-web/src/main.tsx index 2ed1c95..e304f5b 100644 --- a/src/hospitality-web/src/main.tsx +++ b/src/hospitality-web/src/main.tsx @@ -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( diff --git a/src/hospitality-web/src/pages/LoginPage.tsx b/src/hospitality-web/src/pages/LoginPage.tsx index baa4f46..9889db7 100644 --- a/src/hospitality-web/src/pages/LoginPage.tsx +++ b/src/hospitality-web/src/pages/LoginPage.tsx @@ -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 = () => { -

Login

+

{t('auth.login')}

{error && {error}}
- Email + {t('auth.email')} { - Password + {t('auth.password')} setPassword(e.target.value)} required - placeholder="Enter password" + placeholder={t('auth.password')} /> @@ -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')}
- Test Accounts:
- Admin: admin@example.com / Admin123!
- Staff: staff@example.com / Staff123!
- Guest: guest@example.com / Guest123! + {t('auth.testAccounts')}:
+ {t('auth.admin')}: admin@example.com / Admin123!
+ {t('auth.staff')}: staff@example.com / Staff123!
+ {t('auth.guest')}: guest@example.com / Guest123!
diff --git a/src/hospitality-web/src/pages/guest/GuestQrPage.tsx b/src/hospitality-web/src/pages/guest/GuestQrPage.tsx index 10a66aa..743939f 100644 --- a/src/hospitality-web/src/pages/guest/GuestQrPage.tsx +++ b/src/hospitality-web/src/pages/guest/GuestQrPage.tsx @@ -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(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 ( -

My QR Code & Quotas

+

{t('guest.title')}

{loading && (
- Loading... + {t('common.loading')}
)} @@ -70,18 +72,18 @@ export default function GuestQrPage() { - Show this QR code to staff for scanning + {t('guest.showQrCode')}
-
My Quotas
+
{t('guest.myQuotas')}
{personData.quotas.length === 0 ? ( -

No quotas assigned

+

{t('guest.noQuotas')}

) : (
{personData.quotas.map((quota, index) => ( @@ -89,7 +91,7 @@ export default function GuestQrPage() {
{quota.productName} - {quota.remainingAmount} / {quota.initialAmount} remaining + {quota.remainingAmount} / {quota.initialAmount} {t('quotas.remaining')}