create-brainerce-store 1.50.5 → 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 +146 -100
- package/messages/en.json +36 -1
- package/messages/he.json +36 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +60 -0
- package/templates/nextjs/base/.env.local.ejs +5 -0
- package/templates/nextjs/base/.eslintrc.json +57 -3
- package/templates/nextjs/base/AGENTS.md +41 -0
- package/templates/nextjs/base/AI-GUIDE.md +143 -0
- package/templates/nextjs/base/CLAUDE.md +52 -0
- package/templates/nextjs/base/TRANSLATIONS.md +1 -1
- package/templates/nextjs/base/next.config.ts +2 -2
- package/templates/nextjs/base/package.json.ejs +52 -39
- package/templates/nextjs/base/src/app/account/page.tsx +5 -5
- package/templates/nextjs/base/src/app/api/auth/logout/route.ts +1 -1
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +2 -2
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +1 -1
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +4 -4
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +4 -4
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +2 -2
- package/templates/nextjs/base/src/app/cart/page.tsx +2 -173
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -11
- package/templates/nextjs/base/src/app/contact/page.tsx +3 -3
- package/templates/nextjs/base/src/app/faq/page.tsx.ejs +2 -2
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +4 -4
- package/templates/nextjs/base/src/app/globals.css +36 -30
- package/templates/nextjs/base/src/app/layout.tsx.ejs +12 -12
- package/templates/nextjs/base/src/app/login/page.tsx +4 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +19 -12
- package/templates/nextjs/base/src/app/page.tsx +4 -4
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +3 -3
- package/templates/nextjs/base/src/app/payment-complete/page.tsx +1 -1
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -6
- package/templates/nextjs/base/src/app/products/page.tsx +4 -623
- package/templates/nextjs/base/src/app/register/page.tsx +4 -4
- package/templates/nextjs/base/src/app/reset-password/page.tsx +5 -5
- package/templates/nextjs/base/src/app/sitemap.ts +1 -1
- package/templates/nextjs/base/src/app/verify-email/page.tsx +5 -5
- package/templates/nextjs/base/src/components/account/address-book.tsx +3 -3
- package/templates/nextjs/base/src/components/account/order-customizations.tsx +3 -3
- package/templates/nextjs/base/src/components/account/order-history.tsx +13 -5
- package/templates/nextjs/base/src/components/account/order-payment-block.tsx +2 -2
- package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +2 -2
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +2 -2
- package/templates/nextjs/base/src/components/account/profile-section.tsx +3 -3
- package/templates/nextjs/base/src/components/auth/login-form.tsx +4 -4
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +4 -4
- package/templates/nextjs/base/src/components/auth/register-form.tsx +4 -4
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +24 -3
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +4 -4
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +20 -16
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +3 -3
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +3 -3
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +3 -3
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +2 -2
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +3 -3
- 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/core/hooks/use-cart-page.ts +58 -0
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -0
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -0
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +278 -0
- package/templates/nextjs/base/src/{hooks → core/hooks}/use-search.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/brainerce.ts.ejs +1 -1
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -0
- package/templates/nextjs/base/src/{lib → core/lib}/resolve-currency.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/safe-redirect.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/sanitize.ts.ejs +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/use-currency.ts +1 -1
- package/templates/nextjs/base/src/{providers → core/providers}/store-provider.tsx.ejs +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +22 -16
- package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +8 -14
- package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +28 -39
- package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +7 -5
- package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +13 -28
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -0
- package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +14 -25
- package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +6 -7
- package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +95 -103
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -0
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -0
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
- package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +116 -125
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -0
- package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +5 -20
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -0
- package/templates/nextjs/base/src/{components/content → ui/layout}/rich-text-block.tsx.ejs +1 -1
- package/templates/nextjs/base/src/{components/content → ui/layout}/site-footer.tsx.ejs +144 -164
- package/templates/nextjs/base/src/{components/content → ui/layout}/site-header.tsx.ejs +152 -182
- package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +457 -478
- package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +18 -22
- package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +21 -33
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -0
- package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +5 -4
- package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +238 -271
- package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +455 -631
- package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -0
- package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +102 -107
- package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +46 -45
- package/templates/nextjs/base/src/{components/reviews/reviews-section.tsx → ui/product/reviews-section.tsx.ejs} +38 -15
- package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +61 -63
- package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
- package/templates/nextjs/base/src/{components → ui}/shared/cdn-image.tsx +1 -1
- package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
- package/templates/nextjs/base/tailwind.config.ts +69 -30
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +251 -0
- 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/atelier/ui/cart/cart-bundle-offer.tsx +110 -0
- 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/atelier/ui/cart/cart-upgrade-banner.tsx +123 -0
- 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/atelier/ui/cart/free-shipping-bar.tsx +77 -0
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -0
- 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/atelier/ui/product/discount-badge.tsx +17 -0
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -0
- 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/atelier/ui/product/review-form.tsx +267 -0
- 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/atelier/ui/shared/fly-to-cart.ts +86 -0
- 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 +89 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -0
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -0
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -0
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -0
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -0
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -0
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -0
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -0
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -0
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -0
- package/templates/nextjs/{base/src/components → ui-canvas}/layout/language-switcher.tsx.ejs +76 -70
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -0
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -0
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -0
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +197 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -0
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -0
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -0
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -0
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -0
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -0
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -0
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -0
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -0
- package/templates/nextjs/base/src/app/home-client.tsx +0 -98
- package/templates/nextjs/base/src/components/content/faq-section.tsx.ejs +0 -103
- package/templates/nextjs/base/src/components/products/modifier-group-selector.tsx +0 -238
- /package/templates/nextjs/base/src/{lib → core/lib}/auth.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/csrf.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/image-hosts.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/navigation.tsx.ejs +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/nonce.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/sanitize-html.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/seo.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/store-info.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/translations.ts.ejs +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/utils.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/validation.ts +0 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
+
import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
|
|
6
|
+
import { formatPrice } from 'brainerce';
|
|
7
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
8
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
9
|
+
import { cn } from '@/core/lib/utils';
|
|
10
|
+
|
|
11
|
+
interface CartBundleOfferCardProps {
|
|
12
|
+
offer: CartBundleOfferType;
|
|
13
|
+
cartId: string;
|
|
14
|
+
onAdd: () => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* DESIGN ME — bundle offer card on the cart page: offered products with
|
|
20
|
+
* original vs. discounted prices + add-bundle button; offers come from
|
|
21
|
+
* useCartPage().bundles.
|
|
22
|
+
*
|
|
23
|
+
* 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.
|
|
24
|
+
*/
|
|
25
|
+
export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBundleOfferCardProps) {
|
|
26
|
+
const t = useTranslations('cart');
|
|
27
|
+
const currency = useCurrency();
|
|
28
|
+
const [adding, setAdding] = useState(false);
|
|
29
|
+
|
|
30
|
+
const offered = offer.offeredProducts;
|
|
31
|
+
|
|
32
|
+
const totalOriginal = parseFloat(offer.totalOriginalPrice);
|
|
33
|
+
const totalDiscounted = parseFloat(offer.totalDiscountedPrice);
|
|
34
|
+
const discountLabel =
|
|
35
|
+
offer.discountType === 'PERCENTAGE'
|
|
36
|
+
? `${offer.discountValue}%`
|
|
37
|
+
: (formatPrice(parseFloat(offer.discountValue), { currency }) as string);
|
|
38
|
+
|
|
39
|
+
async function handleAdd() {
|
|
40
|
+
if (adding) return;
|
|
41
|
+
try {
|
|
42
|
+
setAdding(true);
|
|
43
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
44
|
+
const client = getClient();
|
|
45
|
+
// No variant selections in the default template — products with
|
|
46
|
+
// variants are best handled with a dedicated picker per offered item.
|
|
47
|
+
await client.addBundleToCart(cartId, offer.id);
|
|
48
|
+
onAdd();
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.error('Failed to add bundle:', err);
|
|
51
|
+
} finally {
|
|
52
|
+
setAdding(false);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<article className={cn(className)}>
|
|
58
|
+
<h4>{offer.name}</h4>
|
|
59
|
+
{offer.description && <p>{offer.description}</p>}
|
|
60
|
+
|
|
61
|
+
<ul>
|
|
62
|
+
{offered.map((p) => {
|
|
63
|
+
const firstImage = p.images?.[0];
|
|
64
|
+
const imageUrl = firstImage?.url ?? null;
|
|
65
|
+
return (
|
|
66
|
+
<li key={p.id} className="flex items-center gap-3">
|
|
67
|
+
{/* `relative` + fixed box are layout-critical for next/image fill */}
|
|
68
|
+
<span className="relative block h-12 w-12">
|
|
69
|
+
{imageUrl ? <Image src={imageUrl} alt={p.name} fill sizes="48px" /> : null}
|
|
70
|
+
</span>
|
|
71
|
+
<span>{p.name}</span>
|
|
72
|
+
<s>{formatPrice(parseFloat(p.originalPrice), { currency }) as string}</s>
|
|
73
|
+
<span>{formatPrice(parseFloat(p.discountedPrice), { currency }) as string}</span>
|
|
74
|
+
</li>
|
|
75
|
+
);
|
|
76
|
+
})}
|
|
77
|
+
</ul>
|
|
78
|
+
|
|
79
|
+
<div className="flex items-center gap-2">
|
|
80
|
+
<s>{formatPrice(totalOriginal, { currency }) as string}</s>
|
|
81
|
+
<span>{formatPrice(totalDiscounted, { currency }) as string}</span>
|
|
82
|
+
<span>-{discountLabel}</span>
|
|
83
|
+
<button type="button" onClick={handleAdd} disabled={adding}>
|
|
84
|
+
{adding ? t('addingBundle') : t('addBundleItem')}
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
</article>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
|
|
13
|
+
interface CartItemProps {
|
|
14
|
+
item: CartItemType;
|
|
15
|
+
onUpdate: () => void;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* DESIGN ME — single cart line: image, name, variant, unit price, quantity
|
|
21
|
+
* controls, remove, line total; the `item` prop comes from useCartPage().cart.
|
|
22
|
+
*
|
|
23
|
+
* 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.
|
|
24
|
+
*/
|
|
25
|
+
export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
26
|
+
const t = useTranslations('common');
|
|
27
|
+
const td = useTranslations('productDetail');
|
|
28
|
+
const currency = useCurrency();
|
|
29
|
+
const [updating, setUpdating] = useState(false);
|
|
30
|
+
const [removing, setRemoving] = useState(false);
|
|
31
|
+
|
|
32
|
+
const productName = item.product.name;
|
|
33
|
+
const imageUrl = getCartItemImage(item);
|
|
34
|
+
const variantName = item.variant?.name;
|
|
35
|
+
const unitPrice = parseFloat(item.unitPrice);
|
|
36
|
+
const lineTotal = unitPrice * item.quantity;
|
|
37
|
+
|
|
38
|
+
async function handleQuantityChange(newQuantity: number) {
|
|
39
|
+
if (newQuantity < 1 || updating) return;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
setUpdating(true);
|
|
43
|
+
const client = getClient();
|
|
44
|
+
await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
|
|
45
|
+
onUpdate();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error('Failed to update quantity:', err);
|
|
48
|
+
} finally {
|
|
49
|
+
setUpdating(false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function handleRemove() {
|
|
54
|
+
if (removing) return;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
setRemoving(true);
|
|
58
|
+
const client = getClient();
|
|
59
|
+
await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
|
|
60
|
+
onUpdate();
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.error('Failed to remove item:', err);
|
|
63
|
+
} finally {
|
|
64
|
+
setRemoving(false);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className={cn('flex items-start gap-4', className)}>
|
|
70
|
+
{/* Image — `relative` + fixed box are layout-critical for next/image fill */}
|
|
71
|
+
<div className="relative h-20 w-20">
|
|
72
|
+
{imageUrl ? (
|
|
73
|
+
<Image src={imageUrl} alt={productName} fill sizes="80px" />
|
|
74
|
+
) : (
|
|
75
|
+
<span className="sr-only">{productName}</span>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
{/* Details */}
|
|
80
|
+
<div>
|
|
81
|
+
<h3>{productName}</h3>
|
|
82
|
+
|
|
83
|
+
{/* Variant name */}
|
|
84
|
+
{variantName && <p>{variantName}</p>}
|
|
85
|
+
|
|
86
|
+
{/* Unit price */}
|
|
87
|
+
<p>{formatPrice(unitPrice, { currency }) as string}</p>
|
|
88
|
+
|
|
89
|
+
{/* Quantity controls */}
|
|
90
|
+
<div className="flex items-center gap-3">
|
|
91
|
+
<div className="flex items-center gap-2">
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={() => handleQuantityChange(item.quantity - 1)}
|
|
95
|
+
disabled={updating || item.quantity <= 1}
|
|
96
|
+
aria-label={td('decreaseQuantity')}
|
|
97
|
+
>
|
|
98
|
+
-
|
|
99
|
+
</button>
|
|
100
|
+
<span aria-live="polite">
|
|
101
|
+
{updating ? <LoadingSpinner size="sm" /> : item.quantity}
|
|
102
|
+
</span>
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
onClick={() => handleQuantityChange(item.quantity + 1)}
|
|
106
|
+
disabled={updating}
|
|
107
|
+
aria-label={td('increaseQuantity')}
|
|
108
|
+
>
|
|
109
|
+
+
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<button type="button" onClick={handleRemove} disabled={removing}>
|
|
114
|
+
{removing ? t('removing') : t('remove')}
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{/* Line total */}
|
|
120
|
+
<span>{formatPrice(lineTotal, { currency }) as string}</span>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { CartNudge } from 'brainerce';
|
|
4
|
+
import { cn } from '@/core/lib/utils';
|
|
5
|
+
|
|
6
|
+
interface CartNudgesProps {
|
|
7
|
+
nudges: CartNudge[];
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* DESIGN ME — discount-rule nudges on the cart page ("add X more for Y");
|
|
13
|
+
* texts come from useCartPage().cart.nudges.
|
|
14
|
+
*
|
|
15
|
+
* 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.
|
|
16
|
+
*/
|
|
17
|
+
export function CartNudges({ nudges, className }: CartNudgesProps) {
|
|
18
|
+
if (nudges.length === 0) return null;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<ul className={cn(className)} aria-label="Cart suggestions">
|
|
22
|
+
{nudges.map((nudge) => (
|
|
23
|
+
<li key={nudge.ruleId}>{nudge.text}</li>
|
|
24
|
+
))}
|
|
25
|
+
</ul>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,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 { 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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
+
import type { CartUpgradeSuggestion, CartItem as CartItemType } from 'brainerce';
|
|
6
|
+
import { formatPrice } from 'brainerce';
|
|
7
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
8
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
9
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
10
|
+
import { cn } from '@/core/lib/utils';
|
|
11
|
+
|
|
12
|
+
interface CartUpgradeBannerProps {
|
|
13
|
+
suggestion: CartUpgradeSuggestion;
|
|
14
|
+
cartItem: CartItemType;
|
|
15
|
+
onUpgrade: () => void;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* DESIGN ME — per-line upgrade suggestion under a cart item ("upgrade to X
|
|
21
|
+
* for +Y"); the suggestion comes from useCartPage().upgrades keyed by
|
|
22
|
+
* productId. Keep the session-scoped dismiss and the swap logic.
|
|
23
|
+
*
|
|
24
|
+
* 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.
|
|
25
|
+
*/
|
|
26
|
+
export function CartUpgradeBanner({
|
|
27
|
+
suggestion,
|
|
28
|
+
cartItem,
|
|
29
|
+
onUpgrade,
|
|
30
|
+
className,
|
|
31
|
+
}: CartUpgradeBannerProps) {
|
|
32
|
+
const t = useTranslations('cart');
|
|
33
|
+
const currency = useCurrency();
|
|
34
|
+
const [upgrading, setUpgrading] = useState(false);
|
|
35
|
+
const [dismissed, setDismissed] = useState(false);
|
|
36
|
+
|
|
37
|
+
const storageKey = `dismissed_upgrade_${suggestion.sourceProductId}`;
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
try {
|
|
41
|
+
if (sessionStorage.getItem(storageKey)) {
|
|
42
|
+
setDismissed(true);
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
/* ignore */
|
|
46
|
+
}
|
|
47
|
+
}, [storageKey]);
|
|
48
|
+
|
|
49
|
+
if (dismissed) return null;
|
|
50
|
+
|
|
51
|
+
const target = suggestion.targetProduct;
|
|
52
|
+
const firstImage = target.images?.[0];
|
|
53
|
+
const imageUrl = firstImage
|
|
54
|
+
? typeof firstImage === 'string'
|
|
55
|
+
? firstImage
|
|
56
|
+
: firstImage.url
|
|
57
|
+
: null;
|
|
58
|
+
const formattedDelta = formatPrice(parseFloat(suggestion.priceDelta), { currency }) as string;
|
|
59
|
+
|
|
60
|
+
function handleDismiss() {
|
|
61
|
+
try {
|
|
62
|
+
sessionStorage.setItem(storageKey, '1');
|
|
63
|
+
} catch {
|
|
64
|
+
/* ignore */
|
|
65
|
+
}
|
|
66
|
+
setDismissed(true);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function handleUpgrade() {
|
|
70
|
+
if (upgrading) return;
|
|
71
|
+
try {
|
|
72
|
+
setUpgrading(true);
|
|
73
|
+
const client = getClient();
|
|
74
|
+
await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
|
|
75
|
+
await client.smartAddToCart({ productId: target.id, quantity: cartItem.quantity });
|
|
76
|
+
onUpgrade();
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error('Failed to upgrade cart item:', err);
|
|
79
|
+
} finally {
|
|
80
|
+
setUpgrading(false);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className={cn('flex items-center gap-3', className)}>
|
|
86
|
+
{/* Product image — `relative` + fixed box are layout-critical for next/image fill */}
|
|
87
|
+
<span className="relative block h-12 w-12">
|
|
88
|
+
{imageUrl ? <Image src={imageUrl} alt={target.name} fill sizes="48px" /> : null}
|
|
89
|
+
</span>
|
|
90
|
+
|
|
91
|
+
{/* Text */}
|
|
92
|
+
<p>{t('upgradeFor', { name: target.name, amount: formattedDelta })}</p>
|
|
93
|
+
|
|
94
|
+
{/* Upgrade button */}
|
|
95
|
+
<button type="button" onClick={handleUpgrade} disabled={upgrading}>
|
|
96
|
+
{upgrading ? t('upgrading') : t('upgrade')}
|
|
97
|
+
</button>
|
|
98
|
+
|
|
99
|
+
{/* Dismiss button */}
|
|
100
|
+
<button type="button" onClick={handleDismiss} aria-label={t('dismissUpgrade')}>
|
|
101
|
+
×
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
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 { cart, cartLoading, refreshCart, itemCount, cartRecs, upgrades, bundles } = useCartPage();
|
|
21
|
+
|
|
22
|
+
if (cartLoading) {
|
|
23
|
+
return <LoadingSpinner size="lg" />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Empty cart state
|
|
27
|
+
if (!cart || cart.items.length === 0) {
|
|
28
|
+
return (
|
|
29
|
+
<section>
|
|
30
|
+
{/* 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. */}
|
|
31
|
+
<h1>{t('emptyTitle')}</h1>
|
|
32
|
+
<p>{t('emptySubtitle')}</p>
|
|
33
|
+
<Link href="/products">{tc('continueShopping')}</Link>
|
|
34
|
+
</section>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<section>
|
|
40
|
+
{/* 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. */}
|
|
41
|
+
<h1>
|
|
42
|
+
{t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
|
|
43
|
+
</h1>
|
|
44
|
+
|
|
45
|
+
{/* Reservation countdown */}
|
|
46
|
+
{cart.reservation?.hasReservation && <ReservationCountdown reservation={cart.reservation} />}
|
|
47
|
+
|
|
48
|
+
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
49
|
+
{/* Cart Items */}
|
|
50
|
+
<div className="lg:col-span-2">
|
|
51
|
+
{/* Nudges */}
|
|
52
|
+
{cart.nudges && cart.nudges.length > 0 && <CartNudges nudges={cart.nudges} />}
|
|
53
|
+
|
|
54
|
+
{/* Cart items */}
|
|
55
|
+
<ul>
|
|
56
|
+
{cart.items.map((item) => (
|
|
57
|
+
<li key={item.id}>
|
|
58
|
+
<CartItem item={item} onUpdate={refreshCart} />
|
|
59
|
+
{upgrades?.upgrades?.[item.productId] && (
|
|
60
|
+
<CartUpgradeBanner
|
|
61
|
+
suggestion={upgrades.upgrades[item.productId]}
|
|
62
|
+
cartItem={item}
|
|
63
|
+
onUpgrade={refreshCart}
|
|
64
|
+
/>
|
|
65
|
+
)}
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
|
|
70
|
+
{/* Bundle offers */}
|
|
71
|
+
{bundles?.bundles && bundles.bundles.length > 0 && (
|
|
72
|
+
<section>
|
|
73
|
+
<h3>{t('bundleOffers')}</h3>
|
|
74
|
+
{bundles.bundles.map((offer) => (
|
|
75
|
+
<CartBundleOfferCard
|
|
76
|
+
key={offer.id}
|
|
77
|
+
offer={offer}
|
|
78
|
+
cartId={cart.id}
|
|
79
|
+
onAdd={refreshCart}
|
|
80
|
+
/>
|
|
81
|
+
))}
|
|
82
|
+
</section>
|
|
83
|
+
)}
|
|
84
|
+
|
|
85
|
+
{/* Coupon input */}
|
|
86
|
+
<CouponInput cart={cart} onUpdate={refreshCart} />
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{/* Summary sidebar */}
|
|
90
|
+
<aside className="lg:col-span-1">
|
|
91
|
+
<FreeShippingBar />
|
|
92
|
+
<CartSummary />
|
|
93
|
+
|
|
94
|
+
<Link href="/checkout">{t('proceedToCheckout')}</Link>
|
|
95
|
+
|
|
96
|
+
<Link href="/products">{tc('continueShopping')}</Link>
|
|
97
|
+
</aside>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{/* Cross-sell recommendations */}
|
|
101
|
+
{cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
|
|
102
|
+
<CartRecommendationSection title={t('youMightAlsoNeed')} items={cartRecs.recommendations} />
|
|
103
|
+
)}
|
|
104
|
+
</section>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { Cart } 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 CouponInputProps {
|
|
11
|
+
cart: Cart;
|
|
12
|
+
/** When provided, uses applyCheckoutCoupon/removeCheckoutCoupon instead of
|
|
13
|
+
* applyCoupon/removeCoupon. Always pass this when a checkout session exists. */
|
|
14
|
+
checkoutId?: string;
|
|
15
|
+
onUpdate: () => void;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* DESIGN ME — coupon apply/remove control used on the cart page AND the
|
|
21
|
+
* checkout page (via `checkoutId`); the `cart` prop comes from useCartPage().
|
|
22
|
+
* Keep the applied-coupon state and the error message.
|
|
23
|
+
*
|
|
24
|
+
* 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.
|
|
25
|
+
*/
|
|
26
|
+
export function CouponInput({ cart, checkoutId, onUpdate, className }: CouponInputProps) {
|
|
27
|
+
const t = useTranslations('coupon');
|
|
28
|
+
const tc = useTranslations('common');
|
|
29
|
+
const [code, setCode] = useState('');
|
|
30
|
+
const [applying, setApplying] = useState(false);
|
|
31
|
+
const [removing, setRemoving] = useState(false);
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
|
|
34
|
+
const appliedCoupon = cart.couponCode || null;
|
|
35
|
+
|
|
36
|
+
async function handleApply() {
|
|
37
|
+
const trimmed = code.trim();
|
|
38
|
+
if (!trimmed || applying) return;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
setApplying(true);
|
|
42
|
+
setError(null);
|
|
43
|
+
const client = getClient();
|
|
44
|
+
if (checkoutId) {
|
|
45
|
+
await client.applyCheckoutCoupon(checkoutId, trimmed);
|
|
46
|
+
} else {
|
|
47
|
+
await client.applyCoupon(cart.id, trimmed);
|
|
48
|
+
}
|
|
49
|
+
setCode('');
|
|
50
|
+
onUpdate();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
const message = err instanceof Error ? err.message : t('invalidCode');
|
|
53
|
+
setError(message);
|
|
54
|
+
} finally {
|
|
55
|
+
setApplying(false);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function handleRemove() {
|
|
60
|
+
if (removing) return;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
setRemoving(true);
|
|
64
|
+
setError(null);
|
|
65
|
+
const client = getClient();
|
|
66
|
+
if (checkoutId) {
|
|
67
|
+
await client.removeCheckoutCoupon(checkoutId);
|
|
68
|
+
} else {
|
|
69
|
+
await client.removeCoupon(cart.id);
|
|
70
|
+
}
|
|
71
|
+
onUpdate();
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error('Failed to remove coupon:', err);
|
|
74
|
+
} finally {
|
|
75
|
+
setRemoving(false);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Show applied coupon
|
|
80
|
+
if (appliedCoupon) {
|
|
81
|
+
return (
|
|
82
|
+
<div className={cn('flex items-center gap-2', className)}>
|
|
83
|
+
<span>{appliedCoupon}</span>
|
|
84
|
+
<button type="button" onClick={handleRemove} disabled={removing}>
|
|
85
|
+
{removing ? tc('removing') : tc('remove')}
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className={cn(className)}>
|
|
93
|
+
<div className="flex gap-2">
|
|
94
|
+
<input
|
|
95
|
+
type="text"
|
|
96
|
+
value={code}
|
|
97
|
+
onChange={(e) => {
|
|
98
|
+
setCode(e.target.value);
|
|
99
|
+
if (error) setError(null);
|
|
100
|
+
}}
|
|
101
|
+
onKeyDown={(e) => {
|
|
102
|
+
if (e.key === 'Enter') {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
handleApply();
|
|
105
|
+
}
|
|
106
|
+
}}
|
|
107
|
+
placeholder={t('placeholder')}
|
|
108
|
+
aria-label={t('placeholder')}
|
|
109
|
+
aria-invalid={error ? true : undefined}
|
|
110
|
+
/>
|
|
111
|
+
<button type="button" onClick={handleApply} disabled={applying || !code.trim()}>
|
|
112
|
+
{applying ? <LoadingSpinner size="sm" /> : tc('apply')}
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{error && <p role="alert">{error}</p>}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|