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,99 +1,98 @@
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 { cn } from '@/core/lib/utils';
8
-
9
- interface CartSummaryProps {
10
- className?: string;
11
- }
12
-
13
- /**
14
- * DESIGN ME — order summary (subtotal, rule/coupon discounts, shipping, tax
15
- * note, total); all figures come from useCart().totals and useCart().cart.
16
- * Keep every discount branch merchants rely on the per-rule breakdown.
17
- *
18
- * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
19
- */
20
- export function CartSummary({ className }: CartSummaryProps) {
21
- const t = useTranslations('cart');
22
- const tc = useTranslations('common');
23
- const { totals, cart } = useCart();
24
- const currency = useCurrency();
25
-
26
- const rules = cart?.appliedDiscounts;
27
- const ruleAmt = cart?.ruleDiscountAmount ? parseFloat(cart.ruleDiscountAmount) : 0;
28
- const couponAmt = totals.discount - ruleAmt;
29
-
30
- return (
31
- <section className={cn(className)}>
32
- <h3>{t('orderSummary')}</h3>
33
-
34
- <dl>
35
- {/* Subtotal */}
36
- <div className="flex items-center justify-between gap-4">
37
- <dt>{tc('subtotal')}</dt>
38
- <dd>{formatPrice(totals.subtotal, { currency }) as string}</dd>
39
- </div>
40
-
41
- {/* Rule discounts - show each rule by name */}
42
- {rules && rules.length > 0
43
- ? rules.map((rule) => (
44
- <div key={rule.ruleId} className="flex items-center justify-between gap-4">
45
- <dt>{rule.ruleName}</dt>
46
- <dd>-{formatPrice(parseFloat(rule.discountAmount), { currency }) as string}</dd>
47
- </div>
48
- ))
49
- : ruleAmt > 0 && (
50
- <div className="flex items-center justify-between gap-4">
51
- <dt>{tc('generalDiscount')}</dt>
52
- <dd>-{formatPrice(ruleAmt, { currency }) as string}</dd>
53
- </div>
54
- )}
55
-
56
- {/* Coupon discount */}
57
- {cart?.couponCode && couponAmt > 0 && (
58
- <div className="flex items-center justify-between gap-4">
59
- <dt>
60
- {tc('couponDiscount')} ({cart.couponCode})
61
- </dt>
62
- <dd>-{formatPrice(couponAmt, { currency }) as string}</dd>
63
- </div>
64
- )}
65
-
66
- {/* Fallback: generic discount when no breakdown available */}
67
- {totals.discount > 0 &&
68
- ruleAmt <= 0 &&
69
- !cart?.couponCode &&
70
- (!rules || rules.length === 0) && (
71
- <div className="flex items-center justify-between gap-4">
72
- <dt>{tc('discount')}</dt>
73
- <dd>-{formatPrice(totals.discount, { currency }) as string}</dd>
74
- </div>
75
- )}
76
-
77
- {/* Shipping */}
78
- {totals.shipping > 0 && (
79
- <div className="flex items-center justify-between gap-4">
80
- <dt>{tc('shipping')}</dt>
81
- <dd>{formatPrice(totals.shipping, { currency }) as string}</dd>
82
- </div>
83
- )}
84
-
85
- {/* Tax */}
86
- <div className="flex items-center justify-between gap-4">
87
- <dt>{tc('tax')}</dt>
88
- <dd>{t('taxAtCheckout')}</dd>
89
- </div>
90
-
91
- {/* Total */}
92
- <div className="flex items-center justify-between gap-4">
93
- <dt>{tc('total')}</dt>
94
- <dd>{formatPrice(totals.total, { currency }) as string}</dd>
95
- </div>
96
- </dl>
97
- </section>
98
- );
99
- }
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 { cn } from '@/core/lib/utils';
9
+
10
+ interface CartSummaryProps {
11
+ className?: string;
12
+ }
13
+
14
+ /**
15
+ * DESIGN ME order summary (subtotal, rule/coupon discounts, shipping, tax
16
+ * note, total); all figures come from useCart().totals and useCart().cart.
17
+ * Keep every discount branch — merchants rely on the per-rule breakdown.
18
+ *
19
+ * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
20
+ */
21
+ export function CartSummary({ className }: CartSummaryProps) {
22
+ const t = useTranslations('cart');
23
+ const tc = useTranslations('common');
24
+ const { totals, cart } = useCart();
25
+ const currency = useCurrency();
26
+
27
+ const rules = cart?.appliedDiscounts;
28
+ const ruleAmt = cart?.ruleDiscountAmount ? parseFloat(cart.ruleDiscountAmount) : 0;
29
+ const couponAmt = totals.discount - ruleAmt;
30
+
31
+ return (
32
+ <section className={cn(className)}>
33
+ <h3>{t('orderSummary')}</h3>
34
+
35
+ <dl>
36
+ {/* Subtotal */}
37
+ <div className="flex items-center justify-between gap-4">
38
+ <dt>{tc('subtotal')}</dt>
39
+ <dd>{formatPrice(totals.subtotal, { currency }) as string}</dd>
40
+ </div>
41
+
42
+ {/* Rule discounts - show each rule by name */}
43
+ {rules && rules.length > 0
44
+ ? rules.map((rule) => (
45
+ <div key={rule.ruleId} className="flex items-center justify-between gap-4">
46
+ <dt>{rule.ruleName}</dt>
47
+ <dd>-{formatPrice(parseFloat(rule.discountAmount), { currency }) as string}</dd>
48
+ </div>
49
+ ))
50
+ : ruleAmt > 0 && (
51
+ <div className="flex items-center justify-between gap-4">
52
+ <dt>{tc('generalDiscount')}</dt>
53
+ <dd>-{formatPrice(ruleAmt, { currency }) as string}</dd>
54
+ </div>
55
+ )}
56
+
57
+ {/* Coupon discount */}
58
+ {cart?.couponCode && couponAmt > 0 && (
59
+ <div className="flex items-center justify-between gap-4">
60
+ <dt>
61
+ {tc('couponDiscount')} ({cart.couponCode})
62
+ </dt>
63
+ <dd>-{formatPrice(couponAmt, { currency }) as string}</dd>
64
+ </div>
65
+ )}
66
+
67
+ {/* Fallback: generic discount when no breakdown available */}
68
+ {totals.discount > 0 &&
69
+ ruleAmt <= 0 &&
70
+ !cart?.couponCode &&
71
+ (!rules || rules.length === 0) && (
72
+ <div className="flex items-center justify-between gap-4">
73
+ <dt>{tc('discount')}</dt>
74
+ <dd>-{formatPrice(totals.discount, { currency }) as string}</dd>
75
+ </div>
76
+ )}
77
+
78
+ {/* Shipping */}
79
+ {totals.shipping > 0 && (
80
+ <div className="flex items-center justify-between gap-4">
81
+ <dt>{tc('shipping')}</dt>
82
+ <dd>{formatPrice(totals.shipping, { currency }) as string}</dd>
83
+ </div>
84
+ )}
85
+
86
+ {/* Tax: a real estimate when the store has a rate for the buyer's
87
+ country, and the original "calculated at checkout" row otherwise. */}
88
+ <TaxEstimateLine />
89
+
90
+ {/* Total */}
91
+ <div className="flex items-center justify-between gap-4">
92
+ <dt>{tc('total')}</dt>
93
+ <dd>{formatPrice(totals.total, { currency }) as string}</dd>
94
+ </div>
95
+ </dl>
96
+ </section>
97
+ );
98
+ }
@@ -1,140 +1,140 @@
1
- 'use client';
2
-
3
- import { Link } from '@/core/lib/navigation';
4
- import { useCartPage } from '@/core/hooks/use-cart-page';
5
- import { CartItem } from '@/ui/cart/cart-item';
6
- import { CartUpgradeBanner } from '@/ui/cart/cart-upgrade-banner';
7
- import { CartBundleOfferCard } from '@/ui/cart/cart-bundle-offer';
8
- import { CartSummary } from '@/ui/cart/cart-summary';
9
- import { CouponInput } from '@/ui/cart/coupon-input';
10
- import { CartNudges } from '@/ui/cart/cart-nudges';
11
- import { FreeShippingBar } from '@/ui/cart/free-shipping-bar';
12
- import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
13
- import { CartRecommendationSection } from '@/ui/product/recommendation-section';
14
- import { LoadingSpinner } from '@/ui/shared/loading-spinner';
15
- import { useTranslations } from '@/core/lib/translations';
16
-
17
- export function CartView() {
18
- const t = useTranslations('cart');
19
- const tc = useTranslations('common');
20
- const tr = useTranslations('reservation');
21
- const {
22
- cart,
23
- cartLoading,
24
- refreshCart,
25
- itemCount,
26
- cartRecs,
27
- upgrades,
28
- bundles,
29
- reservationExpired,
30
- unavailableItems,
31
- canProceedToCheckout,
32
- onReservationExpired,
33
- } = useCartPage();
34
-
35
- if (cartLoading) {
36
- return <LoadingSpinner size="lg" />;
37
- }
38
-
39
- // Empty cart state
40
- if (!cart || cart.items.length === 0) {
41
- return (
42
- <section>
43
- {/* DESIGN ME — empty-cart state: message + continue-shopping CTA; state comes from useCartPage(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
44
- <h1>{t('emptyTitle')}</h1>
45
- <p>{t('emptySubtitle')}</p>
46
- <Link href="/products">{tc('continueShopping')}</Link>
47
- </section>
48
- );
49
- }
50
-
51
- return (
52
- <section>
53
- {/* DESIGN ME — cart page: items list, upgrade/bundle offers, coupon, summary sidebar, checkout CTA, cross-sells; everything comes from useCartPage(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
54
- <h1>
55
- {t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
56
- </h1>
57
-
58
- {/* Reservation countdown. onExpire refreshes the cart and closes the
59
- checkout gate below. Passing it is what makes expiry mean anything. */}
60
- {cart.reservation?.hasReservation && (
61
- <ReservationCountdown reservation={cart.reservation} onExpire={onReservationExpired} />
62
- )}
63
-
64
- <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
65
- {/* Cart Items */}
66
- <div className="lg:col-span-2">
67
- {/* Nudges */}
68
- {cart.nudges && cart.nudges.length > 0 && <CartNudges nudges={cart.nudges} />}
69
-
70
- {/* Cart items */}
71
- <ul>
72
- {cart.items.map((item) => (
73
- <li key={item.id}>
74
- <CartItem item={item} onUpdate={refreshCart} />
75
- {upgrades?.upgrades?.[item.productId] && (
76
- <CartUpgradeBanner
77
- suggestion={upgrades.upgrades[item.productId]}
78
- cartItem={item}
79
- onUpgrade={refreshCart}
80
- />
81
- )}
82
- </li>
83
- ))}
84
- </ul>
85
-
86
- {/* Bundle offers */}
87
- {bundles?.bundles && bundles.bundles.length > 0 && (
88
- <section>
89
- <h3>{t('bundleOffers')}</h3>
90
- {bundles.bundles.map((offer) => (
91
- <CartBundleOfferCard
92
- key={offer.id}
93
- offer={offer}
94
- cartId={cart.id}
95
- onAdd={refreshCart}
96
- />
97
- ))}
98
- </section>
99
- )}
100
-
101
- {/* Coupon input */}
102
- <CouponInput cart={cart} onUpdate={refreshCart} />
103
- </div>
104
-
105
- {/* Summary sidebar */}
106
- <aside className="lg:col-span-1">
107
- <FreeShippingBar />
108
- <CartSummary />
109
-
110
- {/* Proceed to checkout. When the gate is closed this must be a real
111
- disabled control, never a styled-down link: an anchor ignores
112
- `disabled` and would still navigate. */}
113
- {canProceedToCheckout ? (
114
- <Link href="/checkout">{t('proceedToCheckout')}</Link>
115
- ) : (
116
- <>
117
- <button type="button" disabled>
118
- {t('proceedToCheckout')}
119
- </button>
120
- <p data-state="blocked">
121
- {unavailableItems.length > 0
122
- ? t('unavailableItemsHint')
123
- : reservationExpired
124
- ? tr('expiredHint')
125
- : null}
126
- </p>
127
- </>
128
- )}
129
-
130
- <Link href="/products">{tc('continueShopping')}</Link>
131
- </aside>
132
- </div>
133
-
134
- {/* Cross-sell recommendations */}
135
- {cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
136
- <CartRecommendationSection title={t('youMightAlsoNeed')} items={cartRecs.recommendations} />
137
- )}
138
- </section>
139
- );
140
- }
1
+ 'use client';
2
+
3
+ import { Link } from '@/core/lib/navigation';
4
+ import { useCartPage } from '@/core/hooks/use-cart-page';
5
+ import { CartItem } from '@/ui/cart/cart-item';
6
+ import { CartUpgradeBanner } from '@/ui/cart/cart-upgrade-banner';
7
+ import { CartBundleOfferCard } from '@/ui/cart/cart-bundle-offer';
8
+ import { CartSummary } from '@/ui/cart/cart-summary';
9
+ import { CouponInput } from '@/ui/cart/coupon-input';
10
+ import { CartNudges } from '@/ui/cart/cart-nudges';
11
+ import { FreeShippingBar } from '@/ui/cart/free-shipping-bar';
12
+ import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
13
+ import { CartRecommendationSection } from '@/ui/product/recommendation-section';
14
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
15
+ import { useTranslations } from '@/core/lib/translations';
16
+
17
+ export function CartView() {
18
+ const t = useTranslations('cart');
19
+ const tc = useTranslations('common');
20
+ const tr = useTranslations('reservation');
21
+ const {
22
+ cart,
23
+ cartLoading,
24
+ refreshCart,
25
+ itemCount,
26
+ cartRecs,
27
+ upgrades,
28
+ bundles,
29
+ reservationExpired,
30
+ unavailableItems,
31
+ canProceedToCheckout,
32
+ onReservationExpired,
33
+ } = useCartPage();
34
+
35
+ if (cartLoading) {
36
+ return <LoadingSpinner size="lg" />;
37
+ }
38
+
39
+ // Empty cart state
40
+ if (!cart || cart.items.length === 0) {
41
+ return (
42
+ <section>
43
+ {/* DESIGN ME — empty-cart state: message + continue-shopping CTA; state comes from useCartPage(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
44
+ <h1>{t('emptyTitle')}</h1>
45
+ <p>{t('emptySubtitle')}</p>
46
+ <Link href="/products">{tc('continueShopping')}</Link>
47
+ </section>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <section>
53
+ {/* DESIGN ME — cart page: items list, upgrade/bundle offers, coupon, summary sidebar, checkout CTA, cross-sells; everything comes from useCartPage(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
54
+ <h1>
55
+ {t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
56
+ </h1>
57
+
58
+ {/* Reservation countdown. onExpire refreshes the cart and closes the
59
+ checkout gate below. Passing it is what makes expiry mean anything. */}
60
+ {cart.reservation?.hasReservation && (
61
+ <ReservationCountdown reservation={cart.reservation} onExpire={onReservationExpired} />
62
+ )}
63
+
64
+ <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
65
+ {/* Cart Items */}
66
+ <div className="lg:col-span-2">
67
+ {/* Nudges */}
68
+ {cart.nudges && cart.nudges.length > 0 && <CartNudges nudges={cart.nudges} />}
69
+
70
+ {/* Cart items */}
71
+ <ul>
72
+ {cart.items.map((item) => (
73
+ <li key={item.id}>
74
+ <CartItem item={item} onUpdate={refreshCart} />
75
+ {upgrades?.upgrades?.[item.productId] && (
76
+ <CartUpgradeBanner
77
+ suggestion={upgrades.upgrades[item.productId]}
78
+ cartItem={item}
79
+ onUpgrade={refreshCart}
80
+ />
81
+ )}
82
+ </li>
83
+ ))}
84
+ </ul>
85
+
86
+ {/* Bundle offers */}
87
+ {bundles?.bundles && bundles.bundles.length > 0 && (
88
+ <section>
89
+ <h3>{t('bundleOffers')}</h3>
90
+ {bundles.bundles.map((offer) => (
91
+ <CartBundleOfferCard
92
+ key={offer.id}
93
+ offer={offer}
94
+ cartId={cart.id}
95
+ onAdd={refreshCart}
96
+ />
97
+ ))}
98
+ </section>
99
+ )}
100
+
101
+ {/* Coupon input */}
102
+ <CouponInput cart={cart} onUpdate={refreshCart} />
103
+ </div>
104
+
105
+ {/* Summary sidebar */}
106
+ <aside className="lg:col-span-1">
107
+ <FreeShippingBar />
108
+ <CartSummary />
109
+
110
+ {/* Proceed to checkout. When the gate is closed this must be a real
111
+ disabled control, never a styled-down link: an anchor ignores
112
+ `disabled` and would still navigate. */}
113
+ {canProceedToCheckout ? (
114
+ <Link href="/checkout">{t('proceedToCheckout')}</Link>
115
+ ) : (
116
+ <>
117
+ <button type="button" disabled>
118
+ {t('proceedToCheckout')}
119
+ </button>
120
+ <p data-state="blocked">
121
+ {unavailableItems.length > 0
122
+ ? t('unavailableItemsHint')
123
+ : reservationExpired
124
+ ? tr('expiredHint')
125
+ : null}
126
+ </p>
127
+ </>
128
+ )}
129
+
130
+ <Link href="/products">{tc('continueShopping')}</Link>
131
+ </aside>
132
+ </div>
133
+
134
+ {/* Cross-sell recommendations */}
135
+ {cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
136
+ <CartRecommendationSection title={t('youMightAlsoNeed')} items={cartRecs.recommendations} />
137
+ )}
138
+ </section>
139
+ );
140
+ }
@@ -0,0 +1,142 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { Checkout } from 'brainerce';
5
+ import { getClient } from '@/core/lib/brainerce';
6
+ import { useTranslations } from '@/core/lib/translations';
7
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
8
+ import { cn } from '@/core/lib/utils';
9
+
10
+ interface GiftCardInputProps {
11
+ checkoutId: string;
12
+ /** Cards already on this checkout. Each is removed by its own tenderId. */
13
+ tenders: NonNullable<Checkout['tenders']>;
14
+ /** Formats an amount in the checkout's currency. */
15
+ formatAmount: (value: string) => string;
16
+ onUpdate: () => void;
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * DESIGN ME — gift-card redemption control on the checkout page; `tenders` and
22
+ * `formatAmount` come from the checkout page's own state.
23
+ *
24
+ * ⛔ Do NOT design this as a second coupon field. A coupon reduces what the
25
+ * order is worth; a gift card PAYS for an order that is still worth what it
26
+ * was. The total does not move and tax is unchanged — only the amount charged
27
+ * to the payment provider drops. Style the applied card as its own line near
28
+ * the total, never inside the discount row, or the shopper is shown one number
29
+ * and charged another and the receipt misstates the taxable base.
30
+ *
31
+ * Keep: the list of applied cards (each removable by `tenderId`), the code
32
+ * field, and the error message. A checkout can carry several cards at once.
33
+ *
34
+ * Keep `dir="ltr"` on the input — the code is Latin even on an RTL storefront.
35
+ *
36
+ * Refusals are deliberately identical for "no such code", "expired", "already
37
+ * spent" and "wrong currency": anything that tells them apart is an oracle
38
+ * someone walks the code space against. Show what the server said; never guess
39
+ * a more specific reason.
40
+ *
41
+ * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
42
+ */
43
+ export function GiftCardInput({
44
+ checkoutId,
45
+ tenders,
46
+ formatAmount,
47
+ onUpdate,
48
+ className,
49
+ }: GiftCardInputProps) {
50
+ const t = useTranslations('giftCard');
51
+ const tc = useTranslations('common');
52
+ const [code, setCode] = useState('');
53
+ const [applying, setApplying] = useState(false);
54
+ const [removingId, setRemovingId] = useState<string | null>(null);
55
+ const [error, setError] = useState<string | null>(null);
56
+
57
+ async function handleApply() {
58
+ const trimmed = code.trim();
59
+ if (!trimmed || applying) return;
60
+
61
+ try {
62
+ setApplying(true);
63
+ setError(null);
64
+ await getClient().applyGiftCard(checkoutId, trimmed);
65
+ // Cleared on success only. A rejected code stays in the field so the
66
+ // shopper can fix a typo instead of re-reading it off the card.
67
+ setCode('');
68
+ onUpdate();
69
+ } catch (err) {
70
+ setError(err instanceof Error ? err.message : t('invalidCode'));
71
+ } finally {
72
+ setApplying(false);
73
+ }
74
+ }
75
+
76
+ async function handleRemove(tenderId: string) {
77
+ if (removingId) return;
78
+
79
+ try {
80
+ setRemovingId(tenderId);
81
+ setError(null);
82
+ await getClient().removeGiftCard(checkoutId, tenderId);
83
+ onUpdate();
84
+ } catch (err) {
85
+ setError(err instanceof Error ? err.message : t('removeFailed'));
86
+ } finally {
87
+ setRemovingId(null);
88
+ }
89
+ }
90
+
91
+ return (
92
+ <div className={cn(className)}>
93
+ {tenders.length > 0 && (
94
+ <ul>
95
+ {tenders.map((tender) => (
96
+ <li key={tender.tenderId}>
97
+ {/* What was applied, not what the card holds. A ₪200 card on a
98
+ ₪50 order applies ₪50 and keeps the rest for next time —
99
+ showing the balance here would look like a lost ₪150. */}
100
+ <span>{t('applied', { amount: formatAmount(tender.amountApplied) })}</span>
101
+ <button
102
+ type="button"
103
+ onClick={() => handleRemove(tender.tenderId)}
104
+ disabled={removingId === tender.tenderId}
105
+ >
106
+ {removingId === tender.tenderId ? tc('removing') : tc('remove')}
107
+ </button>
108
+ </li>
109
+ ))}
110
+ </ul>
111
+ )}
112
+
113
+ <div className="flex gap-2">
114
+ <input
115
+ type="text"
116
+ value={code}
117
+ onChange={(e) => {
118
+ setCode(e.target.value);
119
+ if (error) setError(null);
120
+ }}
121
+ onKeyDown={(e) => {
122
+ if (e.key === 'Enter') {
123
+ e.preventDefault();
124
+ handleApply();
125
+ }
126
+ }}
127
+ placeholder={t('placeholder')}
128
+ aria-label={t('placeholder')}
129
+ aria-invalid={error ? true : undefined}
130
+ dir="ltr"
131
+ autoComplete="off"
132
+ spellCheck={false}
133
+ />
134
+ <button type="button" onClick={handleApply} disabled={applying || !code.trim()}>
135
+ {applying ? <LoadingSpinner size="sm" /> : tc('apply')}
136
+ </button>
137
+ </div>
138
+
139
+ {error && <p role="alert">{error}</p>}
140
+ </div>
141
+ );
142
+ }