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.
Files changed (88) hide show
  1. package/dist/index.js +37 -4
  2. package/messages/en.json +30 -1
  3. package/messages/he.json +30 -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/TRANSLATIONS.md +207 -207
  9. package/templates/nextjs/base/src/app/account/page.tsx +168 -122
  10. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
  11. package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
  12. package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
  13. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
  14. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  15. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
  16. package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
  17. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
  18. package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
  19. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
  20. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
  21. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
  22. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
  23. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
  24. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
  25. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
  26. package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
  27. package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
  28. package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
  29. package/templates/nextjs/base/src/core/lib/region.ts +112 -0
  30. package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
  31. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
  32. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
  33. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
  34. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
  35. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
  37. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
  38. package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
  39. package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
  40. package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
  41. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
  42. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
  43. package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
  44. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
  45. package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
  46. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
  47. package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
  48. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
  49. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
  50. package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
  51. package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
  52. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
  53. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
  54. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
  55. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
  56. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
  57. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
  58. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
  59. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
  60. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
  61. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
  62. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
  63. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
  64. package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
  65. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
  66. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
  67. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
  68. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
  69. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
  70. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
  71. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
  72. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
  73. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
  74. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
  75. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
  76. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
  77. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
  78. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
  79. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
  80. package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
  81. package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
  82. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
  83. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
  84. package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
  85. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
  86. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
  87. package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
  88. 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 { Card } from '@/components/ui/card';
18
- import { Separator } from '@/components/ui/separator';
19
- import { useTranslations } from '@/core/lib/translations';
20
-
21
- export function CartView() {
22
- const t = useTranslations('cart');
23
- const tc = useTranslations('common');
24
- const tr = useTranslations('reservation');
25
- const {
26
- cart,
27
- cartLoading,
28
- refreshCart,
29
- itemCount,
30
- cartRecs,
31
- upgrades,
32
- bundles,
33
- reservationExpired,
34
- unavailableItems,
35
- canProceedToCheckout,
36
- onReservationExpired,
37
- } = useCartPage();
38
-
39
- if (cartLoading) {
40
- return (
41
- <div className="flex min-h-[60vh] items-center justify-center">
42
- <LoadingSpinner size="lg" />
43
- </div>
44
- );
45
- }
46
-
47
- // Empty cart state
48
- if (!cart || cart.items.length === 0) {
49
- return (
50
- <div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
51
- <ShoppingBag
52
- className="text-muted-foreground mx-auto mb-4 h-16 w-16"
53
- strokeWidth={1.5}
54
- aria-hidden="true"
55
- />
56
- <h1 className="text-foreground text-2xl font-bold">{t('emptyTitle')}</h1>
57
- <p className="text-muted-foreground mt-2">{t('emptySubtitle')}</p>
58
- <Button asChild size="lg" className="mt-6 rounded px-6 text-base">
59
- <Link href="/products">{tc('continueShopping')}</Link>
60
- </Button>
61
- </div>
62
- );
63
- }
64
-
65
- return (
66
- <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
67
- <h1 className="text-foreground mb-6 text-2xl font-bold">
68
- {t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
69
- </h1>
70
-
71
- {/* Reservation countdown. onExpire refreshes the cart and closes the
72
- checkout gate below. Passing it is what makes expiry mean anything. */}
73
- {cart.reservation?.hasReservation && (
74
- <ReservationCountdown
75
- reservation={cart.reservation}
76
- onExpire={onReservationExpired}
77
- className="mb-6"
78
- />
79
- )}
80
-
81
- <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
82
- {/* Cart Items */}
83
- <div className="lg:col-span-2">
84
- {/* Nudges */}
85
- {cart.nudges && cart.nudges.length > 0 && (
86
- <CartNudges nudges={cart.nudges} className="mb-4" />
87
- )}
88
-
89
- {/* Cart items */}
90
- <div>
91
- {cart.items.map((item) => (
92
- <div key={item.id}>
93
- <CartItem item={item} onUpdate={refreshCart} />
94
- {upgrades?.upgrades?.[item.productId] && (
95
- <CartUpgradeBanner
96
- suggestion={upgrades.upgrades[item.productId]}
97
- cartItem={item}
98
- onUpgrade={refreshCart}
99
- className="mb-2 ms-24"
100
- />
101
- )}
102
- </div>
103
- ))}
104
- </div>
105
-
106
- {/* Bundle offers */}
107
- {bundles?.bundles && bundles.bundles.length > 0 && (
108
- <div className="mt-6 space-y-3">
109
- <h3 className="text-foreground text-sm font-semibold">{t('bundleOffers')}</h3>
110
- {bundles.bundles.map((offer) => (
111
- <CartBundleOfferCard
112
- key={offer.id}
113
- offer={offer}
114
- cartId={cart.id}
115
- onAdd={refreshCart}
116
- />
117
- ))}
118
- </div>
119
- )}
120
-
121
- {/* Coupon input */}
122
- <Separator className="mt-6" />
123
- <div className="pt-4">
124
- <CouponInput cart={cart} onUpdate={refreshCart} />
125
- </div>
126
- </div>
127
-
128
- {/* Summary sidebar */}
129
- <div className="lg:col-span-1">
130
- <Card className="bg-muted/50 sticky top-24 p-6 shadow-none">
131
- <FreeShippingBar className="mb-4" />
132
- <CartSummary />
133
-
134
- {/* Proceed to checkout. When the gate is closed this must be a
135
- real disabled control, never a styled-down link: an anchor
136
- ignores `disabled` and would still navigate. */}
137
- {canProceedToCheckout ? (
138
- <Button asChild size="lg" className="mt-6 w-full rounded px-6 text-sm">
139
- <Link href="/checkout">{t('proceedToCheckout')}</Link>
140
- </Button>
141
- ) : (
142
- <>
143
- <Button disabled size="lg" className="mt-6 w-full rounded px-6 text-sm">
144
- {t('proceedToCheckout')}
145
- </Button>
146
- <p className="text-destructive mt-2 text-xs">
147
- {unavailableItems.length > 0
148
- ? t('unavailableItemsHint')
149
- : reservationExpired
150
- ? tr('expiredHint')
151
- : null}
152
- </p>
153
- </>
154
- )}
155
-
156
- <Link
157
- href="/products"
158
- 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"
159
- >
160
- {tc('continueShopping')}
161
- </Link>
162
- </Card>
163
- </div>
164
- </div>
165
-
166
- {/* Cross-sell recommendations */}
167
- {cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
168
- <CartRecommendationSection
169
- title={t('youMightAlsoNeed')}
170
- items={cartRecs.recommendations}
171
- className="mt-10"
172
- />
173
- )}
174
- </div>
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
+ }