Files
kooltab-backoffice/apps/dashboard/src/lib/i18n.tsx
T
brammie15 0ca9f4d16a
CI / build-and-test (push) Canceled after 0s
feat: make server own tabs
2026-10-20 16:18:36 +02:00

771 lines
37 KiB
TypeScript

import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
type Lang = 'en' | 'nl';
// Dutch wording follows the bar app: a tab is a "poef", informal "je"
const translations: Record<Lang, Record<string, string>> = {
en: {
'nav.overview': 'Overview',
'nav.tabs': 'Open Tabs',
'nav.reminders': 'Reminders',
'nav.users': 'Users',
'nav.commands': 'Command History',
'nav.settings': 'Settings',
'nav.logout': 'Logout',
'realtime.live': 'Live updates on',
'realtime.offline': 'Live updates unavailable — refreshing periodically',
'common.refresh': 'Refresh',
'common.loading': 'Loading...',
'common.error': 'Something went wrong',
'common.unknown_error': 'An error occurred',
'common.failed': 'Failed',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.copied': 'Copied',
'common.logged_out': 'Logged out',
'errors.invalid_credentials': 'Invalid email or password',
'errors.too_many_requests': 'Too many attempts. Wait a minute and try again.',
'errors.validation_error': 'Some fields are not filled in correctly',
'errors.version_mismatch': 'The tab changed since it was loaded. Check it and try again.',
'errors.command_already_pending': 'A close request for this tab is already waiting for the terminal',
'errors.tab_not_owned': 'This tab is no longer open on the terminal',
'errors.tab_not_found': 'This tab is no longer open',
'errors.tab_empty': 'There is nothing on this tab to settle',
'errors.device_revoked': 'The terminal for this tab has been unpaired',
'errors.name_exists': 'This customer already exists',
'errors.email_exists': 'A user with this email already exists',
'errors.resend_not_configured': 'Sending email is not set up on the server',
'errors.reminders_in_progress': 'Reminders are already being sent',
'errors.no_email': 'This customer has no email address',
'errors.forbidden': "You don't have permission to do this",
'errors.venue_forbidden': "You don't have permission to do this",
'errors.internal_error': 'Something went wrong on the server',
'errors.network_error': "Can't reach the server. Check the connection.",
'errors.session_expired': 'Your session has expired. Log in again.',
'errors.unknown': 'An error occurred',
'status_page.home': 'Back to KoolTap',
'not_found.title': 'Page not found',
'not_found.desc': "This page doesn't exist or has been moved.",
'error_page.title': 'Something went wrong',
'error_page.desc': 'This page could not be shown. Try again, or go back to the start.',
'error_page.retry': 'Try again',
'time.just_now': 'just now',
'time.minutes_ago': '{count}m ago',
'time.hours_ago': '{count}h ago',
'time.days_ago': '{count}d ago',
'status.pending': 'Pending',
'status.completed': 'Completed',
'status.failed': 'Failed',
'status.conflict': 'Conflict',
'status.expired': 'Expired',
'device_status.online': 'Online',
'device_status.recently_seen': 'Recently seen',
'device_status.stale': 'Stale',
'device_status.offline': 'Offline',
'device_status.revoked': 'Revoked',
'payment.cash': 'Cash',
'payment.payconiq': 'Payconiq',
'payment.payment_pending': 'Payment pending',
'tabs.title': 'Open Tabs',
'tabs.count': '{count} open tab',
'tabs.count_plural': '{count} open tabs',
'tabs.empty': 'No open tabs',
'tabs.empty_desc': 'Open tabs appear here when the terminal syncs.',
'tabs.load_failed': 'Failed to load tabs',
'tabs.customer': 'Customer',
'tabs.email': 'Email',
'tabs.amount': 'Amount',
'tabs.items': 'Items',
'tabs.search_placeholder': 'Search customers…',
'tabs.sort': 'Sort by',
'tabs.sort_name': 'Name',
'tabs.sort_amount': 'Highest amount',
'tabs.outstanding': '{amount} outstanding',
'tabs.closing': 'Closing…',
'tabs.no_matches': 'No tabs match "{query}"',
'tabs.reported': 'Last updated',
'tabs.actions': 'Actions',
'tabs.close': 'Close',
'tabs.show_items': 'Show items for {name}',
'tabs.hide_items': 'Hide items for {name}',
'tabs.lines_unknown': "Items aren't reported by this terminal yet. Update the app on the tablet to see them.",
'tabs.lines_empty': 'No items on this tab.',
'tabs.each': 'each',
'tabs.close_title': "Close {name}'s tab?",
'tabs.close_desc': 'Settles everything on the tab now. The terminals show it after their next sync.',
'tabs.payment': 'Payment method',
'tabs.select_payment': 'Please select a payment method',
'tabs.send': 'Send close request',
'tabs.sending': 'Sending...',
'tabs.settled': 'Tab settled',
'tabs.settled_desc': '{name}: {amount}',
'tabs.send_test': 'Send test email',
'tabs.test_sent': 'Test email sent',
'tabs.test_sent_desc': 'Sent to {email}',
'tabs.test_failed': 'Test email failed',
'planned.title': 'Planned Customers',
'planned.name_placeholder': 'Add customer name...',
'planned.email_placeholder': 'Email (optional)',
'planned.add': 'Add customer',
'planned.remove': 'Remove {name}',
'planned.waiting_empty': 'No customers waiting for the terminal.',
'planned.added': 'Added',
'planned.added_desc': '"{name}" will get a tab on the terminal',
'import.button': 'Import from Excel',
'import.title': 'Import customers',
'import.description': 'Upload the customer list. Every name becomes a planned customer and gets a tab on the terminal.',
'import.choose_file': 'Choose a file',
'import.empty_file': 'The file contains no rows.',
'import.unreadable': 'This file could not be read. Use an .xlsx, .xls or .csv file.',
'import.name_column': 'Name (or first name)',
'import.last_name_column': 'Last name',
'import.email_column': 'Email',
'import.column_none': '— none —',
'import.has_header': 'First row contains column headers',
'import.summary': '{count} customers found, {emails} with an email address.',
'import.warnings': '{skipped} rows without a name skipped, {invalid} invalid email addresses ignored.',
'import.email': 'Email',
'import.more': 'and {count} more…',
'import.mode': 'Existing customers',
'import.mode_merge': 'Keep customers that are not in the file',
'import.mode_replace': 'Remove customers that are not in the file (their open tabs on the terminal are kept)',
'import.submit': 'Import {count} customers',
'import.importing': 'Importing...',
'import.done': 'Import complete',
'import.result': '{created} added, {updated} updated, {unchanged} unchanged, {removed} removed.',
'import.failed': 'Import failed',
'users.title': 'Users',
'users.count': '{count} user',
'users.count_plural': '{count} users',
'users.load_failed': 'Failed to load users',
'users.empty': 'No users',
'users.empty_desc': 'Add users who can manage tabs from this dashboard.',
'users.add': 'Add User',
'users.name': 'Name',
'users.email': 'Email',
'users.role': 'Role',
'users.created': 'Created',
'users.add_title': 'Add User',
'users.add_desc': 'Create a new user who can access the dashboard.',
'users.name_placeholder': 'Bartender name',
'users.password': 'Password',
'users.password_placeholder': 'At least 6 characters',
'users.role_viewer': 'Viewer (can close tabs)',
'users.role_admin': 'Admin (full access)',
'users.role.viewer': 'Viewer',
'users.role.admin': 'Admin',
'users.creating': 'Creating...',
'users.create': 'Create User',
'users.created_toast': 'User created',
'users.created_toast_desc': '{email} added as {role}',
'users.create_failed': 'Failed to create user',
'commands.title': 'Command History',
'commands.count': '{count} command',
'commands.count_plural': '{count} commands',
'commands.load_failed': 'Failed to load commands',
'commands.empty': 'No commands found',
'commands.empty_desc': 'Commands appear here when you send close requests.',
'commands.customer': 'Customer',
'commands.amount': 'Amount',
'commands.requested': 'Requested By',
'commands.payment': 'Payment',
'commands.created': 'Created',
'commands.status': 'Status',
'commands.processed': 'Processed',
'commands.result': 'Result',
'commands.all': 'All',
'commands.previous': 'Previous',
'commands.next': 'Next',
'commands.page': 'Page {page} of {total}',
'settings.title': 'Settings',
'settings.desc': 'Venue and terminal',
'settings.venue': 'Venue',
'settings.device': 'Terminal',
'settings.paired': 'Paired as "{name}" — last seen {time}',
'settings.not_paired': 'No terminal paired yet',
'settings.since': 'Since {date}',
'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.',
'settings.pair': 'Pair Terminal',
'settings.repair': 'Pair Another Terminal',
'settings.unpair': 'Unpair',
'settings.unpair_title': 'Unpair {name}?',
'settings.unpair_desc': 'The terminal stops syncing and has to be paired again to reconnect. Its changes that have not been synced yet are lost.',
'settings.unpaired': '{name} is unpaired',
'settings.terminals': '{count} terminals paired',
'settings.generating': 'Generating...',
'settings.pair_title': 'Pair a Terminal',
'settings.pair_instructions': 'Enter this code on the bar terminal to connect it to your venue.',
'settings.pair_code': 'Registration Code',
'settings.pair_expires': 'Expires at {time}',
'settings.pair_steps': 'Instructions:',
'settings.pair_step1': 'Open the bar terminal app',
'settings.pair_step2': 'Select your venue from the list',
'settings.pair_step3': 'Enter this registration code',
'settings.pair_step4': 'Give the terminal a name',
'settings.pair_step5': 'Complete registration',
'settings.copy': 'Copy Code',
'settings.copied_desc': 'Registration code copied to clipboard',
'overview.title': 'Overview',
'overview.open_tabs': 'Open Tabs',
'overview.device': 'Terminal',
'overview.pending': 'Waiting for terminal',
'overview.outstanding': 'Outstanding',
'overview.recent': 'Recent Commands',
'overview.no_commands': 'No commands yet',
'overview.not_connected': 'Not connected',
'overview.last_seen': 'Last seen {time}',
'overview.terminals': '{count} terminals, last seen {time}',
'auth.login': 'Sign in',
'auth.logging_in': 'Signing in...',
'auth.email': 'Email',
'auth.password': 'Password',
'auth.invalid': 'Invalid email or password',
'lang.en': 'English',
'lang.nl': 'Nederlands',
'lang.label': 'Language',
'reminders.title': 'Payment Reminders',
'reminders.desc': 'Send payment reminders to customers with an unpaid tab',
'reminders.candidates': 'Eligible for a reminder',
'reminders.candidates_desc': 'These customers have an unpaid tab and an email address on file. Reminders are sent via Resend.',
'reminders.no_candidates': 'No unpaid tabs',
'reminders.no_candidates_desc': 'Customers with an open balance show up here.',
'reminders.send': 'Send Reminders',
'reminders.sending': 'Sending...',
'reminders.admin_only': 'Only admins can send reminders.',
'reminders.confirm': 'This emails a payment reminder to {count} customer(s) with an unpaid tab.',
'reminders.sent_title': 'Reminders Sent',
'reminders.partial': '{sent} of {total} sent',
'reminders.error_detail': 'Error: {error}',
'reminders.zero_sent': 'No reminders were sent. Check that Resend is configured.',
'reminders.success': 'Successfully sent {sent} reminder email(s)',
'reminders.history': 'Sent Reminders',
'reminders.history_empty': 'No reminders have been sent yet',
'reminders.customer': 'Customer',
'reminders.email': 'Email',
'reminders.amount': 'Amount',
'reminders.sent_at': 'Sent At',
'reminders.status': 'Status',
'reminders.status_sent': 'Sent',
'reminders.customers': 'Customers with an unpaid tab',
'reminders.customers_desc': 'Eligible customers are selected by default. Anyone reminded in the last {days} days can still be picked by hand.',
'reminders.search': 'Search name or email',
'reminders.select_eligible': 'Select eligible',
'reminders.select_all': 'Select all',
'reminders.select_none': 'Clear',
'reminders.count': 'Reminders',
'reminders.last_sent': 'Last reminder',
'reminders.never': 'Never',
'reminders.state_eligible': 'Eligible',
'reminders.state_no_email': 'No email',
'reminders.state_cooldown': 'Eligible in {count} day',
'reminders.state_cooldown_plural': 'Eligible in {count} days',
'reminders.view_history': 'View reminders sent to this customer',
'reminders.no_matches': 'No customers match your search',
'reminders.selected': '{count} customer selected · {amount} outstanding',
'reminders.selected_plural': '{count} customers selected · {amount} outstanding',
'reminders.confirm_cooldown': '{count} of them was already reminded in the last {days} days.',
'reminders.confirm_cooldown_plural': '{count} of them were already reminded in the last {days} days.',
'reminders.recent': 'Recently reminded',
'reminders.person_history': 'Reminders for {name}',
'reminders.person_history_desc': '{count} reminder sent',
'reminders.person_history_desc_plural': '{count} reminders sent',
'nav.stock': 'Stock',
'errors.product_not_found': 'This product is no longer on the terminal',
'errors.stock_not_tracked': "Stock isn't tracked for this product yet. Count it first.",
'errors.invalid_quantity': 'Enter a valid quantity',
'errors.adjustment_not_pending': 'The terminal already processed this change',
'errors.adjustment_not_found': 'This stock change no longer exists',
'stock.title': 'Stock',
'stock.count': '{count} product',
'stock.count_plural': '{count} products',
'stock.low_count': '{count} running low',
'stock.low_count_plural': '{count} running low',
'stock.reported': 'reported {time}',
'stock.load_failed': 'Failed to load stock',
'stock.empty': 'No products yet',
'stock.empty_desc': 'The terminal reports its products and stock once it runs an app version with stock.',
'stock.search_placeholder': 'Search products',
'stock.filter_all': 'All',
'stock.filter_low': 'Running low',
'stock.filter_untracked': 'Not tracked',
'stock.no_matches': 'No products match',
'stock.no_category': 'Other',
'stock.product': 'Product',
'stock.price': 'Price',
'stock.stock': 'Stock',
'stock.pending': 'Waiting for terminal',
'stock.actions': 'Actions',
'stock.not_tracked': 'Not tracked',
'stock.low': 'Low',
'stock.out': 'Out',
'stock.adjust': 'Adjust',
'stock.start_tracking': 'Track stock',
'stock.start_tracking_desc': "Stock isn't tracked for this product yet. Enter how many you have to start tracking it.",
'stock.cancel_adjustment': 'Cancel {change} for {name}',
'stock.cancelled': 'Change cancelled',
'stock.current': 'Currently {stock} in stock.',
'stock.mode_add': 'Add (delivery)',
'stock.mode_remove': 'Take out (breakage, own use…)',
'stock.mode_count': 'Count (set the stock to what you counted)',
'stock.quantity': 'Quantity',
'stock.counted': 'Counted',
'stock.preview': '{from} → {to}',
'stock.count_hint': 'Drinks rung up before your count are in it: they are not subtracted again, also not when an offline terminal sends them later.',
'stock.note': 'Note (optional)',
'stock.note_placeholder': 'e.g. delivery from the brewery',
'stock.threshold': 'Warn when stock is at or below',
'stock.threshold_none': 'No warning',
'stock.threshold_save': 'Save',
'stock.threshold_saved': 'Warning level saved',
'stock.send': 'Save',
'stock.sending': 'Sending…',
'stock.sent': 'Stock updated',
'stock.sent_desc': 'The terminals show it after their next sync.',
'stock.delivery': 'Register delivery',
'stock.delivery_title': 'Register delivery',
'stock.delivery_desc': 'Enter how many of each product were delivered. Products you leave empty are not changed.',
'stock.delivery_note_placeholder': 'e.g. supplier or delivery note number',
'stock.delivery_send': 'Add {count} product',
'stock.delivery_send_plural': 'Add {count} products',
'stock.history': 'Recent stock changes',
'stock.history_empty': 'No stock changes from the dashboard yet',
'stock.now': '→ {stock}',
'stock.status.pending': 'Waiting',
'stock.status.applied': 'Applied',
'stock.status.rejected': 'Rejected',
'stock.status.cancelled': 'Cancelled',
'stock.reason.unknown_product': 'product not on the terminal',
'stock.reason.stock_not_tracked': 'stock not tracked on the terminal',
},
nl: {
'nav.overview': 'Overzicht',
'nav.tabs': 'Open poefs',
'nav.reminders': 'Herinneringen',
'nav.users': 'Gebruikers',
'nav.commands': 'Opdrachten',
'nav.settings': 'Instellingen',
'nav.logout': 'Uitloggen',
'realtime.live': 'Live updates aan',
'realtime.offline': 'Live updates niet beschikbaar — wordt regelmatig vernieuwd',
'common.refresh': 'Vernieuwen',
'common.loading': 'Laden...',
'common.error': 'Er is iets misgegaan',
'common.unknown_error': 'Er is een fout opgetreden',
'common.failed': 'Mislukt',
'common.cancel': 'Annuleren',
'common.close': 'Sluiten',
'common.copied': 'Gekopieerd',
'common.logged_out': 'Uitgelogd',
'errors.invalid_credentials': 'Ongeldig e-mailadres of wachtwoord',
'errors.too_many_requests': 'Te veel pogingen. Wacht een minuutje en probeer opnieuw.',
'errors.validation_error': 'Sommige velden zijn niet correct ingevuld',
'errors.version_mismatch': 'De poef is gewijzigd sinds ze geladen werd. Controleer en probeer opnieuw.',
'errors.command_already_pending': 'Er wacht al een sluitverzoek voor deze poef op de kassa',
'errors.tab_not_owned': 'Deze poef staat niet meer open op de kassa',
'errors.tab_not_found': 'Deze poef staat niet meer open',
'errors.tab_empty': 'Er staat niets op deze poef om af te rekenen',
'errors.device_revoked': 'De kassa van deze poef is ontkoppeld',
'errors.name_exists': 'Deze klant bestaat al',
'errors.email_exists': 'Er bestaat al een gebruiker met dit e-mailadres',
'errors.resend_not_configured': 'E-mail versturen is niet ingesteld op de server',
'errors.reminders_in_progress': 'Herinneringen worden al verstuurd',
'errors.no_email': 'Deze klant heeft geen e-mailadres',
'errors.forbidden': 'Je hebt geen toestemming om dit te doen',
'errors.venue_forbidden': 'Je hebt geen toestemming om dit te doen',
'errors.internal_error': 'Er ging iets mis op de server',
'errors.network_error': 'Kan de server niet bereiken. Controleer de verbinding.',
'errors.session_expired': 'Je sessie is verlopen. Log opnieuw in.',
'errors.unknown': 'Er is een fout opgetreden',
'status_page.home': 'Terug naar KoolTap',
'not_found.title': 'Pagina niet gevonden',
'not_found.desc': 'Deze pagina bestaat niet of is verplaatst.',
'error_page.title': 'Er is iets misgegaan',
'error_page.desc': 'Deze pagina kon niet getoond worden. Probeer het opnieuw of ga terug naar het begin.',
'error_page.retry': 'Opnieuw proberen',
'time.just_now': 'zonet',
'time.minutes_ago': '{count} min geleden',
'time.hours_ago': '{count} u geleden',
'time.days_ago': '{count} d geleden',
'status.pending': 'In behandeling',
'status.completed': 'Voltooid',
'status.failed': 'Mislukt',
'status.conflict': 'Conflict',
'status.expired': 'Verlopen',
'device_status.online': 'Online',
'device_status.recently_seen': 'Recent gezien',
'device_status.stale': 'Inactief',
'device_status.offline': 'Offline',
'device_status.revoked': 'Ingetrokken',
'payment.cash': 'Contant',
'payment.payconiq': 'Payconiq',
'payment.payment_pending': 'Onbetaald laten',
'tabs.title': 'Open poefs',
'tabs.count': '{count} open poef',
'tabs.count_plural': '{count} open poefs',
'tabs.empty': 'Geen open poefs',
'tabs.empty_desc': 'Open poefs verschijnen hier zodra de terminal synchroniseert.',
'tabs.load_failed': 'Poefs konden niet geladen worden',
'tabs.customer': 'Klant',
'tabs.email': 'E-mail',
'tabs.amount': 'Bedrag',
'tabs.items': 'Items',
'tabs.search_placeholder': 'Zoek klant…',
'tabs.sort': 'Sorteer op',
'tabs.sort_name': 'Naam',
'tabs.sort_amount': 'Hoogste bedrag',
'tabs.outstanding': '{amount} openstaand',
'tabs.closing': 'Wordt gesloten…',
'tabs.no_matches': 'Geen poefs gevonden voor "{query}"',
'tabs.reported': 'Laatst bijgewerkt',
'tabs.actions': 'Acties',
'tabs.close': 'Sluiten',
'tabs.show_items': 'Items van {name} tonen',
'tabs.hide_items': 'Items van {name} verbergen',
'tabs.lines_unknown': 'Deze terminal stuurt de items nog niet door. Werk de app op de tablet bij om ze te zien.',
'tabs.lines_empty': 'Geen items op deze poef.',
'tabs.each': 'per stuk',
'tabs.close_title': 'Poef van {name} sluiten?',
'tabs.close_desc': 'Rekent alles op de poef nu af. De terminals tonen het na hun volgende synchronisatie.',
'tabs.payment': 'Betaalmethode',
'tabs.select_payment': 'Kies een betaalmethode',
'tabs.send': 'Sluitverzoek versturen',
'tabs.sending': 'Bezig met versturen...',
'tabs.settled': 'Poef afgerekend',
'tabs.settled_desc': '{name}: {amount}',
'tabs.send_test': 'Testmail versturen',
'tabs.test_sent': 'Testmail verstuurd',
'tabs.test_sent_desc': 'Verstuurd naar {email}',
'tabs.test_failed': 'Testmail mislukt',
'planned.title': 'Geplande klanten',
'planned.name_placeholder': 'Naam van de klant...',
'planned.email_placeholder': 'E-mail (optioneel)',
'planned.add': 'Klant toevoegen',
'planned.remove': '{name} verwijderen',
'planned.waiting_empty': 'Geen klanten die op de terminal wachten.',
'planned.added': 'Toegevoegd',
'planned.added_desc': '"{name}" krijgt een poef op de terminal',
'import.button': 'Importeren uit Excel',
'import.title': 'Klanten importeren',
'import.description': 'Upload de klantenlijst. Elke naam wordt een geplande klant en krijgt een poef op de terminal.',
'import.choose_file': 'Kies een bestand',
'import.empty_file': 'Het bestand bevat geen rijen.',
'import.unreadable': 'Dit bestand kon niet gelezen worden. Gebruik een .xlsx-, .xls- of .csv-bestand.',
'import.name_column': 'Naam (of voornaam)',
'import.last_name_column': 'Achternaam',
'import.email_column': 'E-mail',
'import.column_none': '— geen —',
'import.has_header': 'De eerste rij bevat kolomnamen',
'import.summary': '{count} klanten gevonden, {emails} met een e-mailadres.',
'import.warnings': '{skipped} rijen zonder naam overgeslagen, {invalid} ongeldige e-mailadressen genegeerd.',
'import.email': 'E-mail',
'import.more': 'en nog {count}…',
'import.mode': 'Bestaande klanten',
'import.mode_merge': 'Klanten die niet in het bestand staan behouden',
'import.mode_replace': 'Klanten die niet in het bestand staan verwijderen (hun open poefs op de terminal blijven bestaan)',
'import.submit': '{count} klanten importeren',
'import.importing': 'Bezig met importeren...',
'import.done': 'Import voltooid',
'import.result': '{created} toegevoegd, {updated} bijgewerkt, {unchanged} ongewijzigd, {removed} verwijderd.',
'import.failed': 'Importeren mislukt',
'users.title': 'Gebruikers',
'users.count': '{count} gebruiker',
'users.count_plural': '{count} gebruikers',
'users.load_failed': 'Gebruikers konden niet geladen worden',
'users.empty': 'Geen gebruikers',
'users.empty_desc': 'Voeg gebruikers toe die poefs kunnen beheren via dit dashboard.',
'users.add': 'Gebruiker toevoegen',
'users.name': 'Naam',
'users.email': 'E-mail',
'users.role': 'Rol',
'users.created': 'Aangemaakt',
'users.add_title': 'Gebruiker toevoegen',
'users.add_desc': 'Maak een nieuwe gebruiker aan die toegang krijgt tot het dashboard.',
'users.name_placeholder': 'Naam van de medewerker',
'users.password': 'Wachtwoord',
'users.password_placeholder': 'Minstens 6 tekens',
'users.role_viewer': 'Medewerker (kan poefs sluiten)',
'users.role_admin': 'Beheerder (volledige toegang)',
'users.role.viewer': 'Medewerker',
'users.role.admin': 'Beheerder',
'users.creating': 'Bezig met aanmaken...',
'users.create': 'Gebruiker aanmaken',
'users.created_toast': 'Gebruiker aangemaakt',
'users.created_toast_desc': '{email} toegevoegd als {role}',
'users.create_failed': 'Gebruiker aanmaken mislukt',
'commands.title': 'Opdrachtgeschiedenis',
'commands.count': '{count} opdracht',
'commands.count_plural': '{count} opdrachten',
'commands.load_failed': 'Opdrachten konden niet geladen worden',
'commands.empty': 'Geen opdrachten gevonden',
'commands.empty_desc': 'Opdrachten verschijnen hier zodra je een sluitverzoek verstuurt.',
'commands.customer': 'Klant',
'commands.amount': 'Bedrag',
'commands.requested': 'Aangevraagd door',
'commands.payment': 'Betaling',
'commands.created': 'Aangemaakt',
'commands.status': 'Status',
'commands.processed': 'Verwerkt',
'commands.result': 'Resultaat',
'commands.all': 'Alle',
'commands.previous': 'Vorige',
'commands.next': 'Volgende',
'commands.page': 'Pagina {page} van {total}',
'settings.title': 'Instellingen',
'settings.desc': 'Zaak en terminal',
'settings.venue': 'Zaak',
'settings.device': 'Terminal',
'settings.paired': 'Gekoppeld als "{name}" — laatst gezien {time}',
'settings.not_paired': 'Nog geen terminal gekoppeld',
'settings.since': 'Sinds {date}',
'settings.pair_desc': 'Koppel een barterminal om poefs te ontvangen.',
'settings.pair': 'Terminal koppelen',
'settings.repair': 'Nog een terminal koppelen',
'settings.unpair': 'Ontkoppelen',
'settings.unpair_title': '{name} ontkoppelen?',
'settings.unpair_desc': 'De terminal synchroniseert niet meer en moet opnieuw gekoppeld worden. Wijzigingen die nog niet gesynchroniseerd zijn, gaan verloren.',
'settings.unpaired': '{name} is ontkoppeld',
'settings.terminals': '{count} terminals gekoppeld',
'settings.generating': 'Code aanmaken...',
'settings.pair_title': 'Terminal koppelen',
'settings.pair_instructions': 'Voer deze code in op de barterminal om hem met je zaak te verbinden.',
'settings.pair_code': 'Registratiecode',
'settings.pair_expires': 'Verloopt om {time}',
'settings.pair_steps': 'Stappen:',
'settings.pair_step1': 'Open de app op de barterminal',
'settings.pair_step2': 'Kies je zaak uit de lijst',
'settings.pair_step3': 'Voer deze registratiecode in',
'settings.pair_step4': 'Geef de terminal een naam',
'settings.pair_step5': 'Rond de registratie af',
'settings.copy': 'Code kopiëren',
'settings.copied_desc': 'Registratiecode gekopieerd naar het klembord',
'overview.title': 'Overzicht',
'overview.open_tabs': 'Open poefs',
'overview.device': 'Terminal',
'overview.pending': 'Wachtend op kassa',
'overview.outstanding': 'Openstaand',
'overview.recent': 'Recente opdrachten',
'overview.no_commands': 'Nog geen opdrachten',
'overview.not_connected': 'Niet verbonden',
'overview.last_seen': 'Laatst gezien {time}',
'overview.terminals': '{count} terminals, laatst gezien {time}',
'auth.login': 'Inloggen',
'auth.logging_in': 'Bezig met inloggen...',
'auth.email': 'E-mail',
'auth.password': 'Wachtwoord',
'auth.invalid': 'Ongeldig e-mailadres of wachtwoord',
'lang.en': 'English',
'lang.nl': 'Nederlands',
'lang.label': 'Taal',
'reminders.title': 'Betalingsherinneringen',
'reminders.desc': 'Stuur betalingsherinneringen naar klanten met een openstaande poef',
'reminders.candidates': 'Komen in aanmerking',
'reminders.candidates_desc': 'Deze klanten hebben een openstaande poef en een gekend e-mailadres. Herinneringen worden verstuurd via Resend.',
'reminders.no_candidates': 'Niemand heeft een openstaande poef',
'reminders.no_candidates_desc': 'Klanten met een openstaand bedrag verschijnen hier.',
'reminders.send': 'Herinneringen versturen',
'reminders.sending': 'Bezig met versturen...',
'reminders.admin_only': 'Alleen beheerders kunnen herinneringen versturen.',
'reminders.confirm': 'Er wordt een betalingsherinnering gemaild naar {count} klant(en) met een openstaande poef.',
'reminders.sent_title': 'Herinneringen verstuurd',
'reminders.partial': '{sent} van {total} verstuurd',
'reminders.error_detail': 'Fout: {error}',
'reminders.zero_sent': 'Er zijn geen herinneringen verstuurd. Controleer of Resend is ingesteld.',
'reminders.success': '{sent} herinnering(en) verstuurd',
'reminders.history': 'Verstuurde herinneringen',
'reminders.history_empty': 'Nog geen herinneringen verstuurd',
'reminders.customer': 'Klant',
'reminders.email': 'E-mail',
'reminders.amount': 'Bedrag',
'reminders.sent_at': 'Verstuurd op',
'reminders.status': 'Status',
'reminders.status_sent': 'Verstuurd',
'reminders.customers': 'Klanten met een openstaande poef',
'reminders.customers_desc': 'Klanten die in aanmerking komen zijn standaard geselecteerd. Wie de laatste {days} dagen al een herinnering kreeg, kun je nog steeds zelf aanvinken.',
'reminders.search': 'Zoek op naam of e-mail',
'reminders.select_eligible': 'Selecteer in aanmerking',
'reminders.select_all': 'Alles selecteren',
'reminders.select_none': 'Wissen',
'reminders.count': 'Herinneringen',
'reminders.last_sent': 'Laatste herinnering',
'reminders.never': 'Nooit',
'reminders.state_eligible': 'In aanmerking',
'reminders.state_no_email': 'Geen e-mail',
'reminders.state_cooldown': 'Over {count} dag',
'reminders.state_cooldown_plural': 'Over {count} dagen',
'reminders.view_history': 'Bekijk de herinneringen aan deze klant',
'reminders.no_matches': 'Geen klanten gevonden',
'reminders.selected': '{count} klant geselecteerd · {amount} openstaand',
'reminders.selected_plural': '{count} klanten geselecteerd · {amount} openstaand',
'reminders.confirm_cooldown': '{count} ervan kreeg de laatste {days} dagen al een herinnering.',
'reminders.confirm_cooldown_plural': '{count} ervan kregen de laatste {days} dagen al een herinnering.',
'reminders.recent': 'Recent herinnerd',
'reminders.person_history': 'Herinneringen voor {name}',
'reminders.person_history_desc': '{count} herinnering verstuurd',
'reminders.person_history_desc_plural': '{count} herinneringen verstuurd',
'nav.stock': 'Voorraad',
'errors.product_not_found': 'Dit product staat niet meer op de kassa',
'errors.stock_not_tracked': 'Voor dit product wordt nog geen voorraad bijgehouden. Tel het eerst.',
'errors.invalid_quantity': 'Vul een geldig aantal in',
'errors.adjustment_not_pending': 'De kassa heeft deze wijziging al verwerkt',
'errors.adjustment_not_found': 'Deze voorraadwijziging bestaat niet meer',
'stock.title': 'Voorraad',
'stock.count': '{count} product',
'stock.count_plural': '{count} producten',
'stock.low_count': '{count} bijna op',
'stock.low_count_plural': '{count} bijna op',
'stock.reported': 'gemeld {time}',
'stock.load_failed': 'Voorraad laden mislukt',
'stock.empty': 'Nog geen producten',
'stock.empty_desc': 'De kassa meldt haar producten en voorraad zodra ze een app-versie met voorraad heeft.',
'stock.search_placeholder': 'Zoek producten',
'stock.filter_all': 'Alle',
'stock.filter_low': 'Bijna op',
'stock.filter_untracked': 'Niet bijgehouden',
'stock.no_matches': 'Geen producten gevonden',
'stock.no_category': 'Overige',
'stock.product': 'Product',
'stock.price': 'Prijs',
'stock.stock': 'Voorraad',
'stock.pending': 'Wacht op kassa',
'stock.actions': 'Acties',
'stock.not_tracked': 'Niet bijgehouden',
'stock.low': 'Bijna op',
'stock.out': 'Op',
'stock.adjust': 'Aanpassen',
'stock.start_tracking': 'Voorraad bijhouden',
'stock.start_tracking_desc': 'Voor dit product wordt nog geen voorraad bijgehouden. Vul in hoeveel je er hebt om te beginnen.',
'stock.cancel_adjustment': '{change} voor {name} annuleren',
'stock.cancelled': 'Wijziging geannuleerd',
'stock.current': 'Nu {stock} in voorraad.',
'stock.mode_add': 'Bijvullen (levering)',
'stock.mode_remove': 'Afboeken (breuk, eigen gebruik…)',
'stock.mode_count': 'Tellen (voorraad wordt wat je telde)',
'stock.quantity': 'Aantal',
'stock.counted': 'Geteld',
'stock.preview': '{from} → {to}',
'stock.count_hint': 'Wat voor je telling aangeslagen werd, zit erin: het wordt niet nog eens afgetrokken, ook niet als een offline kassa het later doorstuurt.',
'stock.note': 'Notitie (optioneel)',
'stock.note_placeholder': 'bv. levering brouwerij',
'stock.threshold': 'Waarschuw vanaf',
'stock.threshold_none': 'Geen waarschuwing',
'stock.threshold_save': 'Opslaan',
'stock.threshold_saved': 'Waarschuwingsniveau opgeslagen',
'stock.send': 'Opslaan',
'stock.sending': 'Versturen…',
'stock.sent': 'Voorraad bijgewerkt',
'stock.sent_desc': 'De kassa\'s tonen het na hun volgende synchronisatie.',
'stock.delivery': 'Levering invoeren',
'stock.delivery_title': 'Levering invoeren',
'stock.delivery_desc': 'Vul in hoeveel er van elk product geleverd is. Lege producten blijven ongewijzigd.',
'stock.delivery_note_placeholder': 'bv. leverancier of leveringsbon',
'stock.delivery_send': '{count} product bijvullen',
'stock.delivery_send_plural': '{count} producten bijvullen',
'stock.history': 'Recente voorraadwijzigingen',
'stock.history_empty': 'Nog geen voorraadwijzigingen vanuit het dashboard',
'stock.now': '→ {stock}',
'stock.status.pending': 'Wacht',
'stock.status.applied': 'Verwerkt',
'stock.status.rejected': 'Geweigerd',
'stock.status.cancelled': 'Geannuleerd',
'stock.reason.unknown_product': 'product staat niet op de kassa',
'stock.reason.stock_not_tracked': 'voorraad niet bijgehouden op de kassa',
},
};
const LOCALES: Record<Lang, string> = { en: 'en-GB', nl: 'nl-BE' };
type Params = Record<string, string | number>;
interface LangContextType {
lang: Lang;
setLang: (lang: Lang) => void;
t: (key: string, params?: Params) => string;
/** Uses `<key>_plural` unless `count` is 1; `{count}` is filled in. */
tn: (key: string, count: number, params?: Params) => string;
/** An API error in the current language: by its code when translated, else its message. */
te: (error: unknown, fallbackKey?: string) => string;
/** "just now", "5m ago"…, falling back to a date after a week. */
relativeTime: (date: string | Date) => string;
dateTime: (date: string | Date) => string;
time: (date: string | Date) => string;
}
function translate(lang: Lang, key: string, params?: Params): string {
let s = translations[lang][key] ?? translations.en[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
s = s.split(`{${k}}`).join(String(v));
}
}
return s;
}
function helpers(lang: Lang): Omit<LangContextType, 'setLang'> {
const t = (key: string, params?: Params) => translate(lang, key, params);
const locale = LOCALES[lang];
return {
lang,
t,
tn: (key, count, params) => t(count === 1 ? key : `${key}_plural`, { count, ...params }),
te: (error, fallbackKey = 'common.unknown_error') => {
const { code, message } = (error ?? {}) as { code?: string; message?: string };
const key = `errors.${code}`;
if (code && (translations[lang][key] ?? translations.en[key])) return t(key);
return message || t(fallbackKey);
},
relativeTime: (value) => {
const date = new Date(value);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return t('time.just_now');
if (minutes < 60) return t('time.minutes_ago', { count: minutes });
if (hours < 24) return t('time.hours_ago', { count: hours });
if (days < 7) return t('time.days_ago', { count: days });
return date.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' });
},
dateTime: (value) =>
new Date(value).toLocaleString(locale, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }),
time: (value) => new Date(value).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
};
}
const LangContext = createContext<LangContextType>({ ...helpers('en'), setLang: () => {} });
export function LanguageProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>('en');
useEffect(() => {
try {
const stored = localStorage.getItem('kooltap-lang') as Lang | null;
if (stored === 'en' || stored === 'nl') setLangState(stored);
} catch {
// Storage unavailable (e.g. private mode): keep the default language
}
}, []);
useEffect(() => {
document.documentElement.lang = lang;
}, [lang]);
function setLang(l: Lang) {
setLangState(l);
try {
localStorage.setItem('kooltap-lang', l);
} catch {
// Not persisted, but the choice still applies for this visit
}
}
return (
<LangContext.Provider value={{ ...helpers(lang), setLang }}>
{children}
</LangContext.Provider>
);
}
export function useLang() {
return useContext(LangContext);
}