feat: reminders, Excel customer import and security hardening

Features
- Payment reminders via Resend (candidates, send, history, test email)
- Bulk import of the customer list from Excel/CSV in the dashboard
- Dashboard i18n (en/nl) with language selector

Security
- Enforce venue isolation on all /venues/:venueId routes
- Require admin for planned-tab changes and pairing codes
- Rate-limit login and device registration; opt-in TRUST_PROXY
- Require JWT_SECRET in production; stop leaking internal errors
- Escape customer names in reminder emails

Sync and commands
- Remove tabs the terminal no longer reports; validate nested sync payload
- Return all pending commands on every sync (fixes missed-command race)
- First command ack wins; atomic registration code claim
- Reject commands for revoked devices, stale versions or duplicates
- Remind only unpaid balances, with a 7-day cooldown

Dashboard
- Share a single token refresh across concurrent 401s (random logouts)
- Keep the session alive for the refresh token lifetime

Deployment
- Replace `db push --accept-data-loss` with migrations (baselines legacy DBs)
- Set NODE_ENV=production; pass Resend and TRUST_PROXY env in prod compose
This commit is contained in:
2026-10-07 23:18:37 +02:00
parent 58eb8ddb8e
commit 8b1e38cb4d
56 changed files with 3168 additions and 376 deletions
+14 -8
View File
@@ -12,18 +12,22 @@ import {
LogOut,
Menu,
X,
Bell,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/cn';
import { useLang } from '@/lib/i18n';
import { LanguageSelector } from '@/components/language-selector';
import { authApi, api } from '@/lib/api/client';
import { toast } from '@/components/ui/toast';
const navItems = [
{ href: '/dashboard', label: 'Overview', icon: LayoutDashboard, neededRole: 'viewer' },
{ href: '/dashboard/tabs', label: 'Open Tabs', icon: ClipboardList, neededRole: 'viewer' },
{ href: '/dashboard/users', label: 'Users', icon: Monitor, neededRole: 'admin' },
{ href: '/dashboard/commands', label: 'Command History', icon: Terminal, neededRole: 'viewer' },
{ href: '/dashboard/settings', label: 'Settings', icon: Settings, neededRole: 'viewer' },
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
{ href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, adminOnly: false },
{ href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, adminOnly: false },
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true },
{ href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, adminOnly: false },
{ href: '/dashboard/settings', key: 'nav.settings', icon: Settings, adminOnly: false },
];
export default function DashboardLayout({
@@ -36,6 +40,7 @@ export default function DashboardLayout({
const [sidebarOpen, setSidebarOpen] = useState(false);
const [user, setUser] = useState<{ name: string; email: string; role: string; venueId: string } | null>(null);
const [venue, setVenue] = useState<{ name: string } | null>(null);
const { t } = useLang();
useEffect(() => {
// Check auth
@@ -111,7 +116,7 @@ export default function DashboardLayout({
const Icon = item.icon;
const active = pathname === item.href;
if (item.neededRole === 'admin' && user?.role !== 'admin') {
if (item.adminOnly && user?.role !== 'admin') {
return null;
}
@@ -128,13 +133,14 @@ export default function DashboardLayout({
)}
>
<Icon className="h-4 w-4" />
{item.label}
{t(item.key)}
</Link>
);
})}
</nav>
<div className="p-4 border-t">
<LanguageSelector />
{user && (
<div className="mb-3 text-sm">
<p className="font-medium truncate">{user.name}</p>
@@ -147,7 +153,7 @@ export default function DashboardLayout({
onClick={handleLogout}
>
<LogOut className="h-4 w-4" />
Logout
{t('nav.logout')}
</Button>
</div>
</div>
@@ -0,0 +1,216 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { remindersApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { EmptyState } from '@/components/empty-state';
import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { formatCents, formatRelativeTime } from '@/lib/utils';
import { useLang } from '@/lib/i18n';
import { Send, History } from 'lucide-react';
export default function RemindersPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useLang();
const [venueId, setVenueId] = useState<string | null>(null);
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [showHistory, setShowHistory] = useState(false);
useEffect(() => {
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
if (!cookie) { router.push('/login'); return; }
const payload = JSON.parse(atob(cookie.split('.')[1]));
setVenueId(payload.venueId);
}, [router]);
const candidatesQuery = useQuery({
queryKey: ['reminder-candidates', venueId],
queryFn: () => remindersApi.candidates(venueId!),
enabled: !!venueId,
});
const historyQuery = useQuery({
queryKey: ['reminder-history', venueId],
queryFn: () => remindersApi.history(venueId!),
enabled: !!venueId && showHistory,
});
const sendMutation = useMutation({
mutationFn: () => remindersApi.send(venueId!),
onSuccess: (result: any) => {
setSendDialogOpen(false);
if (result.errors && result.errors.length > 0) {
const firstError = result.errors[0];
toast({ title: `${result.sent}/${result.total} sent`, description: `Error: ${firstError.error}`, variant: 'destructive' });
} else if (result.sent > 0) {
toast({ title: t('reminders.sent_title'), description: t('reminders.success', { sent: result.sent }), variant: 'success' });
} else {
toast({ title: t('reminders.sent_title'), description: t('reminders.zero_sent'), variant: 'destructive' });
}
queryClient.invalidateQueries({ queryKey: ['reminder-candidates'] });
queryClient.invalidateQueries({ queryKey: ['reminder-history'] });
},
onError: (err: any) => {
toast({ title: 'Failed', description: err.message || 'An error occurred', variant: 'destructive' });
},
});
if (!venueId) return <LoadingState />;
const candidates = candidatesQuery.data?.candidates || [];
const reminders = historyQuery.data?.reminders || [];
return (
<div>
<PageHeader
title={t('reminders.title')}
description={t('reminders.desc')}
actions={
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => { setShowHistory(!showHistory); if (!showHistory) queryClient.invalidateQueries({ queryKey: ['reminder-history'] }); }}>
<History className="h-4 w-4 mr-2" />
{showHistory ? t('reminders.candidates') : t('reminders.history')}
</Button>
</div>
}
/>
{showHistory ? (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">{t('reminders.history')}</CardTitle>
</CardHeader>
<CardContent>
{reminders.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('reminders.history_empty')}</p>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('reminders.customer')}</TableHead>
<TableHead>{t('reminders.email')}</TableHead>
<TableHead>{t('reminders.amount')}</TableHead>
<TableHead>{t('reminders.sent_at')}</TableHead>
<TableHead>{t('reminders.status')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reminders.map((r: any) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.customerName}</TableCell>
<TableCell>{r.email}</TableCell>
<TableCell>{formatCents(r.amountInCents)}</TableCell>
<TableCell>{formatRelativeTime(r.sentAt)}</TableCell>
<TableCell><Badge variant="success">{r.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Send className="h-4 w-4" />
{t('reminders.candidates')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-4">
{t('reminders.candidates_desc')}
</p>
{candidatesQuery.isLoading ? (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
) : candidatesQuery.error ? (
<p className="text-sm text-destructive">{t('common.error')}</p>
) : candidates.length === 0 ? (
<EmptyState title={t('reminders.no_candidates')} description={t('reminders.no_candidates_desc')} />
) : (
<>
<div className="rounded-md border mb-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('reminders.customer')}</TableHead>
<TableHead>{t('reminders.email')}</TableHead>
<TableHead>{t('reminders.amount')}</TableHead>
<TableHead>{t('tabs.items')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{candidates.map((c: any) => (
<TableRow key={c.tabId}>
<TableCell className="font-medium">{c.customerName}</TableCell>
<TableCell>{c.email}</TableCell>
<TableCell>{formatCents(c.amountInCents)}</TableCell>
<TableCell>{c.itemCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Button onClick={() => setSendDialogOpen(true)}>
<Send className="h-4 w-4 mr-2" />
{t('reminders.send')} ({candidates.length})
</Button>
</>
)}
</CardContent>
</Card>
)}
<Dialog open={sendDialogOpen} onOpenChange={setSendDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('reminders.send')}</DialogTitle>
<DialogDescription>
{t('reminders.result', { sent: 0, total: candidates.length })}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
This will send payment reminder emails to <strong>{candidates.length}</strong> customer(s) with open tabs.
</p>
<ul className="mt-3 space-y-1">
{candidates.map((c: any) => (
<li key={c.tabId} className="text-sm flex items-center gap-2">
<span className="font-medium">{c.customerName}</span>
<span className="text-muted-foreground">({c.email})</span>
<span className="ml-auto">{formatCents(c.amountInCents)}</span>
</li>
))}
</ul>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSendDialogOpen(false)} disabled={sendMutation.isPending}>
{t('common.cancel')}
</Button>
<Button onClick={() => sendMutation.mutate()} disabled={sendMutation.isPending}>
{sendMutation.isPending ? (
<>{t('reminders.sending')}</>
) : (
<><Send className="h-4 w-4 mr-2" />{t('reminders.send')}</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+59 -7
View File
@@ -3,7 +3,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { api, commandsApi, plannedTabsApi } from '@/lib/api/client';
import { api, commandsApi, plannedTabsApi, remindersApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -17,7 +17,9 @@ import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { formatCents, formatRelativeTime } from '@/lib/utils';
import { RefreshCw, Plus, X, Users } from 'lucide-react';
import { RefreshCw, Plus, X, Users, Mail, Send, Upload } from 'lucide-react';
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
import { useLang } from '@/lib/i18n';
export default function TabsPage() {
const router = useRouter();
@@ -28,7 +30,10 @@ export default function TabsPage() {
const [creating, setCreating] = useState(false);
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set());
const [plannedName, setPlannedName] = useState('');
const [plannedEmail, setPlannedEmail] = useState('');
const [userRole, setUserRole] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const { t } = useLang();
useEffect(() => {
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
@@ -97,8 +102,9 @@ export default function TabsPage() {
async function addPlannedName() {
if (!plannedName.trim()) return;
try {
await plannedTabsApi.create(venueId!, plannedName.trim());
await plannedTabsApi.create(venueId!, plannedName.trim(), plannedEmail.trim() || undefined);
setPlannedName('');
setPlannedEmail('');
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
toast({ title: 'Added', description: `"${plannedName.trim()}" added to planned tabs` });
} catch (err: any) {
@@ -115,6 +121,16 @@ export default function TabsPage() {
}
}
const sendTestMutation = useMutation({
mutationFn: (plannedTabId: string) => remindersApi.sendTest(venueId!, plannedTabId),
onSuccess: (result) => {
toast({ title: 'Test email sent', description: `Sent to ${result.email}`, variant: 'success' });
},
onError: (err: any) => {
toast({ title: 'Test email failed', description: err.message || 'An error occurred', variant: 'destructive' });
},
});
if (!venueId) return <LoadingState />;
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={(error as any).message || 'Failed to load tabs'} />;
@@ -132,14 +148,21 @@ export default function TabsPage() {
{/* Planned Customers */}
<Card className="mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> Planned Customers</CardTitle>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> Planned Customers</CardTitle>
{userRole === 'admin' && (
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<Upload className="h-4 w-4 mr-2" />{t('import.button')}
</Button>
)}
</div>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
These names are synced to the terminal. It will create tabs for these customers automatically.
</p>
{userRole === 'admin' && (
<div className="flex gap-2 mb-3">
<div className="flex gap-2 mb-3 flex-wrap">
<Input
placeholder="Add customer name..."
value={plannedName}
@@ -147,6 +170,14 @@ export default function TabsPage() {
onKeyDown={(e) => e.key === 'Enter' && addPlannedName()}
className="max-w-xs"
/>
<Input
placeholder="Email (optional)"
type="email"
value={plannedEmail}
onChange={(e) => setPlannedEmail(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addPlannedName()}
className="max-w-xs"
/>
<Button size="sm" onClick={addPlannedName} disabled={!plannedName.trim()}>
<Plus className="h-4 w-4" />
</Button>
@@ -159,8 +190,19 @@ export default function TabsPage() {
{(plannedQuery.data?.plannedTabs || []).map((pt: any) => (
<div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
{pt.customerName}
{pt.email && <Mail className="h-3 w-3 text-muted-foreground" />}
{userRole === 'admin' && pt.email && (
<button
onClick={() => sendTestMutation.mutate(pt.id)}
disabled={sendTestMutation.isPending}
className="text-muted-foreground hover:text-primary ml-0.5"
title="Send test email"
>
<Send className="h-3 w-3" />
</button>
)}
{userRole === 'admin' && (
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-1">
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-0.5">
<X className="h-3 w-3" />
</button>
)}
@@ -194,7 +236,8 @@ export default function TabsPage() {
<TableCell>{tab.version}</TableCell>
<TableCell>{formatRelativeTime(tab.lastReportedAt)}</TableCell>
<TableCell className="text-right">
<Button size="sm" onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>Close</Button>
{/* Closing settles the tab on the terminal; a tab without a balance has nothing to settle */}
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>Close</Button>
</TableCell>
</TableRow>
))}
@@ -203,6 +246,15 @@ export default function TabsPage() {
</div>
)}
{venueId && (
<ImportCustomersDialog
venueId={venueId}
open={importOpen}
onOpenChange={setImportOpen}
onImported={() => queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })}
/>
)}
{closeDialog.tab && (
<Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}>
<DialogContent>
+2 -1
View File
@@ -2,6 +2,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
import { LanguageProvider } from '@/lib/i18n';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
@@ -18,7 +19,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<LanguageProvider>{children}</LanguageProvider>
</QueryClientProvider>
);
}
@@ -0,0 +1,218 @@
'use client';
import { useMemo, useState } from 'react';
import { Upload } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { toast } from '@/components/ui/toast';
import { plannedTabsApi } from '@/lib/api/client';
import { useLang } from '@/lib/i18n';
import { buildPreview, columnLabel, detectMapping, readSheet, type ColumnMapping, type SheetData } from '@/lib/customer-import';
const PREVIEW_ROWS = 8;
const selectClassName =
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring';
interface Props {
venueId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onImported: () => void;
}
export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported }: Props) {
const { t } = useLang();
const [fileName, setFileName] = useState<string | null>(null);
const [sheet, setSheet] = useState<SheetData | null>(null);
const [mapping, setMapping] = useState<ColumnMapping | null>(null);
const [mode, setMode] = useState<'merge' | 'replace'>('merge');
const [submitting, setSubmitting] = useState(false);
const preview = useMemo(() => (sheet && mapping ? buildPreview(sheet, mapping) : null), [sheet, mapping]);
function reset() {
setFileName(null);
setSheet(null);
setMapping(null);
setMode('merge');
}
function handleOpenChange(next: boolean) {
if (!next) reset();
onOpenChange(next);
}
async function handleFile(file: File | undefined) {
if (!file) return;
try {
const data = await readSheet(file);
if (data.rows.length === 0) {
toast({ title: t('import.failed'), description: t('import.empty_file'), variant: 'destructive' });
return;
}
setFileName(file.name);
setSheet(data);
setMapping(detectMapping(data));
} catch {
toast({ title: t('import.failed'), description: t('import.unreadable'), variant: 'destructive' });
}
}
async function submit() {
if (!preview || preview.entries.length === 0) return;
setSubmitting(true);
try {
const result = await plannedTabsApi.import(venueId, preview.entries, mode);
toast({ title: t('import.done'), description: t('import.result', result), variant: 'success' });
onImported();
handleOpenChange(false);
} catch (err: any) {
toast({ title: t('import.failed'), description: err.message, variant: 'destructive' });
} finally {
setSubmitting(false);
}
}
const header = sheet && mapping?.hasHeader ? sheet.rows[0] : null;
const columnOptions = sheet
? Array.from({ length: sheet.columnCount }, (_, i) => ({
value: i,
label: header?.[i] ? `${columnLabel(i)} – ${header[i]}` : columnLabel(i),
}))
: [];
function columnSelect(value: number | null, onChange: (value: number | null) => void, optional: boolean) {
return (
<select
className={selectClassName}
value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
>
{optional && <option value="">{t('import.column_none')}</option>}
{columnOptions.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('import.title')}</DialogTitle>
<DialogDescription>{t('import.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<label className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed p-6 text-sm cursor-pointer hover:bg-accent">
<Upload className="h-5 w-5 text-muted-foreground" />
<span>{fileName ?? t('import.choose_file')}</span>
<span className="text-xs text-muted-foreground">.xlsx, .xls, .csv</span>
<input
type="file"
accept=".xlsx,.xls,.csv"
className="sr-only"
onChange={(e) => {
handleFile(e.target.files?.[0]);
e.target.value = '';
}}
/>
</label>
{sheet && mapping && preview && (
<>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1">
<Label>{t('import.name_column')}</Label>
{columnSelect(mapping.nameColumn, (v) => setMapping({ ...mapping, nameColumn: v ?? 0 }), false)}
</div>
<div className="space-y-1">
<Label>{t('import.last_name_column')}</Label>
{columnSelect(mapping.lastNameColumn, (v) => setMapping({ ...mapping, lastNameColumn: v }), true)}
</div>
<div className="space-y-1">
<Label>{t('import.email_column')}</Label>
{columnSelect(mapping.emailColumn, (v) => setMapping({ ...mapping, emailColumn: v }), true)}
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={mapping.hasHeader}
onChange={(e) => setMapping({ ...mapping, hasHeader: e.target.checked })}
/>
{t('import.has_header')}
</label>
<div className="space-y-2">
<p className="text-sm">
{t('import.summary', {
count: preview.entries.length,
emails: preview.entries.filter((e) => e.email).length,
})}
</p>
{(preview.skippedRows > 0 || preview.invalidEmails > 0) && (
<p className="text-xs text-muted-foreground">
{t('import.warnings', { skipped: preview.skippedRows, invalid: preview.invalidEmails })}
</p>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('tabs.customer')}</TableHead>
<TableHead>{t('import.email')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{preview.entries.slice(0, PREVIEW_ROWS).map((entry, i) => (
<TableRow key={i}>
<TableCell className="font-medium">{entry.customerName}</TableCell>
<TableCell className="text-muted-foreground">{entry.email ?? '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{preview.entries.length > PREVIEW_ROWS && (
<p className="text-xs text-muted-foreground">
{t('import.more', { count: preview.entries.length - PREVIEW_ROWS })}
</p>
)}
</div>
<div className="space-y-2">
<Label className="text-base font-semibold">{t('import.mode')}</Label>
<RadioGroup value={mode} onValueChange={(v) => setMode(v as 'merge' | 'replace')}>
<div className="flex items-start space-x-2">
<RadioGroupItem value="merge" id="import-merge" className="mt-0.5" />
<Label htmlFor="import-merge" className="font-normal">{t('import.mode_merge')}</Label>
</div>
<div className="flex items-start space-x-2">
<RadioGroupItem value="replace" id="import-replace" className="mt-0.5" />
<Label htmlFor="import-replace" className="font-normal">{t('import.mode_replace')}</Label>
</div>
</RadioGroup>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={submitting}>
{t('tabs.cancel')}
</Button>
<Button onClick={submit} disabled={submitting || !preview || preview.entries.length === 0}>
{submitting ? t('import.importing') : t('import.submit', { count: preview?.entries.length ?? 0 })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,27 @@
'use client';
import { useLang } from '@/lib/i18n';
import { Languages } from 'lucide-react';
export function LanguageSelector() {
const { lang, setLang, t } = useLang();
return (
<div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground">
<Languages className="h-3 w-3" />
<span>{t('lang.label')}:</span>
<button
onClick={() => setLang('en')}
className={`px-1.5 py-0.5 rounded ${lang === 'en' ? 'bg-primary text-primary-foreground font-medium' : 'hover:text-foreground'}`}
>
EN
</button>
<button
onClick={() => setLang('nl')}
className={`px-1.5 py-0.5 rounded ${lang === 'nl' ? 'bg-primary text-primary-foreground font-medium' : 'hover:text-foreground'}`}
>
NL
</button>
</div>
);
}
+37 -6
View File
@@ -15,7 +15,9 @@ function getTokens() {
}
function setTokens(accessToken: string, refreshToken: string) {
document.cookie = `access_token=${accessToken}; path=/; max-age=${24 * 60 * 60}; SameSite=Lax`;
// The access cookie outlives the JWT on purpose: pages decode it for venue/role, and an
// expired JWT triggers a 401 which is transparently refreshed below.
document.cookie = `access_token=${accessToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
document.cookie = `refresh_token=${refreshToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
}
@@ -24,7 +26,19 @@ function clearTokens() {
document.cookie = 'refresh_token=; path=/; max-age=0';
}
async function refreshAccessToken(): Promise<string | null> {
// Refresh tokens rotate on use, so concurrent 401s must share a single refresh request
let refreshInFlight: Promise<string | null> | null = null;
function refreshAccessToken(): Promise<string | null> {
if (!refreshInFlight) {
refreshInFlight = doRefreshAccessToken().finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
async function doRefreshAccessToken(): Promise<string | null> {
const refreshToken = document.cookie
.split('; ')
.find((row) => row.startsWith('refresh_token='))
@@ -169,12 +183,29 @@ export const venuesApi = {
// Planned Tabs API
export const plannedTabsApi = {
list: (venueId: string) => api<{ plannedTabs: any[] }>(`/venues/${venueId}/planned-tabs`),
create: (venueId: string, customerName: string) =>
api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName }) }),
update: (venueId: string, id: string, customerName: string) =>
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName }) }),
create: (venueId: string, customerName: string, email?: string) =>
api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName, email }) }),
update: (venueId: string, id: string, customerName: string, email?: string) =>
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName, email }) }),
remove: (venueId: string, id: string) =>
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'DELETE' }),
import: (venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') =>
api<{ created: number; updated: number; unchanged: number; removed: number }>(`/venues/${venueId}/planned-tabs/import`, {
method: 'POST',
body: JSON.stringify({ entries, mode }),
}),
};
// Reminders API
export const remindersApi = {
candidates: (venueId: string) =>
api<{ candidates: any[] }>(`/venues/${venueId}/reminders/candidates`),
send: (venueId: string) =>
api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, { method: 'POST' }),
history: (venueId: string) =>
api<{ reminders: any[] }>(`/venues/${venueId}/reminders`),
sendTest: (venueId: string, plannedTabId: string) =>
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
};
export { setTokens, clearTokens };
+122
View File
@@ -0,0 +1,122 @@
export interface SheetData {
rows: string[][];
columnCount: number;
}
export interface ColumnMapping {
hasHeader: boolean;
nameColumn: number;
lastNameColumn: number | null;
emailColumn: number | null;
}
export interface ImportEntry {
customerName: string;
email?: string;
}
export interface ImportPreview {
entries: ImportEntry[];
skippedRows: number;
invalidEmails: number;
}
const NAME_HEADERS = ['naam', 'name', 'volledige naam', 'full name', 'klant', 'klantnaam', 'customer', 'lid', 'member'];
const FIRST_NAME_HEADERS = ['voornaam', 'first name', 'firstname', 'given name'];
const LAST_NAME_HEADERS = ['achternaam', 'familienaam', 'last name', 'lastname', 'surname', 'family name'];
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function normalizeHeader(value: string) {
return value.toLowerCase().replace(/[_\-.:]/g, ' ').replace(/\s+/g, ' ').trim();
}
/** Reads the first sheet of an .xlsx, .xls or .csv file into trimmed string rows. */
export async function readSheet(file: File): Promise<SheetData> {
// Loaded on demand: the parser is large and only needed when an admin imports a file
const XLSX = await import('xlsx');
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
if (!sheet) return { rows: [], columnCount: 0 };
const raw = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, defval: '', raw: false, blankrows: false });
const rows = raw
.map((row) => row.map((cell) => String(cell ?? '').trim()))
.filter((row) => row.some((cell) => cell !== ''));
const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0);
return { rows, columnCount };
}
/** Guesses which columns hold the name(s) and email from the first row. */
export function detectMapping(sheet: SheetData): ColumnMapping {
const header = (sheet.rows[0] ?? []).map(normalizeHeader);
const find = (candidates: string[]) => {
const index = header.findIndex((h) => candidates.includes(h));
return index === -1 ? null : index;
};
const name = find(NAME_HEADERS);
const firstName = find(FIRST_NAME_HEADERS);
const lastName = find(LAST_NAME_HEADERS);
const emailHeaderIndex = header.findIndex((h) => h.includes('mail'));
const hasHeader = name !== null || firstName !== null || lastName !== null || emailHeaderIndex !== -1;
// Without a recognisable header, fall back to the column whose values look like email addresses
let email: number | null = emailHeaderIndex === -1 ? null : emailHeaderIndex;
if (email === null) {
const sample = sheet.rows.slice(hasHeader ? 1 : 0, 50);
for (let col = 0; col < sheet.columnCount && email === null; col++) {
const filled = sample.map((row) => row[col]).filter(Boolean);
if (filled.length > 0 && filled.filter((v) => EMAIL_PATTERN.test(v)).length / filled.length >= 0.5) {
email = col;
}
}
}
if (firstName !== null && lastName !== null) {
return { hasHeader, nameColumn: firstName, lastNameColumn: lastName, emailColumn: email };
}
return { hasHeader, nameColumn: name ?? firstName ?? lastName ?? 0, lastNameColumn: null, emailColumn: email };
}
export function buildPreview(sheet: SheetData, mapping: ColumnMapping): ImportPreview {
const dataRows = mapping.hasHeader ? sheet.rows.slice(1) : sheet.rows;
const entries: ImportEntry[] = [];
let skippedRows = 0;
let invalidEmails = 0;
for (const row of dataRows) {
const parts = [row[mapping.nameColumn]];
if (mapping.lastNameColumn !== null) parts.push(row[mapping.lastNameColumn]);
const customerName = parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim().slice(0, 100);
if (!customerName) {
skippedRows++;
continue;
}
let email: string | undefined;
if (mapping.emailColumn !== null) {
const value = (row[mapping.emailColumn] ?? '').trim();
if (value && EMAIL_PATTERN.test(value)) {
email = value;
} else if (value) {
invalidEmails++;
}
}
entries.push(email ? { customerName, email } : { customerName });
}
return { entries, skippedRows, invalidEmails };
}
/** Spreadsheet-style column label: 0 -> A, 26 -> AA. */
export function columnLabel(index: number): string {
let label = '';
let n = index;
do {
label = String.fromCharCode(65 + (n % 26)) + label;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
return label;
}
+367
View File
@@ -0,0 +1,367 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
type Lang = 'en' | 'nl';
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',
'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 will appear here when the terminal syncs.',
'tabs.customer': 'Customer',
'tabs.amount': 'Amount',
'tabs.items': 'Items',
'tabs.version': 'Version',
'tabs.reported': 'Last Reported',
'tabs.close': 'Close',
'tabs.close_title': "Close {name}'s tab?",
'tabs.close_desc': 'Send a close request to the terminal.',
'tabs.payment': 'Payment method',
'tabs.cash': 'Cash',
'tabs.payconiq': 'Payconiq',
'tabs.pending_payment': 'Payment pending',
'tabs.select_payment': 'Please select a payment method',
'tabs.send': 'Send close request',
'tabs.sending': 'Sending...',
'tabs.cancel': 'Cancel',
'tabs.planned_title': 'Planned Customers',
'tabs.planned_desc': 'These names are synced to the terminal. It will create tabs for these customers automatically.',
'tabs.planned_add': 'Add customer name...',
'tabs.planned_empty': 'No planned customers. Add names above and they\'ll sync to 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.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.password': 'Password',
'users.creating': 'Creating...',
'users.create': 'Create User',
'commands.title': 'Command History',
'commands.count': '{count} command',
'commands.count_plural': '{count} commands',
'commands.empty': 'No commands found',
'commands.tab': 'Tab',
'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 device management',
'settings.venue': 'Venue',
'settings.device': 'Device',
'settings.paired': 'Paired as "{name}" — last seen {time}',
'settings.not_paired': 'No device paired yet',
'settings.connected': 'Connected',
'settings.since': 'Since {date}',
'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.',
'settings.pair': 'Pair Device',
'settings.repair': 'Re-pair Device',
'settings.pair_title': 'Pair a Device',
'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_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.close': 'Close',
'settings.api': 'API',
'settings.base_url': 'Base URL',
'settings.swagger': 'Swagger',
'overview.title': 'Overview',
'overview.desc': 'Real-time overview of your venue',
'overview.open_tabs': 'Open Tabs',
'overview.device': 'Device',
'overview.pending': 'Pending Commands',
'overview.recent': 'Recent Commands',
'overview.no_commands': 'No commands yet',
'overview.not_connected': 'Not connected',
'overview.last_seen': 'Last seen {time}',
'common.refresh': 'Refresh',
'common.loading': 'Loading...',
'common.error': 'Something went wrong',
'common.confirm': 'Confirm',
'common.cancel': 'Cancel',
'common.processing': 'Processing...',
'common.copied': 'Copied',
'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 open tabs',
'reminders.candidates': 'Eligible for reminder',
'reminders.candidates_desc': 'These customers have open tabs and an email address on file. Reminders will be sent via Resend.',
'reminders.no_candidates': 'No eligible customers',
'reminders.no_candidates_desc': 'Make sure planned customers have email addresses set.',
'reminders.send': 'Send Reminders',
'reminders.sending': 'Sending...',
'reminders.result': 'Sent {sent} of {total} reminder(s)',
'reminders.sent_title': 'Reminders Sent',
'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',
'tabs.email': 'Email (optional)',
'tabs.planned_email_hint': 'Add email to enable payment reminders',
},
nl: {
'nav.overview': 'Overzicht',
'nav.tabs': 'Open Tabs',
'nav.reminders': 'Herinneringen',
'nav.users': 'Gebruikers',
'nav.commands': 'Opdrachten',
'nav.settings': 'Instellingen',
'nav.logout': 'Uitloggen',
'tabs.title': 'Open Tabs',
'tabs.count': '{count} open tab',
'tabs.count_plural': '{count} open tabs',
'tabs.empty': 'Geen open tabs',
'tabs.empty_desc': 'Open tabs verschijnen hier wanneer de terminal synchroniseert.',
'tabs.customer': 'Klant',
'tabs.amount': 'Bedrag',
'tabs.items': 'Artikelen',
'tabs.version': 'Versie',
'tabs.reported': 'Laatst gemeld',
'tabs.close': 'Sluiten',
'tabs.close_title': 'Sluit {name}\'s tab?',
'tabs.close_desc': 'Stuur een sluitverzoek naar de terminal.',
'tabs.payment': 'Betaalmethode',
'tabs.cash': 'Contant',
'tabs.payconiq': 'Payconiq',
'tabs.pending_payment': 'Betaling in behandeling',
'tabs.select_payment': 'Selecteer een betaalmethode',
'tabs.send': 'Sluitverzoek versturen',
'tabs.sending': 'Bezig met verzenden...',
'tabs.cancel': 'Annuleren',
'tabs.planned_title': 'Geplande Klanten',
'tabs.planned_desc': 'Deze namen worden gesynchroniseerd met de terminal. Deze maakt automatisch tabs aan voor deze klanten.',
'tabs.planned_add': 'Klantnaam toevoegen...',
'tabs.planned_empty': 'Geen geplande klanten. Voeg namen toe en ze worden gesynchroniseerd met 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 rekening 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 rekeningen op de terminal blijven)',
'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.empty': 'Geen gebruikers',
'users.empty_desc': 'Voeg gebruikers toe die tabs kunnen beheren vanaf 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 heeft tot het dashboard.',
'users.password': 'Wachtwoord',
'users.creating': 'Bezig...',
'users.create': 'Gebruiker aanmaken',
'commands.title': 'Opdrachtgeschiedenis',
'commands.count': '{count} opdracht',
'commands.count_plural': '{count} opdrachten',
'commands.empty': 'Geen opdrachten gevonden',
'commands.tab': 'Tab',
'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': 'Locatie- en apparaatbeheer',
'settings.venue': 'Locatie',
'settings.device': 'Apparaat',
'settings.paired': 'Gekoppeld als "{name}" — laatst gezien {time}',
'settings.not_paired': 'Nog geen apparaat gekoppeld',
'settings.connected': 'Verbonden',
'settings.since': 'Sinds {date}',
'settings.pair_desc': 'Koppel een barterminal om tabs te ontvangen.',
'settings.pair': 'Apparaat koppelen',
'settings.repair': 'Opnieuw koppelen',
'settings.pair_title': 'Apparaat koppelen',
'settings.pair_instructions': 'Voer deze code in op de barterminal om deze te verbinden met uw locatie.',
'settings.pair_code': 'Registratiecode',
'settings.pair_expires': 'Verloopt om {time}',
'settings.pair_step1': 'Open de barterminal app',
'settings.pair_step2': 'Selecteer uw locatie uit de lijst',
'settings.pair_step3': 'Voer deze registratiecode in',
'settings.pair_step4': 'Geef de terminal een naam',
'settings.pair_step5': 'Voltooi de registratie',
'settings.copy': 'Code kopiëren',
'settings.close': 'Sluiten',
'settings.api': 'API',
'settings.base_url': 'Basis-URL',
'settings.swagger': 'Swagger',
'overview.title': 'Overzicht',
'overview.desc': 'Real-time overzicht van uw locatie',
'overview.open_tabs': 'Open Tabs',
'overview.device': 'Apparaat',
'overview.pending': 'Openstaande opdrachten',
'overview.recent': 'Recente opdrachten',
'overview.no_commands': 'Nog geen opdrachten',
'overview.not_connected': 'Niet verbonden',
'overview.last_seen': 'Laatst gezien {time}',
'common.refresh': 'Verversen',
'common.loading': 'Laden...',
'common.error': 'Er is iets misgegaan',
'common.confirm': 'Bevestigen',
'common.cancel': 'Annuleren',
'common.processing': 'Bezig...',
'common.copied': 'Gekopieerd',
'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 openstaande tabs',
'reminders.candidates': 'In aanmerking voor herinnering',
'reminders.candidates_desc': 'Deze klanten hebben openstaande tabs en een e-mailadres. Herinneringen worden verzonden via Resend.',
'reminders.no_candidates': 'Geen geschikte klanten',
'reminders.no_candidates_desc': 'Zorg dat geplande klanten een e-mailadres hebben.',
'reminders.send': 'Verstuur herinneringen',
'reminders.sending': 'Bezig met versturen...',
'reminders.result': '{sent} van {total} herinnering(en) verzonden',
'reminders.sent_title': 'Herinneringen verzonden',
'reminders.zero_sent': 'Geen herinneringen verzonden. Controleer of Resend is geconfigureerd.',
'reminders.success': '{sent} herinnering(en) succesvol verzonden',
'reminders.history': 'Verzonden herinneringen',
'reminders.history_empty': 'Nog geen herinneringen verzonden',
'reminders.customer': 'Klant',
'reminders.email': 'E-mail',
'reminders.amount': 'Bedrag',
'reminders.sent_at': 'Verzonden op',
'reminders.status': 'Status',
'tabs.email': 'E-mail (optioneel)',
'tabs.planned_email_hint': 'Voeg e-mail toe om betalingsherinneringen in te schakelen',
},
};
interface LangContextType {
lang: Lang;
setLang: (lang: Lang) => void;
t: (key: string, params?: Record<string, string | number>) => string;
}
const LangContext = createContext<LangContextType>({
lang: 'en',
setLang: () => {},
t: (key) => key,
});
export function LanguageProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>('en');
useEffect(() => {
const stored = localStorage.getItem('kooltab-lang') as Lang | null;
if (stored === 'en' || stored === 'nl') setLangState(stored);
}, []);
function setLang(l: Lang) {
setLangState(l);
localStorage.setItem('kooltab-lang', l);
}
function t(key: string, params?: Record<string, string | number>): string {
let s = translations[lang][key];
if (!s) s = translations['en'][key] || key;
if (params) {
for (const [k, v] of Object.entries(params)) {
s = s.replace(`{${k}}`, String(v));
}
}
return s;
}
return (
<LangContext.Provider value={{ lang, setLang, t }}>
{children}
</LangContext.Provider>
);
}
export function useLang() {
return useContext(LangContext);
}