feat: add realtime
This commit is contained in:
@@ -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 || [];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user