feat: fix dutch translation

This commit is contained in:
2026-10-10 23:11:08 +02:00
parent 5246aeabc1
commit db06276a4f
17 changed files with 463 additions and 330 deletions
@@ -13,22 +13,23 @@ import { PaymentMethodBadge } from '@/components/payment-method-badge';
import { EmptyState } from '@/components/empty-state'; import { EmptyState } from '@/components/empty-state';
import { LoadingState } from '@/components/loading-state'; import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state'; import { ErrorState } from '@/components/error-state';
import { formatRelativeTime, formatDateTime } from '@/lib/utils';
import { Filter } from 'lucide-react'; import { Filter } from 'lucide-react';
import { useRealtime } from '@/lib/realtime'; import { useRealtime } from '@/lib/realtime';
import { useLang } from '@/lib/i18n';
const statusFilters = [ const statusFilters = [
{ value: '', label: 'All' }, { value: '', label: 'commands.all' },
{ value: 'pending', label: 'Pending' }, { value: 'pending', label: 'status.pending' },
{ value: 'completed', label: 'Completed' }, { value: 'completed', label: 'status.completed' },
{ value: 'failed', label: 'Failed' }, { value: 'failed', label: 'status.failed' },
{ value: 'conflict', label: 'Conflict' }, { value: 'conflict', label: 'status.conflict' },
{ value: 'expired', label: 'Expired' }, { value: 'expired', label: 'status.expired' },
]; ];
export default function CommandsPage() { export default function CommandsPage() {
// Live updates make polling unnecessary; it only runs while the socket is down // Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime(); const { connected: live } = useRealtime();
const { t, tn, relativeTime } = useLang();
const router = useRouter(); const router = useRouter();
const [venueId, setVenueId] = useState<string | null>(null); const [venueId, setVenueId] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
@@ -61,13 +62,13 @@ export default function CommandsPage() {
if (!venueId) return <LoadingState />; if (!venueId) return <LoadingState />;
if (isLoading) return <LoadingState />; if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={(error as any).message || 'Failed to load commands'} />; if (error) return <ErrorState message={(error as any).message || t('commands.load_failed')} />;
return ( return (
<div> <div>
<PageHeader <PageHeader
title="Command History" title={t('commands.title')}
description={`${total} command${total !== 1 ? 's' : ''} total`} description={tn('commands.count', total)}
actions={ actions={
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
{statusFilters.map((f) => ( {statusFilters.map((f) => (
@@ -77,7 +78,7 @@ export default function CommandsPage() {
size="sm" size="sm"
onClick={() => { setStatusFilter(f.value); setPage(0); }} onClick={() => { setStatusFilter(f.value); setPage(0); }}
> >
{f.label} {t(f.label)}
</Button> </Button>
))} ))}
</div> </div>
@@ -85,20 +86,20 @@ export default function CommandsPage() {
/> />
{commands.length === 0 ? ( {commands.length === 0 ? (
<EmptyState title="No commands found" description="Commands will appear here when you create close requests." /> <EmptyState title={t('commands.empty')} description={t('commands.empty_desc')} />
) : ( ) : (
<> <>
<div className="rounded-md border mb-4"> <div className="rounded-md border mb-4">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Tab</TableHead> <TableHead>{t('commands.tab')}</TableHead>
<TableHead>Requested By</TableHead> <TableHead>{t('commands.requested')}</TableHead>
<TableHead>Payment</TableHead> <TableHead>{t('commands.payment')}</TableHead>
<TableHead>Created</TableHead> <TableHead>{t('commands.created')}</TableHead>
<TableHead>Status</TableHead> <TableHead>{t('commands.status')}</TableHead>
<TableHead>Processed</TableHead> <TableHead>{t('commands.processed')}</TableHead>
<TableHead>Result</TableHead> <TableHead>{t('commands.result')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -107,9 +108,9 @@ export default function CommandsPage() {
<TableCell className="font-mono text-xs">{cmd.tabId.substring(0, 8)}</TableCell> <TableCell className="font-mono text-xs">{cmd.tabId.substring(0, 8)}</TableCell>
<TableCell>{cmd.requestedBy}</TableCell> <TableCell>{cmd.requestedBy}</TableCell>
<TableCell><PaymentMethodBadge method={cmd.paymentMethod} /></TableCell> <TableCell><PaymentMethodBadge method={cmd.paymentMethod} /></TableCell>
<TableCell>{formatRelativeTime(cmd.createdAt)}</TableCell> <TableCell>{relativeTime(cmd.createdAt)}</TableCell>
<TableCell><CommandStatusBadge status={cmd.status} /></TableCell> <TableCell><CommandStatusBadge status={cmd.status} /></TableCell>
<TableCell>{cmd.processedAt ? formatRelativeTime(cmd.processedAt) : '-'}</TableCell> <TableCell>{cmd.processedAt ? relativeTime(cmd.processedAt) : '-'}</TableCell>
<TableCell className="max-w-[200px] truncate">{cmd.result || '-'}</TableCell> <TableCell className="max-w-[200px] truncate">{cmd.result || '-'}</TableCell>
</TableRow> </TableRow>
))} ))}
@@ -120,7 +121,7 @@ export default function CommandsPage() {
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Page {page + 1} of {totalPages} {t('commands.page', { page: page + 1, total: totalPages })}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@@ -129,7 +130,7 @@ export default function CommandsPage() {
disabled={page === 0} disabled={page === 0}
onClick={() => setPage(page - 1)} onClick={() => setPage(page - 1)}
> >
Previous {t('commands.previous')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
@@ -137,7 +138,7 @@ export default function CommandsPage() {
disabled={page >= totalPages - 1} disabled={page >= totalPages - 1}
onClick={() => setPage(page + 1)} onClick={() => setPage(page + 1)}
> >
Next {t('commands.next')}
</Button> </Button>
</div> </div>
</div> </div>
+1 -1
View File
@@ -74,7 +74,7 @@ export default function DashboardLayout({
function handleLogout() { function handleLogout() {
authApi.logout(); authApi.logout();
toast({ title: 'Logged out', variant: 'default' }); toast({ title: t('common.logged_out'), variant: 'default' });
router.push('/login'); router.push('/login');
} }
+11 -10
View File
@@ -9,13 +9,14 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { LoadingState } from '@/components/loading-state'; import { LoadingState } from '@/components/loading-state';
import { CommandStatusBadge } from '@/components/command-status-badge'; import { CommandStatusBadge } from '@/components/command-status-badge';
import { formatRelativeTime } from '@/lib/utils';
import { ClipboardList, Terminal, Monitor } from 'lucide-react'; import { ClipboardList, Terminal, Monitor } from 'lucide-react';
import { useRealtime } from '@/lib/realtime'; import { useRealtime } from '@/lib/realtime';
import { useLang } from '@/lib/i18n';
export default function DashboardPage() { export default function DashboardPage() {
// Live updates make polling unnecessary; it only runs while the socket is down // Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime(); const { connected: live } = useRealtime();
const { t, relativeTime } = useLang();
const router = useRouter(); const router = useRouter();
const [venueId, setVenueId] = useState<string | null>(null); const [venueId, setVenueId] = useState<string | null>(null);
@@ -64,7 +65,7 @@ export default function DashboardPage() {
return ( return (
<div> <div>
<PageHeader title="Overview" description="" /> <PageHeader title={t('overview.title')} description="" />
{isLoading ? ( {isLoading ? (
<div className="grid gap-4 md:grid-cols-3 mb-8"> <div className="grid gap-4 md:grid-cols-3 mb-8">
@@ -76,24 +77,24 @@ export default function DashboardPage() {
<div className="grid gap-4 md:grid-cols-3 mb-8"> <div className="grid gap-4 md:grid-cols-3 mb-8">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Open Tabs</CardTitle> <CardTitle className="text-sm font-medium">{t('overview.open_tabs')}</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" /> <ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent><div className="text-2xl font-bold">{tabs.length}</div></CardContent> <CardContent><div className="text-2xl font-bold">{tabs.length}</div></CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Device</CardTitle> <CardTitle className="text-sm font-medium">{t('overview.device')}</CardTitle>
<Monitor className="h-4 w-4 text-muted-foreground" /> <Monitor className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{device?.name || 'Not connected'}</div> <div className="text-2xl font-bold">{device?.name || t('overview.not_connected')}</div>
{device && <p className="text-xs text-muted-foreground mt-1">Last seen {formatRelativeTime(device.lastSeenAt)}</p>} {device && <p className="text-xs text-muted-foreground mt-1">{t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}</p>}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">Pending Commands</CardTitle> <CardTitle className="text-sm font-medium">{t('overview.pending')}</CardTitle>
<Terminal className="h-4 w-4 text-muted-foreground" /> <Terminal className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent><div className="text-2xl font-bold">{pendingCommands.length}</div></CardContent> <CardContent><div className="text-2xl font-bold">{pendingCommands.length}</div></CardContent>
@@ -102,10 +103,10 @@ export default function DashboardPage() {
)} )}
<Card> <Card>
<CardHeader><CardTitle className="text-lg">Recent Commands</CardTitle></CardHeader> <CardHeader><CardTitle className="text-lg">{t('overview.recent')}</CardTitle></CardHeader>
<CardContent> <CardContent>
{commands.length === 0 ? ( {commands.length === 0 ? (
<p className="text-sm text-muted-foreground">No commands yet</p> <p className="text-sm text-muted-foreground">{t('overview.no_commands')}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{commands.map((cmd: any) => ( {commands.map((cmd: any) => (
@@ -116,7 +117,7 @@ export default function DashboardPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CommandStatusBadge status={cmd.status} /> <CommandStatusBadge status={cmd.status} />
<span className="text-muted-foreground">{formatRelativeTime(cmd.createdAt)}</span> <span className="text-muted-foreground">{relativeTime(cmd.createdAt)}</span>
</div> </div>
</div> </div>
))} ))}
@@ -14,14 +14,14 @@ import { EmptyState } from '@/components/empty-state';
import { LoadingState } from '@/components/loading-state'; import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state'; import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast'; import { toast } from '@/components/ui/toast';
import { formatCents, formatRelativeTime } from '@/lib/utils'; import { formatCents } from '@/lib/utils';
import { useLang } from '@/lib/i18n'; import { useLang } from '@/lib/i18n';
import { Send, History } from 'lucide-react'; import { Send, History } from 'lucide-react';
export default function RemindersPage() { export default function RemindersPage() {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t } = useLang(); const { t, relativeTime } = useLang();
const [venueId, setVenueId] = useState<string | null>(null); const [venueId, setVenueId] = useState<string | null>(null);
const [sendDialogOpen, setSendDialogOpen] = useState(false); const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
@@ -51,7 +51,7 @@ export default function RemindersPage() {
setSendDialogOpen(false); setSendDialogOpen(false);
if (result.errors && result.errors.length > 0) { if (result.errors && result.errors.length > 0) {
const firstError = result.errors[0]; const firstError = result.errors[0];
toast({ title: `${result.sent}/${result.total} sent`, description: `Error: ${firstError.error}`, variant: 'destructive' }); toast({ title: t('reminders.partial', { sent: result.sent, total: result.total }), description: t('reminders.error_detail', { error: firstError.error }), variant: 'destructive' });
} else if (result.sent > 0) { } else if (result.sent > 0) {
toast({ title: t('reminders.sent_title'), description: t('reminders.success', { sent: result.sent }), variant: 'success' }); toast({ title: t('reminders.sent_title'), description: t('reminders.success', { sent: result.sent }), variant: 'success' });
} else { } else {
@@ -61,7 +61,7 @@ export default function RemindersPage() {
queryClient.invalidateQueries({ queryKey: ['reminder-history'] }); queryClient.invalidateQueries({ queryKey: ['reminder-history'] });
}, },
onError: (err: any) => { onError: (err: any) => {
toast({ title: 'Failed', description: err.message || 'An error occurred', variant: 'destructive' }); toast({ title: t('common.failed'), description: err.message || t('common.unknown_error'), variant: 'destructive' });
}, },
}); });
@@ -111,8 +111,8 @@ export default function RemindersPage() {
<TableCell className="font-medium">{r.customerName}</TableCell> <TableCell className="font-medium">{r.customerName}</TableCell>
<TableCell>{r.email}</TableCell> <TableCell>{r.email}</TableCell>
<TableCell>{formatCents(r.amountInCents)}</TableCell> <TableCell>{formatCents(r.amountInCents)}</TableCell>
<TableCell>{formatRelativeTime(r.sentAt)}</TableCell> <TableCell>{relativeTime(r.sentAt)}</TableCell>
<TableCell><Badge variant="success">{r.status}</Badge></TableCell> <TableCell><Badge variant="success">{r.status === 'sent' ? t('reminders.status_sent') : r.status}</Badge></TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -135,7 +135,7 @@ export default function RemindersPage() {
) : candidatesQuery.error ? ( ) : candidatesQuery.error ? (
<p className="text-sm text-destructive">{t('common.error')}</p> <p className="text-sm text-destructive">{t('common.error')}</p>
) : candidates.length === 0 ? ( ) : candidates.length === 0 ? (
<EmptyState title={t('reminders.no_candidates')} description='' /> <EmptyState title={t('reminders.no_candidates')} description={t('reminders.no_candidates_desc')} />
) : ( ) : (
<> <>
<div className="rounded-md border mb-4"> <div className="rounded-md border mb-4">
@@ -176,12 +176,12 @@ export default function RemindersPage() {
<DialogHeader> <DialogHeader>
<DialogTitle>{t('reminders.send')}</DialogTitle> <DialogTitle>{t('reminders.send')}</DialogTitle>
<DialogDescription> <DialogDescription>
{t('reminders.result', { sent: 0, total: candidates.length })} {t('reminders.candidates_desc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="py-4"> <div className="py-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This will send payment reminder emails to <strong>{candidates.length}</strong> customer(s) with open tabs. {t('reminders.confirm', { count: candidates.length })}
</p> </p>
<ul className="mt-3 space-y-1"> <ul className="mt-3 space-y-1">
{candidates.map((c: any) => ( {candidates.map((c: any) => (
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { toast } from '@/components/ui/toast'; import { toast } from '@/components/ui/toast';
import { formatRelativeTime, formatDateTime } from '@/lib/utils'; import { useLang } from '@/lib/i18n';
import { Monitor, Copy, Plus } from 'lucide-react'; import { Monitor, Copy, Plus } from 'lucide-react';
export default function SettingsPage() { export default function SettingsPage() {
@@ -19,6 +19,7 @@ export default function SettingsPage() {
const [userRole, setUserRole] = useState<string | null>(null); const [userRole, setUserRole] = useState<string | null>(null);
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null); const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const { t, relativeTime, dateTime, time } = useLang();
useEffect(() => { useEffect(() => {
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token=')); const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
@@ -50,7 +51,7 @@ export default function SettingsPage() {
const result = await devicesApi.generateCode(); const result = await devicesApi.generateCode();
setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt }); setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt });
} catch (err: any) { } catch (err: any) {
toast({ title: 'Failed', description: err.message, variant: 'destructive' }); toast({ title: t('common.failed'), description: err.message, variant: 'destructive' });
} finally { } finally {
setGenerating(false); setGenerating(false);
} }
@@ -59,50 +60,50 @@ export default function SettingsPage() {
function copyCode() { function copyCode() {
if (regDialog) { if (regDialog) {
navigator.clipboard.writeText(regDialog.code); navigator.clipboard.writeText(regDialog.code);
toast({ title: 'Copied', description: 'Registration code copied to clipboard' }); toast({ title: t('common.copied'), description: t('settings.copied_desc') });
} }
} }
return ( return (
<div> <div>
<PageHeader title="Settings" description="Venue and device management" /> <PageHeader title={t('settings.title')} description={t('settings.desc')} />
<div className="grid gap-6"> <div className="grid gap-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Venue</CardTitle> <CardTitle>{t('settings.venue')}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="text-sm"> <CardContent className="text-sm">
<p className="font-medium">{venue?.name || 'Loading...'}</p> <p className="font-medium">{venue?.name || t('common.loading')}</p>
<p className="text-muted-foreground font-mono text-xs mt-1">ID: {venueId}</p> <p className="text-muted-foreground font-mono text-xs mt-1">ID: {venueId}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Device</CardTitle> <CardTitle>{t('settings.device')}</CardTitle>
<CardDescription> <CardDescription>
{device {device
? `Paired as "${device.name}" — last seen ${formatRelativeTime(device.lastSeenAt)}` ? t('settings.paired', { name: device.name, time: relativeTime(device.lastSeenAt) })
: 'No device paired yet'} : t('settings.not_paired')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{device ? ( {device ? (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Monitor className="h-4 w-4 text-green-600" /> <Monitor className="h-4 w-4 text-green-600" />
<Badge variant="success">Connected</Badge> <Badge variant="success">{t('settings.connected')}</Badge>
<span className="text-muted-foreground">Since {formatDateTime(device.createdAt)}</span> <span className="text-muted-foreground">{t('settings.since', { date: dateTime(device.createdAt) })}</span>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground mb-3"> <p className="text-sm text-muted-foreground mb-3">
Pair a bar terminal to start receiving tabs. {t('settings.pair_desc')}
</p> </p>
)} )}
{userRole === 'admin' && ( {userRole === 'admin' && (
<Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3"> <Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
{generating ? 'Generating...' : device ? 'Re-pair Device' : 'Pair Device'} {generating ? t('settings.generating') : device ? t('settings.repair') : t('settings.pair')}
</Button> </Button>
)} )}
</CardContent> </CardContent>
@@ -114,35 +115,35 @@ export default function SettingsPage() {
<Dialog open={regDialog.open} onOpenChange={(open) => !open && setRegDialog(null)}> <Dialog open={regDialog.open} onOpenChange={(open) => !open && setRegDialog(null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Pair a Device</DialogTitle> <DialogTitle>{t('settings.pair_title')}</DialogTitle>
<DialogDescription> <DialogDescription>
Enter this code on the bar terminal to connect it to your venue. {t('settings.pair_instructions')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="py-6 space-y-4"> <div className="py-6 space-y-4">
<div className="text-center"> <div className="text-center">
<p className="text-sm text-muted-foreground mb-2">Registration Code</p> <p className="text-sm text-muted-foreground mb-2">{t('settings.pair_code')}</p>
<div className="text-4xl font-bold tracking-widest text-primary bg-primary/5 py-4 rounded-lg"> <div className="text-4xl font-bold tracking-widest text-primary bg-primary/5 py-4 rounded-lg">
{regDialog.code} {regDialog.code}
</div> </div>
</div> </div>
<div className="text-center text-sm text-muted-foreground"> <div className="text-center text-sm text-muted-foreground">
Expires at {new Date(regDialog.expiresAt).toLocaleTimeString()} {t('settings.pair_expires', { time: time(regDialog.expiresAt) })}
</div> </div>
<div className="space-y-1 text-sm"> <div className="space-y-1 text-sm">
<p className="font-medium">Instructions:</p> <p className="font-medium">{t('settings.pair_steps')}</p>
<ol className="list-decimal list-inside text-muted-foreground space-y-1"> <ol className="list-decimal list-inside text-muted-foreground space-y-1">
<li>Open the bar terminal app</li> <li>{t('settings.pair_step1')}</li>
<li>Select your venue from the list</li> <li>{t('settings.pair_step2')}</li>
<li>Enter this registration code</li> <li>{t('settings.pair_step3')}</li>
<li>Give the terminal a name</li> <li>{t('settings.pair_step4')}</li>
<li>Complete registration</li> <li>{t('settings.pair_step5')}</li>
</ol> </ol>
</div> </div>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setRegDialog(null)}>Close</Button> <Button variant="outline" onClick={() => setRegDialog(null)}>{t('common.close')}</Button>
<Button onClick={copyCode}><Copy className="h-4 w-4 mr-2" />Copy Code</Button> <Button onClick={copyCode}><Copy className="h-4 w-4 mr-2" />{t('settings.copy')}</Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+53 -49
View File
@@ -16,7 +16,7 @@ import { EmptyState } from '@/components/empty-state';
import { LoadingState } from '@/components/loading-state'; import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state'; import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast'; import { toast } from '@/components/ui/toast';
import { formatCents, formatRelativeTime } from '@/lib/utils'; import { formatCents } from '@/lib/utils';
import { RefreshCw, Plus, X, Users, Mail, Send, Upload, ChevronRight, ChevronDown } from 'lucide-react'; import { RefreshCw, Plus, X, Users, Mail, Send, Upload, ChevronRight, ChevronDown } from 'lucide-react';
import { ImportCustomersDialog } from '@/components/import-customers-dialog'; import { ImportCustomersDialog } from '@/components/import-customers-dialog';
import { useLang } from '@/lib/i18n'; import { useLang } from '@/lib/i18n';
@@ -37,7 +37,7 @@ export default function TabsPage() {
const [plannedEmail, setPlannedEmail] = useState(''); const [plannedEmail, setPlannedEmail] = useState('');
const [userRole, setUserRole] = useState<string | null>(null); const [userRole, setUserRole] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const { t } = useLang(); const { t, tn, relativeTime } = useLang();
useEffect(() => { useEffect(() => {
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token=')); const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
@@ -78,24 +78,25 @@ export default function TabsPage() {
const newSet = new Set(pendingCommandIds); const newSet = new Set(pendingCommandIds);
newSet.delete(cmd.id); newSet.delete(cmd.id);
setPendingCommandIds(newSet); setPendingCommandIds(newSet);
toast({ title: `Command ${cmd.status}`, description: `Close request for tab ${cmd.tabId?.substring(0, 8)} is now ${cmd.status}`, variant: cmd.status === 'completed' ? 'success' : 'destructive' }); const customerName = data?.tabs?.find((tab: any) => tab.tabId === cmd.tabId)?.customerName ?? cmd.tabId?.substring(0, 8);
toast({ title: t(`status.${cmd.status}`), description: t('tabs.request_result', { name: customerName, status: t(`status.${cmd.status}`) }), variant: cmd.status === 'completed' ? 'success' : 'destructive' });
queryClient.invalidateQueries({ queryKey: ['tabs'] }); queryClient.invalidateQueries({ queryKey: ['tabs'] });
queryClient.invalidateQueries({ queryKey: ['commands'] }); queryClient.invalidateQueries({ queryKey: ['commands'] });
} }
} }
} }
}, [commandsQuery.data, pendingCommandIds, queryClient]); }, [commandsQuery.data, pendingCommandIds, queryClient, data, t]);
const createCommandMutation = useMutation({ const createCommandMutation = useMutation({
mutationFn: (data: { tabId: string; expectedVersion: number; paymentMethod: string }) => mutationFn: (data: { tabId: string; expectedVersion: number; paymentMethod: string }) =>
commandsApi.create(venueId!, { ...data, type: 'close_tab' }), commandsApi.create(venueId!, { ...data, type: 'close_tab' }),
onSuccess: (result) => { onSuccess: (result) => {
setPendingCommandIds(new Set([...pendingCommandIds, result.id])); setPendingCommandIds(new Set([...pendingCommandIds, result.id]));
toast({ title: 'Close request sent', description: result.warning || 'Command sent to the terminal', variant: result.warning ? 'default' : 'success' }); toast({ title: t('tabs.request_sent'), description: result.warning || t('tabs.request_sent_desc'), variant: result.warning ? 'default' : 'success' });
queryClient.invalidateQueries({ queryKey: ['commands'] }); queryClient.invalidateQueries({ queryKey: ['commands'] });
}, },
onError: (err: any) => { onError: (err: any) => {
toast({ title: 'Failed', description: err.message || 'An error occurred', variant: 'destructive' }); toast({ title: t('common.failed'), description: err.message || t('common.unknown_error'), variant: 'destructive' });
}, },
}); });
@@ -114,9 +115,9 @@ export default function TabsPage() {
setPlannedName(''); setPlannedName('');
setPlannedEmail(''); setPlannedEmail('');
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] }); queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
toast({ title: 'Added', description: `"${plannedName.trim()}" added to planned tabs` }); toast({ title: t('planned.added'), description: t('planned.added_desc', { name: plannedName.trim() }) });
} catch (err: any) { } catch (err: any) {
toast({ title: 'Failed', description: err.message, variant: 'destructive' }); toast({ title: t('common.failed'), description: err.message, variant: 'destructive' });
} }
} }
@@ -125,39 +126,39 @@ export default function TabsPage() {
await plannedTabsApi.remove(venueId!, id); await plannedTabsApi.remove(venueId!, id);
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] }); queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
} catch (err: any) { } catch (err: any) {
toast({ title: 'Failed', description: err.message, variant: 'destructive' }); toast({ title: t('common.failed'), description: err.message, variant: 'destructive' });
} }
} }
const sendTestMutation = useMutation({ const sendTestMutation = useMutation({
mutationFn: (plannedTabId: string) => remindersApi.sendTest(venueId!, plannedTabId), mutationFn: (plannedTabId: string) => remindersApi.sendTest(venueId!, plannedTabId),
onSuccess: (result) => { onSuccess: (result) => {
toast({ title: 'Test email sent', description: `Sent to ${result.email}`, variant: 'success' }); toast({ title: t('tabs.test_sent'), description: t('tabs.test_sent_desc', { email: result.email }), variant: 'success' });
}, },
onError: (err: any) => { onError: (err: any) => {
toast({ title: 'Test email failed', description: err.message || 'An error occurred', variant: 'destructive' }); toast({ title: t('tabs.test_failed'), description: err.message || t('common.unknown_error'), variant: 'destructive' });
}, },
}); });
if (!venueId) return <LoadingState />; if (!venueId) return <LoadingState />;
if (isLoading) return <LoadingState />; if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={(error as any).message || 'Failed to load tabs'} />; if (error) return <ErrorState message={(error as any).message || t('tabs.load_failed')} />;
const tabs = data?.tabs || []; const tabs = data?.tabs || [];
return ( return (
<div> <div>
<PageHeader <PageHeader
title="Open Tabs" title={t('tabs.title')}
description={`${tabs.length} open tab${tabs.length !== 1 ? 's' : ''}`} description={tn('tabs.count', tabs.length)}
actions={<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['tabs'] })}><RefreshCw className="h-4 w-4 mr-2" />Refresh</Button>} actions={<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['tabs'] })}><RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}</Button>}
/> />
{/* Planned Customers */} {/* Planned Customers */}
<Card className="mb-6"> <Card className="mb-6">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2"> <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> <CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> {t('planned.title')}</CardTitle>
{userRole === 'admin' && ( {userRole === 'admin' && (
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}> <Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<Upload className="h-4 w-4 mr-2" />{t('import.button')} <Upload className="h-4 w-4 mr-2" />{t('import.button')}
@@ -169,28 +170,28 @@ export default function TabsPage() {
{userRole === 'admin' && ( {userRole === 'admin' && (
<div className="flex gap-2 mb-3 flex-wrap"> <div className="flex gap-2 mb-3 flex-wrap">
<Input <Input
placeholder="Add customer name..." placeholder={t('planned.name_placeholder')}
value={plannedName} value={plannedName}
onChange={(e) => setPlannedName(e.target.value)} onChange={(e) => setPlannedName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addPlannedName()} onKeyDown={(e) => e.key === 'Enter' && addPlannedName()}
className="max-w-xs" className="max-w-xs"
/> />
<Input <Input
placeholder="Email (optional)" placeholder={t('planned.email_placeholder')}
type="email" type="email"
value={plannedEmail} value={plannedEmail}
onChange={(e) => setPlannedEmail(e.target.value)} onChange={(e) => setPlannedEmail(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addPlannedName()} onKeyDown={(e) => e.key === 'Enter' && addPlannedName()}
className="max-w-xs" className="max-w-xs"
/> />
<Button size="sm" onClick={addPlannedName} disabled={!plannedName.trim()}> <Button size="sm" onClick={addPlannedName} disabled={!plannedName.trim()} aria-label={t('planned.add')}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>
)} )}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{plannedQuery.data && pendingPlanned.length === 0 && ( {plannedQuery.data && pendingPlanned.length === 0 && (
<p className="text-sm text-muted-foreground">No customers waiting for the terminal.</p> <p className="text-sm text-muted-foreground">{t('planned.waiting_empty')}</p>
)} )}
{pendingPlanned.map((pt: any) => ( {pendingPlanned.map((pt: any) => (
<div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm"> <div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
@@ -201,13 +202,14 @@ export default function TabsPage() {
onClick={() => sendTestMutation.mutate(pt.id)} onClick={() => sendTestMutation.mutate(pt.id)}
disabled={sendTestMutation.isPending} disabled={sendTestMutation.isPending}
className="text-muted-foreground hover:text-primary ml-0.5" className="text-muted-foreground hover:text-primary ml-0.5"
title="Send test email" title={t('tabs.send_test')}
aria-label={t('tabs.send_test')}
> >
<Send className="h-3 w-3" /> <Send className="h-3 w-3" />
</button> </button>
)} )}
{userRole === 'admin' && ( {userRole === 'admin' && (
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-0.5"> <button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-0.5" aria-label={t('planned.remove', { name: pt.customerName })}>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
)} )}
@@ -218,19 +220,19 @@ export default function TabsPage() {
</Card> </Card>
{tabs.length === 0 ? ( {tabs.length === 0 ? (
<EmptyState title="No open tabs" description="" /> <EmptyState title={t('tabs.empty')} description={t('tabs.empty_desc')} />
) : ( ) : (
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Customer</TableHead> <TableHead>{t('tabs.customer')}</TableHead>
<TableHead>Email</TableHead> <TableHead>{t('tabs.email')}</TableHead>
<TableHead>Amount</TableHead> <TableHead>{t('tabs.amount')}</TableHead>
<TableHead>Items</TableHead> <TableHead>{t('tabs.items')}</TableHead>
<TableHead>Version</TableHead> <TableHead>{t('tabs.version')}</TableHead>
<TableHead>Last Reported</TableHead> <TableHead>{t('tabs.reported')}</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">{t('tabs.actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -245,7 +247,7 @@ export default function TabsPage() {
<Fragment key={tab.tabId}> <Fragment key={tab.tabId}>
<TableRow> <TableRow>
<TableCell className="font-medium"> <TableCell className="font-medium">
<button onClick={toggle} className="flex items-center gap-1 hover:text-primary" aria-expanded={expanded} aria-label={`${expanded ? 'Hide' : 'Show'} items for ${tab.customerName}`}> <button onClick={toggle} className="flex items-center gap-1 hover:text-primary" aria-expanded={expanded} aria-label={t(expanded ? 'tabs.hide_items' : 'tabs.show_items', { name: tab.customerName })}>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} {expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
{tab.customerName} {tab.customerName}
</button> </button>
@@ -260,7 +262,8 @@ export default function TabsPage() {
onClick={() => sendTestMutation.mutate(tab.plannedTabId)} onClick={() => sendTestMutation.mutate(tab.plannedTabId)}
disabled={sendTestMutation.isPending} disabled={sendTestMutation.isPending}
className="hover:text-primary" className="hover:text-primary"
title="Send test email" title={t('tabs.send_test')}
aria-label={t('tabs.send_test')}
> >
<Send className="h-3 w-3" /> <Send className="h-3 w-3" />
</button> </button>
@@ -273,10 +276,10 @@ export default function TabsPage() {
<TableCell>{formatCents(tab.totalInCents)}</TableCell> <TableCell>{formatCents(tab.totalInCents)}</TableCell>
<TableCell>{tab.itemCount}</TableCell> <TableCell>{tab.itemCount}</TableCell>
<TableCell>{tab.version}</TableCell> <TableCell>{tab.version}</TableCell>
<TableCell>{formatRelativeTime(tab.lastReportedAt)}</TableCell> <TableCell>{relativeTime(tab.lastReportedAt)}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{/* Closing settles the tab on the terminal; a tab without a balance has nothing to settle */} {/* 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> <Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>{t('tabs.close')}</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
{expanded && ( {expanded && (
@@ -310,27 +313,27 @@ export default function TabsPage() {
<Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}> <Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Close {closeDialog.tab.customerName}'s tab?</DialogTitle> <DialogTitle>{t('tabs.close_title', { name: closeDialog.tab.customerName })}</DialogTitle>
<DialogDescription>Send a close request to the terminal.</DialogDescription> <DialogDescription>{t('tabs.close_desc')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 py-4"> <div className="space-y-3 py-4">
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Amount</span><span className="font-semibold">{formatCents(closeDialog.tab.totalInCents)}</span></div> <div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.amount')}</span><span className="font-semibold">{formatCents(closeDialog.tab.totalInCents)}</span></div>
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Items</span><span>{closeDialog.tab.itemCount}</span></div> <div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.items')}</span><span>{closeDialog.tab.itemCount}</span></div>
<div className="rounded-md border p-3"><TabLines items={closeDialog.tab.items} /></div> <div className="rounded-md border p-3"><TabLines items={closeDialog.tab.items} /></div>
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Version</span><span>{closeDialog.tab.version}</span></div> <div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.version')}</span><span>{closeDialog.tab.version}</span></div>
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
<Label className="text-base font-semibold">Payment method</Label> <Label className="text-base font-semibold">{t('tabs.payment')}</Label>
<RadioGroup value={paymentMethod} onValueChange={setPaymentMethod}> <RadioGroup value={paymentMethod} onValueChange={setPaymentMethod}>
<div className="flex items-center space-x-2"><RadioGroupItem value="cash" id="cash" /><Label htmlFor="cash">Cash</Label></div> <div className="flex items-center space-x-2"><RadioGroupItem value="cash" id="cash" /><Label htmlFor="cash">{t('payment.cash')}</Label></div>
<div className="flex items-center space-x-2"><RadioGroupItem value="payconiq" id="payconiq" /><Label htmlFor="payconiq">Payconiq</Label></div> <div className="flex items-center space-x-2"><RadioGroupItem value="payconiq" id="payconiq" /><Label htmlFor="payconiq">{t('payment.payconiq')}</Label></div>
<div className="flex items-center space-x-2"><RadioGroupItem value="payment_pending" id="payment_pending" /><Label htmlFor="payment_pending">Payment pending</Label></div> <div className="flex items-center space-x-2"><RadioGroupItem value="payment_pending" id="payment_pending" /><Label htmlFor="payment_pending">{t('payment.payment_pending')}</Label></div>
</RadioGroup> </RadioGroup>
{!paymentMethod && <p className="text-xs text-destructive">Please select a payment method</p>} {!paymentMethod && <p className="text-xs text-destructive">{t('tabs.select_payment')}</p>}
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setCloseDialog({ open: false, tab: null })} disabled={creating}>Cancel</Button> <Button variant="outline" onClick={() => setCloseDialog({ open: false, tab: null })} disabled={creating}>{t('common.cancel')}</Button>
<Button onClick={confirmClose} disabled={creating || !paymentMethod}>{creating ? 'Sending...' : 'Send close request'}</Button> <Button onClick={confirmClose} disabled={creating || !paymentMethod}>{creating ? t('tabs.sending') : t('tabs.send')}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -343,11 +346,12 @@ type TabLine = { productName: string; quantity: number; unitPriceInCents: number
/** The lines on a tab, e.g. "10× Cola · €2.50 each · €25.00". */ /** The lines on a tab, e.g. "10× Cola · €2.50 each · €25.00". */
function TabLines({ items }: { items: TabLine[] | null | undefined }) { function TabLines({ items }: { items: TabLine[] | null | undefined }) {
const { t } = useLang();
if (!items) { if (!items) {
return <p className="text-sm text-muted-foreground">Items aren't reported by this terminal yet. Update the app on the tablet to see them.</p>; return <p className="text-sm text-muted-foreground">{t('tabs.lines_unknown')}</p>;
} }
if (items.length === 0) { if (items.length === 0) {
return <p className="text-sm text-muted-foreground">No items on this tab.</p>; return <p className="text-sm text-muted-foreground">{t('tabs.lines_empty')}</p>;
} }
return ( return (
<ul className="space-y-1 text-sm"> <ul className="space-y-1 text-sm">
@@ -355,7 +359,7 @@ function TabLines({ items }: { items: TabLine[] | null | undefined }) {
<li key={i} className="flex justify-between gap-4"> <li key={i} className="flex justify-between gap-4">
<span> <span>
<span className="font-medium">{item.quantity}×</span> {item.productName} <span className="font-medium">{item.quantity}×</span> {item.productName}
<span className="text-muted-foreground"> · {formatCents(item.unitPriceInCents)} each</span> <span className="text-muted-foreground"> · {formatCents(item.unitPriceInCents)} {t('tabs.each')}</span>
</span> </span>
<span className="tabular-nums">{formatCents(item.quantity * item.unitPriceInCents)}</span> <span className="tabular-nums">{formatCents(item.quantity * item.unitPriceInCents)}</span>
</li> </li>
+28 -27
View File
@@ -16,7 +16,7 @@ import { EmptyState } from '@/components/empty-state';
import { LoadingState } from '@/components/loading-state'; import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state'; import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast'; import { toast } from '@/components/ui/toast';
import { formatRelativeTime } from '@/lib/utils'; import { useLang } from '@/lib/i18n';
import { RefreshCw, Plus } from 'lucide-react'; import { RefreshCw, Plus } from 'lucide-react';
export default function UsersPage() { export default function UsersPage() {
@@ -27,6 +27,7 @@ export default function UsersPage() {
const [addDialog, setAddDialog] = useState(false); const [addDialog, setAddDialog] = useState(false);
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer' }); const [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer' });
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const { t, tn, relativeTime } = useLang();
useEffect(() => { useEffect(() => {
const cookie = document.cookie const cookie = document.cookie
@@ -51,12 +52,12 @@ export default function UsersPage() {
setSubmitting(true); setSubmitting(true);
try { try {
await usersApi.create(venueId!, form); await usersApi.create(venueId!, form);
toast({ title: 'User created', description: `${form.email} added as ${form.role}`, variant: 'success' }); toast({ title: t('users.created_toast'), description: t('users.created_toast_desc', { email: form.email, role: t(`users.role.${form.role}`) }), variant: 'success' });
setAddDialog(false); setAddDialog(false);
setForm({ email: '', password: '', name: '', role: 'viewer' }); setForm({ email: '', password: '', name: '', role: 'viewer' });
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
} catch (err: any) { } catch (err: any) {
toast({ title: 'Failed to create user', description: err.message, variant: 'destructive' }); toast({ title: t('users.create_failed'), description: err.message, variant: 'destructive' });
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -64,21 +65,21 @@ export default function UsersPage() {
if (!venueId) return <LoadingState />; if (!venueId) return <LoadingState />;
if (isLoading) return <LoadingState />; if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={(error as any).message || 'Failed to load users'} />; if (error) return <ErrorState message={(error as any).message || t('users.load_failed')} />;
return ( return (
<div> <div>
<PageHeader <PageHeader
title="Users" title={t('users.title')}
description={`${users.length} user${users.length !== 1 ? 's' : ''} registered`} description={tn('users.count', users.length)}
actions={ actions={
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['users'] })}> <Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['users'] })}>
<RefreshCw className="h-4 w-4 mr-2" /> Refresh <RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
</Button> </Button>
{userRole === 'admin' && ( {userRole === 'admin' && (
<Button size="sm" onClick={() => setAddDialog(true)}> <Button size="sm" onClick={() => setAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" /> Add User <Plus className="h-4 w-4 mr-2" /> {t('users.add')}
</Button> </Button>
)} )}
</div> </div>
@@ -86,16 +87,16 @@ export default function UsersPage() {
/> />
{users.length === 0 ? ( {users.length === 0 ? (
<EmptyState title="No users" description="Add users who can manage tabs from this dashboard." /> <EmptyState title={t('users.empty')} description={t('users.empty_desc')} />
) : ( ) : (
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>{t('users.name')}</TableHead>
<TableHead>Email</TableHead> <TableHead>{t('users.email')}</TableHead>
<TableHead>Role</TableHead> <TableHead>{t('users.role')}</TableHead>
<TableHead>Created</TableHead> <TableHead>{t('users.created')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -103,8 +104,8 @@ export default function UsersPage() {
<TableRow key={u.id}> <TableRow key={u.id}>
<TableCell className="font-medium">{u.name}</TableCell> <TableCell className="font-medium">{u.name}</TableCell>
<TableCell>{u.email}</TableCell> <TableCell>{u.email}</TableCell>
<TableCell><Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{u.role}</Badge></TableCell> <TableCell><Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>{t(`users.role.${u.role}`)}</Badge></TableCell>
<TableCell>{formatRelativeTime(u.createdAt)}</TableCell> <TableCell>{relativeTime(u.createdAt)}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -115,37 +116,37 @@ export default function UsersPage() {
<Dialog open={addDialog} onOpenChange={setAddDialog}> <Dialog open={addDialog} onOpenChange={setAddDialog}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Add User</DialogTitle> <DialogTitle>{t('users.add_title')}</DialogTitle>
<DialogDescription>Create a new user who can access the dashboard.</DialogDescription> <DialogDescription>{t('users.add_desc')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Name</Label> <Label htmlFor="name">{t('users.name')}</Label>
<Input id="name" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="Bartender name" /> <Input id="name" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder={t('users.name_placeholder')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">{t('users.email')}</Label>
<Input id="email" type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} placeholder="email@example.com" /> <Input id="email" type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })} placeholder="email@example.com" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">{t('users.password')}</Label>
<Input id="password" type="password" value={form.password} onChange={e => setForm({ ...form, password: e.target.value })} placeholder="Min 6 characters" /> <Input id="password" type="password" value={form.password} onChange={e => setForm({ ...form, password: e.target.value })} placeholder={t('users.password_placeholder')} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="role">Role</Label> <Label htmlFor="role">{t('users.role')}</Label>
<Select value={form.role} onValueChange={v => setForm({ ...form, role: v })}> <Select value={form.role} onValueChange={v => setForm({ ...form, role: v })}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="viewer">Viewer (can close tabs)</SelectItem> <SelectItem value="viewer">{t('users.role_viewer')}</SelectItem>
<SelectItem value="admin">Admin (full access)</SelectItem> <SelectItem value="admin">{t('users.role_admin')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setAddDialog(false)} disabled={submitting}>Cancel</Button> <Button variant="outline" onClick={() => setAddDialog(false)} disabled={submitting}>{t('common.cancel')}</Button>
<Button onClick={handleCreateUser} disabled={submitting || !form.email || !form.password || !form.name}> <Button onClick={handleCreateUser} disabled={submitting || !form.email || !form.password || !form.name}>
{submitting ? 'Creating...' : 'Create User'} {submitting ? t('users.creating') : t('users.create')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+1 -1
View File
@@ -6,7 +6,7 @@ import { Providers } from './providers';
const inter = Inter({ subsets: ['latin'] }); const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'KoolTap - Remote Tab Control', title: 'KoolTap',
description: 'Manage tabs remotely across your venue', description: 'Manage tabs remotely across your venue',
}; };
+10 -4
View File
@@ -7,6 +7,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { LanguageSelector } from '@/components/language-selector';
import { useLang } from '@/lib/i18n';
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
@@ -14,6 +16,7 @@ export default function LoginPage() {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { t } = useLang();
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -25,7 +28,7 @@ export default function LoginPage() {
setTokens(result.accessToken, result.refreshToken); setTokens(result.accessToken, result.refreshToken);
router.push('/dashboard'); router.push('/dashboard');
} catch (err: any) { } catch (err: any) {
setError(err.message || 'Invalid email or password'); setError(err.message || t('auth.invalid'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -40,7 +43,7 @@ export default function LoginPage() {
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">{t('auth.email')}</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
@@ -51,7 +54,7 @@ export default function LoginPage() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Password</Label> <Label htmlFor="password">{t('auth.password')}</Label>
<Input <Input
id="password" id="password"
type="password" type="password"
@@ -67,9 +70,12 @@ export default function LoginPage() {
</div> </div>
)} )}
<Button type="submit" className="w-full" disabled={loading}> <Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'} {loading ? t('auth.logging_in') : t('auth.login')}
</Button> </Button>
</form> </form>
<div className="mt-6 flex justify-center">
<LanguageSelector />
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -1,14 +1,18 @@
import { Badge } from '@/components/ui/badge'; 'use client';
const statusConfig: Record<string, { label: string; variant: 'default' | 'warning' | 'success' | 'destructive' | 'secondary' | 'info' }> = { import { Badge } from '@/components/ui/badge';
pending: { label: 'Pending', variant: 'warning' }, import { useLang } from '@/lib/i18n';
completed: { label: 'Completed', variant: 'success' },
failed: { label: 'Failed', variant: 'destructive' }, const statusVariants: Record<string, 'default' | 'warning' | 'success' | 'destructive' | 'secondary' | 'info'> = {
conflict: { label: 'Conflict', variant: 'destructive' }, pending: 'warning',
expired: { label: 'Expired', variant: 'secondary' }, completed: 'success',
failed: 'destructive',
conflict: 'destructive',
expired: 'secondary',
}; };
export function CommandStatusBadge({ status }: { status: string }) { export function CommandStatusBadge({ status }: { status: string }) {
const config = statusConfig[status] || { label: status, variant: 'default' as const }; const { t } = useLang();
return <Badge variant={config.variant}>{config.label}</Badge>; const variant = statusVariants[status];
return <Badge variant={variant ?? 'default'}>{variant ? t(`status.${status}`) : status}</Badge>;
} }
@@ -1,12 +1,15 @@
'use client';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { getDeviceStatus, DeviceStatus } from '@/lib/device-status'; import { getDeviceStatus, DeviceStatus } from '@/lib/device-status';
import { useLang } from '@/lib/i18n';
const statusConfig: Record<DeviceStatus, { label: string; variant: 'success' | 'warning' | 'destructive' | 'secondary' }> = { const statusVariants: Record<DeviceStatus, 'success' | 'warning' | 'destructive' | 'secondary'> = {
online: { label: 'Online', variant: 'success' }, online: 'success',
recently_seen: { label: 'Recently seen', variant: 'secondary' }, recently_seen: 'secondary',
stale: { label: 'Stale', variant: 'warning' }, stale: 'warning',
offline: { label: 'Offline', variant: 'destructive' }, offline: 'destructive',
revoked: { label: 'Revoked', variant: 'destructive' }, revoked: 'destructive',
}; };
export function DeviceStatusBadge({ export function DeviceStatusBadge({
@@ -16,7 +19,7 @@ export function DeviceStatusBadge({
lastSeenAt: string | Date; lastSeenAt: string | Date;
revokedAt?: string | Date | null; revokedAt?: string | Date | null;
}) { }) {
const { t } = useLang();
const status = getDeviceStatus(lastSeenAt, revokedAt); const status = getDeviceStatus(lastSeenAt, revokedAt);
const config = statusConfig[status]; return <Badge variant={statusVariants[status]}>{t(`device_status.${status}`)}</Badge>;
return <Badge variant={config.variant}>{config.label}</Badge>;
} }
@@ -1,11 +1,15 @@
'use client';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useLang } from '@/lib/i18n';
export function ErrorState({ message }: { message: string }) { export function ErrorState({ message }: { message: string }) {
const { t } = useLang();
return ( return (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-destructive"> <CardContent className="flex flex-col items-center justify-center py-12 text-destructive">
<div className="text-4xl mb-4">⚠️</div> <div className="text-4xl mb-4">⚠️</div>
<h3 className="text-lg font-semibold mb-1">Something went wrong</h3> <h3 className="text-lg font-semibold mb-1">{t('common.error')}</h3>
<p className="text-sm">{message}</p> <p className="text-sm">{message}</p>
</CardContent> </CardContent>
</Card> </Card>
@@ -206,7 +206,7 @@ export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={submitting}> <Button variant="outline" onClick={() => handleOpenChange(false)} disabled={submitting}>
{t('tabs.cancel')} {t('common.cancel')}
</Button> </Button>
<Button onClick={submit} disabled={submitting || !preview || preview.entries.length === 0}> <Button onClick={submit} disabled={submitting || !preview || preview.entries.length === 0}>
{submitting ? t('import.importing') : t('import.submit', { count: preview?.entries.length ?? 0 })} {submitting ? t('import.importing') : t('import.submit', { count: preview?.entries.length ?? 0 })}
@@ -1,12 +1,11 @@
import { Badge } from '@/components/ui/badge'; 'use client';
const methodLabels: Record<string, string> = { import { Badge } from '@/components/ui/badge';
cash: 'Cash', import { useLang } from '@/lib/i18n';
payconiq: 'Payconiq',
payment_pending: 'Payment pending', const KNOWN_METHODS = ['cash', 'payconiq', 'payment_pending'];
};
export function PaymentMethodBadge({ method }: { method: string }) { export function PaymentMethodBadge({ method }: { method: string }) {
const label = methodLabels[method] || method; const { t } = useLang();
return <Badge variant="outline">{label}</Badge>; return <Badge variant="outline">{KNOWN_METHODS.includes(method) ? t(`payment.${method}`) : method}</Badge>;
} }
+256 -123
View File
@@ -2,6 +2,7 @@ import { createContext, useContext, useState, useEffect, ReactNode } from 'react
type Lang = 'en' | 'nl'; type Lang = 'en' | 'nl';
// Dutch wording follows the bar app: a tab is a "poef", informal "je"
const translations: Record<Lang, Record<string, string>> = { const translations: Record<Lang, Record<string, string>> = {
en: { en: {
'nav.overview': 'Overview', 'nav.overview': 'Overview',
@@ -13,31 +14,72 @@ const translations: Record<Lang, Record<string, string>> = {
'nav.logout': 'Logout', 'nav.logout': 'Logout',
'realtime.live': 'Live updates on', 'realtime.live': 'Live updates on',
'realtime.offline': 'Live updates unavailable — refreshing periodically', 'realtime.offline': 'Live updates unavailable — refreshing periodically',
'common.refresh': 'Refresh',
'common.loading': 'Loading...',
'common.error': 'Something went wrong',
'common.unknown_error': 'An error occurred',
'common.failed': 'Failed',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.copied': 'Copied',
'common.logged_out': 'Logged out',
'time.just_now': 'just now',
'time.minutes_ago': '{count}m ago',
'time.hours_ago': '{count}h ago',
'time.days_ago': '{count}d ago',
'status.pending': 'Pending',
'status.completed': 'Completed',
'status.failed': 'Failed',
'status.conflict': 'Conflict',
'status.expired': 'Expired',
'device_status.online': 'Online',
'device_status.recently_seen': 'Recently seen',
'device_status.stale': 'Stale',
'device_status.offline': 'Offline',
'device_status.revoked': 'Revoked',
'payment.cash': 'Cash',
'payment.payconiq': 'Payconiq',
'payment.payment_pending': 'Payment pending',
'tabs.title': 'Open Tabs', 'tabs.title': 'Open Tabs',
'tabs.count': '{count} open tab', 'tabs.count': '{count} open tab',
'tabs.count_plural': '{count} open tabs', 'tabs.count_plural': '{count} open tabs',
'tabs.empty': 'No open tabs', 'tabs.empty': 'No open tabs',
'tabs.empty_desc': 'Open tabs will appear here when the terminal syncs.', 'tabs.empty_desc': 'Open tabs appear here when the terminal syncs.',
'tabs.load_failed': 'Failed to load tabs',
'tabs.customer': 'Customer', 'tabs.customer': 'Customer',
'tabs.email': 'Email',
'tabs.amount': 'Amount', 'tabs.amount': 'Amount',
'tabs.items': 'Items', 'tabs.items': 'Items',
'tabs.version': 'Version', 'tabs.version': 'Version',
'tabs.reported': 'Last Reported', 'tabs.reported': 'Last updated',
'tabs.actions': 'Actions',
'tabs.close': 'Close', 'tabs.close': 'Close',
'tabs.show_items': 'Show items for {name}',
'tabs.hide_items': 'Hide items for {name}',
'tabs.lines_unknown': "Items aren't reported by this terminal yet. Update the app on the tablet to see them.",
'tabs.lines_empty': 'No items on this tab.',
'tabs.each': 'each',
'tabs.close_title': "Close {name}'s tab?", 'tabs.close_title': "Close {name}'s tab?",
'tabs.close_desc': 'Send a close request to the terminal.', 'tabs.close_desc': 'Send a close request to the terminal.',
'tabs.payment': 'Payment method', 'tabs.payment': 'Payment method',
'tabs.cash': 'Cash',
'tabs.payconiq': 'Payconiq',
'tabs.pending_payment': 'Payment pending',
'tabs.select_payment': 'Please select a payment method', 'tabs.select_payment': 'Please select a payment method',
'tabs.send': 'Send close request', 'tabs.send': 'Send close request',
'tabs.sending': 'Sending...', 'tabs.sending': 'Sending...',
'tabs.cancel': 'Cancel', 'tabs.request_sent': 'Close request sent',
'tabs.planned_title': 'Planned Customers', 'tabs.request_sent_desc': 'Sent to the terminal',
'tabs.planned_desc': 'These names are synced to the terminal. It will create tabs for these customers automatically.', 'tabs.request_result': 'Close request for {name}: {status}',
'tabs.planned_add': 'Add customer name...', 'tabs.send_test': 'Send test email',
'tabs.planned_empty': 'No planned customers. Add names above and they\'ll sync to the terminal.', 'tabs.test_sent': 'Test email sent',
'tabs.test_sent_desc': 'Sent to {email}',
'tabs.test_failed': 'Test email failed',
'planned.title': 'Planned Customers',
'planned.name_placeholder': 'Add customer name...',
'planned.email_placeholder': 'Email (optional)',
'planned.add': 'Add customer',
'planned.remove': 'Remove {name}',
'planned.waiting_empty': 'No customers waiting for the terminal.',
'planned.added': 'Added',
'planned.added_desc': '"{name}" will get a tab on the terminal',
'import.button': 'Import from Excel', 'import.button': 'Import from Excel',
'import.title': 'Import customers', 'import.title': 'Import customers',
'import.description': 'Upload the customer list. Every name becomes a planned customer and gets a tab on the terminal.', 'import.description': 'Upload the customer list. Every name becomes a planned customer and gets a tab on the terminal.',
@@ -64,6 +106,7 @@ const translations: Record<Lang, Record<string, string>> = {
'users.title': 'Users', 'users.title': 'Users',
'users.count': '{count} user', 'users.count': '{count} user',
'users.count_plural': '{count} users', 'users.count_plural': '{count} users',
'users.load_failed': 'Failed to load users',
'users.empty': 'No users', 'users.empty': 'No users',
'users.empty_desc': 'Add users who can manage tabs from this dashboard.', 'users.empty_desc': 'Add users who can manage tabs from this dashboard.',
'users.add': 'Add User', 'users.add': 'Add User',
@@ -73,13 +116,24 @@ const translations: Record<Lang, Record<string, string>> = {
'users.created': 'Created', 'users.created': 'Created',
'users.add_title': 'Add User', 'users.add_title': 'Add User',
'users.add_desc': 'Create a new user who can access the dashboard.', 'users.add_desc': 'Create a new user who can access the dashboard.',
'users.name_placeholder': 'Bartender name',
'users.password': 'Password', 'users.password': 'Password',
'users.password_placeholder': 'At least 6 characters',
'users.role_viewer': 'Viewer (can close tabs)',
'users.role_admin': 'Admin (full access)',
'users.role.viewer': 'Viewer',
'users.role.admin': 'Admin',
'users.creating': 'Creating...', 'users.creating': 'Creating...',
'users.create': 'Create User', 'users.create': 'Create User',
'users.created_toast': 'User created',
'users.created_toast_desc': '{email} added as {role}',
'users.create_failed': 'Failed to create user',
'commands.title': 'Command History', 'commands.title': 'Command History',
'commands.count': '{count} command', 'commands.count': '{count} command',
'commands.count_plural': '{count} commands', 'commands.count_plural': '{count} commands',
'commands.load_failed': 'Failed to load commands',
'commands.empty': 'No commands found', 'commands.empty': 'No commands found',
'commands.empty_desc': 'Commands appear here when you send close requests.',
'commands.tab': 'Tab', 'commands.tab': 'Tab',
'commands.requested': 'Requested By', 'commands.requested': 'Requested By',
'commands.payment': 'Payment', 'commands.payment': 'Payment',
@@ -92,46 +146,37 @@ const translations: Record<Lang, Record<string, string>> = {
'commands.next': 'Next', 'commands.next': 'Next',
'commands.page': 'Page {page} of {total}', 'commands.page': 'Page {page} of {total}',
'settings.title': 'Settings', 'settings.title': 'Settings',
'settings.desc': 'Venue and device management', 'settings.desc': 'Venue and terminal',
'settings.venue': 'Venue', 'settings.venue': 'Venue',
'settings.device': 'Device', 'settings.device': 'Terminal',
'settings.paired': 'Paired as "{name}" — last seen {time}', 'settings.paired': 'Paired as "{name}" — last seen {time}',
'settings.not_paired': 'No device paired yet', 'settings.not_paired': 'No terminal paired yet',
'settings.connected': 'Connected', 'settings.connected': 'Connected',
'settings.since': 'Since {date}', 'settings.since': 'Since {date}',
'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.', 'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.',
'settings.pair': 'Pair Device', 'settings.pair': 'Pair Terminal',
'settings.repair': 'Re-pair Device', 'settings.repair': 'Re-pair Terminal',
'settings.pair_title': 'Pair a Device', 'settings.generating': 'Generating...',
'settings.pair_title': 'Pair a Terminal',
'settings.pair_instructions': 'Enter this code on the bar terminal to connect it to your venue.', 'settings.pair_instructions': 'Enter this code on the bar terminal to connect it to your venue.',
'settings.pair_code': 'Registration Code', 'settings.pair_code': 'Registration Code',
'settings.pair_expires': 'Expires at {time}', 'settings.pair_expires': 'Expires at {time}',
'settings.pair_steps': 'Instructions:',
'settings.pair_step1': 'Open the bar terminal app', 'settings.pair_step1': 'Open the bar terminal app',
'settings.pair_step2': 'Select your venue from the list', 'settings.pair_step2': 'Select your venue from the list',
'settings.pair_step3': 'Enter this registration code', 'settings.pair_step3': 'Enter this registration code',
'settings.pair_step4': 'Give the terminal a name', 'settings.pair_step4': 'Give the terminal a name',
'settings.pair_step5': 'Complete registration', 'settings.pair_step5': 'Complete registration',
'settings.copy': 'Copy Code', 'settings.copy': 'Copy Code',
'settings.close': 'Close', 'settings.copied_desc': 'Registration code copied to clipboard',
'settings.api': 'API',
'settings.base_url': 'Base URL',
'settings.swagger': 'Swagger',
'overview.title': 'Overview', 'overview.title': 'Overview',
'overview.desc': 'Real-time overview of your venue',
'overview.open_tabs': 'Open Tabs', 'overview.open_tabs': 'Open Tabs',
'overview.device': 'Device', 'overview.device': 'Terminal',
'overview.pending': 'Pending Commands', 'overview.pending': 'Pending Commands',
'overview.recent': 'Recent Commands', 'overview.recent': 'Recent Commands',
'overview.no_commands': 'No commands yet', 'overview.no_commands': 'No commands yet',
'overview.not_connected': 'Not connected', 'overview.not_connected': 'Not connected',
'overview.last_seen': 'Last seen {time}', '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.login': 'Sign in',
'auth.logging_in': 'Signing in...', 'auth.logging_in': 'Signing in...',
'auth.email': 'Email', 'auth.email': 'Email',
@@ -141,15 +186,17 @@ const translations: Record<Lang, Record<string, string>> = {
'lang.nl': 'Nederlands', 'lang.nl': 'Nederlands',
'lang.label': 'Language', 'lang.label': 'Language',
'reminders.title': 'Payment Reminders', 'reminders.title': 'Payment Reminders',
'reminders.desc': 'Send payment reminders to customers with open tabs', 'reminders.desc': 'Send payment reminders to customers with an unpaid tab',
'reminders.candidates': 'Eligible for reminder', 'reminders.candidates': 'Eligible for a reminder',
'reminders.candidates_desc': 'These customers have open tabs and an email address on file. Reminders will be sent via Resend.', 'reminders.candidates_desc': 'These customers have an unpaid tab and an email address on file. Reminders are sent via Resend.',
'reminders.no_candidates': 'No eligible customers', 'reminders.no_candidates': 'No eligible customers',
'reminders.no_candidates_desc': 'Make sure planned customers have email addresses set.', 'reminders.no_candidates_desc': 'Make sure planned customers have an email address.',
'reminders.send': 'Send Reminders', 'reminders.send': 'Send Reminders',
'reminders.sending': 'Sending...', 'reminders.sending': 'Sending...',
'reminders.result': 'Sent {sent} of {total} reminder(s)', 'reminders.confirm': 'This emails a payment reminder to {count} customer(s) with an unpaid tab.',
'reminders.sent_title': 'Reminders Sent', 'reminders.sent_title': 'Reminders Sent',
'reminders.partial': '{sent} of {total} sent',
'reminders.error_detail': 'Error: {error}',
'reminders.zero_sent': 'No reminders were sent. Check that Resend is configured.', 'reminders.zero_sent': 'No reminders were sent. Check that Resend is configured.',
'reminders.success': 'Successfully sent {sent} reminder email(s)', 'reminders.success': 'Successfully sent {sent} reminder email(s)',
'reminders.history': 'Sent Reminders', 'reminders.history': 'Sent Reminders',
@@ -159,47 +206,87 @@ const translations: Record<Lang, Record<string, string>> = {
'reminders.amount': 'Amount', 'reminders.amount': 'Amount',
'reminders.sent_at': 'Sent At', 'reminders.sent_at': 'Sent At',
'reminders.status': 'Status', 'reminders.status': 'Status',
'tabs.email': 'Email (optional)', 'reminders.status_sent': 'Sent',
'tabs.planned_email_hint': 'Add email to enable payment reminders',
}, },
nl: { nl: {
'nav.overview': 'Overzicht', 'nav.overview': 'Overzicht',
'nav.tabs': 'Open Tabs', 'nav.tabs': 'Open poefs',
'nav.reminders': 'Herinneringen', 'nav.reminders': 'Herinneringen',
'nav.users': 'Gebruikers', 'nav.users': 'Gebruikers',
'nav.commands': 'Opdrachten', 'nav.commands': 'Opdrachten',
'nav.settings': 'Instellingen', 'nav.settings': 'Instellingen',
'nav.logout': 'Uitloggen', 'nav.logout': 'Uitloggen',
'realtime.live': 'Live updates aan', 'realtime.live': 'Live updates aan',
'realtime.offline': 'Live updates niet beschikbaar — wordt periodiek vernieuwd', 'realtime.offline': 'Live updates niet beschikbaar — wordt regelmatig vernieuwd',
'tabs.title': 'Open Tabs', 'common.refresh': 'Vernieuwen',
'tabs.count': '{count} open tab', 'common.loading': 'Laden...',
'tabs.count_plural': '{count} open tabs', 'common.error': 'Er is iets misgegaan',
'tabs.empty': 'Geen open tabs', 'common.unknown_error': 'Er is een fout opgetreden',
'tabs.empty_desc': 'Open tabs verschijnen hier wanneer de terminal synchroniseert.', 'common.failed': 'Mislukt',
'common.cancel': 'Annuleren',
'common.close': 'Sluiten',
'common.copied': 'Gekopieerd',
'common.logged_out': 'Uitgelogd',
'time.just_now': 'zonet',
'time.minutes_ago': '{count} min geleden',
'time.hours_ago': '{count} u geleden',
'time.days_ago': '{count} d geleden',
'status.pending': 'In behandeling',
'status.completed': 'Voltooid',
'status.failed': 'Mislukt',
'status.conflict': 'Conflict',
'status.expired': 'Verlopen',
'device_status.online': 'Online',
'device_status.recently_seen': 'Recent gezien',
'device_status.stale': 'Inactief',
'device_status.offline': 'Offline',
'device_status.revoked': 'Ingetrokken',
'payment.cash': 'Contant',
'payment.payconiq': 'Payconiq',
'payment.payment_pending': 'Onbetaald laten',
'tabs.title': 'Open poefs',
'tabs.count': '{count} open poef',
'tabs.count_plural': '{count} open poefs',
'tabs.empty': 'Geen open poefs',
'tabs.empty_desc': 'Open poefs verschijnen hier zodra de terminal synchroniseert.',
'tabs.load_failed': 'Poefs konden niet geladen worden',
'tabs.customer': 'Klant', 'tabs.customer': 'Klant',
'tabs.email': 'E-mail',
'tabs.amount': 'Bedrag', 'tabs.amount': 'Bedrag',
'tabs.items': 'Artikelen', 'tabs.items': 'Items',
'tabs.version': 'Versie', 'tabs.version': 'Versie',
'tabs.reported': 'Laatst gemeld', 'tabs.reported': 'Laatst bijgewerkt',
'tabs.actions': 'Acties',
'tabs.close': 'Sluiten', 'tabs.close': 'Sluiten',
'tabs.close_title': 'Sluit {name}\'s tab?', 'tabs.show_items': 'Items van {name} tonen',
'tabs.close_desc': 'Stuur een sluitverzoek naar de terminal.', 'tabs.hide_items': 'Items van {name} verbergen',
'tabs.lines_unknown': 'Deze terminal stuurt de items nog niet door. Werk de app op de tablet bij om ze te zien.',
'tabs.lines_empty': 'Geen items op deze poef.',
'tabs.each': 'per stuk',
'tabs.close_title': 'Poef van {name} sluiten?',
'tabs.close_desc': 'Stuur een verzoek naar de terminal om deze poef te sluiten.',
'tabs.payment': 'Betaalmethode', 'tabs.payment': 'Betaalmethode',
'tabs.cash': 'Contant', 'tabs.select_payment': 'Kies een betaalmethode',
'tabs.payconiq': 'Payconiq',
'tabs.pending_payment': 'Betaling in behandeling',
'tabs.select_payment': 'Selecteer een betaalmethode',
'tabs.send': 'Sluitverzoek versturen', 'tabs.send': 'Sluitverzoek versturen',
'tabs.sending': 'Bezig met verzenden...', 'tabs.sending': 'Bezig met versturen...',
'tabs.cancel': 'Annuleren', 'tabs.request_sent': 'Sluitverzoek verstuurd',
'tabs.planned_title': 'Geplande Klanten', 'tabs.request_sent_desc': 'Verstuurd naar de terminal',
'tabs.planned_desc': 'Deze namen worden gesynchroniseerd met de terminal. Deze maakt automatisch tabs aan voor deze klanten.', 'tabs.request_result': 'Sluitverzoek voor {name}: {status}',
'tabs.planned_add': 'Klantnaam toevoegen...', 'tabs.send_test': 'Testmail versturen',
'tabs.planned_empty': 'Geen geplande klanten. Voeg namen toe en ze worden gesynchroniseerd met de terminal.', 'tabs.test_sent': 'Testmail verstuurd',
'tabs.test_sent_desc': 'Verstuurd naar {email}',
'tabs.test_failed': 'Testmail mislukt',
'planned.title': 'Geplande klanten',
'planned.name_placeholder': 'Naam van de klant...',
'planned.email_placeholder': 'E-mail (optioneel)',
'planned.add': 'Klant toevoegen',
'planned.remove': '{name} verwijderen',
'planned.waiting_empty': 'Geen klanten die op de terminal wachten.',
'planned.added': 'Toegevoegd',
'planned.added_desc': '"{name}" krijgt een poef op de terminal',
'import.button': 'Importeren uit Excel', 'import.button': 'Importeren uit Excel',
'import.title': 'Klanten importeren', 'import.title': 'Klanten importeren',
'import.description': 'Upload de klantenlijst. Elke naam wordt een geplande klant en krijgt een rekening op de terminal.', 'import.description': 'Upload de klantenlijst. Elke naam wordt een geplande klant en krijgt een poef op de terminal.',
'import.choose_file': 'Kies een bestand', 'import.choose_file': 'Kies een bestand',
'import.empty_file': 'Het bestand bevat geen rijen.', 'import.empty_file': 'Het bestand bevat geen rijen.',
'import.unreadable': 'Dit bestand kon niet gelezen worden. Gebruik een .xlsx-, .xls- of .csv-bestand.', 'import.unreadable': 'Dit bestand kon niet gelezen worden. Gebruik een .xlsx-, .xls- of .csv-bestand.',
@@ -214,7 +301,7 @@ const translations: Record<Lang, Record<string, string>> = {
'import.more': 'en nog {count}…', 'import.more': 'en nog {count}…',
'import.mode': 'Bestaande klanten', 'import.mode': 'Bestaande klanten',
'import.mode_merge': 'Klanten die niet in het bestand staan behouden', '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.mode_replace': 'Klanten die niet in het bestand staan verwijderen (hun open poefs op de terminal blijven bestaan)',
'import.submit': '{count} klanten importeren', 'import.submit': '{count} klanten importeren',
'import.importing': 'Bezig met importeren...', 'import.importing': 'Bezig met importeren...',
'import.done': 'Import voltooid', 'import.done': 'Import voltooid',
@@ -223,23 +310,35 @@ const translations: Record<Lang, Record<string, string>> = {
'users.title': 'Gebruikers', 'users.title': 'Gebruikers',
'users.count': '{count} gebruiker', 'users.count': '{count} gebruiker',
'users.count_plural': '{count} gebruikers', 'users.count_plural': '{count} gebruikers',
'users.load_failed': 'Gebruikers konden niet geladen worden',
'users.empty': 'Geen gebruikers', 'users.empty': 'Geen gebruikers',
'users.empty_desc': 'Voeg gebruikers toe die tabs kunnen beheren vanaf dit dashboard.', 'users.empty_desc': 'Voeg gebruikers toe die poefs kunnen beheren via dit dashboard.',
'users.add': 'Gebruiker toevoegen', 'users.add': 'Gebruiker toevoegen',
'users.name': 'Naam', 'users.name': 'Naam',
'users.email': 'E-mail', 'users.email': 'E-mail',
'users.role': 'Rol', 'users.role': 'Rol',
'users.created': 'Aangemaakt', 'users.created': 'Aangemaakt',
'users.add_title': 'Gebruiker toevoegen', 'users.add_title': 'Gebruiker toevoegen',
'users.add_desc': 'Maak een nieuwe gebruiker aan die toegang heeft tot het dashboard.', 'users.add_desc': 'Maak een nieuwe gebruiker aan die toegang krijgt tot het dashboard.',
'users.name_placeholder': 'Naam van de medewerker',
'users.password': 'Wachtwoord', 'users.password': 'Wachtwoord',
'users.creating': 'Bezig...', 'users.password_placeholder': 'Minstens 6 tekens',
'users.role_viewer': 'Medewerker (kan poefs sluiten)',
'users.role_admin': 'Beheerder (volledige toegang)',
'users.role.viewer': 'Medewerker',
'users.role.admin': 'Beheerder',
'users.creating': 'Bezig met aanmaken...',
'users.create': 'Gebruiker aanmaken', 'users.create': 'Gebruiker aanmaken',
'users.created_toast': 'Gebruiker aangemaakt',
'users.created_toast_desc': '{email} toegevoegd als {role}',
'users.create_failed': 'Gebruiker aanmaken mislukt',
'commands.title': 'Opdrachtgeschiedenis', 'commands.title': 'Opdrachtgeschiedenis',
'commands.count': '{count} opdracht', 'commands.count': '{count} opdracht',
'commands.count_plural': '{count} opdrachten', 'commands.count_plural': '{count} opdrachten',
'commands.load_failed': 'Opdrachten konden niet geladen worden',
'commands.empty': 'Geen opdrachten gevonden', 'commands.empty': 'Geen opdrachten gevonden',
'commands.tab': 'Tab', 'commands.empty_desc': 'Opdrachten verschijnen hier zodra je een sluitverzoek verstuurt.',
'commands.tab': 'Poef',
'commands.requested': 'Aangevraagd door', 'commands.requested': 'Aangevraagd door',
'commands.payment': 'Betaling', 'commands.payment': 'Betaling',
'commands.created': 'Aangemaakt', 'commands.created': 'Aangemaakt',
@@ -251,46 +350,37 @@ const translations: Record<Lang, Record<string, string>> = {
'commands.next': 'Volgende', 'commands.next': 'Volgende',
'commands.page': 'Pagina {page} van {total}', 'commands.page': 'Pagina {page} van {total}',
'settings.title': 'Instellingen', 'settings.title': 'Instellingen',
'settings.desc': 'Locatie- en apparaatbeheer', 'settings.desc': 'Zaak en terminal',
'settings.venue': 'Locatie', 'settings.venue': 'Zaak',
'settings.device': 'Apparaat', 'settings.device': 'Terminal',
'settings.paired': 'Gekoppeld als "{name}" — laatst gezien {time}', 'settings.paired': 'Gekoppeld als "{name}" — laatst gezien {time}',
'settings.not_paired': 'Nog geen apparaat gekoppeld', 'settings.not_paired': 'Nog geen terminal gekoppeld',
'settings.connected': 'Verbonden', 'settings.connected': 'Verbonden',
'settings.since': 'Sinds {date}', 'settings.since': 'Sinds {date}',
'settings.pair_desc': 'Koppel een barterminal om tabs te ontvangen.', 'settings.pair_desc': 'Koppel een barterminal om poefs te ontvangen.',
'settings.pair': 'Apparaat koppelen', 'settings.pair': 'Terminal koppelen',
'settings.repair': 'Opnieuw koppelen', 'settings.repair': 'Terminal opnieuw koppelen',
'settings.pair_title': 'Apparaat koppelen', 'settings.generating': 'Code aanmaken...',
'settings.pair_instructions': 'Voer deze code in op de barterminal om deze te verbinden met uw locatie.', 'settings.pair_title': 'Terminal koppelen',
'settings.pair_instructions': 'Voer deze code in op de barterminal om hem met je zaak te verbinden.',
'settings.pair_code': 'Registratiecode', 'settings.pair_code': 'Registratiecode',
'settings.pair_expires': 'Verloopt om {time}', 'settings.pair_expires': 'Verloopt om {time}',
'settings.pair_step1': 'Open de barterminal app', 'settings.pair_steps': 'Stappen:',
'settings.pair_step2': 'Selecteer uw locatie uit de lijst', 'settings.pair_step1': 'Open de app op de barterminal',
'settings.pair_step2': 'Kies je zaak uit de lijst',
'settings.pair_step3': 'Voer deze registratiecode in', 'settings.pair_step3': 'Voer deze registratiecode in',
'settings.pair_step4': 'Geef de terminal een naam', 'settings.pair_step4': 'Geef de terminal een naam',
'settings.pair_step5': 'Voltooi de registratie', 'settings.pair_step5': 'Rond de registratie af',
'settings.copy': 'Code kopiëren', 'settings.copy': 'Code kopiëren',
'settings.close': 'Sluiten', 'settings.copied_desc': 'Registratiecode gekopieerd naar het klembord',
'settings.api': 'API',
'settings.base_url': 'Basis-URL',
'settings.swagger': 'Swagger',
'overview.title': 'Overzicht', 'overview.title': 'Overzicht',
'overview.desc': 'Real-time overzicht van uw locatie', 'overview.open_tabs': 'Open poefs',
'overview.open_tabs': 'Open Tabs', 'overview.device': 'Terminal',
'overview.device': 'Apparaat',
'overview.pending': 'Openstaande opdrachten', 'overview.pending': 'Openstaande opdrachten',
'overview.recent': 'Recente opdrachten', 'overview.recent': 'Recente opdrachten',
'overview.no_commands': 'Nog geen opdrachten', 'overview.no_commands': 'Nog geen opdrachten',
'overview.not_connected': 'Niet verbonden', 'overview.not_connected': 'Niet verbonden',
'overview.last_seen': 'Laatst gezien {time}', '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.login': 'Inloggen',
'auth.logging_in': 'Bezig met inloggen...', 'auth.logging_in': 'Bezig met inloggen...',
'auth.email': 'E-mail', 'auth.email': 'E-mail',
@@ -300,67 +390,110 @@ const translations: Record<Lang, Record<string, string>> = {
'lang.nl': 'Nederlands', 'lang.nl': 'Nederlands',
'lang.label': 'Taal', 'lang.label': 'Taal',
'reminders.title': 'Betalingsherinneringen', 'reminders.title': 'Betalingsherinneringen',
'reminders.desc': 'Stuur betalingsherinneringen naar klanten met openstaande tabs', 'reminders.desc': 'Stuur betalingsherinneringen naar klanten met een openstaande poef',
'reminders.candidates': 'In aanmerking voor herinnering', 'reminders.candidates': 'Komen in aanmerking',
'reminders.candidates_desc': 'Deze klanten hebben openstaande tabs en een e-mailadres. Herinneringen worden verzonden via Resend.', 'reminders.candidates_desc': 'Deze klanten hebben een openstaande poef en een gekend e-mailadres. Herinneringen worden verstuurd via Resend.',
'reminders.no_candidates': 'Geen geschikte klanten', 'reminders.no_candidates': 'Geen klanten die in aanmerking komen',
'reminders.no_candidates_desc': 'Zorg dat geplande klanten een e-mailadres hebben.', 'reminders.no_candidates_desc': 'Zorg dat geplande klanten een e-mailadres hebben.',
'reminders.send': 'Verstuur herinneringen', 'reminders.send': 'Herinneringen versturen',
'reminders.sending': 'Bezig met versturen...', 'reminders.sending': 'Bezig met versturen...',
'reminders.result': '{sent} van {total} herinnering(en) verzonden', 'reminders.confirm': 'Er wordt een betalingsherinnering gemaild naar {count} klant(en) met een openstaande poef.',
'reminders.sent_title': 'Herinneringen verzonden', 'reminders.sent_title': 'Herinneringen verstuurd',
'reminders.zero_sent': 'Geen herinneringen verzonden. Controleer of Resend is geconfigureerd.', 'reminders.partial': '{sent} van {total} verstuurd',
'reminders.success': '{sent} herinnering(en) succesvol verzonden', 'reminders.error_detail': 'Fout: {error}',
'reminders.history': 'Verzonden herinneringen', 'reminders.zero_sent': 'Er zijn geen herinneringen verstuurd. Controleer of Resend is ingesteld.',
'reminders.history_empty': 'Nog geen herinneringen verzonden', 'reminders.success': '{sent} herinnering(en) verstuurd',
'reminders.history': 'Verstuurde herinneringen',
'reminders.history_empty': 'Nog geen herinneringen verstuurd',
'reminders.customer': 'Klant', 'reminders.customer': 'Klant',
'reminders.email': 'E-mail', 'reminders.email': 'E-mail',
'reminders.amount': 'Bedrag', 'reminders.amount': 'Bedrag',
'reminders.sent_at': 'Verzonden op', 'reminders.sent_at': 'Verstuurd op',
'reminders.status': 'Status', 'reminders.status': 'Status',
'tabs.email': 'E-mail (optioneel)', 'reminders.status_sent': 'Verstuurd',
'tabs.planned_email_hint': 'Voeg e-mail toe om betalingsherinneringen in te schakelen',
}, },
}; };
const LOCALES: Record<Lang, string> = { en: 'en-GB', nl: 'nl-BE' };
type Params = Record<string, string | number>;
interface LangContextType { interface LangContextType {
lang: Lang; lang: Lang;
setLang: (lang: Lang) => void; setLang: (lang: Lang) => void;
t: (key: string, params?: Record<string, string | number>) => string; t: (key: string, params?: Params) => string;
/** Uses `<key>_plural` unless `count` is 1; `{count}` is filled in. */
tn: (key: string, count: number, params?: Params) => string;
/** "just now", "5m ago"…, falling back to a date after a week. */
relativeTime: (date: string | Date) => string;
dateTime: (date: string | Date) => string;
time: (date: string | Date) => string;
} }
const LangContext = createContext<LangContextType>({ function translate(lang: Lang, key: string, params?: Params): string {
lang: 'en', let s = translations[lang][key] ?? translations.en[key] ?? key;
setLang: () => {}, if (params) {
t: (key) => key, for (const [k, v] of Object.entries(params)) {
}); s = s.split(`{${k}}`).join(String(v));
}
}
return s;
}
function helpers(lang: Lang): Omit<LangContextType, 'setLang'> {
const t = (key: string, params?: Params) => translate(lang, key, params);
const locale = LOCALES[lang];
return {
lang,
t,
tn: (key, count, params) => t(count === 1 ? key : `${key}_plural`, { count, ...params }),
relativeTime: (value) => {
const date = new Date(value);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return t('time.just_now');
if (minutes < 60) return t('time.minutes_ago', { count: minutes });
if (hours < 24) return t('time.hours_ago', { count: hours });
if (days < 7) return t('time.days_ago', { count: days });
return date.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' });
},
dateTime: (value) =>
new Date(value).toLocaleString(locale, { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }),
time: (value) => new Date(value).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
};
}
const LangContext = createContext<LangContextType>({ ...helpers('en'), setLang: () => {} });
export function LanguageProvider({ children }: { children: ReactNode }) { export function LanguageProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>('en'); const [lang, setLangState] = useState<Lang>('en');
useEffect(() => { useEffect(() => {
const stored = localStorage.getItem('kooltap-lang') as Lang | null; try {
if (stored === 'en' || stored === 'nl') setLangState(stored); const stored = localStorage.getItem('kooltap-lang') as Lang | null;
if (stored === 'en' || stored === 'nl') setLangState(stored);
} catch {
// Storage unavailable (e.g. private mode): keep the default language
}
}, []); }, []);
useEffect(() => {
document.documentElement.lang = lang;
}, [lang]);
function setLang(l: Lang) { function setLang(l: Lang) {
setLangState(l); setLangState(l);
localStorage.setItem('kooltap-lang', l); try {
} localStorage.setItem('kooltap-lang', l);
} catch {
function t(key: string, params?: Record<string, string | number>): string { // Not persisted, but the choice still applies for this visit
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 ( return (
<LangContext.Provider value={{ lang, setLang, t }}> <LangContext.Provider value={{ ...helpers(lang), setLang }}>
{children} {children}
</LangContext.Provider> </LangContext.Provider>
); );
+1 -25
View File
@@ -6,30 +6,6 @@ export function formatCents(cents: number, currency: string = 'EUR'): string {
}).format(euros); }).format(euros);
} }
export function formatRelativeTime(dateString: string | Date): string { // Relative times and dates are language-dependent: use relativeTime/dateTime from useLang()
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short', year: 'numeric' });
}
export function formatDateTime(dateString: string | Date): string {
return new Date(dateString).toLocaleDateString('nl-NL', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export { cn } from './cn'; export { cn } from './cn';
File diff suppressed because one or more lines are too long