create-brainerce-store 1.71.0 → 1.73.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 +31 -10
- package/dist/index.js +179 -107
- package/messages/en.json +14 -1
- package/messages/he.json +14 -1
- package/package.json +1 -1
- package/templates/nextjs/base/TRANSLATIONS.md +207 -200
- package/templates/nextjs/base/src/app/checkout/page.tsx +1074 -1018
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +21 -2
- package/templates/nextjs/base/src/components/account/order-history.tsx +371 -385
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -66
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +127 -58
- package/templates/nextjs/base/src/core/lib/auth.ts +32 -39
- package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +5 -16
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +3 -6
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +164 -146
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +176 -140
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -95
- package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -163
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +158 -140
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +184 -147
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -89
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -123
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -106
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -81
- package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
|
@@ -1,89 +1,131 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
-
import type { ReservationInfo } from 'brainerce';
|
|
5
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
-
import { cn } from '@/core/lib/utils';
|
|
7
|
-
import { IconClock } from '@/ui/shared/icons';
|
|
8
|
-
|
|
9
|
-
interface ReservationCountdownProps {
|
|
10
|
-
reservation: ReservationInfo;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
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
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import type { ReservationInfo } from 'brainerce';
|
|
5
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
+
import { cn } from '@/core/lib/utils';
|
|
7
|
+
import { IconClock } from '@/ui/shared/icons';
|
|
8
|
+
|
|
9
|
+
interface ReservationCountdownProps {
|
|
10
|
+
reservation: ReservationInfo;
|
|
11
|
+
/**
|
|
12
|
+
* Fired once per reservation window when the timer reaches zero, including
|
|
13
|
+
* when the page opens on an already-expired reservation. Wired to
|
|
14
|
+
* `useCartPage().onReservationExpired`, which refreshes the cart and blocks
|
|
15
|
+
* checkout. This component only displays; it never decides what is still
|
|
16
|
+
* purchasable.
|
|
17
|
+
*/
|
|
18
|
+
onExpire?: () => void;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Stock-reservation countdown on the cart/checkout pages; the `reservation`
|
|
24
|
+
* prop comes from useCartPage().cart.reservation. Keeps the ticking logic
|
|
25
|
+
* and the expired/urgent states (exposed via data-state).
|
|
26
|
+
*/
|
|
27
|
+
export function ReservationCountdown({
|
|
28
|
+
reservation,
|
|
29
|
+
onExpire,
|
|
30
|
+
className,
|
|
31
|
+
}: ReservationCountdownProps) {
|
|
32
|
+
const t = useTranslations('reservation');
|
|
33
|
+
// `null` means "not measured yet". Server-rendered HTML must not compute a
|
|
34
|
+
// time, or it disagrees with the client on hydration; starting at 0 instead
|
|
35
|
+
// would render the expired state for one frame on a perfectly live
|
|
36
|
+
// reservation, and would fire onExpire on every mount.
|
|
37
|
+
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
|
|
38
|
+
|
|
39
|
+
const calculateRemaining = useCallback(() => {
|
|
40
|
+
if (!reservation.expiresAt) return 0;
|
|
41
|
+
const expiresAtMs = new Date(reservation.expiresAt).getTime();
|
|
42
|
+
const nowMs = Date.now();
|
|
43
|
+
return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
|
|
44
|
+
}, [reservation.expiresAt]);
|
|
45
|
+
|
|
46
|
+
// Report each expiry window at most once. Keyed on `expiresAt` rather than a
|
|
47
|
+
// boolean, so the cart refresh that expiry triggers (which remounts this
|
|
48
|
+
// component) cannot bounce straight back into a second report.
|
|
49
|
+
const expiresAt = reservation.expiresAt;
|
|
50
|
+
const reportedExpiryRef = useRef<string | null>(null);
|
|
51
|
+
const onExpireRef = useRef(onExpire);
|
|
52
|
+
// Kept in a ref, and synced in an effect rather than during render, so a
|
|
53
|
+
// caller passing an inline arrow does not restart the timer every render.
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
onExpireRef.current = onExpire;
|
|
56
|
+
}, [onExpire]);
|
|
57
|
+
|
|
58
|
+
const reportExpired = useCallback(() => {
|
|
59
|
+
if (!expiresAt) return;
|
|
60
|
+
if (reportedExpiryRef.current === expiresAt) return;
|
|
61
|
+
reportedExpiryRef.current = expiresAt;
|
|
62
|
+
onExpireRef.current?.();
|
|
63
|
+
}, [expiresAt]);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const initial = calculateRemaining();
|
|
67
|
+
setRemainingSeconds(initial);
|
|
68
|
+
if (initial <= 0) reportExpired();
|
|
69
|
+
|
|
70
|
+
const interval = setInterval(() => {
|
|
71
|
+
const remaining = calculateRemaining();
|
|
72
|
+
setRemainingSeconds(remaining);
|
|
73
|
+
|
|
74
|
+
if (remaining <= 0) {
|
|
75
|
+
clearInterval(interval);
|
|
76
|
+
reportExpired();
|
|
77
|
+
}
|
|
78
|
+
}, 1000);
|
|
79
|
+
|
|
80
|
+
return () => clearInterval(interval);
|
|
81
|
+
}, [calculateRemaining, reportExpired]);
|
|
82
|
+
|
|
83
|
+
if (!reservation.hasReservation) return null;
|
|
84
|
+
|
|
85
|
+
const displaySeconds = remainingSeconds ?? 0;
|
|
86
|
+
const minutes = Math.floor(displaySeconds / 60);
|
|
87
|
+
const seconds = displaySeconds % 60;
|
|
88
|
+
const isExpired = remainingSeconds !== null && remainingSeconds <= 0;
|
|
89
|
+
const isUrgent = displaySeconds > 0 && displaySeconds < 120;
|
|
90
|
+
|
|
91
|
+
const displayMessage = reservation.countdownMessage
|
|
92
|
+
? reservation.countdownMessage.replace(
|
|
93
|
+
'{time}',
|
|
94
|
+
`${minutes}:${seconds.toString().padStart(2, '0')}`
|
|
95
|
+
)
|
|
96
|
+
: null;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
role="timer"
|
|
101
|
+
data-state={isExpired ? 'expired' : isUrgent ? 'urgent' : 'active'}
|
|
102
|
+
className={cn(
|
|
103
|
+
'flex items-center gap-2.5 rounded-lg px-4 py-3 text-sm font-medium',
|
|
104
|
+
isExpired || isUrgent
|
|
105
|
+
? 'bg-destructive/10 text-destructive'
|
|
106
|
+
: 'bg-secondary text-foreground',
|
|
107
|
+
className
|
|
108
|
+
)}
|
|
109
|
+
>
|
|
110
|
+
<span aria-hidden="true" className="shrink-0">
|
|
111
|
+
<IconClock size={18} />
|
|
112
|
+
</span>
|
|
113
|
+
{isExpired ? (
|
|
114
|
+
<div>
|
|
115
|
+
<p>{t('expired')}</p>
|
|
116
|
+
<p className="text-xs font-normal opacity-90">{t('expiredHint')}</p>
|
|
117
|
+
</div>
|
|
118
|
+
) : displayMessage ? (
|
|
119
|
+
<p>{displayMessage}</p>
|
|
120
|
+
) : (
|
|
121
|
+
<p>
|
|
122
|
+
{isUrgent ? `${t('hurry')} ` : ''}
|
|
123
|
+
{t('reservedFor')}{' '}
|
|
124
|
+
<span className="font-bold tabular-nums">
|
|
125
|
+
{minutes}:{seconds.toString().padStart(2, '0')}
|
|
126
|
+
</span>
|
|
127
|
+
</p>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -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 && (
|
|
@@ -1,123 +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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
// 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
|
+
}
|