'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { Clock } from 'lucide-react'; import type { ReservationInfo } from 'brainerce'; import { useTranslations } from '@/core/lib/translations'; import { cn } from '@/core/lib/utils'; interface ReservationCountdownProps { reservation: ReservationInfo; /** * Fired once per reservation window when the timer reaches zero, including * when the page opens on an already-expired reservation. The handler is * expected to refresh the cart and gate checkout: this component only * displays, it never decides what is still purchasable. Wire it to * `useCartPage().onReservationExpired`. */ onExpire?: () => void; className?: string; } export function ReservationCountdown({ reservation, onExpire, className, }: ReservationCountdownProps) { const t = useTranslations('reservation'); // `null` means "not measured yet". Server-rendered HTML must not compute a // time, or it disagrees with the client on hydration; starting at 0 instead // would render the expired banner for one frame on a perfectly live // reservation, and would fire onExpire on every mount. const [remainingSeconds, setRemainingSeconds] = useState(null); const calculateRemaining = useCallback(() => { if (!reservation.expiresAt) return 0; const expiresAtMs = new Date(reservation.expiresAt).getTime(); const nowMs = Date.now(); return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000)); }, [reservation.expiresAt]); // Report each expiry window at most once. Keyed on `expiresAt` rather than a // boolean, so the cart refresh that expiry triggers (which remounts this // component) cannot bounce straight back into a second report. const expiresAt = reservation.expiresAt; const reportedExpiryRef = useRef(null); const onExpireRef = useRef(onExpire); // Kept in a ref, and synced in an effect rather than during render, so a // caller passing an inline arrow does not restart the timer every render. useEffect(() => { onExpireRef.current = onExpire; }, [onExpire]); const reportExpired = useCallback(() => { if (!expiresAt) return; if (reportedExpiryRef.current === expiresAt) return; reportedExpiryRef.current = expiresAt; onExpireRef.current?.(); }, [expiresAt]); useEffect(() => { const initial = calculateRemaining(); setRemainingSeconds(initial); if (initial <= 0) reportExpired(); const interval = setInterval(() => { const remaining = calculateRemaining(); setRemainingSeconds(remaining); if (remaining <= 0) { clearInterval(interval); reportExpired(); } }, 1000); return () => clearInterval(interval); }, [calculateRemaining, reportExpired]); if (!reservation.hasReservation) return null; const displaySeconds = remainingSeconds ?? 0; const minutes = Math.floor(displaySeconds / 60); const seconds = displaySeconds % 60; const isExpired = remainingSeconds !== null && remainingSeconds <= 0; const isUrgent = displaySeconds > 0 && displaySeconds < 120; const displayMessage = reservation.countdownMessage ? reservation.countdownMessage.replace( '{time}', `${minutes}:${seconds.toString().padStart(2, '0')}` ) : null; return (
); }