create-brainerce-store 1.0.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.d.ts +1 -0
- package/dist/index.js +502 -0
- package/package.json +44 -0
- package/templates/nextjs/base/.env.local.ejs +3 -0
- package/templates/nextjs/base/.eslintrc.json +3 -0
- package/templates/nextjs/base/gitignore +30 -0
- package/templates/nextjs/base/next.config.ts +9 -0
- package/templates/nextjs/base/package.json.ejs +30 -0
- package/templates/nextjs/base/postcss.config.mjs +9 -0
- package/templates/nextjs/base/src/app/account/page.tsx +105 -0
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +99 -0
- package/templates/nextjs/base/src/app/cart/page.tsx +263 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +463 -0
- package/templates/nextjs/base/src/app/globals.css +30 -0
- package/templates/nextjs/base/src/app/layout.tsx.ejs +33 -0
- package/templates/nextjs/base/src/app/login/page.tsx +56 -0
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +191 -0
- package/templates/nextjs/base/src/app/page.tsx +95 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +346 -0
- package/templates/nextjs/base/src/app/products/page.tsx +243 -0
- package/templates/nextjs/base/src/app/register/page.tsx +66 -0
- package/templates/nextjs/base/src/app/verify-email/page.tsx +291 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +184 -0
- package/templates/nextjs/base/src/components/account/profile-section.tsx +73 -0
- package/templates/nextjs/base/src/components/auth/login-form.tsx +92 -0
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +134 -0
- package/templates/nextjs/base/src/components/auth/register-form.tsx +177 -0
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +150 -0
- package/templates/nextjs/base/src/components/cart/cart-nudges.tsx +39 -0
- package/templates/nextjs/base/src/components/cart/cart-summary.tsx +67 -0
- package/templates/nextjs/base/src/components/cart/coupon-input.tsx +131 -0
- package/templates/nextjs/base/src/components/cart/reservation-countdown.tsx +100 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +273 -0
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +124 -0
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +111 -0
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +62 -0
- package/templates/nextjs/base/src/components/layout/footer.tsx +35 -0
- package/templates/nextjs/base/src/components/layout/header.tsx +329 -0
- package/templates/nextjs/base/src/components/products/discount-badge.tsx +36 -0
- package/templates/nextjs/base/src/components/products/product-card.tsx +94 -0
- package/templates/nextjs/base/src/components/products/product-grid.tsx +33 -0
- package/templates/nextjs/base/src/components/products/stock-badge.tsx +34 -0
- package/templates/nextjs/base/src/components/products/variant-selector.tsx +147 -0
- package/templates/nextjs/base/src/components/shared/loading-spinner.tsx +30 -0
- package/templates/nextjs/base/src/components/shared/price-display.tsx +62 -0
- package/templates/nextjs/base/src/hooks/use-search.ts +77 -0
- package/templates/nextjs/base/src/lib/brainerce.ts.ejs +59 -0
- package/templates/nextjs/base/src/lib/utils.ts +6 -0
- package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +168 -0
- package/templates/nextjs/base/tailwind.config.ts +30 -0
- package/templates/nextjs/base/tsconfig.json +23 -0
- package/templates/nextjs/themes/minimal/globals.css +30 -0
- package/templates/nextjs/themes/minimal/theme.json +23 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import type { CustomerProfile, Order } from 'brainerce';
|
|
6
|
+
import { getClient } from '@/lib/brainerce';
|
|
7
|
+
import { useAuth } from '@/providers/store-provider';
|
|
8
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
9
|
+
import { ProfileSection } from '@/components/account/profile-section';
|
|
10
|
+
import { OrderHistory } from '@/components/account/order-history';
|
|
11
|
+
|
|
12
|
+
export default function AccountPage() {
|
|
13
|
+
const router = useRouter();
|
|
14
|
+
const { isLoggedIn, logout } = useAuth();
|
|
15
|
+
const [profile, setProfile] = useState<CustomerProfile | null>(null);
|
|
16
|
+
const [orders, setOrders] = useState<Order[]>([]);
|
|
17
|
+
const [loading, setLoading] = useState(true);
|
|
18
|
+
const [error, setError] = useState<string | null>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!isLoggedIn) {
|
|
22
|
+
router.push('/login');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function loadAccountData() {
|
|
27
|
+
try {
|
|
28
|
+
const client = getClient();
|
|
29
|
+
const [profileResult, ordersResult] = await Promise.allSettled([
|
|
30
|
+
client.getMyProfile(),
|
|
31
|
+
client.getMyOrders({ limit: 20 }),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
if (profileResult.status === 'fulfilled') {
|
|
35
|
+
setProfile(profileResult.value);
|
|
36
|
+
} else {
|
|
37
|
+
setError('Failed to load profile.');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (ordersResult.status === 'fulfilled') {
|
|
41
|
+
setOrders(ordersResult.value.data);
|
|
42
|
+
}
|
|
43
|
+
} catch (err) {
|
|
44
|
+
const message = err instanceof Error ? err.message : 'Failed to load account data.';
|
|
45
|
+
setError(message);
|
|
46
|
+
} finally {
|
|
47
|
+
setLoading(false);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
loadAccountData();
|
|
52
|
+
}, [isLoggedIn, router]);
|
|
53
|
+
|
|
54
|
+
if (!isLoggedIn) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (loading) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
61
|
+
<LoadingSpinner size="lg" />
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (error && !profile) {
|
|
67
|
+
return (
|
|
68
|
+
<div className="mx-auto max-w-3xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
69
|
+
<h1 className="text-foreground text-2xl font-bold">Something went wrong</h1>
|
|
70
|
+
<p className="text-muted-foreground mt-2">{error}</p>
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
onClick={() => window.location.reload()}
|
|
74
|
+
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
75
|
+
>
|
|
76
|
+
Try Again
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
|
84
|
+
<div className="mb-6 flex items-center justify-between">
|
|
85
|
+
<h1 className="text-foreground text-2xl font-bold">My Account</h1>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={logout}
|
|
89
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
90
|
+
>
|
|
91
|
+
Sign out
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{/* Profile Section */}
|
|
96
|
+
{profile && <ProfileSection profile={profile} className="mb-8" />}
|
|
97
|
+
|
|
98
|
+
{/* Order History */}
|
|
99
|
+
<div>
|
|
100
|
+
<h2 className="text-foreground mb-4 text-lg font-semibold">Order History</h2>
|
|
101
|
+
<OrderHistory orders={orders} />
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Suspense, useEffect, useState, useRef } from 'react';
|
|
4
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
5
|
+
import type { CustomerOAuthProvider } from 'brainerce';
|
|
6
|
+
import { getClient } from '@/lib/brainerce';
|
|
7
|
+
import { useAuth } from '@/providers/store-provider';
|
|
8
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
9
|
+
|
|
10
|
+
function OAuthCallbackContent() {
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
const searchParams = useSearchParams();
|
|
13
|
+
const auth = useAuth();
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
const processedRef = useRef(false);
|
|
16
|
+
|
|
17
|
+
const code = searchParams.get('code');
|
|
18
|
+
const state = searchParams.get('state');
|
|
19
|
+
const provider = searchParams.get('provider') as CustomerOAuthProvider | null;
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
// Prevent double-processing in React StrictMode
|
|
23
|
+
if (processedRef.current) return;
|
|
24
|
+
processedRef.current = true;
|
|
25
|
+
|
|
26
|
+
async function handleCallback() {
|
|
27
|
+
if (!code || !provider) {
|
|
28
|
+
setError('Missing authentication parameters. Please try again.');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const client = getClient();
|
|
34
|
+
const result = await client.handleOAuthCallback(provider, code, state || '');
|
|
35
|
+
|
|
36
|
+
auth.login(result.token);
|
|
37
|
+
router.push('/');
|
|
38
|
+
} catch (err) {
|
|
39
|
+
const message =
|
|
40
|
+
err instanceof Error ? err.message : 'Authentication failed. Please try again.';
|
|
41
|
+
setError(message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
handleCallback();
|
|
46
|
+
}, [code, state, provider, auth, router]);
|
|
47
|
+
|
|
48
|
+
if (error) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
51
|
+
<div className="w-full max-w-md space-y-4 text-center">
|
|
52
|
+
<svg
|
|
53
|
+
className="text-destructive mx-auto h-12 w-12"
|
|
54
|
+
fill="none"
|
|
55
|
+
viewBox="0 0 24 24"
|
|
56
|
+
stroke="currentColor"
|
|
57
|
+
>
|
|
58
|
+
<path
|
|
59
|
+
strokeLinecap="round"
|
|
60
|
+
strokeLinejoin="round"
|
|
61
|
+
strokeWidth={1.5}
|
|
62
|
+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.834-2.694-.834-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
|
|
63
|
+
/>
|
|
64
|
+
</svg>
|
|
65
|
+
<h1 className="text-foreground text-2xl font-bold">Authentication failed</h1>
|
|
66
|
+
<p className="text-muted-foreground text-sm">{error}</p>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={() => router.push('/login')}
|
|
70
|
+
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
71
|
+
>
|
|
72
|
+
Back to Login
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 py-12">
|
|
81
|
+
<LoadingSpinner size="lg" />
|
|
82
|
+
<p className="text-muted-foreground mt-4">Completing sign in...</p>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default function OAuthCallbackPage() {
|
|
88
|
+
return (
|
|
89
|
+
<Suspense
|
|
90
|
+
fallback={
|
|
91
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
92
|
+
<LoadingSpinner size="lg" />
|
|
93
|
+
</div>
|
|
94
|
+
}
|
|
95
|
+
>
|
|
96
|
+
<OAuthCallbackContent />
|
|
97
|
+
</Suspense>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import Link from 'next/link';
|
|
5
|
+
import Image from 'next/image';
|
|
6
|
+
import type { LocalCart, LocalCartItem } from 'brainerce';
|
|
7
|
+
import { formatPrice } from 'brainerce';
|
|
8
|
+
import { useStoreInfo, useCart } from '@/providers/store-provider';
|
|
9
|
+
import { CartItem } from '@/components/cart/cart-item';
|
|
10
|
+
import { CartSummary } from '@/components/cart/cart-summary';
|
|
11
|
+
import { CouponInput } from '@/components/cart/coupon-input';
|
|
12
|
+
import { CartNudges } from '@/components/cart/cart-nudges';
|
|
13
|
+
import { ReservationCountdown } from '@/components/cart/reservation-countdown';
|
|
14
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
15
|
+
|
|
16
|
+
export default function CartPage() {
|
|
17
|
+
const { storeInfo } = useStoreInfo();
|
|
18
|
+
const { cart, cartLoading, refreshCart, itemCount, isServerCart } = useCart();
|
|
19
|
+
const currency = storeInfo?.currency || 'USD';
|
|
20
|
+
|
|
21
|
+
if (cartLoading) {
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
24
|
+
<LoadingSpinner size="lg" />
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Empty cart state
|
|
30
|
+
if (!cart || cart.items.length === 0) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
33
|
+
<svg
|
|
34
|
+
className="text-muted-foreground mx-auto mb-4 h-16 w-16"
|
|
35
|
+
fill="none"
|
|
36
|
+
viewBox="0 0 24 24"
|
|
37
|
+
stroke="currentColor"
|
|
38
|
+
>
|
|
39
|
+
<path
|
|
40
|
+
strokeLinecap="round"
|
|
41
|
+
strokeLinejoin="round"
|
|
42
|
+
strokeWidth={1.5}
|
|
43
|
+
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"
|
|
44
|
+
/>
|
|
45
|
+
</svg>
|
|
46
|
+
<h1 className="text-foreground text-2xl font-bold">Your cart is empty</h1>
|
|
47
|
+
<p className="text-muted-foreground mt-2">
|
|
48
|
+
Looks like you haven't added anything to your cart yet.
|
|
49
|
+
</p>
|
|
50
|
+
<Link
|
|
51
|
+
href="/products"
|
|
52
|
+
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
53
|
+
>
|
|
54
|
+
Continue Shopping
|
|
55
|
+
</Link>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const serverCart = isServerCart(cart) ? cart : null;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
64
|
+
<h1 className="text-foreground mb-6 text-2xl font-bold">
|
|
65
|
+
Shopping Cart ({itemCount} {itemCount === 1 ? 'item' : 'items'})
|
|
66
|
+
</h1>
|
|
67
|
+
|
|
68
|
+
{/* Reservation countdown */}
|
|
69
|
+
{serverCart?.reservation?.hasReservation && (
|
|
70
|
+
<ReservationCountdown reservation={serverCart.reservation} className="mb-6" />
|
|
71
|
+
)}
|
|
72
|
+
|
|
73
|
+
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
74
|
+
{/* Cart Items */}
|
|
75
|
+
<div className="lg:col-span-2">
|
|
76
|
+
{/* Nudges */}
|
|
77
|
+
{serverCart?.nudges && serverCart.nudges.length > 0 && (
|
|
78
|
+
<CartNudges nudges={serverCart.nudges} className="mb-4" />
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
{/* Server cart items */}
|
|
82
|
+
{serverCart && (
|
|
83
|
+
<div>
|
|
84
|
+
{serverCart.items.map((item) => (
|
|
85
|
+
<CartItem key={item.id} item={item} onUpdate={refreshCart} />
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{/* Local cart items (guest) */}
|
|
91
|
+
{!serverCart && (
|
|
92
|
+
<div>
|
|
93
|
+
{(cart as LocalCart).items.map((item: LocalCartItem) => (
|
|
94
|
+
<LocalCartItemRow
|
|
95
|
+
key={`${item.productId}-${item.variantId || 'default'}`}
|
|
96
|
+
item={item}
|
|
97
|
+
currency={currency}
|
|
98
|
+
onUpdate={refreshCart}
|
|
99
|
+
/>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
{/* Coupon input - server cart only */}
|
|
105
|
+
{serverCart && (
|
|
106
|
+
<div className="border-border mt-6 border-t pt-4">
|
|
107
|
+
<CouponInput cart={serverCart} onUpdate={refreshCart} />
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{/* Summary sidebar */}
|
|
113
|
+
<div className="lg:col-span-1">
|
|
114
|
+
<div className="bg-muted/50 border-border sticky top-24 rounded-lg border p-6">
|
|
115
|
+
<CartSummary />
|
|
116
|
+
|
|
117
|
+
<Link
|
|
118
|
+
href="/checkout"
|
|
119
|
+
className="bg-primary text-primary-foreground mt-6 inline-flex w-full items-center justify-center rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90"
|
|
120
|
+
>
|
|
121
|
+
Proceed to Checkout
|
|
122
|
+
</Link>
|
|
123
|
+
|
|
124
|
+
<Link
|
|
125
|
+
href="/products"
|
|
126
|
+
className="text-muted-foreground hover:text-foreground mt-3 inline-flex w-full items-center justify-center px-6 py-2 text-sm transition-colors"
|
|
127
|
+
>
|
|
128
|
+
Continue Shopping
|
|
129
|
+
</Link>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Local cart item display for guest users
|
|
138
|
+
function LocalCartItemRow({
|
|
139
|
+
item,
|
|
140
|
+
currency,
|
|
141
|
+
onUpdate,
|
|
142
|
+
}: {
|
|
143
|
+
item: LocalCartItem;
|
|
144
|
+
currency: string;
|
|
145
|
+
onUpdate: () => void;
|
|
146
|
+
}) {
|
|
147
|
+
const [updating, setUpdating] = useState(false);
|
|
148
|
+
const [removing, setRemoving] = useState(false);
|
|
149
|
+
|
|
150
|
+
const unitPrice = parseFloat(item.price || '0');
|
|
151
|
+
const lineTotal = unitPrice * item.quantity;
|
|
152
|
+
|
|
153
|
+
async function handleQuantityChange(newQuantity: number) {
|
|
154
|
+
if (newQuantity < 1 || updating) return;
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
setUpdating(true);
|
|
158
|
+
const { getClient } = await import('@/lib/brainerce');
|
|
159
|
+
const client = getClient();
|
|
160
|
+
await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId);
|
|
161
|
+
onUpdate();
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error('Failed to update quantity:', err);
|
|
164
|
+
} finally {
|
|
165
|
+
setUpdating(false);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function handleRemove() {
|
|
170
|
+
if (removing) return;
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
setRemoving(true);
|
|
174
|
+
const { getClient } = await import('@/lib/brainerce');
|
|
175
|
+
const client = getClient();
|
|
176
|
+
await client.smartRemoveFromCart(item.productId, item.variantId);
|
|
177
|
+
onUpdate();
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.error('Failed to remove item:', err);
|
|
180
|
+
} finally {
|
|
181
|
+
setRemoving(false);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<div className="border-border flex gap-4 border-b py-4 last:border-0">
|
|
187
|
+
{/* Image */}
|
|
188
|
+
<div className="bg-muted relative h-20 w-20 flex-shrink-0 overflow-hidden rounded">
|
|
189
|
+
{item.image ? (
|
|
190
|
+
<Image
|
|
191
|
+
src={item.image}
|
|
192
|
+
alt={item.name || 'Product'}
|
|
193
|
+
fill
|
|
194
|
+
sizes="80px"
|
|
195
|
+
className="object-cover"
|
|
196
|
+
/>
|
|
197
|
+
) : (
|
|
198
|
+
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
199
|
+
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
200
|
+
<path
|
|
201
|
+
strokeLinecap="round"
|
|
202
|
+
strokeLinejoin="round"
|
|
203
|
+
strokeWidth={1.5}
|
|
204
|
+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
205
|
+
/>
|
|
206
|
+
</svg>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{/* Details */}
|
|
212
|
+
<div className="min-w-0 flex-1">
|
|
213
|
+
<h3 className="text-foreground truncate text-sm font-medium">{item.name || 'Product'}</h3>
|
|
214
|
+
|
|
215
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
216
|
+
{formatPrice(unitPrice, { currency }) as string}
|
|
217
|
+
</p>
|
|
218
|
+
|
|
219
|
+
<div className="mt-2 flex items-center gap-3">
|
|
220
|
+
<div className="border-border flex items-center rounded border">
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
onClick={() => handleQuantityChange(item.quantity - 1)}
|
|
224
|
+
disabled={updating || item.quantity <= 1}
|
|
225
|
+
className="text-foreground hover:bg-muted px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
|
226
|
+
aria-label="Decrease quantity"
|
|
227
|
+
>
|
|
228
|
+
-
|
|
229
|
+
</button>
|
|
230
|
+
<span className="text-foreground min-w-[2.5rem] px-3 py-1 text-center text-sm font-medium">
|
|
231
|
+
{item.quantity}
|
|
232
|
+
</span>
|
|
233
|
+
<button
|
|
234
|
+
type="button"
|
|
235
|
+
onClick={() => handleQuantityChange(item.quantity + 1)}
|
|
236
|
+
disabled={updating}
|
|
237
|
+
className="text-foreground hover:bg-muted px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
|
238
|
+
aria-label="Increase quantity"
|
|
239
|
+
>
|
|
240
|
+
+
|
|
241
|
+
</button>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<button
|
|
245
|
+
type="button"
|
|
246
|
+
onClick={handleRemove}
|
|
247
|
+
disabled={removing}
|
|
248
|
+
className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
|
|
249
|
+
>
|
|
250
|
+
{removing ? 'Removing...' : 'Remove'}
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
{/* Line total */}
|
|
256
|
+
<div className="flex-shrink-0 text-end">
|
|
257
|
+
<span className="text-foreground text-sm font-medium">
|
|
258
|
+
{formatPrice(lineTotal, { currency }) as string}
|
|
259
|
+
</span>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|