create-brainerce-store 1.72.0 → 1.73.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,184 +1,184 @@
1
- 'use client';
2
-
3
- import { Link } from '@/core/lib/navigation';
4
- import { useCartPage } from '@/core/hooks/use-cart-page';
5
- import { CartItem } from '@/ui/cart/cart-item';
6
- import { CartUpgradeBanner } from '@/ui/cart/cart-upgrade-banner';
7
- import { CartBundleOfferCard } from '@/ui/cart/cart-bundle-offer';
8
- import { CartSummary } from '@/ui/cart/cart-summary';
9
- import { CouponInput } from '@/ui/cart/coupon-input';
10
- import { CartNudges } from '@/ui/cart/cart-nudges';
11
- import { FreeShippingBar } from '@/ui/cart/free-shipping-bar';
12
- import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
13
- import { CartRecommendationSection } from '@/ui/product/recommendation-section';
14
- import { LoadingSpinner } from '@/ui/shared/loading-spinner';
15
- import { useTranslations } from '@/core/lib/translations';
16
- import { IconBag, IconArrowEnd, IconShield } from '@/ui/shared/icons';
17
-
18
- export function CartView() {
19
- const t = useTranslations('cart');
20
- const tc = useTranslations('common');
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();
35
-
36
- if (cartLoading) {
37
- return <LoadingSpinner size="lg" className="min-h-[60vh]" />;
38
- }
39
-
40
- // Empty cart state — designed, never a bare sentence in blank space.
41
- if (!cart || cart.items.length === 0) {
42
- return (
43
- <section className="container-narrow flex min-h-[60vh] items-center justify-center py-16">
44
- <div className="card flex w-full max-w-md flex-col items-center gap-3 px-8 py-14 text-center">
45
- <span className="flex h-16 w-16 items-center justify-center rounded-full bg-secondary text-primary">
46
- <IconBag size={32} />
47
- </span>
48
- <h1 className="text-2xl sm:text-3xl">{t('emptyTitle')}</h1>
49
- <p className="text-muted-foreground">{t('emptySubtitle')}</p>
50
- <Link href="/products" className="btn-primary btn-md mt-3">
51
- {tc('continueShopping')}
52
- <IconArrowEnd size={18} className="rtl-flip" />
53
- </Link>
54
- </div>
55
- </section>
56
- );
57
- }
58
-
59
- return (
60
- <section className="container-narrow py-8 lg:py-12">
61
- <h1 className="text-3xl sm:text-4xl">
62
- {t('title')}{' '}
63
- <span className="font-sans text-base font-normal text-muted-foreground">
64
- ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
65
- </span>
66
- </h1>
67
-
68
- {/* Reservation countdown. onExpire refreshes the cart and closes the
69
- checkout gate below. Passing it is what makes expiry mean anything. */}
70
- {cart.reservation?.hasReservation && (
71
- <ReservationCountdown
72
- reservation={cart.reservation}
73
- onExpire={onReservationExpired}
74
- className="mt-4"
75
- />
76
- )}
77
-
78
- <div className="mt-6 grid grid-cols-1 items-start gap-8 lg:grid-cols-3 lg:gap-10">
79
- {/* Cart Items */}
80
- <div className="lg:col-span-2">
81
- {/* Nudges */}
82
- {cart.nudges && cart.nudges.length > 0 && (
83
- <CartNudges nudges={cart.nudges} className="mb-4" />
84
- )}
85
-
86
- {/* Cart items */}
87
- <ul className="card divide-y overflow-hidden">
88
- {cart.items.map((item) => (
89
- <li key={item.id} className="p-4 sm:p-5">
90
- <CartItem item={item} onUpdate={refreshCart} />
91
- {upgrades?.upgrades?.[item.productId] && (
92
- <CartUpgradeBanner
93
- suggestion={upgrades.upgrades[item.productId]}
94
- cartItem={item}
95
- onUpgrade={refreshCart}
96
- className="mt-4"
97
- />
98
- )}
99
- </li>
100
- ))}
101
- </ul>
102
-
103
- {/* Bundle offers */}
104
- {bundles?.bundles && bundles.bundles.length > 0 && (
105
- <section className="mt-6">
106
- <h3 className="mb-3 text-xl">{t('bundleOffers')}</h3>
107
- <div className="space-y-4">
108
- {bundles.bundles.map((offer) => (
109
- <CartBundleOfferCard
110
- key={offer.id}
111
- offer={offer}
112
- cartId={cart.id}
113
- onAdd={refreshCart}
114
- />
115
- ))}
116
- </div>
117
- </section>
118
- )}
119
-
120
- {/* Coupon input */}
121
- <div className="mt-6">
122
- <p className="mb-2 text-sm font-semibold text-foreground">{t('couponTitle')}</p>
123
- <CouponInput cart={cart} onUpdate={refreshCart} />
124
- </div>
125
- </div>
126
-
127
- {/* Summary sidebar */}
128
- <aside className="lg:sticky lg:top-24">
129
- <div className="card space-y-5 p-5 sm:p-6">
130
- <FreeShippingBar />
131
- <CartSummary />
132
-
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
- )}
156
-
157
- <p className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
158
- <IconShield size={16} />
159
- {t('secureCheckoutNote')}
160
- </p>
161
- </div>
162
-
163
- <p className="mt-4 text-center">
164
- <Link
165
- href="/products"
166
- className="text-sm font-medium text-primary underline-offset-2 hover:underline"
167
- >
168
- {tc('continueShopping')}
169
- </Link>
170
- </p>
171
- </aside>
172
- </div>
173
-
174
- {/* Cross-sell recommendations */}
175
- {cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
176
- <CartRecommendationSection
177
- title={t('youMightAlsoNeed')}
178
- items={cartRecs.recommendations}
179
- className="mt-12"
180
- />
181
- )}
182
- </section>
183
- );
184
- }
1
+ 'use client';
2
+
3
+ import { Link } from '@/core/lib/navigation';
4
+ import { useCartPage } from '@/core/hooks/use-cart-page';
5
+ import { CartItem } from '@/ui/cart/cart-item';
6
+ import { CartUpgradeBanner } from '@/ui/cart/cart-upgrade-banner';
7
+ import { CartBundleOfferCard } from '@/ui/cart/cart-bundle-offer';
8
+ import { CartSummary } from '@/ui/cart/cart-summary';
9
+ import { CouponInput } from '@/ui/cart/coupon-input';
10
+ import { CartNudges } from '@/ui/cart/cart-nudges';
11
+ import { FreeShippingBar } from '@/ui/cart/free-shipping-bar';
12
+ import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
13
+ import { CartRecommendationSection } from '@/ui/product/recommendation-section';
14
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
15
+ import { useTranslations } from '@/core/lib/translations';
16
+ import { IconBag, IconArrowEnd, IconShield } from '@/ui/shared/icons';
17
+
18
+ export function CartView() {
19
+ const t = useTranslations('cart');
20
+ const tc = useTranslations('common');
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();
35
+
36
+ if (cartLoading) {
37
+ return <LoadingSpinner size="lg" className="min-h-[60vh]" />;
38
+ }
39
+
40
+ // Empty cart state — designed, never a bare sentence in blank space.
41
+ if (!cart || cart.items.length === 0) {
42
+ return (
43
+ <section className="container-narrow flex min-h-[60vh] items-center justify-center py-16">
44
+ <div className="card flex w-full max-w-md flex-col items-center gap-3 px-8 py-14 text-center">
45
+ <span className="bg-secondary text-primary flex h-16 w-16 items-center justify-center rounded-full">
46
+ <IconBag size={32} />
47
+ </span>
48
+ <h1 className="text-2xl sm:text-3xl">{t('emptyTitle')}</h1>
49
+ <p className="text-muted-foreground">{t('emptySubtitle')}</p>
50
+ <Link href="/products" className="btn-primary btn-md mt-3">
51
+ {tc('continueShopping')}
52
+ <IconArrowEnd size={18} className="rtl-flip" />
53
+ </Link>
54
+ </div>
55
+ </section>
56
+ );
57
+ }
58
+
59
+ return (
60
+ <section className="container-narrow py-8 lg:py-12">
61
+ <h1 className="text-3xl sm:text-4xl">
62
+ {t('title')}{' '}
63
+ <span className="text-muted-foreground font-sans text-base font-normal">
64
+ ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
65
+ </span>
66
+ </h1>
67
+
68
+ {/* Reservation countdown. onExpire refreshes the cart and closes the
69
+ checkout gate below. Passing it is what makes expiry mean anything. */}
70
+ {cart.reservation?.hasReservation && (
71
+ <ReservationCountdown
72
+ reservation={cart.reservation}
73
+ onExpire={onReservationExpired}
74
+ className="mt-4"
75
+ />
76
+ )}
77
+
78
+ <div className="mt-6 grid grid-cols-1 items-start gap-8 lg:grid-cols-3 lg:gap-10">
79
+ {/* Cart Items */}
80
+ <div className="lg:col-span-2">
81
+ {/* Nudges */}
82
+ {cart.nudges && cart.nudges.length > 0 && (
83
+ <CartNudges nudges={cart.nudges} className="mb-4" />
84
+ )}
85
+
86
+ {/* Cart items */}
87
+ <ul className="card divide-y overflow-hidden">
88
+ {cart.items.map((item) => (
89
+ <li key={item.id} className="p-4 sm:p-5">
90
+ <CartItem item={item} onUpdate={refreshCart} />
91
+ {upgrades?.upgrades?.[item.productId] && (
92
+ <CartUpgradeBanner
93
+ suggestion={upgrades.upgrades[item.productId]}
94
+ cartItem={item}
95
+ onUpgrade={refreshCart}
96
+ className="mt-4"
97
+ />
98
+ )}
99
+ </li>
100
+ ))}
101
+ </ul>
102
+
103
+ {/* Bundle offers */}
104
+ {bundles?.bundles && bundles.bundles.length > 0 && (
105
+ <section className="mt-6">
106
+ <h3 className="mb-3 text-xl">{t('bundleOffers')}</h3>
107
+ <div className="space-y-4">
108
+ {bundles.bundles.map((offer) => (
109
+ <CartBundleOfferCard
110
+ key={offer.id}
111
+ offer={offer}
112
+ cartId={cart.id}
113
+ onAdd={refreshCart}
114
+ />
115
+ ))}
116
+ </div>
117
+ </section>
118
+ )}
119
+
120
+ {/* Coupon input */}
121
+ <div className="mt-6">
122
+ <p className="text-foreground mb-2 text-sm font-semibold">{t('couponTitle')}</p>
123
+ <CouponInput cart={cart} onUpdate={refreshCart} />
124
+ </div>
125
+ </div>
126
+
127
+ {/* Summary sidebar */}
128
+ <aside className="lg:sticky lg:top-24">
129
+ <div className="card space-y-5 p-5 sm:p-6">
130
+ <FreeShippingBar />
131
+ <CartSummary />
132
+
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="text-destructive mt-2 text-center text-xs">
148
+ {unavailableItems.length > 0
149
+ ? t('unavailableItemsHint')
150
+ : reservationExpired
151
+ ? tr('expiredHint')
152
+ : null}
153
+ </p>
154
+ </div>
155
+ )}
156
+
157
+ <p className="text-muted-foreground flex items-center justify-center gap-1.5 text-xs">
158
+ <IconShield size={16} />
159
+ {t('secureCheckoutNote')}
160
+ </p>
161
+ </div>
162
+
163
+ <p className="mt-4 text-center">
164
+ <Link
165
+ href="/products"
166
+ className="text-primary text-sm font-medium underline-offset-2 hover:underline"
167
+ >
168
+ {tc('continueShopping')}
169
+ </Link>
170
+ </p>
171
+ </aside>
172
+ </div>
173
+
174
+ {/* Cross-sell recommendations */}
175
+ {cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
176
+ <CartRecommendationSection
177
+ title={t('youMightAlsoNeed')}
178
+ items={cartRecs.recommendations}
179
+ className="mt-12"
180
+ />
181
+ )}
182
+ </section>
183
+ );
184
+ }
@@ -1,131 +1,131 @@
1
- 'use client';
2
-
3
- import { useState, useEffect, useCallback, useRef } from 'react';
4
- import type { ReservationInfo } from 'brainerce';
5
- import { useTranslations } from '@/core/lib/translations';
6
- import { cn } from '@/core/lib/utils';
7
- import { IconClock } from '@/ui/shared/icons';
8
-
9
- interface ReservationCountdownProps {
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;
19
- className?: string;
20
- }
21
-
22
- /**
23
- * Stock-reservation countdown on the cart/checkout pages; the `reservation`
24
- * prop comes from useCartPage().cart.reservation. Keeps the ticking logic
25
- * and the expired/urgent states (exposed via data-state).
26
- */
27
- export function ReservationCountdown({
28
- reservation,
29
- onExpire,
30
- className,
31
- }: ReservationCountdownProps) {
32
- const t = useTranslations('reservation');
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);
38
-
39
- const calculateRemaining = useCallback(() => {
40
- if (!reservation.expiresAt) return 0;
41
- const expiresAtMs = new Date(reservation.expiresAt).getTime();
42
- const nowMs = Date.now();
43
- return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
44
- }, [reservation.expiresAt]);
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.
54
- useEffect(() => {
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();
69
-
70
- const interval = setInterval(() => {
71
- const remaining = calculateRemaining();
72
- setRemainingSeconds(remaining);
73
-
74
- if (remaining <= 0) {
75
- clearInterval(interval);
76
- reportExpired();
77
- }
78
- }, 1000);
79
-
80
- return () => clearInterval(interval);
81
- }, [calculateRemaining, reportExpired]);
82
-
83
- if (!reservation.hasReservation) return null;
84
-
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;
90
-
91
- const displayMessage = reservation.countdownMessage
92
- ? reservation.countdownMessage.replace(
93
- '{time}',
94
- `${minutes}:${seconds.toString().padStart(2, '0')}`
95
- )
96
- : null;
97
-
98
- return (
99
- <div
100
- role="timer"
101
- data-state={isExpired ? 'expired' : isUrgent ? 'urgent' : 'active'}
102
- className={cn(
103
- 'flex items-center gap-2.5 rounded-lg px-4 py-3 text-sm font-medium',
104
- isExpired || isUrgent
105
- ? 'bg-destructive/10 text-destructive'
106
- : 'bg-secondary text-foreground',
107
- className
108
- )}
109
- >
110
- <span aria-hidden="true" className="shrink-0">
111
- <IconClock size={18} />
112
- </span>
113
- {isExpired ? (
114
- <div>
115
- <p>{t('expired')}</p>
116
- <p className="text-xs font-normal opacity-90">{t('expiredHint')}</p>
117
- </div>
118
- ) : displayMessage ? (
119
- <p>{displayMessage}</p>
120
- ) : (
121
- <p>
122
- {isUrgent ? `${t('hurry')} ` : ''}
123
- {t('reservedFor')}{' '}
124
- <span className="font-bold tabular-nums">
125
- {minutes}:{seconds.toString().padStart(2, '0')}
126
- </span>
127
- </p>
128
- )}
129
- </div>
130
- );
131
- }
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
+ import type { ReservationInfo } from 'brainerce';
5
+ import { useTranslations } from '@/core/lib/translations';
6
+ import { cn } from '@/core/lib/utils';
7
+ import { IconClock } from '@/ui/shared/icons';
8
+
9
+ interface ReservationCountdownProps {
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;
19
+ className?: string;
20
+ }
21
+
22
+ /**
23
+ * Stock-reservation countdown on the cart/checkout pages; the `reservation`
24
+ * prop comes from useCartPage().cart.reservation. Keeps the ticking logic
25
+ * and the expired/urgent states (exposed via data-state).
26
+ */
27
+ export function ReservationCountdown({
28
+ reservation,
29
+ onExpire,
30
+ className,
31
+ }: ReservationCountdownProps) {
32
+ const t = useTranslations('reservation');
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);
38
+
39
+ const calculateRemaining = useCallback(() => {
40
+ if (!reservation.expiresAt) return 0;
41
+ const expiresAtMs = new Date(reservation.expiresAt).getTime();
42
+ const nowMs = Date.now();
43
+ return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
44
+ }, [reservation.expiresAt]);
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.
54
+ useEffect(() => {
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();
69
+
70
+ const interval = setInterval(() => {
71
+ const remaining = calculateRemaining();
72
+ setRemainingSeconds(remaining);
73
+
74
+ if (remaining <= 0) {
75
+ clearInterval(interval);
76
+ reportExpired();
77
+ }
78
+ }, 1000);
79
+
80
+ return () => clearInterval(interval);
81
+ }, [calculateRemaining, reportExpired]);
82
+
83
+ if (!reservation.hasReservation) return null;
84
+
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;
90
+
91
+ const displayMessage = reservation.countdownMessage
92
+ ? reservation.countdownMessage.replace(
93
+ '{time}',
94
+ `${minutes}:${seconds.toString().padStart(2, '0')}`
95
+ )
96
+ : null;
97
+
98
+ return (
99
+ <div
100
+ role="timer"
101
+ data-state={isExpired ? 'expired' : isUrgent ? 'urgent' : 'active'}
102
+ className={cn(
103
+ 'flex items-center gap-2.5 rounded-lg px-4 py-3 text-sm font-medium',
104
+ isExpired || isUrgent
105
+ ? 'bg-destructive/10 text-destructive'
106
+ : 'bg-secondary text-foreground',
107
+ className
108
+ )}
109
+ >
110
+ <span aria-hidden="true" className="shrink-0">
111
+ <IconClock size={18} />
112
+ </span>
113
+ {isExpired ? (
114
+ <div>
115
+ <p>{t('expired')}</p>
116
+ <p className="text-xs font-normal opacity-90">{t('expiredHint')}</p>
117
+ </div>
118
+ ) : displayMessage ? (
119
+ <p>{displayMessage}</p>
120
+ ) : (
121
+ <p>
122
+ {isUrgent ? `${t('hurry')} ` : ''}
123
+ {t('reservedFor')}{' '}
124
+ <span className="font-bold tabular-nums">
125
+ {minutes}:{seconds.toString().padStart(2, '0')}
126
+ </span>
127
+ </p>
128
+ )}
129
+ </div>
130
+ );
131
+ }