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.
Files changed (77) hide show
  1. package/dist/index.js +35 -2
  2. package/messages/en.json +27 -1
  3. package/messages/he.json +27 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/AGENTS.md.ejs +63 -16
  6. package/templates/nextjs/base/AI-GUIDE.md +17 -4
  7. package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
  8. package/templates/nextjs/base/src/app/account/page.tsx +168 -122
  9. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
  10. package/templates/nextjs/base/src/app/checkout/page.tsx +145 -40
  11. package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
  12. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
  13. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  14. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
  15. package/templates/nextjs/base/src/components/account/order-history.tsx +58 -7
  16. package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
  17. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
  18. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
  19. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
  20. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +97 -6
  21. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
  22. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
  23. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
  24. package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
  25. package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
  26. package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
  27. package/templates/nextjs/base/src/core/lib/region.ts +112 -0
  28. package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
  29. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
  30. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +10 -0
  31. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
  32. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +45 -2
  33. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
  34. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
  35. package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
  36. package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
  37. package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
  38. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
  39. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
  40. package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
  41. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
  42. package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
  43. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
  44. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
  45. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
  46. package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
  47. package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
  48. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +10 -0
  49. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
  50. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +56 -11
  51. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
  52. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
  53. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
  54. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
  55. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
  56. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
  57. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
  58. package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
  59. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
  60. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
  61. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
  62. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
  63. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
  64. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
  65. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
  66. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
  67. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
  68. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
  69. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
  70. package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
  71. package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
  72. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
  73. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
  74. package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
  75. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
  76. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
  77. 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 { useCurrency } from '@/core/lib/use-currency';
7
- import { Separator } from '@/components/ui/separator';
8
- import { cn } from '@/core/lib/utils';
9
-
10
- interface CartSummaryProps {
11
- className?: string;
12
- }
13
-
14
- export function CartSummary({ className }: CartSummaryProps) {
15
- const t = useTranslations('cart');
16
- const tc = useTranslations('common');
17
- const { totals, cart } = useCart();
18
- const currency = useCurrency();
19
-
20
- const rules = cart?.appliedDiscounts;
21
- const ruleAmt = cart?.ruleDiscountAmount ? parseFloat(cart.ruleDiscountAmount) : 0;
22
- const couponAmt = totals.discount - ruleAmt;
23
-
24
- return (
25
- <div className={cn('space-y-3', className)}>
26
- <h3 className="text-foreground text-lg font-semibold">{t('orderSummary')}</h3>
27
-
28
- <div className="space-y-2 text-sm">
29
- {/* Subtotal */}
30
- <div className="flex items-center justify-between">
31
- <span className="text-muted-foreground">{tc('subtotal')}</span>
32
- <span className="text-foreground font-medium">
33
- {formatPrice(totals.subtotal, { currency }) as string}
34
- </span>
35
- </div>
36
-
37
- {/* Rule discounts - show each rule by name */}
38
- {rules && rules.length > 0
39
- ? rules.map((rule) => (
40
- <div key={rule.ruleId} className="flex items-center justify-between">
41
- <span className="text-muted-foreground">{rule.ruleName}</span>
42
- <span className="text-destructive font-medium">
43
- -{formatPrice(parseFloat(rule.discountAmount), { currency }) as string}
44
- </span>
45
- </div>
46
- ))
47
- : ruleAmt > 0 && (
48
- <div className="flex items-center justify-between">
49
- <span className="text-muted-foreground">{tc('generalDiscount')}</span>
50
- <span className="text-destructive font-medium">
51
- -{formatPrice(ruleAmt, { currency }) as string}
52
- </span>
53
- </div>
54
- )}
55
-
56
- {/* Coupon discount */}
57
- {cart?.couponCode && couponAmt > 0 && (
58
- <div className="flex items-center justify-between">
59
- <span className="text-muted-foreground">
60
- {tc('couponDiscount')} ({cart.couponCode})
61
- </span>
62
- <span className="text-destructive font-medium">
63
- -{formatPrice(couponAmt, { currency }) as string}
64
- </span>
65
- </div>
66
- )}
67
-
68
- {/* Fallback: generic discount when no breakdown available */}
69
- {totals.discount > 0 &&
70
- ruleAmt <= 0 &&
71
- !cart?.couponCode &&
72
- (!rules || rules.length === 0) && (
73
- <div className="flex items-center justify-between">
74
- <span className="text-muted-foreground">{tc('discount')}</span>
75
- <span className="text-destructive font-medium">
76
- -{formatPrice(totals.discount, { currency }) as string}
77
- </span>
78
- </div>
79
- )}
80
-
81
- {/* Shipping */}
82
- {totals.shipping > 0 && (
83
- <div className="flex items-center justify-between">
84
- <span className="text-muted-foreground">{tc('shipping')}</span>
85
- <span className="text-foreground font-medium">
86
- {formatPrice(totals.shipping, { currency }) as string}
87
- </span>
88
- </div>
89
- )}
90
-
91
- {/* Tax */}
92
- <div className="flex items-center justify-between">
93
- <span className="text-muted-foreground">{tc('tax')}</span>
94
- <span className="text-muted-foreground text-xs">{t('taxAtCheckout')}</span>
95
- </div>
96
-
97
- {/* Divider */}
98
- <Separator className="mt-2" />
99
- <div className="pt-1">
100
- <div className="flex items-center justify-between">
101
- <span className="text-foreground font-semibold">{tc('total')}</span>
102
- <span className="text-foreground text-base font-semibold">
103
- {formatPrice(totals.total, { currency }) as string}
104
- </span>
105
- </div>
106
- </div>
107
- </div>
108
- </div>
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
- <CartItem item={item} onUpdate={refreshCart} />
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
- <Link href="/checkout">{t('proceedToCheckout')}</Link>
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
+ }