create-brainerce-store 1.14.2 → 1.14.3
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 +1 -1
- package/messages/en.json +2 -1
- package/messages/he.json +2 -1
- package/package.json +1 -1
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +81 -74
- package/templates/nextjs/base/src/app/checkout/page.tsx +6 -5
- package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +1 -6
- package/templates/nextjs/base/src/components/account/order-history.tsx +2 -1
- package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
- package/templates/nextjs/base/src/components/products/product-card.tsx +137 -43
- package/templates/nextjs/base/src/components/products/stock-badge.tsx +60 -53
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "create-brainerce-store",
|
|
34
|
-
version: "1.14.
|
|
34
|
+
version: "1.14.3",
|
|
35
35
|
description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
36
36
|
bin: {
|
|
37
37
|
"create-brainerce-store": "dist/index.js"
|
package/messages/en.json
CHANGED
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
"sortNameAZ": "Name A-Z",
|
|
63
63
|
"sortNameZA": "Name Z-A",
|
|
64
64
|
"sortPriceLow": "Price: Low to High",
|
|
65
|
-
"sortPriceHigh": "Price: High to Low"
|
|
65
|
+
"sortPriceHigh": "Price: High to Low",
|
|
66
|
+
"selectOptions": "Select Options"
|
|
66
67
|
},
|
|
67
68
|
"productDetail": {
|
|
68
69
|
"notFound": "Product not found.",
|
package/messages/he.json
CHANGED
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
"sortNameAZ": "שם א-ת",
|
|
63
63
|
"sortNameZA": "שם ת-א",
|
|
64
64
|
"sortPriceLow": "מחיר: מהנמוך לגבוה",
|
|
65
|
-
"sortPriceHigh": "מחיר: מהגבוה לנמוך"
|
|
65
|
+
"sortPriceHigh": "מחיר: מהגבוה לנמוך",
|
|
66
|
+
"selectOptions": "בחר אפשרויות"
|
|
66
67
|
},
|
|
67
68
|
"productDetail": {
|
|
68
69
|
"notFound": "המוצר לא נמצא.",
|
package/package.json
CHANGED
|
@@ -1,74 +1,81 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (currency)
|
|
74
|
-
|
|
1
|
+
/* global process, console, fetch */
|
|
2
|
+
/* eslint-disable no-console */
|
|
3
|
+
/**
|
|
4
|
+
* Setup script: fetches store info from Brainerce using the connection ID
|
|
5
|
+
* and saves NEXT_PUBLIC_STORE_NAME (and other public fields) to .env.local.
|
|
6
|
+
*
|
|
7
|
+
* Run: node scripts/fetch-store-info.mjs
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
|
|
13
|
+
const envPath = join(process.cwd(), '.env.local');
|
|
14
|
+
|
|
15
|
+
if (!existsSync(envPath)) {
|
|
16
|
+
console.error(
|
|
17
|
+
'❌ .env.local not found. Create it first with NEXT_PUBLIC_BRAINERCE_CONNECTION_ID set.'
|
|
18
|
+
);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const envContent = readFileSync(envPath, 'utf-8');
|
|
23
|
+
|
|
24
|
+
function getVar(content, key) {
|
|
25
|
+
const match = content.match(new RegExp(`^${key}=(.*)$`, 'm'));
|
|
26
|
+
return match ? match[1].trim() : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setVar(content, key, value) {
|
|
30
|
+
const regex = new RegExp(`^${key}=.*$`, 'm');
|
|
31
|
+
if (regex.test(content)) {
|
|
32
|
+
return content.replace(regex, `${key}=${value}`);
|
|
33
|
+
}
|
|
34
|
+
return content.trimEnd() + `\n${key}=${value}\n`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const connectionId = getVar(envContent, 'NEXT_PUBLIC_BRAINERCE_CONNECTION_ID');
|
|
38
|
+
const apiUrl = (getVar(envContent, 'BRAINERCE_API_URL') || 'https://api.brainerce.com').replace(
|
|
39
|
+
/\/$/,
|
|
40
|
+
''
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (!connectionId) {
|
|
44
|
+
console.error('❌ NEXT_PUBLIC_BRAINERCE_CONNECTION_ID is not set in .env.local');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.log(`Fetching store info for connection: ${connectionId} ...`);
|
|
49
|
+
|
|
50
|
+
let storeInfo;
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(`${apiUrl}/api/vc/${connectionId}/info`);
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
console.error(`❌ API returned ${res.status}: ${await res.text()}`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
storeInfo = await res.json();
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error(`❌ Failed to reach ${apiUrl}: ${err.message}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const name = storeInfo.name;
|
|
64
|
+
const currency = storeInfo.currency;
|
|
65
|
+
|
|
66
|
+
if (!name) {
|
|
67
|
+
console.error('❌ Store info response has no `name` field:', storeInfo);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let updated = envContent;
|
|
72
|
+
updated = setVar(updated, 'NEXT_PUBLIC_STORE_NAME', name);
|
|
73
|
+
if (currency) {
|
|
74
|
+
updated = setVar(updated, 'NEXT_PUBLIC_STORE_CURRENCY', currency);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
writeFileSync(envPath, updated, 'utf-8');
|
|
78
|
+
|
|
79
|
+
console.log(`✓ NEXT_PUBLIC_STORE_NAME=${name}`);
|
|
80
|
+
if (currency) console.log(`✓ NEXT_PUBLIC_STORE_CURRENCY=${currency}`);
|
|
81
|
+
console.log('Done. Restart the dev server for changes to take effect.');
|
|
@@ -564,11 +564,12 @@ function CheckoutContent() {
|
|
|
564
564
|
)}
|
|
565
565
|
|
|
566
566
|
{/* Coupon input — show from shipping/pickup step onwards (or immediately if digital) */}
|
|
567
|
-
{cart &&
|
|
568
|
-
|
|
569
|
-
<
|
|
570
|
-
|
|
571
|
-
|
|
567
|
+
{cart &&
|
|
568
|
+
(isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
|
|
569
|
+
<div className="border-border border-t pt-4">
|
|
570
|
+
<CouponInput cart={cart} onUpdate={handleCouponUpdate} />
|
|
571
|
+
</div>
|
|
572
|
+
)}
|
|
572
573
|
|
|
573
574
|
{/* Totals */}
|
|
574
575
|
{checkout && (
|
|
@@ -7,13 +7,8 @@ import type {
|
|
|
7
7
|
ProductVariant,
|
|
8
8
|
ProductImage,
|
|
9
9
|
ProductMetafield,
|
|
10
|
-
ProductRecommendationsResponse,
|
|
11
10
|
DownloadFile,
|
|
12
11
|
} from 'brainerce';
|
|
13
|
-
|
|
14
|
-
type ProductWithRecommendations = Product & {
|
|
15
|
-
recommendations?: ProductRecommendationsResponse;
|
|
16
|
-
};
|
|
17
12
|
import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
|
|
18
13
|
import { useCart } from '@/providers/store-provider';
|
|
19
14
|
import { PriceDisplay } from '@/components/shared/price-display';
|
|
@@ -116,7 +111,7 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
116
111
|
const { refreshCart } = useCart();
|
|
117
112
|
const t = useTranslations('productDetail');
|
|
118
113
|
|
|
119
|
-
const product = initialProduct
|
|
114
|
+
const product = initialProduct;
|
|
120
115
|
const recommendations = product?.recommendations ?? null;
|
|
121
116
|
|
|
122
117
|
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
|
|
@@ -77,7 +77,8 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
77
77
|
const t = useTranslations('account');
|
|
78
78
|
const tc = useTranslations('common');
|
|
79
79
|
const [expanded, setExpanded] = useState(false);
|
|
80
|
-
const statusConfig =
|
|
80
|
+
const statusConfig =
|
|
81
|
+
STATUS_CONFIG[order.status?.toLowerCase() as OrderStatus] || STATUS_CONFIG.pending;
|
|
81
82
|
const currency = order.currency || 'USD';
|
|
82
83
|
const totalAmount = order.totalAmount || order.total || '0';
|
|
83
84
|
|
|
@@ -1,153 +1,153 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react';
|
|
4
|
-
import Image from 'next/image';
|
|
5
|
-
import type { CartItem as CartItemType } from 'brainerce';
|
|
6
|
-
import {
|
|
7
|
-
import { getClient } from '@/lib/brainerce';
|
|
8
|
-
import { useTranslations } from '@/lib/translations';
|
|
9
|
-
import { useStoreInfo } from '@/providers/store-provider';
|
|
10
|
-
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
11
|
-
import { cn } from '@/lib/utils';
|
|
12
|
-
|
|
13
|
-
interface CartItemProps {
|
|
14
|
-
item: CartItemType;
|
|
15
|
-
onUpdate: () => void;
|
|
16
|
-
className?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
20
|
-
const t = useTranslations('common');
|
|
21
|
-
const td = useTranslations('productDetail');
|
|
22
|
-
const { storeInfo } = useStoreInfo();
|
|
23
|
-
const currency = storeInfo?.currency || 'USD';
|
|
24
|
-
const [updating, setUpdating] = useState(false);
|
|
25
|
-
const [removing, setRemoving] = useState(false);
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const imageUrl = getCartItemImage(item);
|
|
29
|
-
const variantName = item.variant?.name;
|
|
30
|
-
const unitPrice = parseFloat(item.unitPrice);
|
|
31
|
-
const lineTotal = unitPrice * item.quantity;
|
|
32
|
-
|
|
33
|
-
async function handleQuantityChange(newQuantity: number) {
|
|
34
|
-
if (newQuantity < 1 || updating) return;
|
|
35
|
-
|
|
36
|
-
try {
|
|
37
|
-
setUpdating(true);
|
|
38
|
-
const client = getClient();
|
|
39
|
-
await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
|
|
40
|
-
onUpdate();
|
|
41
|
-
} catch (err) {
|
|
42
|
-
console.error('Failed to update quantity:', err);
|
|
43
|
-
} finally {
|
|
44
|
-
setUpdating(false);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async function handleRemove() {
|
|
49
|
-
if (removing) return;
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
setRemoving(true);
|
|
53
|
-
const client = getClient();
|
|
54
|
-
await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
|
|
55
|
-
onUpdate();
|
|
56
|
-
} catch (err) {
|
|
57
|
-
console.error('Failed to remove item:', err);
|
|
58
|
-
} finally {
|
|
59
|
-
setRemoving(false);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return (
|
|
64
|
-
<div
|
|
65
|
-
className={cn(
|
|
66
|
-
'border-border flex gap-4 border-b py-4 last:border-0',
|
|
67
|
-
(updating || removing) && 'opacity-60',
|
|
68
|
-
className
|
|
69
|
-
)}
|
|
70
|
-
>
|
|
71
|
-
{/* Image */}
|
|
72
|
-
<div className="bg-muted relative h-20 w-20 flex-shrink-0 overflow-hidden rounded">
|
|
73
|
-
{imageUrl ? (
|
|
74
|
-
<Image src={imageUrl} alt={
|
|
75
|
-
) : (
|
|
76
|
-
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
77
|
-
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
78
|
-
<path
|
|
79
|
-
strokeLinecap="round"
|
|
80
|
-
strokeLinejoin="round"
|
|
81
|
-
strokeWidth={1.5}
|
|
82
|
-
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"
|
|
83
|
-
/>
|
|
84
|
-
</svg>
|
|
85
|
-
</div>
|
|
86
|
-
)}
|
|
87
|
-
</div>
|
|
88
|
-
|
|
89
|
-
{/* Details */}
|
|
90
|
-
<div className="min-w-0 flex-1">
|
|
91
|
-
<h3 className="text-foreground truncate text-sm font-medium">{
|
|
92
|
-
|
|
93
|
-
{/* Variant name */}
|
|
94
|
-
{variantName && <p className="text-muted-foreground mt-1 text-xs">{variantName}</p>}
|
|
95
|
-
|
|
96
|
-
{/* Unit price */}
|
|
97
|
-
<p className="text-muted-foreground mt-1 text-sm">
|
|
98
|
-
{formatPrice(unitPrice, { currency }) as string}
|
|
99
|
-
</p>
|
|
100
|
-
|
|
101
|
-
{/* Quantity controls */}
|
|
102
|
-
<div className="mt-2 flex items-center gap-3">
|
|
103
|
-
<div className="border-border flex items-center rounded border">
|
|
104
|
-
<button
|
|
105
|
-
type="button"
|
|
106
|
-
onClick={() => handleQuantityChange(item.quantity - 1)}
|
|
107
|
-
disabled={updating || item.quantity <= 1}
|
|
108
|
-
className="text-foreground hover:bg-muted px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
|
109
|
-
aria-label={td('decreaseQuantity')}
|
|
110
|
-
>
|
|
111
|
-
-
|
|
112
|
-
</button>
|
|
113
|
-
<span className="text-foreground min-w-[2.5rem] px-3 py-1 text-center text-sm font-medium">
|
|
114
|
-
{updating ? (
|
|
115
|
-
<LoadingSpinner
|
|
116
|
-
size="sm"
|
|
117
|
-
className="border-muted-foreground/30 border-t-foreground mx-auto"
|
|
118
|
-
/>
|
|
119
|
-
) : (
|
|
120
|
-
item.quantity
|
|
121
|
-
)}
|
|
122
|
-
</span>
|
|
123
|
-
<button
|
|
124
|
-
type="button"
|
|
125
|
-
onClick={() => handleQuantityChange(item.quantity + 1)}
|
|
126
|
-
disabled={updating}
|
|
127
|
-
className="text-foreground hover:bg-muted px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
|
128
|
-
aria-label={td('increaseQuantity')}
|
|
129
|
-
>
|
|
130
|
-
+
|
|
131
|
-
</button>
|
|
132
|
-
</div>
|
|
133
|
-
|
|
134
|
-
<button
|
|
135
|
-
type="button"
|
|
136
|
-
onClick={handleRemove}
|
|
137
|
-
disabled={removing}
|
|
138
|
-
className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
|
|
139
|
-
>
|
|
140
|
-
{removing ? t('removing') : t('remove')}
|
|
141
|
-
</button>
|
|
142
|
-
</div>
|
|
143
|
-
</div>
|
|
144
|
-
|
|
145
|
-
{/* Line total */}
|
|
146
|
-
<div className="flex-shrink-0 text-end">
|
|
147
|
-
<span className="text-foreground text-sm font-medium">
|
|
148
|
-
{formatPrice(lineTotal, { currency }) as string}
|
|
149
|
-
</span>
|
|
150
|
-
</div>
|
|
151
|
-
</div>
|
|
152
|
-
);
|
|
153
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import Image from 'next/image';
|
|
5
|
+
import type { CartItem as CartItemType } from 'brainerce';
|
|
6
|
+
import { getCartItemImage, formatPrice } from 'brainerce';
|
|
7
|
+
import { getClient } from '@/lib/brainerce';
|
|
8
|
+
import { useTranslations } from '@/lib/translations';
|
|
9
|
+
import { useStoreInfo } from '@/providers/store-provider';
|
|
10
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
11
|
+
import { cn } from '@/lib/utils';
|
|
12
|
+
|
|
13
|
+
interface CartItemProps {
|
|
14
|
+
item: CartItemType;
|
|
15
|
+
onUpdate: () => void;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
20
|
+
const t = useTranslations('common');
|
|
21
|
+
const td = useTranslations('productDetail');
|
|
22
|
+
const { storeInfo } = useStoreInfo();
|
|
23
|
+
const currency = storeInfo?.currency || 'USD';
|
|
24
|
+
const [updating, setUpdating] = useState(false);
|
|
25
|
+
const [removing, setRemoving] = useState(false);
|
|
26
|
+
|
|
27
|
+
const productName = item.product.name;
|
|
28
|
+
const imageUrl = getCartItemImage(item);
|
|
29
|
+
const variantName = item.variant?.name;
|
|
30
|
+
const unitPrice = parseFloat(item.unitPrice);
|
|
31
|
+
const lineTotal = unitPrice * item.quantity;
|
|
32
|
+
|
|
33
|
+
async function handleQuantityChange(newQuantity: number) {
|
|
34
|
+
if (newQuantity < 1 || updating) return;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
setUpdating(true);
|
|
38
|
+
const client = getClient();
|
|
39
|
+
await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
|
|
40
|
+
onUpdate();
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error('Failed to update quantity:', err);
|
|
43
|
+
} finally {
|
|
44
|
+
setUpdating(false);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function handleRemove() {
|
|
49
|
+
if (removing) return;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
setRemoving(true);
|
|
53
|
+
const client = getClient();
|
|
54
|
+
await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
|
|
55
|
+
onUpdate();
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error('Failed to remove item:', err);
|
|
58
|
+
} finally {
|
|
59
|
+
setRemoving(false);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
className={cn(
|
|
66
|
+
'border-border flex gap-4 border-b py-4 last:border-0',
|
|
67
|
+
(updating || removing) && 'opacity-60',
|
|
68
|
+
className
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
{/* Image */}
|
|
72
|
+
<div className="bg-muted relative h-20 w-20 flex-shrink-0 overflow-hidden rounded">
|
|
73
|
+
{imageUrl ? (
|
|
74
|
+
<Image src={imageUrl} alt={productName} fill sizes="80px" className="object-cover" />
|
|
75
|
+
) : (
|
|
76
|
+
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
77
|
+
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
78
|
+
<path
|
|
79
|
+
strokeLinecap="round"
|
|
80
|
+
strokeLinejoin="round"
|
|
81
|
+
strokeWidth={1.5}
|
|
82
|
+
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"
|
|
83
|
+
/>
|
|
84
|
+
</svg>
|
|
85
|
+
</div>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{/* Details */}
|
|
90
|
+
<div className="min-w-0 flex-1">
|
|
91
|
+
<h3 className="text-foreground truncate text-sm font-medium">{productName}</h3>
|
|
92
|
+
|
|
93
|
+
{/* Variant name */}
|
|
94
|
+
{variantName && <p className="text-muted-foreground mt-1 text-xs">{variantName}</p>}
|
|
95
|
+
|
|
96
|
+
{/* Unit price */}
|
|
97
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
98
|
+
{formatPrice(unitPrice, { currency }) as string}
|
|
99
|
+
</p>
|
|
100
|
+
|
|
101
|
+
{/* Quantity controls */}
|
|
102
|
+
<div className="mt-2 flex items-center gap-3">
|
|
103
|
+
<div className="border-border flex items-center rounded border">
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={() => handleQuantityChange(item.quantity - 1)}
|
|
107
|
+
disabled={updating || item.quantity <= 1}
|
|
108
|
+
className="text-foreground hover:bg-muted px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
|
109
|
+
aria-label={td('decreaseQuantity')}
|
|
110
|
+
>
|
|
111
|
+
-
|
|
112
|
+
</button>
|
|
113
|
+
<span className="text-foreground min-w-[2.5rem] px-3 py-1 text-center text-sm font-medium">
|
|
114
|
+
{updating ? (
|
|
115
|
+
<LoadingSpinner
|
|
116
|
+
size="sm"
|
|
117
|
+
className="border-muted-foreground/30 border-t-foreground mx-auto"
|
|
118
|
+
/>
|
|
119
|
+
) : (
|
|
120
|
+
item.quantity
|
|
121
|
+
)}
|
|
122
|
+
</span>
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={() => handleQuantityChange(item.quantity + 1)}
|
|
126
|
+
disabled={updating}
|
|
127
|
+
className="text-foreground hover:bg-muted px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
|
128
|
+
aria-label={td('increaseQuantity')}
|
|
129
|
+
>
|
|
130
|
+
+
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={handleRemove}
|
|
137
|
+
disabled={removing}
|
|
138
|
+
className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
|
|
139
|
+
>
|
|
140
|
+
{removing ? t('removing') : t('remove')}
|
|
141
|
+
</button>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
{/* Line total */}
|
|
146
|
+
<div className="flex-shrink-0 text-end">
|
|
147
|
+
<span className="text-foreground text-sm font-medium">
|
|
148
|
+
{formatPrice(lineTotal, { currency }) as string}
|
|
149
|
+
</span>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import { useState } from 'react';
|
|
3
4
|
import Link from 'next/link';
|
|
5
|
+
import { useRouter } from 'next/navigation';
|
|
4
6
|
import Image from 'next/image';
|
|
5
7
|
import type { Product } from 'brainerce';
|
|
6
|
-
import { getProductPriceInfo } from 'brainerce';
|
|
8
|
+
import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
|
|
7
9
|
import { useTranslations } from '@/lib/translations';
|
|
8
10
|
import { PriceDisplay } from '@/components/shared/price-display';
|
|
9
11
|
import { StockBadge } from '@/components/products/stock-badge';
|
|
10
12
|
import { DiscountBadge } from '@/components/products/discount-badge';
|
|
13
|
+
import { useCart, useStoreInfo } from '@/providers/store-provider';
|
|
11
14
|
import { cn } from '@/lib/utils';
|
|
12
15
|
|
|
13
16
|
interface ProductCardProps {
|
|
@@ -15,60 +18,145 @@ interface ProductCardProps {
|
|
|
15
18
|
className?: string;
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
function VariantPriceRange({ product }: { product: Product }) {
|
|
22
|
+
const { storeInfo } = useStoreInfo();
|
|
23
|
+
const currency = storeInfo?.currency || 'USD';
|
|
24
|
+
const variants = product.variants ?? [];
|
|
25
|
+
if (variants.length === 0) return null;
|
|
26
|
+
|
|
27
|
+
const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
|
|
28
|
+
const min = Math.min(...prices);
|
|
29
|
+
const max = Math.max(...prices);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<span className="text-foreground text-sm font-medium">
|
|
33
|
+
{min === max
|
|
34
|
+
? (formatPrice(min, { currency }) as string)
|
|
35
|
+
: `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`}
|
|
36
|
+
</span>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
18
40
|
export function ProductCard({ product, className }: ProductCardProps) {
|
|
19
41
|
const t = useTranslations('common');
|
|
20
42
|
const tp = useTranslations('productDetail');
|
|
43
|
+
const tProd = useTranslations('products');
|
|
44
|
+
const router = useRouter();
|
|
45
|
+
const { refreshCart } = useCart();
|
|
21
46
|
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
22
47
|
const mainImage = product.images?.[0];
|
|
23
48
|
const imageUrl = mainImage?.url || null;
|
|
24
49
|
const slug = product.slug || product.id;
|
|
50
|
+
const isVariable = product.type === 'VARIABLE';
|
|
51
|
+
|
|
52
|
+
const [adding, setAdding] = useState(false);
|
|
53
|
+
const [added, setAdded] = useState(false);
|
|
54
|
+
|
|
55
|
+
const canPurchase = product.inventory?.canPurchase !== false;
|
|
56
|
+
|
|
57
|
+
async function handleAddToCart(e: React.MouseEvent) {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
e.stopPropagation();
|
|
60
|
+
|
|
61
|
+
if (isVariable) {
|
|
62
|
+
router.push(`/products/${slug}`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (adding || !canPurchase) return;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
setAdding(true);
|
|
70
|
+
const { getClient } = await import('@/lib/brainerce');
|
|
71
|
+
const client = getClient();
|
|
72
|
+
await client.smartAddToCart({ productId: product.id, quantity: 1 });
|
|
73
|
+
await refreshCart();
|
|
74
|
+
setAdded(true);
|
|
75
|
+
setTimeout(() => setAdded(false), 2000);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error('Failed to add to cart:', err);
|
|
78
|
+
} finally {
|
|
79
|
+
setAdding(false);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
25
82
|
|
|
26
83
|
return (
|
|
27
|
-
<
|
|
28
|
-
href={`/products/${slug}`}
|
|
84
|
+
<div
|
|
29
85
|
className={cn(
|
|
30
86
|
'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
|
|
31
87
|
className
|
|
32
88
|
)}
|
|
33
89
|
>
|
|
34
|
-
{/* Image */}
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
<
|
|
47
|
-
<
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
90
|
+
{/* Image — clickable */}
|
|
91
|
+
<Link href={`/products/${slug}`} className="block">
|
|
92
|
+
<div className="bg-muted relative aspect-square overflow-hidden">
|
|
93
|
+
{imageUrl ? (
|
|
94
|
+
<Image
|
|
95
|
+
src={imageUrl}
|
|
96
|
+
alt={mainImage?.alt || product.name}
|
|
97
|
+
fill
|
|
98
|
+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
99
|
+
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
|
100
|
+
/>
|
|
101
|
+
) : (
|
|
102
|
+
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
103
|
+
<svg className="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
104
|
+
<path
|
|
105
|
+
strokeLinecap="round"
|
|
106
|
+
strokeLinejoin="round"
|
|
107
|
+
strokeWidth={1.5}
|
|
108
|
+
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"
|
|
109
|
+
/>
|
|
110
|
+
</svg>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{/* Badges */}
|
|
115
|
+
<div className="absolute start-2 top-2 flex flex-col gap-1">
|
|
116
|
+
{isOnSale && (
|
|
117
|
+
<span className="bg-destructive text-destructive-foreground rounded px-2 py-1 text-xs font-bold">
|
|
118
|
+
{t('sale')}
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
<DiscountBadge discount={product.discount} />
|
|
122
|
+
{product.isDownloadable && (
|
|
123
|
+
<span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
|
|
124
|
+
{tp('digitalProduct')}
|
|
125
|
+
</span>
|
|
126
|
+
)}
|
|
54
127
|
</div>
|
|
55
|
-
)}
|
|
56
128
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
{
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
129
|
+
{/* Add to cart overlay button */}
|
|
130
|
+
{(isVariable || canPurchase) && (
|
|
131
|
+
<button
|
|
132
|
+
onClick={handleAddToCart}
|
|
133
|
+
disabled={adding}
|
|
134
|
+
aria-label={isVariable ? tProd('selectOptions') : tp('addToCart')}
|
|
135
|
+
className={cn(
|
|
136
|
+
'absolute bottom-2 end-2 flex h-8 w-8 items-center justify-center rounded-full shadow-md transition-all',
|
|
137
|
+
'translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100',
|
|
138
|
+
added
|
|
139
|
+
? 'bg-green-500 text-white'
|
|
140
|
+
: 'bg-primary text-primary-foreground hover:opacity-90'
|
|
141
|
+
)}
|
|
142
|
+
>
|
|
143
|
+
{added ? (
|
|
144
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
145
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
146
|
+
</svg>
|
|
147
|
+
) : isVariable ? (
|
|
148
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
149
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
|
150
|
+
</svg>
|
|
151
|
+
) : (
|
|
152
|
+
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
153
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
|
154
|
+
</svg>
|
|
155
|
+
)}
|
|
156
|
+
</button>
|
|
69
157
|
)}
|
|
70
158
|
</div>
|
|
71
|
-
</
|
|
159
|
+
</Link>
|
|
72
160
|
|
|
73
161
|
{/* Content */}
|
|
74
162
|
<div className="space-y-2 p-3">
|
|
@@ -86,17 +174,23 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
86
174
|
</div>
|
|
87
175
|
)}
|
|
88
176
|
|
|
89
|
-
{/* Name */}
|
|
90
|
-
<
|
|
91
|
-
|
|
92
|
-
|
|
177
|
+
{/* Name — clickable */}
|
|
178
|
+
<Link href={`/products/${slug}`}>
|
|
179
|
+
<h3 className="text-foreground hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
|
|
180
|
+
{product.name}
|
|
181
|
+
</h3>
|
|
182
|
+
</Link>
|
|
93
183
|
|
|
94
184
|
{/* Price */}
|
|
95
|
-
|
|
185
|
+
{isVariable ? (
|
|
186
|
+
<VariantPriceRange product={product} />
|
|
187
|
+
) : (
|
|
188
|
+
<PriceDisplay price={originalPrice} salePrice={isOnSale ? price : undefined} size="sm" />
|
|
189
|
+
)}
|
|
96
190
|
|
|
97
191
|
{/* Stock */}
|
|
98
192
|
<StockBadge inventory={product.inventory} />
|
|
99
193
|
</div>
|
|
100
|
-
</
|
|
194
|
+
</div>
|
|
101
195
|
);
|
|
102
196
|
}
|
|
@@ -1,53 +1,60 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import type { InventoryInfo } from 'brainerce';
|
|
4
|
-
import { cn } from '@/lib/utils';
|
|
5
|
-
|
|
6
|
-
interface StockBadgeProps {
|
|
7
|
-
inventory: InventoryInfo | null | undefined;
|
|
8
|
-
lowStockThreshold?: number;
|
|
9
|
-
className?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
|
|
13
|
-
const label = getStockLabel(inventory, lowStockThreshold);
|
|
14
|
-
const color = getStockColor(inventory, lowStockThreshold);
|
|
15
|
-
|
|
16
|
-
return (
|
|
17
|
-
<span
|
|
18
|
-
className={cn(
|
|
19
|
-
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
|
20
|
-
color,
|
|
21
|
-
className
|
|
22
|
-
)}
|
|
23
|
-
>
|
|
24
|
-
{label}
|
|
25
|
-
</span>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function getStockLabel(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return 'bg-
|
|
53
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { InventoryInfo } from 'brainerce';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
|
|
6
|
+
interface StockBadgeProps {
|
|
7
|
+
inventory: InventoryInfo | null | undefined;
|
|
8
|
+
lowStockThreshold?: number;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
|
|
13
|
+
const label = getStockLabel(inventory, lowStockThreshold);
|
|
14
|
+
const color = getStockColor(inventory, lowStockThreshold);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<span
|
|
18
|
+
className={cn(
|
|
19
|
+
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
|
20
|
+
color,
|
|
21
|
+
className
|
|
22
|
+
)}
|
|
23
|
+
>
|
|
24
|
+
{label}
|
|
25
|
+
</span>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getStockLabel(
|
|
30
|
+
inventory: InventoryInfo | null | undefined,
|
|
31
|
+
lowStockThreshold: number
|
|
32
|
+
): string {
|
|
33
|
+
if (!inventory) return 'Out of Stock';
|
|
34
|
+
|
|
35
|
+
const { trackingMode, inStock, available } = inventory;
|
|
36
|
+
|
|
37
|
+
if (trackingMode === 'DISABLED') return 'Unavailable';
|
|
38
|
+
if (!inStock) return 'Out of Stock';
|
|
39
|
+
if (trackingMode === 'UNLIMITED') return 'In Stock';
|
|
40
|
+
|
|
41
|
+
// TRACKED — show actual quantity
|
|
42
|
+
if (available <= lowStockThreshold) {
|
|
43
|
+
return `Only ${available} left`;
|
|
44
|
+
}
|
|
45
|
+
return `${available} in stock`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getStockColor(
|
|
49
|
+
inventory: InventoryInfo | null | undefined,
|
|
50
|
+
lowStockThreshold: number
|
|
51
|
+
): string {
|
|
52
|
+
if (!inventory) return 'bg-red-100 text-red-800';
|
|
53
|
+
|
|
54
|
+
const { trackingMode, inStock, available } = inventory;
|
|
55
|
+
|
|
56
|
+
if (trackingMode === 'DISABLED' || !inStock) return 'bg-red-100 text-red-800';
|
|
57
|
+
if (trackingMode === 'TRACKED' && available <= lowStockThreshold)
|
|
58
|
+
return 'bg-yellow-100 text-yellow-800';
|
|
59
|
+
return 'bg-green-100 text-green-800';
|
|
60
|
+
}
|