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