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
@@ -1,95 +1,101 @@
1
- 'use client';
2
-
3
- import { Link } from '@/core/lib/navigation';
4
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
- import type { ProductRecommendation } from 'brainerce';
6
- import { PriceDisplay } from '@/ui/product/price-display';
7
- import { cn } from '@/core/lib/utils';
8
-
9
- interface RecommendationCardProps {
10
- item: ProductRecommendation;
11
- className?: string;
12
- }
13
-
14
- function RecommendationCard({ item, className }: RecommendationCardProps) {
15
- const firstImage = item.images?.[0];
16
- const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
17
- const slug = item.slug || item.id;
18
- const basePrice = parseFloat(item.basePrice);
19
- const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
20
- const isOnSale = salePrice != null && salePrice < basePrice;
21
-
22
- return (
23
- <Link
24
- href={`/products/${slug}`}
25
- className={cn('group card card-hover block overflow-hidden', className)}
26
- >
27
- {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
28
- <span className="relative block aspect-square overflow-hidden bg-secondary">
29
- {imageUrl ? (
30
- <Image
31
- src={imageUrl}
32
- alt={item.name}
33
- fill
34
- sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
35
- className="img-zoom object-cover"
36
- />
37
- ) : null}
38
- </span>
39
- <span className="block space-y-1 p-4">
40
- <span className="block text-sm font-semibold leading-snug text-foreground group-hover:text-primary">
41
- {item.name}
42
- </span>
43
- <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
44
- </span>
45
- </Link>
46
- );
47
- }
48
-
49
- interface RecommendationSectionProps {
50
- title: string;
51
- items: ProductRecommendation[];
52
- className?: string;
53
- }
54
-
55
- /** Recommendation strip on the product page (upsells / related). */
56
- export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
57
- if (items.length === 0) return null;
58
-
59
- return (
60
- <section className={cn('border-t pt-10', className)}>
61
- <h2 className="mb-6 text-2xl">{title}</h2>
62
- <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
63
- {items.slice(0, 4).map((item) => (
64
- <RecommendationCard key={item.id} item={item} />
65
- ))}
66
- </div>
67
- </section>
68
- );
69
- }
70
-
71
- interface CartRecommendationSectionProps {
72
- title: string;
73
- items: ProductRecommendation[];
74
- className?: string;
75
- }
76
-
77
- /** Cross-sell strip on the cart page ("you might also need"). */
78
- export function CartRecommendationSection({
79
- title,
80
- items,
81
- className,
82
- }: CartRecommendationSectionProps) {
83
- if (items.length === 0) return null;
84
-
85
- return (
86
- <section className={cn('border-t pt-10', className)}>
87
- <h2 className="mb-6 text-2xl">{title}</h2>
88
- <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
89
- {items.slice(0, 4).map((item) => (
90
- <RecommendationCard key={item.id} item={item} />
91
- ))}
92
- </div>
93
- </section>
94
- );
95
- }
1
+ 'use client';
2
+
3
+ import { Link } from '@/core/lib/navigation';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { ProductRecommendation } from 'brainerce';
6
+ import { PriceDisplay } from '@/ui/product/price-display';
7
+ import { cn } from '@/core/lib/utils';
8
+
9
+ interface RecommendationCardProps {
10
+ item: ProductRecommendation;
11
+ className?: string;
12
+ }
13
+
14
+ function RecommendationCard({ item, className }: RecommendationCardProps) {
15
+ const firstImage = item.images?.[0];
16
+ const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
17
+ const slug = item.slug || item.id;
18
+ // NOT region-converted, and it cannot be. `ProductRecommendation` is a
19
+ // trimmed shape that carries `basePrice` / `salePrice` only: the backend
20
+ // attaches no displayPrice/displayCurrency to it, so there is no converted
21
+ // amount to render and converting here would mean inventing an FX rate.
22
+ // These tiles stay in the store currency on a multi-region store. Do not
23
+ // "fix" it with a client-side conversion.
24
+ const basePrice = parseFloat(item.basePrice);
25
+ const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
26
+ const isOnSale = salePrice != null && salePrice < basePrice;
27
+
28
+ return (
29
+ <Link
30
+ href={`/products/${slug}`}
31
+ className={cn('group card card-hover block overflow-hidden', className)}
32
+ >
33
+ {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
34
+ <span className="relative block aspect-square overflow-hidden bg-secondary">
35
+ {imageUrl ? (
36
+ <Image
37
+ src={imageUrl}
38
+ alt={item.name}
39
+ fill
40
+ sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
41
+ className="img-zoom object-cover"
42
+ />
43
+ ) : null}
44
+ </span>
45
+ <span className="block space-y-1 p-4">
46
+ <span className="block text-sm font-semibold leading-snug text-foreground group-hover:text-primary">
47
+ {item.name}
48
+ </span>
49
+ <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
50
+ </span>
51
+ </Link>
52
+ );
53
+ }
54
+
55
+ interface RecommendationSectionProps {
56
+ title: string;
57
+ items: ProductRecommendation[];
58
+ className?: string;
59
+ }
60
+
61
+ /** Recommendation strip on the product page (upsells / related). */
62
+ export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
63
+ if (items.length === 0) return null;
64
+
65
+ return (
66
+ <section className={cn('border-t pt-10', className)}>
67
+ <h2 className="mb-6 text-2xl">{title}</h2>
68
+ <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
69
+ {items.slice(0, 4).map((item) => (
70
+ <RecommendationCard key={item.id} item={item} />
71
+ ))}
72
+ </div>
73
+ </section>
74
+ );
75
+ }
76
+
77
+ interface CartRecommendationSectionProps {
78
+ title: string;
79
+ items: ProductRecommendation[];
80
+ className?: string;
81
+ }
82
+
83
+ /** Cross-sell strip on the cart page ("you might also need"). */
84
+ export function CartRecommendationSection({
85
+ title,
86
+ items,
87
+ className,
88
+ }: CartRecommendationSectionProps) {
89
+ if (items.length === 0) return null;
90
+
91
+ return (
92
+ <section className={cn('border-t pt-10', className)}>
93
+ <h2 className="mb-6 text-2xl">{title}</h2>
94
+ <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
95
+ {items.slice(0, 4).map((item) => (
96
+ <RecommendationCard key={item.id} item={item} />
97
+ ))}
98
+ </div>
99
+ </section>
100
+ );
101
+ }
@@ -3,9 +3,16 @@
3
3
  import type { InventoryInfo } from 'brainerce';
4
4
  import { cn } from '@/core/lib/utils';
5
5
  import { useTranslations } from '@/core/lib/translations';
6
+ import { useStoreCapabilities } from '@/core/providers/store-provider';
7
+ import { resolveLowStockThreshold } from '@/core/lib/capabilities';
6
8
 
7
9
  interface StockBadgeProps {
8
10
  inventory: InventoryInfo | null | undefined;
11
+ /**
12
+ * Override the threshold. Leave it unset — the merchant's configured value
13
+ * comes from the store's capabilities, and a hardcoded number here shows the
14
+ * wrong badge on every store that chose a different one.
15
+ */
9
16
  lowStockThreshold?: number;
10
17
  className?: string;
11
18
  }
@@ -16,9 +23,17 @@ type StockState = 'in' | 'low' | 'out';
16
23
  * Stock status pill (in stock / low stock / out of stock) with a colored
17
24
  * status dot; derives the label from the product/variant `inventory` prop.
18
25
  */
19
- export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
26
+ export function StockBadge({ inventory, lowStockThreshold, className }: StockBadgeProps) {
20
27
  const t = useTranslations('productDetail');
21
- const { label, state } = getStock(inventory, lowStockThreshold, t);
28
+ // Merchant-configured, from the channel's capabilities. Falls back to the
29
+ // platform default while the fetch is in flight or if it failed, and resolves
30
+ // to 0 (nothing is ever low) when the merchant turned low-stock warnings off.
31
+ const { capabilities } = useStoreCapabilities();
32
+ const { label, state } = getStock(
33
+ inventory,
34
+ lowStockThreshold ?? resolveLowStockThreshold(capabilities),
35
+ t
36
+ );
22
37
 
23
38
  return (
24
39
  <span
@@ -104,6 +104,15 @@ export function IconCheck(p: IconProps) {
104
104
  );
105
105
  }
106
106
 
107
+ export function IconSearch(p: IconProps) {
108
+ return (
109
+ <Base {...p}>
110
+ <circle cx="11" cy="11" r="6.5" />
111
+ <path d="m16 16 4.5 4.5" />
112
+ </Base>
113
+ );
114
+ }
115
+
107
116
  export function IconStar({ filled = true, ...p }: IconProps & { filled?: boolean }) {
108
117
  return (
109
118
  <Base {...p} fill={filled ? 'currentColor' : 'none'} strokeWidth={filled ? 0 : 1.5}>
@@ -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
+ }
@@ -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
+ }