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:
@@ -32,6 +32,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.49.0",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user