feat: implement real-time booking updates using Server-Sent Events (SSE) and add public booking URL sharing to the dashboard

This commit is contained in:
Jose Selesan
2026-04-17 17:25:46 -03:00
parent a075f13587
commit e0d755bef7
10 changed files with 226 additions and 8 deletions

View File

@@ -27,6 +27,7 @@ import {
import { zodResolver } from '@hookform/resolvers/zod';
import type { AdminBooking } from '@repo/api-contract';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, Copy } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -131,6 +132,7 @@ export function HomePage() {
const [selectedCourtId, setSelectedCourtId] = useState<string>('');
const [selectedStartTime, setSelectedStartTime] = useState<string>('');
const [isManualBookingOpen, setIsManualBookingOpen] = useState(false);
const [urlCopied, setUrlCopied] = useState(false);
useEffect(() => {
setCurrentComplexSlug(getCurrentComplexSlug());
@@ -162,6 +164,61 @@ export function HomePage() {
persistCurrentComplexSlug(selectedComplex.complexSlug);
}, [selectedComplex]);
useEffect(() => {
if (!selectedComplex?.id) return;
const channel = `complex-${selectedComplex.id}`;
let eventSource: EventSource | null = null;
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
const connect = () => {
if (eventSource) {
eventSource.close();
}
if (reconnectAttempts >= maxReconnectAttempts) {
console.log('[SSE] Max reconnect attempts reached');
return;
}
const url = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'}/api/events/${encodeURIComponent(channel)}`;
eventSource = new EventSource(url);
eventSource.onerror = () => {
if (eventSource?.readyState === EventSource.CLOSED) {
reconnectAttempts++;
const delay = Math.min(1000 * 2 ** reconnectAttempts, 30000);
console.log(
`[SSE] Connection closed, retry ${reconnectAttempts}/${maxReconnectAttempts} in ${delay}ms`
);
reconnectTimeout = setTimeout(() => {
connect();
}, delay);
}
};
eventSource.addEventListener('message', (event) => {
if (event.data && event.data !== 'connected' && event.data !== 'ping') {
reconnectAttempts = 0;
void queryClient.invalidateQueries({ queryKey: ['admin-bookings', selectedComplex.id] });
}
});
};
connect();
return () => {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
}
eventSource?.close();
};
}, [selectedComplex?.id, queryClient]);
const bookingsQuery = useQuery({
queryKey: ['admin-bookings', selectedComplex?.id, fromDate],
enabled: Boolean(selectedComplex?.id),
@@ -302,6 +359,18 @@ export function HomePage() {
await createManualBookingMutation.mutateAsync(values);
};
const publicBookingUrl = selectedComplex
? `${window.location.origin}/${selectedComplex.complexSlug}/booking`
: '';
const copyToClipboard = async () => {
await navigator.clipboard.writeText(publicBookingUrl);
setUrlCopied(true);
setTimeout(() => {
setUrlCopied(false);
}, 3000);
};
if (myComplexesQuery.isLoading) {
return (
<main className="mx-auto w-full max-w-6xl px-6 py-8">
@@ -337,6 +406,28 @@ export function HomePage() {
<p className="mt-2 text-sm text-muted-foreground">
Gestiona turnos del día y futuros para {selectedComplex.complexName}.
</p>
<div className="mt-2 flex items-center gap-2">
<a
href={publicBookingUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground underline hover:text-foreground"
>
{publicBookingUrl}
</a>
<button
type="button"
onClick={copyToClipboard}
className="text-muted-foreground hover:text-foreground"
title="Copiar URL"
>
{urlCopied ? (
<Check className="h-4 w-4 text-emerald-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
<ResponsiveDialog
open={isManualBookingOpen}