Add multi-language support (Norwegian, English, Nynorsk)
This commit is contained in:
Generated
+88
-1
@@ -14,11 +14,13 @@
|
|||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
|
"i18next": "^25.6.3",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-bootstrap": "^2.10.10",
|
"react-bootstrap": "^2.10.10",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-i18next": "^16.3.5",
|
||||||
"react-router-dom": "^7.9.6"
|
"react-router-dom": "^7.9.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -3000,12 +3002,52 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"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": {
|
"node_modules/html5-qrcode": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
|
||||||
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
|
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -3633,6 +3675,33 @@
|
|||||||
"react": "^19.2.0"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -3981,7 +4050,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -4078,6 +4147,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "7.2.4",
|
"version": "7.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz",
|
||||||
@@ -4184,6 +4262,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/warning": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
|
||||||
|
|||||||
@@ -16,11 +16,13 @@
|
|||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"html5-qrcode": "^2.3.8",
|
"html5-qrcode": "^2.3.8",
|
||||||
|
"i18next": "^25.6.3",
|
||||||
"lucide-react": "^0.554.0",
|
"lucide-react": "^0.554.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-bootstrap": "^2.10.10",
|
"react-bootstrap": "^2.10.10",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-i18next": "^16.3.5",
|
||||||
"react-router-dom": "^7.9.6"
|
"react-router-dom": "^7.9.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { BrowserRouter, Routes, Route, Link, useNavigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Link, useNavigate } from 'react-router-dom';
|
||||||
import { Navbar, Nav, Container, Button } from 'react-bootstrap';
|
import { Navbar, Nav, Container, Button } from 'react-bootstrap';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||||
|
import { LanguageSelector } from './components/LanguageSelector';
|
||||||
import { LoginPage } from './pages/LoginPage';
|
import { LoginPage } from './pages/LoginPage';
|
||||||
import EventsPage from './pages/admin/EventsPage';
|
import EventsPage from './pages/admin/EventsPage';
|
||||||
import EventDetailPage from './pages/admin/EventDetailPage';
|
import EventDetailPage from './pages/admin/EventDetailPage';
|
||||||
@@ -14,6 +16,7 @@ const queryClient = new QueryClient();
|
|||||||
|
|
||||||
function NavbarContent() {
|
function NavbarContent() {
|
||||||
const { isAuthenticated, user, logout, hasRole } = useAuth();
|
const { isAuthenticated, user, logout, hasRole } = useAuth();
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@@ -24,26 +27,32 @@ function NavbarContent() {
|
|||||||
return (
|
return (
|
||||||
<Navbar bg="primary" variant="dark" expand="lg" className="mb-4">
|
<Navbar bg="primary" variant="dark" expand="lg" className="mb-4">
|
||||||
<Container>
|
<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.Toggle aria-controls="basic-navbar-nav" />
|
||||||
<Navbar.Collapse id="basic-navbar-nav">
|
<Navbar.Collapse id="basic-navbar-nav">
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<>
|
<>
|
||||||
<Nav className="me-auto">
|
<Nav className="me-auto">
|
||||||
{hasRole('Admin') && <Nav.Link as={Link} to="/">Admin</Nav.Link>}
|
{hasRole('Admin') && <Nav.Link as={Link} to="/">{t('nav.admin')}</Nav.Link>}
|
||||||
{hasRole('Staff') && <Nav.Link as={Link} to="/staff">Staff Scanner</Nav.Link>}
|
{hasRole('Staff') && <Nav.Link as={Link} to="/staff">{t('nav.staffScanner')}</Nav.Link>}
|
||||||
{hasRole('Guest') && <Nav.Link as={Link} to="/guest">Guest View</Nav.Link>}
|
{hasRole('Guest') && <Nav.Link as={Link} to="/guest">{t('nav.guestView')}</Nav.Link>}
|
||||||
</Nav>
|
</Nav>
|
||||||
<Nav>
|
<Nav>
|
||||||
<Navbar.Text className="me-3">
|
<LanguageSelector />
|
||||||
|
<Navbar.Text className="ms-3 me-3">
|
||||||
{user?.email} ({user?.roles.join(', ')})
|
{user?.email} ({user?.roles.join(', ')})
|
||||||
</Navbar.Text>
|
</Navbar.Text>
|
||||||
<Button variant="outline-light" size="sm" onClick={handleLogout}>
|
<Button variant="outline-light" size="sm" onClick={handleLogout}>
|
||||||
Logout
|
{t('auth.logout')}
|
||||||
</Button>
|
</Button>
|
||||||
</Nav>
|
</Nav>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<Nav className="ms-auto">
|
||||||
|
<LanguageSelector />
|
||||||
|
</Nav>
|
||||||
|
)}
|
||||||
</Navbar.Collapse>
|
</Navbar.Collapse>
|
||||||
</Container>
|
</Container>
|
||||||
</Navbar>
|
</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 { createRoot } from 'react-dom/client'
|
||||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import './i18n' // Initialize i18n
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Container, Card, Form, Button, Alert } from 'react-bootstrap';
|
import { Container, Card, Form, Button, Alert } from 'react-bootstrap';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
export const LoginPage: React.FC = () => {
|
export const LoginPage: React.FC = () => {
|
||||||
@@ -10,6 +11,7 @@ export const LoginPage: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -20,7 +22,7 @@ export const LoginPage: React.FC = () => {
|
|||||||
await login({ email, password });
|
await login({ email, password });
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Invalid email or password');
|
setError(t('auth.invalidCredentials'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -30,13 +32,13 @@ export const LoginPage: React.FC = () => {
|
|||||||
<Container className="d-flex align-items-center justify-content-center" style={{ minHeight: '100vh' }}>
|
<Container className="d-flex align-items-center justify-content-center" style={{ minHeight: '100vh' }}>
|
||||||
<Card style={{ maxWidth: '400px', width: '100%' }} className="shadow">
|
<Card style={{ maxWidth: '400px', width: '100%' }} className="shadow">
|
||||||
<Card.Body className="p-4">
|
<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>}
|
{error && <Alert variant="danger">{error}</Alert>}
|
||||||
|
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>Email</Form.Label>
|
<Form.Label>{t('auth.email')}</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
@@ -47,13 +49,13 @@ export const LoginPage: React.FC = () => {
|
|||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>Password</Form.Label>
|
<Form.Label>{t('auth.password')}</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
placeholder="Enter password"
|
placeholder={t('auth.password')}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
@@ -63,16 +65,16 @@ export const LoginPage: React.FC = () => {
|
|||||||
className="w-100"
|
className="w-100"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? 'Logging in...' : 'Login'}
|
{loading ? t('auth.loggingIn') : t('auth.loginButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<div className="mt-4 text-center">
|
<div className="mt-4 text-center">
|
||||||
<small className="text-muted">
|
<small className="text-muted">
|
||||||
<strong>Test Accounts:</strong><br />
|
<strong>{t('auth.testAccounts')}:</strong><br />
|
||||||
Admin: admin@example.com / Admin123!<br />
|
{t('auth.admin')}: admin@example.com / Admin123!<br />
|
||||||
Staff: staff@example.com / Staff123!<br />
|
{t('auth.staff')}: staff@example.com / Staff123!<br />
|
||||||
Guest: guest@example.com / Guest123!
|
{t('auth.guest')}: guest@example.com / Guest123!
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Container, Card, Alert, Spinner, ProgressBar } from 'react-bootstrap';
|
import { Container, Card, Alert, Spinner, ProgressBar } from 'react-bootstrap';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
|
|
||||||
interface Quota {
|
interface Quota {
|
||||||
@@ -20,6 +21,7 @@ export default function GuestQrPage() {
|
|||||||
const [personData, setPersonData] = useState<PersonData | null>(null);
|
const [personData, setPersonData] = useState<PersonData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Auto-load guest's own data on mount
|
// Auto-load guest's own data on mount
|
||||||
@@ -33,23 +35,23 @@ export default function GuestQrPage() {
|
|||||||
|
|
||||||
setPersonData(response.data);
|
setPersonData(response.data);
|
||||||
} catch (err: any) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchGuestData();
|
fetchGuestData();
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="py-4">
|
<Container className="py-4">
|
||||||
<h2 className="mb-4">My QR Code & Quotas</h2>
|
<h2 className="mb-4">{t('guest.title')}</h2>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="text-center py-5">
|
<div className="text-center py-5">
|
||||||
<Spinner animation="border" role="status">
|
<Spinner animation="border" role="status">
|
||||||
<span className="visually-hidden">Loading...</span>
|
<span className="visually-hidden">{t('common.loading')}</span>
|
||||||
</Spinner>
|
</Spinner>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -70,18 +72,18 @@ export default function GuestQrPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<small className="text-muted d-block">
|
<small className="text-muted d-block">
|
||||||
Show this QR code to staff for scanning
|
{t('guest.showQrCode')}
|
||||||
</small>
|
</small>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="shadow-sm">
|
<Card className="shadow-sm">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<h5 className="mb-0">My Quotas</h5>
|
<h5 className="mb-0">{t('guest.myQuotas')}</h5>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Body>
|
<Card.Body>
|
||||||
{personData.quotas.length === 0 ? (
|
{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">
|
<div className="d-grid gap-3">
|
||||||
{personData.quotas.map((quota, index) => (
|
{personData.quotas.map((quota, index) => (
|
||||||
@@ -89,7 +91,7 @@ export default function GuestQrPage() {
|
|||||||
<div className="d-flex justify-content-between mb-2">
|
<div className="d-flex justify-content-between mb-2">
|
||||||
<strong>{quota.productName}</strong>
|
<strong>{quota.productName}</strong>
|
||||||
<span className="text-muted">
|
<span className="text-muted">
|
||||||
{quota.remainingAmount} / {quota.initialAmount} remaining
|
{quota.remainingAmount} / {quota.initialAmount} {t('quotas.remaining')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
|
|||||||
Reference in New Issue
Block a user