create-brainerce-store 1.72.0 → 1.74.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 +37 -4
- package/messages/en.json +30 -1
- package/messages/he.json +30 -1
- package/package.json +1 -1
- package/templates/nextjs/base/AGENTS.md.ejs +63 -16
- package/templates/nextjs/base/AI-GUIDE.md +17 -4
- package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
- package/templates/nextjs/base/TRANSLATIONS.md +207 -207
- package/templates/nextjs/base/src/app/account/page.tsx +168 -122
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
- package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
- package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
- package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
- package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
- package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
- package/templates/nextjs/base/src/core/lib/region.ts +112 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
- package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
- package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
- package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
- package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
- package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
- package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
- package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
|
@@ -1,176 +1,219 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { ShoppingBag } from 'lucide-react';
|
|
4
|
-
import { Link } from '@/core/lib/navigation';
|
|
5
|
-
import { useCartPage } from '@/core/hooks/use-cart-page';
|
|
6
|
-
import { CartItem } from '@/ui/cart/cart-item';
|
|
7
|
-
import { CartUpgradeBanner } from '@/ui/cart/cart-upgrade-banner';
|
|
8
|
-
import { CartBundleOfferCard } from '@/ui/cart/cart-bundle-offer';
|
|
9
|
-
import { CartSummary } from '@/ui/cart/cart-summary';
|
|
10
|
-
import { CouponInput } from '@/ui/cart/coupon-input';
|
|
11
|
-
import { CartNudges } from '@/ui/cart/cart-nudges';
|
|
12
|
-
import { FreeShippingBar } from '@/ui/cart/free-shipping-bar';
|
|
13
|
-
import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
|
|
14
|
-
import { CartRecommendationSection } from '@/ui/product/recommendation-section';
|
|
15
|
-
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
16
|
-
import { Button } from '@/components/ui/button';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ShoppingBag } from 'lucide-react';
|
|
4
|
+
import { Link } from '@/core/lib/navigation';
|
|
5
|
+
import { useCartPage } from '@/core/hooks/use-cart-page';
|
|
6
|
+
import { CartItem } from '@/ui/cart/cart-item';
|
|
7
|
+
import { CartUpgradeBanner } from '@/ui/cart/cart-upgrade-banner';
|
|
8
|
+
import { CartBundleOfferCard } from '@/ui/cart/cart-bundle-offer';
|
|
9
|
+
import { CartSummary } from '@/ui/cart/cart-summary';
|
|
10
|
+
import { CouponInput } from '@/ui/cart/coupon-input';
|
|
11
|
+
import { CartNudges } from '@/ui/cart/cart-nudges';
|
|
12
|
+
import { FreeShippingBar } from '@/ui/cart/free-shipping-bar';
|
|
13
|
+
import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
|
|
14
|
+
import { CartRecommendationSection } from '@/ui/product/recommendation-section';
|
|
15
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
16
|
+
import { Button } from '@/components/ui/button';
|
|
17
|
+
import { Checkbox } from '@/components/ui/checkbox';
|
|
18
|
+
import { Card } from '@/components/ui/card';
|
|
19
|
+
import { Separator } from '@/components/ui/separator';
|
|
20
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
21
|
+
|
|
22
|
+
export function CartView() {
|
|
23
|
+
const t = useTranslations('cart');
|
|
24
|
+
const tc = useTranslations('common');
|
|
25
|
+
const tr = useTranslations('reservation');
|
|
26
|
+
const {
|
|
27
|
+
cart,
|
|
28
|
+
cartLoading,
|
|
29
|
+
refreshCart,
|
|
30
|
+
itemCount,
|
|
31
|
+
cartRecs,
|
|
32
|
+
upgrades,
|
|
33
|
+
bundles,
|
|
34
|
+
reservationExpired,
|
|
35
|
+
unavailableItems,
|
|
36
|
+
selectedItemIds,
|
|
37
|
+
selectedItems,
|
|
38
|
+
toggleItemSelected,
|
|
39
|
+
allSelected,
|
|
40
|
+
setAllSelected,
|
|
41
|
+
checkoutHref,
|
|
42
|
+
canProceedToCheckout,
|
|
43
|
+
onReservationExpired,
|
|
44
|
+
} = useCartPage();
|
|
45
|
+
|
|
46
|
+
if (cartLoading) {
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
49
|
+
<LoadingSpinner size="lg" />
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Empty cart state
|
|
55
|
+
if (!cart || cart.items.length === 0) {
|
|
56
|
+
return (
|
|
57
|
+
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
58
|
+
<ShoppingBag
|
|
59
|
+
className="text-muted-foreground mx-auto mb-4 h-16 w-16"
|
|
60
|
+
strokeWidth={1.5}
|
|
61
|
+
aria-hidden="true"
|
|
62
|
+
/>
|
|
63
|
+
<h1 className="text-foreground text-2xl font-bold">{t('emptyTitle')}</h1>
|
|
64
|
+
<p className="text-muted-foreground mt-2">{t('emptySubtitle')}</p>
|
|
65
|
+
<Button asChild size="lg" className="mt-6 rounded px-6 text-base">
|
|
66
|
+
<Link href="/products">{tc('continueShopping')}</Link>
|
|
67
|
+
</Button>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
74
|
+
<h1 className="text-foreground mb-6 text-2xl font-bold">
|
|
75
|
+
{t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
|
|
76
|
+
</h1>
|
|
77
|
+
|
|
78
|
+
{/* Reservation countdown. onExpire refreshes the cart and closes the
|
|
79
|
+
checkout gate below. Passing it is what makes expiry mean anything. */}
|
|
80
|
+
{cart.reservation?.hasReservation && (
|
|
81
|
+
<ReservationCountdown
|
|
82
|
+
reservation={cart.reservation}
|
|
83
|
+
onExpire={onReservationExpired}
|
|
84
|
+
className="mb-6"
|
|
85
|
+
/>
|
|
86
|
+
)}
|
|
87
|
+
|
|
88
|
+
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
89
|
+
{/* Cart Items */}
|
|
90
|
+
<div className="lg:col-span-2">
|
|
91
|
+
{/* Nudges */}
|
|
92
|
+
{cart.nudges && cart.nudges.length > 0 && (
|
|
93
|
+
<CartNudges nudges={cart.nudges} className="mb-4" />
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
{/* Select-all. Partial checkout: only the ticked lines are sent to
|
|
97
|
+
createCheckout, and whatever stays unticked is still in the cart
|
|
98
|
+
(and still for sale) after payment. */}
|
|
99
|
+
<div className="border-border mb-2 flex items-center gap-3 border-b pb-3">
|
|
100
|
+
<Checkbox
|
|
101
|
+
id="cart-select-all"
|
|
102
|
+
checked={allSelected}
|
|
103
|
+
onCheckedChange={(checked) => setAllSelected(checked === true)}
|
|
104
|
+
/>
|
|
105
|
+
<label htmlFor="cart-select-all" className="text-foreground cursor-pointer text-sm">
|
|
106
|
+
{tc('all')}
|
|
107
|
+
</label>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Cart items */}
|
|
111
|
+
<div>
|
|
112
|
+
{cart.items.map((item) => (
|
|
113
|
+
<div key={item.id}>
|
|
114
|
+
<div className="flex items-start gap-3">
|
|
115
|
+
<Checkbox
|
|
116
|
+
className="mt-6"
|
|
117
|
+
checked={selectedItemIds.includes(item.id)}
|
|
118
|
+
onCheckedChange={() => toggleItemSelected(item.id)}
|
|
119
|
+
aria-label={item.product.name}
|
|
120
|
+
/>
|
|
121
|
+
<CartItem item={item} onUpdate={refreshCart} className="flex-1" />
|
|
122
|
+
</div>
|
|
123
|
+
{upgrades?.upgrades?.[item.productId] && (
|
|
124
|
+
<CartUpgradeBanner
|
|
125
|
+
suggestion={upgrades.upgrades[item.productId]}
|
|
126
|
+
cartItem={item}
|
|
127
|
+
onUpgrade={refreshCart}
|
|
128
|
+
className="mb-2 ms-24"
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Bundle offers */}
|
|
136
|
+
{bundles?.bundles && bundles.bundles.length > 0 && (
|
|
137
|
+
<div className="mt-6 space-y-3">
|
|
138
|
+
<h3 className="text-foreground text-sm font-semibold">{t('bundleOffers')}</h3>
|
|
139
|
+
{bundles.bundles.map((offer) => (
|
|
140
|
+
<CartBundleOfferCard
|
|
141
|
+
key={offer.id}
|
|
142
|
+
offer={offer}
|
|
143
|
+
cartId={cart.id}
|
|
144
|
+
onAdd={refreshCart}
|
|
145
|
+
/>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
{/* Coupon input */}
|
|
151
|
+
<Separator className="mt-6" />
|
|
152
|
+
<div className="pt-4">
|
|
153
|
+
<CouponInput cart={cart} onUpdate={refreshCart} />
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
{/* Summary sidebar */}
|
|
158
|
+
<div className="lg:col-span-1">
|
|
159
|
+
<Card className="bg-muted/50 sticky top-24 p-6 shadow-none">
|
|
160
|
+
<FreeShippingBar className="mb-4" />
|
|
161
|
+
{/* Totals are for the WHOLE cart. A partial selection is priced
|
|
162
|
+
authoritatively on the checkout page, which is the only place
|
|
163
|
+
the server has scoped the discounts and tax to the subset. */}
|
|
164
|
+
<CartSummary />
|
|
165
|
+
|
|
166
|
+
{/* Shown once the shopper unticks anything. At zero selected this
|
|
167
|
+
is what explains the disabled button below — a count, so it
|
|
168
|
+
needs no copy of its own. */}
|
|
169
|
+
{!allSelected && (
|
|
170
|
+
<p className="text-muted-foreground mt-3 text-center text-xs">
|
|
171
|
+
{selectedItems.length} {selectedItems.length === 1 ? tc('item') : tc('items')}
|
|
172
|
+
</p>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{/* Proceed to checkout. When the gate is closed this must be a
|
|
176
|
+
real disabled control, never a styled-down link: an anchor
|
|
177
|
+
ignores `disabled` and would still navigate. */}
|
|
178
|
+
{canProceedToCheckout ? (
|
|
179
|
+
<Button asChild size="lg" className="mt-6 w-full rounded px-6 text-sm">
|
|
180
|
+
{/* `checkoutHref` carries `?items=` only for a strict subset;
|
|
181
|
+
a full cart links to a bare /checkout exactly as before. */}
|
|
182
|
+
<Link href={checkoutHref}>{t('proceedToCheckout')}</Link>
|
|
183
|
+
</Button>
|
|
184
|
+
) : (
|
|
185
|
+
<>
|
|
186
|
+
<Button disabled size="lg" className="mt-6 w-full rounded px-6 text-sm">
|
|
187
|
+
{t('proceedToCheckout')}
|
|
188
|
+
</Button>
|
|
189
|
+
<p className="text-destructive mt-2 text-xs">
|
|
190
|
+
{unavailableItems.length > 0
|
|
191
|
+
? t('unavailableItemsHint')
|
|
192
|
+
: reservationExpired
|
|
193
|
+
? tr('expiredHint')
|
|
194
|
+
: null}
|
|
195
|
+
</p>
|
|
196
|
+
</>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
<Link
|
|
200
|
+
href="/products"
|
|
201
|
+
className="text-muted-foreground hover:text-foreground mt-3 inline-flex w-full items-center justify-center px-6 py-2 text-sm transition-colors"
|
|
202
|
+
>
|
|
203
|
+
{tc('continueShopping')}
|
|
204
|
+
</Link>
|
|
205
|
+
</Card>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{/* Cross-sell recommendations */}
|
|
210
|
+
{cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
|
|
211
|
+
<CartRecommendationSection
|
|
212
|
+
title={t('youMightAlsoNeed')}
|
|
213
|
+
items={cartRecs.recommendations}
|
|
214
|
+
className="mt-10"
|
|
215
|
+
/>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Gift } from 'lucide-react';
|
|
5
|
+
import type { Checkout } from 'brainerce';
|
|
6
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
7
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
8
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
9
|
+
import { Button } from '@/components/ui/button';
|
|
10
|
+
import { Input } from '@/components/ui/input';
|
|
11
|
+
import { cn } from '@/core/lib/utils';
|
|
12
|
+
|
|
13
|
+
interface GiftCardInputProps {
|
|
14
|
+
checkoutId: string;
|
|
15
|
+
/**
|
|
16
|
+
* Cards already on this checkout. Each is removed by its own tenderId.
|
|
17
|
+
*
|
|
18
|
+
* Derived from `Checkout` rather than written out, and deliberately NOT the
|
|
19
|
+
* `CheckoutTender` that `applyGiftCard` returns — that one carries a third
|
|
20
|
+
* field, `providerAmountDue`, because an apply response reports the new
|
|
21
|
+
* amount due alongside the tender it just created. Reading a checkout puts
|
|
22
|
+
* that figure at the top level instead, once, since it describes the whole
|
|
23
|
+
* checkout and not any single card. Naming the source type here keeps the two
|
|
24
|
+
* from being confused again.
|
|
25
|
+
*/
|
|
26
|
+
tenders: NonNullable<Checkout['tenders']>;
|
|
27
|
+
/** Formats an amount in the checkout's currency. */
|
|
28
|
+
formatAmount: (value: string) => string;
|
|
29
|
+
onUpdate: () => void;
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Redeem a gift card at checkout.
|
|
35
|
+
*
|
|
36
|
+
* ## A gift card is NOT a coupon
|
|
37
|
+
*
|
|
38
|
+
* This sits beside the coupon field and behaves nothing like it. A coupon
|
|
39
|
+
* reduces what the order is worth; a gift card pays for an order that is still
|
|
40
|
+
* worth what it was. The order total does not move, tax is unchanged, and what
|
|
41
|
+
* drops is only the amount the payment provider is charged.
|
|
42
|
+
*
|
|
43
|
+
* Rendering it as a discount would misstate the order to the shopper and, on a
|
|
44
|
+
* receipt, misstate the taxable base. That is why the applied card appears on
|
|
45
|
+
* its own line near the total rather than inside the discount row.
|
|
46
|
+
*
|
|
47
|
+
* ## Checkout only, despite living in `ui/cart/`
|
|
48
|
+
*
|
|
49
|
+
* It sits beside `coupon-input.tsx` because a shopper reaches for both in the
|
|
50
|
+
* same place — but a coupon has a cart-level call and a gift card does not.
|
|
51
|
+
* `applyGiftCard` needs a checkout, so `checkoutId` is a REQUIRED prop: the type
|
|
52
|
+
* system, not a convention, is what stops this being dropped onto a cart page.
|
|
53
|
+
*
|
|
54
|
+
* ## More than one card
|
|
55
|
+
*
|
|
56
|
+
* A checkout can carry several. Each is removed by its `tenderId`, never by the
|
|
57
|
+
* code — the code is bearer value and is not kept anywhere after it is applied.
|
|
58
|
+
*
|
|
59
|
+
* ## Refusals say one thing
|
|
60
|
+
*
|
|
61
|
+
* The server answers identically for "no such code", "expired", "already spent"
|
|
62
|
+
* and "wrong currency". That is deliberate: any response that distinguishes them
|
|
63
|
+
* is an oracle someone walks the code space against. Show what the server said;
|
|
64
|
+
* never guess a more specific reason.
|
|
65
|
+
*/
|
|
66
|
+
export function GiftCardInput({
|
|
67
|
+
checkoutId,
|
|
68
|
+
tenders,
|
|
69
|
+
formatAmount,
|
|
70
|
+
onUpdate,
|
|
71
|
+
className,
|
|
72
|
+
}: GiftCardInputProps) {
|
|
73
|
+
const t = useTranslations('giftCard');
|
|
74
|
+
const tc = useTranslations('common');
|
|
75
|
+
const [code, setCode] = useState('');
|
|
76
|
+
const [applying, setApplying] = useState(false);
|
|
77
|
+
const [removingId, setRemovingId] = useState<string | null>(null);
|
|
78
|
+
const [error, setError] = useState<string | null>(null);
|
|
79
|
+
|
|
80
|
+
async function handleApply() {
|
|
81
|
+
const trimmed = code.trim();
|
|
82
|
+
if (!trimmed || applying) return;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
setApplying(true);
|
|
86
|
+
setError(null);
|
|
87
|
+
await getClient().applyGiftCard(checkoutId, trimmed);
|
|
88
|
+
// Cleared on success only. A rejected code stays in the field so the
|
|
89
|
+
// shopper can fix a typo instead of re-reading it off the card.
|
|
90
|
+
setCode('');
|
|
91
|
+
onUpdate();
|
|
92
|
+
} catch (err) {
|
|
93
|
+
setError(err instanceof Error ? err.message : t('invalidCode'));
|
|
94
|
+
} finally {
|
|
95
|
+
setApplying(false);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function handleRemove(tenderId: string) {
|
|
100
|
+
if (removingId) return;
|
|
101
|
+
try {
|
|
102
|
+
setRemovingId(tenderId);
|
|
103
|
+
setError(null);
|
|
104
|
+
await getClient().removeGiftCard(checkoutId, tenderId);
|
|
105
|
+
onUpdate();
|
|
106
|
+
} catch (err) {
|
|
107
|
+
setError(err instanceof Error ? err.message : t('removeFailed'));
|
|
108
|
+
} finally {
|
|
109
|
+
setRemovingId(null);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className={cn('space-y-2', className)}>
|
|
115
|
+
{tenders.length > 0 && (
|
|
116
|
+
<ul className="space-y-2">
|
|
117
|
+
{tenders.map((tender) => (
|
|
118
|
+
<li
|
|
119
|
+
key={tender.tenderId}
|
|
120
|
+
className="bg-muted flex items-center justify-between rounded px-3 py-2"
|
|
121
|
+
>
|
|
122
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
123
|
+
<Gift className="text-primary h-4 w-4 flex-shrink-0" aria-hidden="true" />
|
|
124
|
+
<span className="text-foreground truncate text-sm font-medium">
|
|
125
|
+
{/* What was applied, not what the card holds. A ₪200 card on a
|
|
126
|
+
₪50 order applies ₪50 and keeps the rest for next time —
|
|
127
|
+
showing the balance here would look like a lost ₪150. */}
|
|
128
|
+
{t('applied', { amount: formatAmount(tender.amountApplied) })}
|
|
129
|
+
</span>
|
|
130
|
+
</div>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={() => handleRemove(tender.tenderId)}
|
|
134
|
+
disabled={removingId === tender.tenderId}
|
|
135
|
+
className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
|
|
136
|
+
>
|
|
137
|
+
{removingId === tender.tenderId ? tc('removing') : tc('remove')}
|
|
138
|
+
</button>
|
|
139
|
+
</li>
|
|
140
|
+
))}
|
|
141
|
+
</ul>
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
<div className="flex gap-2">
|
|
145
|
+
<Input
|
|
146
|
+
type="text"
|
|
147
|
+
value={code}
|
|
148
|
+
onChange={(e) => {
|
|
149
|
+
setCode(e.target.value);
|
|
150
|
+
if (error) setError(null);
|
|
151
|
+
}}
|
|
152
|
+
onKeyDown={(e) => {
|
|
153
|
+
if (e.key === 'Enter') {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
handleApply();
|
|
156
|
+
}
|
|
157
|
+
}}
|
|
158
|
+
placeholder={t('placeholder')}
|
|
159
|
+
aria-label={t('placeholder')}
|
|
160
|
+
aria-invalid={!!error}
|
|
161
|
+
// A gift card code is Latin and case-insensitive but the server
|
|
162
|
+
// normalises it, so the shopper may type it however it is printed.
|
|
163
|
+
// LTR because the code is Latin even on an RTL storefront.
|
|
164
|
+
dir="ltr"
|
|
165
|
+
autoComplete="off"
|
|
166
|
+
spellCheck={false}
|
|
167
|
+
className={cn('h-9 flex-1 rounded font-mono', error && 'border-destructive')}
|
|
168
|
+
/>
|
|
169
|
+
<Button
|
|
170
|
+
type="button"
|
|
171
|
+
variant="outline"
|
|
172
|
+
onClick={handleApply}
|
|
173
|
+
disabled={applying || !code.trim()}
|
|
174
|
+
className="hover:bg-muted h-9 rounded px-4"
|
|
175
|
+
>
|
|
176
|
+
{applying ? (
|
|
177
|
+
<LoadingSpinner size="sm" className="border-muted-foreground/30 border-t-foreground" />
|
|
178
|
+
) : (
|
|
179
|
+
tc('apply')
|
|
180
|
+
)}
|
|
181
|
+
</Button>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{error && (
|
|
185
|
+
<p role="alert" className="text-destructive text-xs">
|
|
186
|
+
{error}
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|