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.
- package/README.md +72 -0
- package/dist/index.js +9 -68
- package/messages/en.json +32 -0
- package/messages/he.json +32 -0
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +9 -0
- package/templates/nextjs/base/AI-GUIDE.md +52 -3
- package/templates/nextjs/base/CLAUDE.md +10 -0
- package/templates/nextjs/base/package.json.ejs +52 -39
- package/templates/nextjs/base/src/app/globals.css +36 -30
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -10
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +17 -11
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +2 -8
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -39
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +3 -1
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +8 -23
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +18 -27
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +10 -21
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +2 -3
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -103
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +4 -6
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -125
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -103
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +2 -17
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -70
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -164
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -182
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +456 -444
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -22
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +15 -27
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -175
- package/templates/nextjs/base/src/ui/product/price-display.tsx +3 -2
- package/templates/nextjs/base/src/ui/product/product-card.tsx +238 -271
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -470
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +96 -79
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -107
- package/templates/nextjs/base/src/ui/product/review-form.tsx +44 -43
- package/templates/nextjs/{designs/passepartout → base/src}/ui/product/reviews-section.tsx.ejs +108 -96
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -63
- package/templates/nextjs/base/tailwind.config.ts +69 -42
- package/templates/nextjs/designs/{passepartout → atelier}/app-overlay/layout.tsx.ejs +23 -25
- package/templates/nextjs/designs/atelier/globals.css +396 -0
- package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
- package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-bundle-offer.tsx +110 -111
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-upgrade-banner.tsx +123 -141
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/free-shipping-bar.tsx +77 -62
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/reservation-countdown.tsx +89 -103
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/discount-badge.tsx +17 -22
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/frequently-bought-together.tsx +196 -205
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/review-form.tsx +267 -294
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/shared/fly-to-cart.ts +86 -85
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
- package/templates/nextjs/themes/luxury/globals.css +405 -399
- package/templates/nextjs/themes/minimal/globals.css +36 -30
- package/templates/nextjs/themes/playful/globals.css +406 -400
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +2 -2
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +2 -0
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/home-client.tsx +1 -1
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +1 -1
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +1 -1
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +2 -0
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +2 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +4 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +29 -32
- package/templates/nextjs/ui-canvas/product/{reviews-section.tsx → reviews-section.tsx.ejs} +26 -8
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +2 -0
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +2 -0
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +2 -0
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx +0 -85
- package/templates/nextjs/designs/passepartout/globals.css +0 -412
- package/templates/nextjs/designs/passepartout/messages-patch/en.json +0 -87
- package/templates/nextjs/designs/passepartout/messages-patch/he.json +0 -87
- package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +0 -39
- package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +0 -149
- package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +0 -401
- package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +0 -139
- package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +0 -45
- package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +0 -103
- package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +0 -159
- package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +0 -155
- package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +0 -60
- package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +0 -34
- package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +0 -111
- package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +0 -265
- package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +0 -667
- package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +0 -35
- package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +0 -427
- package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +0 -113
- package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +0 -52
- package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +0 -307
- 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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
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
|
-
fill="
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
</
|
|
101
|
-
|
|
102
|
-
{/*
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
+
}
|