create-brainerce-store 1.71.0 → 1.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import { Clock } from 'lucide-react';
5
5
  import type { ReservationInfo } from 'brainerce';
6
6
  import { useTranslations } from '@/core/lib/translations';
@@ -8,12 +8,28 @@ import { cn } from '@/core/lib/utils';
8
8
 
9
9
  interface ReservationCountdownProps {
10
10
  reservation: ReservationInfo;
11
+ /**
12
+ * Fired once per reservation window when the timer reaches zero, including
13
+ * when the page opens on an already-expired reservation. The handler is
14
+ * expected to refresh the cart and gate checkout: this component only
15
+ * displays, it never decides what is still purchasable. Wire it to
16
+ * `useCartPage().onReservationExpired`.
17
+ */
18
+ onExpire?: () => void;
11
19
  className?: string;
12
20
  }
13
21
 
14
- export function ReservationCountdown({ reservation, className }: ReservationCountdownProps) {
22
+ export function ReservationCountdown({
23
+ reservation,
24
+ onExpire,
25
+ className,
26
+ }: ReservationCountdownProps) {
15
27
  const t = useTranslations('reservation');
16
- const [remainingSeconds, setRemainingSeconds] = useState<number>(0);
28
+ // `null` means "not measured yet". Server-rendered HTML must not compute a
29
+ // time, or it disagrees with the client on hydration; starting at 0 instead
30
+ // would render the expired banner for one frame on a perfectly live
31
+ // reservation, and would fire onExpire on every mount.
32
+ const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
17
33
 
18
34
  const calculateRemaining = useCallback(() => {
19
35
  if (!reservation.expiresAt) return 0;
@@ -22,8 +38,29 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
22
38
  return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
23
39
  }, [reservation.expiresAt]);
24
40
 
41
+ // Report each expiry window at most once. Keyed on `expiresAt` rather than a
42
+ // boolean, so the cart refresh that expiry triggers (which remounts this
43
+ // component) cannot bounce straight back into a second report.
44
+ const expiresAt = reservation.expiresAt;
45
+ const reportedExpiryRef = useRef<string | null>(null);
46
+ const onExpireRef = useRef(onExpire);
47
+ // Kept in a ref, and synced in an effect rather than during render, so a
48
+ // caller passing an inline arrow does not restart the timer every render.
25
49
  useEffect(() => {
26
- setRemainingSeconds(calculateRemaining());
50
+ onExpireRef.current = onExpire;
51
+ }, [onExpire]);
52
+
53
+ const reportExpired = useCallback(() => {
54
+ if (!expiresAt) return;
55
+ if (reportedExpiryRef.current === expiresAt) return;
56
+ reportedExpiryRef.current = expiresAt;
57
+ onExpireRef.current?.();
58
+ }, [expiresAt]);
59
+
60
+ useEffect(() => {
61
+ const initial = calculateRemaining();
62
+ setRemainingSeconds(initial);
63
+ if (initial <= 0) reportExpired();
27
64
 
28
65
  const interval = setInterval(() => {
29
66
  const remaining = calculateRemaining();
@@ -31,18 +68,20 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
31
68
 
32
69
  if (remaining <= 0) {
33
70
  clearInterval(interval);
71
+ reportExpired();
34
72
  }
35
73
  }, 1000);
36
74
 
37
75
  return () => clearInterval(interval);
38
- }, [calculateRemaining]);
76
+ }, [calculateRemaining, reportExpired]);
39
77
 
40
78
  if (!reservation.hasReservation) return null;
41
79
 
42
- const minutes = Math.floor(remainingSeconds / 60);
43
- const seconds = remainingSeconds % 60;
44
- const isExpired = remainingSeconds <= 0;
45
- const isUrgent = remainingSeconds > 0 && remainingSeconds < 120;
80
+ const displaySeconds = remainingSeconds ?? 0;
81
+ const minutes = Math.floor(displaySeconds / 60);
82
+ const seconds = displaySeconds % 60;
83
+ const isExpired = remainingSeconds !== null && remainingSeconds <= 0;
84
+ const isUrgent = displaySeconds > 0 && displaySeconds < 120;
46
85
 
47
86
  const displayMessage = reservation.countdownMessage
48
87
  ? reservation.countdownMessage.replace(
@@ -77,7 +116,10 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
77
116
 
78
117
  <div className="flex-1">
79
118
  {isExpired ? (
80
- <p className="font-medium">{t('expired')}</p>
119
+ <>
120
+ <p className="font-medium">{t('expired')}</p>
121
+ <p className="text-xs opacity-90">{t('expiredHint')}</p>
122
+ </>
81
123
  ) : displayMessage ? (
82
124
  <p>{displayMessage}</p>
83
125
  ) : (
@@ -147,9 +147,27 @@ export function CartDrawer() {
147
147
  )}
148
148
  <p className="text-muted-foreground mt-1 text-xs">{t('shippingAtCheckout')}</p>
149
149
  <div className="mt-4 grid gap-2">
150
- <Link href="/checkout" onClick={close} className="btn-primary btn-lg w-full">
151
- {t('proceedToCheckout')}
152
- </Link>
150
+ {/* Same gate as the cart page: a line the server marked
151
+ unavailable blocks checkout. A disabled-looking anchor
152
+ would still navigate, so use a real disabled button. */}
153
+ {items.some((item) => item.isAvailable === false) ? (
154
+ <>
155
+ <button
156
+ type="button"
157
+ disabled
158
+ className="btn-primary btn-lg w-full opacity-50"
159
+ >
160
+ {t('proceedToCheckout')}
161
+ </button>
162
+ <p className="text-destructive text-center text-xs">
163
+ {t('unavailableItemsHint')}
164
+ </p>
165
+ </>
166
+ ) : (
167
+ <Link href="/checkout" onClick={close} className="btn-primary btn-lg w-full">
168
+ {t('proceedToCheckout')}
169
+ </Link>
170
+ )}
153
171
  <Link href="/cart" onClick={close} className="btn-outline btn-lg w-full">
154
172
  {t('viewCart')}
155
173
  </Link>
@@ -34,6 +34,17 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
34
34
  const unitPrice = parseFloat(item.unitPrice);
35
35
  const lineTotal = unitPrice * item.quantity;
36
36
 
37
+ // The server decides purchasability, per line. `isAvailable === false` means
38
+ // this line blocks checkout until it is removed, whether the stock ran out
39
+ // (a released reservation is the common cause) or the product/variant was
40
+ // withdrawn from sale. `unavailableReason` separates the two for the label.
41
+ const isUnavailable = item.isAvailable === false;
42
+ const unavailableLabel = isUnavailable
43
+ ? item.unavailableReason === 'OUT_OF_STOCK'
44
+ ? td('outOfStock')
45
+ : td('unavailable')
46
+ : null;
47
+
37
48
  async function handleQuantityChange(newQuantity: number) {
38
49
  if (newQuantity < 1 || updating) return;
39
50
 
@@ -81,6 +92,13 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
81
92
  {productName}
82
93
  </h3>
83
94
 
95
+ {/* Availability badge. This line blocks checkout while it shows. */}
96
+ {unavailableLabel && (
97
+ <span className="mt-1 inline-flex items-center rounded-full bg-destructive/10 px-2 py-0.5 text-xs font-medium text-destructive">
98
+ {unavailableLabel}
99
+ </span>
100
+ )}
101
+
84
102
  {/* Variant name */}
85
103
  {variantName && <p className="mt-0.5 text-xs text-muted-foreground">{variantName}</p>}
86
104
 
@@ -111,7 +129,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
111
129
  <button
112
130
  type="button"
113
131
  onClick={() => handleQuantityChange(item.quantity + 1)}
114
- disabled={updating}
132
+ disabled={updating || isUnavailable}
115
133
  aria-label={td('increaseQuantity')}
116
134
  className="flex h-full w-9 items-center justify-center rounded-e-full transition-colors hover:bg-secondary disabled:opacity-40"
117
135
  >
@@ -18,7 +18,20 @@ import { IconBag, IconArrowEnd, IconShield } from '@/ui/shared/icons';
18
18
  export function CartView() {
19
19
  const t = useTranslations('cart');
20
20
  const tc = useTranslations('common');
21
- const { cart, cartLoading, refreshCart, itemCount, cartRecs, upgrades, bundles } = useCartPage();
21
+ const tr = useTranslations('reservation');
22
+ const {
23
+ cart,
24
+ cartLoading,
25
+ refreshCart,
26
+ itemCount,
27
+ cartRecs,
28
+ upgrades,
29
+ bundles,
30
+ reservationExpired,
31
+ unavailableItems,
32
+ canProceedToCheckout,
33
+ onReservationExpired,
34
+ } = useCartPage();
22
35
 
23
36
  if (cartLoading) {
24
37
  return <LoadingSpinner size="lg" className="min-h-[60vh]" />;
@@ -52,9 +65,14 @@ export function CartView() {
52
65
  </span>
53
66
  </h1>
54
67
 
55
- {/* Reservation countdown */}
68
+ {/* Reservation countdown. onExpire refreshes the cart and closes the
69
+ checkout gate below. Passing it is what makes expiry mean anything. */}
56
70
  {cart.reservation?.hasReservation && (
57
- <ReservationCountdown reservation={cart.reservation} className="mt-4" />
71
+ <ReservationCountdown
72
+ reservation={cart.reservation}
73
+ onExpire={onReservationExpired}
74
+ className="mt-4"
75
+ />
58
76
  )}
59
77
 
60
78
  <div className="mt-6 grid grid-cols-1 items-start gap-8 lg:grid-cols-3 lg:gap-10">
@@ -112,10 +130,29 @@ export function CartView() {
112
130
  <FreeShippingBar />
113
131
  <CartSummary />
114
132
 
115
- <Link href="/checkout" className="btn-primary btn-lg w-full">
116
- {t('proceedToCheckout')}
117
- <IconArrowEnd size={20} className="rtl-flip" />
118
- </Link>
133
+ {/* Proceed to checkout. When the gate is closed this must be a
134
+ real disabled control, never a styled-down link: an anchor
135
+ ignores `disabled` and would still navigate. */}
136
+ {canProceedToCheckout ? (
137
+ <Link href="/checkout" className="btn-primary btn-lg w-full">
138
+ {t('proceedToCheckout')}
139
+ <IconArrowEnd size={20} className="rtl-flip" />
140
+ </Link>
141
+ ) : (
142
+ <div>
143
+ <button type="button" disabled className="btn-primary btn-lg w-full opacity-50">
144
+ {t('proceedToCheckout')}
145
+ <IconArrowEnd size={20} className="rtl-flip" />
146
+ </button>
147
+ <p className="mt-2 text-center text-xs text-destructive">
148
+ {unavailableItems.length > 0
149
+ ? t('unavailableItemsHint')
150
+ : reservationExpired
151
+ ? tr('expiredHint')
152
+ : null}
153
+ </p>
154
+ </div>
155
+ )}
119
156
 
120
157
  <p className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
121
158
  <IconShield size={16} />
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import type { ReservationInfo } from 'brainerce';
5
5
  import { useTranslations } from '@/core/lib/translations';
6
6
  import { cn } from '@/core/lib/utils';
@@ -8,6 +8,14 @@ import { IconClock } from '@/ui/shared/icons';
8
8
 
9
9
  interface ReservationCountdownProps {
10
10
  reservation: ReservationInfo;
11
+ /**
12
+ * Fired once per reservation window when the timer reaches zero, including
13
+ * when the page opens on an already-expired reservation. Wired to
14
+ * `useCartPage().onReservationExpired`, which refreshes the cart and blocks
15
+ * checkout. This component only displays; it never decides what is still
16
+ * purchasable.
17
+ */
18
+ onExpire?: () => void;
11
19
  className?: string;
12
20
  }
13
21
 
@@ -16,9 +24,17 @@ interface ReservationCountdownProps {
16
24
  * prop comes from useCartPage().cart.reservation. Keeps the ticking logic
17
25
  * and the expired/urgent states (exposed via data-state).
18
26
  */
19
- export function ReservationCountdown({ reservation, className }: ReservationCountdownProps) {
27
+ export function ReservationCountdown({
28
+ reservation,
29
+ onExpire,
30
+ className,
31
+ }: ReservationCountdownProps) {
20
32
  const t = useTranslations('reservation');
21
- const [remainingSeconds, setRemainingSeconds] = useState<number>(0);
33
+ // `null` means "not measured yet". Server-rendered HTML must not compute a
34
+ // time, or it disagrees with the client on hydration; starting at 0 instead
35
+ // would render the expired state for one frame on a perfectly live
36
+ // reservation, and would fire onExpire on every mount.
37
+ const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
22
38
 
23
39
  const calculateRemaining = useCallback(() => {
24
40
  if (!reservation.expiresAt) return 0;
@@ -27,8 +43,29 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
27
43
  return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
28
44
  }, [reservation.expiresAt]);
29
45
 
46
+ // Report each expiry window at most once. Keyed on `expiresAt` rather than a
47
+ // boolean, so the cart refresh that expiry triggers (which remounts this
48
+ // component) cannot bounce straight back into a second report.
49
+ const expiresAt = reservation.expiresAt;
50
+ const reportedExpiryRef = useRef<string | null>(null);
51
+ const onExpireRef = useRef(onExpire);
52
+ // Kept in a ref, and synced in an effect rather than during render, so a
53
+ // caller passing an inline arrow does not restart the timer every render.
30
54
  useEffect(() => {
31
- setRemainingSeconds(calculateRemaining());
55
+ onExpireRef.current = onExpire;
56
+ }, [onExpire]);
57
+
58
+ const reportExpired = useCallback(() => {
59
+ if (!expiresAt) return;
60
+ if (reportedExpiryRef.current === expiresAt) return;
61
+ reportedExpiryRef.current = expiresAt;
62
+ onExpireRef.current?.();
63
+ }, [expiresAt]);
64
+
65
+ useEffect(() => {
66
+ const initial = calculateRemaining();
67
+ setRemainingSeconds(initial);
68
+ if (initial <= 0) reportExpired();
32
69
 
33
70
  const interval = setInterval(() => {
34
71
  const remaining = calculateRemaining();
@@ -36,18 +73,20 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
36
73
 
37
74
  if (remaining <= 0) {
38
75
  clearInterval(interval);
76
+ reportExpired();
39
77
  }
40
78
  }, 1000);
41
79
 
42
80
  return () => clearInterval(interval);
43
- }, [calculateRemaining]);
81
+ }, [calculateRemaining, reportExpired]);
44
82
 
45
83
  if (!reservation.hasReservation) return null;
46
84
 
47
- const minutes = Math.floor(remainingSeconds / 60);
48
- const seconds = remainingSeconds % 60;
49
- const isExpired = remainingSeconds <= 0;
50
- const isUrgent = remainingSeconds > 0 && remainingSeconds < 120;
85
+ const displaySeconds = remainingSeconds ?? 0;
86
+ const minutes = Math.floor(displaySeconds / 60);
87
+ const seconds = displaySeconds % 60;
88
+ const isExpired = remainingSeconds !== null && remainingSeconds <= 0;
89
+ const isUrgent = displaySeconds > 0 && displaySeconds < 120;
51
90
 
52
91
  const displayMessage = reservation.countdownMessage
53
92
  ? reservation.countdownMessage.replace(
@@ -72,7 +111,10 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
72
111
  <IconClock size={18} />
73
112
  </span>
74
113
  {isExpired ? (
75
- <p>{t('expired')}</p>
114
+ <div>
115
+ <p>{t('expired')}</p>
116
+ <p className="text-xs font-normal opacity-90">{t('expiredHint')}</p>
117
+ </div>
76
118
  ) : displayMessage ? (
77
119
  <p>{displayMessage}</p>
78
120
  ) : (
@@ -35,6 +35,17 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
35
35
  const unitPrice = parseFloat(item.unitPrice);
36
36
  const lineTotal = unitPrice * item.quantity;
37
37
 
38
+ // The server decides purchasability, per line. `isAvailable === false` means
39
+ // this line blocks checkout until it is removed, whether the stock ran out
40
+ // (a released reservation is the common cause) or the product/variant was
41
+ // withdrawn from sale. Keep the badge when you redesign this line.
42
+ const isUnavailable = item.isAvailable === false;
43
+ const unavailableLabel = isUnavailable
44
+ ? item.unavailableReason === 'OUT_OF_STOCK'
45
+ ? td('outOfStock')
46
+ : td('unavailable')
47
+ : null;
48
+
38
49
  async function handleQuantityChange(newQuantity: number) {
39
50
  if (newQuantity < 1 || updating) return;
40
51
 
@@ -80,6 +91,9 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
80
91
  <div>
81
92
  <h3>{productName}</h3>
82
93
 
94
+ {/* Availability badge. This line blocks checkout while it shows. */}
95
+ {unavailableLabel && <span data-state="unavailable">{unavailableLabel}</span>}
96
+
83
97
  {/* Variant name */}
84
98
  {variantName && <p>{variantName}</p>}
85
99
 
@@ -103,7 +117,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
103
117
  <button
104
118
  type="button"
105
119
  onClick={() => handleQuantityChange(item.quantity + 1)}
106
- disabled={updating}
120
+ disabled={updating || isUnavailable}
107
121
  aria-label={td('increaseQuantity')}
108
122
  >
109
123
  +
@@ -17,7 +17,20 @@ import { useTranslations } from '@/core/lib/translations';
17
17
  export function CartView() {
18
18
  const t = useTranslations('cart');
19
19
  const tc = useTranslations('common');
20
- const { cart, cartLoading, refreshCart, itemCount, cartRecs, upgrades, bundles } = useCartPage();
20
+ const tr = useTranslations('reservation');
21
+ const {
22
+ cart,
23
+ cartLoading,
24
+ refreshCart,
25
+ itemCount,
26
+ cartRecs,
27
+ upgrades,
28
+ bundles,
29
+ reservationExpired,
30
+ unavailableItems,
31
+ canProceedToCheckout,
32
+ onReservationExpired,
33
+ } = useCartPage();
21
34
 
22
35
  if (cartLoading) {
23
36
  return <LoadingSpinner size="lg" />;
@@ -42,8 +55,11 @@ export function CartView() {
42
55
  {t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
43
56
  </h1>
44
57
 
45
- {/* Reservation countdown */}
46
- {cart.reservation?.hasReservation && <ReservationCountdown reservation={cart.reservation} />}
58
+ {/* Reservation countdown. onExpire refreshes the cart and closes the
59
+ checkout gate below. Passing it is what makes expiry mean anything. */}
60
+ {cart.reservation?.hasReservation && (
61
+ <ReservationCountdown reservation={cart.reservation} onExpire={onReservationExpired} />
62
+ )}
47
63
 
48
64
  <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
49
65
  {/* Cart Items */}
@@ -91,7 +107,25 @@ export function CartView() {
91
107
  <FreeShippingBar />
92
108
  <CartSummary />
93
109
 
94
- <Link href="/checkout">{t('proceedToCheckout')}</Link>
110
+ {/* Proceed to checkout. When the gate is closed this must be a real
111
+ disabled control, never a styled-down link: an anchor ignores
112
+ `disabled` and would still navigate. */}
113
+ {canProceedToCheckout ? (
114
+ <Link href="/checkout">{t('proceedToCheckout')}</Link>
115
+ ) : (
116
+ <>
117
+ <button type="button" disabled>
118
+ {t('proceedToCheckout')}
119
+ </button>
120
+ <p data-state="blocked">
121
+ {unavailableItems.length > 0
122
+ ? t('unavailableItemsHint')
123
+ : reservationExpired
124
+ ? tr('expiredHint')
125
+ : null}
126
+ </p>
127
+ </>
128
+ )}
95
129
 
96
130
  <Link href="/products">{tc('continueShopping')}</Link>
97
131
  </aside>
@@ -1,25 +1,42 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import type { ReservationInfo } from 'brainerce';
5
5
  import { useTranslations } from '@/core/lib/translations';
6
6
  import { cn } from '@/core/lib/utils';
7
7
 
8
8
  interface ReservationCountdownProps {
9
9
  reservation: ReservationInfo;
10
+ /**
11
+ * Fired once per reservation window when the timer reaches zero, including
12
+ * when the page opens on an already-expired reservation. Wired to
13
+ * `useCartPage().onReservationExpired`, which refreshes the cart and blocks
14
+ * checkout. Keep the prop when you redesign this: the gate lives in the
15
+ * hook, but nothing tells it about the expiry unless this still calls it.
16
+ */
17
+ onExpire?: () => void;
10
18
  className?: string;
11
19
  }
12
20
 
13
21
  /**
14
22
  * DESIGN ME — stock-reservation countdown on the cart/checkout pages; the
15
23
  * `reservation` prop comes from useCartPage().cart.reservation. Keep the
16
- * ticking logic and the expired/urgent states (exposed via data-state).
24
+ * ticking logic, the `onExpire` call and the expired/urgent states (exposed
25
+ * via data-state).
17
26
  *
18
27
  * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
19
28
  */
20
- export function ReservationCountdown({ reservation, className }: ReservationCountdownProps) {
29
+ export function ReservationCountdown({
30
+ reservation,
31
+ onExpire,
32
+ className,
33
+ }: ReservationCountdownProps) {
21
34
  const t = useTranslations('reservation');
22
- const [remainingSeconds, setRemainingSeconds] = useState<number>(0);
35
+ // `null` means "not measured yet". Server-rendered HTML must not compute a
36
+ // time, or it disagrees with the client on hydration; starting at 0 instead
37
+ // would render the expired state for one frame on a perfectly live
38
+ // reservation, and would fire onExpire on every mount.
39
+ const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
23
40
 
24
41
  const calculateRemaining = useCallback(() => {
25
42
  if (!reservation.expiresAt) return 0;
@@ -28,8 +45,29 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
28
45
  return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
29
46
  }, [reservation.expiresAt]);
30
47
 
48
+ // Report each expiry window at most once. Keyed on `expiresAt` rather than a
49
+ // boolean, so the cart refresh that expiry triggers (which remounts this
50
+ // component) cannot bounce straight back into a second report.
51
+ const expiresAt = reservation.expiresAt;
52
+ const reportedExpiryRef = useRef<string | null>(null);
53
+ const onExpireRef = useRef(onExpire);
54
+ // Kept in a ref, and synced in an effect rather than during render, so a
55
+ // caller passing an inline arrow does not restart the timer every render.
31
56
  useEffect(() => {
32
- setRemainingSeconds(calculateRemaining());
57
+ onExpireRef.current = onExpire;
58
+ }, [onExpire]);
59
+
60
+ const reportExpired = useCallback(() => {
61
+ if (!expiresAt) return;
62
+ if (reportedExpiryRef.current === expiresAt) return;
63
+ reportedExpiryRef.current = expiresAt;
64
+ onExpireRef.current?.();
65
+ }, [expiresAt]);
66
+
67
+ useEffect(() => {
68
+ const initial = calculateRemaining();
69
+ setRemainingSeconds(initial);
70
+ if (initial <= 0) reportExpired();
33
71
 
34
72
  const interval = setInterval(() => {
35
73
  const remaining = calculateRemaining();
@@ -37,18 +75,20 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
37
75
 
38
76
  if (remaining <= 0) {
39
77
  clearInterval(interval);
78
+ reportExpired();
40
79
  }
41
80
  }, 1000);
42
81
 
43
82
  return () => clearInterval(interval);
44
- }, [calculateRemaining]);
83
+ }, [calculateRemaining, reportExpired]);
45
84
 
46
85
  if (!reservation.hasReservation) return null;
47
86
 
48
- const minutes = Math.floor(remainingSeconds / 60);
49
- const seconds = remainingSeconds % 60;
50
- const isExpired = remainingSeconds <= 0;
51
- const isUrgent = remainingSeconds > 0 && remainingSeconds < 120;
87
+ const displaySeconds = remainingSeconds ?? 0;
88
+ const minutes = Math.floor(displaySeconds / 60);
89
+ const seconds = displaySeconds % 60;
90
+ const isExpired = remainingSeconds !== null && remainingSeconds <= 0;
91
+ const isUrgent = displaySeconds > 0 && displaySeconds < 120;
52
92
 
53
93
  const displayMessage = reservation.countdownMessage
54
94
  ? reservation.countdownMessage.replace(
@@ -64,7 +104,10 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
64
104
  className={cn(className)}
65
105
  >
66
106
  {isExpired ? (
67
- <p>{t('expired')}</p>
107
+ <>
108
+ <p>{t('expired')}</p>
109
+ <p>{t('expiredHint')}</p>
110
+ </>
68
111
  ) : displayMessage ? (
69
112
  <p>{displayMessage}</p>
70
113
  ) : (