@brainerce/mcp-server 1.3.0 → 2.0.1
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/bin/http.js +2750 -1280
- package/dist/bin/stdio.js +2750 -1280
- package/dist/index.d.mts +50 -1
- package/dist/index.d.ts +50 -1
- package/dist/index.js +2754 -1280
- package/dist/index.mjs +2752 -1280
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -33,7 +33,9 @@ __export(index_exports, {
|
|
|
33
33
|
AVAILABLE_DOMAINS: () => AVAILABLE_DOMAINS,
|
|
34
34
|
PROJECT_TEMPLATE: () => PROJECT_TEMPLATE,
|
|
35
35
|
buildFullStorePrompt: () => buildFullStorePrompt,
|
|
36
|
+
buildStoreBundle: () => buildStoreBundle,
|
|
36
37
|
createServer: () => createServer,
|
|
38
|
+
fetchStoreCapabilities: () => fetchStoreCapabilities,
|
|
37
39
|
fetchStoreInfo: () => fetchStoreInfo,
|
|
38
40
|
getAllTypes: () => getAllTypes,
|
|
39
41
|
getSectionByTopic: () => getSectionByTopic,
|
|
@@ -116,7 +118,7 @@ function getTypeQuickReference() {
|
|
|
116
118
|
- \`SetShippingAddressDto.email\` is **required** \u2014 always include email
|
|
117
119
|
|
|
118
120
|
**Mandatory helpers (import from \`'brainerce'\`):**
|
|
119
|
-
\`getCartTotals\`, \`getCartItemName\`, \`getCartItemImage\`, \`formatPrice\`, \`getProductPriceInfo\`, \`getVariantPrice\`, \`getStockStatus\`, \`getVariantOptions\`, \`getDescriptionContent\`, \`getProductMetafieldValue\``;
|
|
121
|
+
\`getCartTotals\`, \`getCartItemName\`, \`getCartItemImage\`, \`formatPrice\`, \`getProductPriceInfo\`, \`getVariantPrice\`, \`getStockStatus\`, \`getVariantOptions\`, \`getProductSwatches\`, \`getDescriptionContent\`, \`getProductMetafieldValue\``;
|
|
120
122
|
}
|
|
121
123
|
function getSdkSetupSection(connectionId) {
|
|
122
124
|
return `## SDK Setup
|
|
@@ -604,6 +606,25 @@ const material = getProductMetafieldValue(product, 'material');
|
|
|
604
606
|
|
|
605
607
|
**Metafield fields:** \`definitionName\` (display label), \`definitionKey\` (lookup key), \`value\`, \`type\` (IMAGE, GALLERY, URL, COLOR, BOOLEAN, DATE, DATETIME, TEXT, TEXTAREA, NUMBER, DIMENSION, WEIGHT, JSON)
|
|
606
608
|
|
|
609
|
+
### Downloadable / Digital Products
|
|
610
|
+
|
|
611
|
+
Products with \`isDownloadable: true\` are digital products. Show a digital badge instead of stock badge, list included files, and note that download links appear after purchase.
|
|
612
|
+
|
|
613
|
+
\`\`\`typescript
|
|
614
|
+
import type { DownloadFile } from 'brainerce';
|
|
615
|
+
|
|
616
|
+
// Check if product is digital
|
|
617
|
+
if (product.isDownloadable) {
|
|
618
|
+
// Show "Instant Download" badge instead of StockBadge
|
|
619
|
+
// List files: product.downloads?.map((file: DownloadFile) => file.name)
|
|
620
|
+
// Show "Download available after purchase" note
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// After purchase \u2014 get download links from order
|
|
624
|
+
const downloads = await client.getOrderDownloads(orderId);
|
|
625
|
+
// downloads[]: { fileName, downloadUrl, downloadsUsed, downloadLimit, expiresAt }
|
|
626
|
+
\`\`\`
|
|
627
|
+
|
|
607
628
|
### Categories
|
|
608
629
|
|
|
609
630
|
\`\`\`typescript
|
|
@@ -1184,60 +1205,6 @@ ${getAdminApiSection()}
|
|
|
1184
1205
|
|
|
1185
1206
|
Start building with the SDK!`;
|
|
1186
1207
|
}
|
|
1187
|
-
function buildMcpStorePrompt(options) {
|
|
1188
|
-
const { connectionId, storeName, currency, storeType, storeStyle } = options;
|
|
1189
|
-
const styleDesc = storeStyle ? ` with a ${storeStyle} aesthetic` : "";
|
|
1190
|
-
return `# Build a Brainerce E-Commerce Store
|
|
1191
|
-
|
|
1192
|
-
## Task
|
|
1193
|
-
Build a **${storeType}** called **${storeName}**${styleDesc}.
|
|
1194
|
-
Use the Brainerce SDK (\`npm install brainerce\`) to fetch all data \u2014 never hardcode products.
|
|
1195
|
-
|
|
1196
|
-
## Connection
|
|
1197
|
-
- **Connection ID:** \`${connectionId}\`
|
|
1198
|
-
- **Currency:** ${currency}
|
|
1199
|
-
|
|
1200
|
-
${getCriticalRulesSection()}
|
|
1201
|
-
|
|
1202
|
-
${getTypeQuickReference()}
|
|
1203
|
-
|
|
1204
|
-
## Required Pages
|
|
1205
|
-
- \`/\` \u2014 Home (hero, featured products, categories)
|
|
1206
|
-
- \`/products\` \u2014 Product list with infinite scroll
|
|
1207
|
-
- \`/products/[slug]\` \u2014 Product detail (images, variants, add to cart)
|
|
1208
|
-
- \`/cart\` \u2014 Cart with checkboxes, quantities, coupon input
|
|
1209
|
-
- \`/checkout\` \u2014 Customer info \u2192 Shipping \u2192 Payment
|
|
1210
|
-
- \`/order-confirmation\` \u2014 MUST call \`waitForOrder()\` + \`handlePaymentSuccess()\`
|
|
1211
|
-
- \`/login\` \u2014 Email/password + social login
|
|
1212
|
-
- \`/register\` \u2014 Registration + social signup
|
|
1213
|
-
- \`/verify-email\` \u2014 6-digit code verification (always build, even if disabled)
|
|
1214
|
-
- \`/auth/callback\` \u2014 OAuth redirect handler
|
|
1215
|
-
- \`/account\` \u2014 Profile + order history (protected)
|
|
1216
|
-
- Header \u2014 Logo, nav, cart count, search bar with autocomplete
|
|
1217
|
-
|
|
1218
|
-
## How to Build (Use MCP Tools!)
|
|
1219
|
-
Build pages in this order. For each page, call the MCP tools to get the code and docs you need:
|
|
1220
|
-
|
|
1221
|
-
1. **SDK Setup** \u2014 \`get-code-example\` \u2192 \`sdk-setup\`, then \`store-provider\`
|
|
1222
|
-
2. **Layout** \u2014 \`get-code-example\` \u2192 \`layout\`
|
|
1223
|
-
3. **Header** \u2014 \`get-code-example\` \u2192 \`header\`
|
|
1224
|
-
4. **Home** \u2014 \`get-code-example\` \u2192 \`home-page\`
|
|
1225
|
-
5. **Products** \u2014 \`get-code-example\` \u2192 \`product-listing\`, then \`product-detail\`
|
|
1226
|
-
6. **Cart** \u2014 \`get-code-example\` \u2192 \`cart-page\`, then \`coupon-input\`
|
|
1227
|
-
7. **Checkout** \u2014 \`get-code-example\` \u2192 \`checkout-page\` | \`get-sdk-docs\` \u2192 topic: \`checkout\`
|
|
1228
|
-
8. **Payment** \u2014 \`get-code-example\` \u2192 \`stripe-payment\` / \`grow-payment\` / \`paypal-payment\`
|
|
1229
|
-
9. **Order Confirmation** \u2014 \`get-code-example\` \u2192 \`order-confirmation\`
|
|
1230
|
-
10. **Auth** \u2014 \`get-code-example\` \u2192 \`login-page\`, \`register-page\`, \`verify-email\`, \`oauth-callback\`
|
|
1231
|
-
11. **Account** \u2014 \`get-code-example\` \u2192 \`account-page\`
|
|
1232
|
-
|
|
1233
|
-
**Additional tools:**
|
|
1234
|
-
- \`get-sdk-docs\` \u2014 Detailed API docs per topic (setup, products, cart, checkout, payment, auth, inventory, discounts, tax, etc.)
|
|
1235
|
-
- \`get-type-definitions\` \u2014 Full TypeScript type definitions for any SDK type
|
|
1236
|
-
- \`get-required-pages\` \u2014 Complete pages checklist with design guidelines
|
|
1237
|
-
- \`get-store-info\` \u2014 Fetch live store data (name, currency, product count)
|
|
1238
|
-
|
|
1239
|
-
Start with step 1 \u2014 call \`get-code-example\` with useCase \`sdk-setup\` now!`;
|
|
1240
|
-
}
|
|
1241
1208
|
function getSectionByTopic(topic, connectionId, currency) {
|
|
1242
1209
|
const cid = connectionId || "vc_YOUR_CONNECTION_ID";
|
|
1243
1210
|
const cur = currency || "USD";
|
|
@@ -1340,10 +1307,21 @@ interface Product {
|
|
|
1340
1307
|
brands?: Array<{ id: string; name: string }>;
|
|
1341
1308
|
tags?: string[];
|
|
1342
1309
|
metafields?: ProductMetafield[];
|
|
1310
|
+
productAttributeOptions?: Array<{
|
|
1311
|
+
id: string;
|
|
1312
|
+
attributeId: string;
|
|
1313
|
+
attributeOptionId: string;
|
|
1314
|
+
platform: string;
|
|
1315
|
+
attribute: { id: string; name: string; displayType?: 'DEFAULT' | 'COLOR_SWATCH' | 'IMAGE_SWATCH' } | null;
|
|
1316
|
+
attributeOption: { id: string; name: string; value?: string | null; swatchColor?: string | null; swatchColor2?: string | null; swatchImageUrl?: string | null } | null;
|
|
1317
|
+
}>;
|
|
1343
1318
|
createdAt: string;
|
|
1344
1319
|
updatedAt: string;
|
|
1345
1320
|
}
|
|
1346
1321
|
|
|
1322
|
+
// Use getProductSwatches(product) to get grouped swatch data for storefront rendering.
|
|
1323
|
+
// Returns Array<{ attributeName, displayType, options: Array<{ name, swatchColor?, swatchColor2?, swatchImageUrl? }> }>
|
|
1324
|
+
|
|
1347
1325
|
interface ProductImage {
|
|
1348
1326
|
url: string;
|
|
1349
1327
|
position?: number;
|
|
@@ -1391,7 +1369,7 @@ interface ProductQueryParams {
|
|
|
1391
1369
|
page?: number;
|
|
1392
1370
|
limit?: number;
|
|
1393
1371
|
search?: string;
|
|
1394
|
-
status?: 'active' | 'draft'
|
|
1372
|
+
status?: 'active' | 'draft';
|
|
1395
1373
|
categories?: string | string[];
|
|
1396
1374
|
brands?: string | string[];
|
|
1397
1375
|
tags?: string | string[];
|
|
@@ -1951,6 +1929,17 @@ export function setStoredCartId(cartId: string | null): void {
|
|
|
1951
1929
|
export function initClient(): BrainerceClient {
|
|
1952
1930
|
return getClient();
|
|
1953
1931
|
}
|
|
1932
|
+
|
|
1933
|
+
// Server-side client \u2014 calls backend directly (no proxy needed for public data)
|
|
1934
|
+
// Used by Server Components for SSR data fetching (generateMetadata, page rendering)
|
|
1935
|
+
export function getServerClient(): BrainerceClient {
|
|
1936
|
+
const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
|
|
1937
|
+
return new BrainerceClient({
|
|
1938
|
+
connectionId: CONNECTION_ID,
|
|
1939
|
+
baseUrl: apiUrl,
|
|
1940
|
+
origin: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1954
1943
|
`,
|
|
1955
1944
|
"src/lib/auth.ts": `/**
|
|
1956
1945
|
* Client-side auth helpers that call the BFF proxy API routes.
|
|
@@ -2035,6 +2024,7 @@ export async function proxyRegister(data: {
|
|
|
2035
2024
|
lastName: string;
|
|
2036
2025
|
email: string;
|
|
2037
2026
|
password: string;
|
|
2027
|
+
acceptsMarketing?: boolean;
|
|
2038
2028
|
}): Promise<RegisterResult> {
|
|
2039
2029
|
const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/register\`, {
|
|
2040
2030
|
method: 'POST',
|
|
@@ -2390,12 +2380,34 @@ import './globals.css';
|
|
|
2390
2380
|
|
|
2391
2381
|
<%- fontVariable %>
|
|
2392
2382
|
|
|
2383
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
2384
|
+
|
|
2393
2385
|
export const metadata: Metadata = {
|
|
2394
|
-
|
|
2386
|
+
metadataBase: new URL(baseUrl),
|
|
2387
|
+
title: {
|
|
2388
|
+
default: 'My Store',
|
|
2389
|
+
template: \`%s | My Store\`,
|
|
2390
|
+
},
|
|
2395
2391
|
description: 'My Store',
|
|
2392
|
+
alternates: {
|
|
2393
|
+
canonical: '/',
|
|
2394
|
+
},
|
|
2396
2395
|
openGraph: {
|
|
2396
|
+
siteName: 'My Store',
|
|
2397
2397
|
locale: '<%= ogLocale %>',
|
|
2398
|
+
type: 'website',
|
|
2398
2399
|
},
|
|
2400
|
+
robots: {
|
|
2401
|
+
index: true,
|
|
2402
|
+
follow: true,
|
|
2403
|
+
},
|
|
2404
|
+
};
|
|
2405
|
+
|
|
2406
|
+
const organizationJsonLd = {
|
|
2407
|
+
'@context': 'https://schema.org',
|
|
2408
|
+
'@type': 'Organization',
|
|
2409
|
+
name: 'My Store',
|
|
2410
|
+
url: baseUrl,
|
|
2399
2411
|
};
|
|
2400
2412
|
|
|
2401
2413
|
export default function RootLayout({
|
|
@@ -2405,6 +2417,12 @@ export default function RootLayout({
|
|
|
2405
2417
|
}) {
|
|
2406
2418
|
return (
|
|
2407
2419
|
<html lang="<%= language %>" dir="<%= direction %>">
|
|
2420
|
+
<head>
|
|
2421
|
+
<script
|
|
2422
|
+
type="application/ld+json"
|
|
2423
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
|
|
2424
|
+
/>
|
|
2425
|
+
</head>
|
|
2408
2426
|
<body className={font.className}>
|
|
2409
2427
|
<StoreProvider>
|
|
2410
2428
|
<div className="min-h-screen flex flex-col">
|
|
@@ -2449,6 +2467,7 @@ const sortOptions: SortOption[] = [
|
|
|
2449
2467
|
interface CategoryNode {
|
|
2450
2468
|
id: string;
|
|
2451
2469
|
name: string;
|
|
2470
|
+
image?: string | null;
|
|
2452
2471
|
parentId?: string | null;
|
|
2453
2472
|
children: CategoryNode[];
|
|
2454
2473
|
}
|
|
@@ -2489,24 +2508,24 @@ function ChevronDown({ className }: { className?: string }) {
|
|
|
2489
2508
|
|
|
2490
2509
|
/** Recursive dropdown items for nested categories */
|
|
2491
2510
|
function CategoryDropdownItems({
|
|
2492
|
-
|
|
2511
|
+
items,
|
|
2493
2512
|
depth,
|
|
2494
2513
|
selectedId,
|
|
2495
2514
|
onSelect,
|
|
2496
2515
|
}: {
|
|
2497
|
-
|
|
2516
|
+
items: CategoryNode[];
|
|
2498
2517
|
depth: number;
|
|
2499
2518
|
selectedId: string;
|
|
2500
2519
|
onSelect: (id: string) => void;
|
|
2501
2520
|
}) {
|
|
2502
2521
|
return (
|
|
2503
2522
|
<>
|
|
2504
|
-
{
|
|
2523
|
+
{items.map((child) => (
|
|
2505
2524
|
<div key={child.id}>
|
|
2506
2525
|
<button
|
|
2507
2526
|
onClick={() => onSelect(child.id)}
|
|
2508
2527
|
className={cn(
|
|
2509
|
-
'w-full
|
|
2528
|
+
'hover:bg-muted w-full px-4 py-2 text-start text-sm transition-colors',
|
|
2510
2529
|
selectedId === child.id && 'bg-primary/10 text-primary font-medium'
|
|
2511
2530
|
)}
|
|
2512
2531
|
style={{ paddingInlineStart: \`\${(depth + 1) * 16}px\` }}
|
|
@@ -2515,7 +2534,7 @@ function CategoryDropdownItems({
|
|
|
2515
2534
|
</button>
|
|
2516
2535
|
{child.children.length > 0 && (
|
|
2517
2536
|
<CategoryDropdownItems
|
|
2518
|
-
|
|
2537
|
+
items={child.children}
|
|
2519
2538
|
depth={depth + 1}
|
|
2520
2539
|
selectedId={selectedId}
|
|
2521
2540
|
onSelect={onSelect}
|
|
@@ -2602,9 +2621,7 @@ function CategoryChip({
|
|
|
2602
2621
|
{'\xB7'} {selectedChildName}
|
|
2603
2622
|
</span>
|
|
2604
2623
|
)}
|
|
2605
|
-
<ChevronDown
|
|
2606
|
-
className={cn('transition-transform ms-0.5', open && 'rotate-180')}
|
|
2607
|
-
/>
|
|
2624
|
+
<ChevronDown className={cn('ms-0.5 transition-transform', open && 'rotate-180')} />
|
|
2608
2625
|
</button>
|
|
2609
2626
|
|
|
2610
2627
|
{open && (
|
|
@@ -2616,7 +2633,7 @@ function CategoryChip({
|
|
|
2616
2633
|
setOpen(false);
|
|
2617
2634
|
}}
|
|
2618
2635
|
className={cn(
|
|
2619
|
-
'w-full
|
|
2636
|
+
'hover:bg-muted w-full px-4 py-2 text-start text-sm font-medium transition-colors',
|
|
2620
2637
|
selectedId === category.id && 'bg-primary/10 text-primary'
|
|
2621
2638
|
)}
|
|
2622
2639
|
>
|
|
@@ -2624,11 +2641,9 @@ function CategoryChip({
|
|
|
2624
2641
|
</button>
|
|
2625
2642
|
<div className="bg-border mx-2 h-px" />
|
|
2626
2643
|
{/* Recursive children */}
|
|
2627
|
-
<div
|
|
2628
|
-
onClick={() => setOpen(false)}
|
|
2629
|
-
>
|
|
2644
|
+
<div onClick={() => setOpen(false)}>
|
|
2630
2645
|
<CategoryDropdownItems
|
|
2631
|
-
|
|
2646
|
+
items={category.children}
|
|
2632
2647
|
depth={0}
|
|
2633
2648
|
selectedId={selectedId}
|
|
2634
2649
|
onSelect={onSelect}
|
|
@@ -2853,513 +2868,71 @@ export default function ProductsPage() {
|
|
|
2853
2868
|
);
|
|
2854
2869
|
}
|
|
2855
2870
|
`,
|
|
2856
|
-
"src/app/products/[slug]/page.tsx": `
|
|
2871
|
+
"src/app/products/[slug]/page.tsx": `import type { Metadata } from 'next';
|
|
2872
|
+
import { notFound } from 'next/navigation';
|
|
2873
|
+
import { getServerClient } from '@/lib/brainerce';
|
|
2874
|
+
import { ProductJsonLd } from '@/components/seo/product-json-ld';
|
|
2875
|
+
import { ProductClientSection } from './product-client-section';
|
|
2876
|
+
|
|
2877
|
+
type Props = {
|
|
2878
|
+
params: Promise<{ slug: string }>;
|
|
2879
|
+
};
|
|
2857
2880
|
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
import Image from 'next/image';
|
|
2861
|
-
import Link from 'next/link';
|
|
2862
|
-
import type {
|
|
2863
|
-
Product,
|
|
2864
|
-
ProductVariant,
|
|
2865
|
-
ProductImage,
|
|
2866
|
-
ProductMetafield,
|
|
2867
|
-
ProductRecommendationsResponse,
|
|
2868
|
-
DownloadFile,
|
|
2869
|
-
} from 'brainerce';
|
|
2881
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
2882
|
+
const { slug } = await params;
|
|
2870
2883
|
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
import { useCart } from '@/providers/store-provider';
|
|
2877
|
-
import { PriceDisplay } from '@/components/shared/price-display';
|
|
2878
|
-
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
2879
|
-
import { VariantSelector } from '@/components/products/variant-selector';
|
|
2880
|
-
import { StockBadge } from '@/components/products/stock-badge';
|
|
2881
|
-
import { RecommendationSection } from '@/components/products/recommendation-section';
|
|
2882
|
-
import { useTranslations } from '@/lib/translations';
|
|
2883
|
-
import { cn } from '@/lib/utils';
|
|
2884
|
+
try {
|
|
2885
|
+
const client = getServerClient();
|
|
2886
|
+
const product = await client.getProductBySlug(slug);
|
|
2887
|
+
const imageUrl = product.images?.[0]?.url;
|
|
2888
|
+
const description = product.description?.substring(0, 160) || product.name;
|
|
2884
2889
|
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
}
|
|
2909
|
-
if (urls.length === 0) return <span className="text-muted-foreground">-</span>;
|
|
2910
|
-
return (
|
|
2911
|
-
<div className="flex flex-wrap gap-2">
|
|
2912
|
-
{urls.map((url, i) => (
|
|
2913
|
-
<img
|
|
2914
|
-
key={i}
|
|
2915
|
-
src={url}
|
|
2916
|
-
alt={\`\${field.definitionName} \${i + 1}\`}
|
|
2917
|
-
className="h-16 w-16 rounded object-cover"
|
|
2918
|
-
/>
|
|
2919
|
-
))}
|
|
2920
|
-
</div>
|
|
2921
|
-
);
|
|
2922
|
-
}
|
|
2923
|
-
case 'URL':
|
|
2924
|
-
return field.value ? (
|
|
2925
|
-
<a
|
|
2926
|
-
href={field.value}
|
|
2927
|
-
target="_blank"
|
|
2928
|
-
rel="noopener noreferrer"
|
|
2929
|
-
className="text-primary break-all hover:underline"
|
|
2930
|
-
>
|
|
2931
|
-
{field.value}
|
|
2932
|
-
</a>
|
|
2933
|
-
) : (
|
|
2934
|
-
<span className="text-muted-foreground">-</span>
|
|
2935
|
-
);
|
|
2936
|
-
case 'COLOR':
|
|
2937
|
-
return field.value ? (
|
|
2938
|
-
<span className="inline-flex items-center gap-2">
|
|
2939
|
-
<span
|
|
2940
|
-
className="border-border inline-block h-4 w-4 rounded-full border"
|
|
2941
|
-
style={{ backgroundColor: field.value }}
|
|
2942
|
-
/>
|
|
2943
|
-
{field.value}
|
|
2944
|
-
</span>
|
|
2945
|
-
) : (
|
|
2946
|
-
<span className="text-muted-foreground">-</span>
|
|
2947
|
-
);
|
|
2948
|
-
case 'BOOLEAN':
|
|
2949
|
-
return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
|
|
2950
|
-
case 'DATE':
|
|
2951
|
-
case 'DATETIME': {
|
|
2952
|
-
if (!field.value) return <span className="text-muted-foreground">-</span>;
|
|
2953
|
-
try {
|
|
2954
|
-
const date = new Date(field.value);
|
|
2955
|
-
return (
|
|
2956
|
-
<span>
|
|
2957
|
-
{field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
|
|
2958
|
-
</span>
|
|
2959
|
-
);
|
|
2960
|
-
} catch {
|
|
2961
|
-
return <span>{field.value}</span>;
|
|
2962
|
-
}
|
|
2963
|
-
}
|
|
2964
|
-
default:
|
|
2965
|
-
return <span>{field.value || '-'}</span>;
|
|
2890
|
+
return {
|
|
2891
|
+
title: product.name,
|
|
2892
|
+
description,
|
|
2893
|
+
alternates: {
|
|
2894
|
+
canonical: \`/products/\${slug}\`,
|
|
2895
|
+
},
|
|
2896
|
+
openGraph: {
|
|
2897
|
+
title: product.name,
|
|
2898
|
+
description,
|
|
2899
|
+
images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
|
|
2900
|
+
type: 'website',
|
|
2901
|
+
},
|
|
2902
|
+
twitter: {
|
|
2903
|
+
card: 'summary_large_image',
|
|
2904
|
+
title: product.name,
|
|
2905
|
+
description,
|
|
2906
|
+
images: imageUrl ? [imageUrl] : [],
|
|
2907
|
+
},
|
|
2908
|
+
};
|
|
2909
|
+
} catch {
|
|
2910
|
+
return {
|
|
2911
|
+
title: 'Product not found',
|
|
2912
|
+
};
|
|
2966
2913
|
}
|
|
2967
2914
|
}
|
|
2968
2915
|
|
|
2969
|
-
export default function ProductDetailPage() {
|
|
2970
|
-
const
|
|
2971
|
-
const slug = params.slug as string;
|
|
2972
|
-
const { refreshCart } = useCart();
|
|
2973
|
-
const t = useTranslations('productDetail');
|
|
2974
|
-
const tc = useTranslations('common');
|
|
2975
|
-
const [product, setProduct] = useState<ProductWithRecommendations | null>(null);
|
|
2976
|
-
const [loading, setLoading] = useState(true);
|
|
2977
|
-
const [error, setError] = useState<string | null>(null);
|
|
2978
|
-
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
|
2979
|
-
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
|
2980
|
-
const [quantity, setQuantity] = useState(1);
|
|
2981
|
-
const [addingToCart, setAddingToCart] = useState(false);
|
|
2982
|
-
const [addedMessage, setAddedMessage] = useState(false);
|
|
2983
|
-
|
|
2984
|
-
const recommendations = product?.recommendations ?? null;
|
|
2985
|
-
|
|
2986
|
-
// Load product
|
|
2987
|
-
useEffect(() => {
|
|
2988
|
-
async function load() {
|
|
2989
|
-
try {
|
|
2990
|
-
setLoading(true);
|
|
2991
|
-
setError(null);
|
|
2992
|
-
const client = getClient();
|
|
2993
|
-
const p = await client.getProductBySlug(slug);
|
|
2994
|
-
setProduct(p as ProductWithRecommendations);
|
|
2995
|
-
|
|
2996
|
-
// Auto-select first variant
|
|
2997
|
-
if (p.variants && p.variants.length > 0) {
|
|
2998
|
-
setSelectedVariant(p.variants[0]);
|
|
2999
|
-
}
|
|
3000
|
-
} catch {
|
|
3001
|
-
setError(t('notFound'));
|
|
3002
|
-
} finally {
|
|
3003
|
-
setLoading(false);
|
|
3004
|
-
}
|
|
3005
|
-
}
|
|
3006
|
-
load();
|
|
3007
|
-
}, [slug]);
|
|
3008
|
-
|
|
3009
|
-
// Images list - switch main image when variant changes
|
|
3010
|
-
const images: ProductImage[] = useMemo(() => {
|
|
3011
|
-
return product?.images || [];
|
|
3012
|
-
}, [product]);
|
|
3013
|
-
|
|
3014
|
-
// When variant changes, update selected image to variant image if available
|
|
3015
|
-
useEffect(() => {
|
|
3016
|
-
if (!selectedVariant?.image || !product) return;
|
|
3017
|
-
|
|
3018
|
-
const variantImgUrl =
|
|
3019
|
-
typeof selectedVariant.image === 'string' ? selectedVariant.image : selectedVariant.image.url;
|
|
3020
|
-
|
|
3021
|
-
// Find if variant image exists in product images
|
|
3022
|
-
const idx = images.findIndex((img) => img.url === variantImgUrl);
|
|
3023
|
-
if (idx >= 0) {
|
|
3024
|
-
setSelectedImageIndex(idx);
|
|
3025
|
-
} else {
|
|
3026
|
-
// Variant image not in product images - select index 0 as fallback
|
|
3027
|
-
// (The variant image will be shown as the main image via override)
|
|
3028
|
-
setSelectedImageIndex(-1);
|
|
3029
|
-
}
|
|
3030
|
-
}, [selectedVariant, images, product]);
|
|
3031
|
-
|
|
3032
|
-
// Determine which image to show
|
|
3033
|
-
const mainImageUrl = useMemo(() => {
|
|
3034
|
-
if (selectedImageIndex === -1 && selectedVariant?.image) {
|
|
3035
|
-
const img = selectedVariant.image;
|
|
3036
|
-
return typeof img === 'string' ? img : img.url;
|
|
3037
|
-
}
|
|
3038
|
-
return images[selectedImageIndex]?.url || null;
|
|
3039
|
-
}, [selectedImageIndex, selectedVariant, images]);
|
|
3040
|
-
|
|
3041
|
-
// Price info - use variant price if selected, else product price
|
|
3042
|
-
const priceInfo = useMemo(() => {
|
|
3043
|
-
if (selectedVariant?.price) {
|
|
3044
|
-
return {
|
|
3045
|
-
price: parseFloat(selectedVariant.salePrice || selectedVariant.price),
|
|
3046
|
-
originalPrice: parseFloat(selectedVariant.price),
|
|
3047
|
-
isOnSale:
|
|
3048
|
-
selectedVariant.salePrice != null &&
|
|
3049
|
-
parseFloat(selectedVariant.salePrice) < parseFloat(selectedVariant.price),
|
|
3050
|
-
discountPercent:
|
|
3051
|
-
selectedVariant.salePrice != null &&
|
|
3052
|
-
parseFloat(selectedVariant.salePrice) < parseFloat(selectedVariant.price)
|
|
3053
|
-
? Math.round(
|
|
3054
|
-
((parseFloat(selectedVariant.price) - parseFloat(selectedVariant.salePrice)) /
|
|
3055
|
-
parseFloat(selectedVariant.price)) *
|
|
3056
|
-
100
|
|
3057
|
-
)
|
|
3058
|
-
: 0,
|
|
3059
|
-
};
|
|
3060
|
-
}
|
|
3061
|
-
return getProductPriceInfo(product);
|
|
3062
|
-
}, [product, selectedVariant]);
|
|
3063
|
-
|
|
3064
|
-
// Inventory: use variant inventory if selected, else product inventory
|
|
3065
|
-
const inventory = selectedVariant?.inventory ?? product?.inventory ?? null;
|
|
3066
|
-
const canPurchase = inventory?.canPurchase !== false;
|
|
3067
|
-
|
|
3068
|
-
// Description
|
|
3069
|
-
const description = useMemo(() => {
|
|
3070
|
-
return product ? getDescriptionContent(product) : null;
|
|
3071
|
-
}, [product]);
|
|
3072
|
-
|
|
3073
|
-
async function handleAddToCart() {
|
|
3074
|
-
if (!product || addingToCart) return;
|
|
3075
|
-
|
|
3076
|
-
try {
|
|
3077
|
-
setAddingToCart(true);
|
|
3078
|
-
const client = getClient();
|
|
3079
|
-
await client.smartAddToCart({
|
|
3080
|
-
productId: product.id,
|
|
3081
|
-
variantId: selectedVariant?.id,
|
|
3082
|
-
quantity,
|
|
3083
|
-
name: product.name,
|
|
3084
|
-
price: String(priceInfo.price),
|
|
3085
|
-
image: mainImageUrl || undefined,
|
|
3086
|
-
});
|
|
3087
|
-
await refreshCart();
|
|
3088
|
-
setAddedMessage(true);
|
|
3089
|
-
setTimeout(() => setAddedMessage(false), 2000);
|
|
3090
|
-
} catch (err) {
|
|
3091
|
-
console.error('Failed to add to cart:', err);
|
|
3092
|
-
} finally {
|
|
3093
|
-
setAddingToCart(false);
|
|
3094
|
-
}
|
|
3095
|
-
}
|
|
2916
|
+
export default async function ProductDetailPage({ params }: Props) {
|
|
2917
|
+
const { slug } = await params;
|
|
3096
2918
|
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
);
|
|
2919
|
+
let product;
|
|
2920
|
+
try {
|
|
2921
|
+
const client = getServerClient();
|
|
2922
|
+
product = await client.getProductBySlug(slug);
|
|
2923
|
+
} catch {
|
|
2924
|
+
notFound();
|
|
3103
2925
|
}
|
|
3104
2926
|
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
<h1 className="text-foreground text-2xl font-bold">{error || t('notFound')}</h1>
|
|
3109
|
-
<Link href="/products" className="text-primary mt-4 inline-block hover:underline">
|
|
3110
|
-
{t('backToProducts')}
|
|
3111
|
-
</Link>
|
|
3112
|
-
</div>
|
|
3113
|
-
);
|
|
3114
|
-
}
|
|
2927
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
2928
|
+
const productUrl = \`\${baseUrl}/products/\${slug}\`;
|
|
2929
|
+
const currency = process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
|
|
3115
2930
|
|
|
3116
2931
|
return (
|
|
3117
|
-
|
|
3118
|
-
<
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
{/* Main Image */}
|
|
3122
|
-
<div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
|
|
3123
|
-
{mainImageUrl ? (
|
|
3124
|
-
<Image
|
|
3125
|
-
src={mainImageUrl}
|
|
3126
|
-
alt={product.name}
|
|
3127
|
-
fill
|
|
3128
|
-
sizes="(max-width: 1024px) 100vw, 50vw"
|
|
3129
|
-
className="object-contain"
|
|
3130
|
-
priority
|
|
3131
|
-
/>
|
|
3132
|
-
) : (
|
|
3133
|
-
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
3134
|
-
<svg className="h-24 w-24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3135
|
-
<path
|
|
3136
|
-
strokeLinecap="round"
|
|
3137
|
-
strokeLinejoin="round"
|
|
3138
|
-
strokeWidth={1}
|
|
3139
|
-
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"
|
|
3140
|
-
/>
|
|
3141
|
-
</svg>
|
|
3142
|
-
</div>
|
|
3143
|
-
)}
|
|
3144
|
-
</div>
|
|
3145
|
-
|
|
3146
|
-
{/* Thumbnails */}
|
|
3147
|
-
{images.length > 1 && (
|
|
3148
|
-
<div className="flex gap-2 overflow-x-auto pb-2">
|
|
3149
|
-
{images.map((img, idx) => (
|
|
3150
|
-
<button
|
|
3151
|
-
key={idx}
|
|
3152
|
-
type="button"
|
|
3153
|
-
onClick={() => setSelectedImageIndex(idx)}
|
|
3154
|
-
className={cn(
|
|
3155
|
-
'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded border-2 transition-colors',
|
|
3156
|
-
selectedImageIndex === idx
|
|
3157
|
-
? 'border-primary'
|
|
3158
|
-
: 'border-border hover:border-muted-foreground'
|
|
3159
|
-
)}
|
|
3160
|
-
>
|
|
3161
|
-
<Image
|
|
3162
|
-
src={img.url}
|
|
3163
|
-
alt={img.alt || \`\${product.name} \${idx + 1}\`}
|
|
3164
|
-
fill
|
|
3165
|
-
sizes="64px"
|
|
3166
|
-
className="object-cover"
|
|
3167
|
-
/>
|
|
3168
|
-
</button>
|
|
3169
|
-
))}
|
|
3170
|
-
</div>
|
|
3171
|
-
)}
|
|
3172
|
-
</div>
|
|
3173
|
-
|
|
3174
|
-
{/* Product Info */}
|
|
3175
|
-
<div className="space-y-6">
|
|
3176
|
-
{/* Categories */}
|
|
3177
|
-
{product.categories && product.categories.length > 0 && (
|
|
3178
|
-
<div className="flex flex-wrap gap-2">
|
|
3179
|
-
{product.categories.map((cat) => (
|
|
3180
|
-
<span
|
|
3181
|
-
key={cat.id}
|
|
3182
|
-
className="text-muted-foreground bg-muted rounded px-2 py-1 text-xs"
|
|
3183
|
-
>
|
|
3184
|
-
{cat.name}
|
|
3185
|
-
</span>
|
|
3186
|
-
))}
|
|
3187
|
-
</div>
|
|
3188
|
-
)}
|
|
3189
|
-
|
|
3190
|
-
{/* Title */}
|
|
3191
|
-
<h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
|
|
3192
|
-
|
|
3193
|
-
{/* Price */}
|
|
3194
|
-
<PriceDisplay
|
|
3195
|
-
price={priceInfo.originalPrice}
|
|
3196
|
-
salePrice={priceInfo.isOnSale ? priceInfo.price : undefined}
|
|
3197
|
-
size="lg"
|
|
3198
|
-
/>
|
|
3199
|
-
|
|
3200
|
-
{/* Stock / Digital badge */}
|
|
3201
|
-
{product.isDownloadable ? (
|
|
3202
|
-
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-800 dark:bg-green-950/30 dark:text-green-400">
|
|
3203
|
-
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3204
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
3205
|
-
</svg>
|
|
3206
|
-
{t('instantDownload')}
|
|
3207
|
-
</span>
|
|
3208
|
-
) : (
|
|
3209
|
-
<StockBadge inventory={inventory} lowStockThreshold={5} />
|
|
3210
|
-
)}
|
|
3211
|
-
|
|
3212
|
-
{/* Downloadable files info */}
|
|
3213
|
-
{product.isDownloadable && product.downloads && product.downloads.length > 0 && (
|
|
3214
|
-
<div className="bg-muted/50 rounded-lg border p-4">
|
|
3215
|
-
<p className="text-foreground mb-2 text-sm font-medium">
|
|
3216
|
-
{t('filesIncluded', { count: product.downloads.length })}
|
|
3217
|
-
</p>
|
|
3218
|
-
<ul className="space-y-1.5">
|
|
3219
|
-
{product.downloads.map((file: DownloadFile) => (
|
|
3220
|
-
<li key={file.id} className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
3221
|
-
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3222
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
|
3223
|
-
</svg>
|
|
3224
|
-
<span className="truncate">{file.name}</span>
|
|
3225
|
-
{file.size && (
|
|
3226
|
-
<span className="flex-shrink-0 text-xs">
|
|
3227
|
-
({file.size < 1024 * 1024
|
|
3228
|
-
? \`\${(file.size / 1024).toFixed(0)} KB\`
|
|
3229
|
-
: \`\${(file.size / (1024 * 1024)).toFixed(1)} MB\`})
|
|
3230
|
-
</span>
|
|
3231
|
-
)}
|
|
3232
|
-
</li>
|
|
3233
|
-
))}
|
|
3234
|
-
</ul>
|
|
3235
|
-
</div>
|
|
3236
|
-
)}
|
|
3237
|
-
|
|
3238
|
-
{/* Variant Selector */}
|
|
3239
|
-
{product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
|
|
3240
|
-
<VariantSelector
|
|
3241
|
-
product={product}
|
|
3242
|
-
selectedVariant={selectedVariant}
|
|
3243
|
-
onVariantChange={setSelectedVariant}
|
|
3244
|
-
/>
|
|
3245
|
-
)}
|
|
3246
|
-
|
|
3247
|
-
{/* Quantity + Add to Cart */}
|
|
3248
|
-
<div className="flex items-center gap-4">
|
|
3249
|
-
<div className="border-border flex items-center rounded border">
|
|
3250
|
-
<button
|
|
3251
|
-
type="button"
|
|
3252
|
-
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
|
3253
|
-
className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
|
|
3254
|
-
aria-label={t('decreaseQuantity')}
|
|
3255
|
-
>
|
|
3256
|
-
-
|
|
3257
|
-
</button>
|
|
3258
|
-
<span className="text-foreground min-w-[3rem] px-4 py-2 text-center text-sm font-medium">
|
|
3259
|
-
{quantity}
|
|
3260
|
-
</span>
|
|
3261
|
-
<button
|
|
3262
|
-
type="button"
|
|
3263
|
-
onClick={() => setQuantity((q) => q + 1)}
|
|
3264
|
-
className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
|
|
3265
|
-
aria-label={t('increaseQuantity')}
|
|
3266
|
-
>
|
|
3267
|
-
+
|
|
3268
|
-
</button>
|
|
3269
|
-
</div>
|
|
3270
|
-
|
|
3271
|
-
<button
|
|
3272
|
-
type="button"
|
|
3273
|
-
onClick={handleAddToCart}
|
|
3274
|
-
disabled={!canPurchase || addingToCart}
|
|
3275
|
-
className={cn(
|
|
3276
|
-
'flex-1 rounded px-6 py-3 text-sm font-medium transition-all',
|
|
3277
|
-
canPurchase
|
|
3278
|
-
? 'bg-primary text-primary-foreground hover:opacity-90'
|
|
3279
|
-
: 'bg-muted text-muted-foreground cursor-not-allowed'
|
|
3280
|
-
)}
|
|
3281
|
-
>
|
|
3282
|
-
{addingToCart ? (
|
|
3283
|
-
<span className="inline-flex items-center gap-2">
|
|
3284
|
-
<LoadingSpinner
|
|
3285
|
-
size="sm"
|
|
3286
|
-
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
3287
|
-
/>
|
|
3288
|
-
{t('addingToCart')}
|
|
3289
|
-
</span>
|
|
3290
|
-
) : addedMessage ? (
|
|
3291
|
-
t('addedToCart')
|
|
3292
|
-
) : !canPurchase ? (
|
|
3293
|
-
t('outOfStock')
|
|
3294
|
-
) : (
|
|
3295
|
-
t('addToCart')
|
|
3296
|
-
)}
|
|
3297
|
-
</button>
|
|
3298
|
-
</div>
|
|
3299
|
-
|
|
3300
|
-
{/* Download after purchase note */}
|
|
3301
|
-
{product.isDownloadable && (
|
|
3302
|
-
<p className="text-muted-foreground text-sm">
|
|
3303
|
-
{t('downloadAfterPurchase')}
|
|
3304
|
-
</p>
|
|
3305
|
-
)}
|
|
3306
|
-
|
|
3307
|
-
{/* Description */}
|
|
3308
|
-
{description && (
|
|
3309
|
-
<div className="border-border border-t pt-4">
|
|
3310
|
-
<h2 className="text-foreground mb-3 text-lg font-semibold">{t('description')}</h2>
|
|
3311
|
-
{'html' in description ? (
|
|
3312
|
-
<div
|
|
3313
|
-
className="prose prose-sm text-muted-foreground max-w-none"
|
|
3314
|
-
dangerouslySetInnerHTML={{ __html: description.html }}
|
|
3315
|
-
/>
|
|
3316
|
-
) : (
|
|
3317
|
-
<p className="text-muted-foreground whitespace-pre-wrap">{description.text}</p>
|
|
3318
|
-
)}
|
|
3319
|
-
</div>
|
|
3320
|
-
)}
|
|
3321
|
-
|
|
3322
|
-
{/* Metafields / Specifications */}
|
|
3323
|
-
{product.metafields && product.metafields.length > 0 && (
|
|
3324
|
-
<div className="border-border border-t pt-4">
|
|
3325
|
-
<h2 className="text-foreground mb-3 text-lg font-semibold">{t('specifications')}</h2>
|
|
3326
|
-
<table className="w-full text-sm">
|
|
3327
|
-
<tbody>
|
|
3328
|
-
{product.metafields.map((field) => (
|
|
3329
|
-
<tr key={field.id} className="border-border border-b last:border-0">
|
|
3330
|
-
<td className="text-foreground whitespace-nowrap py-2 pe-4 font-medium">
|
|
3331
|
-
{field.definitionName}
|
|
3332
|
-
</td>
|
|
3333
|
-
<td className="text-muted-foreground py-2">
|
|
3334
|
-
<MetafieldValue field={field} />
|
|
3335
|
-
</td>
|
|
3336
|
-
</tr>
|
|
3337
|
-
))}
|
|
3338
|
-
</tbody>
|
|
3339
|
-
</table>
|
|
3340
|
-
</div>
|
|
3341
|
-
)}
|
|
3342
|
-
</div>
|
|
3343
|
-
</div>
|
|
3344
|
-
|
|
3345
|
-
{/* Upsells \u2014 premium alternatives (product page) */}
|
|
3346
|
-
{recommendations?.upsells && recommendations.upsells.length > 0 && (
|
|
3347
|
-
<RecommendationSection
|
|
3348
|
-
title={t('upgradeYourChoice')}
|
|
3349
|
-
items={recommendations.upsells}
|
|
3350
|
-
className="mt-12"
|
|
3351
|
-
/>
|
|
3352
|
-
)}
|
|
3353
|
-
|
|
3354
|
-
{/* Related products \u2014 similar items (bottom of product page) */}
|
|
3355
|
-
{recommendations?.related && recommendations.related.length > 0 && (
|
|
3356
|
-
<RecommendationSection
|
|
3357
|
-
title={t('similarProducts')}
|
|
3358
|
-
items={recommendations.related}
|
|
3359
|
-
className="mt-12"
|
|
3360
|
-
/>
|
|
3361
|
-
)}
|
|
3362
|
-
</div>
|
|
2932
|
+
<>
|
|
2933
|
+
<ProductJsonLd product={product} url={productUrl} currency={currency} />
|
|
2934
|
+
<ProductClientSection product={product} />
|
|
2935
|
+
</>
|
|
3363
2936
|
);
|
|
3364
2937
|
}
|
|
3365
2938
|
`,
|
|
@@ -3392,7 +2965,10 @@ export default function CartPage() {
|
|
|
3392
2965
|
return;
|
|
3393
2966
|
}
|
|
3394
2967
|
const client = getClient();
|
|
3395
|
-
client
|
|
2968
|
+
client
|
|
2969
|
+
.getCartRecommendations(cart.id, 4)
|
|
2970
|
+
.then(setCartRecs)
|
|
2971
|
+
.catch(() => {});
|
|
3396
2972
|
}, [cart?.id, cart?.items.length]);
|
|
3397
2973
|
|
|
3398
2974
|
if (cartLoading) {
|
|
@@ -3500,7 +3076,7 @@ export default function CartPage() {
|
|
|
3500
3076
|
`,
|
|
3501
3077
|
"src/app/checkout/page.tsx": `'use client';
|
|
3502
3078
|
|
|
3503
|
-
import { Suspense, useEffect, useState, useCallback } from 'react';
|
|
3079
|
+
import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
|
|
3504
3080
|
import { useSearchParams } from 'next/navigation';
|
|
3505
3081
|
import Image from 'next/image';
|
|
3506
3082
|
import Link from 'next/link';
|
|
@@ -3513,7 +3089,7 @@ import type {
|
|
|
3513
3089
|
} from 'brainerce';
|
|
3514
3090
|
import { formatPrice } from 'brainerce';
|
|
3515
3091
|
import { getClient } from '@/lib/brainerce';
|
|
3516
|
-
import { useStoreInfo,
|
|
3092
|
+
import { useStoreInfo, useCart, useAuth } from '@/providers/store-provider';
|
|
3517
3093
|
import { CheckoutForm } from '@/components/checkout/checkout-form';
|
|
3518
3094
|
import { ShippingStep } from '@/components/checkout/shipping-step';
|
|
3519
3095
|
import { PaymentStep } from '@/components/checkout/payment-step';
|
|
@@ -3531,8 +3107,8 @@ type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'payment';
|
|
|
3531
3107
|
function CheckoutContent() {
|
|
3532
3108
|
const searchParams = useSearchParams();
|
|
3533
3109
|
const { storeInfo } = useStoreInfo();
|
|
3534
|
-
const { isLoggedIn } = useAuth();
|
|
3535
3110
|
const { cart, refreshCart } = useCart();
|
|
3111
|
+
const { isLoggedIn } = useAuth();
|
|
3536
3112
|
const currency = storeInfo?.currency || 'USD';
|
|
3537
3113
|
const t = useTranslations('checkout');
|
|
3538
3114
|
const tc = useTranslations('common');
|
|
@@ -3547,90 +3123,125 @@ function CheckoutContent() {
|
|
|
3547
3123
|
const [destinations, setDestinations] = useState<ShippingDestinations | null>(null);
|
|
3548
3124
|
const [pickupLocations, setPickupLocations] = useState<PickupLocation[]>([]);
|
|
3549
3125
|
const [deliveryType, setDeliveryType] = useState<'shipping' | 'pickup'>('shipping');
|
|
3126
|
+
const [isAllDigital, setIsAllDigital] = useState(false);
|
|
3127
|
+
const [prefillAddress, setPrefillAddress] = useState<SetShippingAddressDto | null>(null);
|
|
3128
|
+
const [prefillCustomer, setPrefillCustomer] = useState<{
|
|
3129
|
+
email: string;
|
|
3130
|
+
firstName?: string;
|
|
3131
|
+
lastName?: string;
|
|
3132
|
+
phone?: string;
|
|
3133
|
+
} | null>(null);
|
|
3134
|
+
const [hasSavedAddress, setHasSavedAddress] = useState(false);
|
|
3550
3135
|
|
|
3551
3136
|
// Check for returning from canceled payment
|
|
3552
3137
|
const canceled = searchParams.get('canceled') === 'true';
|
|
3553
3138
|
const existingCheckoutId = searchParams.get('checkout_id');
|
|
3554
3139
|
|
|
3555
|
-
//
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
.then(setDestinations)
|
|
3566
|
-
.catch(() => {});
|
|
3567
|
-
|
|
3568
|
-
const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]);
|
|
3569
|
-
setPickupLocations(locations);
|
|
3570
|
-
|
|
3571
|
-
// If returning with existing checkout ID, resume it
|
|
3572
|
-
if (existingCheckoutId) {
|
|
3573
|
-
const existing = await client.getCheckout(existingCheckoutId);
|
|
3574
|
-
setCheckout(existing);
|
|
3575
|
-
|
|
3576
|
-
// Determine step based on checkout state
|
|
3577
|
-
if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
|
|
3578
|
-
setDeliveryType('pickup');
|
|
3579
|
-
setStep('payment');
|
|
3580
|
-
} else if (existing.shippingAddress && existing.shippingRateId) {
|
|
3581
|
-
setStep('payment');
|
|
3582
|
-
} else if (existing.shippingAddress) {
|
|
3583
|
-
// Fetch shipping rates
|
|
3584
|
-
const rates = await client.getShippingRates(existing.id);
|
|
3585
|
-
setShippingRates(rates);
|
|
3586
|
-
setStep('shipping');
|
|
3587
|
-
} else if (locations.length > 0) {
|
|
3588
|
-
setStep('method');
|
|
3140
|
+
// Pre-fill address and customer data from profile when logged in
|
|
3141
|
+
useEffect(() => {
|
|
3142
|
+
if (!isLoggedIn) return;
|
|
3143
|
+
getClient()
|
|
3144
|
+
.getCheckoutPrefillData()
|
|
3145
|
+
.then((data) => {
|
|
3146
|
+
if (data.customer) setPrefillCustomer(data.customer);
|
|
3147
|
+
if (data.shippingAddress) {
|
|
3148
|
+
setPrefillAddress(data.shippingAddress);
|
|
3149
|
+
setHasSavedAddress(true);
|
|
3589
3150
|
}
|
|
3590
|
-
|
|
3591
|
-
}
|
|
3151
|
+
})
|
|
3152
|
+
.catch(() => {});
|
|
3153
|
+
}, [isLoggedIn]);
|
|
3592
3154
|
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3155
|
+
// Initialize or resume checkout (only once)
|
|
3156
|
+
const checkoutInitRef = useRef(false);
|
|
3157
|
+
const cartIdRef = useRef<string | null>(null);
|
|
3158
|
+
|
|
3159
|
+
useEffect(() => {
|
|
3160
|
+
// Only init once, or if cart ID actually changed (e.g. cart was replaced)
|
|
3161
|
+
if (!cart?.id) return;
|
|
3162
|
+
if (checkoutInitRef.current && cartIdRef.current === cart.id) return;
|
|
3163
|
+
checkoutInitRef.current = true;
|
|
3164
|
+
cartIdRef.current = cart.id;
|
|
3165
|
+
|
|
3166
|
+
const initCheckout = async () => {
|
|
3167
|
+
try {
|
|
3168
|
+
setInitializing(true);
|
|
3169
|
+
setError(null);
|
|
3170
|
+
const client = getClient();
|
|
3171
|
+
|
|
3172
|
+
// Fetch shipping destinations and pickup locations in parallel
|
|
3173
|
+
client
|
|
3174
|
+
.getShippingDestinations()
|
|
3175
|
+
.then(setDestinations)
|
|
3176
|
+
.catch(() => {});
|
|
3177
|
+
|
|
3178
|
+
const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]);
|
|
3179
|
+
setPickupLocations(locations);
|
|
3180
|
+
|
|
3181
|
+
// If returning with existing checkout ID, resume it
|
|
3182
|
+
if (existingCheckoutId) {
|
|
3183
|
+
const existing = await client.getCheckout(existingCheckoutId);
|
|
3184
|
+
setCheckout(existing);
|
|
3185
|
+
|
|
3186
|
+
// Determine step based on checkout state
|
|
3187
|
+
const allDigital = existing.lineItems.every(
|
|
3188
|
+
(i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
|
|
3189
|
+
);
|
|
3190
|
+
setIsAllDigital(allDigital);
|
|
3191
|
+
if (allDigital) {
|
|
3192
|
+
// Digital products: show contact info step if email not set, else payment
|
|
3193
|
+
setStep(existing.email ? 'payment' : 'address');
|
|
3194
|
+
} else if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
|
|
3195
|
+
setDeliveryType('pickup');
|
|
3196
|
+
setStep('payment');
|
|
3197
|
+
} else if (existing.shippingAddress && existing.shippingRateId) {
|
|
3198
|
+
setStep('payment');
|
|
3199
|
+
} else if (existing.shippingAddress) {
|
|
3200
|
+
// Fetch shipping rates
|
|
3201
|
+
const rates = await client.getShippingRates(existing.id);
|
|
3202
|
+
setShippingRates(rates);
|
|
3203
|
+
setStep('shipping');
|
|
3204
|
+
} else if (locations.length > 0) {
|
|
3205
|
+
setStep('method');
|
|
3607
3206
|
}
|
|
3207
|
+
return;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
// Create new checkout \u2014 cart is always server-side now
|
|
3211
|
+
const newCheckout = await client.createCheckout({ cartId: cart.id });
|
|
3212
|
+
setCheckout(newCheckout);
|
|
3213
|
+
|
|
3214
|
+
// If all items are downloadable, skip shipping \u2014 show contact info step
|
|
3215
|
+
const allDigital = newCheckout.lineItems.every(
|
|
3216
|
+
(i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
|
|
3217
|
+
);
|
|
3218
|
+
setIsAllDigital(allDigital);
|
|
3219
|
+
if (allDigital) {
|
|
3220
|
+
setStep('address');
|
|
3221
|
+
return;
|
|
3608
3222
|
}
|
|
3609
|
-
} else {
|
|
3610
|
-
setError(t('cartIsEmpty'));
|
|
3611
|
-
}
|
|
3612
3223
|
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3224
|
+
// If pickup locations exist, start with delivery method selection
|
|
3225
|
+
if (locations.length > 0) {
|
|
3226
|
+
setStep('method');
|
|
3227
|
+
}
|
|
3228
|
+
} catch (err) {
|
|
3229
|
+
const message = err instanceof Error ? err.message : t('failedToInitCheckout');
|
|
3230
|
+
setError(message);
|
|
3231
|
+
} finally {
|
|
3232
|
+
setInitializing(false);
|
|
3616
3233
|
}
|
|
3617
|
-
}
|
|
3618
|
-
const message = err instanceof Error ? err.message : t('failedToInitCheckout');
|
|
3619
|
-
setError(message);
|
|
3620
|
-
} finally {
|
|
3621
|
-
setInitializing(false);
|
|
3622
|
-
}
|
|
3623
|
-
}, [existingCheckoutId, isLoggedIn, cart]);
|
|
3234
|
+
};
|
|
3624
3235
|
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
initCheckout();
|
|
3629
|
-
}
|
|
3630
|
-
}, [cartLoaded, initCheckout]);
|
|
3236
|
+
initCheckout();
|
|
3237
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3238
|
+
}, [cart?.id, existingCheckoutId]);
|
|
3631
3239
|
|
|
3632
3240
|
// Handle shipping address submission
|
|
3633
|
-
async function handleAddressSubmit(
|
|
3241
|
+
async function handleAddressSubmit(
|
|
3242
|
+
address: SetShippingAddressDto,
|
|
3243
|
+
consent: { acceptsMarketing: boolean; saveDetails: boolean }
|
|
3244
|
+
) {
|
|
3634
3245
|
if (!checkout) return;
|
|
3635
3246
|
|
|
3636
3247
|
try {
|
|
@@ -3638,10 +3249,52 @@ function CheckoutContent() {
|
|
|
3638
3249
|
setError(null);
|
|
3639
3250
|
const client = getClient();
|
|
3640
3251
|
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3252
|
+
if (isAllDigital) {
|
|
3253
|
+
// Digital products: set customer info only, skip shipping
|
|
3254
|
+
const updated = await client.setCheckoutCustomer(checkout.id, {
|
|
3255
|
+
email: address.email,
|
|
3256
|
+
firstName: address.firstName,
|
|
3257
|
+
lastName: address.lastName,
|
|
3258
|
+
phone: address.phone,
|
|
3259
|
+
acceptsMarketing: consent.acceptsMarketing,
|
|
3260
|
+
});
|
|
3261
|
+
setCheckout(updated);
|
|
3262
|
+
setStep('payment');
|
|
3263
|
+
} else {
|
|
3264
|
+
const response = await client.setShippingAddress(checkout.id, address);
|
|
3265
|
+
setCheckout(response.checkout);
|
|
3266
|
+
setShippingRates(response.rates);
|
|
3267
|
+
setStep('shipping');
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
// Update marketing preference for logged-in users
|
|
3271
|
+
if (isLoggedIn) {
|
|
3272
|
+
try {
|
|
3273
|
+
await client.updateMyProfile({ acceptsMarketing: consent.acceptsMarketing });
|
|
3274
|
+
} catch {
|
|
3275
|
+
// non-critical
|
|
3276
|
+
}
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
// Save address to profile if checkbox was checked and no existing saved address
|
|
3280
|
+
if (isLoggedIn && consent.saveDetails && !hasSavedAddress && !isAllDigital) {
|
|
3281
|
+
try {
|
|
3282
|
+
await client.addMyAddress({
|
|
3283
|
+
firstName: address.firstName,
|
|
3284
|
+
lastName: address.lastName,
|
|
3285
|
+
line1: address.line1,
|
|
3286
|
+
line2: address.line2,
|
|
3287
|
+
city: address.city,
|
|
3288
|
+
region: address.region,
|
|
3289
|
+
postalCode: address.postalCode,
|
|
3290
|
+
country: address.country,
|
|
3291
|
+
phone: address.phone,
|
|
3292
|
+
isDefault: true,
|
|
3293
|
+
});
|
|
3294
|
+
} catch {
|
|
3295
|
+
// non-critical
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3645
3298
|
} catch (err) {
|
|
3646
3299
|
const message = err instanceof Error ? err.message : t('failedToSaveAddress');
|
|
3647
3300
|
setError(message);
|
|
@@ -3778,8 +3431,12 @@ function CheckoutContent() {
|
|
|
3778
3431
|
);
|
|
3779
3432
|
}
|
|
3780
3433
|
|
|
3781
|
-
const steps: { key: CheckoutStep; label: string }[] =
|
|
3782
|
-
|
|
3434
|
+
const steps: { key: CheckoutStep; label: string }[] = isAllDigital
|
|
3435
|
+
? [
|
|
3436
|
+
{ key: 'address', label: t('stepContactInfo') },
|
|
3437
|
+
{ key: 'payment', label: t('stepPayment') },
|
|
3438
|
+
]
|
|
3439
|
+
: pickupLocations.length > 0
|
|
3783
3440
|
? deliveryType === 'pickup'
|
|
3784
3441
|
? [
|
|
3785
3442
|
{ key: 'method', label: t('stepMethod') },
|
|
@@ -3889,8 +3546,10 @@ function CheckoutContent() {
|
|
|
3889
3546
|
{step === 'address' && (
|
|
3890
3547
|
<div>
|
|
3891
3548
|
<div className="mb-4 flex items-center justify-between">
|
|
3892
|
-
<h2 className="text-foreground text-lg font-semibold">
|
|
3893
|
-
|
|
3549
|
+
<h2 className="text-foreground text-lg font-semibold">
|
|
3550
|
+
{isAllDigital ? t('contactInfo') : t('shippingAddress')}
|
|
3551
|
+
</h2>
|
|
3552
|
+
{!isAllDigital && pickupLocations.length > 0 && (
|
|
3894
3553
|
<button
|
|
3895
3554
|
type="button"
|
|
3896
3555
|
onClick={() => setStep('method')}
|
|
@@ -3903,7 +3562,9 @@ function CheckoutContent() {
|
|
|
3903
3562
|
<CheckoutForm
|
|
3904
3563
|
onSubmit={handleAddressSubmit}
|
|
3905
3564
|
loading={loading}
|
|
3906
|
-
destinations={destinations}
|
|
3565
|
+
destinations={isAllDigital ? null : destinations}
|
|
3566
|
+
showSaveDetails={isLoggedIn && !hasSavedAddress && !isAllDigital}
|
|
3567
|
+
emailOnly={isAllDigital}
|
|
3907
3568
|
initialValues={
|
|
3908
3569
|
checkout?.shippingAddress
|
|
3909
3570
|
? {
|
|
@@ -3918,7 +3579,27 @@ function CheckoutContent() {
|
|
|
3918
3579
|
country: checkout.shippingAddress.country,
|
|
3919
3580
|
phone: checkout.shippingAddress.phone || '',
|
|
3920
3581
|
}
|
|
3921
|
-
:
|
|
3582
|
+
: prefillAddress
|
|
3583
|
+
? {
|
|
3584
|
+
email: prefillAddress.email,
|
|
3585
|
+
firstName: prefillAddress.firstName,
|
|
3586
|
+
lastName: prefillAddress.lastName,
|
|
3587
|
+
line1: prefillAddress.line1,
|
|
3588
|
+
line2: prefillAddress.line2 || '',
|
|
3589
|
+
city: prefillAddress.city,
|
|
3590
|
+
region: prefillAddress.region || '',
|
|
3591
|
+
postalCode: prefillAddress.postalCode,
|
|
3592
|
+
country: prefillAddress.country,
|
|
3593
|
+
phone: prefillAddress.phone || '',
|
|
3594
|
+
}
|
|
3595
|
+
: prefillCustomer
|
|
3596
|
+
? {
|
|
3597
|
+
email: prefillCustomer.email,
|
|
3598
|
+
firstName: prefillCustomer.firstName || '',
|
|
3599
|
+
lastName: prefillCustomer.lastName || '',
|
|
3600
|
+
phone: prefillCustomer.phone || '',
|
|
3601
|
+
}
|
|
3602
|
+
: undefined
|
|
3922
3603
|
}
|
|
3923
3604
|
/>
|
|
3924
3605
|
</div>
|
|
@@ -3974,13 +3655,15 @@ function CheckoutContent() {
|
|
|
3974
3655
|
<div>
|
|
3975
3656
|
<div className="mb-4 flex items-center justify-between">
|
|
3976
3657
|
<h2 className="text-foreground text-lg font-semibold">{t('payment')}</h2>
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3658
|
+
{!isAllDigital && (
|
|
3659
|
+
<button
|
|
3660
|
+
type="button"
|
|
3661
|
+
onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
|
|
3662
|
+
className="text-primary text-sm hover:underline"
|
|
3663
|
+
>
|
|
3664
|
+
{deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
|
|
3665
|
+
</button>
|
|
3666
|
+
)}
|
|
3984
3667
|
</div>
|
|
3985
3668
|
|
|
3986
3669
|
<PaymentStep checkoutId={checkout.id} />
|
|
@@ -4056,12 +3739,13 @@ function CheckoutContent() {
|
|
|
4056
3739
|
)
|
|
4057
3740
|
)}
|
|
4058
3741
|
|
|
4059
|
-
{/* Coupon input \u2014 show from shipping/pickup step onwards */}
|
|
4060
|
-
{cart &&
|
|
4061
|
-
|
|
4062
|
-
<
|
|
4063
|
-
|
|
4064
|
-
|
|
3742
|
+
{/* Coupon input \u2014 show from shipping/pickup step onwards (or immediately if digital) */}
|
|
3743
|
+
{cart &&
|
|
3744
|
+
(isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
|
|
3745
|
+
<div className="border-border border-t pt-4">
|
|
3746
|
+
<CouponInput cart={cart} onUpdate={handleCouponUpdate} />
|
|
3747
|
+
</div>
|
|
3748
|
+
)}
|
|
4065
3749
|
|
|
4066
3750
|
{/* Totals */}
|
|
4067
3751
|
{checkout && (
|
|
@@ -4183,7 +3867,7 @@ export default function CheckoutPage() {
|
|
|
4183
3867
|
import { Suspense, useEffect, useState } from 'react';
|
|
4184
3868
|
import { useSearchParams } from 'next/navigation';
|
|
4185
3869
|
import Link from 'next/link';
|
|
4186
|
-
import type { WaitForOrderResult } from 'brainerce';
|
|
3870
|
+
import type { WaitForOrderResult, OrderDownloadLink } from 'brainerce';
|
|
4187
3871
|
import { getClient } from '@/lib/brainerce';
|
|
4188
3872
|
import { useCart } from '@/providers/store-provider';
|
|
4189
3873
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
@@ -4299,6 +3983,10 @@ function OrderConfirmationContent() {
|
|
|
4299
3983
|
|
|
4300
3984
|
<p className="text-muted-foreground mt-2">{t('confirmationEmail')}</p>
|
|
4301
3985
|
|
|
3986
|
+
{result.status.orderId && (
|
|
3987
|
+
<ConfirmationDownloads orderId={result.status.orderId} checkoutId={checkoutId!} />
|
|
3988
|
+
)}
|
|
3989
|
+
|
|
4302
3990
|
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
|
4303
3991
|
<Link
|
|
4304
3992
|
href="/products"
|
|
@@ -4358,6 +4046,63 @@ function OrderConfirmationContent() {
|
|
|
4358
4046
|
);
|
|
4359
4047
|
}
|
|
4360
4048
|
|
|
4049
|
+
function ConfirmationDownloads({ orderId, checkoutId }: { orderId: string; checkoutId: string }) {
|
|
4050
|
+
const t = useTranslations('orderConfirmation');
|
|
4051
|
+
const [downloads, setDownloads] = useState<OrderDownloadLink[] | null>(null);
|
|
4052
|
+
|
|
4053
|
+
useEffect(() => {
|
|
4054
|
+
let cancelled = false;
|
|
4055
|
+
async function fetchDownloads() {
|
|
4056
|
+
const client = getClient();
|
|
4057
|
+
// Retry a few times \u2014 the worker may still be writing downloadMeta
|
|
4058
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
4059
|
+
try {
|
|
4060
|
+
const links = await client.getOrderDownloads(orderId, { checkoutId });
|
|
4061
|
+
if (!cancelled && links.length > 0) {
|
|
4062
|
+
setDownloads(links);
|
|
4063
|
+
return;
|
|
4064
|
+
}
|
|
4065
|
+
} catch {
|
|
4066
|
+
// Not all orders have downloads
|
|
4067
|
+
}
|
|
4068
|
+
if (attempt < 2 && !cancelled) {
|
|
4069
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
4070
|
+
}
|
|
4071
|
+
}
|
|
4072
|
+
}
|
|
4073
|
+
fetchDownloads();
|
|
4074
|
+
return () => {
|
|
4075
|
+
cancelled = true;
|
|
4076
|
+
};
|
|
4077
|
+
}, [orderId, checkoutId]);
|
|
4078
|
+
|
|
4079
|
+
if (!downloads || downloads.length === 0) return null;
|
|
4080
|
+
|
|
4081
|
+
return (
|
|
4082
|
+
<div className="border-border bg-muted/30 mx-auto mt-8 max-w-md rounded-lg border p-6 text-start">
|
|
4083
|
+
<h3 className="text-foreground mb-3 text-sm font-semibold">{t('yourDownloads')}</h3>
|
|
4084
|
+
<div className="space-y-2">
|
|
4085
|
+
{downloads.map((link, idx) => (
|
|
4086
|
+
<div key={idx} className="flex items-center justify-between gap-3">
|
|
4087
|
+
<div className="min-w-0 flex-1">
|
|
4088
|
+
<p className="text-foreground truncate text-sm">{link.fileName}</p>
|
|
4089
|
+
<p className="text-muted-foreground truncate text-xs">{link.productName}</p>
|
|
4090
|
+
</div>
|
|
4091
|
+
<a
|
|
4092
|
+
href={link.downloadUrl}
|
|
4093
|
+
target="_blank"
|
|
4094
|
+
rel="noopener noreferrer"
|
|
4095
|
+
className="bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1.5 text-xs font-medium hover:opacity-90"
|
|
4096
|
+
>
|
|
4097
|
+
{t('download')}
|
|
4098
|
+
</a>
|
|
4099
|
+
</div>
|
|
4100
|
+
))}
|
|
4101
|
+
</div>
|
|
4102
|
+
</div>
|
|
4103
|
+
);
|
|
4104
|
+
}
|
|
4105
|
+
|
|
4361
4106
|
export default function OrderConfirmationPage() {
|
|
4362
4107
|
return (
|
|
4363
4108
|
<Suspense
|
|
@@ -4454,6 +4199,7 @@ export default function RegisterPage() {
|
|
|
4454
4199
|
lastName: string;
|
|
4455
4200
|
email: string;
|
|
4456
4201
|
password: string;
|
|
4202
|
+
acceptsMarketing: boolean;
|
|
4457
4203
|
}) {
|
|
4458
4204
|
try {
|
|
4459
4205
|
setError(null);
|
|
@@ -4679,7 +4425,7 @@ function VerifyEmailContent() {
|
|
|
4679
4425
|
|
|
4680
4426
|
<form onSubmit={handleFormSubmit} className="space-y-6">
|
|
4681
4427
|
{/* Digit inputs */}
|
|
4682
|
-
<div className="flex justify-center gap-2 sm:gap-3" onPaste={handlePaste}>
|
|
4428
|
+
<div dir="ltr" className="flex justify-center gap-2 sm:gap-3" onPaste={handlePaste}>
|
|
4683
4429
|
{digits.map((digit, index) => (
|
|
4684
4430
|
<input
|
|
4685
4431
|
key={index}
|
|
@@ -4756,108 +4502,355 @@ export default function VerifyEmailPage() {
|
|
|
4756
4502
|
);
|
|
4757
4503
|
}
|
|
4758
4504
|
`,
|
|
4759
|
-
"src/app/
|
|
4505
|
+
"src/app/forgot-password/page.tsx": `'use client';
|
|
4760
4506
|
|
|
4761
|
-
import {
|
|
4762
|
-
import
|
|
4763
|
-
import {
|
|
4507
|
+
import { useState } from 'react';
|
|
4508
|
+
import Link from 'next/link';
|
|
4509
|
+
import { getClient } from '@/lib/brainerce';
|
|
4764
4510
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
4765
4511
|
import { useTranslations } from '@/lib/translations';
|
|
4766
4512
|
|
|
4767
|
-
function
|
|
4768
|
-
const router = useRouter();
|
|
4769
|
-
const searchParams = useSearchParams();
|
|
4770
|
-
const auth = useAuth();
|
|
4771
|
-
const [error, setError] = useState<string | null>(null);
|
|
4772
|
-
const processedRef = useRef(false);
|
|
4513
|
+
export default function ForgotPasswordPage() {
|
|
4773
4514
|
const t = useTranslations('auth');
|
|
4515
|
+
const [email, setEmail] = useState('');
|
|
4516
|
+
const [loading, setLoading] = useState(false);
|
|
4517
|
+
const [sent, setSent] = useState(false);
|
|
4518
|
+
const [error, setError] = useState<string | null>(null);
|
|
4774
4519
|
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
useEffect(() => {
|
|
4780
|
-
// Prevent double-processing in React StrictMode
|
|
4781
|
-
if (processedRef.current) return;
|
|
4782
|
-
processedRef.current = true;
|
|
4783
|
-
|
|
4784
|
-
if (oauthError) {
|
|
4785
|
-
setError(oauthError);
|
|
4786
|
-
return;
|
|
4787
|
-
}
|
|
4520
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
4521
|
+
e.preventDefault();
|
|
4522
|
+
if (loading) return;
|
|
4788
4523
|
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
}
|
|
4794
|
-
|
|
4795
|
-
|
|
4524
|
+
try {
|
|
4525
|
+
setLoading(true);
|
|
4526
|
+
setError(null);
|
|
4527
|
+
const client = getClient();
|
|
4528
|
+
const resetUrl = \`\${window.location.origin}/api/auth/reset-callback\`;
|
|
4529
|
+
await client.forgotPassword(email, { resetUrl });
|
|
4530
|
+
setSent(true);
|
|
4531
|
+
} catch (err) {
|
|
4532
|
+
const message =
|
|
4533
|
+
err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
|
4534
|
+
setError(message);
|
|
4535
|
+
} finally {
|
|
4536
|
+
setLoading(false);
|
|
4796
4537
|
}
|
|
4797
|
-
}, [oauthSuccess, oauthError, auth, router, t]);
|
|
4798
|
-
|
|
4799
|
-
if (error) {
|
|
4800
|
-
return (
|
|
4801
|
-
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
4802
|
-
<div className="w-full max-w-md space-y-4 text-center">
|
|
4803
|
-
<svg
|
|
4804
|
-
className="text-destructive mx-auto h-12 w-12"
|
|
4805
|
-
fill="none"
|
|
4806
|
-
viewBox="0 0 24 24"
|
|
4807
|
-
stroke="currentColor"
|
|
4808
|
-
>
|
|
4809
|
-
<path
|
|
4810
|
-
strokeLinecap="round"
|
|
4811
|
-
strokeLinejoin="round"
|
|
4812
|
-
strokeWidth={1.5}
|
|
4813
|
-
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"
|
|
4814
|
-
/>
|
|
4815
|
-
</svg>
|
|
4816
|
-
<h1 className="text-foreground text-2xl font-bold">{t('authFailed')}</h1>
|
|
4817
|
-
<p className="text-muted-foreground text-sm">{error}</p>
|
|
4818
|
-
<button
|
|
4819
|
-
type="button"
|
|
4820
|
-
onClick={() => router.push('/login')}
|
|
4821
|
-
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
4822
|
-
>
|
|
4823
|
-
{t('backToLogin')}
|
|
4824
|
-
</button>
|
|
4825
|
-
</div>
|
|
4826
|
-
</div>
|
|
4827
|
-
);
|
|
4828
4538
|
}
|
|
4829
4539
|
|
|
4830
4540
|
return (
|
|
4831
|
-
<div className="flex min-h-[60vh]
|
|
4832
|
-
<
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
}
|
|
4837
|
-
|
|
4838
|
-
export default function OAuthCallbackPage() {
|
|
4839
|
-
return (
|
|
4840
|
-
<Suspense
|
|
4841
|
-
fallback={
|
|
4842
|
-
<div className="flex min-h-[60vh] items-center justify-center">
|
|
4843
|
-
<LoadingSpinner size="lg" />
|
|
4541
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
4542
|
+
<div className="w-full max-w-md space-y-6">
|
|
4543
|
+
<div className="text-center">
|
|
4544
|
+
<h1 className="text-foreground text-2xl font-bold">{t('forgotPasswordTitle')}</h1>
|
|
4545
|
+
<p className="text-muted-foreground mt-1 text-sm">{t('forgotPasswordSubtitle')}</p>
|
|
4844
4546
|
</div>
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4547
|
+
|
|
4548
|
+
{error && (
|
|
4549
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm">
|
|
4550
|
+
{error}
|
|
4551
|
+
</div>
|
|
4552
|
+
)}
|
|
4553
|
+
|
|
4554
|
+
{sent ? (
|
|
4555
|
+
<div className="space-y-4">
|
|
4556
|
+
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300">
|
|
4557
|
+
{t('resetLinkSent')}
|
|
4558
|
+
</div>
|
|
4559
|
+
<Link
|
|
4560
|
+
href="/login"
|
|
4561
|
+
className="text-primary block text-center text-sm font-medium hover:underline"
|
|
4562
|
+
>
|
|
4563
|
+
{t('backToLogin')}
|
|
4564
|
+
</Link>
|
|
4565
|
+
</div>
|
|
4566
|
+
) : (
|
|
4567
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
4568
|
+
<div>
|
|
4569
|
+
<label
|
|
4570
|
+
htmlFor="forgot-email"
|
|
4571
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
4572
|
+
>
|
|
4573
|
+
{t('email')}
|
|
4574
|
+
</label>
|
|
4575
|
+
<input
|
|
4576
|
+
id="forgot-email"
|
|
4577
|
+
type="email"
|
|
4578
|
+
required
|
|
4579
|
+
value={email}
|
|
4580
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
4581
|
+
placeholder={t('emailPlaceholder')}
|
|
4582
|
+
autoComplete="email"
|
|
4583
|
+
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2"
|
|
4584
|
+
/>
|
|
4585
|
+
</div>
|
|
4586
|
+
|
|
4587
|
+
<button
|
|
4588
|
+
type="submit"
|
|
4589
|
+
disabled={loading}
|
|
4590
|
+
className="bg-primary text-primary-foreground flex h-10 w-full items-center justify-center gap-2 rounded text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
4591
|
+
>
|
|
4592
|
+
{loading ? (
|
|
4593
|
+
<>
|
|
4594
|
+
<LoadingSpinner
|
|
4595
|
+
size="sm"
|
|
4596
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
4597
|
+
/>
|
|
4598
|
+
{t('sendingResetLink')}
|
|
4599
|
+
</>
|
|
4600
|
+
) : (
|
|
4601
|
+
t('sendResetLink')
|
|
4602
|
+
)}
|
|
4603
|
+
</button>
|
|
4604
|
+
|
|
4605
|
+
<Link
|
|
4606
|
+
href="/login"
|
|
4607
|
+
className="text-muted-foreground block text-center text-sm hover:underline"
|
|
4608
|
+
>
|
|
4609
|
+
{t('backToLogin')}
|
|
4610
|
+
</Link>
|
|
4611
|
+
</form>
|
|
4612
|
+
)}
|
|
4613
|
+
</div>
|
|
4614
|
+
</div>
|
|
4849
4615
|
);
|
|
4850
4616
|
}
|
|
4851
4617
|
`,
|
|
4852
|
-
"src/app/
|
|
4618
|
+
"src/app/reset-password/page.tsx": `'use client';
|
|
4853
4619
|
|
|
4854
|
-
import {
|
|
4620
|
+
import { useState } from 'react';
|
|
4855
4621
|
import { useRouter } from 'next/navigation';
|
|
4856
|
-
import
|
|
4857
|
-
import {
|
|
4858
|
-
import { useAuth } from '@/providers/store-provider';
|
|
4622
|
+
import Link from 'next/link';
|
|
4623
|
+
import { proxyResetPassword } from '@/lib/auth';
|
|
4859
4624
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
4860
|
-
import {
|
|
4625
|
+
import { useTranslations } from '@/lib/translations';
|
|
4626
|
+
|
|
4627
|
+
export default function ResetPasswordPage() {
|
|
4628
|
+
const router = useRouter();
|
|
4629
|
+
const t = useTranslations('auth');
|
|
4630
|
+
|
|
4631
|
+
const [newPassword, setNewPassword] = useState('');
|
|
4632
|
+
const [confirmPassword, setConfirmPassword] = useState('');
|
|
4633
|
+
const [loading, setLoading] = useState(false);
|
|
4634
|
+
const [error, setError] = useState<string | null>(null);
|
|
4635
|
+
const [success, setSuccess] = useState(false);
|
|
4636
|
+
|
|
4637
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
4638
|
+
e.preventDefault();
|
|
4639
|
+
if (loading) return;
|
|
4640
|
+
|
|
4641
|
+
if (newPassword !== confirmPassword) {
|
|
4642
|
+
setError(t('passwordsMustMatch'));
|
|
4643
|
+
return;
|
|
4644
|
+
}
|
|
4645
|
+
|
|
4646
|
+
try {
|
|
4647
|
+
setLoading(true);
|
|
4648
|
+
setError(null);
|
|
4649
|
+
await proxyResetPassword(newPassword);
|
|
4650
|
+
setSuccess(true);
|
|
4651
|
+
setTimeout(() => router.push('/login'), 2000);
|
|
4652
|
+
} catch (err) {
|
|
4653
|
+
const message =
|
|
4654
|
+
err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
|
4655
|
+
setError(message);
|
|
4656
|
+
} finally {
|
|
4657
|
+
setLoading(false);
|
|
4658
|
+
}
|
|
4659
|
+
}
|
|
4660
|
+
|
|
4661
|
+
return (
|
|
4662
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
4663
|
+
<div className="w-full max-w-md space-y-6">
|
|
4664
|
+
<div className="text-center">
|
|
4665
|
+
<h1 className="text-foreground text-2xl font-bold">{t('resetPasswordTitle')}</h1>
|
|
4666
|
+
<p className="text-muted-foreground mt-1 text-sm">{t('resetPasswordSubtitle')}</p>
|
|
4667
|
+
</div>
|
|
4668
|
+
|
|
4669
|
+
{error && (
|
|
4670
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive space-y-2 rounded-lg border px-4 py-3 text-sm">
|
|
4671
|
+
<p>{error}</p>
|
|
4672
|
+
<Link
|
|
4673
|
+
href="/forgot-password"
|
|
4674
|
+
className="text-primary block font-medium hover:underline"
|
|
4675
|
+
>
|
|
4676
|
+
{t('sendResetLink')}
|
|
4677
|
+
</Link>
|
|
4678
|
+
</div>
|
|
4679
|
+
)}
|
|
4680
|
+
|
|
4681
|
+
{success ? (
|
|
4682
|
+
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300">
|
|
4683
|
+
{t('passwordResetSuccess')}
|
|
4684
|
+
</div>
|
|
4685
|
+
) : (
|
|
4686
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
4687
|
+
<div>
|
|
4688
|
+
<label
|
|
4689
|
+
htmlFor="new-password"
|
|
4690
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
4691
|
+
>
|
|
4692
|
+
{t('newPassword')}
|
|
4693
|
+
</label>
|
|
4694
|
+
<input
|
|
4695
|
+
id="new-password"
|
|
4696
|
+
type="password"
|
|
4697
|
+
required
|
|
4698
|
+
minLength={8}
|
|
4699
|
+
value={newPassword}
|
|
4700
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
4701
|
+
placeholder={t('newPasswordPlaceholder')}
|
|
4702
|
+
autoComplete="new-password"
|
|
4703
|
+
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2"
|
|
4704
|
+
/>
|
|
4705
|
+
</div>
|
|
4706
|
+
|
|
4707
|
+
<div>
|
|
4708
|
+
<label
|
|
4709
|
+
htmlFor="confirm-password"
|
|
4710
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
4711
|
+
>
|
|
4712
|
+
{t('confirmPassword')}
|
|
4713
|
+
</label>
|
|
4714
|
+
<input
|
|
4715
|
+
id="confirm-password"
|
|
4716
|
+
type="password"
|
|
4717
|
+
required
|
|
4718
|
+
minLength={8}
|
|
4719
|
+
value={confirmPassword}
|
|
4720
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
4721
|
+
placeholder={t('confirmPasswordPlaceholder')}
|
|
4722
|
+
autoComplete="new-password"
|
|
4723
|
+
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2"
|
|
4724
|
+
/>
|
|
4725
|
+
</div>
|
|
4726
|
+
|
|
4727
|
+
<button
|
|
4728
|
+
type="submit"
|
|
4729
|
+
disabled={loading}
|
|
4730
|
+
className="bg-primary text-primary-foreground flex h-10 w-full items-center justify-center gap-2 rounded text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
4731
|
+
>
|
|
4732
|
+
{loading ? (
|
|
4733
|
+
<>
|
|
4734
|
+
<LoadingSpinner
|
|
4735
|
+
size="sm"
|
|
4736
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
4737
|
+
/>
|
|
4738
|
+
{t('resettingPassword')}
|
|
4739
|
+
</>
|
|
4740
|
+
) : (
|
|
4741
|
+
t('resetPassword')
|
|
4742
|
+
)}
|
|
4743
|
+
</button>
|
|
4744
|
+
</form>
|
|
4745
|
+
)}
|
|
4746
|
+
</div>
|
|
4747
|
+
</div>
|
|
4748
|
+
);
|
|
4749
|
+
}
|
|
4750
|
+
`,
|
|
4751
|
+
"src/app/auth/callback/page.tsx": `'use client';
|
|
4752
|
+
|
|
4753
|
+
import { Suspense, useEffect, useState, useRef } from 'react';
|
|
4754
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
4755
|
+
import { useAuth } from '@/providers/store-provider';
|
|
4756
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
4757
|
+
import { useTranslations } from '@/lib/translations';
|
|
4758
|
+
|
|
4759
|
+
function OAuthCallbackContent() {
|
|
4760
|
+
const router = useRouter();
|
|
4761
|
+
const searchParams = useSearchParams();
|
|
4762
|
+
const auth = useAuth();
|
|
4763
|
+
const [error, setError] = useState<string | null>(null);
|
|
4764
|
+
const processedRef = useRef(false);
|
|
4765
|
+
const t = useTranslations('auth');
|
|
4766
|
+
|
|
4767
|
+
const oauthSuccess = searchParams.get('oauth_success');
|
|
4768
|
+
const oauthError = searchParams.get('oauth_error');
|
|
4769
|
+
// Token is no longer in URL \u2014 it was set as httpOnly cookie by /api/auth/oauth-callback
|
|
4770
|
+
|
|
4771
|
+
useEffect(() => {
|
|
4772
|
+
// Prevent double-processing in React StrictMode
|
|
4773
|
+
if (processedRef.current) return;
|
|
4774
|
+
processedRef.current = true;
|
|
4775
|
+
|
|
4776
|
+
if (oauthError) {
|
|
4777
|
+
setError(oauthError);
|
|
4778
|
+
return;
|
|
4779
|
+
}
|
|
4780
|
+
|
|
4781
|
+
if (oauthSuccess === 'true') {
|
|
4782
|
+
// Cookie was already set by the API route; refresh auth state
|
|
4783
|
+
auth.login().then(() => {
|
|
4784
|
+
router.push('/');
|
|
4785
|
+
});
|
|
4786
|
+
} else {
|
|
4787
|
+
setError(t('authFailedDesc'));
|
|
4788
|
+
}
|
|
4789
|
+
}, [oauthSuccess, oauthError, auth, router, t]);
|
|
4790
|
+
|
|
4791
|
+
if (error) {
|
|
4792
|
+
return (
|
|
4793
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
4794
|
+
<div className="w-full max-w-md space-y-4 text-center">
|
|
4795
|
+
<svg
|
|
4796
|
+
className="text-destructive mx-auto h-12 w-12"
|
|
4797
|
+
fill="none"
|
|
4798
|
+
viewBox="0 0 24 24"
|
|
4799
|
+
stroke="currentColor"
|
|
4800
|
+
>
|
|
4801
|
+
<path
|
|
4802
|
+
strokeLinecap="round"
|
|
4803
|
+
strokeLinejoin="round"
|
|
4804
|
+
strokeWidth={1.5}
|
|
4805
|
+
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"
|
|
4806
|
+
/>
|
|
4807
|
+
</svg>
|
|
4808
|
+
<h1 className="text-foreground text-2xl font-bold">{t('authFailed')}</h1>
|
|
4809
|
+
<p className="text-muted-foreground text-sm">{error}</p>
|
|
4810
|
+
<button
|
|
4811
|
+
type="button"
|
|
4812
|
+
onClick={() => router.push('/login')}
|
|
4813
|
+
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
4814
|
+
>
|
|
4815
|
+
{t('backToLogin')}
|
|
4816
|
+
</button>
|
|
4817
|
+
</div>
|
|
4818
|
+
</div>
|
|
4819
|
+
);
|
|
4820
|
+
}
|
|
4821
|
+
|
|
4822
|
+
return (
|
|
4823
|
+
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 py-12">
|
|
4824
|
+
<LoadingSpinner size="lg" />
|
|
4825
|
+
<p className="text-muted-foreground mt-4">{t('completingSignIn')}</p>
|
|
4826
|
+
</div>
|
|
4827
|
+
);
|
|
4828
|
+
}
|
|
4829
|
+
|
|
4830
|
+
export default function OAuthCallbackPage() {
|
|
4831
|
+
return (
|
|
4832
|
+
<Suspense
|
|
4833
|
+
fallback={
|
|
4834
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
4835
|
+
<LoadingSpinner size="lg" />
|
|
4836
|
+
</div>
|
|
4837
|
+
}
|
|
4838
|
+
>
|
|
4839
|
+
<OAuthCallbackContent />
|
|
4840
|
+
</Suspense>
|
|
4841
|
+
);
|
|
4842
|
+
}
|
|
4843
|
+
`,
|
|
4844
|
+
"src/app/account/page.tsx": `'use client';
|
|
4845
|
+
|
|
4846
|
+
import { useEffect, useState } from 'react';
|
|
4847
|
+
import { useRouter } from 'next/navigation';
|
|
4848
|
+
import type { CustomerProfile, Order } from 'brainerce';
|
|
4849
|
+
import { getClient } from '@/lib/brainerce';
|
|
4850
|
+
import { useAuth } from '@/providers/store-provider';
|
|
4851
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
4852
|
+
import { ProfileSection } from '@/components/account/profile-section';
|
|
4853
|
+
import { AddressBook } from '@/components/account/address-book';
|
|
4861
4854
|
import { OrderHistory } from '@/components/account/order-history';
|
|
4862
4855
|
import { useTranslations } from '@/lib/translations';
|
|
4863
4856
|
|
|
@@ -4950,7 +4943,16 @@ export default function AccountPage() {
|
|
|
4950
4943
|
|
|
4951
4944
|
{/* Profile Section */}
|
|
4952
4945
|
{profile && (
|
|
4953
|
-
<ProfileSection profile={profile} onProfileUpdate={setProfile} className="mb-
|
|
4946
|
+
<ProfileSection profile={profile} onProfileUpdate={setProfile} className="mb-6" />
|
|
4947
|
+
)}
|
|
4948
|
+
|
|
4949
|
+
{/* Address Book */}
|
|
4950
|
+
{profile && (
|
|
4951
|
+
<AddressBook
|
|
4952
|
+
addresses={profile.addresses ?? []}
|
|
4953
|
+
onUpdate={(updated) => setProfile((p) => (p ? { ...p, addresses: updated } : p))}
|
|
4954
|
+
className="mb-8"
|
|
4955
|
+
/>
|
|
4954
4956
|
)}
|
|
4955
4957
|
|
|
4956
4958
|
{/* Order History */}
|
|
@@ -5039,9 +5041,11 @@ async function proxyRequest(
|
|
|
5039
5041
|
};
|
|
5040
5042
|
|
|
5041
5043
|
// Forward Origin/Referer so backend BrowserOriginGuard accepts proxied requests
|
|
5042
|
-
|
|
5044
|
+
// Always send Origin \u2014 same-origin GET requests may not include it, but the backend
|
|
5045
|
+
// uses its presence to distinguish fetch() calls from direct browser navigation
|
|
5046
|
+
const origin = request.headers.get('origin') || request.nextUrl.origin;
|
|
5043
5047
|
const referer = request.headers.get('referer');
|
|
5044
|
-
|
|
5048
|
+
headers['Origin'] = origin;
|
|
5045
5049
|
if (referer) headers['Referer'] = referer;
|
|
5046
5050
|
|
|
5047
5051
|
// Forward SDK version header if present
|
|
@@ -5220,7 +5224,7 @@ export async function GET(request: NextRequest) {
|
|
|
5220
5224
|
}
|
|
5221
5225
|
`,
|
|
5222
5226
|
"src/app/api/auth/me/route.ts": `import { NextResponse } from 'next/server';
|
|
5223
|
-
import { cookies } from 'next/headers';
|
|
5227
|
+
import { cookies, headers } from 'next/headers';
|
|
5224
5228
|
|
|
5225
5229
|
const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
|
|
5226
5230
|
/\\/$/,
|
|
@@ -5244,12 +5248,19 @@ export async function GET() {
|
|
|
5244
5248
|
return NextResponse.json({ isLoggedIn: false });
|
|
5245
5249
|
}
|
|
5246
5250
|
|
|
5251
|
+
// Derive Origin from the incoming request so the backend's BrowserOriginGuard accepts it
|
|
5252
|
+
const requestHeaders = await headers();
|
|
5253
|
+
const host = requestHeaders.get('host') || 'localhost:3000';
|
|
5254
|
+
const proto = requestHeaders.get('x-forwarded-proto') || 'http';
|
|
5255
|
+
const origin = requestHeaders.get('origin') || \`\${proto}://\${host}\`;
|
|
5256
|
+
|
|
5247
5257
|
try {
|
|
5248
5258
|
// Validate token by calling backend profile endpoint
|
|
5249
5259
|
const response = await fetch(\`\${BACKEND_URL}/api/vc/\${CONNECTION_ID}/customers/me\`, {
|
|
5250
5260
|
headers: {
|
|
5251
5261
|
Authorization: \`Bearer \${tokenCookie.value}\`,
|
|
5252
5262
|
'Content-Type': 'application/json',
|
|
5263
|
+
Origin: origin,
|
|
5253
5264
|
},
|
|
5254
5265
|
});
|
|
5255
5266
|
|
|
@@ -5283,6 +5294,126 @@ export async function POST() {
|
|
|
5283
5294
|
response.cookies.delete(LOGGED_IN_COOKIE);
|
|
5284
5295
|
return response;
|
|
5285
5296
|
}
|
|
5297
|
+
`,
|
|
5298
|
+
"src/app/api/auth/reset-callback/route.ts": `import { NextRequest, NextResponse } from 'next/server';
|
|
5299
|
+
|
|
5300
|
+
const RESET_TOKEN_COOKIE = 'brainerce_reset_token';
|
|
5301
|
+
const RESET_TOKEN_MAX_AGE = 10 * 60; // 10 minutes
|
|
5302
|
+
|
|
5303
|
+
function isSecure(): boolean {
|
|
5304
|
+
return process.env.NODE_ENV === 'production';
|
|
5305
|
+
}
|
|
5306
|
+
|
|
5307
|
+
/**
|
|
5308
|
+
* Password-reset callback handler.
|
|
5309
|
+
* The email link redirects here with ?token=... from the backend.
|
|
5310
|
+
* We store the token in an httpOnly cookie and redirect to /reset-password (clean URL).
|
|
5311
|
+
* This mirrors the OAuth callback pattern \u2014 the token never reaches client JS.
|
|
5312
|
+
*/
|
|
5313
|
+
export async function GET(request: NextRequest) {
|
|
5314
|
+
const { searchParams } = request.nextUrl;
|
|
5315
|
+
const token = searchParams.get('token');
|
|
5316
|
+
|
|
5317
|
+
if (!token) {
|
|
5318
|
+
const redirectUrl = new URL('/forgot-password', request.url);
|
|
5319
|
+
return NextResponse.redirect(redirectUrl);
|
|
5320
|
+
}
|
|
5321
|
+
|
|
5322
|
+
const redirectUrl = new URL('/reset-password', request.url);
|
|
5323
|
+
const response = NextResponse.redirect(redirectUrl);
|
|
5324
|
+
|
|
5325
|
+
// Set httpOnly cookie with the reset token (short-lived)
|
|
5326
|
+
response.cookies.set(RESET_TOKEN_COOKIE, token, {
|
|
5327
|
+
httpOnly: true,
|
|
5328
|
+
secure: isSecure(),
|
|
5329
|
+
sameSite: 'lax',
|
|
5330
|
+
path: '/',
|
|
5331
|
+
maxAge: RESET_TOKEN_MAX_AGE,
|
|
5332
|
+
});
|
|
5333
|
+
|
|
5334
|
+
// Prevent token leaking via Referer header
|
|
5335
|
+
response.headers.set('Referrer-Policy', 'no-referrer');
|
|
5336
|
+
|
|
5337
|
+
return response;
|
|
5338
|
+
}
|
|
5339
|
+
`,
|
|
5340
|
+
"src/app/api/auth/reset-password/route.ts": `import { NextRequest, NextResponse } from 'next/server';
|
|
5341
|
+
import { cookies, headers } from 'next/headers';
|
|
5342
|
+
|
|
5343
|
+
const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
|
|
5344
|
+
/\\/$/,
|
|
5345
|
+
''
|
|
5346
|
+
);
|
|
5347
|
+
|
|
5348
|
+
const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '';
|
|
5349
|
+
|
|
5350
|
+
const RESET_TOKEN_COOKIE = 'brainerce_reset_token';
|
|
5351
|
+
const CSRF_HEADER = 'x-requested-with';
|
|
5352
|
+
const CSRF_VALUE = 'brainerce';
|
|
5353
|
+
|
|
5354
|
+
/**
|
|
5355
|
+
* BFF endpoint for password reset.
|
|
5356
|
+
* Reads the reset token from the httpOnly cookie (set by /api/auth/reset-callback)
|
|
5357
|
+
* and proxies the request to the backend. The token never touches client JS.
|
|
5358
|
+
*/
|
|
5359
|
+
export async function POST(request: NextRequest) {
|
|
5360
|
+
// CSRF check
|
|
5361
|
+
const csrfHeader = request.headers.get(CSRF_HEADER);
|
|
5362
|
+
if (csrfHeader !== CSRF_VALUE) {
|
|
5363
|
+
return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 });
|
|
5364
|
+
}
|
|
5365
|
+
|
|
5366
|
+
// Read reset token from httpOnly cookie
|
|
5367
|
+
const cookieStore = await cookies();
|
|
5368
|
+
const resetTokenCookie = cookieStore.get(RESET_TOKEN_COOKIE);
|
|
5369
|
+
|
|
5370
|
+
if (!resetTokenCookie?.value) {
|
|
5371
|
+
return NextResponse.json(
|
|
5372
|
+
{ error: 'No reset token found. Please request a new password reset link.' },
|
|
5373
|
+
{ status: 400 }
|
|
5374
|
+
);
|
|
5375
|
+
}
|
|
5376
|
+
|
|
5377
|
+
// Parse request body
|
|
5378
|
+
const body = await request.json();
|
|
5379
|
+
const { newPassword } = body;
|
|
5380
|
+
|
|
5381
|
+
if (!newPassword) {
|
|
5382
|
+
return NextResponse.json({ error: 'New password is required' }, { status: 400 });
|
|
5383
|
+
}
|
|
5384
|
+
|
|
5385
|
+
// Derive Origin from the incoming request so the backend's BrowserOriginGuard accepts it
|
|
5386
|
+
const requestHeaders = await headers();
|
|
5387
|
+
const host = requestHeaders.get('host') || 'localhost:3000';
|
|
5388
|
+
const proto = requestHeaders.get('x-forwarded-proto') || 'http';
|
|
5389
|
+
const origin = requestHeaders.get('origin') || \`\${proto}://\${host}\`;
|
|
5390
|
+
|
|
5391
|
+
// Proxy to backend
|
|
5392
|
+
const backendUrl = \`\${BACKEND_URL}/api/vc/\${CONNECTION_ID}/customers/reset-password\`;
|
|
5393
|
+
|
|
5394
|
+
const backendResponse = await fetch(backendUrl, {
|
|
5395
|
+
method: 'POST',
|
|
5396
|
+
headers: {
|
|
5397
|
+
'Content-Type': 'application/json',
|
|
5398
|
+
Origin: origin,
|
|
5399
|
+
},
|
|
5400
|
+
body: JSON.stringify({
|
|
5401
|
+
token: resetTokenCookie.value,
|
|
5402
|
+
newPassword,
|
|
5403
|
+
}),
|
|
5404
|
+
});
|
|
5405
|
+
|
|
5406
|
+
const data = await backendResponse.json();
|
|
5407
|
+
|
|
5408
|
+
const response = NextResponse.json(data, {
|
|
5409
|
+
status: backendResponse.status,
|
|
5410
|
+
});
|
|
5411
|
+
|
|
5412
|
+
// Always clear the reset token cookie after use (success or failure)
|
|
5413
|
+
response.cookies.delete(RESET_TOKEN_COOKIE);
|
|
5414
|
+
|
|
5415
|
+
return response;
|
|
5416
|
+
}
|
|
5286
5417
|
`,
|
|
5287
5418
|
"src/middleware.ts": `import { NextRequest, NextResponse } from 'next/server';
|
|
5288
5419
|
|
|
@@ -5312,14 +5443,17 @@ export const config = {
|
|
|
5312
5443
|
`,
|
|
5313
5444
|
"src/components/products/product-card.tsx": `'use client';
|
|
5314
5445
|
|
|
5446
|
+
import { useState } from 'react';
|
|
5315
5447
|
import Link from 'next/link';
|
|
5448
|
+
import { useRouter } from 'next/navigation';
|
|
5316
5449
|
import Image from 'next/image';
|
|
5317
5450
|
import type { Product } from 'brainerce';
|
|
5318
|
-
import { getProductPriceInfo } from 'brainerce';
|
|
5451
|
+
import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
|
|
5319
5452
|
import { useTranslations } from '@/lib/translations';
|
|
5320
5453
|
import { PriceDisplay } from '@/components/shared/price-display';
|
|
5321
5454
|
import { StockBadge } from '@/components/products/stock-badge';
|
|
5322
5455
|
import { DiscountBadge } from '@/components/products/discount-badge';
|
|
5456
|
+
import { useCart, useStoreInfo } from '@/providers/store-provider';
|
|
5323
5457
|
import { cn } from '@/lib/utils';
|
|
5324
5458
|
|
|
5325
5459
|
interface ProductCardProps {
|
|
@@ -5327,60 +5461,167 @@ interface ProductCardProps {
|
|
|
5327
5461
|
className?: string;
|
|
5328
5462
|
}
|
|
5329
5463
|
|
|
5464
|
+
function VariantPriceRange({ product }: { product: Product }) {
|
|
5465
|
+
const { storeInfo } = useStoreInfo();
|
|
5466
|
+
const currency = storeInfo?.currency || 'USD';
|
|
5467
|
+
const variants = product.variants ?? [];
|
|
5468
|
+
if (variants.length === 0) return null;
|
|
5469
|
+
|
|
5470
|
+
const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
|
|
5471
|
+
const min = Math.min(...prices);
|
|
5472
|
+
const max = Math.max(...prices);
|
|
5473
|
+
|
|
5474
|
+
return (
|
|
5475
|
+
<span className="text-foreground text-sm font-medium">
|
|
5476
|
+
{min === max
|
|
5477
|
+
? (formatPrice(min, { currency }) as string)
|
|
5478
|
+
: \`\${formatPrice(min, { currency })} \u2013 \${formatPrice(max, { currency })}\`}
|
|
5479
|
+
</span>
|
|
5480
|
+
);
|
|
5481
|
+
}
|
|
5482
|
+
|
|
5330
5483
|
export function ProductCard({ product, className }: ProductCardProps) {
|
|
5331
5484
|
const t = useTranslations('common');
|
|
5332
5485
|
const tp = useTranslations('productDetail');
|
|
5486
|
+
const tProd = useTranslations('products');
|
|
5487
|
+
const router = useRouter();
|
|
5488
|
+
const { refreshCart } = useCart();
|
|
5333
5489
|
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
5334
5490
|
const mainImage = product.images?.[0];
|
|
5335
5491
|
const imageUrl = mainImage?.url || null;
|
|
5336
5492
|
const slug = product.slug || product.id;
|
|
5493
|
+
const isVariable = product.type === 'VARIABLE';
|
|
5494
|
+
|
|
5495
|
+
const [adding, setAdding] = useState(false);
|
|
5496
|
+
const [added, setAdded] = useState(false);
|
|
5497
|
+
|
|
5498
|
+
const canPurchase = product.inventory?.canPurchase !== false;
|
|
5499
|
+
|
|
5500
|
+
async function handleAddToCart(e: React.MouseEvent) {
|
|
5501
|
+
e.preventDefault();
|
|
5502
|
+
e.stopPropagation();
|
|
5503
|
+
|
|
5504
|
+
if (isVariable) {
|
|
5505
|
+
router.push(\`/products/\${slug}\`);
|
|
5506
|
+
return;
|
|
5507
|
+
}
|
|
5508
|
+
|
|
5509
|
+
if (adding || !canPurchase) return;
|
|
5510
|
+
|
|
5511
|
+
try {
|
|
5512
|
+
setAdding(true);
|
|
5513
|
+
const { getClient } = await import('@/lib/brainerce');
|
|
5514
|
+
const client = getClient();
|
|
5515
|
+
await client.smartAddToCart({ productId: product.id, quantity: 1 });
|
|
5516
|
+
await refreshCart();
|
|
5517
|
+
setAdded(true);
|
|
5518
|
+
setTimeout(() => setAdded(false), 2000);
|
|
5519
|
+
} catch (err) {
|
|
5520
|
+
console.error('Failed to add to cart:', err);
|
|
5521
|
+
} finally {
|
|
5522
|
+
setAdding(false);
|
|
5523
|
+
}
|
|
5524
|
+
}
|
|
5337
5525
|
|
|
5338
5526
|
return (
|
|
5339
|
-
<
|
|
5340
|
-
href={\`/products/\${slug}\`}
|
|
5527
|
+
<div
|
|
5341
5528
|
className={cn(
|
|
5342
5529
|
'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
|
|
5343
5530
|
className
|
|
5344
5531
|
)}
|
|
5345
5532
|
>
|
|
5346
|
-
{/* Image */}
|
|
5347
|
-
<
|
|
5348
|
-
|
|
5349
|
-
|
|
5350
|
-
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
<
|
|
5359
|
-
<
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5533
|
+
{/* Image \u2014 clickable */}
|
|
5534
|
+
<Link href={\`/products/\${slug}\`} className="block">
|
|
5535
|
+
<div className="bg-muted relative aspect-square overflow-hidden">
|
|
5536
|
+
{imageUrl ? (
|
|
5537
|
+
<Image
|
|
5538
|
+
src={imageUrl}
|
|
5539
|
+
alt={mainImage?.alt || product.name}
|
|
5540
|
+
fill
|
|
5541
|
+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
5542
|
+
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
|
5543
|
+
/>
|
|
5544
|
+
) : (
|
|
5545
|
+
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
5546
|
+
<svg className="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
5547
|
+
<path
|
|
5548
|
+
strokeLinecap="round"
|
|
5549
|
+
strokeLinejoin="round"
|
|
5550
|
+
strokeWidth={1.5}
|
|
5551
|
+
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"
|
|
5552
|
+
/>
|
|
5553
|
+
</svg>
|
|
5554
|
+
</div>
|
|
5555
|
+
)}
|
|
5556
|
+
|
|
5557
|
+
{/* Badges */}
|
|
5558
|
+
<div className="absolute start-2 top-2 flex flex-col gap-1">
|
|
5559
|
+
{isOnSale && (
|
|
5560
|
+
<span className="bg-destructive text-destructive-foreground rounded px-2 py-1 text-xs font-bold">
|
|
5561
|
+
{t('sale')}
|
|
5562
|
+
</span>
|
|
5563
|
+
)}
|
|
5564
|
+
<DiscountBadge discount={product.discount} />
|
|
5565
|
+
{product.isDownloadable && (
|
|
5566
|
+
<span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
|
|
5567
|
+
{tp('digitalProduct')}
|
|
5568
|
+
</span>
|
|
5569
|
+
)}
|
|
5366
5570
|
</div>
|
|
5367
|
-
)}
|
|
5368
5571
|
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
{
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
|
|
5380
|
-
|
|
5572
|
+
{/* Add to cart overlay button */}
|
|
5573
|
+
{(isVariable || canPurchase) && (
|
|
5574
|
+
<button
|
|
5575
|
+
onClick={handleAddToCart}
|
|
5576
|
+
disabled={adding}
|
|
5577
|
+
aria-label={isVariable ? tProd('selectOptions') : tp('addToCart')}
|
|
5578
|
+
className={cn(
|
|
5579
|
+
'absolute bottom-2 end-2 flex h-8 w-8 items-center justify-center rounded-full shadow-md transition-all',
|
|
5580
|
+
'translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100',
|
|
5581
|
+
added
|
|
5582
|
+
? 'bg-green-500 text-white'
|
|
5583
|
+
: 'bg-primary text-primary-foreground hover:opacity-90'
|
|
5584
|
+
)}
|
|
5585
|
+
>
|
|
5586
|
+
{added ? (
|
|
5587
|
+
<svg
|
|
5588
|
+
className="h-4 w-4"
|
|
5589
|
+
fill="none"
|
|
5590
|
+
viewBox="0 0 24 24"
|
|
5591
|
+
stroke="currentColor"
|
|
5592
|
+
strokeWidth={2.5}
|
|
5593
|
+
>
|
|
5594
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
5595
|
+
</svg>
|
|
5596
|
+
) : isVariable ? (
|
|
5597
|
+
<svg
|
|
5598
|
+
className="h-4 w-4"
|
|
5599
|
+
fill="none"
|
|
5600
|
+
viewBox="0 0 24 24"
|
|
5601
|
+
stroke="currentColor"
|
|
5602
|
+
strokeWidth={2}
|
|
5603
|
+
>
|
|
5604
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
|
5605
|
+
</svg>
|
|
5606
|
+
) : (
|
|
5607
|
+
<svg
|
|
5608
|
+
className="h-4 w-4"
|
|
5609
|
+
fill="none"
|
|
5610
|
+
viewBox="0 0 24 24"
|
|
5611
|
+
stroke="currentColor"
|
|
5612
|
+
strokeWidth={2}
|
|
5613
|
+
>
|
|
5614
|
+
<path
|
|
5615
|
+
strokeLinecap="round"
|
|
5616
|
+
strokeLinejoin="round"
|
|
5617
|
+
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"
|
|
5618
|
+
/>
|
|
5619
|
+
</svg>
|
|
5620
|
+
)}
|
|
5621
|
+
</button>
|
|
5381
5622
|
)}
|
|
5382
5623
|
</div>
|
|
5383
|
-
</
|
|
5624
|
+
</Link>
|
|
5384
5625
|
|
|
5385
5626
|
{/* Content */}
|
|
5386
5627
|
<div className="space-y-2 p-3">
|
|
@@ -5398,18 +5639,24 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
5398
5639
|
</div>
|
|
5399
5640
|
)}
|
|
5400
5641
|
|
|
5401
|
-
{/* Name */}
|
|
5402
|
-
<
|
|
5403
|
-
|
|
5404
|
-
|
|
5642
|
+
{/* Name \u2014 clickable */}
|
|
5643
|
+
<Link href={\`/products/\${slug}\`}>
|
|
5644
|
+
<h3 className="text-foreground hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
|
|
5645
|
+
{product.name}
|
|
5646
|
+
</h3>
|
|
5647
|
+
</Link>
|
|
5405
5648
|
|
|
5406
5649
|
{/* Price */}
|
|
5407
|
-
|
|
5650
|
+
{isVariable ? (
|
|
5651
|
+
<VariantPriceRange product={product} />
|
|
5652
|
+
) : (
|
|
5653
|
+
<PriceDisplay price={originalPrice} salePrice={isOnSale ? price : undefined} size="sm" />
|
|
5654
|
+
)}
|
|
5408
5655
|
|
|
5409
5656
|
{/* Stock */}
|
|
5410
5657
|
<StockBadge inventory={product.inventory} />
|
|
5411
5658
|
</div>
|
|
5412
|
-
</
|
|
5659
|
+
</div>
|
|
5413
5660
|
);
|
|
5414
5661
|
}
|
|
5415
5662
|
`,
|
|
@@ -5453,8 +5700,10 @@ export function ProductGrid({ products, className }: ProductGridProps) {
|
|
|
5453
5700
|
|
|
5454
5701
|
import { useMemo } from 'react';
|
|
5455
5702
|
import type { Product, ProductVariant } from 'brainerce';
|
|
5456
|
-
import { getVariantOptions,
|
|
5703
|
+
import { getVariantOptions, getProductSwatches, formatPrice } from 'brainerce';
|
|
5704
|
+
import type { InventoryInfo } from 'brainerce';
|
|
5457
5705
|
import { useStoreInfo } from '@/providers/store-provider';
|
|
5706
|
+
import { useTranslations } from '@/lib/translations';
|
|
5458
5707
|
import { cn } from '@/lib/utils';
|
|
5459
5708
|
|
|
5460
5709
|
interface VariantSelectorProps {
|
|
@@ -5466,8 +5715,12 @@ interface VariantSelectorProps {
|
|
|
5466
5715
|
|
|
5467
5716
|
interface AttributeGroup {
|
|
5468
5717
|
name: string;
|
|
5718
|
+
displayType: string;
|
|
5469
5719
|
values: Array<{
|
|
5470
5720
|
value: string;
|
|
5721
|
+
swatchColor?: string | null;
|
|
5722
|
+
swatchColor2?: string | null;
|
|
5723
|
+
swatchImageUrl?: string | null;
|
|
5471
5724
|
variants: ProductVariant[];
|
|
5472
5725
|
}>;
|
|
5473
5726
|
}
|
|
@@ -5479,10 +5732,49 @@ export function VariantSelector({
|
|
|
5479
5732
|
className,
|
|
5480
5733
|
}: VariantSelectorProps) {
|
|
5481
5734
|
const { storeInfo } = useStoreInfo();
|
|
5735
|
+
const t = useTranslations('productDetail');
|
|
5482
5736
|
const currency = storeInfo?.currency || 'USD';
|
|
5483
5737
|
const variants = useMemo(() => product.variants || [], [product.variants]);
|
|
5484
5738
|
|
|
5485
|
-
//
|
|
5739
|
+
// Get swatch metadata from product attribute options
|
|
5740
|
+
const swatchData = useMemo(() => getProductSwatches(product), [product]);
|
|
5741
|
+
const swatchMap = useMemo(() => {
|
|
5742
|
+
const map = new Map<
|
|
5743
|
+
string,
|
|
5744
|
+
{
|
|
5745
|
+
displayType: string;
|
|
5746
|
+
options: Map<
|
|
5747
|
+
string,
|
|
5748
|
+
{
|
|
5749
|
+
swatchColor?: string | null;
|
|
5750
|
+
swatchColor2?: string | null;
|
|
5751
|
+
swatchImageUrl?: string | null;
|
|
5752
|
+
}
|
|
5753
|
+
>;
|
|
5754
|
+
}
|
|
5755
|
+
>();
|
|
5756
|
+
for (const attr of swatchData) {
|
|
5757
|
+
const optMap = new Map<
|
|
5758
|
+
string,
|
|
5759
|
+
{
|
|
5760
|
+
swatchColor?: string | null;
|
|
5761
|
+
swatchColor2?: string | null;
|
|
5762
|
+
swatchImageUrl?: string | null;
|
|
5763
|
+
}
|
|
5764
|
+
>();
|
|
5765
|
+
for (const opt of attr.options) {
|
|
5766
|
+
optMap.set(opt.name, {
|
|
5767
|
+
swatchColor: opt.swatchColor,
|
|
5768
|
+
swatchColor2: opt.swatchColor2,
|
|
5769
|
+
swatchImageUrl: opt.swatchImageUrl,
|
|
5770
|
+
});
|
|
5771
|
+
}
|
|
5772
|
+
map.set(attr.attributeName, { displayType: attr.displayType, options: optMap });
|
|
5773
|
+
}
|
|
5774
|
+
return map;
|
|
5775
|
+
}, [swatchData]);
|
|
5776
|
+
|
|
5777
|
+
// Build attribute groups from variant data, enriched with swatch info
|
|
5486
5778
|
const attributeGroups = useMemo<AttributeGroup[]>(() => {
|
|
5487
5779
|
const groups = new Map<string, Map<string, ProductVariant[]>>();
|
|
5488
5780
|
|
|
@@ -5500,14 +5792,24 @@ export function VariantSelector({
|
|
|
5500
5792
|
}
|
|
5501
5793
|
}
|
|
5502
5794
|
|
|
5503
|
-
return Array.from(groups.entries()).map(([name, valuesMap]) =>
|
|
5504
|
-
name
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5795
|
+
return Array.from(groups.entries()).map(([name, valuesMap]) => {
|
|
5796
|
+
const attrSwatch = swatchMap.get(name);
|
|
5797
|
+
return {
|
|
5798
|
+
name,
|
|
5799
|
+
displayType: attrSwatch?.displayType || 'DROPDOWN',
|
|
5800
|
+
values: Array.from(valuesMap.entries()).map(([value, variantList]) => {
|
|
5801
|
+
const optSwatch = attrSwatch?.options.get(value);
|
|
5802
|
+
return {
|
|
5803
|
+
value,
|
|
5804
|
+
swatchColor: optSwatch?.swatchColor,
|
|
5805
|
+
swatchColor2: optSwatch?.swatchColor2,
|
|
5806
|
+
swatchImageUrl: optSwatch?.swatchImageUrl,
|
|
5807
|
+
variants: variantList,
|
|
5808
|
+
};
|
|
5809
|
+
}),
|
|
5810
|
+
};
|
|
5811
|
+
});
|
|
5812
|
+
}, [variants, swatchMap]);
|
|
5511
5813
|
|
|
5512
5814
|
// Get currently selected attribute values
|
|
5513
5815
|
const selectedOptions = useMemo(() => {
|
|
@@ -5547,33 +5849,111 @@ export function VariantSelector({
|
|
|
5547
5849
|
)}
|
|
5548
5850
|
</label>
|
|
5549
5851
|
<div className="flex flex-wrap gap-2">
|
|
5550
|
-
{group.values.map(
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5852
|
+
{group.values.map(
|
|
5853
|
+
({
|
|
5854
|
+
value,
|
|
5855
|
+
swatchColor,
|
|
5856
|
+
swatchColor2,
|
|
5857
|
+
swatchImageUrl,
|
|
5858
|
+
variants: matchingVariants,
|
|
5859
|
+
}) => {
|
|
5860
|
+
const isSelected = selectedOptions.get(group.name) === value;
|
|
5861
|
+
const matchedVariant = findMatchingVariant(group.name, value);
|
|
5862
|
+
const isAvailable = matchedVariant?.inventory?.canPurchase !== false;
|
|
5863
|
+
|
|
5864
|
+
// Color swatch rendering
|
|
5865
|
+
if (group.displayType === 'COLOR_SWATCH' && swatchColor) {
|
|
5866
|
+
return (
|
|
5867
|
+
<button
|
|
5868
|
+
key={value}
|
|
5869
|
+
type="button"
|
|
5870
|
+
disabled={!isAvailable}
|
|
5871
|
+
title={value}
|
|
5872
|
+
onClick={() => {
|
|
5873
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
5874
|
+
if (variant) onVariantChange(variant);
|
|
5875
|
+
}}
|
|
5876
|
+
className={cn(
|
|
5877
|
+
'h-9 w-9 rounded-full border-2 transition-all',
|
|
5878
|
+
isSelected
|
|
5879
|
+
? 'border-primary ring-primary/30 ring-2'
|
|
5880
|
+
: isAvailable
|
|
5881
|
+
? 'border-border hover:border-primary'
|
|
5882
|
+
: 'cursor-not-allowed opacity-40'
|
|
5883
|
+
)}
|
|
5884
|
+
style={{
|
|
5885
|
+
background: swatchColor2
|
|
5886
|
+
? \`linear-gradient(135deg, \${swatchColor} 50%, \${swatchColor2} 50%)\`
|
|
5887
|
+
: swatchColor,
|
|
5888
|
+
}}
|
|
5889
|
+
>
|
|
5890
|
+
{!isAvailable && (
|
|
5891
|
+
<span
|
|
5892
|
+
className="bg-muted-foreground block h-full w-full rounded-full opacity-50"
|
|
5893
|
+
style={{
|
|
5894
|
+
backgroundImage:
|
|
5895
|
+
'linear-gradient(135deg, transparent 45%, currentColor 45%, currentColor 55%, transparent 55%)',
|
|
5896
|
+
}}
|
|
5897
|
+
/>
|
|
5898
|
+
)}
|
|
5899
|
+
</button>
|
|
5900
|
+
);
|
|
5901
|
+
}
|
|
5554
5902
|
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
|
|
5903
|
+
// Image swatch rendering
|
|
5904
|
+
if (group.displayType === 'IMAGE_SWATCH' && swatchImageUrl) {
|
|
5905
|
+
return (
|
|
5906
|
+
<button
|
|
5907
|
+
key={value}
|
|
5908
|
+
type="button"
|
|
5909
|
+
disabled={!isAvailable}
|
|
5910
|
+
title={value}
|
|
5911
|
+
onClick={() => {
|
|
5912
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
5913
|
+
if (variant) onVariantChange(variant);
|
|
5914
|
+
}}
|
|
5915
|
+
className={cn(
|
|
5916
|
+
'h-10 w-10 overflow-hidden rounded-lg border-2 transition-all',
|
|
5917
|
+
isSelected
|
|
5918
|
+
? 'border-primary ring-primary/30 ring-2'
|
|
5919
|
+
: isAvailable
|
|
5920
|
+
? 'border-border hover:border-primary'
|
|
5921
|
+
: 'cursor-not-allowed opacity-40'
|
|
5922
|
+
)}
|
|
5923
|
+
>
|
|
5924
|
+
<img
|
|
5925
|
+
src={swatchImageUrl}
|
|
5926
|
+
alt={value}
|
|
5927
|
+
className="h-full w-full object-cover"
|
|
5928
|
+
/>
|
|
5929
|
+
</button>
|
|
5930
|
+
);
|
|
5931
|
+
}
|
|
5932
|
+
|
|
5933
|
+
// Default button rendering (BUTTON, DROPDOWN, or fallback)
|
|
5934
|
+
return (
|
|
5935
|
+
<button
|
|
5936
|
+
key={value}
|
|
5937
|
+
type="button"
|
|
5938
|
+
disabled={!isAvailable}
|
|
5939
|
+
onClick={() => {
|
|
5940
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
5941
|
+
if (variant) onVariantChange(variant);
|
|
5942
|
+
}}
|
|
5943
|
+
className={cn(
|
|
5944
|
+
'rounded border px-4 py-2 text-sm transition-colors',
|
|
5945
|
+
isSelected
|
|
5946
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
5947
|
+
: isAvailable
|
|
5948
|
+
? 'border-border bg-background text-foreground hover:border-primary'
|
|
5949
|
+
: 'border-border bg-muted text-muted-foreground cursor-not-allowed line-through opacity-50'
|
|
5950
|
+
)}
|
|
5951
|
+
>
|
|
5952
|
+
{value}
|
|
5953
|
+
</button>
|
|
5954
|
+
);
|
|
5955
|
+
}
|
|
5956
|
+
)}
|
|
5577
5957
|
</div>
|
|
5578
5958
|
</div>
|
|
5579
5959
|
))}
|
|
@@ -5590,18 +5970,30 @@ export function VariantSelector({
|
|
|
5590
5970
|
}
|
|
5591
5971
|
</span>
|
|
5592
5972
|
)}
|
|
5593
|
-
<span>{
|
|
5973
|
+
<span>{getTranslatedStockStatus(selectedVariant.inventory, t)}</span>
|
|
5594
5974
|
</div>
|
|
5595
5975
|
)}
|
|
5596
5976
|
</div>
|
|
5597
5977
|
);
|
|
5598
5978
|
}
|
|
5979
|
+
|
|
5980
|
+
function getTranslatedStockStatus(
|
|
5981
|
+
inventory: InventoryInfo | null | undefined,
|
|
5982
|
+
t: (key: string) => string
|
|
5983
|
+
): string {
|
|
5984
|
+
if (!inventory) return t('outOfStock');
|
|
5985
|
+
const { trackingMode, inStock, available } = inventory;
|
|
5986
|
+
if (trackingMode === 'DISABLED') return t('unavailable');
|
|
5987
|
+
if (!inStock) return t('outOfStock');
|
|
5988
|
+
if (trackingMode === 'UNLIMITED') return t('inStock');
|
|
5989
|
+
return t('availableInStock').replace('{available}', String(available));
|
|
5990
|
+
}
|
|
5599
5991
|
`,
|
|
5600
5992
|
"src/components/products/stock-badge.tsx": `'use client';
|
|
5601
5993
|
|
|
5602
|
-
import { getStockStatus } from 'brainerce';
|
|
5603
5994
|
import type { InventoryInfo } from 'brainerce';
|
|
5604
5995
|
import { cn } from '@/lib/utils';
|
|
5996
|
+
import { useTranslations } from '@/lib/translations';
|
|
5605
5997
|
|
|
5606
5998
|
interface StockBadgeProps {
|
|
5607
5999
|
inventory: InventoryInfo | null | undefined;
|
|
@@ -5610,27 +6002,56 @@ interface StockBadgeProps {
|
|
|
5610
6002
|
}
|
|
5611
6003
|
|
|
5612
6004
|
export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
|
|
5613
|
-
const
|
|
5614
|
-
|
|
5615
|
-
const
|
|
5616
|
-
status === 'Out of Stock' || status === 'Unavailable'
|
|
5617
|
-
? 'bg-red-100 text-red-800'
|
|
5618
|
-
: status === 'Low Stock'
|
|
5619
|
-
? 'bg-yellow-100 text-yellow-800'
|
|
5620
|
-
: 'bg-green-100 text-green-800';
|
|
6005
|
+
const t = useTranslations('productDetail');
|
|
6006
|
+
const label = getStockLabel(inventory, lowStockThreshold, t);
|
|
6007
|
+
const color = getStockColor(inventory, lowStockThreshold);
|
|
5621
6008
|
|
|
5622
6009
|
return (
|
|
5623
6010
|
<span
|
|
5624
6011
|
className={cn(
|
|
5625
6012
|
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
|
5626
|
-
|
|
6013
|
+
color,
|
|
5627
6014
|
className
|
|
5628
6015
|
)}
|
|
5629
6016
|
>
|
|
5630
|
-
{
|
|
6017
|
+
{label}
|
|
5631
6018
|
</span>
|
|
5632
6019
|
);
|
|
5633
6020
|
}
|
|
6021
|
+
|
|
6022
|
+
function getStockLabel(
|
|
6023
|
+
inventory: InventoryInfo | null | undefined,
|
|
6024
|
+
lowStockThreshold: number,
|
|
6025
|
+
t: (key: string) => string
|
|
6026
|
+
): string {
|
|
6027
|
+
if (!inventory) return t('outOfStock');
|
|
6028
|
+
|
|
6029
|
+
const { trackingMode, inStock, available } = inventory;
|
|
6030
|
+
|
|
6031
|
+
if (trackingMode === 'DISABLED') return t('unavailable');
|
|
6032
|
+
if (!inStock) return t('outOfStock');
|
|
6033
|
+
if (trackingMode === 'UNLIMITED') return t('inStock');
|
|
6034
|
+
|
|
6035
|
+
// TRACKED \u2014 show actual quantity
|
|
6036
|
+
if (available <= lowStockThreshold) {
|
|
6037
|
+
return t('onlyLeft').replace('{available}', String(available));
|
|
6038
|
+
}
|
|
6039
|
+
return t('availableInStock').replace('{available}', String(available));
|
|
6040
|
+
}
|
|
6041
|
+
|
|
6042
|
+
function getStockColor(
|
|
6043
|
+
inventory: InventoryInfo | null | undefined,
|
|
6044
|
+
lowStockThreshold: number
|
|
6045
|
+
): string {
|
|
6046
|
+
if (!inventory) return 'bg-red-100 text-red-800';
|
|
6047
|
+
|
|
6048
|
+
const { trackingMode, inStock, available } = inventory;
|
|
6049
|
+
|
|
6050
|
+
if (trackingMode === 'DISABLED' || !inStock) return 'bg-red-100 text-red-800';
|
|
6051
|
+
if (trackingMode === 'TRACKED' && available <= lowStockThreshold)
|
|
6052
|
+
return 'bg-yellow-100 text-yellow-800';
|
|
6053
|
+
return 'bg-green-100 text-green-800';
|
|
6054
|
+
}
|
|
5634
6055
|
`,
|
|
5635
6056
|
"src/components/products/discount-badge.tsx": `import type { ProductDiscount } from 'brainerce';
|
|
5636
6057
|
import { cn } from '@/lib/utils';
|
|
@@ -5660,7 +6081,7 @@ export function DiscountBadge({ discount, className }: DiscountBadgeProps) {
|
|
|
5660
6081
|
import { useState } from 'react';
|
|
5661
6082
|
import Image from 'next/image';
|
|
5662
6083
|
import type { CartItem as CartItemType } from 'brainerce';
|
|
5663
|
-
import {
|
|
6084
|
+
import { getCartItemImage, formatPrice } from 'brainerce';
|
|
5664
6085
|
import { getClient } from '@/lib/brainerce';
|
|
5665
6086
|
import { useTranslations } from '@/lib/translations';
|
|
5666
6087
|
import { useStoreInfo } from '@/providers/store-provider';
|
|
@@ -5681,7 +6102,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
5681
6102
|
const [updating, setUpdating] = useState(false);
|
|
5682
6103
|
const [removing, setRemoving] = useState(false);
|
|
5683
6104
|
|
|
5684
|
-
const
|
|
6105
|
+
const productName = item.product.name;
|
|
5685
6106
|
const imageUrl = getCartItemImage(item);
|
|
5686
6107
|
const variantName = item.variant?.name;
|
|
5687
6108
|
const unitPrice = parseFloat(item.unitPrice);
|
|
@@ -5728,7 +6149,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
5728
6149
|
{/* Image */}
|
|
5729
6150
|
<div className="bg-muted relative h-20 w-20 flex-shrink-0 overflow-hidden rounded">
|
|
5730
6151
|
{imageUrl ? (
|
|
5731
|
-
<Image src={imageUrl} alt={
|
|
6152
|
+
<Image src={imageUrl} alt={productName} fill sizes="80px" className="object-cover" />
|
|
5732
6153
|
) : (
|
|
5733
6154
|
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
5734
6155
|
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
@@ -5745,7 +6166,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
5745
6166
|
|
|
5746
6167
|
{/* Details */}
|
|
5747
6168
|
<div className="min-w-0 flex-1">
|
|
5748
|
-
<h3 className="text-foreground truncate text-sm font-medium">{
|
|
6169
|
+
<h3 className="text-foreground truncate text-sm font-medium">{productName}</h3>
|
|
5749
6170
|
|
|
5750
6171
|
{/* Variant name */}
|
|
5751
6172
|
{variantName && <p className="text-muted-foreground mt-1 text-xs">{variantName}</p>}
|
|
@@ -6199,17 +6620,24 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
|
|
|
6199
6620
|
`,
|
|
6200
6621
|
"src/components/checkout/checkout-form.tsx": `'use client';
|
|
6201
6622
|
|
|
6202
|
-
import { useState } from 'react';
|
|
6623
|
+
import { useState, useEffect, useRef } from 'react';
|
|
6203
6624
|
import type { SetShippingAddressDto, ShippingDestinations } from 'brainerce';
|
|
6204
6625
|
import { useTranslations } from '@/lib/translations';
|
|
6205
6626
|
import { cn } from '@/lib/utils';
|
|
6206
6627
|
|
|
6628
|
+
interface CheckoutConsent {
|
|
6629
|
+
acceptsMarketing: boolean;
|
|
6630
|
+
saveDetails: boolean;
|
|
6631
|
+
}
|
|
6632
|
+
|
|
6207
6633
|
interface CheckoutFormProps {
|
|
6208
|
-
onSubmit: (address: SetShippingAddressDto) => void;
|
|
6634
|
+
onSubmit: (address: SetShippingAddressDto, consent: CheckoutConsent) => void;
|
|
6209
6635
|
loading?: boolean;
|
|
6210
6636
|
initialValues?: Partial<SetShippingAddressDto>;
|
|
6211
6637
|
destinations?: ShippingDestinations | null;
|
|
6212
6638
|
className?: string;
|
|
6639
|
+
showSaveDetails?: boolean;
|
|
6640
|
+
emailOnly?: boolean;
|
|
6213
6641
|
}
|
|
6214
6642
|
|
|
6215
6643
|
export function CheckoutForm({
|
|
@@ -6218,6 +6646,8 @@ export function CheckoutForm({
|
|
|
6218
6646
|
initialValues,
|
|
6219
6647
|
destinations,
|
|
6220
6648
|
className,
|
|
6649
|
+
showSaveDetails = false,
|
|
6650
|
+
emailOnly = false,
|
|
6221
6651
|
}: CheckoutFormProps) {
|
|
6222
6652
|
const [formData, setFormData] = useState<SetShippingAddressDto>({
|
|
6223
6653
|
email: initialValues?.email || '',
|
|
@@ -6232,8 +6662,30 @@ export function CheckoutForm({
|
|
|
6232
6662
|
phone: initialValues?.phone || '',
|
|
6233
6663
|
});
|
|
6234
6664
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
6665
|
+
const [privacyAccepted, setPrivacyAccepted] = useState(false);
|
|
6666
|
+
const [acceptsMarketing, setAcceptsMarketing] = useState(false);
|
|
6667
|
+
const [saveDetails, setSaveDetails] = useState(true);
|
|
6235
6668
|
const t = useTranslations('checkoutForm');
|
|
6236
6669
|
const tc = useTranslations('common');
|
|
6670
|
+
const hasAppliedPrefill = useRef(!!initialValues);
|
|
6671
|
+
|
|
6672
|
+
// Sync prefill data when it arrives async (e.g. from getCheckoutPrefillData)
|
|
6673
|
+
useEffect(() => {
|
|
6674
|
+
if (!initialValues || hasAppliedPrefill.current) return;
|
|
6675
|
+
hasAppliedPrefill.current = true;
|
|
6676
|
+
setFormData((prev) => ({
|
|
6677
|
+
email: initialValues.email || prev.email,
|
|
6678
|
+
firstName: initialValues.firstName || prev.firstName,
|
|
6679
|
+
lastName: initialValues.lastName || prev.lastName,
|
|
6680
|
+
line1: initialValues.line1 || prev.line1,
|
|
6681
|
+
line2: initialValues.line2 || prev.line2 || '',
|
|
6682
|
+
city: initialValues.city || prev.city,
|
|
6683
|
+
region: initialValues.region || prev.region || '',
|
|
6684
|
+
postalCode: initialValues.postalCode || prev.postalCode,
|
|
6685
|
+
country: initialValues.country || prev.country,
|
|
6686
|
+
phone: initialValues.phone || prev.phone || '',
|
|
6687
|
+
}));
|
|
6688
|
+
}, [initialValues]);
|
|
6237
6689
|
|
|
6238
6690
|
const hasCountryOptions = destinations && destinations.countries.length > 0;
|
|
6239
6691
|
const countryRegions = destinations?.regions[formData.country];
|
|
@@ -6254,17 +6706,22 @@ export function CheckoutForm({
|
|
|
6254
6706
|
if (!formData.lastName.trim()) {
|
|
6255
6707
|
newErrors.lastName = t('lastNameRequired');
|
|
6256
6708
|
}
|
|
6257
|
-
if (!
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
|
|
6263
|
-
|
|
6264
|
-
|
|
6709
|
+
if (!emailOnly) {
|
|
6710
|
+
if (!formData.line1.trim()) {
|
|
6711
|
+
newErrors.line1 = t('addressRequired');
|
|
6712
|
+
}
|
|
6713
|
+
if (!formData.city.trim()) {
|
|
6714
|
+
newErrors.city = t('cityRequired');
|
|
6715
|
+
}
|
|
6716
|
+
if (!formData.postalCode.trim()) {
|
|
6717
|
+
newErrors.postalCode = t('postalCodeRequired');
|
|
6718
|
+
}
|
|
6719
|
+
if (!formData.country.trim()) {
|
|
6720
|
+
newErrors.country = t('countryRequired');
|
|
6721
|
+
}
|
|
6265
6722
|
}
|
|
6266
|
-
if (!
|
|
6267
|
-
newErrors.
|
|
6723
|
+
if (!privacyAccepted) {
|
|
6724
|
+
newErrors.privacy = t('privacyRequired');
|
|
6268
6725
|
}
|
|
6269
6726
|
|
|
6270
6727
|
setErrors(newErrors);
|
|
@@ -6274,7 +6731,7 @@ export function CheckoutForm({
|
|
|
6274
6731
|
function handleSubmit(e: React.FormEvent) {
|
|
6275
6732
|
e.preventDefault();
|
|
6276
6733
|
if (validate()) {
|
|
6277
|
-
onSubmit(formData);
|
|
6734
|
+
onSubmit(formData, { acceptsMarketing, saveDetails: showSaveDetails && saveDetails });
|
|
6278
6735
|
}
|
|
6279
6736
|
}
|
|
6280
6737
|
|
|
@@ -6350,154 +6807,228 @@ export function CheckoutForm({
|
|
|
6350
6807
|
</div>
|
|
6351
6808
|
</div>
|
|
6352
6809
|
|
|
6353
|
-
{
|
|
6354
|
-
|
|
6355
|
-
|
|
6356
|
-
<
|
|
6357
|
-
|
|
6358
|
-
|
|
6359
|
-
|
|
6360
|
-
|
|
6361
|
-
|
|
6362
|
-
|
|
6363
|
-
|
|
6364
|
-
|
|
6365
|
-
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
|
|
6370
|
-
|
|
6371
|
-
|
|
6372
|
-
|
|
6373
|
-
|
|
6810
|
+
{!emailOnly && (
|
|
6811
|
+
<>
|
|
6812
|
+
{/* Country + Region row */}
|
|
6813
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
6814
|
+
<div>
|
|
6815
|
+
<label htmlFor="country" className="text-foreground mb-1 block text-sm font-medium">
|
|
6816
|
+
{t('country')} <span className="text-destructive">*</span>
|
|
6817
|
+
</label>
|
|
6818
|
+
{hasCountryOptions ? (
|
|
6819
|
+
<select
|
|
6820
|
+
id="country"
|
|
6821
|
+
value={formData.country}
|
|
6822
|
+
onChange={(e) => updateField('country', e.target.value)}
|
|
6823
|
+
className={cn(
|
|
6824
|
+
selectClass,
|
|
6825
|
+
errors.country ? 'border-destructive' : 'border-border'
|
|
6826
|
+
)}
|
|
6827
|
+
>
|
|
6828
|
+
<option value="">{t('selectCountry')}</option>
|
|
6829
|
+
{destinations.countries.map((c) => (
|
|
6830
|
+
<option key={c.code} value={c.code}>
|
|
6831
|
+
{c.name}
|
|
6832
|
+
</option>
|
|
6833
|
+
))}
|
|
6834
|
+
</select>
|
|
6835
|
+
) : (
|
|
6836
|
+
<input
|
|
6837
|
+
id="country"
|
|
6838
|
+
type="text"
|
|
6839
|
+
value={formData.country}
|
|
6840
|
+
onChange={(e) => updateField('country', e.target.value)}
|
|
6841
|
+
className={cn(
|
|
6842
|
+
inputClass,
|
|
6843
|
+
errors.country ? 'border-destructive' : 'border-border'
|
|
6844
|
+
)}
|
|
6845
|
+
placeholder={t('countryPlaceholder')}
|
|
6846
|
+
/>
|
|
6847
|
+
)}
|
|
6848
|
+
{errors.country && <p className="text-destructive mt-1 text-xs">{errors.country}</p>}
|
|
6849
|
+
</div>
|
|
6850
|
+
|
|
6851
|
+
<div>
|
|
6852
|
+
<label htmlFor="region" className="text-foreground mb-1 block text-sm font-medium">
|
|
6853
|
+
{t('stateRegion')}
|
|
6854
|
+
</label>
|
|
6855
|
+
{hasRegionOptions ? (
|
|
6856
|
+
<select
|
|
6857
|
+
id="region"
|
|
6858
|
+
value={formData.region || ''}
|
|
6859
|
+
onChange={(e) => updateField('region', e.target.value)}
|
|
6860
|
+
className={cn(selectClass, 'border-border')}
|
|
6861
|
+
>
|
|
6862
|
+
<option value="">{t('selectRegion')}</option>
|
|
6863
|
+
{countryRegions.map((r) => (
|
|
6864
|
+
<option key={r.code} value={r.code}>
|
|
6865
|
+
{r.name}
|
|
6866
|
+
</option>
|
|
6867
|
+
))}
|
|
6868
|
+
</select>
|
|
6869
|
+
) : (
|
|
6870
|
+
<input
|
|
6871
|
+
id="region"
|
|
6872
|
+
type="text"
|
|
6873
|
+
value={formData.region || ''}
|
|
6874
|
+
onChange={(e) => updateField('region', e.target.value)}
|
|
6875
|
+
className={cn(inputClass, 'border-border')}
|
|
6876
|
+
/>
|
|
6877
|
+
)}
|
|
6878
|
+
</div>
|
|
6879
|
+
</div>
|
|
6880
|
+
|
|
6881
|
+
{/* Address line 1 */}
|
|
6882
|
+
<div>
|
|
6883
|
+
<label htmlFor="line1" className="text-foreground mb-1 block text-sm font-medium">
|
|
6884
|
+
{t('address')} <span className="text-destructive">*</span>
|
|
6885
|
+
</label>
|
|
6374
6886
|
<input
|
|
6375
|
-
id="
|
|
6887
|
+
id="line1"
|
|
6376
6888
|
type="text"
|
|
6377
|
-
value={formData.
|
|
6378
|
-
onChange={(e) => updateField('
|
|
6379
|
-
className={cn(inputClass, errors.
|
|
6380
|
-
placeholder={t('
|
|
6889
|
+
value={formData.line1}
|
|
6890
|
+
onChange={(e) => updateField('line1', e.target.value)}
|
|
6891
|
+
className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
|
|
6892
|
+
placeholder={t('streetAddress')}
|
|
6381
6893
|
/>
|
|
6382
|
-
|
|
6383
|
-
|
|
6384
|
-
</div>
|
|
6894
|
+
{errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
|
|
6895
|
+
</div>
|
|
6385
6896
|
|
|
6386
|
-
|
|
6387
|
-
<
|
|
6388
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
<select
|
|
6392
|
-
id="region"
|
|
6393
|
-
value={formData.region || ''}
|
|
6394
|
-
onChange={(e) => updateField('region', e.target.value)}
|
|
6395
|
-
className={cn(selectClass, 'border-border')}
|
|
6396
|
-
>
|
|
6397
|
-
<option value="">{t('selectRegion')}</option>
|
|
6398
|
-
{countryRegions.map((r) => (
|
|
6399
|
-
<option key={r.code} value={r.code}>
|
|
6400
|
-
{r.name}
|
|
6401
|
-
</option>
|
|
6402
|
-
))}
|
|
6403
|
-
</select>
|
|
6404
|
-
) : (
|
|
6897
|
+
{/* Address line 2 */}
|
|
6898
|
+
<div>
|
|
6899
|
+
<label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
|
|
6900
|
+
{t('apartmentSuite')}
|
|
6901
|
+
</label>
|
|
6405
6902
|
<input
|
|
6406
|
-
id="
|
|
6903
|
+
id="line2"
|
|
6407
6904
|
type="text"
|
|
6408
|
-
value={formData.
|
|
6409
|
-
onChange={(e) => updateField('
|
|
6905
|
+
value={formData.line2 || ''}
|
|
6906
|
+
onChange={(e) => updateField('line2', e.target.value)}
|
|
6410
6907
|
className={cn(inputClass, 'border-border')}
|
|
6908
|
+
placeholder={t('aptPlaceholder')}
|
|
6411
6909
|
/>
|
|
6412
|
-
|
|
6413
|
-
|
|
6414
|
-
|
|
6910
|
+
</div>
|
|
6911
|
+
|
|
6912
|
+
{/* City + Postal code row */}
|
|
6913
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
6914
|
+
<div>
|
|
6915
|
+
<label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
|
|
6916
|
+
{t('city')} <span className="text-destructive">*</span>
|
|
6917
|
+
</label>
|
|
6918
|
+
<input
|
|
6919
|
+
id="city"
|
|
6920
|
+
type="text"
|
|
6921
|
+
value={formData.city}
|
|
6922
|
+
onChange={(e) => updateField('city', e.target.value)}
|
|
6923
|
+
className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')}
|
|
6924
|
+
/>
|
|
6925
|
+
{errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
|
|
6926
|
+
</div>
|
|
6927
|
+
|
|
6928
|
+
<div>
|
|
6929
|
+
<label
|
|
6930
|
+
htmlFor="postalCode"
|
|
6931
|
+
className="text-foreground mb-1 block text-sm font-medium"
|
|
6932
|
+
>
|
|
6933
|
+
{t('postalCode')} <span className="text-destructive">*</span>
|
|
6934
|
+
</label>
|
|
6935
|
+
<input
|
|
6936
|
+
id="postalCode"
|
|
6937
|
+
type="text"
|
|
6938
|
+
value={formData.postalCode}
|
|
6939
|
+
onChange={(e) => updateField('postalCode', e.target.value)}
|
|
6940
|
+
className={cn(
|
|
6941
|
+
inputClass,
|
|
6942
|
+
errors.postalCode ? 'border-destructive' : 'border-border'
|
|
6943
|
+
)}
|
|
6944
|
+
/>
|
|
6945
|
+
{errors.postalCode && (
|
|
6946
|
+
<p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
|
|
6947
|
+
)}
|
|
6948
|
+
</div>
|
|
6949
|
+
</div>
|
|
6950
|
+
|
|
6951
|
+
{/* Phone */}
|
|
6952
|
+
<div>
|
|
6953
|
+
<label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
|
|
6954
|
+
{t('phone')}
|
|
6955
|
+
</label>
|
|
6956
|
+
<input
|
|
6957
|
+
id="phone"
|
|
6958
|
+
type="tel"
|
|
6959
|
+
value={formData.phone || ''}
|
|
6960
|
+
onChange={(e) => updateField('phone', e.target.value)}
|
|
6961
|
+
className={cn(inputClass, 'border-border')}
|
|
6962
|
+
placeholder={t('phonePlaceholder')}
|
|
6963
|
+
/>
|
|
6964
|
+
</div>
|
|
6965
|
+
</>
|
|
6966
|
+
)}
|
|
6415
6967
|
|
|
6416
|
-
{/*
|
|
6968
|
+
{/* Privacy Policy (required) */}
|
|
6417
6969
|
<div>
|
|
6418
|
-
<label
|
|
6419
|
-
|
|
6970
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
6971
|
+
<input
|
|
6972
|
+
type="checkbox"
|
|
6973
|
+
checked={privacyAccepted}
|
|
6974
|
+
onChange={(e) => {
|
|
6975
|
+
setPrivacyAccepted(e.target.checked);
|
|
6976
|
+
if (e.target.checked && errors.privacy) {
|
|
6977
|
+
setErrors((prev) => {
|
|
6978
|
+
const next = { ...prev };
|
|
6979
|
+
delete next.privacy;
|
|
6980
|
+
return next;
|
|
6981
|
+
});
|
|
6982
|
+
}
|
|
6983
|
+
}}
|
|
6984
|
+
className="accent-primary mt-0.5"
|
|
6985
|
+
/>
|
|
6986
|
+
<span className="text-muted-foreground text-sm">
|
|
6987
|
+
{t('privacyAcceptPrefix')}{' '}
|
|
6988
|
+
<a
|
|
6989
|
+
href="/privacy"
|
|
6990
|
+
target="_blank"
|
|
6991
|
+
rel="noopener noreferrer"
|
|
6992
|
+
className="text-primary underline underline-offset-2"
|
|
6993
|
+
>
|
|
6994
|
+
{t('privacyPolicyLink')}
|
|
6995
|
+
</a>{' '}
|
|
6996
|
+
<span className="text-destructive">*</span>
|
|
6997
|
+
</span>
|
|
6420
6998
|
</label>
|
|
6421
|
-
<
|
|
6422
|
-
id="line1"
|
|
6423
|
-
type="text"
|
|
6424
|
-
value={formData.line1}
|
|
6425
|
-
onChange={(e) => updateField('line1', e.target.value)}
|
|
6426
|
-
className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
|
|
6427
|
-
placeholder={t('streetAddress')}
|
|
6428
|
-
/>
|
|
6429
|
-
{errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
|
|
6999
|
+
{errors.privacy && <p className="text-destructive mt-1 text-xs">{errors.privacy}</p>}
|
|
6430
7000
|
</div>
|
|
6431
7001
|
|
|
6432
|
-
{/*
|
|
6433
|
-
<
|
|
6434
|
-
<label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
|
|
6435
|
-
{t('apartmentSuite')}
|
|
6436
|
-
</label>
|
|
7002
|
+
{/* Marketing consent (optional) */}
|
|
7003
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
6437
7004
|
<input
|
|
6438
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
className={cn(inputClass, 'border-border')}
|
|
6443
|
-
placeholder={t('aptPlaceholder')}
|
|
7005
|
+
type="checkbox"
|
|
7006
|
+
checked={acceptsMarketing}
|
|
7007
|
+
onChange={(e) => setAcceptsMarketing(e.target.checked)}
|
|
7008
|
+
className="accent-primary mt-0.5"
|
|
6444
7009
|
/>
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
{/* City + Postal code row */}
|
|
6448
|
-
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
6449
|
-
<div>
|
|
6450
|
-
<label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
|
|
6451
|
-
{t('city')} <span className="text-destructive">*</span>
|
|
6452
|
-
</label>
|
|
6453
|
-
<input
|
|
6454
|
-
id="city"
|
|
6455
|
-
type="text"
|
|
6456
|
-
value={formData.city}
|
|
6457
|
-
onChange={(e) => updateField('city', e.target.value)}
|
|
6458
|
-
className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')}
|
|
6459
|
-
/>
|
|
6460
|
-
{errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
|
|
6461
|
-
</div>
|
|
7010
|
+
<span className="text-muted-foreground text-sm">{t('acceptsMarketing')}</span>
|
|
7011
|
+
</label>
|
|
6462
7012
|
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
|
|
6466
|
-
</label>
|
|
7013
|
+
{/* Save details for next time (logged-in users only) */}
|
|
7014
|
+
{showSaveDetails && (
|
|
7015
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
6467
7016
|
<input
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6472
|
-
className={cn(inputClass, errors.postalCode ? 'border-destructive' : 'border-border')}
|
|
7017
|
+
type="checkbox"
|
|
7018
|
+
checked={saveDetails}
|
|
7019
|
+
onChange={(e) => setSaveDetails(e.target.checked)}
|
|
7020
|
+
className="accent-primary mt-0.5"
|
|
6473
7021
|
/>
|
|
6474
|
-
|
|
6475
|
-
<p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
|
|
6476
|
-
)}
|
|
6477
|
-
</div>
|
|
6478
|
-
</div>
|
|
6479
|
-
|
|
6480
|
-
{/* Phone */}
|
|
6481
|
-
<div>
|
|
6482
|
-
<label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
|
|
6483
|
-
{t('phone')}
|
|
7022
|
+
<span className="text-muted-foreground text-sm">{t('saveDetailsForNextTime')}</span>
|
|
6484
7023
|
</label>
|
|
6485
|
-
|
|
6486
|
-
id="phone"
|
|
6487
|
-
type="tel"
|
|
6488
|
-
value={formData.phone || ''}
|
|
6489
|
-
onChange={(e) => updateField('phone', e.target.value)}
|
|
6490
|
-
className={cn(inputClass, 'border-border')}
|
|
6491
|
-
placeholder={t('phonePlaceholder')}
|
|
6492
|
-
/>
|
|
6493
|
-
</div>
|
|
7024
|
+
)}
|
|
6494
7025
|
|
|
6495
7026
|
<button
|
|
6496
7027
|
type="submit"
|
|
6497
7028
|
disabled={loading}
|
|
6498
7029
|
className="bg-primary text-primary-foreground w-full rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
6499
7030
|
>
|
|
6500
|
-
{loading ? tc('saving') : t('continueToShipping')}
|
|
7031
|
+
{loading ? tc('saving') : emailOnly ? t('continueToPayment') : t('continueToShipping')}
|
|
6501
7032
|
</button>
|
|
6502
7033
|
</form>
|
|
6503
7034
|
);
|
|
@@ -6617,112 +7148,95 @@ export function ShippingStep({
|
|
|
6617
7148
|
"src/components/checkout/payment-step.tsx": `'use client';
|
|
6618
7149
|
|
|
6619
7150
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
6620
|
-
import type { PaymentIntent } from 'brainerce';
|
|
7151
|
+
import type { PaymentIntent, PaymentClientSdk } from 'brainerce';
|
|
6621
7152
|
import { getClient } from '@/lib/brainerce';
|
|
6622
7153
|
import { useTranslations } from '@/lib/translations';
|
|
6623
7154
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
6624
7155
|
import { cn } from '@/lib/utils';
|
|
6625
7156
|
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
6630
|
-
|
|
6631
|
-
|
|
6632
|
-
|
|
6633
|
-
|
|
6634
|
-
|
|
6635
|
-
|
|
6636
|
-
|
|
6637
|
-
|
|
6638
|
-
|
|
6639
|
-
|
|
6640
|
-
|
|
6641
|
-
|
|
6642
|
-
|
|
6643
|
-
renderPaymentOptions: (authCode: string) => void;
|
|
6644
|
-
};
|
|
6645
|
-
}
|
|
6646
|
-
}
|
|
6647
|
-
|
|
6648
|
-
// Payment SDK script URLs \u2014 resolved per provider
|
|
6649
|
-
// SRI hashes must be regenerated when the provider updates their SDK
|
|
6650
|
-
const PAYMENT_SDK_URL = 'https://cdn.meshulam.co.il/sdk/gs.min.js';
|
|
6651
|
-
const PAYMENT_SDK_SRI = 'sha384-e1OYzZERZLgbR8Zw1I4Ww/J7qXGyEsZb7LF0z5W/sbnTsFwtxop7sKZsLGNp6CB1';
|
|
6652
|
-
const APPLE_PAY_SDK_URL = 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js';
|
|
6653
|
-
const APPLE_PAY_SDK_SRI = 'sha384-CG67HXmWBeyuRR/jqFsYr6dGEMFyuZw3/hRmcyACJRYHGb/1ebEkQZyzdJXDc9/u';
|
|
7157
|
+
/**
|
|
7158
|
+
* Backward-compat defaults when backend doesn't return clientSdk.
|
|
7159
|
+
*/
|
|
7160
|
+
const LEGACY_GROW_SDK: PaymentClientSdk = {
|
|
7161
|
+
renderType: 'sdk-widget',
|
|
7162
|
+
scriptUrl: 'https://cdn.meshulam.co.il/sdk/gs.min.js',
|
|
7163
|
+
globalName: 'growPayment',
|
|
7164
|
+
initMethod: 'init',
|
|
7165
|
+
renderMethod: 'renderPaymentOptions',
|
|
7166
|
+
containerId: 'grow-payment-container',
|
|
7167
|
+
initConfig: { version: 1, environment: 'DEV' },
|
|
7168
|
+
additionalScripts: [
|
|
7169
|
+
{ url: 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js', optional: true },
|
|
7170
|
+
],
|
|
7171
|
+
bodyStyles:
|
|
7172
|
+
'[id*="Gr0W8-"],[id*="Gr0W8-"] *,[class*="Gr0W8-"],[class*="Gr0W8-"] *{direction:ltr !important;text-align:left}',
|
|
7173
|
+
};
|
|
6654
7174
|
|
|
6655
7175
|
interface PaymentStepProps {
|
|
6656
7176
|
checkoutId: string;
|
|
6657
7177
|
className?: string;
|
|
6658
7178
|
}
|
|
6659
7179
|
|
|
6660
|
-
|
|
6661
|
-
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
|
|
6671
|
-
|
|
6672
|
-
|
|
6673
|
-
|
|
6674
|
-
script.integrity = integrity;
|
|
6675
|
-
script.crossOrigin = 'anonymous';
|
|
6676
|
-
}
|
|
6677
|
-
script.onload = () => resolve();
|
|
6678
|
-
script.onerror = () => {
|
|
6679
|
-
if (optional) {
|
|
6680
|
-
resolve(); // Non-blocking for optional SDKs
|
|
6681
|
-
} else {
|
|
6682
|
-
resolve(); // Still resolve \u2014 caller handles missing global
|
|
6683
|
-
}
|
|
7180
|
+
function resolveClientSdk(
|
|
7181
|
+
intent: PaymentIntent | null,
|
|
7182
|
+
preloadedSdk?: PaymentClientSdk | null
|
|
7183
|
+
): PaymentClientSdk {
|
|
7184
|
+
const fullSdk = [preloadedSdk, intent?.clientSdk].find((s) => s?.renderType);
|
|
7185
|
+
const runtimeSdk = intent?.clientSdk;
|
|
7186
|
+
if (fullSdk) {
|
|
7187
|
+
if (!runtimeSdk || runtimeSdk === fullSdk) return fullSdk;
|
|
7188
|
+
return {
|
|
7189
|
+
...fullSdk,
|
|
7190
|
+
...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
|
|
7191
|
+
...(runtimeSdk.initConfig
|
|
7192
|
+
? { initConfig: { ...fullSdk.initConfig, ...runtimeSdk.initConfig } }
|
|
7193
|
+
: {}),
|
|
6684
7194
|
};
|
|
6685
|
-
|
|
6686
|
-
|
|
7195
|
+
}
|
|
7196
|
+
const legacy = intent?.provider === 'grow' ? LEGACY_GROW_SDK : null;
|
|
7197
|
+
if (legacy && runtimeSdk) {
|
|
7198
|
+
return {
|
|
7199
|
+
...legacy,
|
|
7200
|
+
...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
|
|
7201
|
+
...(runtimeSdk.initConfig
|
|
7202
|
+
? { initConfig: { ...legacy.initConfig, ...runtimeSdk.initConfig } }
|
|
7203
|
+
: {}),
|
|
7204
|
+
};
|
|
7205
|
+
}
|
|
7206
|
+
if (legacy) return legacy;
|
|
7207
|
+
return { renderType: 'redirect' };
|
|
6687
7208
|
}
|
|
6688
7209
|
|
|
6689
|
-
|
|
6690
|
-
|
|
6691
|
-
|
|
6692
|
-
function waitForPaymentSdkGlobal(timeoutMs = 5000): Promise<boolean> {
|
|
6693
|
-
return new Promise((resolve) => {
|
|
6694
|
-
if (window.growPayment) {
|
|
6695
|
-
resolve(true);
|
|
6696
|
-
return;
|
|
6697
|
-
}
|
|
6698
|
-
const start = Date.now();
|
|
6699
|
-
const check = setInterval(() => {
|
|
6700
|
-
if (window.growPayment) {
|
|
6701
|
-
clearInterval(check);
|
|
6702
|
-
resolve(true);
|
|
6703
|
-
} else if (Date.now() - start > timeoutMs) {
|
|
6704
|
-
clearInterval(check);
|
|
6705
|
-
resolve(false);
|
|
6706
|
-
}
|
|
6707
|
-
}, 50);
|
|
6708
|
-
});
|
|
7210
|
+
function extractMessage(response: unknown): string {
|
|
7211
|
+
if (typeof response === 'string') return response;
|
|
7212
|
+
return (response as { message?: string })?.message || '';
|
|
6709
7213
|
}
|
|
6710
7214
|
|
|
6711
7215
|
export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
6712
7216
|
const t = useTranslations('checkout');
|
|
6713
7217
|
const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
|
|
7218
|
+
const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
|
|
6714
7219
|
const [loading, setLoading] = useState(true);
|
|
6715
7220
|
const [error, setError] = useState<string | null>(null);
|
|
6716
7221
|
const [sdkReady, setSdkReady] = useState(false);
|
|
6717
|
-
const
|
|
6718
|
-
const
|
|
7222
|
+
const walletOpenRef = useRef(false);
|
|
7223
|
+
const initialized = useRef(false);
|
|
7224
|
+
|
|
7225
|
+
// Stable refs for SDK event callbacks (avoids stale closures in onload)
|
|
7226
|
+
const cbRef = useRef({
|
|
7227
|
+
onSuccess: (_r: unknown) => {},
|
|
7228
|
+
onFailure: (_r: unknown) => {},
|
|
7229
|
+
onError: (_r: unknown) => {},
|
|
7230
|
+
onTimeout: () => {},
|
|
7231
|
+
onWalletChange: (_s: string) => {},
|
|
7232
|
+
retryRender: () => {},
|
|
7233
|
+
});
|
|
6719
7234
|
|
|
6720
|
-
const
|
|
7235
|
+
const handleSuccess = useCallback(
|
|
6721
7236
|
async (response: unknown) => {
|
|
6722
7237
|
console.info('Payment SDK success:', JSON.stringify(response));
|
|
6723
7238
|
try {
|
|
6724
7239
|
const client = getClient();
|
|
6725
|
-
// Try response.data first, fall back to response itself
|
|
6726
7240
|
const resp = response as Record<string, unknown>;
|
|
6727
7241
|
const data = (resp?.data && typeof resp.data === 'object' ? resp.data : resp) as
|
|
6728
7242
|
| Record<string, unknown>
|
|
@@ -6736,188 +7250,286 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
6736
7250
|
[checkoutId]
|
|
6737
7251
|
);
|
|
6738
7252
|
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
|
|
6742
|
-
|
|
6743
|
-
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
console.
|
|
6757
|
-
|
|
6758
|
-
}
|
|
6759
|
-
|
|
6760
|
-
|
|
6761
|
-
|
|
7253
|
+
cbRef.current = {
|
|
7254
|
+
onSuccess: handleSuccess,
|
|
7255
|
+
onFailure: (response: unknown) => {
|
|
7256
|
+
console.error('Payment SDK failure:', response);
|
|
7257
|
+
setError(extractMessage(response) || t('paymentError'));
|
|
7258
|
+
},
|
|
7259
|
+
onError: (response: unknown) => {
|
|
7260
|
+
const TRANSIENT = [
|
|
7261
|
+
'Wallet not initialized',
|
|
7262
|
+
"SDK was not loaded as needed and therefore can't run",
|
|
7263
|
+
];
|
|
7264
|
+
const msg = extractMessage(response);
|
|
7265
|
+
if (TRANSIENT.some((e) => msg.includes(e))) {
|
|
7266
|
+
console.info('Payment SDK: transient error, retrying render in 1s:', msg);
|
|
7267
|
+
setTimeout(() => cbRef.current.retryRender(), 1000);
|
|
7268
|
+
return;
|
|
7269
|
+
}
|
|
7270
|
+
console.error('Payment SDK error:', response);
|
|
7271
|
+
setError(msg || t('paymentError'));
|
|
7272
|
+
},
|
|
7273
|
+
onTimeout: () => {
|
|
7274
|
+
console.warn('Payment SDK: wallet timed out');
|
|
7275
|
+
setError(t('paymentTimedOut'));
|
|
7276
|
+
},
|
|
7277
|
+
onWalletChange: (state: string) => {
|
|
7278
|
+
console.info('Payment SDK wallet state:', state);
|
|
7279
|
+
if (state === 'open') {
|
|
7280
|
+
walletOpenRef.current = true;
|
|
7281
|
+
setSdkReady(true);
|
|
7282
|
+
}
|
|
7283
|
+
if (state === 'close') setSdkReady(false);
|
|
7284
|
+
},
|
|
7285
|
+
retryRender: () => {},
|
|
7286
|
+
};
|
|
6762
7287
|
|
|
6763
|
-
//
|
|
7288
|
+
// =========================================================================
|
|
7289
|
+
// MAIN EFFECT \u2014 Follows Grow SDK docs exactly:
|
|
7290
|
+
//
|
|
7291
|
+
// Step 1: Load gs.min.js (insertBefore, as docs show)
|
|
7292
|
+
// Step 2: s.onload \u2192 growPayment.init({ environment, version, events })
|
|
7293
|
+
// This triggers the SDK to load mp.min.js \u2192 CSS, HTML, params, services
|
|
7294
|
+
// Step 3: createPaymentIntent (starts wallet timer \u2014 should be AFTER init)
|
|
7295
|
+
// Step 4: growPayment.renderPaymentOptions(authCode)
|
|
7296
|
+
//
|
|
7297
|
+
// "call createPaymentProcess right before you need to render the wallet"
|
|
7298
|
+
// =========================================================================
|
|
6764
7299
|
useEffect(() => {
|
|
6765
|
-
|
|
6766
|
-
|
|
6767
|
-
async function loadSdkScripts() {
|
|
6768
|
-
// Load Apple Pay SDK (optional)
|
|
6769
|
-
await loadScript(APPLE_PAY_SDK_URL, true, APPLE_PAY_SDK_SRI);
|
|
7300
|
+
if (initialized.current) return;
|
|
7301
|
+
initialized.current = true;
|
|
6770
7302
|
|
|
6771
|
-
|
|
6772
|
-
|
|
7303
|
+
const client = getClient();
|
|
7304
|
+
const successUrl = \`\${window.location.origin}/order-confirmation?checkout_id=\${checkoutId}\`;
|
|
7305
|
+
const cancelUrl = \`\${window.location.origin}/checkout?checkout_id=\${checkoutId}&canceled=true\`;
|
|
7306
|
+
|
|
7307
|
+
let sdkInitDone = false;
|
|
7308
|
+
let currentSdk: PaymentClientSdk | null = null;
|
|
7309
|
+
const cleanups: (() => void)[] = [];
|
|
7310
|
+
|
|
7311
|
+
// --- Load SDK script exactly as Grow docs show ---
|
|
7312
|
+
function loadScript(sdk: PaymentClientSdk) {
|
|
7313
|
+
if (!sdk.scriptUrl || !sdk.globalName) return;
|
|
7314
|
+
|
|
7315
|
+
// Inject bodyStyles
|
|
7316
|
+
if (sdk.bodyStyles && !document.querySelector('style[data-payment-sdk]')) {
|
|
7317
|
+
const style = document.createElement('style');
|
|
7318
|
+
style.setAttribute('data-payment-sdk', 'true');
|
|
7319
|
+
style.textContent = sdk.bodyStyles;
|
|
7320
|
+
document.head.appendChild(style);
|
|
7321
|
+
cleanups.push(() => style.remove());
|
|
7322
|
+
}
|
|
6773
7323
|
|
|
6774
|
-
//
|
|
6775
|
-
|
|
7324
|
+
// Additional scripts (Apple Pay etc.) \u2014 fire and forget
|
|
7325
|
+
if (sdk.additionalScripts) {
|
|
7326
|
+
for (const extra of sdk.additionalScripts) {
|
|
7327
|
+
if (document.querySelector(\`script[src="\${extra.url}"]\`)) continue;
|
|
7328
|
+
const s = document.createElement('script');
|
|
7329
|
+
s.type = 'text/javascript';
|
|
7330
|
+
s.async = true;
|
|
7331
|
+
s.src = extra.url;
|
|
7332
|
+
const ref = document.getElementsByTagName('script')[0];
|
|
7333
|
+
if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
|
|
7334
|
+
else document.head.appendChild(s);
|
|
7335
|
+
}
|
|
7336
|
+
}
|
|
6776
7337
|
|
|
6777
|
-
|
|
7338
|
+
// Already loaded? Init immediately
|
|
7339
|
+
if ((window as any)[sdk.globalName]) {
|
|
7340
|
+
initSdk(sdk);
|
|
7341
|
+
return;
|
|
7342
|
+
}
|
|
6778
7343
|
|
|
6779
|
-
|
|
6780
|
-
|
|
7344
|
+
// Already loading (from a previous call)? Wait for it instead of duplicating
|
|
7345
|
+
if (document.querySelector(\`script[src="\${sdk.scriptUrl}"]\`)) {
|
|
7346
|
+
const waitId = setInterval(() => {
|
|
7347
|
+
if ((window as any)[sdk.globalName!]) {
|
|
7348
|
+
clearInterval(waitId);
|
|
7349
|
+
initSdk(sdk);
|
|
7350
|
+
}
|
|
7351
|
+
}, 100);
|
|
7352
|
+
cleanups.push(() => clearInterval(waitId));
|
|
6781
7353
|
return;
|
|
6782
7354
|
}
|
|
6783
7355
|
|
|
6784
|
-
|
|
7356
|
+
// Load main SDK \u2014 insertBefore first <script> as Grow docs show
|
|
7357
|
+
const s = document.createElement('script');
|
|
7358
|
+
s.type = 'text/javascript';
|
|
7359
|
+
s.async = true;
|
|
7360
|
+
s.src = sdk.scriptUrl;
|
|
7361
|
+
s.onload = () => initSdk(sdk); // init DIRECTLY in onload
|
|
7362
|
+
s.onerror = () => {
|
|
7363
|
+
console.error('Payment SDK: script load failed');
|
|
7364
|
+
setError(t('failedToLoadPaymentSdk'));
|
|
7365
|
+
};
|
|
7366
|
+
const ref = document.getElementsByTagName('script')[0];
|
|
7367
|
+
if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
|
|
7368
|
+
else document.head.appendChild(s);
|
|
6785
7369
|
}
|
|
6786
7370
|
|
|
6787
|
-
|
|
7371
|
+
// --- Init: called in s.onload (as Grow docs require) ---
|
|
7372
|
+
function initSdk(sdk: PaymentClientSdk) {
|
|
7373
|
+
if (sdkInitDone) return; // Guard against double init
|
|
6788
7374
|
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
7375
|
+
const global = (window as any)[sdk.globalName!];
|
|
7376
|
+
if (!global) {
|
|
7377
|
+
setError(t('failedToLoadPaymentSdk'));
|
|
7378
|
+
return;
|
|
7379
|
+
}
|
|
6793
7380
|
|
|
6794
|
-
|
|
6795
|
-
|
|
6796
|
-
|
|
6797
|
-
|
|
6798
|
-
|
|
6799
|
-
|
|
7381
|
+
const method = sdk.initMethod || 'init';
|
|
7382
|
+
const config = {
|
|
7383
|
+
...(sdk.initConfig || {}),
|
|
7384
|
+
events: {
|
|
7385
|
+
onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
|
|
7386
|
+
onFailure: (r: unknown) => cbRef.current.onFailure(r),
|
|
7387
|
+
onError: (r: unknown) => cbRef.current.onError(r),
|
|
7388
|
+
onTimeout: () => cbRef.current.onTimeout(),
|
|
7389
|
+
onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
|
|
7390
|
+
},
|
|
7391
|
+
};
|
|
6800
7392
|
|
|
6801
|
-
|
|
6802
|
-
|
|
6803
|
-
|
|
6804
|
-
|
|
6805
|
-
const client = getClient();
|
|
7393
|
+
console.info(\`Payment SDK: calling \${method}()\`);
|
|
7394
|
+
global[method](config);
|
|
7395
|
+
sdkInitDone = true;
|
|
7396
|
+
}
|
|
6806
7397
|
|
|
6807
|
-
|
|
6808
|
-
|
|
7398
|
+
// --- Render: call once, then safety-net retries if wallet doesn't open ---
|
|
7399
|
+
// Grow SDK sometimes silently swallows renderPaymentOptions when its
|
|
7400
|
+
// internal resources (mp.min.js etc.) aren't fully loaded yet.
|
|
7401
|
+
// Strategy: render once, then retry up to 3 times with increasing delays
|
|
7402
|
+
// (2s, 3s, 4s) if onWalletChange("open") hasn't fired.
|
|
7403
|
+
let pendingRender: { sdk: PaymentClientSdk; intent: PaymentIntent } | null = null;
|
|
7404
|
+
let renderAttempts = 0;
|
|
7405
|
+
const MAX_RENDER_ATTEMPTS = 4;
|
|
6809
7406
|
|
|
6810
|
-
|
|
6811
|
-
|
|
6812
|
-
|
|
6813
|
-
});
|
|
7407
|
+
function renderPayment(sdk: PaymentClientSdk, intent: PaymentIntent) {
|
|
7408
|
+
const global = (window as any)[sdk.globalName!];
|
|
7409
|
+
if (!global || walletOpenRef.current) return;
|
|
6814
7410
|
|
|
6815
|
-
|
|
7411
|
+
const renderMethod = sdk.renderMethod || 'renderPaymentOptions';
|
|
7412
|
+
const renderArg = sdk.renderArg || intent.clientSecret;
|
|
7413
|
+
renderAttempts++;
|
|
6816
7414
|
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
}
|
|
7415
|
+
try {
|
|
7416
|
+
global[renderMethod](renderArg);
|
|
7417
|
+
console.info(\`Payment SDK: renderPaymentOptions called (attempt \${renderAttempts})\`);
|
|
6821
7418
|
} catch (err) {
|
|
6822
|
-
|
|
6823
|
-
setError(message);
|
|
6824
|
-
} finally {
|
|
6825
|
-
setLoading(false);
|
|
7419
|
+
console.info('Payment SDK: render threw, will retry in 1s');
|
|
6826
7420
|
}
|
|
6827
|
-
}
|
|
6828
7421
|
|
|
6829
|
-
|
|
6830
|
-
|
|
7422
|
+
// Safety net: if wallet doesn't open within a delay, retry
|
|
7423
|
+
if (renderAttempts < MAX_RENDER_ATTEMPTS) {
|
|
7424
|
+
const delay = 1000 + renderAttempts * 1000; // 2s, 3s, 4s
|
|
7425
|
+
const retryId = setTimeout(() => {
|
|
7426
|
+
if (!walletOpenRef.current) {
|
|
7427
|
+
console.info(\`Payment SDK: wallet not open after \${delay}ms, retrying render...\`);
|
|
7428
|
+
renderPayment(sdk, intent);
|
|
7429
|
+
}
|
|
7430
|
+
}, delay);
|
|
7431
|
+
cleanups.push(() => clearTimeout(retryId));
|
|
7432
|
+
}
|
|
7433
|
+
}
|
|
6831
7434
|
|
|
6832
|
-
|
|
6833
|
-
|
|
6834
|
-
|
|
6835
|
-
useEffect(() => {
|
|
6836
|
-
if (!sdkScriptLoaded) return;
|
|
6837
|
-
if (!paymentIntent || paymentIntent.provider !== 'grow') return;
|
|
6838
|
-
if (!window.growPayment) return;
|
|
6839
|
-
if (renderAttempted.current) return;
|
|
6840
|
-
|
|
6841
|
-
renderAttempted.current = true;
|
|
6842
|
-
|
|
6843
|
-
// Parse environment and authCode from clientSecret (format: "ENV|authCode")
|
|
6844
|
-
const pipeIndex = paymentIntent.clientSecret.indexOf('|');
|
|
6845
|
-
const env = pipeIndex !== -1 ? paymentIntent.clientSecret.substring(0, pipeIndex) : 'DEV';
|
|
6846
|
-
const authCode =
|
|
6847
|
-
pipeIndex !== -1
|
|
6848
|
-
? paymentIntent.clientSecret.substring(pipeIndex + 1)
|
|
6849
|
-
: paymentIntent.clientSecret;
|
|
6850
|
-
|
|
6851
|
-
let rendered = false;
|
|
6852
|
-
let walletReady = false;
|
|
6853
|
-
let pollId: ReturnType<typeof setInterval>;
|
|
6854
|
-
|
|
6855
|
-
function tryRenderPaymentOptions() {
|
|
6856
|
-
if (rendered) return;
|
|
6857
|
-
try {
|
|
6858
|
-
window.growPayment?.renderPaymentOptions(authCode);
|
|
6859
|
-
rendered = true;
|
|
6860
|
-
clearInterval(pollId);
|
|
6861
|
-
setSdkReady(true);
|
|
6862
|
-
console.info('Payment SDK: renderPaymentOptions succeeded');
|
|
6863
|
-
} catch (err) {
|
|
6864
|
-
console.info('Payment SDK: renderPaymentOptions not ready yet', err);
|
|
7435
|
+
function retryRender() {
|
|
7436
|
+
if (pendingRender && !walletOpenRef.current) {
|
|
7437
|
+
renderPayment(pendingRender.sdk, pendingRender.intent);
|
|
6865
7438
|
}
|
|
6866
7439
|
}
|
|
6867
7440
|
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
|
|
6871
|
-
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
7441
|
+
// =============================================
|
|
7442
|
+
// Execution flow
|
|
7443
|
+
// =============================================
|
|
7444
|
+
|
|
7445
|
+
// A) Get SDK config from providers (fast, no wallet timer)
|
|
7446
|
+
const providerPromise = client
|
|
7447
|
+
.getPaymentProviders()
|
|
7448
|
+
.then((res) => {
|
|
7449
|
+
const sdk = res.defaultProvider?.clientSdk;
|
|
7450
|
+
if (sdk) setPreloadedSdk(sdk);
|
|
7451
|
+
return sdk || null;
|
|
7452
|
+
})
|
|
7453
|
+
.catch(() => null);
|
|
7454
|
+
|
|
7455
|
+
// B) Load + init SDK as early as possible
|
|
7456
|
+
providerPromise.then((providerSdk) => {
|
|
7457
|
+
if (providerSdk?.renderType === 'sdk-widget' && providerSdk.scriptUrl) {
|
|
7458
|
+
currentSdk = providerSdk;
|
|
7459
|
+
loadScript(providerSdk);
|
|
7460
|
+
}
|
|
6882
7461
|
});
|
|
6883
7462
|
|
|
6884
|
-
//
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
6890
|
-
|
|
6891
|
-
|
|
6892
|
-
|
|
6893
|
-
|
|
6894
|
-
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
|
|
7463
|
+
// C) Create payment intent (starts wallet timer)
|
|
7464
|
+
const intentPromise = client
|
|
7465
|
+
.createPaymentIntent(checkoutId, { successUrl, cancelUrl })
|
|
7466
|
+
.then((intent) => {
|
|
7467
|
+
setPaymentIntent(intent);
|
|
7468
|
+
return intent;
|
|
7469
|
+
})
|
|
7470
|
+
.catch((err) => {
|
|
7471
|
+
setError(err instanceof Error ? err.message : t('paymentError'));
|
|
7472
|
+
return null;
|
|
7473
|
+
})
|
|
7474
|
+
.finally(() => setLoading(false));
|
|
7475
|
+
|
|
7476
|
+
// D) When both ready: resolve final SDK config and render
|
|
7477
|
+
Promise.all([providerPromise, intentPromise]).then(([providerSdk, intent]) => {
|
|
7478
|
+
if (!intent) return;
|
|
7479
|
+
|
|
7480
|
+
const sdk = resolveClientSdk(intent, providerSdk);
|
|
7481
|
+
currentSdk = sdk;
|
|
7482
|
+
|
|
7483
|
+
if (sdk.renderType === 'redirect') {
|
|
7484
|
+
window.location.href = intent.clientSecret;
|
|
7485
|
+
return;
|
|
7486
|
+
}
|
|
7487
|
+
if (sdk.renderType !== 'sdk-widget' || !sdk.globalName) return;
|
|
7488
|
+
|
|
7489
|
+
// Store for retryRender from onError callback
|
|
7490
|
+
pendingRender = { sdk, intent };
|
|
7491
|
+
cbRef.current.retryRender = retryRender;
|
|
7492
|
+
|
|
7493
|
+
// If SDK wasn't loaded from providers, load + init now
|
|
7494
|
+
if (!sdkInitDone) {
|
|
7495
|
+
loadScript(sdk);
|
|
7496
|
+
// Wait for init to complete, then render once
|
|
7497
|
+
const id = setInterval(() => {
|
|
7498
|
+
if (sdkInitDone) {
|
|
7499
|
+
clearInterval(id);
|
|
7500
|
+
renderPayment(sdk, intent);
|
|
6905
7501
|
}
|
|
6906
|
-
},
|
|
7502
|
+
}, 100);
|
|
7503
|
+
cleanups.push(() => clearInterval(id));
|
|
7504
|
+
return;
|
|
6907
7505
|
}
|
|
6908
|
-
}, 1000);
|
|
6909
7506
|
|
|
6910
|
-
|
|
6911
|
-
|
|
6912
|
-
|
|
6913
|
-
|
|
6914
|
-
|
|
6915
|
-
|
|
6916
|
-
|
|
6917
|
-
|
|
6918
|
-
|
|
6919
|
-
|
|
6920
|
-
|
|
7507
|
+
// Re-init with final config if environment changed
|
|
7508
|
+
if (sdk.initConfig?.environment && currentSdk) {
|
|
7509
|
+
const global = (window as any)[sdk.globalName];
|
|
7510
|
+
if (global) {
|
|
7511
|
+
const method = sdk.initMethod || 'init';
|
|
7512
|
+
global[method]({
|
|
7513
|
+
...(sdk.initConfig || {}),
|
|
7514
|
+
events: {
|
|
7515
|
+
onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
|
|
7516
|
+
onFailure: (r: unknown) => cbRef.current.onFailure(r),
|
|
7517
|
+
onError: (r: unknown) => cbRef.current.onError(r),
|
|
7518
|
+
onTimeout: () => cbRef.current.onTimeout(),
|
|
7519
|
+
onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
|
|
7520
|
+
},
|
|
7521
|
+
});
|
|
7522
|
+
}
|
|
7523
|
+
}
|
|
7524
|
+
|
|
7525
|
+
// SDK ready \u2014 render once
|
|
7526
|
+
renderPayment(sdk, intent);
|
|
7527
|
+
});
|
|
7528
|
+
|
|
7529
|
+
return () => cleanups.forEach((fn) => fn());
|
|
7530
|
+
}, [checkoutId]);
|
|
7531
|
+
|
|
7532
|
+
// --- UI ---
|
|
6921
7533
|
|
|
6922
7534
|
if (loading) {
|
|
6923
7535
|
return (
|
|
@@ -6933,7 +7545,6 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
6933
7545
|
error.toLowerCase().includes('not configured') ||
|
|
6934
7546
|
error.toLowerCase().includes('no payment') ||
|
|
6935
7547
|
error.toLowerCase().includes('provider');
|
|
6936
|
-
|
|
6937
7548
|
return (
|
|
6938
7549
|
<div className={cn('py-12 text-center', className)}>
|
|
6939
7550
|
<svg
|
|
@@ -6959,8 +7570,13 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
6959
7570
|
);
|
|
6960
7571
|
}
|
|
6961
7572
|
|
|
6962
|
-
|
|
6963
|
-
|
|
7573
|
+
if (!paymentIntent) return null;
|
|
7574
|
+
|
|
7575
|
+
const sdk = resolveClientSdk(paymentIntent, preloadedSdk);
|
|
7576
|
+
|
|
7577
|
+
if (sdk.renderType === 'sdk-widget') {
|
|
7578
|
+
const containerId =
|
|
7579
|
+
sdk.containerId || \`\${paymentIntent.provider || 'payment'}-payment-container\`;
|
|
6964
7580
|
return (
|
|
6965
7581
|
<div className={cn('py-4', className)}>
|
|
6966
7582
|
{!sdkReady && (
|
|
@@ -6969,29 +7585,38 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
6969
7585
|
<p className="text-muted-foreground mt-4 text-sm">{t('loadingPaymentOptions')}</p>
|
|
6970
7586
|
</div>
|
|
6971
7587
|
)}
|
|
6972
|
-
<div id=
|
|
7588
|
+
<div id={containerId} />
|
|
6973
7589
|
</div>
|
|
6974
7590
|
);
|
|
6975
7591
|
}
|
|
6976
7592
|
|
|
6977
|
-
|
|
6978
|
-
if (paymentIntent) {
|
|
7593
|
+
if (sdk.renderType === 'iframe') {
|
|
6979
7594
|
return (
|
|
6980
|
-
<div className={cn('
|
|
6981
|
-
<
|
|
6982
|
-
|
|
6983
|
-
|
|
6984
|
-
{
|
|
6985
|
-
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
.
|
|
6989
|
-
</p>
|
|
7595
|
+
<div className={cn('py-4', className)}>
|
|
7596
|
+
<iframe
|
|
7597
|
+
src={paymentIntent.clientSecret}
|
|
7598
|
+
className="w-full border-0"
|
|
7599
|
+
style={{ minHeight: '500px' }}
|
|
7600
|
+
title={t('payment')}
|
|
7601
|
+
allow="payment"
|
|
7602
|
+
/>
|
|
6990
7603
|
</div>
|
|
6991
7604
|
);
|
|
6992
7605
|
}
|
|
6993
7606
|
|
|
6994
|
-
return
|
|
7607
|
+
return (
|
|
7608
|
+
<div className={cn('flex flex-col items-center justify-center py-12', className)}>
|
|
7609
|
+
<LoadingSpinner size="lg" />
|
|
7610
|
+
<p className="text-muted-foreground mt-4 text-sm">{t('redirectingToPayment')}</p>
|
|
7611
|
+
<p className="text-muted-foreground mt-2 text-xs">
|
|
7612
|
+
{t('redirectingHint')}
|
|
7613
|
+
<a href={paymentIntent.clientSecret} className="text-primary hover:underline">
|
|
7614
|
+
{t('clickHere')}
|
|
7615
|
+
</a>
|
|
7616
|
+
.
|
|
7617
|
+
</p>
|
|
7618
|
+
</div>
|
|
7619
|
+
);
|
|
6995
7620
|
}
|
|
6996
7621
|
`,
|
|
6997
7622
|
"src/components/checkout/tax-display.tsx": `'use client';
|
|
@@ -7174,6 +7799,7 @@ interface RegisterData {
|
|
|
7174
7799
|
lastName: string;
|
|
7175
7800
|
email: string;
|
|
7176
7801
|
password: string;
|
|
7802
|
+
acceptsMarketing: boolean;
|
|
7177
7803
|
}
|
|
7178
7804
|
|
|
7179
7805
|
interface RegisterFormProps {
|
|
@@ -7205,6 +7831,9 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
|
|
|
7205
7831
|
const [lastName, setLastName] = useState('');
|
|
7206
7832
|
const [email, setEmail] = useState('');
|
|
7207
7833
|
const [password, setPassword] = useState('');
|
|
7834
|
+
const [privacyAccepted, setPrivacyAccepted] = useState(false);
|
|
7835
|
+
const [privacyError, setPrivacyError] = useState(false);
|
|
7836
|
+
const [acceptsMarketing, setAcceptsMarketing] = useState(false);
|
|
7208
7837
|
const [loading, setLoading] = useState(false);
|
|
7209
7838
|
|
|
7210
7839
|
const strength = useMemo(() => getPasswordStrength(password), [password]);
|
|
@@ -7213,9 +7842,14 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
|
|
|
7213
7842
|
e.preventDefault();
|
|
7214
7843
|
if (loading) return;
|
|
7215
7844
|
|
|
7845
|
+
if (!privacyAccepted) {
|
|
7846
|
+
setPrivacyError(true);
|
|
7847
|
+
return;
|
|
7848
|
+
}
|
|
7849
|
+
|
|
7216
7850
|
try {
|
|
7217
7851
|
setLoading(true);
|
|
7218
|
-
await onSubmit({ firstName, lastName, email, password });
|
|
7852
|
+
await onSubmit({ firstName, lastName, email, password, acceptsMarketing });
|
|
7219
7853
|
} finally {
|
|
7220
7854
|
setLoading(false);
|
|
7221
7855
|
}
|
|
@@ -7326,6 +7960,45 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
|
|
|
7326
7960
|
)}
|
|
7327
7961
|
</div>
|
|
7328
7962
|
|
|
7963
|
+
{/* Privacy Policy (required) */}
|
|
7964
|
+
<div>
|
|
7965
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
7966
|
+
<input
|
|
7967
|
+
type="checkbox"
|
|
7968
|
+
checked={privacyAccepted}
|
|
7969
|
+
onChange={(e) => {
|
|
7970
|
+
setPrivacyAccepted(e.target.checked);
|
|
7971
|
+
setPrivacyError(false);
|
|
7972
|
+
}}
|
|
7973
|
+
className="accent-primary mt-0.5"
|
|
7974
|
+
/>
|
|
7975
|
+
<span className="text-muted-foreground text-sm">
|
|
7976
|
+
{t('privacyAcceptPrefix')}{' '}
|
|
7977
|
+
<a
|
|
7978
|
+
href="/privacy"
|
|
7979
|
+
target="_blank"
|
|
7980
|
+
rel="noopener noreferrer"
|
|
7981
|
+
className="text-primary underline underline-offset-2"
|
|
7982
|
+
>
|
|
7983
|
+
{t('privacyPolicyLink')}
|
|
7984
|
+
</a>{' '}
|
|
7985
|
+
<span className="text-destructive">*</span>
|
|
7986
|
+
</span>
|
|
7987
|
+
</label>
|
|
7988
|
+
{privacyError && <p className="text-destructive mt-1 text-xs">{t('privacyRequired')}</p>}
|
|
7989
|
+
</div>
|
|
7990
|
+
|
|
7991
|
+
{/* Marketing consent (optional) */}
|
|
7992
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
7993
|
+
<input
|
|
7994
|
+
type="checkbox"
|
|
7995
|
+
checked={acceptsMarketing}
|
|
7996
|
+
onChange={(e) => setAcceptsMarketing(e.target.checked)}
|
|
7997
|
+
className="accent-primary mt-0.5"
|
|
7998
|
+
/>
|
|
7999
|
+
<span className="text-muted-foreground text-sm">{t('acceptsMarketing')}</span>
|
|
8000
|
+
</label>
|
|
8001
|
+
|
|
7329
8002
|
<button
|
|
7330
8003
|
type="submit"
|
|
7331
8004
|
disabled={loading}
|
|
@@ -7791,7 +8464,8 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
7791
8464
|
const t = useTranslations('account');
|
|
7792
8465
|
const tc = useTranslations('common');
|
|
7793
8466
|
const [expanded, setExpanded] = useState(false);
|
|
7794
|
-
const statusConfig =
|
|
8467
|
+
const statusConfig =
|
|
8468
|
+
STATUS_CONFIG[order.status?.toLowerCase() as OrderStatus] || STATUS_CONFIG.pending;
|
|
7795
8469
|
const currency = order.currency || 'USD';
|
|
7796
8470
|
const totalAmount = order.totalAmount || order.total || '0';
|
|
7797
8471
|
|
|
@@ -7903,9 +8577,7 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
7903
8577
|
))}
|
|
7904
8578
|
|
|
7905
8579
|
{/* Downloads section */}
|
|
7906
|
-
{order.hasDownloads &&
|
|
7907
|
-
<OrderDownloads orderId={order.id} />
|
|
7908
|
-
)}
|
|
8580
|
+
{order.hasDownloads && <OrderDownloads orderId={order.id} />}
|
|
7909
8581
|
|
|
7910
8582
|
<OrderFinancialSummary order={order} currency={currency} />
|
|
7911
8583
|
</div>
|
|
@@ -7933,7 +8605,9 @@ function OrderDownloads({ orderId }: { orderId: string }) {
|
|
|
7933
8605
|
}
|
|
7934
8606
|
}
|
|
7935
8607
|
fetch();
|
|
7936
|
-
return () => {
|
|
8608
|
+
return () => {
|
|
8609
|
+
cancelled = true;
|
|
8610
|
+
};
|
|
7937
8611
|
}, [orderId]);
|
|
7938
8612
|
|
|
7939
8613
|
if (loading) {
|
|
@@ -7957,11 +8631,11 @@ function OrderDownloads({ orderId }: { orderId: string }) {
|
|
|
7957
8631
|
{link.productName}
|
|
7958
8632
|
{' \xB7 '}
|
|
7959
8633
|
{link.downloadLimit != null
|
|
7960
|
-
?
|
|
8634
|
+
? \`\${link.downloadsUsed}/\${link.downloadLimit} \${t('downloadsRemaining')}\`
|
|
7961
8635
|
: t('unlimitedDownloads')}
|
|
7962
8636
|
{' \xB7 '}
|
|
7963
8637
|
{link.expiresAt
|
|
7964
|
-
? t('expiresAt'
|
|
8638
|
+
? \`\${t('expiresAt')} \${new Date(link.expiresAt).toLocaleDateString()}\`
|
|
7965
8639
|
: t('noExpiry')}
|
|
7966
8640
|
</p>
|
|
7967
8641
|
</div>
|
|
@@ -8145,7 +8819,7 @@ export function Header() {
|
|
|
8145
8819
|
<div className="flex h-16 items-center justify-between gap-4">
|
|
8146
8820
|
{/* Logo / Store Name */}
|
|
8147
8821
|
<Link href="/" className="text-foreground flex-shrink-0 text-xl font-bold">
|
|
8148
|
-
{storeInfo?.name || tc('store')}
|
|
8822
|
+
{storeInfo?.name || process.env.NEXT_PUBLIC_STORE_NAME || tc('store')}
|
|
8149
8823
|
</Link>
|
|
8150
8824
|
|
|
8151
8825
|
{/* Desktop Navigation */}
|
|
@@ -8156,12 +8830,14 @@ export function Header() {
|
|
|
8156
8830
|
>
|
|
8157
8831
|
{t('products')}
|
|
8158
8832
|
</Link>
|
|
8159
|
-
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
|
|
8833
|
+
{isLoggedIn && (
|
|
8834
|
+
<Link
|
|
8835
|
+
href="/account"
|
|
8836
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
8837
|
+
>
|
|
8838
|
+
{t('account')}
|
|
8839
|
+
</Link>
|
|
8840
|
+
)}
|
|
8165
8841
|
</nav>
|
|
8166
8842
|
|
|
8167
8843
|
{/* Search */}
|
|
@@ -8351,13 +9027,15 @@ export function Header() {
|
|
|
8351
9027
|
>
|
|
8352
9028
|
{t('products')}
|
|
8353
9029
|
</Link>
|
|
8354
|
-
|
|
8355
|
-
|
|
8356
|
-
|
|
8357
|
-
|
|
8358
|
-
|
|
8359
|
-
|
|
8360
|
-
|
|
9030
|
+
{isLoggedIn && (
|
|
9031
|
+
<Link
|
|
9032
|
+
href="/account"
|
|
9033
|
+
onClick={() => setMobileMenuOpen(false)}
|
|
9034
|
+
className="text-foreground hover:bg-muted block rounded px-2 py-2 text-sm"
|
|
9035
|
+
>
|
|
9036
|
+
{t('account')}
|
|
9037
|
+
</Link>
|
|
9038
|
+
)}
|
|
8361
9039
|
{isLoggedIn ? (
|
|
8362
9040
|
<button
|
|
8363
9041
|
onClick={() => {
|
|
@@ -8399,12 +9077,13 @@ export function Header() {
|
|
|
8399
9077
|
|
|
8400
9078
|
import Link from 'next/link';
|
|
8401
9079
|
import { useTranslations } from '@/lib/translations';
|
|
8402
|
-
import { useStoreInfo } from '@/providers/store-provider';
|
|
9080
|
+
import { useStoreInfo, useAuth } from '@/providers/store-provider';
|
|
8403
9081
|
|
|
8404
9082
|
export function Footer() {
|
|
8405
9083
|
const t = useTranslations('common');
|
|
8406
9084
|
const tn = useTranslations('nav');
|
|
8407
9085
|
const { storeInfo } = useStoreInfo();
|
|
9086
|
+
const { isLoggedIn } = useAuth();
|
|
8408
9087
|
const year = new Date().getFullYear();
|
|
8409
9088
|
|
|
8410
9089
|
return (
|
|
@@ -8421,12 +9100,14 @@ export function Footer() {
|
|
|
8421
9100
|
>
|
|
8422
9101
|
{tn('products')}
|
|
8423
9102
|
</Link>
|
|
8424
|
-
|
|
8425
|
-
|
|
8426
|
-
|
|
8427
|
-
|
|
8428
|
-
|
|
8429
|
-
|
|
9103
|
+
{isLoggedIn && (
|
|
9104
|
+
<Link
|
|
9105
|
+
href="/account"
|
|
9106
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
9107
|
+
>
|
|
9108
|
+
{tn('account')}
|
|
9109
|
+
</Link>
|
|
9110
|
+
)}
|
|
8430
9111
|
</nav>
|
|
8431
9112
|
</div>
|
|
8432
9113
|
</div>
|
|
@@ -8681,6 +9362,12 @@ var USE_CASE_FILES = {
|
|
|
8681
9362
|
{ path: "src/components/auth/oauth-buttons.tsx", label: "OAuth Buttons Component" }
|
|
8682
9363
|
],
|
|
8683
9364
|
"verify-email": [{ path: "src/app/verify-email/page.tsx", label: "Verify Email Page" }],
|
|
9365
|
+
"forgot-password": [{ path: "src/app/forgot-password/page.tsx", label: "Forgot Password Page" }],
|
|
9366
|
+
"reset-password": [
|
|
9367
|
+
{ path: "src/app/reset-password/page.tsx", label: "Reset Password Page" },
|
|
9368
|
+
{ path: "src/app/api/auth/reset-callback/route.ts", label: "Reset Callback API Route" },
|
|
9369
|
+
{ path: "src/app/api/auth/reset-password/route.ts", label: "Reset Password API Route" }
|
|
9370
|
+
],
|
|
8684
9371
|
"oauth-callback": [{ path: "src/app/auth/callback/page.tsx", label: "OAuth Callback Page" }],
|
|
8685
9372
|
"account-page": [
|
|
8686
9373
|
{ path: "src/app/account/page.tsx", label: "Account Page" },
|
|
@@ -8757,6 +9444,8 @@ var GET_CODE_EXAMPLE_SCHEMA = {
|
|
|
8757
9444
|
"login-page",
|
|
8758
9445
|
"register-page",
|
|
8759
9446
|
"verify-email",
|
|
9447
|
+
"forgot-password",
|
|
9448
|
+
"reset-password",
|
|
8760
9449
|
"oauth-callback",
|
|
8761
9450
|
"account-page",
|
|
8762
9451
|
"header",
|
|
@@ -8797,6 +9486,8 @@ var TOPIC_MAP = {
|
|
|
8797
9486
|
"login-page": "auth",
|
|
8798
9487
|
"register-page": "auth",
|
|
8799
9488
|
"verify-email": "auth",
|
|
9489
|
+
"forgot-password": "auth",
|
|
9490
|
+
"reset-password": "auth",
|
|
8800
9491
|
"oauth-callback": "auth",
|
|
8801
9492
|
"account-page": "auth",
|
|
8802
9493
|
header: "products",
|
|
@@ -9036,6 +9727,735 @@ async function handleGetStoreInfo(args) {
|
|
|
9036
9727
|
}
|
|
9037
9728
|
}
|
|
9038
9729
|
|
|
9730
|
+
// src/tools/get-store-capabilities.ts
|
|
9731
|
+
var import_zod6 = require("zod");
|
|
9732
|
+
|
|
9733
|
+
// src/utils/fetch-store-capabilities.ts
|
|
9734
|
+
var import_https2 = __toESM(require("https"));
|
|
9735
|
+
var import_http2 = __toESM(require("http"));
|
|
9736
|
+
async function fetchStoreCapabilities(connectionId, baseUrl = "https://api.brainerce.com") {
|
|
9737
|
+
const url = `${baseUrl}/api/vc/${connectionId}/capabilities`;
|
|
9738
|
+
return new Promise((resolve, reject) => {
|
|
9739
|
+
const client = url.startsWith("https") ? import_https2.default : import_http2.default;
|
|
9740
|
+
const req = client.get(url, { timeout: 1e4 }, (res) => {
|
|
9741
|
+
let data = "";
|
|
9742
|
+
res.on("data", (chunk) => {
|
|
9743
|
+
data += chunk;
|
|
9744
|
+
});
|
|
9745
|
+
res.on("end", () => {
|
|
9746
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
9747
|
+
if (res.statusCode === 404) {
|
|
9748
|
+
reject(new Error(`Connection ID "${connectionId}" not found. Check your dashboard.`));
|
|
9749
|
+
} else {
|
|
9750
|
+
reject(new Error(`API returned status ${res.statusCode}`));
|
|
9751
|
+
}
|
|
9752
|
+
return;
|
|
9753
|
+
}
|
|
9754
|
+
try {
|
|
9755
|
+
resolve(JSON.parse(data));
|
|
9756
|
+
} catch {
|
|
9757
|
+
reject(new Error("Invalid response from API"));
|
|
9758
|
+
}
|
|
9759
|
+
});
|
|
9760
|
+
});
|
|
9761
|
+
req.on("error", (err) => {
|
|
9762
|
+
reject(new Error(`Failed to connect to Brainerce API: ${err.message}`));
|
|
9763
|
+
});
|
|
9764
|
+
req.on("timeout", () => {
|
|
9765
|
+
req.destroy();
|
|
9766
|
+
reject(new Error("Request timed out"));
|
|
9767
|
+
});
|
|
9768
|
+
});
|
|
9769
|
+
}
|
|
9770
|
+
|
|
9771
|
+
// src/tools/get-store-capabilities.ts
|
|
9772
|
+
var GET_STORE_CAPABILITIES_NAME = "get-store-capabilities";
|
|
9773
|
+
var GET_STORE_CAPABILITIES_DESCRIPTION = "Get live store capabilities and configured features for a vibe-coded connection. Returns what payment providers, OAuth, shipping, discounts, and other features are set up. Use this to discover what your store supports and what pages/components to build.";
|
|
9774
|
+
var GET_STORE_CAPABILITIES_SCHEMA = {
|
|
9775
|
+
connectionId: import_zod6.z.string().describe("Vibe-coded connection ID (starts with vc_)")
|
|
9776
|
+
};
|
|
9777
|
+
function formatCapabilities(caps) {
|
|
9778
|
+
const lines = [];
|
|
9779
|
+
lines.push(`## Store: ${caps.store.name} (${caps.store.currency})`);
|
|
9780
|
+
lines.push(`Language: ${caps.store.language}`);
|
|
9781
|
+
lines.push("");
|
|
9782
|
+
lines.push("## Configured Features");
|
|
9783
|
+
if (caps.features.paymentProviders.length > 0) {
|
|
9784
|
+
const providers = caps.features.paymentProviders.map((p) => p.name).join(", ");
|
|
9785
|
+
lines.push(`\u2705 Payment: ${providers}`);
|
|
9786
|
+
} else {
|
|
9787
|
+
lines.push("\u274C No payment providers configured \u2014 customers cannot pay!");
|
|
9788
|
+
}
|
|
9789
|
+
if (caps.features.oauthProviders.length > 0) {
|
|
9790
|
+
const enabled = caps.features.oauthProviders.filter((o) => o.isEnabled).map((o) => o.provider).join(", ");
|
|
9791
|
+
if (enabled) {
|
|
9792
|
+
lines.push(`\u2705 OAuth: ${enabled}`);
|
|
9793
|
+
} else {
|
|
9794
|
+
lines.push("\u26A0\uFE0F OAuth providers configured but disabled");
|
|
9795
|
+
}
|
|
9796
|
+
} else {
|
|
9797
|
+
lines.push("\u274C No OAuth providers \u2014 email/password login only");
|
|
9798
|
+
}
|
|
9799
|
+
lines.push(
|
|
9800
|
+
caps.features.hasShippingZones ? "\u2705 Shipping zones configured" : "\u274C No shipping zones"
|
|
9801
|
+
);
|
|
9802
|
+
lines.push(caps.features.hasDiscountRules ? "\u2705 Discount rules active" : "\u274C No discount rules");
|
|
9803
|
+
lines.push(
|
|
9804
|
+
caps.features.hasDownloadableProducts ? "\u2705 Downloadable products available" : "\u274C No downloadable products"
|
|
9805
|
+
);
|
|
9806
|
+
lines.push(caps.features.hasCoupons ? "\u2705 Coupons available" : "\u274C No coupons");
|
|
9807
|
+
lines.push("");
|
|
9808
|
+
lines.push("## Connection Settings");
|
|
9809
|
+
lines.push(
|
|
9810
|
+
`- Guest checkout tracking: ${caps.connection.guestCheckoutTracking ? "enabled" : "disabled"}`
|
|
9811
|
+
);
|
|
9812
|
+
lines.push(
|
|
9813
|
+
`- Email verification: ${caps.connection.requireEmailVerification ? "required" : "not required"}`
|
|
9814
|
+
);
|
|
9815
|
+
lines.push(
|
|
9816
|
+
`- Inventory reservation: ${caps.connection.reservationStrategy} (${caps.connection.reservationTimeout} min timeout)`
|
|
9817
|
+
);
|
|
9818
|
+
if (caps.connection.lowStockWarning) {
|
|
9819
|
+
lines.push(`- Low stock warning: enabled (threshold: ${caps.connection.lowStockThreshold})`);
|
|
9820
|
+
}
|
|
9821
|
+
lines.push(`- Orders write: ${caps.connection.ordersWriteEnabled ? "enabled" : "disabled"}`);
|
|
9822
|
+
lines.push(`- Scopes: ${caps.connection.allowedScopes.join(", ")}`);
|
|
9823
|
+
lines.push("");
|
|
9824
|
+
lines.push("## Build Suggestions");
|
|
9825
|
+
const suggestions = [];
|
|
9826
|
+
if (caps.features.paymentProviders.length > 0) {
|
|
9827
|
+
suggestions.push(
|
|
9828
|
+
'Build a checkout page with payment. Use get-code-example("checkout-page") and get-code-example("stripe-payment").'
|
|
9829
|
+
);
|
|
9830
|
+
}
|
|
9831
|
+
if (caps.features.oauthProviders.some((o) => o.isEnabled)) {
|
|
9832
|
+
suggestions.push(
|
|
9833
|
+
'Add OAuth login buttons. Use get-code-example("login-page") \u2014 it includes OAuth support.'
|
|
9834
|
+
);
|
|
9835
|
+
}
|
|
9836
|
+
if (caps.features.hasDownloadableProducts) {
|
|
9837
|
+
suggestions.push(
|
|
9838
|
+
"Build a downloads page for digital products. Customers need to access their purchased files."
|
|
9839
|
+
);
|
|
9840
|
+
}
|
|
9841
|
+
if (caps.features.hasDiscountRules) {
|
|
9842
|
+
suggestions.push(
|
|
9843
|
+
'Show discount badges and banners. Use get-code-example("discount-badge") and get-sdk-docs("discounts").'
|
|
9844
|
+
);
|
|
9845
|
+
}
|
|
9846
|
+
if (caps.features.hasCoupons) {
|
|
9847
|
+
suggestions.push('Add a coupon input to the cart. Use get-code-example("coupon-input").');
|
|
9848
|
+
}
|
|
9849
|
+
if (caps.connection.requireEmailVerification) {
|
|
9850
|
+
suggestions.push(
|
|
9851
|
+
'Email verification is required \u2014 ALWAYS build the verify-email page. Use get-code-example("verify-email").'
|
|
9852
|
+
);
|
|
9853
|
+
}
|
|
9854
|
+
if (caps.connection.reservationStrategy !== "ON_PAYMENT") {
|
|
9855
|
+
suggestions.push(
|
|
9856
|
+
'Inventory reservation is active \u2014 show a countdown timer. Use get-code-example("reservation-countdown").'
|
|
9857
|
+
);
|
|
9858
|
+
}
|
|
9859
|
+
if (suggestions.length === 0) {
|
|
9860
|
+
suggestions.push(
|
|
9861
|
+
"Start by building the required pages. Use get-required-pages() for the checklist."
|
|
9862
|
+
);
|
|
9863
|
+
}
|
|
9864
|
+
suggestions.forEach((s) => lines.push(`- ${s}`));
|
|
9865
|
+
return lines.join("\n");
|
|
9866
|
+
}
|
|
9867
|
+
async function handleGetStoreCapabilities(args) {
|
|
9868
|
+
try {
|
|
9869
|
+
const caps = await fetchStoreCapabilities(args.connectionId);
|
|
9870
|
+
return {
|
|
9871
|
+
content: [{ type: "text", text: formatCapabilities(caps) }]
|
|
9872
|
+
};
|
|
9873
|
+
} catch (error) {
|
|
9874
|
+
const message = error instanceof Error ? error.message : "Failed to fetch store capabilities";
|
|
9875
|
+
return {
|
|
9876
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
9877
|
+
isError: true
|
|
9878
|
+
};
|
|
9879
|
+
}
|
|
9880
|
+
}
|
|
9881
|
+
|
|
9882
|
+
// src/tools/build-store.ts
|
|
9883
|
+
var import_zod7 = require("zod");
|
|
9884
|
+
|
|
9885
|
+
// src/content/compressed-bundle.ts
|
|
9886
|
+
var SETUP_FILES = [
|
|
9887
|
+
"src/lib/brainerce.ts.ejs",
|
|
9888
|
+
"src/lib/auth.ts",
|
|
9889
|
+
"src/lib/utils.ts",
|
|
9890
|
+
"src/providers/store-provider.tsx.ejs",
|
|
9891
|
+
"src/app/layout.tsx.ejs",
|
|
9892
|
+
"src/middleware.ts"
|
|
9893
|
+
];
|
|
9894
|
+
var PAGE_FILES = [
|
|
9895
|
+
{ path: "src/app/page.tsx", route: "/", label: "Home" },
|
|
9896
|
+
{ path: "src/app/products/page.tsx", route: "/products", label: "Product Listing" },
|
|
9897
|
+
{ path: "src/app/products/[slug]/page.tsx", route: "/products/[slug]", label: "Product Detail" },
|
|
9898
|
+
{ path: "src/app/cart/page.tsx", route: "/cart", label: "Cart" },
|
|
9899
|
+
{ path: "src/app/checkout/page.tsx", route: "/checkout", label: "Checkout" },
|
|
9900
|
+
{
|
|
9901
|
+
path: "src/app/order-confirmation/page.tsx",
|
|
9902
|
+
route: "/order-confirmation",
|
|
9903
|
+
label: "Order Confirmation"
|
|
9904
|
+
},
|
|
9905
|
+
{ path: "src/app/login/page.tsx", route: "/login", label: "Login" },
|
|
9906
|
+
{ path: "src/app/register/page.tsx", route: "/register", label: "Register" },
|
|
9907
|
+
{ path: "src/app/verify-email/page.tsx", route: "/verify-email", label: "Verify Email" },
|
|
9908
|
+
{ path: "src/app/forgot-password/page.tsx", route: "/forgot-password", label: "Forgot Password" },
|
|
9909
|
+
{ path: "src/app/reset-password/page.tsx", route: "/reset-password", label: "Reset Password" },
|
|
9910
|
+
{ path: "src/app/auth/callback/page.tsx", route: "/auth/callback", label: "OAuth Callback" },
|
|
9911
|
+
{ path: "src/app/account/page.tsx", route: "/account", label: "Account" }
|
|
9912
|
+
];
|
|
9913
|
+
var COMPONENT_FILES = [
|
|
9914
|
+
{ path: "src/components/layout/header.tsx", label: "Header" },
|
|
9915
|
+
{ path: "src/components/layout/footer.tsx", label: "Footer" },
|
|
9916
|
+
{ path: "src/components/products/product-card.tsx", label: "Product Card" },
|
|
9917
|
+
{ path: "src/components/products/product-grid.tsx", label: "Product Grid" },
|
|
9918
|
+
{ path: "src/components/products/variant-selector.tsx", label: "Variant Selector" },
|
|
9919
|
+
{ path: "src/components/products/stock-badge.tsx", label: "Stock Badge" },
|
|
9920
|
+
{ path: "src/components/products/discount-badge.tsx", label: "Discount Badge" },
|
|
9921
|
+
{ path: "src/components/cart/cart-item.tsx", label: "Cart Item" },
|
|
9922
|
+
{ path: "src/components/cart/cart-summary.tsx", label: "Cart Summary" },
|
|
9923
|
+
{ path: "src/components/cart/coupon-input.tsx", label: "Coupon Input" },
|
|
9924
|
+
{ path: "src/components/cart/cart-nudges.tsx", label: "Cart Nudges" },
|
|
9925
|
+
{ path: "src/components/cart/reservation-countdown.tsx", label: "Reservation Countdown" },
|
|
9926
|
+
{ path: "src/components/checkout/checkout-form.tsx", label: "Checkout Form" },
|
|
9927
|
+
{ path: "src/components/checkout/shipping-step.tsx", label: "Shipping Step" },
|
|
9928
|
+
{ path: "src/components/checkout/payment-step.tsx", label: "Payment Step" },
|
|
9929
|
+
{ path: "src/components/checkout/tax-display.tsx", label: "Tax Display" },
|
|
9930
|
+
{ path: "src/components/auth/login-form.tsx", label: "Login Form" },
|
|
9931
|
+
{ path: "src/components/auth/register-form.tsx", label: "Register Form" },
|
|
9932
|
+
{ path: "src/components/auth/oauth-buttons.tsx", label: "OAuth Buttons" },
|
|
9933
|
+
{ path: "src/components/account/profile-section.tsx", label: "Profile Section" },
|
|
9934
|
+
{ path: "src/components/account/order-history.tsx", label: "Order History" },
|
|
9935
|
+
{ path: "src/components/shared/price-display.tsx", label: "Price Display" },
|
|
9936
|
+
{ path: "src/components/shared/loading-spinner.tsx", label: "Loading Spinner" },
|
|
9937
|
+
{ path: "src/hooks/use-search.ts", label: "Search Hook" }
|
|
9938
|
+
];
|
|
9939
|
+
var API_ROUTE_FILES = [
|
|
9940
|
+
"src/app/api/store/[...path]/route.ts",
|
|
9941
|
+
"src/app/api/auth/oauth-callback/route.ts",
|
|
9942
|
+
"src/app/api/auth/me/route.ts",
|
|
9943
|
+
"src/app/api/auth/logout/route.ts",
|
|
9944
|
+
"src/app/api/auth/reset-callback/route.ts",
|
|
9945
|
+
"src/app/api/auth/reset-password/route.ts"
|
|
9946
|
+
];
|
|
9947
|
+
function replaceEjsVars(content, vars) {
|
|
9948
|
+
let result = content;
|
|
9949
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
9950
|
+
result = result.replace(new RegExp(`<%[=-]\\s*${key}\\s*%>`, "g"), value);
|
|
9951
|
+
}
|
|
9952
|
+
return result;
|
|
9953
|
+
}
|
|
9954
|
+
function renderFile(path, ejsVars) {
|
|
9955
|
+
const content = EMBEDDED_TEMPLATES[path];
|
|
9956
|
+
if (!content) return null;
|
|
9957
|
+
const rendered = path.endsWith(".ejs") ? replaceEjsVars(content, ejsVars) : content;
|
|
9958
|
+
const displayPath = path.replace(/\.ejs$/, "");
|
|
9959
|
+
return `// \u2500\u2500 ${displayPath} \u2500\u2500
|
|
9960
|
+
${rendered}`;
|
|
9961
|
+
}
|
|
9962
|
+
function formatCapabilitiesSummary(caps) {
|
|
9963
|
+
const lines = [];
|
|
9964
|
+
lines.push(`## Store: ${caps.store.name}`);
|
|
9965
|
+
lines.push(`Currency: ${caps.store.currency} | Language: ${caps.store.language}`);
|
|
9966
|
+
lines.push("");
|
|
9967
|
+
lines.push("### Configured Features");
|
|
9968
|
+
if (caps.features.paymentProviders.length > 0) {
|
|
9969
|
+
const providers = caps.features.paymentProviders.map((p) => `${p.name} (${p.provider})`).join(", ");
|
|
9970
|
+
lines.push(`- Payment: ${providers}`);
|
|
9971
|
+
} else {
|
|
9972
|
+
lines.push(
|
|
9973
|
+
"- Payment: NONE configured \u2014 customers cannot pay! Tell the store owner to configure a payment provider."
|
|
9974
|
+
);
|
|
9975
|
+
}
|
|
9976
|
+
const enabledOAuth = caps.features.oauthProviders.filter((o) => o.isEnabled).map((o) => o.provider);
|
|
9977
|
+
if (enabledOAuth.length > 0) {
|
|
9978
|
+
lines.push(`- OAuth: ${enabledOAuth.join(", ")} \u2014 show OAuth buttons on login/register`);
|
|
9979
|
+
} else {
|
|
9980
|
+
lines.push(
|
|
9981
|
+
"- OAuth: None currently configured \u2014 STILL include the OAuthButtons component on login/register pages (it auto-hides when empty, but store owner may enable providers later)"
|
|
9982
|
+
);
|
|
9983
|
+
}
|
|
9984
|
+
lines.push(
|
|
9985
|
+
caps.features.hasShippingZones ? "- Shipping: configured" : "- Shipping: no zones configured"
|
|
9986
|
+
);
|
|
9987
|
+
lines.push(
|
|
9988
|
+
caps.features.hasDiscountRules ? "- Discounts: active \u2014 show banners and badges" : "- Discounts: none active"
|
|
9989
|
+
);
|
|
9990
|
+
lines.push(
|
|
9991
|
+
caps.features.hasDownloadableProducts ? "- Downloadable products: yes \u2014 show download badges" : "- Downloadable products: no"
|
|
9992
|
+
);
|
|
9993
|
+
lines.push(
|
|
9994
|
+
caps.features.hasCoupons ? "- Coupons: active \u2014 show coupon input on cart" : "- Coupons: none"
|
|
9995
|
+
);
|
|
9996
|
+
lines.push("");
|
|
9997
|
+
lines.push("### Connection Settings");
|
|
9998
|
+
lines.push(
|
|
9999
|
+
`- Email verification: ${caps.connection.requireEmailVerification ? "REQUIRED \u2014 verify-email page is critical" : "not required (still build the page)"}`
|
|
10000
|
+
);
|
|
10001
|
+
lines.push(
|
|
10002
|
+
`- Inventory reservation: ${caps.connection.reservationStrategy} (${caps.connection.reservationTimeout} min)`
|
|
10003
|
+
);
|
|
10004
|
+
lines.push(
|
|
10005
|
+
`- Guest checkout tracking: ${caps.connection.guestCheckoutTracking ? "enabled" : "disabled"}`
|
|
10006
|
+
);
|
|
10007
|
+
return lines.join("\n");
|
|
10008
|
+
}
|
|
10009
|
+
function buildStoreBundle(options) {
|
|
10010
|
+
const { connectionId, storeName, storeType, currency, storeStyle, capabilities } = options;
|
|
10011
|
+
const styleDesc = storeStyle ? ` with a ${storeStyle} aesthetic` : "";
|
|
10012
|
+
const ejsVars = {
|
|
10013
|
+
connectionId,
|
|
10014
|
+
storeName,
|
|
10015
|
+
currency,
|
|
10016
|
+
language: capabilities?.store.language || "en",
|
|
10017
|
+
apiUrl: "https://api.brainerce.com"
|
|
10018
|
+
};
|
|
10019
|
+
const sections = [];
|
|
10020
|
+
sections.push(`# Build: ${storeName} \u2014 ${storeType}${styleDesc}
|
|
10021
|
+
Connection ID: \`${connectionId}\` | Currency: ${currency}
|
|
10022
|
+
|
|
10023
|
+
Install the SDK first: \`npm install brainerce @stripe/stripe-js @stripe/react-stripe-js\`
|
|
10024
|
+
|
|
10025
|
+
## MANDATORY: Every page and component below MUST be built
|
|
10026
|
+
|
|
10027
|
+
**Do NOT skip any page or component.** Even features that appear unused (OAuth buttons, coupon input, discount badges, verify-email) MUST be included because:
|
|
10028
|
+
- They auto-hide when not configured \u2014 zero visual impact
|
|
10029
|
+
- Store owners can enable them at any time from the dashboard
|
|
10030
|
+
- A store without these is incomplete and will break when features are enabled
|
|
10031
|
+
|
|
10032
|
+
**Every file in this bundle is required. Build them ALL.**`);
|
|
10033
|
+
if (capabilities) {
|
|
10034
|
+
sections.push(formatCapabilitiesSummary(capabilities));
|
|
10035
|
+
}
|
|
10036
|
+
sections.push(getCriticalRulesSection());
|
|
10037
|
+
sections.push(getTypeQuickReference());
|
|
10038
|
+
const setupSection = ["## Setup Files\n"];
|
|
10039
|
+
for (const path of SETUP_FILES) {
|
|
10040
|
+
const rendered = renderFile(path, ejsVars);
|
|
10041
|
+
if (rendered) setupSection.push(rendered);
|
|
10042
|
+
}
|
|
10043
|
+
sections.push(setupSection.join("\n\n"));
|
|
10044
|
+
const pagesSection = ["## Pages (ALL 13 required \u2014 build every single one!)\n"];
|
|
10045
|
+
for (const page of PAGE_FILES) {
|
|
10046
|
+
const rendered = renderFile(page.path, ejsVars);
|
|
10047
|
+
if (rendered) {
|
|
10048
|
+
pagesSection.push(`### ${page.route} \u2014 ${page.label}
|
|
10049
|
+
|
|
10050
|
+
${rendered}`);
|
|
10051
|
+
}
|
|
10052
|
+
}
|
|
10053
|
+
sections.push(pagesSection.join("\n\n"));
|
|
10054
|
+
const componentsSection = ["## Components\n"];
|
|
10055
|
+
for (const comp of COMPONENT_FILES) {
|
|
10056
|
+
const rendered = renderFile(comp.path, ejsVars);
|
|
10057
|
+
if (rendered) componentsSection.push(rendered);
|
|
10058
|
+
}
|
|
10059
|
+
sections.push(componentsSection.join("\n\n"));
|
|
10060
|
+
const apiSection = ["## API Routes (BFF Proxy + Auth)\n"];
|
|
10061
|
+
for (const path of API_ROUTE_FILES) {
|
|
10062
|
+
const rendered = renderFile(path, ejsVars);
|
|
10063
|
+
if (rendered) apiSection.push(rendered);
|
|
10064
|
+
}
|
|
10065
|
+
sections.push(apiSection.join("\n\n"));
|
|
10066
|
+
sections.push(`## Verification Checklist
|
|
10067
|
+
|
|
10068
|
+
After building, verify you created ALL of these files:
|
|
10069
|
+
|
|
10070
|
+
### Setup
|
|
10071
|
+
- [ ] \`lib/brainerce.ts\` \u2014 SDK client singleton
|
|
10072
|
+
- [ ] \`lib/auth.ts\` \u2014 Auth helpers (BFF pattern)
|
|
10073
|
+
- [ ] \`providers/store-provider.tsx\` \u2014 Global store context
|
|
10074
|
+
- [ ] \`app/layout.tsx\` \u2014 Root layout with StoreProvider
|
|
10075
|
+
- [ ] \`middleware.ts\` \u2014 Auth middleware
|
|
10076
|
+
|
|
10077
|
+
### Pages (ALL 13 required!)
|
|
10078
|
+
- [ ] \`app/page.tsx\` \u2014 Home (hero, featured products)
|
|
10079
|
+
- [ ] \`app/products/page.tsx\` \u2014 Product listing with filters
|
|
10080
|
+
- [ ] \`app/products/[slug]/page.tsx\` \u2014 Product detail
|
|
10081
|
+
- [ ] \`app/cart/page.tsx\` \u2014 Cart with checkboxes, coupon
|
|
10082
|
+
- [ ] \`app/checkout/page.tsx\` \u2014 Address \u2192 Shipping \u2192 Payment
|
|
10083
|
+
- [ ] \`app/order-confirmation/page.tsx\` \u2014 waitForOrder() + handlePaymentSuccess()
|
|
10084
|
+
- [ ] \`app/login/page.tsx\` \u2014 Login
|
|
10085
|
+
- [ ] \`app/register/page.tsx\` \u2014 Register
|
|
10086
|
+
- [ ] \`app/verify-email/page.tsx\` \u2014 Email verification
|
|
10087
|
+
- [ ] \`app/forgot-password/page.tsx\` \u2014 Forgot password
|
|
10088
|
+
- [ ] \`app/reset-password/page.tsx\` \u2014 Reset password
|
|
10089
|
+
- [ ] \`app/auth/callback/page.tsx\` \u2014 OAuth callback
|
|
10090
|
+
- [ ] \`app/account/page.tsx\` \u2014 Account dashboard
|
|
10091
|
+
|
|
10092
|
+
### Components
|
|
10093
|
+
- [ ] Header with cart count + search autocomplete
|
|
10094
|
+
- [ ] Product card, grid, variant selector
|
|
10095
|
+
- [ ] Cart item, summary, coupon input
|
|
10096
|
+
- [ ] Checkout form, shipping step, payment step
|
|
10097
|
+
- [ ] Auth forms (login, register, OAuth buttons)
|
|
10098
|
+
|
|
10099
|
+
### API Routes
|
|
10100
|
+
- [ ] \`api/store/[...path]/route.ts\` \u2014 BFF proxy
|
|
10101
|
+
- [ ] \`api/auth/\` routes \u2014 OAuth callback, me, logout, reset
|
|
10102
|
+
|
|
10103
|
+
DO NOT STOP until every item is checked. A store with missing pages is broken.`);
|
|
10104
|
+
return sections.join("\n\n---\n\n");
|
|
10105
|
+
}
|
|
10106
|
+
|
|
10107
|
+
// src/tools/build-store.ts
|
|
10108
|
+
var BUILD_STORE_NAME = "build-store";
|
|
10109
|
+
var BUILD_STORE_DESCRIPTION = "Get EVERYTHING needed to build a complete Brainerce store in one call. Returns: critical rules, type reference, ALL page templates (13 pages + header), all components, API routes, and a verification checklist. This is the primary tool \u2014 call this first, then build all pages.";
|
|
10110
|
+
var BUILD_STORE_SCHEMA = {
|
|
10111
|
+
connectionId: import_zod7.z.string().describe("Vibe-coded connection ID (starts with vc_)"),
|
|
10112
|
+
storeName: import_zod7.z.string().describe('Name of the store (e.g., "Urban Threads")'),
|
|
10113
|
+
storeType: import_zod7.z.string().describe('Type of store (e.g., "Clothing store", "Electronics shop", "Bakery")'),
|
|
10114
|
+
currency: import_zod7.z.string().optional().default("USD").describe("Store currency code (e.g., USD, ILS, EUR)"),
|
|
10115
|
+
storeStyle: import_zod7.z.string().optional().describe('Design style (e.g., "minimalist", "luxury", "playful", "dark mode")')
|
|
10116
|
+
};
|
|
10117
|
+
async function handleBuildStore(args) {
|
|
10118
|
+
let capabilities = null;
|
|
10119
|
+
try {
|
|
10120
|
+
capabilities = await fetchStoreCapabilities(args.connectionId);
|
|
10121
|
+
} catch {
|
|
10122
|
+
}
|
|
10123
|
+
const bundle = buildStoreBundle({
|
|
10124
|
+
connectionId: args.connectionId,
|
|
10125
|
+
storeName: args.storeName,
|
|
10126
|
+
storeType: args.storeType,
|
|
10127
|
+
currency: args.currency || capabilities?.store.currency || "USD",
|
|
10128
|
+
storeStyle: args.storeStyle,
|
|
10129
|
+
capabilities
|
|
10130
|
+
});
|
|
10131
|
+
return {
|
|
10132
|
+
content: [{ type: "text", text: bundle }]
|
|
10133
|
+
};
|
|
10134
|
+
}
|
|
10135
|
+
|
|
10136
|
+
// src/tools/validate-store.ts
|
|
10137
|
+
var import_zod8 = require("zod");
|
|
10138
|
+
var VALIDATE_STORE_NAME = "validate-store";
|
|
10139
|
+
var VALIDATE_STORE_DESCRIPTION = "Check if a Brainerce store has all required pages and files. Pass the list of files you created and get back which required pages are missing. Call this after building the store to verify completeness.";
|
|
10140
|
+
var VALIDATE_STORE_SCHEMA = {
|
|
10141
|
+
files: import_zod8.z.array(import_zod8.z.string()).describe('List of file paths created (e.g., ["app/page.tsx", "app/products/page.tsx", ...])')
|
|
10142
|
+
};
|
|
10143
|
+
var REQUIRED_PAGES = [
|
|
10144
|
+
{
|
|
10145
|
+
route: "/",
|
|
10146
|
+
label: "Home",
|
|
10147
|
+
patterns: ["app/page.tsx", "page.tsx", "pages/index"],
|
|
10148
|
+
critical: true
|
|
10149
|
+
},
|
|
10150
|
+
{
|
|
10151
|
+
route: "/products",
|
|
10152
|
+
label: "Product Listing",
|
|
10153
|
+
patterns: ["app/products/page.tsx", "products/page.tsx", "pages/products"],
|
|
10154
|
+
critical: true
|
|
10155
|
+
},
|
|
10156
|
+
{
|
|
10157
|
+
route: "/products/[slug]",
|
|
10158
|
+
label: "Product Detail",
|
|
10159
|
+
patterns: ["app/products/[slug]", "products/[slug]", "pages/products/[slug]"],
|
|
10160
|
+
critical: true
|
|
10161
|
+
},
|
|
10162
|
+
{
|
|
10163
|
+
route: "/cart",
|
|
10164
|
+
label: "Cart",
|
|
10165
|
+
patterns: ["app/cart/page.tsx", "cart/page.tsx", "pages/cart"],
|
|
10166
|
+
critical: true
|
|
10167
|
+
},
|
|
10168
|
+
{
|
|
10169
|
+
route: "/checkout",
|
|
10170
|
+
label: "Checkout",
|
|
10171
|
+
patterns: ["app/checkout/page.tsx", "checkout/page.tsx", "pages/checkout"],
|
|
10172
|
+
critical: true
|
|
10173
|
+
},
|
|
10174
|
+
{
|
|
10175
|
+
route: "/order-confirmation",
|
|
10176
|
+
label: "Order Confirmation",
|
|
10177
|
+
patterns: ["app/order-confirmation", "order-confirmation", "checkout/success"],
|
|
10178
|
+
critical: true
|
|
10179
|
+
},
|
|
10180
|
+
{
|
|
10181
|
+
route: "/login",
|
|
10182
|
+
label: "Login",
|
|
10183
|
+
patterns: ["app/login/page.tsx", "login/page.tsx", "pages/login"],
|
|
10184
|
+
critical: true
|
|
10185
|
+
},
|
|
10186
|
+
{
|
|
10187
|
+
route: "/register",
|
|
10188
|
+
label: "Register",
|
|
10189
|
+
patterns: ["app/register/page.tsx", "register/page.tsx", "pages/register", "signup"],
|
|
10190
|
+
critical: true
|
|
10191
|
+
},
|
|
10192
|
+
{
|
|
10193
|
+
route: "/verify-email",
|
|
10194
|
+
label: "Verify Email",
|
|
10195
|
+
patterns: ["app/verify-email", "verify-email", "email-verification"],
|
|
10196
|
+
critical: true
|
|
10197
|
+
},
|
|
10198
|
+
{
|
|
10199
|
+
route: "/forgot-password",
|
|
10200
|
+
label: "Forgot Password",
|
|
10201
|
+
patterns: ["app/forgot-password", "forgot-password"],
|
|
10202
|
+
critical: true
|
|
10203
|
+
},
|
|
10204
|
+
{
|
|
10205
|
+
route: "/reset-password",
|
|
10206
|
+
label: "Reset Password",
|
|
10207
|
+
patterns: ["app/reset-password", "reset-password"],
|
|
10208
|
+
critical: true
|
|
10209
|
+
},
|
|
10210
|
+
{
|
|
10211
|
+
route: "/auth/callback",
|
|
10212
|
+
label: "OAuth Callback",
|
|
10213
|
+
patterns: ["app/auth/callback", "auth/callback", "oauth/callback"],
|
|
10214
|
+
critical: true
|
|
10215
|
+
},
|
|
10216
|
+
{
|
|
10217
|
+
route: "/account",
|
|
10218
|
+
label: "Account",
|
|
10219
|
+
patterns: ["app/account/page.tsx", "account/page.tsx", "pages/account", "profile"],
|
|
10220
|
+
critical: true
|
|
10221
|
+
}
|
|
10222
|
+
];
|
|
10223
|
+
var REQUIRED_COMPONENTS = [
|
|
10224
|
+
{ label: "Header", patterns: ["header", "Header", "navbar", "Navbar", "nav-bar"] },
|
|
10225
|
+
{ label: "SDK Setup (lib/brainerce.ts)", patterns: ["lib/brainerce", "brainerce.ts", "sdk"] },
|
|
10226
|
+
{ label: "Store Provider", patterns: ["store-provider", "StoreProvider", "provider"] },
|
|
10227
|
+
{ label: "BFF Proxy (api/store)", patterns: ["api/store", "api/proxy"] }
|
|
10228
|
+
];
|
|
10229
|
+
function fileMatchesPattern(file, patterns) {
|
|
10230
|
+
const normalized = file.replace(/\\/g, "/").toLowerCase();
|
|
10231
|
+
return patterns.some((pattern) => normalized.includes(pattern.toLowerCase()));
|
|
10232
|
+
}
|
|
10233
|
+
async function handleValidateStore(args) {
|
|
10234
|
+
const { files } = args;
|
|
10235
|
+
const missingPages = [];
|
|
10236
|
+
const foundPages = [];
|
|
10237
|
+
for (const page of REQUIRED_PAGES) {
|
|
10238
|
+
if (files.some((f) => fileMatchesPattern(f, page.patterns))) {
|
|
10239
|
+
foundPages.push({ route: page.route, label: page.label });
|
|
10240
|
+
} else {
|
|
10241
|
+
missingPages.push({ route: page.route, label: page.label });
|
|
10242
|
+
}
|
|
10243
|
+
}
|
|
10244
|
+
const missingComponents = [];
|
|
10245
|
+
const foundComponents = [];
|
|
10246
|
+
for (const comp of REQUIRED_COMPONENTS) {
|
|
10247
|
+
if (files.some((f) => fileMatchesPattern(f, comp.patterns))) {
|
|
10248
|
+
foundComponents.push(comp.label);
|
|
10249
|
+
} else {
|
|
10250
|
+
missingComponents.push(comp.label);
|
|
10251
|
+
}
|
|
10252
|
+
}
|
|
10253
|
+
const totalRequired = REQUIRED_PAGES.length + REQUIRED_COMPONENTS.length;
|
|
10254
|
+
const totalFound = foundPages.length + foundComponents.length;
|
|
10255
|
+
const lines = [];
|
|
10256
|
+
if (missingPages.length === 0 && missingComponents.length === 0) {
|
|
10257
|
+
lines.push("# Store Validation: COMPLETE");
|
|
10258
|
+
lines.push(`All ${totalRequired} required items found. The store is complete.`);
|
|
10259
|
+
lines.push("");
|
|
10260
|
+
lines.push("## Pages Found");
|
|
10261
|
+
for (const page of foundPages) {
|
|
10262
|
+
lines.push(`- [x] ${page.route} \u2014 ${page.label}`);
|
|
10263
|
+
}
|
|
10264
|
+
lines.push("");
|
|
10265
|
+
lines.push("## Components Found");
|
|
10266
|
+
for (const comp of foundComponents) {
|
|
10267
|
+
lines.push(`- [x] ${comp}`);
|
|
10268
|
+
}
|
|
10269
|
+
} else {
|
|
10270
|
+
lines.push(`# Store Validation: INCOMPLETE (${totalFound}/${totalRequired})`);
|
|
10271
|
+
lines.push("");
|
|
10272
|
+
if (missingPages.length > 0) {
|
|
10273
|
+
lines.push(`## Missing Pages (${missingPages.length})`);
|
|
10274
|
+
for (const page of missingPages) {
|
|
10275
|
+
lines.push(
|
|
10276
|
+
`- [ ] **${page.route}** \u2014 ${page.label} \u2014 Use \`get-page-code\` with page "${page.label.toLowerCase().replace(/\s+/g, "-")}" to get the template`
|
|
10277
|
+
);
|
|
10278
|
+
}
|
|
10279
|
+
lines.push("");
|
|
10280
|
+
}
|
|
10281
|
+
if (missingComponents.length > 0) {
|
|
10282
|
+
lines.push(`## Missing Components (${missingComponents.length})`);
|
|
10283
|
+
for (const comp of missingComponents) {
|
|
10284
|
+
lines.push(`- [ ] **${comp}**`);
|
|
10285
|
+
}
|
|
10286
|
+
lines.push("");
|
|
10287
|
+
}
|
|
10288
|
+
lines.push("## Found");
|
|
10289
|
+
for (const page of foundPages) {
|
|
10290
|
+
lines.push(`- [x] ${page.route} \u2014 ${page.label}`);
|
|
10291
|
+
}
|
|
10292
|
+
for (const comp of foundComponents) {
|
|
10293
|
+
lines.push(`- [x] ${comp}`);
|
|
10294
|
+
}
|
|
10295
|
+
lines.push("");
|
|
10296
|
+
lines.push("**Build the missing pages before considering the store complete!**");
|
|
10297
|
+
}
|
|
10298
|
+
return {
|
|
10299
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
10300
|
+
};
|
|
10301
|
+
}
|
|
10302
|
+
|
|
10303
|
+
// src/tools/get-page-code.ts
|
|
10304
|
+
var import_zod9 = require("zod");
|
|
10305
|
+
var GET_PAGE_CODE_NAME = "get-page-code";
|
|
10306
|
+
var GET_PAGE_CODE_DESCRIPTION = "Get production-ready template code for specific pages. Returns deduplicated code for the requested pages and their components. Use this to drill down into specific pages after calling build-store, or to get code for missing pages.";
|
|
10307
|
+
var GET_PAGE_CODE_SCHEMA = {
|
|
10308
|
+
pages: import_zod9.z.array(
|
|
10309
|
+
import_zod9.z.enum([
|
|
10310
|
+
"home",
|
|
10311
|
+
"products",
|
|
10312
|
+
"product-detail",
|
|
10313
|
+
"cart",
|
|
10314
|
+
"checkout",
|
|
10315
|
+
"order-confirmation",
|
|
10316
|
+
"login",
|
|
10317
|
+
"register",
|
|
10318
|
+
"verify-email",
|
|
10319
|
+
"forgot-password",
|
|
10320
|
+
"reset-password",
|
|
10321
|
+
"oauth-callback",
|
|
10322
|
+
"account",
|
|
10323
|
+
"header",
|
|
10324
|
+
"setup"
|
|
10325
|
+
])
|
|
10326
|
+
).describe('Pages to get code for (e.g., ["checkout", "login", "account"])')
|
|
10327
|
+
};
|
|
10328
|
+
var PAGE_TO_FILES = {
|
|
10329
|
+
setup: [
|
|
10330
|
+
{ path: "src/lib/brainerce.ts.ejs", label: "SDK Client" },
|
|
10331
|
+
{ path: "src/lib/auth.ts", label: "Auth Helpers" },
|
|
10332
|
+
{ path: "src/lib/utils.ts", label: "Utilities" },
|
|
10333
|
+
{ path: "src/providers/store-provider.tsx.ejs", label: "Store Provider" },
|
|
10334
|
+
{ path: "src/app/layout.tsx.ejs", label: "Root Layout" },
|
|
10335
|
+
{ path: "src/middleware.ts", label: "Middleware" },
|
|
10336
|
+
{ path: "src/app/api/store/[...path]/route.ts", label: "BFF Proxy" }
|
|
10337
|
+
],
|
|
10338
|
+
home: [
|
|
10339
|
+
{ path: "src/app/page.tsx", label: "Home Page" },
|
|
10340
|
+
{ path: "src/components/products/product-card.tsx", label: "Product Card" },
|
|
10341
|
+
{ path: "src/components/products/product-grid.tsx", label: "Product Grid" },
|
|
10342
|
+
{ path: "src/components/shared/price-display.tsx", label: "Price Display" }
|
|
10343
|
+
],
|
|
10344
|
+
products: [
|
|
10345
|
+
{ path: "src/app/products/page.tsx", label: "Products Page" },
|
|
10346
|
+
{ path: "src/components/products/product-card.tsx", label: "Product Card" },
|
|
10347
|
+
{ path: "src/components/products/product-grid.tsx", label: "Product Grid" },
|
|
10348
|
+
{ path: "src/components/shared/price-display.tsx", label: "Price Display" }
|
|
10349
|
+
],
|
|
10350
|
+
"product-detail": [
|
|
10351
|
+
{ path: "src/app/products/[slug]/page.tsx", label: "Product Detail Page" },
|
|
10352
|
+
{ path: "src/components/products/variant-selector.tsx", label: "Variant Selector" },
|
|
10353
|
+
{ path: "src/components/products/stock-badge.tsx", label: "Stock Badge" },
|
|
10354
|
+
{ path: "src/components/products/discount-badge.tsx", label: "Discount Badge" },
|
|
10355
|
+
{ path: "src/components/shared/price-display.tsx", label: "Price Display" }
|
|
10356
|
+
],
|
|
10357
|
+
cart: [
|
|
10358
|
+
{ path: "src/app/cart/page.tsx", label: "Cart Page" },
|
|
10359
|
+
{ path: "src/components/cart/cart-item.tsx", label: "Cart Item" },
|
|
10360
|
+
{ path: "src/components/cart/cart-summary.tsx", label: "Cart Summary" },
|
|
10361
|
+
{ path: "src/components/cart/coupon-input.tsx", label: "Coupon Input" },
|
|
10362
|
+
{ path: "src/components/cart/cart-nudges.tsx", label: "Cart Nudges" },
|
|
10363
|
+
{ path: "src/components/cart/reservation-countdown.tsx", label: "Reservation Countdown" }
|
|
10364
|
+
],
|
|
10365
|
+
checkout: [
|
|
10366
|
+
{ path: "src/app/checkout/page.tsx", label: "Checkout Page" },
|
|
10367
|
+
{ path: "src/components/checkout/checkout-form.tsx", label: "Checkout Form" },
|
|
10368
|
+
{ path: "src/components/checkout/shipping-step.tsx", label: "Shipping Step" },
|
|
10369
|
+
{ path: "src/components/checkout/payment-step.tsx", label: "Payment Step" },
|
|
10370
|
+
{ path: "src/components/checkout/tax-display.tsx", label: "Tax Display" }
|
|
10371
|
+
],
|
|
10372
|
+
"order-confirmation": [
|
|
10373
|
+
{ path: "src/app/order-confirmation/page.tsx", label: "Order Confirmation Page" }
|
|
10374
|
+
],
|
|
10375
|
+
login: [
|
|
10376
|
+
{ path: "src/app/login/page.tsx", label: "Login Page" },
|
|
10377
|
+
{ path: "src/components/auth/login-form.tsx", label: "Login Form" },
|
|
10378
|
+
{ path: "src/components/auth/oauth-buttons.tsx", label: "OAuth Buttons" }
|
|
10379
|
+
],
|
|
10380
|
+
register: [
|
|
10381
|
+
{ path: "src/app/register/page.tsx", label: "Register Page" },
|
|
10382
|
+
{ path: "src/components/auth/register-form.tsx", label: "Register Form" },
|
|
10383
|
+
{ path: "src/components/auth/oauth-buttons.tsx", label: "OAuth Buttons" }
|
|
10384
|
+
],
|
|
10385
|
+
"verify-email": [{ path: "src/app/verify-email/page.tsx", label: "Verify Email Page" }],
|
|
10386
|
+
"forgot-password": [{ path: "src/app/forgot-password/page.tsx", label: "Forgot Password Page" }],
|
|
10387
|
+
"reset-password": [
|
|
10388
|
+
{ path: "src/app/reset-password/page.tsx", label: "Reset Password Page" },
|
|
10389
|
+
{ path: "src/app/api/auth/reset-callback/route.ts", label: "Reset Callback API" },
|
|
10390
|
+
{ path: "src/app/api/auth/reset-password/route.ts", label: "Reset Password API" }
|
|
10391
|
+
],
|
|
10392
|
+
"oauth-callback": [
|
|
10393
|
+
{ path: "src/app/auth/callback/page.tsx", label: "OAuth Callback Page" },
|
|
10394
|
+
{ path: "src/app/api/auth/oauth-callback/route.ts", label: "OAuth Callback API" }
|
|
10395
|
+
],
|
|
10396
|
+
account: [
|
|
10397
|
+
{ path: "src/app/account/page.tsx", label: "Account Page" },
|
|
10398
|
+
{ path: "src/components/account/profile-section.tsx", label: "Profile Section" },
|
|
10399
|
+
{ path: "src/components/account/order-history.tsx", label: "Order History" }
|
|
10400
|
+
],
|
|
10401
|
+
header: [
|
|
10402
|
+
{ path: "src/components/layout/header.tsx", label: "Header" },
|
|
10403
|
+
{ path: "src/components/layout/footer.tsx", label: "Footer" },
|
|
10404
|
+
{ path: "src/hooks/use-search.ts", label: "Search Hook" }
|
|
10405
|
+
]
|
|
10406
|
+
};
|
|
10407
|
+
async function handleGetPageCode(args) {
|
|
10408
|
+
const emitted = /* @__PURE__ */ new Set();
|
|
10409
|
+
const results = [];
|
|
10410
|
+
for (const page of args.pages) {
|
|
10411
|
+
const files = PAGE_TO_FILES[page];
|
|
10412
|
+
if (!files) {
|
|
10413
|
+
results.push(
|
|
10414
|
+
`# Unknown page: "${page}"
|
|
10415
|
+
Available: ${Object.keys(PAGE_TO_FILES).join(", ")}`
|
|
10416
|
+
);
|
|
10417
|
+
continue;
|
|
10418
|
+
}
|
|
10419
|
+
const pageResults = [];
|
|
10420
|
+
for (const file of files) {
|
|
10421
|
+
if (emitted.has(file.path)) continue;
|
|
10422
|
+
emitted.add(file.path);
|
|
10423
|
+
const content = EMBEDDED_TEMPLATES[file.path];
|
|
10424
|
+
if (!content) continue;
|
|
10425
|
+
const displayPath = file.path.replace(/\.ejs$/, "");
|
|
10426
|
+
pageResults.push(`// \u2500\u2500 ${displayPath} (${file.label}) \u2500\u2500
|
|
10427
|
+
${content}`);
|
|
10428
|
+
}
|
|
10429
|
+
if (pageResults.length > 0) {
|
|
10430
|
+
results.push(`# ${page}
|
|
10431
|
+
|
|
10432
|
+
${pageResults.join("\n\n")}`);
|
|
10433
|
+
}
|
|
10434
|
+
}
|
|
10435
|
+
if (results.length === 0) {
|
|
10436
|
+
return {
|
|
10437
|
+
content: [
|
|
10438
|
+
{
|
|
10439
|
+
type: "text",
|
|
10440
|
+
text: `No files found. Available pages: ${Object.keys(PAGE_TO_FILES).join(", ")}`
|
|
10441
|
+
}
|
|
10442
|
+
]
|
|
10443
|
+
};
|
|
10444
|
+
}
|
|
10445
|
+
return {
|
|
10446
|
+
content: [
|
|
10447
|
+
{
|
|
10448
|
+
type: "text",
|
|
10449
|
+
text: `# Page Code (${args.pages.join(", ")})
|
|
10450
|
+
|
|
10451
|
+
Production-ready template code. Use as-is \u2014 customize only the design/styling.
|
|
10452
|
+
|
|
10453
|
+
${results.join("\n\n---\n\n")}`
|
|
10454
|
+
}
|
|
10455
|
+
]
|
|
10456
|
+
};
|
|
10457
|
+
}
|
|
10458
|
+
|
|
9039
10459
|
// src/resources/sdk-types.ts
|
|
9040
10460
|
var SDK_TYPES_URI = "brainerce://sdk/types";
|
|
9041
10461
|
var SDK_TYPES_NAME = "Brainerce SDK Types";
|
|
@@ -9190,24 +10610,57 @@ async function handleProjectTemplate(uri) {
|
|
|
9190
10610
|
}
|
|
9191
10611
|
|
|
9192
10612
|
// src/prompts/create-store.ts
|
|
9193
|
-
var
|
|
10613
|
+
var import_zod10 = require("zod");
|
|
9194
10614
|
var CREATE_STORE_NAME = "create-store";
|
|
9195
|
-
var CREATE_STORE_DESCRIPTION = "Generate a personalized prompt for building a complete Brainerce e-commerce store.
|
|
10615
|
+
var CREATE_STORE_DESCRIPTION = "Generate a personalized prompt for building a complete Brainerce e-commerce store. Instructs the AI to call build-store for all templates and validate-store when done.";
|
|
9196
10616
|
var CREATE_STORE_SCHEMA = {
|
|
9197
|
-
connectionId:
|
|
9198
|
-
storeName:
|
|
9199
|
-
storeType:
|
|
9200
|
-
currency:
|
|
9201
|
-
storeStyle:
|
|
10617
|
+
connectionId: import_zod10.z.string().describe("Vibe-coded connection ID (starts with vc_)"),
|
|
10618
|
+
storeName: import_zod10.z.string().describe('Name of the store (e.g., "Urban Threads")'),
|
|
10619
|
+
storeType: import_zod10.z.string().describe('Type of store (e.g., "Clothing store", "Electronics shop", "Bakery")'),
|
|
10620
|
+
currency: import_zod10.z.string().optional().default("USD").describe("Store currency code (e.g., USD, ILS, EUR)"),
|
|
10621
|
+
storeStyle: import_zod10.z.string().optional().describe('Design style (e.g., "minimalist", "luxury", "playful", "dark mode")')
|
|
9202
10622
|
};
|
|
9203
10623
|
function handleCreateStore(args) {
|
|
9204
|
-
const
|
|
9205
|
-
|
|
9206
|
-
|
|
9207
|
-
|
|
9208
|
-
|
|
9209
|
-
|
|
9210
|
-
|
|
10624
|
+
const styleDesc = args.storeStyle ? ` with a ${args.storeStyle} design style` : "";
|
|
10625
|
+
const prompt = `# Build a Brainerce E-Commerce Store
|
|
10626
|
+
|
|
10627
|
+
## Task
|
|
10628
|
+
Build a complete **${args.storeType}** called **${args.storeName}**${styleDesc}.
|
|
10629
|
+
Connection ID: \`${args.connectionId}\` | Currency: ${args.currency || "USD"}
|
|
10630
|
+
|
|
10631
|
+
## Step 1: Get Everything You Need (ONE call)
|
|
10632
|
+
Call the \`build-store\` tool with:
|
|
10633
|
+
- connectionId: "${args.connectionId}"
|
|
10634
|
+
- storeName: "${args.storeName}"
|
|
10635
|
+
- storeType: "${args.storeType}"
|
|
10636
|
+
- currency: "${args.currency || "USD"}"${args.storeStyle ? `
|
|
10637
|
+
- storeStyle: "${args.storeStyle}"` : ""}
|
|
10638
|
+
|
|
10639
|
+
This returns ALL the code templates, critical rules, type reference, and component code you need.
|
|
10640
|
+
|
|
10641
|
+
## Step 2: Build ALL 13 Pages + Header
|
|
10642
|
+
Using the templates from build-store, create every page listed. Customize the design and styling to match the store type, but keep the SDK integration logic exactly as shown.
|
|
10643
|
+
|
|
10644
|
+
DO NOT STOP until all 13 pages + header are implemented:
|
|
10645
|
+
1. \`/\` \u2014 Home
|
|
10646
|
+
2. \`/products\` \u2014 Product listing
|
|
10647
|
+
3. \`/products/[slug]\` \u2014 Product detail
|
|
10648
|
+
4. \`/cart\` \u2014 Cart
|
|
10649
|
+
5. \`/checkout\` \u2014 Checkout
|
|
10650
|
+
6. \`/order-confirmation\` \u2014 Order confirmation
|
|
10651
|
+
7. \`/login\` \u2014 Login
|
|
10652
|
+
8. \`/register\` \u2014 Register
|
|
10653
|
+
9. \`/verify-email\` \u2014 Email verification
|
|
10654
|
+
10. \`/forgot-password\` \u2014 Forgot password
|
|
10655
|
+
11. \`/reset-password\` \u2014 Reset password
|
|
10656
|
+
12. \`/auth/callback\` \u2014 OAuth callback
|
|
10657
|
+
13. \`/account\` \u2014 Account dashboard
|
|
10658
|
+
14. Header component with cart count + search
|
|
10659
|
+
|
|
10660
|
+
## Step 3: Validate
|
|
10661
|
+
After building all pages, call \`validate-store\` with the list of files you created to verify nothing is missing.
|
|
10662
|
+
|
|
10663
|
+
**Start now \u2014 call \`build-store\` to get all the templates.**`;
|
|
9211
10664
|
return {
|
|
9212
10665
|
messages: [
|
|
9213
10666
|
{
|
|
@@ -9222,11 +10675,11 @@ function handleCreateStore(args) {
|
|
|
9222
10675
|
}
|
|
9223
10676
|
|
|
9224
10677
|
// src/prompts/add-feature.ts
|
|
9225
|
-
var
|
|
10678
|
+
var import_zod11 = require("zod");
|
|
9226
10679
|
var ADD_FEATURE_NAME = "add-feature";
|
|
9227
10680
|
var ADD_FEATURE_DESCRIPTION = "Generate a focused prompt for adding a specific feature to an existing Brainerce store. Returns targeted SDK docs, types, and code examples for just that feature.";
|
|
9228
10681
|
var ADD_FEATURE_SCHEMA = {
|
|
9229
|
-
feature:
|
|
10682
|
+
feature: import_zod11.z.enum([
|
|
9230
10683
|
"products",
|
|
9231
10684
|
"cart",
|
|
9232
10685
|
"checkout",
|
|
@@ -9239,8 +10692,8 @@ var ADD_FEATURE_SCHEMA = {
|
|
|
9239
10692
|
"search",
|
|
9240
10693
|
"tax"
|
|
9241
10694
|
]).describe("The feature to add"),
|
|
9242
|
-
connectionId:
|
|
9243
|
-
currency:
|
|
10695
|
+
connectionId: import_zod11.z.string().optional().describe("Vibe-coded connection ID (starts with vc_)"),
|
|
10696
|
+
currency: import_zod11.z.string().optional().default("USD").describe("Store currency code")
|
|
9244
10697
|
};
|
|
9245
10698
|
var FEATURE_TO_TOPICS = {
|
|
9246
10699
|
products: { topics: ["products"], domains: ["products"] },
|
|
@@ -9309,7 +10762,7 @@ ${types}
|
|
|
9309
10762
|
function createServer() {
|
|
9310
10763
|
const server = new import_mcp.McpServer({
|
|
9311
10764
|
name: "brainerce",
|
|
9312
|
-
version: "
|
|
10765
|
+
version: "2.0.0"
|
|
9313
10766
|
});
|
|
9314
10767
|
server.tool(GET_SDK_DOCS_NAME, GET_SDK_DOCS_DESCRIPTION, GET_SDK_DOCS_SCHEMA, handleGetSdkDocs);
|
|
9315
10768
|
server.tool(
|
|
@@ -9336,6 +10789,25 @@ function createServer() {
|
|
|
9336
10789
|
GET_STORE_INFO_SCHEMA,
|
|
9337
10790
|
handleGetStoreInfo
|
|
9338
10791
|
);
|
|
10792
|
+
server.tool(
|
|
10793
|
+
GET_STORE_CAPABILITIES_NAME,
|
|
10794
|
+
GET_STORE_CAPABILITIES_DESCRIPTION,
|
|
10795
|
+
GET_STORE_CAPABILITIES_SCHEMA,
|
|
10796
|
+
handleGetStoreCapabilities
|
|
10797
|
+
);
|
|
10798
|
+
server.tool(BUILD_STORE_NAME, BUILD_STORE_DESCRIPTION, BUILD_STORE_SCHEMA, handleBuildStore);
|
|
10799
|
+
server.tool(
|
|
10800
|
+
VALIDATE_STORE_NAME,
|
|
10801
|
+
VALIDATE_STORE_DESCRIPTION,
|
|
10802
|
+
VALIDATE_STORE_SCHEMA,
|
|
10803
|
+
handleValidateStore
|
|
10804
|
+
);
|
|
10805
|
+
server.tool(
|
|
10806
|
+
GET_PAGE_CODE_NAME,
|
|
10807
|
+
GET_PAGE_CODE_DESCRIPTION,
|
|
10808
|
+
GET_PAGE_CODE_SCHEMA,
|
|
10809
|
+
handleGetPageCode
|
|
10810
|
+
);
|
|
9339
10811
|
server.resource(
|
|
9340
10812
|
SDK_TYPES_NAME,
|
|
9341
10813
|
SDK_TYPES_URI,
|
|
@@ -9362,7 +10834,9 @@ function createServer() {
|
|
|
9362
10834
|
AVAILABLE_DOMAINS,
|
|
9363
10835
|
PROJECT_TEMPLATE,
|
|
9364
10836
|
buildFullStorePrompt,
|
|
10837
|
+
buildStoreBundle,
|
|
9365
10838
|
createServer,
|
|
10839
|
+
fetchStoreCapabilities,
|
|
9366
10840
|
fetchStoreInfo,
|
|
9367
10841
|
getAllTypes,
|
|
9368
10842
|
getSectionByTopic,
|