create-brainerce-store 1.51.0 → 1.52.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 (170) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +9 -68
  3. package/messages/en.json +32 -0
  4. package/messages/he.json +32 -0
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.claude/commands/design.md +9 -0
  7. package/templates/nextjs/base/AI-GUIDE.md +52 -3
  8. package/templates/nextjs/base/CLAUDE.md +10 -0
  9. package/templates/nextjs/base/package.json.ejs +52 -39
  10. package/templates/nextjs/base/src/app/globals.css +36 -30
  11. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -10
  12. package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
  13. package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
  14. package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
  15. package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
  16. package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
  17. package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
  18. package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
  19. package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
  20. package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
  21. package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
  22. package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
  23. package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
  24. package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
  25. package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
  26. package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
  27. package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
  28. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +17 -11
  29. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +2 -8
  30. package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -39
  31. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +3 -1
  32. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +8 -23
  33. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +18 -27
  34. package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +10 -21
  35. package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +2 -3
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -103
  37. package/templates/nextjs/base/src/ui/home/hero-section.tsx +4 -6
  38. package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -125
  39. package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -103
  40. package/templates/nextjs/base/src/ui/layout/header-account.tsx +2 -17
  41. package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -70
  42. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -164
  43. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -182
  44. package/templates/nextjs/base/src/ui/product/customization-fields.tsx +456 -444
  45. package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -22
  46. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +15 -27
  47. package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -175
  48. package/templates/nextjs/base/src/ui/product/price-display.tsx +3 -2
  49. package/templates/nextjs/base/src/ui/product/product-card.tsx +238 -271
  50. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -470
  51. package/templates/nextjs/base/src/ui/product/product-listing.tsx +96 -79
  52. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -107
  53. package/templates/nextjs/base/src/ui/product/review-form.tsx +44 -43
  54. package/templates/nextjs/{designs/passepartout → base/src}/ui/product/reviews-section.tsx.ejs +108 -96
  55. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -63
  56. package/templates/nextjs/base/tailwind.config.ts +69 -42
  57. package/templates/nextjs/designs/{passepartout → atelier}/app-overlay/layout.tsx.ejs +23 -25
  58. package/templates/nextjs/designs/atelier/globals.css +396 -0
  59. package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
  60. package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
  61. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-bundle-offer.tsx +110 -111
  62. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
  63. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
  64. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
  65. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
  66. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-upgrade-banner.tsx +123 -141
  67. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
  68. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
  69. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/free-shipping-bar.tsx +77 -62
  70. package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/reservation-countdown.tsx +89 -103
  71. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
  72. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
  73. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
  74. package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
  75. package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
  76. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
  77. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
  78. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
  79. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
  80. package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
  81. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
  82. package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
  83. package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
  84. package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
  85. package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
  86. package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
  87. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
  88. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
  89. package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
  90. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/discount-badge.tsx +17 -22
  91. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/frequently-bought-together.tsx +196 -205
  92. package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
  93. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
  94. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
  95. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
  96. package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
  97. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
  98. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
  99. package/templates/nextjs/designs/{passepartout → atelier}/ui/product/review-form.tsx +267 -294
  100. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
  101. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
  102. package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
  103. package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
  104. package/templates/nextjs/designs/{passepartout → atelier}/ui/shared/fly-to-cart.ts +86 -85
  105. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
  106. package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
  107. package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
  108. package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
  109. package/templates/nextjs/themes/luxury/globals.css +405 -399
  110. package/templates/nextjs/themes/minimal/globals.css +36 -30
  111. package/templates/nextjs/themes/playful/globals.css +406 -400
  112. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +2 -0
  113. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +2 -0
  114. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +2 -0
  115. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +2 -0
  116. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +2 -0
  117. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +2 -2
  118. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +2 -0
  119. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +2 -0
  120. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +2 -0
  121. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +1 -1
  122. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +1 -1
  123. package/templates/nextjs/ui-canvas/home/hero-section.tsx +1 -1
  124. package/templates/nextjs/ui-canvas/home/home-client.tsx +1 -1
  125. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +1 -1
  126. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +2 -2
  127. package/templates/nextjs/ui-canvas/layout/header-account.tsx +1 -1
  128. package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +2 -0
  129. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +2 -0
  130. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
  131. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +2 -2
  132. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +2 -0
  133. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +2 -0
  134. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +2 -0
  135. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +2 -0
  136. package/templates/nextjs/ui-canvas/product/price-display.tsx +2 -0
  137. package/templates/nextjs/ui-canvas/product/product-card.tsx +1 -1
  138. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +1 -1
  139. package/templates/nextjs/ui-canvas/product/product-grid.tsx +1 -1
  140. package/templates/nextjs/ui-canvas/product/product-listing.tsx +1 -1
  141. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +4 -0
  142. package/templates/nextjs/ui-canvas/product/review-form.tsx +29 -32
  143. package/templates/nextjs/ui-canvas/product/{reviews-section.tsx → reviews-section.tsx.ejs} +26 -8
  144. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +2 -0
  145. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +2 -0
  146. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +2 -0
  147. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +2 -0
  148. package/templates/nextjs/base/src/ui/product/reviews-section.tsx +0 -85
  149. package/templates/nextjs/designs/passepartout/globals.css +0 -412
  150. package/templates/nextjs/designs/passepartout/messages-patch/en.json +0 -87
  151. package/templates/nextjs/designs/passepartout/messages-patch/he.json +0 -87
  152. package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +0 -39
  153. package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +0 -149
  154. package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +0 -401
  155. package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +0 -139
  156. package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +0 -45
  157. package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +0 -103
  158. package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +0 -159
  159. package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +0 -155
  160. package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +0 -60
  161. package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +0 -34
  162. package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +0 -111
  163. package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +0 -265
  164. package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +0 -667
  165. package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +0 -35
  166. package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +0 -427
  167. package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +0 -113
  168. package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +0 -52
  169. package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +0 -307
  170. package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +0 -33
@@ -0,0 +1,140 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { CartItem as CartItemType } from 'brainerce';
6
+ import { getCartItemImage, formatPrice } from 'brainerce';
7
+ import { getClient } from '@/core/lib/brainerce';
8
+ import { useTranslations } from '@/core/lib/translations';
9
+ import { useCurrency } from '@/core/lib/use-currency';
10
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
11
+ import { cn } from '@/core/lib/utils';
12
+ import { IconMinus, IconPlus, IconTrash } from '@/ui/shared/icons';
13
+
14
+ interface CartItemProps {
15
+ item: CartItemType;
16
+ onUpdate: () => void;
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * Single cart line: image, name, variant, unit price, quantity controls,
22
+ * remove, line total; the `item` prop comes from useCartPage().cart.
23
+ */
24
+ export function CartItem({ item, onUpdate, className }: CartItemProps) {
25
+ const t = useTranslations('common');
26
+ const td = useTranslations('productDetail');
27
+ const currency = useCurrency();
28
+ const [updating, setUpdating] = useState(false);
29
+ const [removing, setRemoving] = useState(false);
30
+
31
+ const productName = item.product.name;
32
+ const imageUrl = getCartItemImage(item);
33
+ const variantName = item.variant?.name;
34
+ const unitPrice = parseFloat(item.unitPrice);
35
+ const lineTotal = unitPrice * item.quantity;
36
+
37
+ async function handleQuantityChange(newQuantity: number) {
38
+ if (newQuantity < 1 || updating) return;
39
+
40
+ try {
41
+ setUpdating(true);
42
+ const client = getClient();
43
+ await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
44
+ onUpdate();
45
+ } catch (err) {
46
+ console.error('Failed to update quantity:', err);
47
+ } finally {
48
+ setUpdating(false);
49
+ }
50
+ }
51
+
52
+ async function handleRemove() {
53
+ if (removing) return;
54
+
55
+ try {
56
+ setRemoving(true);
57
+ const client = getClient();
58
+ await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
59
+ onUpdate();
60
+ } catch (err) {
61
+ console.error('Failed to remove item:', err);
62
+ } finally {
63
+ setRemoving(false);
64
+ }
65
+ }
66
+
67
+ return (
68
+ <div className={cn('flex items-start gap-4', className)}>
69
+ {/* Image — `relative` + fixed box are layout-critical for next/image fill */}
70
+ <div className="relative h-20 w-20 shrink-0 overflow-hidden rounded-lg border bg-secondary sm:h-24 sm:w-24">
71
+ {imageUrl ? (
72
+ <Image src={imageUrl} alt={productName} fill sizes="96px" className="object-cover" />
73
+ ) : (
74
+ <span className="sr-only">{productName}</span>
75
+ )}
76
+ </div>
77
+
78
+ {/* Details */}
79
+ <div className="min-w-0 flex-1">
80
+ <h3 className="font-sans text-[15px] font-semibold leading-snug text-foreground">
81
+ {productName}
82
+ </h3>
83
+
84
+ {/* Variant name */}
85
+ {variantName && <p className="mt-0.5 text-xs text-muted-foreground">{variantName}</p>}
86
+
87
+ {/* Unit price */}
88
+ <p className="mt-1 text-sm text-muted-foreground">
89
+ {formatPrice(unitPrice, { currency }) as string}
90
+ </p>
91
+
92
+ {/* Quantity controls + remove */}
93
+ <div className="mt-3 flex flex-wrap items-center gap-3">
94
+ <div
95
+ className="flex h-9 items-center rounded-full border"
96
+ role="group"
97
+ aria-label={td('quantityLabel')}
98
+ >
99
+ <button
100
+ type="button"
101
+ onClick={() => handleQuantityChange(item.quantity - 1)}
102
+ disabled={updating || item.quantity <= 1}
103
+ aria-label={td('decreaseQuantity')}
104
+ className="flex h-full w-9 items-center justify-center rounded-s-full transition-colors hover:bg-secondary disabled:opacity-40"
105
+ >
106
+ <IconMinus size={16} />
107
+ </button>
108
+ <span aria-live="polite" className="min-w-7 text-center text-sm font-semibold">
109
+ {updating ? <LoadingSpinner size="sm" /> : item.quantity}
110
+ </span>
111
+ <button
112
+ type="button"
113
+ onClick={() => handleQuantityChange(item.quantity + 1)}
114
+ disabled={updating}
115
+ aria-label={td('increaseQuantity')}
116
+ className="flex h-full w-9 items-center justify-center rounded-e-full transition-colors hover:bg-secondary disabled:opacity-40"
117
+ >
118
+ <IconPlus size={16} />
119
+ </button>
120
+ </div>
121
+
122
+ <button
123
+ type="button"
124
+ onClick={handleRemove}
125
+ disabled={removing}
126
+ className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-destructive disabled:opacity-50"
127
+ >
128
+ <IconTrash size={16} />
129
+ {removing ? t('removing') : t('remove')}
130
+ </button>
131
+ </div>
132
+ </div>
133
+
134
+ {/* Line total */}
135
+ <span className="text-base font-semibold text-foreground">
136
+ {formatPrice(lineTotal, { currency }) as string}
137
+ </span>
138
+ </div>
139
+ );
140
+ }
@@ -0,0 +1,34 @@
1
+ 'use client';
2
+
3
+ import type { CartNudge } from 'brainerce';
4
+ import { cn } from '@/core/lib/utils';
5
+ import { IconSparkle } from '@/ui/shared/icons';
6
+
7
+ interface CartNudgesProps {
8
+ nudges: CartNudge[];
9
+ className?: string;
10
+ }
11
+
12
+ /**
13
+ * Discount-rule nudges on the cart page ("add X more for Y");
14
+ * texts come from useCartPage().cart.nudges.
15
+ */
16
+ export function CartNudges({ nudges, className }: CartNudgesProps) {
17
+ if (nudges.length === 0) return null;
18
+
19
+ return (
20
+ <ul className={cn('space-y-2', className)} aria-label="Cart suggestions">
21
+ {nudges.map((nudge) => (
22
+ <li
23
+ key={nudge.ruleId}
24
+ className="flex items-center gap-2.5 rounded-lg bg-secondary px-3.5 py-2.5 text-sm font-medium text-foreground"
25
+ >
26
+ <span aria-hidden="true" className="shrink-0 text-primary">
27
+ <IconSparkle size={18} />
28
+ </span>
29
+ {nudge.text}
30
+ </li>
31
+ ))}
32
+ </ul>
33
+ );
34
+ }
@@ -0,0 +1,107 @@
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
+ * Order summary (subtotal, rule/coupon discounts, shipping, tax note, total);
15
+ * all figures come from useCart().totals and useCart().cart. Every discount
16
+ * branch is kept — merchants rely on the per-rule breakdown.
17
+ */
18
+ export function CartSummary({ className }: CartSummaryProps) {
19
+ const t = useTranslations('cart');
20
+ const tc = useTranslations('common');
21
+ const { totals, cart } = useCart();
22
+ const currency = useCurrency();
23
+
24
+ const rules = cart?.appliedDiscounts;
25
+ const ruleAmt = cart?.ruleDiscountAmount ? parseFloat(cart.ruleDiscountAmount) : 0;
26
+ const couponAmt = totals.discount - ruleAmt;
27
+
28
+ return (
29
+ <section className={cn(className)}>
30
+ <h3 className="mb-4 font-sans text-base font-semibold">{t('orderSummary')}</h3>
31
+
32
+ <dl className="space-y-2.5 text-sm">
33
+ {/* Subtotal */}
34
+ <div className="flex items-center justify-between gap-4">
35
+ <dt className="text-muted-foreground">{tc('subtotal')}</dt>
36
+ <dd className="font-medium">{formatPrice(totals.subtotal, { currency }) as string}</dd>
37
+ </div>
38
+
39
+ {/* Rule discounts - show each rule by name */}
40
+ {rules && rules.length > 0
41
+ ? rules.map((rule) => (
42
+ <div key={rule.ruleId} className="flex items-center justify-between gap-4">
43
+ <dt className="text-primary">{rule.ruleName}</dt>
44
+ <dd className="font-medium text-primary">
45
+ -{formatPrice(parseFloat(rule.discountAmount), { currency }) as string}
46
+ </dd>
47
+ </div>
48
+ ))
49
+ : ruleAmt > 0 && (
50
+ <div className="flex items-center justify-between gap-4">
51
+ <dt className="text-primary">{tc('generalDiscount')}</dt>
52
+ <dd className="font-medium text-primary">
53
+ -{formatPrice(ruleAmt, { currency }) as string}
54
+ </dd>
55
+ </div>
56
+ )}
57
+
58
+ {/* Coupon discount */}
59
+ {cart?.couponCode && couponAmt > 0 && (
60
+ <div className="flex items-center justify-between gap-4">
61
+ <dt className="text-primary">
62
+ {tc('couponDiscount')} ({cart.couponCode})
63
+ </dt>
64
+ <dd className="font-medium text-primary">
65
+ -{formatPrice(couponAmt, { currency }) as string}
66
+ </dd>
67
+ </div>
68
+ )}
69
+
70
+ {/* Fallback: generic discount when no breakdown available */}
71
+ {totals.discount > 0 &&
72
+ ruleAmt <= 0 &&
73
+ !cart?.couponCode &&
74
+ (!rules || rules.length === 0) && (
75
+ <div className="flex items-center justify-between gap-4">
76
+ <dt className="text-primary">{tc('discount')}</dt>
77
+ <dd className="font-medium text-primary">
78
+ -{formatPrice(totals.discount, { currency }) as string}
79
+ </dd>
80
+ </div>
81
+ )}
82
+
83
+ {/* Shipping */}
84
+ {totals.shipping > 0 && (
85
+ <div className="flex items-center justify-between gap-4">
86
+ <dt className="text-muted-foreground">{tc('shipping')}</dt>
87
+ <dd className="font-medium">{formatPrice(totals.shipping, { currency }) as string}</dd>
88
+ </div>
89
+ )}
90
+
91
+ {/* Tax */}
92
+ <div className="flex items-center justify-between gap-4">
93
+ <dt className="text-muted-foreground">{tc('tax')}</dt>
94
+ <dd className="text-muted-foreground">{t('taxAtCheckout')}</dd>
95
+ </div>
96
+
97
+ {/* Total */}
98
+ <div className="flex items-center justify-between gap-4 border-t pt-3">
99
+ <dt className="text-base font-semibold text-foreground">{tc('total')}</dt>
100
+ <dd className="text-lg font-bold text-foreground">
101
+ {formatPrice(totals.total, { currency }) as string}
102
+ </dd>
103
+ </div>
104
+ </dl>
105
+ </section>
106
+ );
107
+ }
@@ -1,141 +1,123 @@
1
- 'use client';
2
-
3
- import { useState, useEffect } from 'react';
4
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
- import type { CartUpgradeSuggestion, CartItem as CartItemType } from 'brainerce';
6
- import { formatPrice } from 'brainerce';
7
- import { getClient } from '@/core/lib/brainerce';
8
- import { useCurrency } from '@/core/lib/use-currency';
9
- import { useTranslations } from '@/core/lib/translations';
10
- import { cn } from '@/core/lib/utils';
11
-
12
- interface CartUpgradeBannerProps {
13
- suggestion: CartUpgradeSuggestion;
14
- cartItem: CartItemType;
15
- onUpgrade: () => void;
16
- className?: string;
17
- }
18
-
19
- export function CartUpgradeBanner({
20
- suggestion,
21
- cartItem,
22
- onUpgrade,
23
- className,
24
- }: CartUpgradeBannerProps) {
25
- const t = useTranslations('cart');
26
- const currency = useCurrency();
27
- const [upgrading, setUpgrading] = useState(false);
28
- const [dismissed, setDismissed] = useState(false);
29
-
30
- const storageKey = `dismissed_upgrade_${suggestion.sourceProductId}`;
31
-
32
- useEffect(() => {
33
- try {
34
- if (sessionStorage.getItem(storageKey)) {
35
- setDismissed(true);
36
- }
37
- } catch {
38
- /* ignore */
39
- }
40
- }, [storageKey]);
41
-
42
- if (dismissed) return null;
43
-
44
- const target = suggestion.targetProduct;
45
- const firstImage = target.images?.[0];
46
- const imageUrl = firstImage
47
- ? typeof firstImage === 'string'
48
- ? firstImage
49
- : firstImage.url
50
- : null;
51
- const formattedDelta = formatPrice(parseFloat(suggestion.priceDelta), { currency }) as string;
52
-
53
- function handleDismiss() {
54
- try {
55
- sessionStorage.setItem(storageKey, '1');
56
- } catch {
57
- /* ignore */
58
- }
59
- setDismissed(true);
60
- }
61
-
62
- async function handleUpgrade() {
63
- if (upgrading) return;
64
- try {
65
- setUpgrading(true);
66
- const client = getClient();
67
- await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
68
- await client.smartAddToCart({ productId: target.id, quantity: cartItem.quantity });
69
- onUpgrade();
70
- } catch (err) {
71
- console.error('Failed to upgrade cart item:', err);
72
- } finally {
73
- setUpgrading(false);
74
- }
75
- }
76
-
77
- return (
78
- <div
79
- className={cn(
80
- 'bg-primary/5 border-primary/20 relative flex items-center gap-3 rounded-none border px-4 py-3',
81
- className
82
- )}
83
- >
84
- {/* Dismiss button */}
85
- <button
86
- type="button"
87
- onClick={handleDismiss}
88
- className="text-muted-foreground hover:text-foreground absolute end-2 top-2 text-xs"
89
- aria-label={t('dismissUpgrade')}
90
- >
91
- <svg
92
- className="h-4 w-4"
93
- fill="none"
94
- viewBox="0 0 24 24"
95
- stroke="currentColor"
96
- strokeWidth={2}
97
- >
98
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
99
- </svg>
100
- </button>
101
-
102
- {/* Product image */}
103
- <div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
104
- {imageUrl ? (
105
- <Image src={imageUrl} alt={target.name} fill sizes="48px" className="object-cover" />
106
- ) : (
107
- <div className="text-muted-foreground flex h-full w-full items-center justify-center">
108
- <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
109
- <path
110
- strokeLinecap="round"
111
- strokeLinejoin="round"
112
- strokeWidth={1.5}
113
- d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
114
- />
115
- </svg>
116
- </div>
117
- )}
118
- </div>
119
-
120
- {/* Text */}
121
- <div className="min-w-0 flex-1">
122
- <p className="text-foreground text-sm font-medium">
123
- {t('upgradeFor', { name: target.name, amount: formattedDelta })}
124
- </p>
125
- </div>
126
-
127
- {/* Upgrade button */}
128
- <button
129
- type="button"
130
- onClick={handleUpgrade}
131
- disabled={upgrading}
132
- className={cn(
133
- 'bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-opacity hover:opacity-90',
134
- 'disabled:cursor-not-allowed disabled:opacity-50'
135
- )}
136
- >
137
- {upgrading ? t('upgrading') : t('upgrade')}
138
- </button>
139
- </div>
140
- );
141
- }
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { CartUpgradeSuggestion, CartItem as CartItemType } from 'brainerce';
6
+ import { formatPrice } from 'brainerce';
7
+ import { getClient } from '@/core/lib/brainerce';
8
+ import { useCurrency } from '@/core/lib/use-currency';
9
+ import { useTranslations } from '@/core/lib/translations';
10
+ import { cn } from '@/core/lib/utils';
11
+ import { IconX } from '@/ui/shared/icons';
12
+
13
+ interface CartUpgradeBannerProps {
14
+ suggestion: CartUpgradeSuggestion;
15
+ cartItem: CartItemType;
16
+ onUpgrade: () => void;
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * Per-line upgrade suggestion under a cart item ("upgrade to X for +Y");
22
+ * the suggestion comes from useCartPage().upgrades keyed by productId.
23
+ * Keeps the session-scoped dismiss and the swap logic.
24
+ */
25
+ export function CartUpgradeBanner({
26
+ suggestion,
27
+ cartItem,
28
+ onUpgrade,
29
+ className,
30
+ }: CartUpgradeBannerProps) {
31
+ const t = useTranslations('cart');
32
+ const currency = useCurrency();
33
+ const [upgrading, setUpgrading] = useState(false);
34
+ const [dismissed, setDismissed] = useState(false);
35
+
36
+ const storageKey = `dismissed_upgrade_${suggestion.sourceProductId}`;
37
+
38
+ useEffect(() => {
39
+ try {
40
+ if (sessionStorage.getItem(storageKey)) {
41
+ setDismissed(true);
42
+ }
43
+ } catch {
44
+ /* ignore */
45
+ }
46
+ }, [storageKey]);
47
+
48
+ if (dismissed) return null;
49
+
50
+ const target = suggestion.targetProduct;
51
+ const firstImage = target.images?.[0];
52
+ const imageUrl = firstImage
53
+ ? typeof firstImage === 'string'
54
+ ? firstImage
55
+ : firstImage.url
56
+ : null;
57
+ const formattedDelta = formatPrice(parseFloat(suggestion.priceDelta), { currency }) as string;
58
+
59
+ function handleDismiss() {
60
+ try {
61
+ sessionStorage.setItem(storageKey, '1');
62
+ } catch {
63
+ /* ignore */
64
+ }
65
+ setDismissed(true);
66
+ }
67
+
68
+ async function handleUpgrade() {
69
+ if (upgrading) return;
70
+ try {
71
+ setUpgrading(true);
72
+ const client = getClient();
73
+ await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
74
+ await client.smartAddToCart({ productId: target.id, quantity: cartItem.quantity });
75
+ onUpgrade();
76
+ } catch (err) {
77
+ console.error('Failed to upgrade cart item:', err);
78
+ } finally {
79
+ setUpgrading(false);
80
+ }
81
+ }
82
+
83
+ return (
84
+ <div
85
+ className={cn(
86
+ 'flex items-center gap-3 rounded-lg border border-primary/25 bg-secondary/60 p-3',
87
+ className
88
+ )}
89
+ >
90
+ {/* Product image — `relative` + fixed box are layout-critical for next/image fill */}
91
+ <span className="relative block h-12 w-12 shrink-0 overflow-hidden rounded-lg border bg-background">
92
+ {imageUrl ? (
93
+ <Image src={imageUrl} alt={target.name} fill sizes="48px" className="object-cover" />
94
+ ) : null}
95
+ </span>
96
+
97
+ {/* Text */}
98
+ <p className="min-w-0 flex-1 text-sm text-foreground">
99
+ {t('upgradeFor', { name: target.name, amount: formattedDelta })}
100
+ </p>
101
+
102
+ {/* Upgrade button */}
103
+ <button
104
+ type="button"
105
+ onClick={handleUpgrade}
106
+ disabled={upgrading}
107
+ className="btn-primary btn-sm shrink-0"
108
+ >
109
+ {upgrading ? t('upgrading') : t('upgrade')}
110
+ </button>
111
+
112
+ {/* Dismiss button */}
113
+ <button
114
+ type="button"
115
+ onClick={handleDismiss}
116
+ aria-label={t('dismissUpgrade')}
117
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
118
+ >
119
+ <IconX size={16} />
120
+ </button>
121
+ </div>
122
+ );
123
+ }