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.
- package/dist/index.js +4 -4
- package/messages/en.json +3 -0
- package/messages/he.json +3 -0
- package/package.json +1 -1
- package/templates/nextjs/base/TRANSLATIONS.md +207 -207
- package/templates/nextjs/base/src/app/checkout/page.tsx +1074 -1074
- package/templates/nextjs/base/src/components/account/order-history.tsx +371 -371
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +127 -127
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +164 -164
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +176 -176
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
- package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +158 -158
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +184 -184
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
- package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|