create-brainerce-store 1.73.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 +35 -2
- package/messages/en.json +27 -1
- package/messages/he.json +27 -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/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 +145 -40
- 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 +58 -7
- 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 +97 -6
- 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 +10 -0
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +45 -2
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
- 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/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-item.tsx +10 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +56 -11
- 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/stock-badge.tsx +17 -2
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
- 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/stock-badge.tsx +17 -2
|
@@ -10,6 +10,7 @@ import { useTranslations } from '@/core/lib/translations';
|
|
|
10
10
|
import { useCurrency } from '@/core/lib/use-currency';
|
|
11
11
|
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
12
12
|
import { cn } from '@/core/lib/utils';
|
|
13
|
+
import { OrderCustomizations } from '@/components/account/order-customizations';
|
|
13
14
|
|
|
14
15
|
interface CartItemProps {
|
|
15
16
|
item: CartItemType;
|
|
@@ -104,6 +105,15 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
104
105
|
{/* Variant name */}
|
|
105
106
|
{variantName && <p className="text-muted-foreground mt-1 text-xs">{variantName}</p>}
|
|
106
107
|
|
|
108
|
+
{/* What the buyer typed / uploaded / picked, keyed by the field's own
|
|
109
|
+
`key` and carrying the merchant's label. The SDK resolves this on
|
|
110
|
+
the cart line itself (`CartItem.customizations`, same shape as
|
|
111
|
+
`OrderItem.customizations`) so no product fetch is needed for the
|
|
112
|
+
labels. Present only when the line has at least one value set, and
|
|
113
|
+
the component returns null on an empty map, so a plain product
|
|
114
|
+
renders exactly as before. */}
|
|
115
|
+
{item.customizations && <OrderCustomizations customizations={item.customizations} />}
|
|
116
|
+
|
|
107
117
|
{/* Unit price */}
|
|
108
118
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
109
119
|
{formatPrice(unitPrice, { currency }) as string}
|
|
@@ -1,110 +1,109 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { formatPrice } from 'brainerce';
|
|
4
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
5
|
-
import { useCart } from '@/core/providers/store-provider';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
<span className="text-foreground
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
{
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<span className="text-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<span className="text-
|
|
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
|
-
<span className="text-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
{
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
<span className="text-foreground
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
<span className="text-foreground font-semibold">
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { formatPrice } from 'brainerce';
|
|
4
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
5
|
+
import { useCart } from '@/core/providers/store-provider';
|
|
6
|
+
import { TaxEstimateLine } from '@/ui/cart/tax-estimate-line';
|
|
7
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
8
|
+
import { Separator } from '@/components/ui/separator';
|
|
9
|
+
import { cn } from '@/core/lib/utils';
|
|
10
|
+
|
|
11
|
+
interface CartSummaryProps {
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function CartSummary({ className }: CartSummaryProps) {
|
|
16
|
+
const t = useTranslations('cart');
|
|
17
|
+
const tc = useTranslations('common');
|
|
18
|
+
const { totals, cart } = useCart();
|
|
19
|
+
const currency = useCurrency();
|
|
20
|
+
|
|
21
|
+
const rules = cart?.appliedDiscounts;
|
|
22
|
+
const ruleAmt = cart?.ruleDiscountAmount ? parseFloat(cart.ruleDiscountAmount) : 0;
|
|
23
|
+
const couponAmt = totals.discount - ruleAmt;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className={cn('space-y-3', className)}>
|
|
27
|
+
<h3 className="text-foreground text-lg font-semibold">{t('orderSummary')}</h3>
|
|
28
|
+
|
|
29
|
+
<div className="space-y-2 text-sm">
|
|
30
|
+
{/* Subtotal */}
|
|
31
|
+
<div className="flex items-center justify-between">
|
|
32
|
+
<span className="text-muted-foreground">{tc('subtotal')}</span>
|
|
33
|
+
<span className="text-foreground font-medium">
|
|
34
|
+
{formatPrice(totals.subtotal, { currency }) as string}
|
|
35
|
+
</span>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
{/* Rule discounts - show each rule by name */}
|
|
39
|
+
{rules && rules.length > 0
|
|
40
|
+
? rules.map((rule) => (
|
|
41
|
+
<div key={rule.ruleId} className="flex items-center justify-between">
|
|
42
|
+
<span className="text-muted-foreground">{rule.ruleName}</span>
|
|
43
|
+
<span className="text-destructive font-medium">
|
|
44
|
+
-{formatPrice(parseFloat(rule.discountAmount), { currency }) as string}
|
|
45
|
+
</span>
|
|
46
|
+
</div>
|
|
47
|
+
))
|
|
48
|
+
: ruleAmt > 0 && (
|
|
49
|
+
<div className="flex items-center justify-between">
|
|
50
|
+
<span className="text-muted-foreground">{tc('generalDiscount')}</span>
|
|
51
|
+
<span className="text-destructive font-medium">
|
|
52
|
+
-{formatPrice(ruleAmt, { currency }) as string}
|
|
53
|
+
</span>
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
{/* Coupon discount */}
|
|
58
|
+
{cart?.couponCode && couponAmt > 0 && (
|
|
59
|
+
<div className="flex items-center justify-between">
|
|
60
|
+
<span className="text-muted-foreground">
|
|
61
|
+
{tc('couponDiscount')} ({cart.couponCode})
|
|
62
|
+
</span>
|
|
63
|
+
<span className="text-destructive font-medium">
|
|
64
|
+
-{formatPrice(couponAmt, { currency }) as string}
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
{/* Fallback: generic discount when no breakdown available */}
|
|
70
|
+
{totals.discount > 0 &&
|
|
71
|
+
ruleAmt <= 0 &&
|
|
72
|
+
!cart?.couponCode &&
|
|
73
|
+
(!rules || rules.length === 0) && (
|
|
74
|
+
<div className="flex items-center justify-between">
|
|
75
|
+
<span className="text-muted-foreground">{tc('discount')}</span>
|
|
76
|
+
<span className="text-destructive font-medium">
|
|
77
|
+
-{formatPrice(totals.discount, { currency }) as string}
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
{/* Shipping */}
|
|
83
|
+
{totals.shipping > 0 && (
|
|
84
|
+
<div className="flex items-center justify-between">
|
|
85
|
+
<span className="text-muted-foreground">{tc('shipping')}</span>
|
|
86
|
+
<span className="text-foreground font-medium">
|
|
87
|
+
{formatPrice(totals.shipping, { currency }) as string}
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{/* Tax: a real estimate when the store has a rate for the buyer's
|
|
93
|
+
country, and the original "calculated at checkout" row otherwise. */}
|
|
94
|
+
<TaxEstimateLine />
|
|
95
|
+
|
|
96
|
+
{/* Divider */}
|
|
97
|
+
<Separator className="mt-2" />
|
|
98
|
+
<div className="pt-1">
|
|
99
|
+
<div className="flex items-center justify-between">
|
|
100
|
+
<span className="text-foreground font-semibold">{tc('total')}</span>
|
|
101
|
+
<span className="text-foreground text-base font-semibold">
|
|
102
|
+
{formatPrice(totals.total, { currency }) as string}
|
|
103
|
+
</span>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -14,6 +14,7 @@ import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
|
|
|
14
14
|
import { CartRecommendationSection } from '@/ui/product/recommendation-section';
|
|
15
15
|
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
16
16
|
import { Button } from '@/components/ui/button';
|
|
17
|
+
import { Checkbox } from '@/components/ui/checkbox';
|
|
17
18
|
import { Card } from '@/components/ui/card';
|
|
18
19
|
import { Separator } from '@/components/ui/separator';
|
|
19
20
|
import { useTranslations } from '@/core/lib/translations';
|
|
@@ -32,6 +33,12 @@ export function CartView() {
|
|
|
32
33
|
bundles,
|
|
33
34
|
reservationExpired,
|
|
34
35
|
unavailableItems,
|
|
36
|
+
selectedItemIds,
|
|
37
|
+
selectedItems,
|
|
38
|
+
toggleItemSelected,
|
|
39
|
+
allSelected,
|
|
40
|
+
setAllSelected,
|
|
41
|
+
checkoutHref,
|
|
35
42
|
canProceedToCheckout,
|
|
36
43
|
onReservationExpired,
|
|
37
44
|
} = useCartPage();
|
|
@@ -86,11 +93,33 @@ export function CartView() {
|
|
|
86
93
|
<CartNudges nudges={cart.nudges} className="mb-4" />
|
|
87
94
|
)}
|
|
88
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
|
+
|
|
89
110
|
{/* Cart items */}
|
|
90
111
|
<div>
|
|
91
112
|
{cart.items.map((item) => (
|
|
92
113
|
<div key={item.id}>
|
|
93
|
-
<
|
|
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>
|
|
94
123
|
{upgrades?.upgrades?.[item.productId] && (
|
|
95
124
|
<CartUpgradeBanner
|
|
96
125
|
suggestion={upgrades.upgrades[item.productId]}
|
|
@@ -129,14 +158,28 @@ export function CartView() {
|
|
|
129
158
|
<div className="lg:col-span-1">
|
|
130
159
|
<Card className="bg-muted/50 sticky top-24 p-6 shadow-none">
|
|
131
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. */}
|
|
132
164
|
<CartSummary />
|
|
133
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
|
+
|
|
134
175
|
{/* Proceed to checkout. When the gate is closed this must be a
|
|
135
176
|
real disabled control, never a styled-down link: an anchor
|
|
136
177
|
ignores `disabled` and would still navigate. */}
|
|
137
178
|
{canProceedToCheckout ? (
|
|
138
179
|
<Button asChild size="lg" className="mt-6 w-full rounded px-6 text-sm">
|
|
139
|
-
|
|
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>
|
|
140
183
|
</Button>
|
|
141
184
|
) : (
|
|
142
185
|
<>
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Non-binding tax preview for the cart.
|
|
5
|
+
*
|
|
6
|
+
* Shoppers in VAT markets expect a tax figure before they reach the address
|
|
7
|
+
* form, and "calculated at checkout" reads as an unpleasant surprise waiting
|
|
8
|
+
* to happen. `estimateTax` gives them a number early without pretending it is
|
|
9
|
+
* final.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ NEVER PRESENT THIS AS THE REAL TAX. The authoritative calculation runs at
|
|
12
|
+
* checkout against the full shipping address. A preview has no province, so a
|
|
13
|
+
* Canadian estimate shows federal GST alone and the provincial PST/QST joins
|
|
14
|
+
* it at checkout. That is what `note` is for, and why the "calculated at
|
|
15
|
+
* checkout" caveat stays on the row even when a figure is showing.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ NO ESTIMATE IS RENDERED when `appliesTax` is false, which is what an
|
|
18
|
+
* empty or freshly created store returns: tax is off, no country reached us,
|
|
19
|
+
* or no active rate covers it. In that case (and when the call fails, and
|
|
20
|
+
* while it is in flight, and on an empty cart) this falls back to EXACTLY the
|
|
21
|
+
* row the cart shipped with before the estimate existed: "Tax / calculated at
|
|
22
|
+
* checkout". There is no spinner, no error box, and no empty amount.
|
|
23
|
+
*
|
|
24
|
+
* `getStoreTaxClasses()` would let you add a separate "9% VAT" transparency
|
|
25
|
+
* badge, but `rateName` + `rate` already come back on this response and say
|
|
26
|
+
* the same thing, so a second request would buy nothing.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { useEffect, useState } from 'react';
|
|
30
|
+
import type { TaxEstimateResponse } from 'brainerce';
|
|
31
|
+
import { formatPrice } from 'brainerce';
|
|
32
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
33
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
34
|
+
import { useCart, useRegion } from '@/core/providers/store-provider';
|
|
35
|
+
|
|
36
|
+
export function useTaxEstimate(): TaxEstimateResponse | null {
|
|
37
|
+
const { totals } = useCart();
|
|
38
|
+
// The same country the region lookup already extracted from the edge. Asking
|
|
39
|
+
// for it twice would be two sources that can disagree.
|
|
40
|
+
const { buyerCountry } = useRegion();
|
|
41
|
+
const [estimate, setEstimate] = useState<TaxEstimateResponse | null>(null);
|
|
42
|
+
const subtotal = totals.subtotal;
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
// An empty cart has nothing to tax. Skipping the call also keeps a
|
|
46
|
+
// brand-new store from firing a request on every page load forever.
|
|
47
|
+
if (!(subtotal > 0)) {
|
|
48
|
+
setEstimate(null);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
getClient()
|
|
53
|
+
.estimateTax({ ...(buyerCountry ? { country: buyerCountry } : {}), subtotal })
|
|
54
|
+
.then((result) => {
|
|
55
|
+
if (!cancelled) setEstimate(result);
|
|
56
|
+
})
|
|
57
|
+
.catch(() => {
|
|
58
|
+
// Swallowed on purpose. A tax preview is a courtesy; it must never
|
|
59
|
+
// take the cart down with it. The caller falls back to the plain
|
|
60
|
+
// "calculated at checkout" row.
|
|
61
|
+
if (!cancelled) setEstimate(null);
|
|
62
|
+
});
|
|
63
|
+
return () => {
|
|
64
|
+
cancelled = true;
|
|
65
|
+
};
|
|
66
|
+
}, [subtotal, buyerCountry]);
|
|
67
|
+
|
|
68
|
+
return estimate;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function TaxEstimateLine() {
|
|
72
|
+
const t = useTranslations('cart');
|
|
73
|
+
const tc = useTranslations('common');
|
|
74
|
+
const estimate = useTaxEstimate();
|
|
75
|
+
|
|
76
|
+
// No estimate: the row the cart has always shown.
|
|
77
|
+
if (!estimate || !estimate.appliesTax) {
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex items-center justify-between">
|
|
80
|
+
<span className="text-muted-foreground">{tc('tax')}</span>
|
|
81
|
+
<span className="text-muted-foreground text-xs">{t('taxAtCheckout')}</span>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// `rateName` is one rate or several joined ("GST + QST"); `rate` is the
|
|
87
|
+
// summed effective percent and is null when no rule matched.
|
|
88
|
+
const label =
|
|
89
|
+
estimate.rateName && estimate.rate != null
|
|
90
|
+
? `${estimate.rateName} ${estimate.rate}%`
|
|
91
|
+
: (estimate.rateName ?? tc('tax'));
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="space-y-1">
|
|
95
|
+
<div className="flex items-center justify-between">
|
|
96
|
+
<span className="text-muted-foreground">{label}</span>
|
|
97
|
+
<span className="text-foreground font-medium">
|
|
98
|
+
{formatPrice(estimate.estimatedTax, { currency: estimate.currency }) as string}
|
|
99
|
+
</span>
|
|
100
|
+
</div>
|
|
101
|
+
{/* The caveat travels WITH the number, not in a footnote nobody reads. */}
|
|
102
|
+
<p className="text-muted-foreground text-xs">{t('taxAtCheckout')}</p>
|
|
103
|
+
{/* Server-written and already localized. Never replace it with copy of
|
|
104
|
+
your own, it is what explains a partial estimate. */}
|
|
105
|
+
{estimate.note ? <p className="text-muted-foreground text-xs">{estimate.note}</p> : null}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|