feat: add realtime

This commit is contained in:
2026-10-08 04:11:44 +02:00
parent e5580767a5
commit b336580040
31 changed files with 756 additions and 131 deletions
+1
View File
@@ -31,6 +31,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.0",
"socket.io-client": "^4.8.4",
"tailwind-merge": "^2.2.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.22.4"
@@ -15,6 +15,7 @@ import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { formatRelativeTime, formatDateTime } from '@/lib/utils';
import { Filter } from 'lucide-react';
import { useRealtime } from '@/lib/realtime';
const statusFilters = [
{ value: '', label: 'All' },
@@ -26,6 +27,8 @@ const statusFilters = [
];
export default function CommandsPage() {
// Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime();
const router = useRouter();
const [venueId, setVenueId] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState('');
@@ -49,7 +52,7 @@ export default function CommandsPage() {
queryFn: () =>
api(`/venues/${venueId}/commands?limit=${limit}&offset=${page * limit}${statusFilter ? `&status=${statusFilter}` : ''}`),
enabled: !!venueId,
refetchInterval: 10000,
refetchInterval: live ? false : 10000,
});
const commands = data?.commands || [];
+98 -83
View File
@@ -20,6 +20,7 @@ import { useLang } from '@/lib/i18n';
import { LanguageSelector } from '@/components/language-selector';
import { authApi, api } from '@/lib/api/client';
import { toast } from '@/components/ui/toast';
import { RealtimeProvider, useRealtime } from '@/lib/realtime';
const navItems = [
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
@@ -78,91 +79,105 @@ export default function DashboardLayout({
}
return (
<div className="min-h-screen bg-background">
{/* Mobile sidebar toggle */}
<div className="lg:hidden fixed top-0 left-0 right-0 z-50 flex items-center justify-between h-14 px-4 border-b bg-background">
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2">
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
<span className="font-semibold">KoolTap</span>
<div className="w-9" />
</div>
{/* Sidebar overlay on mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={cn(
'fixed top-0 left-0 z-50 h-full w-64 border-r bg-card transition-transform lg:translate-x-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<div className="flex flex-col h-full">
<div className="p-6 border-b">
<h2 className="text-xl font-bold">KoolTap</h2>
{venue && (
<p className="text-sm text-muted-foreground mt-1">{venue.name}</p>
)}
</div>
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const active = pathname === item.href;
if (item.adminOnly && user?.role !== 'admin') {
return null;
}
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
active
? 'bg-primary text-primary-foreground'
: 'hover:bg-accent hover:text-accent-foreground',
)}
>
<Icon className="h-4 w-4" />
{t(item.key)}
</Link>
);
})}
</nav>
<div className="p-4 border-t">
<LanguageSelector />
{user && (
<div className="mb-3 text-sm">
<p className="font-medium truncate">{user.name}</p>
<p className="text-muted-foreground capitalize">{user.role}</p>
</div>
)}
<Button
variant="outline"
className="w-full justify-start gap-3"
onClick={handleLogout}
>
<LogOut className="h-4 w-4" />
{t('nav.logout')}
</Button>
</div>
<RealtimeProvider>
<div className="min-h-screen bg-background">
{/* Mobile sidebar toggle */}
<div className="lg:hidden fixed top-0 left-0 right-0 z-50 flex items-center justify-between h-14 px-4 border-b bg-background">
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="p-2">
{sidebarOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
<span className="font-semibold">KoolTap</span>
<div className="w-9" />
</div>
</aside>
{/* Main content */}
<main className="lg:pl-64 pt-14 lg:pt-0">
<div className="p-6 max-w-7xl mx-auto">{children}</div>
</main>
{/* Sidebar overlay on mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={cn(
'fixed top-0 left-0 z-50 h-full w-64 border-r bg-card transition-transform lg:translate-x-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<div className="flex flex-col h-full">
<div className="p-6 border-b">
<h2 className="text-xl font-bold">KoolTap</h2>
{venue && (
<p className="text-sm text-muted-foreground mt-1">{venue.name}</p>
)}
</div>
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const active = pathname === item.href;
if (item.adminOnly && user?.role !== 'admin') {
return null;
}
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors',
active
? 'bg-primary text-primary-foreground'
: 'hover:bg-accent hover:text-accent-foreground',
)}
>
<Icon className="h-4 w-4" />
{t(item.key)}
</Link>
);
})}
</nav>
<div className="p-4 border-t">
<LiveIndicator />
<LanguageSelector />
{user && (
<div className="mb-3 text-sm">
<p className="font-medium truncate">{user.name}</p>
<p className="text-muted-foreground capitalize">{user.role}</p>
</div>
)}
<Button
variant="outline"
className="w-full justify-start gap-3"
onClick={handleLogout}
>
<LogOut className="h-4 w-4" />
{t('nav.logout')}
</Button>
</div>
</div>
</aside>
{/* Main content */}
<main className="lg:pl-64 pt-14 lg:pt-0">
<div className="p-6 max-w-7xl mx-auto">{children}</div>
</main>
</div>
</RealtimeProvider>
);
}
function LiveIndicator() {
const { connected } = useRealtime();
const { t } = useLang();
return (
<div className="flex items-center gap-2 mb-3 text-xs text-muted-foreground">
<span className={cn('h-2 w-2 rounded-full', connected ? 'bg-green-500' : 'bg-muted-foreground/40')} />
{connected ? t('realtime.live') : t('realtime.offline')}
</div>
);
}
+7 -3
View File
@@ -11,8 +11,11 @@ import { LoadingState } from '@/components/loading-state';
import { CommandStatusBadge } from '@/components/command-status-badge';
import { formatRelativeTime } from '@/lib/utils';
import { ClipboardList, Terminal, Monitor } from 'lucide-react';
import { useRealtime } from '@/lib/realtime';
export default function DashboardPage() {
// Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime();
const router = useRouter();
const [venueId, setVenueId] = useState<string | null>(null);
@@ -32,13 +35,14 @@ export default function DashboardPage() {
queryKey: ['tabs', venueId],
queryFn: () => api(`/venues/${venueId}/tabs`),
enabled: !!venueId,
refetchInterval: 10000,
refetchInterval: live ? false : 10000,
});
const deviceQuery = useQuery({
queryKey: ['device', venueId],
queryFn: () => api(`/venues/${venueId}/devices`),
enabled: !!venueId,
// A terminal going offline sends no event, so its status is always polled
refetchInterval: 30000,
});
@@ -46,7 +50,7 @@ export default function DashboardPage() {
queryKey: ['commands', venueId, 'recent'],
queryFn: () => api(`/venues/${venueId}/commands?limit=5`),
enabled: !!venueId,
refetchInterval: 15000,
refetchInterval: live ? false : 15000,
});
if (!venueId) return <LoadingState />;
@@ -60,7 +64,7 @@ export default function DashboardPage() {
return (
<div>
<PageHeader title="Overview" description="Real-time overview of your venue" />
<PageHeader title="Overview" description="" />
{isLoading ? (
<div className="grid gap-4 md:grid-cols-3 mb-8">
@@ -130,16 +130,12 @@ export default function RemindersPage() {
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-4">
{t('reminders.candidates_desc')}
</p>
{candidatesQuery.isLoading ? (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
) : candidatesQuery.error ? (
<p className="text-sm text-destructive">{t('common.error')}</p>
) : candidates.length === 0 ? (
<EmptyState title={t('reminders.no_candidates')} description={t('reminders.no_candidates_desc')} />
<EmptyState title={t('reminders.no_candidates')} description='' />
) : (
<>
<div className="rounded-md border mb-4">
@@ -38,6 +38,7 @@ export default function SettingsPage() {
queryKey: ['device', venueId],
queryFn: () => devicesApi.get(venueId!),
enabled: !!venueId,
// A terminal going offline sends no event, so its status is always polled
refetchInterval: 15000,
});
+34 -9
View File
@@ -20,8 +20,11 @@ import { formatCents, formatRelativeTime } from '@/lib/utils';
import { RefreshCw, Plus, X, Users, Mail, Send, Upload } from 'lucide-react';
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
import { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime';
export default function TabsPage() {
// Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime();
const router = useRouter();
const queryClient = useQueryClient();
const [venueId, setVenueId] = useState<string | null>(null);
@@ -47,22 +50,26 @@ export default function TabsPage() {
queryKey: ['tabs', venueId],
queryFn: () => api(`/venues/${venueId}/tabs`),
enabled: !!venueId,
refetchInterval: 10000,
refetchInterval: live ? false : 10000,
});
const commandsQuery = useQuery({
queryKey: ['commands', venueId, 'pending'],
queryFn: () => api(`/venues/${venueId}/commands?status=pending`),
enabled: !!venueId && pendingCommandIds.size > 0,
refetchInterval: 3000,
refetchInterval: live ? false : 3000,
});
const plannedQuery = useQuery({
queryKey: ['planned-tabs', venueId],
queryFn: () => plannedTabsApi.list(venueId!),
enabled: !!venueId,
refetchInterval: live ? false : 10000,
});
// Tiles only show customers the terminal has not opened a tab for yet
const pendingPlanned = (plannedQuery.data?.plannedTabs || []).filter((pt: any) => !pt.deliveredAt);
useEffect(() => {
if (commandsQuery.data?.commands) {
for (const cmd of commandsQuery.data.commands) {
@@ -158,9 +165,6 @@ export default function TabsPage() {
</div>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
These names are synced to the terminal. It will create tabs for these customers automatically.
</p>
{userRole === 'admin' && (
<div className="flex gap-2 mb-3 flex-wrap">
<Input
@@ -184,10 +188,10 @@ export default function TabsPage() {
</div>
)}
<div className="flex flex-wrap gap-2">
{plannedQuery.data?.plannedTabs?.length === 0 && (
<p className="text-sm text-muted-foreground">No planned customers. Add names above and they'll sync to the terminal.</p>
{plannedQuery.data && pendingPlanned.length === 0 && (
<p className="text-sm text-muted-foreground">No customers waiting for the terminal.</p>
)}
{(plannedQuery.data?.plannedTabs || []).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">
{pt.customerName}
{pt.email && <Mail className="h-3 w-3 text-muted-foreground" />}
@@ -213,13 +217,14 @@ export default function TabsPage() {
</Card>
{tabs.length === 0 ? (
<EmptyState title="No open tabs" description="Open tabs will appear here when the terminal syncs." />
<EmptyState title="No open tabs" description="" />
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead>Email</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Items</TableHead>
<TableHead>Version</TableHead>
@@ -231,6 +236,26 @@ export default function TabsPage() {
{tabs.map((tab: any) => (
<TableRow key={tab.tabId}>
<TableCell className="font-medium">{tab.customerName}</TableCell>
<TableCell>
{tab.email ? (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
<span className="truncate max-w-[200px]">{tab.email}</span>
{userRole === 'admin' && tab.plannedTabId && (
<button
onClick={() => sendTestMutation.mutate(tab.plannedTabId)}
disabled={sendTestMutation.isPending}
className="hover:text-primary"
title="Send test email"
>
<Send className="h-3 w-3" />
</button>
)}
</div>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>{formatCents(tab.totalInCents)}</TableCell>
<TableCell>{tab.itemCount}</TableCell>
<TableCell>{tab.version}</TableCell>
-3
View File
@@ -36,7 +36,6 @@ export default function LoginPage() {
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">KoolTap</CardTitle>
<CardDescription>Remote Tab Control Dashboard</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
@@ -45,7 +44,6 @@ export default function LoginPage() {
<Input
id="email"
type="email"
placeholder="admin@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
@@ -57,7 +55,6 @@ export default function LoginPage() {
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
+6 -2
View File
@@ -1,4 +1,4 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
interface ApiError {
code: string;
@@ -208,4 +208,8 @@ export const remindersApi = {
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
};
export { setTokens, clearTokens };
export { setTokens, clearTokens, refreshAccessToken };
export function getAccessToken(): string | null {
return getTokens().accessToken ?? null;
}
+4
View File
@@ -11,6 +11,8 @@ const translations: Record<Lang, Record<string, string>> = {
'nav.commands': 'Command History',
'nav.settings': 'Settings',
'nav.logout': 'Logout',
'realtime.live': 'Live updates on',
'realtime.offline': 'Live updates unavailable — refreshing periodically',
'tabs.title': 'Open Tabs',
'tabs.count': '{count} open tab',
'tabs.count_plural': '{count} open tabs',
@@ -168,6 +170,8 @@ const translations: Record<Lang, Record<string, string>> = {
'nav.commands': 'Opdrachten',
'nav.settings': 'Instellingen',
'nav.logout': 'Uitloggen',
'realtime.live': 'Live updates aan',
'realtime.offline': 'Live updates niet beschikbaar — wordt periodiek vernieuwd',
'tabs.title': 'Open Tabs',
'tabs.count': '{count} open tab',
'tabs.count_plural': '{count} open tabs',
+79
View File
@@ -0,0 +1,79 @@
'use client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { io } from 'socket.io-client';
import { API_URL, getAccessToken, refreshAccessToken } from '@/lib/api/client';
// Server topics mapped to the dashboard queries that show that data
const TOPIC_QUERIES: Record<string, string[][]> = {
tabs: [['tabs'], ['reminder-candidates']],
commands: [['commands']],
'planned-tabs': [['planned-tabs'], ['reminder-candidates']],
device: [['device']],
reminders: [['reminder-candidates'], ['reminder-history']],
users: [['users']],
};
const RealtimeContext = createContext({ connected: false });
export function RealtimeProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const [connected, setConnected] = useState(false);
useEffect(() => {
const socket = io(`${new URL(API_URL).origin}/realtime`, {
// Read on every (re)connect so a refreshed token is used
auth: (cb) => cb({ token: getAccessToken() }),
transports: ['websocket', 'polling'],
});
let hasConnected = false;
let refreshing = false;
let rejections = 0;
socket.on('connect', () => {
setConnected(true);
rejections = 0;
// Changes may have been missed while disconnected
if (hasConnected) queryClient.invalidateQueries();
hasConnected = true;
});
socket.on('disconnect', () => setConnected(false));
socket.on('invalidate', ({ topics }: { topics: string[] }) => {
for (const topic of topics) {
for (const queryKey of TOPIC_QUERIES[topic] ?? []) {
queryClient.invalidateQueries({ queryKey });
}
}
});
// The access token expired: refresh it and reconnect (rejected handshakes are not retried
// automatically). If refreshing fails, the next API call sends the user to the login page.
socket.on('connect_error', async (error) => {
if (error.message !== 'unauthorized' || refreshing || ++rejections > 3) return;
refreshing = true;
const token = await refreshAccessToken();
refreshing = false;
if (token) socket.connect();
});
return () => {
socket.disconnect();
};
}, [queryClient]);
return <RealtimeContext.Provider value={{ connected }}>{children}</RealtimeContext.Provider>;
}
export function useRealtime() {
return useContext(RealtimeContext);
}
/** Polling interval to use only while live updates are unavailable. */
export function usePollingFallback(intervalMs: number): number | false {
const { connected } = useRealtime();
return connected ? false : intervalMs;
}
File diff suppressed because one or more lines are too long