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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user