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,95 +1,101 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Link } from '@/core/lib/navigation';
|
|
4
|
-
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
-
import type { ProductRecommendation } from 'brainerce';
|
|
6
|
-
import { PriceDisplay } from '@/ui/product/price-display';
|
|
7
|
-
import { cn } from '@/core/lib/utils';
|
|
8
|
-
|
|
9
|
-
interface RecommendationCardProps {
|
|
10
|
-
item: ProductRecommendation;
|
|
11
|
-
className?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function RecommendationCard({ item, className }: RecommendationCardProps) {
|
|
15
|
-
const firstImage = item.images?.[0];
|
|
16
|
-
const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
|
|
17
|
-
const slug = item.slug || item.id;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
</span>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Link } from '@/core/lib/navigation';
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
+
import type { ProductRecommendation } from 'brainerce';
|
|
6
|
+
import { PriceDisplay } from '@/ui/product/price-display';
|
|
7
|
+
import { cn } from '@/core/lib/utils';
|
|
8
|
+
|
|
9
|
+
interface RecommendationCardProps {
|
|
10
|
+
item: ProductRecommendation;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function RecommendationCard({ item, className }: RecommendationCardProps) {
|
|
15
|
+
const firstImage = item.images?.[0];
|
|
16
|
+
const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
|
|
17
|
+
const slug = item.slug || item.id;
|
|
18
|
+
// ⛔ NOT region-converted, and it cannot be. `ProductRecommendation` is a
|
|
19
|
+
// trimmed shape that carries `basePrice` / `salePrice` only: the backend
|
|
20
|
+
// attaches no displayPrice/displayCurrency to it, so there is no converted
|
|
21
|
+
// amount to render and converting here would mean inventing an FX rate.
|
|
22
|
+
// These tiles stay in the store currency on a multi-region store. Do not
|
|
23
|
+
// "fix" it with a client-side conversion.
|
|
24
|
+
const basePrice = parseFloat(item.basePrice);
|
|
25
|
+
const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
|
|
26
|
+
const isOnSale = salePrice != null && salePrice < basePrice;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Link
|
|
30
|
+
href={`/products/${slug}`}
|
|
31
|
+
className={cn('group card card-hover block overflow-hidden', className)}
|
|
32
|
+
>
|
|
33
|
+
{/* `relative` + `aspect-square` are layout-critical for next/image fill */}
|
|
34
|
+
<span className="relative block aspect-square overflow-hidden bg-secondary">
|
|
35
|
+
{imageUrl ? (
|
|
36
|
+
<Image
|
|
37
|
+
src={imageUrl}
|
|
38
|
+
alt={item.name}
|
|
39
|
+
fill
|
|
40
|
+
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
|
41
|
+
className="img-zoom object-cover"
|
|
42
|
+
/>
|
|
43
|
+
) : null}
|
|
44
|
+
</span>
|
|
45
|
+
<span className="block space-y-1 p-4">
|
|
46
|
+
<span className="block text-sm font-semibold leading-snug text-foreground group-hover:text-primary">
|
|
47
|
+
{item.name}
|
|
48
|
+
</span>
|
|
49
|
+
<PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
|
|
50
|
+
</span>
|
|
51
|
+
</Link>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface RecommendationSectionProps {
|
|
56
|
+
title: string;
|
|
57
|
+
items: ProductRecommendation[];
|
|
58
|
+
className?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Recommendation strip on the product page (upsells / related). */
|
|
62
|
+
export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
|
|
63
|
+
if (items.length === 0) return null;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<section className={cn('border-t pt-10', className)}>
|
|
67
|
+
<h2 className="mb-6 text-2xl">{title}</h2>
|
|
68
|
+
<div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
|
|
69
|
+
{items.slice(0, 4).map((item) => (
|
|
70
|
+
<RecommendationCard key={item.id} item={item} />
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
</section>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface CartRecommendationSectionProps {
|
|
78
|
+
title: string;
|
|
79
|
+
items: ProductRecommendation[];
|
|
80
|
+
className?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Cross-sell strip on the cart page ("you might also need"). */
|
|
84
|
+
export function CartRecommendationSection({
|
|
85
|
+
title,
|
|
86
|
+
items,
|
|
87
|
+
className,
|
|
88
|
+
}: CartRecommendationSectionProps) {
|
|
89
|
+
if (items.length === 0) return null;
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<section className={cn('border-t pt-10', className)}>
|
|
93
|
+
<h2 className="mb-6 text-2xl">{title}</h2>
|
|
94
|
+
<div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
|
|
95
|
+
{items.slice(0, 4).map((item) => (
|
|
96
|
+
<RecommendationCard key={item.id} item={item} />
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
</section>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -6,7 +6,7 @@ import { getClient } from '@/core/lib/brainerce';
|
|
|
6
6
|
import { checkAuthStatus } from '@/core/lib/auth';
|
|
7
7
|
import { useTranslations } from '@/core/lib/translations';
|
|
8
8
|
import type { MyProductReview, ProductReview, ReviewPhotoUpload } from 'brainerce';
|
|
9
|
-
import { IconStar } from '@/ui/shared/icons';
|
|
9
|
+
import { IconPlus, IconStar } from '@/ui/shared/icons';
|
|
10
10
|
|
|
11
11
|
interface ReviewFormProps {
|
|
12
12
|
productId: string;
|
|
@@ -381,16 +381,36 @@ function ReviewEditor({
|
|
|
381
381
|
</ul>
|
|
382
382
|
)}
|
|
383
383
|
|
|
384
|
-
<input
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
384
|
+
{/* A <label> wrapping a visually hidden input, not a bare file control.
|
|
385
|
+
The browser's own "Choose Files" button cannot be styled and looks
|
|
386
|
+
nothing like the rest of the form. Two things this has to do that the
|
|
387
|
+
native control did for free: `disabled` on a hidden input greys
|
|
388
|
+
NOTHING, so the label paints its own disabled state, and the line
|
|
389
|
+
underneath says WHY it is disabled rather than leaving a dead button.
|
|
390
|
+
The input stays a real input (no ref.click()) so keyboard and
|
|
391
|
+
assistive tech reach it, and focus-within restores the focus ring. */}
|
|
392
|
+
<label
|
|
393
|
+
className={`btn-outline btn-sm focus-within:ring-primary/40 inline-flex cursor-pointer items-center gap-2 focus-within:ring-2 ${
|
|
394
|
+
uploading || roomLeft <= 0 ? 'pointer-events-none opacity-50' : ''
|
|
395
|
+
}`}
|
|
396
|
+
>
|
|
397
|
+
<IconPlus size={16} />
|
|
398
|
+
{uploading ? t('photoUploading') : t('choosePhotos')}
|
|
399
|
+
<input
|
|
400
|
+
type="file"
|
|
401
|
+
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
402
|
+
multiple
|
|
403
|
+
disabled={uploading || roomLeft <= 0}
|
|
404
|
+
onChange={handleFiles}
|
|
405
|
+
className="sr-only"
|
|
406
|
+
/>
|
|
407
|
+
</label>
|
|
392
408
|
|
|
393
|
-
|
|
409
|
+
<p className="text-muted-foreground text-xs">
|
|
410
|
+
{roomLeft <= 0
|
|
411
|
+
? t('photoLimitReached', { max: String(photos.maxPerReview) })
|
|
412
|
+
: t('photoFormats', { mb: String(Math.round(photos.maxBytes / (1024 * 1024))) })}
|
|
413
|
+
</p>
|
|
394
414
|
{/* Said BEFORE they submit: a shopper who uploads, submits, and cannot
|
|
395
415
|
find their photo will conclude the site is broken. */}
|
|
396
416
|
{photos.requiresApproval && (
|
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
import type { InventoryInfo } from 'brainerce';
|
|
4
4
|
import { cn } from '@/core/lib/utils';
|
|
5
5
|
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
+
import { useStoreCapabilities } from '@/core/providers/store-provider';
|
|
7
|
+
import { resolveLowStockThreshold } from '@/core/lib/capabilities';
|
|
6
8
|
|
|
7
9
|
interface StockBadgeProps {
|
|
8
10
|
inventory: InventoryInfo | null | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Override the threshold. Leave it unset — the merchant's configured value
|
|
13
|
+
* comes from the store's capabilities, and a hardcoded number here shows the
|
|
14
|
+
* wrong badge on every store that chose a different one.
|
|
15
|
+
*/
|
|
9
16
|
lowStockThreshold?: number;
|
|
10
17
|
className?: string;
|
|
11
18
|
}
|
|
@@ -16,9 +23,17 @@ type StockState = 'in' | 'low' | 'out';
|
|
|
16
23
|
* Stock status pill (in stock / low stock / out of stock) with a colored
|
|
17
24
|
* status dot; derives the label from the product/variant `inventory` prop.
|
|
18
25
|
*/
|
|
19
|
-
export function StockBadge({ inventory, lowStockThreshold
|
|
26
|
+
export function StockBadge({ inventory, lowStockThreshold, className }: StockBadgeProps) {
|
|
20
27
|
const t = useTranslations('productDetail');
|
|
21
|
-
|
|
28
|
+
// Merchant-configured, from the channel's capabilities. Falls back to the
|
|
29
|
+
// platform default while the fetch is in flight or if it failed, and resolves
|
|
30
|
+
// to 0 (nothing is ever low) when the merchant turned low-stock warnings off.
|
|
31
|
+
const { capabilities } = useStoreCapabilities();
|
|
32
|
+
const { label, state } = getStock(
|
|
33
|
+
inventory,
|
|
34
|
+
lowStockThreshold ?? resolveLowStockThreshold(capabilities),
|
|
35
|
+
t
|
|
36
|
+
);
|
|
22
37
|
|
|
23
38
|
return (
|
|
24
39
|
<span
|
|
@@ -104,6 +104,15 @@ export function IconCheck(p: IconProps) {
|
|
|
104
104
|
);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
export function IconSearch(p: IconProps) {
|
|
108
|
+
return (
|
|
109
|
+
<Base {...p}>
|
|
110
|
+
<circle cx="11" cy="11" r="6.5" />
|
|
111
|
+
<path d="m16 16 4.5 4.5" />
|
|
112
|
+
</Base>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
107
116
|
export function IconStar({ filled = true, ...p }: IconProps & { filled?: boolean }) {
|
|
108
117
|
return (
|
|
109
118
|
<Base {...p} fill={filled ? 'currentColor' : 'none'} strokeWidth={filled ? 0 : 1.5}>
|
|
@@ -1,137 +1,137 @@
|
|
|
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
|
-
// The server decides purchasability, per line. `isAvailable === false` means
|
|
39
|
-
// this line blocks checkout until it is removed, whether the stock ran out
|
|
40
|
-
// (a released reservation is the common cause) or the product/variant was
|
|
41
|
-
// withdrawn from sale. Keep the badge when you redesign this line.
|
|
42
|
-
const isUnavailable = item.isAvailable === false;
|
|
43
|
-
const unavailableLabel = isUnavailable
|
|
44
|
-
? item.unavailableReason === 'OUT_OF_STOCK'
|
|
45
|
-
? td('outOfStock')
|
|
46
|
-
: td('unavailable')
|
|
47
|
-
: null;
|
|
48
|
-
|
|
49
|
-
async function handleQuantityChange(newQuantity: number) {
|
|
50
|
-
if (newQuantity < 1 || updating) return;
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
setUpdating(true);
|
|
54
|
-
const client = getClient();
|
|
55
|
-
await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
|
|
56
|
-
onUpdate();
|
|
57
|
-
} catch (err) {
|
|
58
|
-
console.error('Failed to update quantity:', err);
|
|
59
|
-
} finally {
|
|
60
|
-
setUpdating(false);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function handleRemove() {
|
|
65
|
-
if (removing) return;
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
setRemoving(true);
|
|
69
|
-
const client = getClient();
|
|
70
|
-
await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
|
|
71
|
-
onUpdate();
|
|
72
|
-
} catch (err) {
|
|
73
|
-
console.error('Failed to remove item:', err);
|
|
74
|
-
} finally {
|
|
75
|
-
setRemoving(false);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return (
|
|
80
|
-
<div className={cn('flex items-start gap-4', className)}>
|
|
81
|
-
{/* Image — `relative` + fixed box are layout-critical for next/image fill */}
|
|
82
|
-
<div className="relative h-20 w-20">
|
|
83
|
-
{imageUrl ? (
|
|
84
|
-
<Image src={imageUrl} alt={productName} fill sizes="80px" />
|
|
85
|
-
) : (
|
|
86
|
-
<span className="sr-only">{productName}</span>
|
|
87
|
-
)}
|
|
88
|
-
</div>
|
|
89
|
-
|
|
90
|
-
{/* Details */}
|
|
91
|
-
<div>
|
|
92
|
-
<h3>{productName}</h3>
|
|
93
|
-
|
|
94
|
-
{/* Availability badge. This line blocks checkout while it shows. */}
|
|
95
|
-
{unavailableLabel && <span data-state="unavailable">{unavailableLabel}</span>}
|
|
96
|
-
|
|
97
|
-
{/* Variant name */}
|
|
98
|
-
{variantName && <p>{variantName}</p>}
|
|
99
|
-
|
|
100
|
-
{/* Unit price */}
|
|
101
|
-
<p>{formatPrice(unitPrice, { currency }) as string}</p>
|
|
102
|
-
|
|
103
|
-
{/* Quantity controls */}
|
|
104
|
-
<div className="flex items-center gap-3">
|
|
105
|
-
<div className="flex items-center gap-2">
|
|
106
|
-
<button
|
|
107
|
-
type="button"
|
|
108
|
-
onClick={() => handleQuantityChange(item.quantity - 1)}
|
|
109
|
-
disabled={updating || item.quantity <= 1}
|
|
110
|
-
aria-label={td('decreaseQuantity')}
|
|
111
|
-
>
|
|
112
|
-
-
|
|
113
|
-
</button>
|
|
114
|
-
<span aria-live="polite">
|
|
115
|
-
{updating ? <LoadingSpinner size="sm" /> : item.quantity}
|
|
116
|
-
</span>
|
|
117
|
-
<button
|
|
118
|
-
type="button"
|
|
119
|
-
onClick={() => handleQuantityChange(item.quantity + 1)}
|
|
120
|
-
disabled={updating || isUnavailable}
|
|
121
|
-
aria-label={td('increaseQuantity')}
|
|
122
|
-
>
|
|
123
|
-
+
|
|
124
|
-
</button>
|
|
125
|
-
</div>
|
|
126
|
-
|
|
127
|
-
<button type="button" onClick={handleRemove} disabled={removing}>
|
|
128
|
-
{removing ? t('removing') : t('remove')}
|
|
129
|
-
</button>
|
|
130
|
-
</div>
|
|
131
|
-
</div>
|
|
132
|
-
|
|
133
|
-
{/* Line total */}
|
|
134
|
-
<span>{formatPrice(lineTotal, { currency }) as string}</span>
|
|
135
|
-
</div>
|
|
136
|
-
);
|
|
137
|
-
}
|
|
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
|
+
// The server decides purchasability, per line. `isAvailable === false` means
|
|
39
|
+
// this line blocks checkout until it is removed, whether the stock ran out
|
|
40
|
+
// (a released reservation is the common cause) or the product/variant was
|
|
41
|
+
// withdrawn from sale. Keep the badge when you redesign this line.
|
|
42
|
+
const isUnavailable = item.isAvailable === false;
|
|
43
|
+
const unavailableLabel = isUnavailable
|
|
44
|
+
? item.unavailableReason === 'OUT_OF_STOCK'
|
|
45
|
+
? td('outOfStock')
|
|
46
|
+
: td('unavailable')
|
|
47
|
+
: null;
|
|
48
|
+
|
|
49
|
+
async function handleQuantityChange(newQuantity: number) {
|
|
50
|
+
if (newQuantity < 1 || updating) return;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
setUpdating(true);
|
|
54
|
+
const client = getClient();
|
|
55
|
+
await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
|
|
56
|
+
onUpdate();
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error('Failed to update quantity:', err);
|
|
59
|
+
} finally {
|
|
60
|
+
setUpdating(false);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function handleRemove() {
|
|
65
|
+
if (removing) return;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
setRemoving(true);
|
|
69
|
+
const client = getClient();
|
|
70
|
+
await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
|
|
71
|
+
onUpdate();
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error('Failed to remove item:', err);
|
|
74
|
+
} finally {
|
|
75
|
+
setRemoving(false);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className={cn('flex items-start gap-4', className)}>
|
|
81
|
+
{/* Image — `relative` + fixed box are layout-critical for next/image fill */}
|
|
82
|
+
<div className="relative h-20 w-20">
|
|
83
|
+
{imageUrl ? (
|
|
84
|
+
<Image src={imageUrl} alt={productName} fill sizes="80px" />
|
|
85
|
+
) : (
|
|
86
|
+
<span className="sr-only">{productName}</span>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{/* Details */}
|
|
91
|
+
<div>
|
|
92
|
+
<h3>{productName}</h3>
|
|
93
|
+
|
|
94
|
+
{/* Availability badge. This line blocks checkout while it shows. */}
|
|
95
|
+
{unavailableLabel && <span data-state="unavailable">{unavailableLabel}</span>}
|
|
96
|
+
|
|
97
|
+
{/* Variant name */}
|
|
98
|
+
{variantName && <p>{variantName}</p>}
|
|
99
|
+
|
|
100
|
+
{/* Unit price */}
|
|
101
|
+
<p>{formatPrice(unitPrice, { currency }) as string}</p>
|
|
102
|
+
|
|
103
|
+
{/* Quantity controls */}
|
|
104
|
+
<div className="flex items-center gap-3">
|
|
105
|
+
<div className="flex items-center gap-2">
|
|
106
|
+
<button
|
|
107
|
+
type="button"
|
|
108
|
+
onClick={() => handleQuantityChange(item.quantity - 1)}
|
|
109
|
+
disabled={updating || item.quantity <= 1}
|
|
110
|
+
aria-label={td('decreaseQuantity')}
|
|
111
|
+
>
|
|
112
|
+
-
|
|
113
|
+
</button>
|
|
114
|
+
<span aria-live="polite">
|
|
115
|
+
{updating ? <LoadingSpinner size="sm" /> : item.quantity}
|
|
116
|
+
</span>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={() => handleQuantityChange(item.quantity + 1)}
|
|
120
|
+
disabled={updating || isUnavailable}
|
|
121
|
+
aria-label={td('increaseQuantity')}
|
|
122
|
+
>
|
|
123
|
+
+
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<button type="button" onClick={handleRemove} disabled={removing}>
|
|
128
|
+
{removing ? t('removing') : t('remove')}
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Line total */}
|
|
134
|
+
<span>{formatPrice(lineTotal, { currency }) as string}</span>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|