Add multi-language support (Norwegian, English, Nynorsk)
This commit is contained in:
Generated
+88
-1
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user