create-brainerce-store 1.14.0 → 1.14.2
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/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +4 -0
- package/templates/nextjs/base/package.json.ejs +2 -1
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +74 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +32 -12
- package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +1 -4
- package/templates/nextjs/base/src/app/products/page.tsx +5 -5
- package/templates/nextjs/base/src/components/account/order-history.tsx +3 -3
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +1 -3
- package/templates/nextjs/base/src/components/layout/header.tsx +1 -1
- package/templates/nextjs/base/src/components/products/stock-badge.tsx +30 -11
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.2",
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Brainerce Connection
|
|
2
2
|
NEXT_PUBLIC_BRAINERCE_CONNECTION_ID=<%= connectionId %>
|
|
3
3
|
|
|
4
|
+
# Store info (pre-fetched during setup to avoid flash on first load)
|
|
5
|
+
NEXT_PUBLIC_STORE_NAME=<%= storeName %>
|
|
6
|
+
NEXT_PUBLIC_STORE_CURRENCY=<%= currency %>
|
|
7
|
+
|
|
4
8
|
# Backend API URL (server-side only — used by BFF proxy and SSR, never exposed to browser)
|
|
5
9
|
BRAINERCE_API_URL=<%= apiBaseUrl %>
|
|
6
10
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup script: fetches store info from Brainerce using the connection ID
|
|
3
|
+
* and saves NEXT_PUBLIC_STORE_NAME (and other public fields) to .env.local.
|
|
4
|
+
*
|
|
5
|
+
* Run: node scripts/fetch-store-info.mjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
|
|
11
|
+
const envPath = join(process.cwd(), '.env.local');
|
|
12
|
+
|
|
13
|
+
if (!existsSync(envPath)) {
|
|
14
|
+
console.error('❌ .env.local not found. Create it first with NEXT_PUBLIC_BRAINERCE_CONNECTION_ID set.');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const envContent = readFileSync(envPath, 'utf-8');
|
|
19
|
+
|
|
20
|
+
function getVar(content, key) {
|
|
21
|
+
const match = content.match(new RegExp(`^${key}=(.*)$`, 'm'));
|
|
22
|
+
return match ? match[1].trim() : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function setVar(content, key, value) {
|
|
26
|
+
const regex = new RegExp(`^${key}=.*$`, 'm');
|
|
27
|
+
if (regex.test(content)) {
|
|
28
|
+
return content.replace(regex, `${key}=${value}`);
|
|
29
|
+
}
|
|
30
|
+
return content.trimEnd() + `\n${key}=${value}\n`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const connectionId = getVar(envContent, 'NEXT_PUBLIC_BRAINERCE_CONNECTION_ID');
|
|
34
|
+
const apiUrl = (getVar(envContent, 'BRAINERCE_API_URL') || 'https://api.brainerce.com').replace(/\/$/, '');
|
|
35
|
+
|
|
36
|
+
if (!connectionId) {
|
|
37
|
+
console.error('❌ NEXT_PUBLIC_BRAINERCE_CONNECTION_ID is not set in .env.local');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.log(`Fetching store info for connection: ${connectionId} ...`);
|
|
42
|
+
|
|
43
|
+
let storeInfo;
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`${apiUrl}/api/vc/${connectionId}/info`);
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
console.error(`❌ API returned ${res.status}: ${await res.text()}`);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
storeInfo = await res.json();
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error(`❌ Failed to reach ${apiUrl}: ${err.message}`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const name = storeInfo.name;
|
|
57
|
+
const currency = storeInfo.currency;
|
|
58
|
+
|
|
59
|
+
if (!name) {
|
|
60
|
+
console.error('❌ Store info response has no `name` field:', storeInfo);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let updated = envContent;
|
|
65
|
+
updated = setVar(updated, 'NEXT_PUBLIC_STORE_NAME', name);
|
|
66
|
+
if (currency) {
|
|
67
|
+
updated = setVar(updated, 'NEXT_PUBLIC_STORE_CURRENCY', currency);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
writeFileSync(envPath, updated, 'utf-8');
|
|
71
|
+
|
|
72
|
+
console.log(`✓ NEXT_PUBLIC_STORE_NAME=${name}`);
|
|
73
|
+
if (currency) console.log(`✓ NEXT_PUBLIC_STORE_CURRENCY=${currency}`);
|
|
74
|
+
console.log('Done. Restart the dev server for changes to take effect.');
|
|
@@ -46,6 +46,7 @@ function CheckoutContent() {
|
|
|
46
46
|
const [destinations, setDestinations] = useState<ShippingDestinations | null>(null);
|
|
47
47
|
const [pickupLocations, setPickupLocations] = useState<PickupLocation[]>([]);
|
|
48
48
|
const [deliveryType, setDeliveryType] = useState<'shipping' | 'pickup'>('shipping');
|
|
49
|
+
const [isAllDigital, setIsAllDigital] = useState(false);
|
|
49
50
|
|
|
50
51
|
// Check for returning from canceled payment
|
|
51
52
|
const canceled = searchParams.get('canceled') === 'true';
|
|
@@ -73,7 +74,13 @@ function CheckoutContent() {
|
|
|
73
74
|
setCheckout(existing);
|
|
74
75
|
|
|
75
76
|
// Determine step based on checkout state
|
|
76
|
-
|
|
77
|
+
const allDigital = existing.lineItems.every(
|
|
78
|
+
(i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
|
|
79
|
+
);
|
|
80
|
+
setIsAllDigital(allDigital);
|
|
81
|
+
if (allDigital) {
|
|
82
|
+
setStep('payment');
|
|
83
|
+
} else if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
|
|
77
84
|
setDeliveryType('pickup');
|
|
78
85
|
setStep('payment');
|
|
79
86
|
} else if (existing.shippingAddress && existing.shippingRateId) {
|
|
@@ -93,6 +100,16 @@ function CheckoutContent() {
|
|
|
93
100
|
if (cart && cart.id) {
|
|
94
101
|
const newCheckout = await client.createCheckout({ cartId: cart.id });
|
|
95
102
|
setCheckout(newCheckout);
|
|
103
|
+
|
|
104
|
+
// If all items are downloadable, skip shipping entirely
|
|
105
|
+
const allDigital = newCheckout.lineItems.every(
|
|
106
|
+
(i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
|
|
107
|
+
);
|
|
108
|
+
setIsAllDigital(allDigital);
|
|
109
|
+
if (allDigital) {
|
|
110
|
+
setStep('payment');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
96
113
|
} else {
|
|
97
114
|
setError(t('cartIsEmpty'));
|
|
98
115
|
}
|
|
@@ -265,8 +282,9 @@ function CheckoutContent() {
|
|
|
265
282
|
);
|
|
266
283
|
}
|
|
267
284
|
|
|
268
|
-
const steps: { key: CheckoutStep; label: string }[] =
|
|
269
|
-
|
|
285
|
+
const steps: { key: CheckoutStep; label: string }[] = isAllDigital
|
|
286
|
+
? [{ key: 'payment', label: t('stepPayment') }]
|
|
287
|
+
: pickupLocations.length > 0
|
|
270
288
|
? deliveryType === 'pickup'
|
|
271
289
|
? [
|
|
272
290
|
{ key: 'method', label: t('stepMethod') },
|
|
@@ -461,13 +479,15 @@ function CheckoutContent() {
|
|
|
461
479
|
<div>
|
|
462
480
|
<div className="mb-4 flex items-center justify-between">
|
|
463
481
|
<h2 className="text-foreground text-lg font-semibold">{t('payment')}</h2>
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
482
|
+
{!isAllDigital && (
|
|
483
|
+
<button
|
|
484
|
+
type="button"
|
|
485
|
+
onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
|
|
486
|
+
className="text-primary text-sm hover:underline"
|
|
487
|
+
>
|
|
488
|
+
{deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
|
|
489
|
+
</button>
|
|
490
|
+
)}
|
|
471
491
|
</div>
|
|
472
492
|
|
|
473
493
|
<PaymentStep checkoutId={checkout.id} />
|
|
@@ -543,8 +563,8 @@ function CheckoutContent() {
|
|
|
543
563
|
)
|
|
544
564
|
)}
|
|
545
565
|
|
|
546
|
-
{/* Coupon input — show from shipping/pickup step onwards */}
|
|
547
|
-
{cart && (step === 'shipping' || step === 'pickup' || step === 'payment') && (
|
|
566
|
+
{/* Coupon input — show from shipping/pickup step onwards (or immediately if digital) */}
|
|
567
|
+
{cart && (isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
|
|
548
568
|
<div className="border-border border-t pt-4">
|
|
549
569
|
<CouponInput cart={cart} onUpdate={handleCouponUpdate} />
|
|
550
570
|
</div>
|
|
@@ -201,9 +201,6 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
201
201
|
productId: product.id,
|
|
202
202
|
variantId: selectedVariant?.id,
|
|
203
203
|
quantity,
|
|
204
|
-
name: product.name,
|
|
205
|
-
price: String(priceInfo.price),
|
|
206
|
-
image: mainImageUrl || undefined,
|
|
207
204
|
});
|
|
208
205
|
await refreshCart();
|
|
209
206
|
setAddedMessage(true);
|
|
@@ -320,7 +317,7 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
320
317
|
{product.isDownloadable && product.downloads && product.downloads.length > 0 && (
|
|
321
318
|
<div className="bg-muted/50 rounded-lg border p-4">
|
|
322
319
|
<p className="text-foreground mb-2 text-sm font-medium">
|
|
323
|
-
{t('filesIncluded'
|
|
320
|
+
{t('filesIncluded')} ({product.downloads.length})
|
|
324
321
|
</p>
|
|
325
322
|
<ul className="space-y-1.5">
|
|
326
323
|
{product.downloads.map((file: DownloadFile) => (
|
|
@@ -69,19 +69,19 @@ function ChevronDown({ className }: { className?: string }) {
|
|
|
69
69
|
|
|
70
70
|
/** Recursive dropdown items for nested categories */
|
|
71
71
|
function CategoryDropdownItems({
|
|
72
|
-
|
|
72
|
+
items,
|
|
73
73
|
depth,
|
|
74
74
|
selectedId,
|
|
75
75
|
onSelect,
|
|
76
76
|
}: {
|
|
77
|
-
|
|
77
|
+
items: CategoryNode[];
|
|
78
78
|
depth: number;
|
|
79
79
|
selectedId: string;
|
|
80
80
|
onSelect: (id: string) => void;
|
|
81
81
|
}) {
|
|
82
82
|
return (
|
|
83
83
|
<>
|
|
84
|
-
{
|
|
84
|
+
{items.map((child) => (
|
|
85
85
|
<div key={child.id}>
|
|
86
86
|
<button
|
|
87
87
|
onClick={() => onSelect(child.id)}
|
|
@@ -95,7 +95,7 @@ function CategoryDropdownItems({
|
|
|
95
95
|
</button>
|
|
96
96
|
{child.children.length > 0 && (
|
|
97
97
|
<CategoryDropdownItems
|
|
98
|
-
|
|
98
|
+
items={child.children}
|
|
99
99
|
depth={depth + 1}
|
|
100
100
|
selectedId={selectedId}
|
|
101
101
|
onSelect={onSelect}
|
|
@@ -204,7 +204,7 @@ function CategoryChip({
|
|
|
204
204
|
{/* Recursive children */}
|
|
205
205
|
<div onClick={() => setOpen(false)}>
|
|
206
206
|
<CategoryDropdownItems
|
|
207
|
-
|
|
207
|
+
items={category.children}
|
|
208
208
|
depth={0}
|
|
209
209
|
selectedId={selectedId}
|
|
210
210
|
onSelect={onSelect}
|
|
@@ -77,7 +77,7 @@ 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 = STATUS_CONFIG[order.status] || STATUS_CONFIG.pending;
|
|
80
|
+
const statusConfig = STATUS_CONFIG[order.status?.toLowerCase() as OrderStatus] || STATUS_CONFIG.pending;
|
|
81
81
|
const currency = order.currency || 'USD';
|
|
82
82
|
const totalAmount = order.totalAmount || order.total || '0';
|
|
83
83
|
|
|
@@ -243,11 +243,11 @@ function OrderDownloads({ orderId }: { orderId: string }) {
|
|
|
243
243
|
{link.productName}
|
|
244
244
|
{' · '}
|
|
245
245
|
{link.downloadLimit != null
|
|
246
|
-
?
|
|
246
|
+
? `${link.downloadsUsed}/${link.downloadLimit} ${t('downloadsRemaining')}`
|
|
247
247
|
: t('unlimitedDownloads')}
|
|
248
248
|
{' · '}
|
|
249
249
|
{link.expiresAt
|
|
250
|
-
? t('expiresAt'
|
|
250
|
+
? `${t('expiresAt')} ${new Date(link.expiresAt).toLocaleDateString()}`
|
|
251
251
|
: t('noExpiry')}
|
|
252
252
|
</p>
|
|
253
253
|
</div>
|
|
@@ -243,9 +243,7 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
243
243
|
},
|
|
244
244
|
};
|
|
245
245
|
|
|
246
|
-
console.info(
|
|
247
|
-
`Payment SDK: ${method}({ environment: "${config.environment}", version: ${config.version} })`
|
|
248
|
-
);
|
|
246
|
+
console.info(`Payment SDK: calling ${method}()`);
|
|
249
247
|
global[method](config);
|
|
250
248
|
sdkInitDone = true;
|
|
251
249
|
}
|
|
@@ -81,7 +81,7 @@ export function Header() {
|
|
|
81
81
|
<div className="flex h-16 items-center justify-between gap-4">
|
|
82
82
|
{/* Logo / Store Name */}
|
|
83
83
|
<Link href="/" className="text-foreground flex-shrink-0 text-xl font-bold">
|
|
84
|
-
{storeInfo?.name || tc('store')}
|
|
84
|
+
{storeInfo?.name || process.env.NEXT_PUBLIC_STORE_NAME || tc('store')}
|
|
85
85
|
</Link>
|
|
86
86
|
|
|
87
87
|
{/* Desktop Navigation */}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { getStockStatus } from 'brainerce';
|
|
4
3
|
import type { InventoryInfo } from 'brainerce';
|
|
5
4
|
import { cn } from '@/lib/utils';
|
|
6
5
|
|
|
@@ -11,24 +10,44 @@ interface StockBadgeProps {
|
|
|
11
10
|
}
|
|
12
11
|
|
|
13
12
|
export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const colorClasses =
|
|
17
|
-
status === 'Out of Stock' || status === 'Unavailable'
|
|
18
|
-
? 'bg-red-100 text-red-800'
|
|
19
|
-
: status === 'Low Stock'
|
|
20
|
-
? 'bg-yellow-100 text-yellow-800'
|
|
21
|
-
: 'bg-green-100 text-green-800';
|
|
13
|
+
const label = getStockLabel(inventory, lowStockThreshold);
|
|
14
|
+
const color = getStockColor(inventory, lowStockThreshold);
|
|
22
15
|
|
|
23
16
|
return (
|
|
24
17
|
<span
|
|
25
18
|
className={cn(
|
|
26
19
|
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
|
27
|
-
|
|
20
|
+
color,
|
|
28
21
|
className
|
|
29
22
|
)}
|
|
30
23
|
>
|
|
31
|
-
{
|
|
24
|
+
{label}
|
|
32
25
|
</span>
|
|
33
26
|
);
|
|
34
27
|
}
|
|
28
|
+
|
|
29
|
+
function getStockLabel(inventory: InventoryInfo | null | undefined, lowStockThreshold: number): string {
|
|
30
|
+
if (!inventory) return 'Out of Stock';
|
|
31
|
+
|
|
32
|
+
const { trackingMode, inStock, available } = inventory;
|
|
33
|
+
|
|
34
|
+
if (trackingMode === 'DISABLED') return 'Unavailable';
|
|
35
|
+
if (!inStock) return 'Out of Stock';
|
|
36
|
+
if (trackingMode === 'UNLIMITED') return 'In Stock';
|
|
37
|
+
|
|
38
|
+
// TRACKED — show actual quantity
|
|
39
|
+
if (available <= lowStockThreshold) {
|
|
40
|
+
return `Only ${available} left`;
|
|
41
|
+
}
|
|
42
|
+
return `${available} in stock`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getStockColor(inventory: InventoryInfo | null | undefined, lowStockThreshold: number): string {
|
|
46
|
+
if (!inventory) return 'bg-red-100 text-red-800';
|
|
47
|
+
|
|
48
|
+
const { trackingMode, inStock, available } = inventory;
|
|
49
|
+
|
|
50
|
+
if (trackingMode === 'DISABLED' || !inStock) return 'bg-red-100 text-red-800';
|
|
51
|
+
if (trackingMode === 'TRACKED' && available <= lowStockThreshold) return 'bg-yellow-100 text-yellow-800';
|
|
52
|
+
return 'bg-green-100 text-green-800';
|
|
53
|
+
}
|