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.
- package/dist/index.js +35 -2
- package/messages/en.json +27 -1
- package/messages/he.json +27 -1
- package/package.json +1 -1
- package/templates/nextjs/base/AGENTS.md.ejs +63 -16
- package/templates/nextjs/base/AI-GUIDE.md +17 -4
- package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
- package/templates/nextjs/base/src/app/account/page.tsx +168 -122
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +145 -40
- package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +58 -7
- package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +97 -6
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
- package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
- package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
- package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
- package/templates/nextjs/base/src/core/lib/region.ts +112 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +10 -0
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +45 -2
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
- package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
- package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
- package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
- package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +10 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +56 -11
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
- package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
- package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
- package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
- 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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
</span>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
26
|
+
export function StockBadge({ inventory, lowStockThreshold, className }: StockBadgeProps) {
|
|
20
27
|
const t = useTranslations('productDetail');
|
|
21
|
-
|
|
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 {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
{
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
{
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
{
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
{
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
}
|