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.
- package/README.md +31 -10
- package/dist/index.js +177 -105
- package/messages/en.json +11 -1
- package/messages/he.json +11 -1
- package/package.json +1 -1
- package/templates/nextjs/base/TRANSLATIONS.md +14 -7
- package/templates/nextjs/base/src/app/checkout/page.tsx +59 -3
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +21 -2
- package/templates/nextjs/base/src/components/account/order-history.tsx +25 -39
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +30 -11
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +71 -2
- package/templates/nextjs/base/src/core/lib/auth.ts +32 -39
- package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +5 -16
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +3 -6
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +19 -1
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +42 -6
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +52 -10
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +21 -3
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +19 -1
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +44 -7
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +52 -10
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +15 -1
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +38 -4
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +54 -11
|
@@ -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({
|
|
22
|
+
export function ReservationCountdown({
|
|
23
|
+
reservation,
|
|
24
|
+
onExpire,
|
|
25
|
+
className,
|
|
26
|
+
}: ReservationCountdownProps) {
|
|
15
27
|
const t = useTranslations('reservation');
|
|
16
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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({
|
|
27
|
+
export function ReservationCountdown({
|
|
28
|
+
reservation,
|
|
29
|
+
onExpire,
|
|
30
|
+
className,
|
|
31
|
+
}: ReservationCountdownProps) {
|
|
20
32
|
const t = useTranslations('reservation');
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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({
|
|
29
|
+
export function ReservationCountdown({
|
|
30
|
+
reservation,
|
|
31
|
+
onExpire,
|
|
32
|
+
className,
|
|
33
|
+
}: ReservationCountdownProps) {
|
|
21
34
|
const t = useTranslations('reservation');
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
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
|
-
|
|
107
|
+
<>
|
|
108
|
+
<p>{t('expired')}</p>
|
|
109
|
+
<p>{t('expiredHint')}</p>
|
|
110
|
+
</>
|
|
68
111
|
) : displayMessage ? (
|
|
69
112
|
<p>{displayMessage}</p>
|
|
70
113
|
) : (
|