@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/bin/stdio.js
CHANGED
|
@@ -101,7 +101,7 @@ function getTypeQuickReference() {
|
|
|
101
101
|
- \`SetShippingAddressDto.email\` is **required** \u2014 always include email
|
|
102
102
|
|
|
103
103
|
**Mandatory helpers (import from \`'brainerce'\`):**
|
|
104
|
-
\`getCartTotals\`, \`getCartItemName\`, \`getCartItemImage\`, \`formatPrice\`, \`getProductPriceInfo\`, \`getVariantPrice\`, \`getStockStatus\`, \`getVariantOptions\`, \`getDescriptionContent\`, \`getProductMetafieldValue\``;
|
|
104
|
+
\`getCartTotals\`, \`getCartItemName\`, \`getCartItemImage\`, \`formatPrice\`, \`getProductPriceInfo\`, \`getVariantPrice\`, \`getStockStatus\`, \`getVariantOptions\`, \`getProductSwatches\`, \`getDescriptionContent\`, \`getProductMetafieldValue\``;
|
|
105
105
|
}
|
|
106
106
|
function getSdkSetupSection(connectionId) {
|
|
107
107
|
return `## SDK Setup
|
|
@@ -589,6 +589,25 @@ const material = getProductMetafieldValue(product, 'material');
|
|
|
589
589
|
|
|
590
590
|
**Metafield fields:** \`definitionName\` (display label), \`definitionKey\` (lookup key), \`value\`, \`type\` (IMAGE, GALLERY, URL, COLOR, BOOLEAN, DATE, DATETIME, TEXT, TEXTAREA, NUMBER, DIMENSION, WEIGHT, JSON)
|
|
591
591
|
|
|
592
|
+
### Downloadable / Digital Products
|
|
593
|
+
|
|
594
|
+
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.
|
|
595
|
+
|
|
596
|
+
\`\`\`typescript
|
|
597
|
+
import type { DownloadFile } from 'brainerce';
|
|
598
|
+
|
|
599
|
+
// Check if product is digital
|
|
600
|
+
if (product.isDownloadable) {
|
|
601
|
+
// Show "Instant Download" badge instead of StockBadge
|
|
602
|
+
// List files: product.downloads?.map((file: DownloadFile) => file.name)
|
|
603
|
+
// Show "Download available after purchase" note
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// After purchase \u2014 get download links from order
|
|
607
|
+
const downloads = await client.getOrderDownloads(orderId);
|
|
608
|
+
// downloads[]: { fileName, downloadUrl, downloadsUsed, downloadLimit, expiresAt }
|
|
609
|
+
\`\`\`
|
|
610
|
+
|
|
592
611
|
### Categories
|
|
593
612
|
|
|
594
613
|
\`\`\`typescript
|
|
@@ -1169,60 +1188,6 @@ ${getAdminApiSection()}
|
|
|
1169
1188
|
|
|
1170
1189
|
Start building with the SDK!`;
|
|
1171
1190
|
}
|
|
1172
|
-
function buildMcpStorePrompt(options) {
|
|
1173
|
-
const { connectionId, storeName, currency, storeType, storeStyle } = options;
|
|
1174
|
-
const styleDesc = storeStyle ? ` with a ${storeStyle} aesthetic` : "";
|
|
1175
|
-
return `# Build a Brainerce E-Commerce Store
|
|
1176
|
-
|
|
1177
|
-
## Task
|
|
1178
|
-
Build a **${storeType}** called **${storeName}**${styleDesc}.
|
|
1179
|
-
Use the Brainerce SDK (\`npm install brainerce\`) to fetch all data \u2014 never hardcode products.
|
|
1180
|
-
|
|
1181
|
-
## Connection
|
|
1182
|
-
- **Connection ID:** \`${connectionId}\`
|
|
1183
|
-
- **Currency:** ${currency}
|
|
1184
|
-
|
|
1185
|
-
${getCriticalRulesSection()}
|
|
1186
|
-
|
|
1187
|
-
${getTypeQuickReference()}
|
|
1188
|
-
|
|
1189
|
-
## Required Pages
|
|
1190
|
-
- \`/\` \u2014 Home (hero, featured products, categories)
|
|
1191
|
-
- \`/products\` \u2014 Product list with infinite scroll
|
|
1192
|
-
- \`/products/[slug]\` \u2014 Product detail (images, variants, add to cart)
|
|
1193
|
-
- \`/cart\` \u2014 Cart with checkboxes, quantities, coupon input
|
|
1194
|
-
- \`/checkout\` \u2014 Customer info \u2192 Shipping \u2192 Payment
|
|
1195
|
-
- \`/order-confirmation\` \u2014 MUST call \`waitForOrder()\` + \`handlePaymentSuccess()\`
|
|
1196
|
-
- \`/login\` \u2014 Email/password + social login
|
|
1197
|
-
- \`/register\` \u2014 Registration + social signup
|
|
1198
|
-
- \`/verify-email\` \u2014 6-digit code verification (always build, even if disabled)
|
|
1199
|
-
- \`/auth/callback\` \u2014 OAuth redirect handler
|
|
1200
|
-
- \`/account\` \u2014 Profile + order history (protected)
|
|
1201
|
-
- Header \u2014 Logo, nav, cart count, search bar with autocomplete
|
|
1202
|
-
|
|
1203
|
-
## How to Build (Use MCP Tools!)
|
|
1204
|
-
Build pages in this order. For each page, call the MCP tools to get the code and docs you need:
|
|
1205
|
-
|
|
1206
|
-
1. **SDK Setup** \u2014 \`get-code-example\` \u2192 \`sdk-setup\`, then \`store-provider\`
|
|
1207
|
-
2. **Layout** \u2014 \`get-code-example\` \u2192 \`layout\`
|
|
1208
|
-
3. **Header** \u2014 \`get-code-example\` \u2192 \`header\`
|
|
1209
|
-
4. **Home** \u2014 \`get-code-example\` \u2192 \`home-page\`
|
|
1210
|
-
5. **Products** \u2014 \`get-code-example\` \u2192 \`product-listing\`, then \`product-detail\`
|
|
1211
|
-
6. **Cart** \u2014 \`get-code-example\` \u2192 \`cart-page\`, then \`coupon-input\`
|
|
1212
|
-
7. **Checkout** \u2014 \`get-code-example\` \u2192 \`checkout-page\` | \`get-sdk-docs\` \u2192 topic: \`checkout\`
|
|
1213
|
-
8. **Payment** \u2014 \`get-code-example\` \u2192 \`stripe-payment\` / \`grow-payment\` / \`paypal-payment\`
|
|
1214
|
-
9. **Order Confirmation** \u2014 \`get-code-example\` \u2192 \`order-confirmation\`
|
|
1215
|
-
10. **Auth** \u2014 \`get-code-example\` \u2192 \`login-page\`, \`register-page\`, \`verify-email\`, \`oauth-callback\`
|
|
1216
|
-
11. **Account** \u2014 \`get-code-example\` \u2192 \`account-page\`
|
|
1217
|
-
|
|
1218
|
-
**Additional tools:**
|
|
1219
|
-
- \`get-sdk-docs\` \u2014 Detailed API docs per topic (setup, products, cart, checkout, payment, auth, inventory, discounts, tax, etc.)
|
|
1220
|
-
- \`get-type-definitions\` \u2014 Full TypeScript type definitions for any SDK type
|
|
1221
|
-
- \`get-required-pages\` \u2014 Complete pages checklist with design guidelines
|
|
1222
|
-
- \`get-store-info\` \u2014 Fetch live store data (name, currency, product count)
|
|
1223
|
-
|
|
1224
|
-
Start with step 1 \u2014 call \`get-code-example\` with useCase \`sdk-setup\` now!`;
|
|
1225
|
-
}
|
|
1226
1191
|
function getSectionByTopic(topic, connectionId, currency) {
|
|
1227
1192
|
const cid = connectionId || "vc_YOUR_CONNECTION_ID";
|
|
1228
1193
|
const cur = currency || "USD";
|
|
@@ -1325,10 +1290,21 @@ interface Product {
|
|
|
1325
1290
|
brands?: Array<{ id: string; name: string }>;
|
|
1326
1291
|
tags?: string[];
|
|
1327
1292
|
metafields?: ProductMetafield[];
|
|
1293
|
+
productAttributeOptions?: Array<{
|
|
1294
|
+
id: string;
|
|
1295
|
+
attributeId: string;
|
|
1296
|
+
attributeOptionId: string;
|
|
1297
|
+
platform: string;
|
|
1298
|
+
attribute: { id: string; name: string; displayType?: 'DEFAULT' | 'COLOR_SWATCH' | 'IMAGE_SWATCH' } | null;
|
|
1299
|
+
attributeOption: { id: string; name: string; value?: string | null; swatchColor?: string | null; swatchColor2?: string | null; swatchImageUrl?: string | null } | null;
|
|
1300
|
+
}>;
|
|
1328
1301
|
createdAt: string;
|
|
1329
1302
|
updatedAt: string;
|
|
1330
1303
|
}
|
|
1331
1304
|
|
|
1305
|
+
// Use getProductSwatches(product) to get grouped swatch data for storefront rendering.
|
|
1306
|
+
// Returns Array<{ attributeName, displayType, options: Array<{ name, swatchColor?, swatchColor2?, swatchImageUrl? }> }>
|
|
1307
|
+
|
|
1332
1308
|
interface ProductImage {
|
|
1333
1309
|
url: string;
|
|
1334
1310
|
position?: number;
|
|
@@ -1376,7 +1352,7 @@ interface ProductQueryParams {
|
|
|
1376
1352
|
page?: number;
|
|
1377
1353
|
limit?: number;
|
|
1378
1354
|
search?: string;
|
|
1379
|
-
status?: 'active' | 'draft'
|
|
1355
|
+
status?: 'active' | 'draft';
|
|
1380
1356
|
categories?: string | string[];
|
|
1381
1357
|
brands?: string | string[];
|
|
1382
1358
|
tags?: string | string[];
|
|
@@ -1936,6 +1912,17 @@ export function setStoredCartId(cartId: string | null): void {
|
|
|
1936
1912
|
export function initClient(): BrainerceClient {
|
|
1937
1913
|
return getClient();
|
|
1938
1914
|
}
|
|
1915
|
+
|
|
1916
|
+
// Server-side client \u2014 calls backend directly (no proxy needed for public data)
|
|
1917
|
+
// Used by Server Components for SSR data fetching (generateMetadata, page rendering)
|
|
1918
|
+
export function getServerClient(): BrainerceClient {
|
|
1919
|
+
const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
|
|
1920
|
+
return new BrainerceClient({
|
|
1921
|
+
connectionId: CONNECTION_ID,
|
|
1922
|
+
baseUrl: apiUrl,
|
|
1923
|
+
origin: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
|
1924
|
+
});
|
|
1925
|
+
}
|
|
1939
1926
|
`,
|
|
1940
1927
|
"src/lib/auth.ts": `/**
|
|
1941
1928
|
* Client-side auth helpers that call the BFF proxy API routes.
|
|
@@ -2020,6 +2007,7 @@ export async function proxyRegister(data: {
|
|
|
2020
2007
|
lastName: string;
|
|
2021
2008
|
email: string;
|
|
2022
2009
|
password: string;
|
|
2010
|
+
acceptsMarketing?: boolean;
|
|
2023
2011
|
}): Promise<RegisterResult> {
|
|
2024
2012
|
const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/register\`, {
|
|
2025
2013
|
method: 'POST',
|
|
@@ -2375,12 +2363,34 @@ import './globals.css';
|
|
|
2375
2363
|
|
|
2376
2364
|
<%- fontVariable %>
|
|
2377
2365
|
|
|
2366
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
2367
|
+
|
|
2378
2368
|
export const metadata: Metadata = {
|
|
2379
|
-
|
|
2369
|
+
metadataBase: new URL(baseUrl),
|
|
2370
|
+
title: {
|
|
2371
|
+
default: 'My Store',
|
|
2372
|
+
template: \`%s | My Store\`,
|
|
2373
|
+
},
|
|
2380
2374
|
description: 'My Store',
|
|
2375
|
+
alternates: {
|
|
2376
|
+
canonical: '/',
|
|
2377
|
+
},
|
|
2381
2378
|
openGraph: {
|
|
2379
|
+
siteName: 'My Store',
|
|
2382
2380
|
locale: '<%= ogLocale %>',
|
|
2381
|
+
type: 'website',
|
|
2383
2382
|
},
|
|
2383
|
+
robots: {
|
|
2384
|
+
index: true,
|
|
2385
|
+
follow: true,
|
|
2386
|
+
},
|
|
2387
|
+
};
|
|
2388
|
+
|
|
2389
|
+
const organizationJsonLd = {
|
|
2390
|
+
'@context': 'https://schema.org',
|
|
2391
|
+
'@type': 'Organization',
|
|
2392
|
+
name: 'My Store',
|
|
2393
|
+
url: baseUrl,
|
|
2384
2394
|
};
|
|
2385
2395
|
|
|
2386
2396
|
export default function RootLayout({
|
|
@@ -2390,6 +2400,12 @@ export default function RootLayout({
|
|
|
2390
2400
|
}) {
|
|
2391
2401
|
return (
|
|
2392
2402
|
<html lang="<%= language %>" dir="<%= direction %>">
|
|
2403
|
+
<head>
|
|
2404
|
+
<script
|
|
2405
|
+
type="application/ld+json"
|
|
2406
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
|
|
2407
|
+
/>
|
|
2408
|
+
</head>
|
|
2393
2409
|
<body className={font.className}>
|
|
2394
2410
|
<StoreProvider>
|
|
2395
2411
|
<div className="min-h-screen flex flex-col">
|
|
@@ -2434,6 +2450,7 @@ const sortOptions: SortOption[] = [
|
|
|
2434
2450
|
interface CategoryNode {
|
|
2435
2451
|
id: string;
|
|
2436
2452
|
name: string;
|
|
2453
|
+
image?: string | null;
|
|
2437
2454
|
parentId?: string | null;
|
|
2438
2455
|
children: CategoryNode[];
|
|
2439
2456
|
}
|
|
@@ -2474,24 +2491,24 @@ function ChevronDown({ className }: { className?: string }) {
|
|
|
2474
2491
|
|
|
2475
2492
|
/** Recursive dropdown items for nested categories */
|
|
2476
2493
|
function CategoryDropdownItems({
|
|
2477
|
-
|
|
2494
|
+
items,
|
|
2478
2495
|
depth,
|
|
2479
2496
|
selectedId,
|
|
2480
2497
|
onSelect,
|
|
2481
2498
|
}: {
|
|
2482
|
-
|
|
2499
|
+
items: CategoryNode[];
|
|
2483
2500
|
depth: number;
|
|
2484
2501
|
selectedId: string;
|
|
2485
2502
|
onSelect: (id: string) => void;
|
|
2486
2503
|
}) {
|
|
2487
2504
|
return (
|
|
2488
2505
|
<>
|
|
2489
|
-
{
|
|
2506
|
+
{items.map((child) => (
|
|
2490
2507
|
<div key={child.id}>
|
|
2491
2508
|
<button
|
|
2492
2509
|
onClick={() => onSelect(child.id)}
|
|
2493
2510
|
className={cn(
|
|
2494
|
-
'w-full
|
|
2511
|
+
'hover:bg-muted w-full px-4 py-2 text-start text-sm transition-colors',
|
|
2495
2512
|
selectedId === child.id && 'bg-primary/10 text-primary font-medium'
|
|
2496
2513
|
)}
|
|
2497
2514
|
style={{ paddingInlineStart: \`\${(depth + 1) * 16}px\` }}
|
|
@@ -2500,7 +2517,7 @@ function CategoryDropdownItems({
|
|
|
2500
2517
|
</button>
|
|
2501
2518
|
{child.children.length > 0 && (
|
|
2502
2519
|
<CategoryDropdownItems
|
|
2503
|
-
|
|
2520
|
+
items={child.children}
|
|
2504
2521
|
depth={depth + 1}
|
|
2505
2522
|
selectedId={selectedId}
|
|
2506
2523
|
onSelect={onSelect}
|
|
@@ -2587,9 +2604,7 @@ function CategoryChip({
|
|
|
2587
2604
|
{'\xB7'} {selectedChildName}
|
|
2588
2605
|
</span>
|
|
2589
2606
|
)}
|
|
2590
|
-
<ChevronDown
|
|
2591
|
-
className={cn('transition-transform ms-0.5', open && 'rotate-180')}
|
|
2592
|
-
/>
|
|
2607
|
+
<ChevronDown className={cn('ms-0.5 transition-transform', open && 'rotate-180')} />
|
|
2593
2608
|
</button>
|
|
2594
2609
|
|
|
2595
2610
|
{open && (
|
|
@@ -2601,7 +2616,7 @@ function CategoryChip({
|
|
|
2601
2616
|
setOpen(false);
|
|
2602
2617
|
}}
|
|
2603
2618
|
className={cn(
|
|
2604
|
-
'w-full
|
|
2619
|
+
'hover:bg-muted w-full px-4 py-2 text-start text-sm font-medium transition-colors',
|
|
2605
2620
|
selectedId === category.id && 'bg-primary/10 text-primary'
|
|
2606
2621
|
)}
|
|
2607
2622
|
>
|
|
@@ -2609,11 +2624,9 @@ function CategoryChip({
|
|
|
2609
2624
|
</button>
|
|
2610
2625
|
<div className="bg-border mx-2 h-px" />
|
|
2611
2626
|
{/* Recursive children */}
|
|
2612
|
-
<div
|
|
2613
|
-
onClick={() => setOpen(false)}
|
|
2614
|
-
>
|
|
2627
|
+
<div onClick={() => setOpen(false)}>
|
|
2615
2628
|
<CategoryDropdownItems
|
|
2616
|
-
|
|
2629
|
+
items={category.children}
|
|
2617
2630
|
depth={0}
|
|
2618
2631
|
selectedId={selectedId}
|
|
2619
2632
|
onSelect={onSelect}
|
|
@@ -2838,513 +2851,71 @@ export default function ProductsPage() {
|
|
|
2838
2851
|
);
|
|
2839
2852
|
}
|
|
2840
2853
|
`,
|
|
2841
|
-
"src/app/products/[slug]/page.tsx": `
|
|
2854
|
+
"src/app/products/[slug]/page.tsx": `import type { Metadata } from 'next';
|
|
2855
|
+
import { notFound } from 'next/navigation';
|
|
2856
|
+
import { getServerClient } from '@/lib/brainerce';
|
|
2857
|
+
import { ProductJsonLd } from '@/components/seo/product-json-ld';
|
|
2858
|
+
import { ProductClientSection } from './product-client-section';
|
|
2859
|
+
|
|
2860
|
+
type Props = {
|
|
2861
|
+
params: Promise<{ slug: string }>;
|
|
2862
|
+
};
|
|
2842
2863
|
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
import Image from 'next/image';
|
|
2846
|
-
import Link from 'next/link';
|
|
2847
|
-
import type {
|
|
2848
|
-
Product,
|
|
2849
|
-
ProductVariant,
|
|
2850
|
-
ProductImage,
|
|
2851
|
-
ProductMetafield,
|
|
2852
|
-
ProductRecommendationsResponse,
|
|
2853
|
-
DownloadFile,
|
|
2854
|
-
} from 'brainerce';
|
|
2864
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
2865
|
+
const { slug } = await params;
|
|
2855
2866
|
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
import { useCart } from '@/providers/store-provider';
|
|
2862
|
-
import { PriceDisplay } from '@/components/shared/price-display';
|
|
2863
|
-
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
2864
|
-
import { VariantSelector } from '@/components/products/variant-selector';
|
|
2865
|
-
import { StockBadge } from '@/components/products/stock-badge';
|
|
2866
|
-
import { RecommendationSection } from '@/components/products/recommendation-section';
|
|
2867
|
-
import { useTranslations } from '@/lib/translations';
|
|
2868
|
-
import { cn } from '@/lib/utils';
|
|
2867
|
+
try {
|
|
2868
|
+
const client = getServerClient();
|
|
2869
|
+
const product = await client.getProductBySlug(slug);
|
|
2870
|
+
const imageUrl = product.images?.[0]?.url;
|
|
2871
|
+
const description = product.description?.substring(0, 160) || product.name;
|
|
2869
2872
|
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
}
|
|
2894
|
-
if (urls.length === 0) return <span className="text-muted-foreground">-</span>;
|
|
2895
|
-
return (
|
|
2896
|
-
<div className="flex flex-wrap gap-2">
|
|
2897
|
-
{urls.map((url, i) => (
|
|
2898
|
-
<img
|
|
2899
|
-
key={i}
|
|
2900
|
-
src={url}
|
|
2901
|
-
alt={\`\${field.definitionName} \${i + 1}\`}
|
|
2902
|
-
className="h-16 w-16 rounded object-cover"
|
|
2903
|
-
/>
|
|
2904
|
-
))}
|
|
2905
|
-
</div>
|
|
2906
|
-
);
|
|
2907
|
-
}
|
|
2908
|
-
case 'URL':
|
|
2909
|
-
return field.value ? (
|
|
2910
|
-
<a
|
|
2911
|
-
href={field.value}
|
|
2912
|
-
target="_blank"
|
|
2913
|
-
rel="noopener noreferrer"
|
|
2914
|
-
className="text-primary break-all hover:underline"
|
|
2915
|
-
>
|
|
2916
|
-
{field.value}
|
|
2917
|
-
</a>
|
|
2918
|
-
) : (
|
|
2919
|
-
<span className="text-muted-foreground">-</span>
|
|
2920
|
-
);
|
|
2921
|
-
case 'COLOR':
|
|
2922
|
-
return field.value ? (
|
|
2923
|
-
<span className="inline-flex items-center gap-2">
|
|
2924
|
-
<span
|
|
2925
|
-
className="border-border inline-block h-4 w-4 rounded-full border"
|
|
2926
|
-
style={{ backgroundColor: field.value }}
|
|
2927
|
-
/>
|
|
2928
|
-
{field.value}
|
|
2929
|
-
</span>
|
|
2930
|
-
) : (
|
|
2931
|
-
<span className="text-muted-foreground">-</span>
|
|
2932
|
-
);
|
|
2933
|
-
case 'BOOLEAN':
|
|
2934
|
-
return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
|
|
2935
|
-
case 'DATE':
|
|
2936
|
-
case 'DATETIME': {
|
|
2937
|
-
if (!field.value) return <span className="text-muted-foreground">-</span>;
|
|
2938
|
-
try {
|
|
2939
|
-
const date = new Date(field.value);
|
|
2940
|
-
return (
|
|
2941
|
-
<span>
|
|
2942
|
-
{field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
|
|
2943
|
-
</span>
|
|
2944
|
-
);
|
|
2945
|
-
} catch {
|
|
2946
|
-
return <span>{field.value}</span>;
|
|
2947
|
-
}
|
|
2948
|
-
}
|
|
2949
|
-
default:
|
|
2950
|
-
return <span>{field.value || '-'}</span>;
|
|
2873
|
+
return {
|
|
2874
|
+
title: product.name,
|
|
2875
|
+
description,
|
|
2876
|
+
alternates: {
|
|
2877
|
+
canonical: \`/products/\${slug}\`,
|
|
2878
|
+
},
|
|
2879
|
+
openGraph: {
|
|
2880
|
+
title: product.name,
|
|
2881
|
+
description,
|
|
2882
|
+
images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
|
|
2883
|
+
type: 'website',
|
|
2884
|
+
},
|
|
2885
|
+
twitter: {
|
|
2886
|
+
card: 'summary_large_image',
|
|
2887
|
+
title: product.name,
|
|
2888
|
+
description,
|
|
2889
|
+
images: imageUrl ? [imageUrl] : [],
|
|
2890
|
+
},
|
|
2891
|
+
};
|
|
2892
|
+
} catch {
|
|
2893
|
+
return {
|
|
2894
|
+
title: 'Product not found',
|
|
2895
|
+
};
|
|
2951
2896
|
}
|
|
2952
2897
|
}
|
|
2953
2898
|
|
|
2954
|
-
export default function ProductDetailPage() {
|
|
2955
|
-
const
|
|
2956
|
-
const slug = params.slug as string;
|
|
2957
|
-
const { refreshCart } = useCart();
|
|
2958
|
-
const t = useTranslations('productDetail');
|
|
2959
|
-
const tc = useTranslations('common');
|
|
2960
|
-
const [product, setProduct] = useState<ProductWithRecommendations | null>(null);
|
|
2961
|
-
const [loading, setLoading] = useState(true);
|
|
2962
|
-
const [error, setError] = useState<string | null>(null);
|
|
2963
|
-
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
|
2964
|
-
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
|
2965
|
-
const [quantity, setQuantity] = useState(1);
|
|
2966
|
-
const [addingToCart, setAddingToCart] = useState(false);
|
|
2967
|
-
const [addedMessage, setAddedMessage] = useState(false);
|
|
2968
|
-
|
|
2969
|
-
const recommendations = product?.recommendations ?? null;
|
|
2970
|
-
|
|
2971
|
-
// Load product
|
|
2972
|
-
useEffect(() => {
|
|
2973
|
-
async function load() {
|
|
2974
|
-
try {
|
|
2975
|
-
setLoading(true);
|
|
2976
|
-
setError(null);
|
|
2977
|
-
const client = getClient();
|
|
2978
|
-
const p = await client.getProductBySlug(slug);
|
|
2979
|
-
setProduct(p as ProductWithRecommendations);
|
|
2980
|
-
|
|
2981
|
-
// Auto-select first variant
|
|
2982
|
-
if (p.variants && p.variants.length > 0) {
|
|
2983
|
-
setSelectedVariant(p.variants[0]);
|
|
2984
|
-
}
|
|
2985
|
-
} catch {
|
|
2986
|
-
setError(t('notFound'));
|
|
2987
|
-
} finally {
|
|
2988
|
-
setLoading(false);
|
|
2989
|
-
}
|
|
2990
|
-
}
|
|
2991
|
-
load();
|
|
2992
|
-
}, [slug]);
|
|
2993
|
-
|
|
2994
|
-
// Images list - switch main image when variant changes
|
|
2995
|
-
const images: ProductImage[] = useMemo(() => {
|
|
2996
|
-
return product?.images || [];
|
|
2997
|
-
}, [product]);
|
|
2998
|
-
|
|
2999
|
-
// When variant changes, update selected image to variant image if available
|
|
3000
|
-
useEffect(() => {
|
|
3001
|
-
if (!selectedVariant?.image || !product) return;
|
|
3002
|
-
|
|
3003
|
-
const variantImgUrl =
|
|
3004
|
-
typeof selectedVariant.image === 'string' ? selectedVariant.image : selectedVariant.image.url;
|
|
3005
|
-
|
|
3006
|
-
// Find if variant image exists in product images
|
|
3007
|
-
const idx = images.findIndex((img) => img.url === variantImgUrl);
|
|
3008
|
-
if (idx >= 0) {
|
|
3009
|
-
setSelectedImageIndex(idx);
|
|
3010
|
-
} else {
|
|
3011
|
-
// Variant image not in product images - select index 0 as fallback
|
|
3012
|
-
// (The variant image will be shown as the main image via override)
|
|
3013
|
-
setSelectedImageIndex(-1);
|
|
3014
|
-
}
|
|
3015
|
-
}, [selectedVariant, images, product]);
|
|
3016
|
-
|
|
3017
|
-
// Determine which image to show
|
|
3018
|
-
const mainImageUrl = useMemo(() => {
|
|
3019
|
-
if (selectedImageIndex === -1 && selectedVariant?.image) {
|
|
3020
|
-
const img = selectedVariant.image;
|
|
3021
|
-
return typeof img === 'string' ? img : img.url;
|
|
3022
|
-
}
|
|
3023
|
-
return images[selectedImageIndex]?.url || null;
|
|
3024
|
-
}, [selectedImageIndex, selectedVariant, images]);
|
|
3025
|
-
|
|
3026
|
-
// Price info - use variant price if selected, else product price
|
|
3027
|
-
const priceInfo = useMemo(() => {
|
|
3028
|
-
if (selectedVariant?.price) {
|
|
3029
|
-
return {
|
|
3030
|
-
price: parseFloat(selectedVariant.salePrice || selectedVariant.price),
|
|
3031
|
-
originalPrice: parseFloat(selectedVariant.price),
|
|
3032
|
-
isOnSale:
|
|
3033
|
-
selectedVariant.salePrice != null &&
|
|
3034
|
-
parseFloat(selectedVariant.salePrice) < parseFloat(selectedVariant.price),
|
|
3035
|
-
discountPercent:
|
|
3036
|
-
selectedVariant.salePrice != null &&
|
|
3037
|
-
parseFloat(selectedVariant.salePrice) < parseFloat(selectedVariant.price)
|
|
3038
|
-
? Math.round(
|
|
3039
|
-
((parseFloat(selectedVariant.price) - parseFloat(selectedVariant.salePrice)) /
|
|
3040
|
-
parseFloat(selectedVariant.price)) *
|
|
3041
|
-
100
|
|
3042
|
-
)
|
|
3043
|
-
: 0,
|
|
3044
|
-
};
|
|
3045
|
-
}
|
|
3046
|
-
return getProductPriceInfo(product);
|
|
3047
|
-
}, [product, selectedVariant]);
|
|
3048
|
-
|
|
3049
|
-
// Inventory: use variant inventory if selected, else product inventory
|
|
3050
|
-
const inventory = selectedVariant?.inventory ?? product?.inventory ?? null;
|
|
3051
|
-
const canPurchase = inventory?.canPurchase !== false;
|
|
3052
|
-
|
|
3053
|
-
// Description
|
|
3054
|
-
const description = useMemo(() => {
|
|
3055
|
-
return product ? getDescriptionContent(product) : null;
|
|
3056
|
-
}, [product]);
|
|
3057
|
-
|
|
3058
|
-
async function handleAddToCart() {
|
|
3059
|
-
if (!product || addingToCart) return;
|
|
3060
|
-
|
|
3061
|
-
try {
|
|
3062
|
-
setAddingToCart(true);
|
|
3063
|
-
const client = getClient();
|
|
3064
|
-
await client.smartAddToCart({
|
|
3065
|
-
productId: product.id,
|
|
3066
|
-
variantId: selectedVariant?.id,
|
|
3067
|
-
quantity,
|
|
3068
|
-
name: product.name,
|
|
3069
|
-
price: String(priceInfo.price),
|
|
3070
|
-
image: mainImageUrl || undefined,
|
|
3071
|
-
});
|
|
3072
|
-
await refreshCart();
|
|
3073
|
-
setAddedMessage(true);
|
|
3074
|
-
setTimeout(() => setAddedMessage(false), 2000);
|
|
3075
|
-
} catch (err) {
|
|
3076
|
-
console.error('Failed to add to cart:', err);
|
|
3077
|
-
} finally {
|
|
3078
|
-
setAddingToCart(false);
|
|
3079
|
-
}
|
|
3080
|
-
}
|
|
2899
|
+
export default async function ProductDetailPage({ params }: Props) {
|
|
2900
|
+
const { slug } = await params;
|
|
3081
2901
|
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
);
|
|
2902
|
+
let product;
|
|
2903
|
+
try {
|
|
2904
|
+
const client = getServerClient();
|
|
2905
|
+
product = await client.getProductBySlug(slug);
|
|
2906
|
+
} catch {
|
|
2907
|
+
notFound();
|
|
3088
2908
|
}
|
|
3089
2909
|
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
<h1 className="text-foreground text-2xl font-bold">{error || t('notFound')}</h1>
|
|
3094
|
-
<Link href="/products" className="text-primary mt-4 inline-block hover:underline">
|
|
3095
|
-
{t('backToProducts')}
|
|
3096
|
-
</Link>
|
|
3097
|
-
</div>
|
|
3098
|
-
);
|
|
3099
|
-
}
|
|
2910
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
2911
|
+
const productUrl = \`\${baseUrl}/products/\${slug}\`;
|
|
2912
|
+
const currency = process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
|
|
3100
2913
|
|
|
3101
2914
|
return (
|
|
3102
|
-
|
|
3103
|
-
<
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
{/* Main Image */}
|
|
3107
|
-
<div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
|
|
3108
|
-
{mainImageUrl ? (
|
|
3109
|
-
<Image
|
|
3110
|
-
src={mainImageUrl}
|
|
3111
|
-
alt={product.name}
|
|
3112
|
-
fill
|
|
3113
|
-
sizes="(max-width: 1024px) 100vw, 50vw"
|
|
3114
|
-
className="object-contain"
|
|
3115
|
-
priority
|
|
3116
|
-
/>
|
|
3117
|
-
) : (
|
|
3118
|
-
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
3119
|
-
<svg className="h-24 w-24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3120
|
-
<path
|
|
3121
|
-
strokeLinecap="round"
|
|
3122
|
-
strokeLinejoin="round"
|
|
3123
|
-
strokeWidth={1}
|
|
3124
|
-
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"
|
|
3125
|
-
/>
|
|
3126
|
-
</svg>
|
|
3127
|
-
</div>
|
|
3128
|
-
)}
|
|
3129
|
-
</div>
|
|
3130
|
-
|
|
3131
|
-
{/* Thumbnails */}
|
|
3132
|
-
{images.length > 1 && (
|
|
3133
|
-
<div className="flex gap-2 overflow-x-auto pb-2">
|
|
3134
|
-
{images.map((img, idx) => (
|
|
3135
|
-
<button
|
|
3136
|
-
key={idx}
|
|
3137
|
-
type="button"
|
|
3138
|
-
onClick={() => setSelectedImageIndex(idx)}
|
|
3139
|
-
className={cn(
|
|
3140
|
-
'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded border-2 transition-colors',
|
|
3141
|
-
selectedImageIndex === idx
|
|
3142
|
-
? 'border-primary'
|
|
3143
|
-
: 'border-border hover:border-muted-foreground'
|
|
3144
|
-
)}
|
|
3145
|
-
>
|
|
3146
|
-
<Image
|
|
3147
|
-
src={img.url}
|
|
3148
|
-
alt={img.alt || \`\${product.name} \${idx + 1}\`}
|
|
3149
|
-
fill
|
|
3150
|
-
sizes="64px"
|
|
3151
|
-
className="object-cover"
|
|
3152
|
-
/>
|
|
3153
|
-
</button>
|
|
3154
|
-
))}
|
|
3155
|
-
</div>
|
|
3156
|
-
)}
|
|
3157
|
-
</div>
|
|
3158
|
-
|
|
3159
|
-
{/* Product Info */}
|
|
3160
|
-
<div className="space-y-6">
|
|
3161
|
-
{/* Categories */}
|
|
3162
|
-
{product.categories && product.categories.length > 0 && (
|
|
3163
|
-
<div className="flex flex-wrap gap-2">
|
|
3164
|
-
{product.categories.map((cat) => (
|
|
3165
|
-
<span
|
|
3166
|
-
key={cat.id}
|
|
3167
|
-
className="text-muted-foreground bg-muted rounded px-2 py-1 text-xs"
|
|
3168
|
-
>
|
|
3169
|
-
{cat.name}
|
|
3170
|
-
</span>
|
|
3171
|
-
))}
|
|
3172
|
-
</div>
|
|
3173
|
-
)}
|
|
3174
|
-
|
|
3175
|
-
{/* Title */}
|
|
3176
|
-
<h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
|
|
3177
|
-
|
|
3178
|
-
{/* Price */}
|
|
3179
|
-
<PriceDisplay
|
|
3180
|
-
price={priceInfo.originalPrice}
|
|
3181
|
-
salePrice={priceInfo.isOnSale ? priceInfo.price : undefined}
|
|
3182
|
-
size="lg"
|
|
3183
|
-
/>
|
|
3184
|
-
|
|
3185
|
-
{/* Stock / Digital badge */}
|
|
3186
|
-
{product.isDownloadable ? (
|
|
3187
|
-
<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">
|
|
3188
|
-
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3189
|
-
<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" />
|
|
3190
|
-
</svg>
|
|
3191
|
-
{t('instantDownload')}
|
|
3192
|
-
</span>
|
|
3193
|
-
) : (
|
|
3194
|
-
<StockBadge inventory={inventory} lowStockThreshold={5} />
|
|
3195
|
-
)}
|
|
3196
|
-
|
|
3197
|
-
{/* Downloadable files info */}
|
|
3198
|
-
{product.isDownloadable && product.downloads && product.downloads.length > 0 && (
|
|
3199
|
-
<div className="bg-muted/50 rounded-lg border p-4">
|
|
3200
|
-
<p className="text-foreground mb-2 text-sm font-medium">
|
|
3201
|
-
{t('filesIncluded', { count: product.downloads.length })}
|
|
3202
|
-
</p>
|
|
3203
|
-
<ul className="space-y-1.5">
|
|
3204
|
-
{product.downloads.map((file: DownloadFile) => (
|
|
3205
|
-
<li key={file.id} className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
3206
|
-
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3207
|
-
<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" />
|
|
3208
|
-
</svg>
|
|
3209
|
-
<span className="truncate">{file.name}</span>
|
|
3210
|
-
{file.size && (
|
|
3211
|
-
<span className="flex-shrink-0 text-xs">
|
|
3212
|
-
({file.size < 1024 * 1024
|
|
3213
|
-
? \`\${(file.size / 1024).toFixed(0)} KB\`
|
|
3214
|
-
: \`\${(file.size / (1024 * 1024)).toFixed(1)} MB\`})
|
|
3215
|
-
</span>
|
|
3216
|
-
)}
|
|
3217
|
-
</li>
|
|
3218
|
-
))}
|
|
3219
|
-
</ul>
|
|
3220
|
-
</div>
|
|
3221
|
-
)}
|
|
3222
|
-
|
|
3223
|
-
{/* Variant Selector */}
|
|
3224
|
-
{product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
|
|
3225
|
-
<VariantSelector
|
|
3226
|
-
product={product}
|
|
3227
|
-
selectedVariant={selectedVariant}
|
|
3228
|
-
onVariantChange={setSelectedVariant}
|
|
3229
|
-
/>
|
|
3230
|
-
)}
|
|
3231
|
-
|
|
3232
|
-
{/* Quantity + Add to Cart */}
|
|
3233
|
-
<div className="flex items-center gap-4">
|
|
3234
|
-
<div className="border-border flex items-center rounded border">
|
|
3235
|
-
<button
|
|
3236
|
-
type="button"
|
|
3237
|
-
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
|
3238
|
-
className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
|
|
3239
|
-
aria-label={t('decreaseQuantity')}
|
|
3240
|
-
>
|
|
3241
|
-
-
|
|
3242
|
-
</button>
|
|
3243
|
-
<span className="text-foreground min-w-[3rem] px-4 py-2 text-center text-sm font-medium">
|
|
3244
|
-
{quantity}
|
|
3245
|
-
</span>
|
|
3246
|
-
<button
|
|
3247
|
-
type="button"
|
|
3248
|
-
onClick={() => setQuantity((q) => q + 1)}
|
|
3249
|
-
className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
|
|
3250
|
-
aria-label={t('increaseQuantity')}
|
|
3251
|
-
>
|
|
3252
|
-
+
|
|
3253
|
-
</button>
|
|
3254
|
-
</div>
|
|
3255
|
-
|
|
3256
|
-
<button
|
|
3257
|
-
type="button"
|
|
3258
|
-
onClick={handleAddToCart}
|
|
3259
|
-
disabled={!canPurchase || addingToCart}
|
|
3260
|
-
className={cn(
|
|
3261
|
-
'flex-1 rounded px-6 py-3 text-sm font-medium transition-all',
|
|
3262
|
-
canPurchase
|
|
3263
|
-
? 'bg-primary text-primary-foreground hover:opacity-90'
|
|
3264
|
-
: 'bg-muted text-muted-foreground cursor-not-allowed'
|
|
3265
|
-
)}
|
|
3266
|
-
>
|
|
3267
|
-
{addingToCart ? (
|
|
3268
|
-
<span className="inline-flex items-center gap-2">
|
|
3269
|
-
<LoadingSpinner
|
|
3270
|
-
size="sm"
|
|
3271
|
-
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
3272
|
-
/>
|
|
3273
|
-
{t('addingToCart')}
|
|
3274
|
-
</span>
|
|
3275
|
-
) : addedMessage ? (
|
|
3276
|
-
t('addedToCart')
|
|
3277
|
-
) : !canPurchase ? (
|
|
3278
|
-
t('outOfStock')
|
|
3279
|
-
) : (
|
|
3280
|
-
t('addToCart')
|
|
3281
|
-
)}
|
|
3282
|
-
</button>
|
|
3283
|
-
</div>
|
|
3284
|
-
|
|
3285
|
-
{/* Download after purchase note */}
|
|
3286
|
-
{product.isDownloadable && (
|
|
3287
|
-
<p className="text-muted-foreground text-sm">
|
|
3288
|
-
{t('downloadAfterPurchase')}
|
|
3289
|
-
</p>
|
|
3290
|
-
)}
|
|
3291
|
-
|
|
3292
|
-
{/* Description */}
|
|
3293
|
-
{description && (
|
|
3294
|
-
<div className="border-border border-t pt-4">
|
|
3295
|
-
<h2 className="text-foreground mb-3 text-lg font-semibold">{t('description')}</h2>
|
|
3296
|
-
{'html' in description ? (
|
|
3297
|
-
<div
|
|
3298
|
-
className="prose prose-sm text-muted-foreground max-w-none"
|
|
3299
|
-
dangerouslySetInnerHTML={{ __html: description.html }}
|
|
3300
|
-
/>
|
|
3301
|
-
) : (
|
|
3302
|
-
<p className="text-muted-foreground whitespace-pre-wrap">{description.text}</p>
|
|
3303
|
-
)}
|
|
3304
|
-
</div>
|
|
3305
|
-
)}
|
|
3306
|
-
|
|
3307
|
-
{/* Metafields / Specifications */}
|
|
3308
|
-
{product.metafields && product.metafields.length > 0 && (
|
|
3309
|
-
<div className="border-border border-t pt-4">
|
|
3310
|
-
<h2 className="text-foreground mb-3 text-lg font-semibold">{t('specifications')}</h2>
|
|
3311
|
-
<table className="w-full text-sm">
|
|
3312
|
-
<tbody>
|
|
3313
|
-
{product.metafields.map((field) => (
|
|
3314
|
-
<tr key={field.id} className="border-border border-b last:border-0">
|
|
3315
|
-
<td className="text-foreground whitespace-nowrap py-2 pe-4 font-medium">
|
|
3316
|
-
{field.definitionName}
|
|
3317
|
-
</td>
|
|
3318
|
-
<td className="text-muted-foreground py-2">
|
|
3319
|
-
<MetafieldValue field={field} />
|
|
3320
|
-
</td>
|
|
3321
|
-
</tr>
|
|
3322
|
-
))}
|
|
3323
|
-
</tbody>
|
|
3324
|
-
</table>
|
|
3325
|
-
</div>
|
|
3326
|
-
)}
|
|
3327
|
-
</div>
|
|
3328
|
-
</div>
|
|
3329
|
-
|
|
3330
|
-
{/* Upsells \u2014 premium alternatives (product page) */}
|
|
3331
|
-
{recommendations?.upsells && recommendations.upsells.length > 0 && (
|
|
3332
|
-
<RecommendationSection
|
|
3333
|
-
title={t('upgradeYourChoice')}
|
|
3334
|
-
items={recommendations.upsells}
|
|
3335
|
-
className="mt-12"
|
|
3336
|
-
/>
|
|
3337
|
-
)}
|
|
3338
|
-
|
|
3339
|
-
{/* Related products \u2014 similar items (bottom of product page) */}
|
|
3340
|
-
{recommendations?.related && recommendations.related.length > 0 && (
|
|
3341
|
-
<RecommendationSection
|
|
3342
|
-
title={t('similarProducts')}
|
|
3343
|
-
items={recommendations.related}
|
|
3344
|
-
className="mt-12"
|
|
3345
|
-
/>
|
|
3346
|
-
)}
|
|
3347
|
-
</div>
|
|
2915
|
+
<>
|
|
2916
|
+
<ProductJsonLd product={product} url={productUrl} currency={currency} />
|
|
2917
|
+
<ProductClientSection product={product} />
|
|
2918
|
+
</>
|
|
3348
2919
|
);
|
|
3349
2920
|
}
|
|
3350
2921
|
`,
|
|
@@ -3377,7 +2948,10 @@ export default function CartPage() {
|
|
|
3377
2948
|
return;
|
|
3378
2949
|
}
|
|
3379
2950
|
const client = getClient();
|
|
3380
|
-
client
|
|
2951
|
+
client
|
|
2952
|
+
.getCartRecommendations(cart.id, 4)
|
|
2953
|
+
.then(setCartRecs)
|
|
2954
|
+
.catch(() => {});
|
|
3381
2955
|
}, [cart?.id, cart?.items.length]);
|
|
3382
2956
|
|
|
3383
2957
|
if (cartLoading) {
|
|
@@ -3485,7 +3059,7 @@ export default function CartPage() {
|
|
|
3485
3059
|
`,
|
|
3486
3060
|
"src/app/checkout/page.tsx": `'use client';
|
|
3487
3061
|
|
|
3488
|
-
import { Suspense, useEffect, useState, useCallback } from 'react';
|
|
3062
|
+
import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
|
|
3489
3063
|
import { useSearchParams } from 'next/navigation';
|
|
3490
3064
|
import Image from 'next/image';
|
|
3491
3065
|
import Link from 'next/link';
|
|
@@ -3498,7 +3072,7 @@ import type {
|
|
|
3498
3072
|
} from 'brainerce';
|
|
3499
3073
|
import { formatPrice } from 'brainerce';
|
|
3500
3074
|
import { getClient } from '@/lib/brainerce';
|
|
3501
|
-
import { useStoreInfo,
|
|
3075
|
+
import { useStoreInfo, useCart, useAuth } from '@/providers/store-provider';
|
|
3502
3076
|
import { CheckoutForm } from '@/components/checkout/checkout-form';
|
|
3503
3077
|
import { ShippingStep } from '@/components/checkout/shipping-step';
|
|
3504
3078
|
import { PaymentStep } from '@/components/checkout/payment-step';
|
|
@@ -3516,8 +3090,8 @@ type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'payment';
|
|
|
3516
3090
|
function CheckoutContent() {
|
|
3517
3091
|
const searchParams = useSearchParams();
|
|
3518
3092
|
const { storeInfo } = useStoreInfo();
|
|
3519
|
-
const { isLoggedIn } = useAuth();
|
|
3520
3093
|
const { cart, refreshCart } = useCart();
|
|
3094
|
+
const { isLoggedIn } = useAuth();
|
|
3521
3095
|
const currency = storeInfo?.currency || 'USD';
|
|
3522
3096
|
const t = useTranslations('checkout');
|
|
3523
3097
|
const tc = useTranslations('common');
|
|
@@ -3532,90 +3106,125 @@ function CheckoutContent() {
|
|
|
3532
3106
|
const [destinations, setDestinations] = useState<ShippingDestinations | null>(null);
|
|
3533
3107
|
const [pickupLocations, setPickupLocations] = useState<PickupLocation[]>([]);
|
|
3534
3108
|
const [deliveryType, setDeliveryType] = useState<'shipping' | 'pickup'>('shipping');
|
|
3109
|
+
const [isAllDigital, setIsAllDigital] = useState(false);
|
|
3110
|
+
const [prefillAddress, setPrefillAddress] = useState<SetShippingAddressDto | null>(null);
|
|
3111
|
+
const [prefillCustomer, setPrefillCustomer] = useState<{
|
|
3112
|
+
email: string;
|
|
3113
|
+
firstName?: string;
|
|
3114
|
+
lastName?: string;
|
|
3115
|
+
phone?: string;
|
|
3116
|
+
} | null>(null);
|
|
3117
|
+
const [hasSavedAddress, setHasSavedAddress] = useState(false);
|
|
3535
3118
|
|
|
3536
3119
|
// Check for returning from canceled payment
|
|
3537
3120
|
const canceled = searchParams.get('canceled') === 'true';
|
|
3538
3121
|
const existingCheckoutId = searchParams.get('checkout_id');
|
|
3539
3122
|
|
|
3540
|
-
//
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
.then(setDestinations)
|
|
3551
|
-
.catch(() => {});
|
|
3552
|
-
|
|
3553
|
-
const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]);
|
|
3554
|
-
setPickupLocations(locations);
|
|
3555
|
-
|
|
3556
|
-
// If returning with existing checkout ID, resume it
|
|
3557
|
-
if (existingCheckoutId) {
|
|
3558
|
-
const existing = await client.getCheckout(existingCheckoutId);
|
|
3559
|
-
setCheckout(existing);
|
|
3560
|
-
|
|
3561
|
-
// Determine step based on checkout state
|
|
3562
|
-
if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
|
|
3563
|
-
setDeliveryType('pickup');
|
|
3564
|
-
setStep('payment');
|
|
3565
|
-
} else if (existing.shippingAddress && existing.shippingRateId) {
|
|
3566
|
-
setStep('payment');
|
|
3567
|
-
} else if (existing.shippingAddress) {
|
|
3568
|
-
// Fetch shipping rates
|
|
3569
|
-
const rates = await client.getShippingRates(existing.id);
|
|
3570
|
-
setShippingRates(rates);
|
|
3571
|
-
setStep('shipping');
|
|
3572
|
-
} else if (locations.length > 0) {
|
|
3573
|
-
setStep('method');
|
|
3123
|
+
// Pre-fill address and customer data from profile when logged in
|
|
3124
|
+
useEffect(() => {
|
|
3125
|
+
if (!isLoggedIn) return;
|
|
3126
|
+
getClient()
|
|
3127
|
+
.getCheckoutPrefillData()
|
|
3128
|
+
.then((data) => {
|
|
3129
|
+
if (data.customer) setPrefillCustomer(data.customer);
|
|
3130
|
+
if (data.shippingAddress) {
|
|
3131
|
+
setPrefillAddress(data.shippingAddress);
|
|
3132
|
+
setHasSavedAddress(true);
|
|
3574
3133
|
}
|
|
3575
|
-
|
|
3576
|
-
}
|
|
3134
|
+
})
|
|
3135
|
+
.catch(() => {});
|
|
3136
|
+
}, [isLoggedIn]);
|
|
3577
3137
|
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3138
|
+
// Initialize or resume checkout (only once)
|
|
3139
|
+
const checkoutInitRef = useRef(false);
|
|
3140
|
+
const cartIdRef = useRef<string | null>(null);
|
|
3141
|
+
|
|
3142
|
+
useEffect(() => {
|
|
3143
|
+
// Only init once, or if cart ID actually changed (e.g. cart was replaced)
|
|
3144
|
+
if (!cart?.id) return;
|
|
3145
|
+
if (checkoutInitRef.current && cartIdRef.current === cart.id) return;
|
|
3146
|
+
checkoutInitRef.current = true;
|
|
3147
|
+
cartIdRef.current = cart.id;
|
|
3148
|
+
|
|
3149
|
+
const initCheckout = async () => {
|
|
3150
|
+
try {
|
|
3151
|
+
setInitializing(true);
|
|
3152
|
+
setError(null);
|
|
3153
|
+
const client = getClient();
|
|
3154
|
+
|
|
3155
|
+
// Fetch shipping destinations and pickup locations in parallel
|
|
3156
|
+
client
|
|
3157
|
+
.getShippingDestinations()
|
|
3158
|
+
.then(setDestinations)
|
|
3159
|
+
.catch(() => {});
|
|
3160
|
+
|
|
3161
|
+
const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]);
|
|
3162
|
+
setPickupLocations(locations);
|
|
3163
|
+
|
|
3164
|
+
// If returning with existing checkout ID, resume it
|
|
3165
|
+
if (existingCheckoutId) {
|
|
3166
|
+
const existing = await client.getCheckout(existingCheckoutId);
|
|
3167
|
+
setCheckout(existing);
|
|
3168
|
+
|
|
3169
|
+
// Determine step based on checkout state
|
|
3170
|
+
const allDigital = existing.lineItems.every(
|
|
3171
|
+
(i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
|
|
3172
|
+
);
|
|
3173
|
+
setIsAllDigital(allDigital);
|
|
3174
|
+
if (allDigital) {
|
|
3175
|
+
// Digital products: show contact info step if email not set, else payment
|
|
3176
|
+
setStep(existing.email ? 'payment' : 'address');
|
|
3177
|
+
} else if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
|
|
3178
|
+
setDeliveryType('pickup');
|
|
3179
|
+
setStep('payment');
|
|
3180
|
+
} else if (existing.shippingAddress && existing.shippingRateId) {
|
|
3181
|
+
setStep('payment');
|
|
3182
|
+
} else if (existing.shippingAddress) {
|
|
3183
|
+
// Fetch shipping rates
|
|
3184
|
+
const rates = await client.getShippingRates(existing.id);
|
|
3185
|
+
setShippingRates(rates);
|
|
3186
|
+
setStep('shipping');
|
|
3187
|
+
} else if (locations.length > 0) {
|
|
3188
|
+
setStep('method');
|
|
3592
3189
|
}
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
// Create new checkout \u2014 cart is always server-side now
|
|
3194
|
+
const newCheckout = await client.createCheckout({ cartId: cart.id });
|
|
3195
|
+
setCheckout(newCheckout);
|
|
3196
|
+
|
|
3197
|
+
// If all items are downloadable, skip shipping \u2014 show contact info step
|
|
3198
|
+
const allDigital = newCheckout.lineItems.every(
|
|
3199
|
+
(i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
|
|
3200
|
+
);
|
|
3201
|
+
setIsAllDigital(allDigital);
|
|
3202
|
+
if (allDigital) {
|
|
3203
|
+
setStep('address');
|
|
3204
|
+
return;
|
|
3593
3205
|
}
|
|
3594
|
-
} else {
|
|
3595
|
-
setError(t('cartIsEmpty'));
|
|
3596
|
-
}
|
|
3597
3206
|
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3207
|
+
// If pickup locations exist, start with delivery method selection
|
|
3208
|
+
if (locations.length > 0) {
|
|
3209
|
+
setStep('method');
|
|
3210
|
+
}
|
|
3211
|
+
} catch (err) {
|
|
3212
|
+
const message = err instanceof Error ? err.message : t('failedToInitCheckout');
|
|
3213
|
+
setError(message);
|
|
3214
|
+
} finally {
|
|
3215
|
+
setInitializing(false);
|
|
3601
3216
|
}
|
|
3602
|
-
}
|
|
3603
|
-
const message = err instanceof Error ? err.message : t('failedToInitCheckout');
|
|
3604
|
-
setError(message);
|
|
3605
|
-
} finally {
|
|
3606
|
-
setInitializing(false);
|
|
3607
|
-
}
|
|
3608
|
-
}, [existingCheckoutId, isLoggedIn, cart]);
|
|
3217
|
+
};
|
|
3609
3218
|
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
initCheckout();
|
|
3614
|
-
}
|
|
3615
|
-
}, [cartLoaded, initCheckout]);
|
|
3219
|
+
initCheckout();
|
|
3220
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3221
|
+
}, [cart?.id, existingCheckoutId]);
|
|
3616
3222
|
|
|
3617
3223
|
// Handle shipping address submission
|
|
3618
|
-
async function handleAddressSubmit(
|
|
3224
|
+
async function handleAddressSubmit(
|
|
3225
|
+
address: SetShippingAddressDto,
|
|
3226
|
+
consent: { acceptsMarketing: boolean; saveDetails: boolean }
|
|
3227
|
+
) {
|
|
3619
3228
|
if (!checkout) return;
|
|
3620
3229
|
|
|
3621
3230
|
try {
|
|
@@ -3623,10 +3232,52 @@ function CheckoutContent() {
|
|
|
3623
3232
|
setError(null);
|
|
3624
3233
|
const client = getClient();
|
|
3625
3234
|
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3235
|
+
if (isAllDigital) {
|
|
3236
|
+
// Digital products: set customer info only, skip shipping
|
|
3237
|
+
const updated = await client.setCheckoutCustomer(checkout.id, {
|
|
3238
|
+
email: address.email,
|
|
3239
|
+
firstName: address.firstName,
|
|
3240
|
+
lastName: address.lastName,
|
|
3241
|
+
phone: address.phone,
|
|
3242
|
+
acceptsMarketing: consent.acceptsMarketing,
|
|
3243
|
+
});
|
|
3244
|
+
setCheckout(updated);
|
|
3245
|
+
setStep('payment');
|
|
3246
|
+
} else {
|
|
3247
|
+
const response = await client.setShippingAddress(checkout.id, address);
|
|
3248
|
+
setCheckout(response.checkout);
|
|
3249
|
+
setShippingRates(response.rates);
|
|
3250
|
+
setStep('shipping');
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
// Update marketing preference for logged-in users
|
|
3254
|
+
if (isLoggedIn) {
|
|
3255
|
+
try {
|
|
3256
|
+
await client.updateMyProfile({ acceptsMarketing: consent.acceptsMarketing });
|
|
3257
|
+
} catch {
|
|
3258
|
+
// non-critical
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
// Save address to profile if checkbox was checked and no existing saved address
|
|
3263
|
+
if (isLoggedIn && consent.saveDetails && !hasSavedAddress && !isAllDigital) {
|
|
3264
|
+
try {
|
|
3265
|
+
await client.addMyAddress({
|
|
3266
|
+
firstName: address.firstName,
|
|
3267
|
+
lastName: address.lastName,
|
|
3268
|
+
line1: address.line1,
|
|
3269
|
+
line2: address.line2,
|
|
3270
|
+
city: address.city,
|
|
3271
|
+
region: address.region,
|
|
3272
|
+
postalCode: address.postalCode,
|
|
3273
|
+
country: address.country,
|
|
3274
|
+
phone: address.phone,
|
|
3275
|
+
isDefault: true,
|
|
3276
|
+
});
|
|
3277
|
+
} catch {
|
|
3278
|
+
// non-critical
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3630
3281
|
} catch (err) {
|
|
3631
3282
|
const message = err instanceof Error ? err.message : t('failedToSaveAddress');
|
|
3632
3283
|
setError(message);
|
|
@@ -3763,8 +3414,12 @@ function CheckoutContent() {
|
|
|
3763
3414
|
);
|
|
3764
3415
|
}
|
|
3765
3416
|
|
|
3766
|
-
const steps: { key: CheckoutStep; label: string }[] =
|
|
3767
|
-
|
|
3417
|
+
const steps: { key: CheckoutStep; label: string }[] = isAllDigital
|
|
3418
|
+
? [
|
|
3419
|
+
{ key: 'address', label: t('stepContactInfo') },
|
|
3420
|
+
{ key: 'payment', label: t('stepPayment') },
|
|
3421
|
+
]
|
|
3422
|
+
: pickupLocations.length > 0
|
|
3768
3423
|
? deliveryType === 'pickup'
|
|
3769
3424
|
? [
|
|
3770
3425
|
{ key: 'method', label: t('stepMethod') },
|
|
@@ -3874,8 +3529,10 @@ function CheckoutContent() {
|
|
|
3874
3529
|
{step === 'address' && (
|
|
3875
3530
|
<div>
|
|
3876
3531
|
<div className="mb-4 flex items-center justify-between">
|
|
3877
|
-
<h2 className="text-foreground text-lg font-semibold">
|
|
3878
|
-
|
|
3532
|
+
<h2 className="text-foreground text-lg font-semibold">
|
|
3533
|
+
{isAllDigital ? t('contactInfo') : t('shippingAddress')}
|
|
3534
|
+
</h2>
|
|
3535
|
+
{!isAllDigital && pickupLocations.length > 0 && (
|
|
3879
3536
|
<button
|
|
3880
3537
|
type="button"
|
|
3881
3538
|
onClick={() => setStep('method')}
|
|
@@ -3888,7 +3545,9 @@ function CheckoutContent() {
|
|
|
3888
3545
|
<CheckoutForm
|
|
3889
3546
|
onSubmit={handleAddressSubmit}
|
|
3890
3547
|
loading={loading}
|
|
3891
|
-
destinations={destinations}
|
|
3548
|
+
destinations={isAllDigital ? null : destinations}
|
|
3549
|
+
showSaveDetails={isLoggedIn && !hasSavedAddress && !isAllDigital}
|
|
3550
|
+
emailOnly={isAllDigital}
|
|
3892
3551
|
initialValues={
|
|
3893
3552
|
checkout?.shippingAddress
|
|
3894
3553
|
? {
|
|
@@ -3903,7 +3562,27 @@ function CheckoutContent() {
|
|
|
3903
3562
|
country: checkout.shippingAddress.country,
|
|
3904
3563
|
phone: checkout.shippingAddress.phone || '',
|
|
3905
3564
|
}
|
|
3906
|
-
:
|
|
3565
|
+
: prefillAddress
|
|
3566
|
+
? {
|
|
3567
|
+
email: prefillAddress.email,
|
|
3568
|
+
firstName: prefillAddress.firstName,
|
|
3569
|
+
lastName: prefillAddress.lastName,
|
|
3570
|
+
line1: prefillAddress.line1,
|
|
3571
|
+
line2: prefillAddress.line2 || '',
|
|
3572
|
+
city: prefillAddress.city,
|
|
3573
|
+
region: prefillAddress.region || '',
|
|
3574
|
+
postalCode: prefillAddress.postalCode,
|
|
3575
|
+
country: prefillAddress.country,
|
|
3576
|
+
phone: prefillAddress.phone || '',
|
|
3577
|
+
}
|
|
3578
|
+
: prefillCustomer
|
|
3579
|
+
? {
|
|
3580
|
+
email: prefillCustomer.email,
|
|
3581
|
+
firstName: prefillCustomer.firstName || '',
|
|
3582
|
+
lastName: prefillCustomer.lastName || '',
|
|
3583
|
+
phone: prefillCustomer.phone || '',
|
|
3584
|
+
}
|
|
3585
|
+
: undefined
|
|
3907
3586
|
}
|
|
3908
3587
|
/>
|
|
3909
3588
|
</div>
|
|
@@ -3959,13 +3638,15 @@ function CheckoutContent() {
|
|
|
3959
3638
|
<div>
|
|
3960
3639
|
<div className="mb-4 flex items-center justify-between">
|
|
3961
3640
|
<h2 className="text-foreground text-lg font-semibold">{t('payment')}</h2>
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3641
|
+
{!isAllDigital && (
|
|
3642
|
+
<button
|
|
3643
|
+
type="button"
|
|
3644
|
+
onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
|
|
3645
|
+
className="text-primary text-sm hover:underline"
|
|
3646
|
+
>
|
|
3647
|
+
{deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
|
|
3648
|
+
</button>
|
|
3649
|
+
)}
|
|
3969
3650
|
</div>
|
|
3970
3651
|
|
|
3971
3652
|
<PaymentStep checkoutId={checkout.id} />
|
|
@@ -4041,12 +3722,13 @@ function CheckoutContent() {
|
|
|
4041
3722
|
)
|
|
4042
3723
|
)}
|
|
4043
3724
|
|
|
4044
|
-
{/* Coupon input \u2014 show from shipping/pickup step onwards */}
|
|
4045
|
-
{cart &&
|
|
4046
|
-
|
|
4047
|
-
<
|
|
4048
|
-
|
|
4049
|
-
|
|
3725
|
+
{/* Coupon input \u2014 show from shipping/pickup step onwards (or immediately if digital) */}
|
|
3726
|
+
{cart &&
|
|
3727
|
+
(isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
|
|
3728
|
+
<div className="border-border border-t pt-4">
|
|
3729
|
+
<CouponInput cart={cart} onUpdate={handleCouponUpdate} />
|
|
3730
|
+
</div>
|
|
3731
|
+
)}
|
|
4050
3732
|
|
|
4051
3733
|
{/* Totals */}
|
|
4052
3734
|
{checkout && (
|
|
@@ -4168,7 +3850,7 @@ export default function CheckoutPage() {
|
|
|
4168
3850
|
import { Suspense, useEffect, useState } from 'react';
|
|
4169
3851
|
import { useSearchParams } from 'next/navigation';
|
|
4170
3852
|
import Link from 'next/link';
|
|
4171
|
-
import type { WaitForOrderResult } from 'brainerce';
|
|
3853
|
+
import type { WaitForOrderResult, OrderDownloadLink } from 'brainerce';
|
|
4172
3854
|
import { getClient } from '@/lib/brainerce';
|
|
4173
3855
|
import { useCart } from '@/providers/store-provider';
|
|
4174
3856
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
@@ -4284,6 +3966,10 @@ function OrderConfirmationContent() {
|
|
|
4284
3966
|
|
|
4285
3967
|
<p className="text-muted-foreground mt-2">{t('confirmationEmail')}</p>
|
|
4286
3968
|
|
|
3969
|
+
{result.status.orderId && (
|
|
3970
|
+
<ConfirmationDownloads orderId={result.status.orderId} checkoutId={checkoutId!} />
|
|
3971
|
+
)}
|
|
3972
|
+
|
|
4287
3973
|
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
|
4288
3974
|
<Link
|
|
4289
3975
|
href="/products"
|
|
@@ -4343,6 +4029,63 @@ function OrderConfirmationContent() {
|
|
|
4343
4029
|
);
|
|
4344
4030
|
}
|
|
4345
4031
|
|
|
4032
|
+
function ConfirmationDownloads({ orderId, checkoutId }: { orderId: string; checkoutId: string }) {
|
|
4033
|
+
const t = useTranslations('orderConfirmation');
|
|
4034
|
+
const [downloads, setDownloads] = useState<OrderDownloadLink[] | null>(null);
|
|
4035
|
+
|
|
4036
|
+
useEffect(() => {
|
|
4037
|
+
let cancelled = false;
|
|
4038
|
+
async function fetchDownloads() {
|
|
4039
|
+
const client = getClient();
|
|
4040
|
+
// Retry a few times \u2014 the worker may still be writing downloadMeta
|
|
4041
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
4042
|
+
try {
|
|
4043
|
+
const links = await client.getOrderDownloads(orderId, { checkoutId });
|
|
4044
|
+
if (!cancelled && links.length > 0) {
|
|
4045
|
+
setDownloads(links);
|
|
4046
|
+
return;
|
|
4047
|
+
}
|
|
4048
|
+
} catch {
|
|
4049
|
+
// Not all orders have downloads
|
|
4050
|
+
}
|
|
4051
|
+
if (attempt < 2 && !cancelled) {
|
|
4052
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
4053
|
+
}
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
fetchDownloads();
|
|
4057
|
+
return () => {
|
|
4058
|
+
cancelled = true;
|
|
4059
|
+
};
|
|
4060
|
+
}, [orderId, checkoutId]);
|
|
4061
|
+
|
|
4062
|
+
if (!downloads || downloads.length === 0) return null;
|
|
4063
|
+
|
|
4064
|
+
return (
|
|
4065
|
+
<div className="border-border bg-muted/30 mx-auto mt-8 max-w-md rounded-lg border p-6 text-start">
|
|
4066
|
+
<h3 className="text-foreground mb-3 text-sm font-semibold">{t('yourDownloads')}</h3>
|
|
4067
|
+
<div className="space-y-2">
|
|
4068
|
+
{downloads.map((link, idx) => (
|
|
4069
|
+
<div key={idx} className="flex items-center justify-between gap-3">
|
|
4070
|
+
<div className="min-w-0 flex-1">
|
|
4071
|
+
<p className="text-foreground truncate text-sm">{link.fileName}</p>
|
|
4072
|
+
<p className="text-muted-foreground truncate text-xs">{link.productName}</p>
|
|
4073
|
+
</div>
|
|
4074
|
+
<a
|
|
4075
|
+
href={link.downloadUrl}
|
|
4076
|
+
target="_blank"
|
|
4077
|
+
rel="noopener noreferrer"
|
|
4078
|
+
className="bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1.5 text-xs font-medium hover:opacity-90"
|
|
4079
|
+
>
|
|
4080
|
+
{t('download')}
|
|
4081
|
+
</a>
|
|
4082
|
+
</div>
|
|
4083
|
+
))}
|
|
4084
|
+
</div>
|
|
4085
|
+
</div>
|
|
4086
|
+
);
|
|
4087
|
+
}
|
|
4088
|
+
|
|
4346
4089
|
export default function OrderConfirmationPage() {
|
|
4347
4090
|
return (
|
|
4348
4091
|
<Suspense
|
|
@@ -4439,6 +4182,7 @@ export default function RegisterPage() {
|
|
|
4439
4182
|
lastName: string;
|
|
4440
4183
|
email: string;
|
|
4441
4184
|
password: string;
|
|
4185
|
+
acceptsMarketing: boolean;
|
|
4442
4186
|
}) {
|
|
4443
4187
|
try {
|
|
4444
4188
|
setError(null);
|
|
@@ -4664,7 +4408,7 @@ function VerifyEmailContent() {
|
|
|
4664
4408
|
|
|
4665
4409
|
<form onSubmit={handleFormSubmit} className="space-y-6">
|
|
4666
4410
|
{/* Digit inputs */}
|
|
4667
|
-
<div className="flex justify-center gap-2 sm:gap-3" onPaste={handlePaste}>
|
|
4411
|
+
<div dir="ltr" className="flex justify-center gap-2 sm:gap-3" onPaste={handlePaste}>
|
|
4668
4412
|
{digits.map((digit, index) => (
|
|
4669
4413
|
<input
|
|
4670
4414
|
key={index}
|
|
@@ -4741,108 +4485,355 @@ export default function VerifyEmailPage() {
|
|
|
4741
4485
|
);
|
|
4742
4486
|
}
|
|
4743
4487
|
`,
|
|
4744
|
-
"src/app/
|
|
4488
|
+
"src/app/forgot-password/page.tsx": `'use client';
|
|
4745
4489
|
|
|
4746
|
-
import {
|
|
4747
|
-
import
|
|
4748
|
-
import {
|
|
4490
|
+
import { useState } from 'react';
|
|
4491
|
+
import Link from 'next/link';
|
|
4492
|
+
import { getClient } from '@/lib/brainerce';
|
|
4749
4493
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
4750
4494
|
import { useTranslations } from '@/lib/translations';
|
|
4751
4495
|
|
|
4752
|
-
function
|
|
4753
|
-
const router = useRouter();
|
|
4754
|
-
const searchParams = useSearchParams();
|
|
4755
|
-
const auth = useAuth();
|
|
4756
|
-
const [error, setError] = useState<string | null>(null);
|
|
4757
|
-
const processedRef = useRef(false);
|
|
4496
|
+
export default function ForgotPasswordPage() {
|
|
4758
4497
|
const t = useTranslations('auth');
|
|
4498
|
+
const [email, setEmail] = useState('');
|
|
4499
|
+
const [loading, setLoading] = useState(false);
|
|
4500
|
+
const [sent, setSent] = useState(false);
|
|
4501
|
+
const [error, setError] = useState<string | null>(null);
|
|
4759
4502
|
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
useEffect(() => {
|
|
4765
|
-
// Prevent double-processing in React StrictMode
|
|
4766
|
-
if (processedRef.current) return;
|
|
4767
|
-
processedRef.current = true;
|
|
4768
|
-
|
|
4769
|
-
if (oauthError) {
|
|
4770
|
-
setError(oauthError);
|
|
4771
|
-
return;
|
|
4772
|
-
}
|
|
4503
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
4504
|
+
e.preventDefault();
|
|
4505
|
+
if (loading) return;
|
|
4773
4506
|
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
}
|
|
4779
|
-
|
|
4780
|
-
|
|
4507
|
+
try {
|
|
4508
|
+
setLoading(true);
|
|
4509
|
+
setError(null);
|
|
4510
|
+
const client = getClient();
|
|
4511
|
+
const resetUrl = \`\${window.location.origin}/api/auth/reset-callback\`;
|
|
4512
|
+
await client.forgotPassword(email, { resetUrl });
|
|
4513
|
+
setSent(true);
|
|
4514
|
+
} catch (err) {
|
|
4515
|
+
const message =
|
|
4516
|
+
err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
|
4517
|
+
setError(message);
|
|
4518
|
+
} finally {
|
|
4519
|
+
setLoading(false);
|
|
4781
4520
|
}
|
|
4782
|
-
}, [oauthSuccess, oauthError, auth, router, t]);
|
|
4783
|
-
|
|
4784
|
-
if (error) {
|
|
4785
|
-
return (
|
|
4786
|
-
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
4787
|
-
<div className="w-full max-w-md space-y-4 text-center">
|
|
4788
|
-
<svg
|
|
4789
|
-
className="text-destructive mx-auto h-12 w-12"
|
|
4790
|
-
fill="none"
|
|
4791
|
-
viewBox="0 0 24 24"
|
|
4792
|
-
stroke="currentColor"
|
|
4793
|
-
>
|
|
4794
|
-
<path
|
|
4795
|
-
strokeLinecap="round"
|
|
4796
|
-
strokeLinejoin="round"
|
|
4797
|
-
strokeWidth={1.5}
|
|
4798
|
-
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"
|
|
4799
|
-
/>
|
|
4800
|
-
</svg>
|
|
4801
|
-
<h1 className="text-foreground text-2xl font-bold">{t('authFailed')}</h1>
|
|
4802
|
-
<p className="text-muted-foreground text-sm">{error}</p>
|
|
4803
|
-
<button
|
|
4804
|
-
type="button"
|
|
4805
|
-
onClick={() => router.push('/login')}
|
|
4806
|
-
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
4807
|
-
>
|
|
4808
|
-
{t('backToLogin')}
|
|
4809
|
-
</button>
|
|
4810
|
-
</div>
|
|
4811
|
-
</div>
|
|
4812
|
-
);
|
|
4813
4521
|
}
|
|
4814
4522
|
|
|
4815
4523
|
return (
|
|
4816
|
-
<div className="flex min-h-[60vh]
|
|
4817
|
-
<
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
}
|
|
4822
|
-
|
|
4823
|
-
export default function OAuthCallbackPage() {
|
|
4824
|
-
return (
|
|
4825
|
-
<Suspense
|
|
4826
|
-
fallback={
|
|
4827
|
-
<div className="flex min-h-[60vh] items-center justify-center">
|
|
4828
|
-
<LoadingSpinner size="lg" />
|
|
4524
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
4525
|
+
<div className="w-full max-w-md space-y-6">
|
|
4526
|
+
<div className="text-center">
|
|
4527
|
+
<h1 className="text-foreground text-2xl font-bold">{t('forgotPasswordTitle')}</h1>
|
|
4528
|
+
<p className="text-muted-foreground mt-1 text-sm">{t('forgotPasswordSubtitle')}</p>
|
|
4829
4529
|
</div>
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4530
|
+
|
|
4531
|
+
{error && (
|
|
4532
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm">
|
|
4533
|
+
{error}
|
|
4534
|
+
</div>
|
|
4535
|
+
)}
|
|
4536
|
+
|
|
4537
|
+
{sent ? (
|
|
4538
|
+
<div className="space-y-4">
|
|
4539
|
+
<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">
|
|
4540
|
+
{t('resetLinkSent')}
|
|
4541
|
+
</div>
|
|
4542
|
+
<Link
|
|
4543
|
+
href="/login"
|
|
4544
|
+
className="text-primary block text-center text-sm font-medium hover:underline"
|
|
4545
|
+
>
|
|
4546
|
+
{t('backToLogin')}
|
|
4547
|
+
</Link>
|
|
4548
|
+
</div>
|
|
4549
|
+
) : (
|
|
4550
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
4551
|
+
<div>
|
|
4552
|
+
<label
|
|
4553
|
+
htmlFor="forgot-email"
|
|
4554
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
4555
|
+
>
|
|
4556
|
+
{t('email')}
|
|
4557
|
+
</label>
|
|
4558
|
+
<input
|
|
4559
|
+
id="forgot-email"
|
|
4560
|
+
type="email"
|
|
4561
|
+
required
|
|
4562
|
+
value={email}
|
|
4563
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
4564
|
+
placeholder={t('emailPlaceholder')}
|
|
4565
|
+
autoComplete="email"
|
|
4566
|
+
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"
|
|
4567
|
+
/>
|
|
4568
|
+
</div>
|
|
4569
|
+
|
|
4570
|
+
<button
|
|
4571
|
+
type="submit"
|
|
4572
|
+
disabled={loading}
|
|
4573
|
+
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"
|
|
4574
|
+
>
|
|
4575
|
+
{loading ? (
|
|
4576
|
+
<>
|
|
4577
|
+
<LoadingSpinner
|
|
4578
|
+
size="sm"
|
|
4579
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
4580
|
+
/>
|
|
4581
|
+
{t('sendingResetLink')}
|
|
4582
|
+
</>
|
|
4583
|
+
) : (
|
|
4584
|
+
t('sendResetLink')
|
|
4585
|
+
)}
|
|
4586
|
+
</button>
|
|
4587
|
+
|
|
4588
|
+
<Link
|
|
4589
|
+
href="/login"
|
|
4590
|
+
className="text-muted-foreground block text-center text-sm hover:underline"
|
|
4591
|
+
>
|
|
4592
|
+
{t('backToLogin')}
|
|
4593
|
+
</Link>
|
|
4594
|
+
</form>
|
|
4595
|
+
)}
|
|
4596
|
+
</div>
|
|
4597
|
+
</div>
|
|
4834
4598
|
);
|
|
4835
4599
|
}
|
|
4836
4600
|
`,
|
|
4837
|
-
"src/app/
|
|
4601
|
+
"src/app/reset-password/page.tsx": `'use client';
|
|
4838
4602
|
|
|
4839
|
-
import {
|
|
4603
|
+
import { useState } from 'react';
|
|
4840
4604
|
import { useRouter } from 'next/navigation';
|
|
4841
|
-
import
|
|
4842
|
-
import {
|
|
4843
|
-
import { useAuth } from '@/providers/store-provider';
|
|
4605
|
+
import Link from 'next/link';
|
|
4606
|
+
import { proxyResetPassword } from '@/lib/auth';
|
|
4844
4607
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
4845
|
-
import {
|
|
4608
|
+
import { useTranslations } from '@/lib/translations';
|
|
4609
|
+
|
|
4610
|
+
export default function ResetPasswordPage() {
|
|
4611
|
+
const router = useRouter();
|
|
4612
|
+
const t = useTranslations('auth');
|
|
4613
|
+
|
|
4614
|
+
const [newPassword, setNewPassword] = useState('');
|
|
4615
|
+
const [confirmPassword, setConfirmPassword] = useState('');
|
|
4616
|
+
const [loading, setLoading] = useState(false);
|
|
4617
|
+
const [error, setError] = useState<string | null>(null);
|
|
4618
|
+
const [success, setSuccess] = useState(false);
|
|
4619
|
+
|
|
4620
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
4621
|
+
e.preventDefault();
|
|
4622
|
+
if (loading) return;
|
|
4623
|
+
|
|
4624
|
+
if (newPassword !== confirmPassword) {
|
|
4625
|
+
setError(t('passwordsMustMatch'));
|
|
4626
|
+
return;
|
|
4627
|
+
}
|
|
4628
|
+
|
|
4629
|
+
try {
|
|
4630
|
+
setLoading(true);
|
|
4631
|
+
setError(null);
|
|
4632
|
+
await proxyResetPassword(newPassword);
|
|
4633
|
+
setSuccess(true);
|
|
4634
|
+
setTimeout(() => router.push('/login'), 2000);
|
|
4635
|
+
} catch (err) {
|
|
4636
|
+
const message =
|
|
4637
|
+
err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
|
4638
|
+
setError(message);
|
|
4639
|
+
} finally {
|
|
4640
|
+
setLoading(false);
|
|
4641
|
+
}
|
|
4642
|
+
}
|
|
4643
|
+
|
|
4644
|
+
return (
|
|
4645
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
4646
|
+
<div className="w-full max-w-md space-y-6">
|
|
4647
|
+
<div className="text-center">
|
|
4648
|
+
<h1 className="text-foreground text-2xl font-bold">{t('resetPasswordTitle')}</h1>
|
|
4649
|
+
<p className="text-muted-foreground mt-1 text-sm">{t('resetPasswordSubtitle')}</p>
|
|
4650
|
+
</div>
|
|
4651
|
+
|
|
4652
|
+
{error && (
|
|
4653
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive space-y-2 rounded-lg border px-4 py-3 text-sm">
|
|
4654
|
+
<p>{error}</p>
|
|
4655
|
+
<Link
|
|
4656
|
+
href="/forgot-password"
|
|
4657
|
+
className="text-primary block font-medium hover:underline"
|
|
4658
|
+
>
|
|
4659
|
+
{t('sendResetLink')}
|
|
4660
|
+
</Link>
|
|
4661
|
+
</div>
|
|
4662
|
+
)}
|
|
4663
|
+
|
|
4664
|
+
{success ? (
|
|
4665
|
+
<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">
|
|
4666
|
+
{t('passwordResetSuccess')}
|
|
4667
|
+
</div>
|
|
4668
|
+
) : (
|
|
4669
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
4670
|
+
<div>
|
|
4671
|
+
<label
|
|
4672
|
+
htmlFor="new-password"
|
|
4673
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
4674
|
+
>
|
|
4675
|
+
{t('newPassword')}
|
|
4676
|
+
</label>
|
|
4677
|
+
<input
|
|
4678
|
+
id="new-password"
|
|
4679
|
+
type="password"
|
|
4680
|
+
required
|
|
4681
|
+
minLength={8}
|
|
4682
|
+
value={newPassword}
|
|
4683
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
4684
|
+
placeholder={t('newPasswordPlaceholder')}
|
|
4685
|
+
autoComplete="new-password"
|
|
4686
|
+
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"
|
|
4687
|
+
/>
|
|
4688
|
+
</div>
|
|
4689
|
+
|
|
4690
|
+
<div>
|
|
4691
|
+
<label
|
|
4692
|
+
htmlFor="confirm-password"
|
|
4693
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
4694
|
+
>
|
|
4695
|
+
{t('confirmPassword')}
|
|
4696
|
+
</label>
|
|
4697
|
+
<input
|
|
4698
|
+
id="confirm-password"
|
|
4699
|
+
type="password"
|
|
4700
|
+
required
|
|
4701
|
+
minLength={8}
|
|
4702
|
+
value={confirmPassword}
|
|
4703
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
4704
|
+
placeholder={t('confirmPasswordPlaceholder')}
|
|
4705
|
+
autoComplete="new-password"
|
|
4706
|
+
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"
|
|
4707
|
+
/>
|
|
4708
|
+
</div>
|
|
4709
|
+
|
|
4710
|
+
<button
|
|
4711
|
+
type="submit"
|
|
4712
|
+
disabled={loading}
|
|
4713
|
+
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"
|
|
4714
|
+
>
|
|
4715
|
+
{loading ? (
|
|
4716
|
+
<>
|
|
4717
|
+
<LoadingSpinner
|
|
4718
|
+
size="sm"
|
|
4719
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
4720
|
+
/>
|
|
4721
|
+
{t('resettingPassword')}
|
|
4722
|
+
</>
|
|
4723
|
+
) : (
|
|
4724
|
+
t('resetPassword')
|
|
4725
|
+
)}
|
|
4726
|
+
</button>
|
|
4727
|
+
</form>
|
|
4728
|
+
)}
|
|
4729
|
+
</div>
|
|
4730
|
+
</div>
|
|
4731
|
+
);
|
|
4732
|
+
}
|
|
4733
|
+
`,
|
|
4734
|
+
"src/app/auth/callback/page.tsx": `'use client';
|
|
4735
|
+
|
|
4736
|
+
import { Suspense, useEffect, useState, useRef } from 'react';
|
|
4737
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
4738
|
+
import { useAuth } from '@/providers/store-provider';
|
|
4739
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
4740
|
+
import { useTranslations } from '@/lib/translations';
|
|
4741
|
+
|
|
4742
|
+
function OAuthCallbackContent() {
|
|
4743
|
+
const router = useRouter();
|
|
4744
|
+
const searchParams = useSearchParams();
|
|
4745
|
+
const auth = useAuth();
|
|
4746
|
+
const [error, setError] = useState<string | null>(null);
|
|
4747
|
+
const processedRef = useRef(false);
|
|
4748
|
+
const t = useTranslations('auth');
|
|
4749
|
+
|
|
4750
|
+
const oauthSuccess = searchParams.get('oauth_success');
|
|
4751
|
+
const oauthError = searchParams.get('oauth_error');
|
|
4752
|
+
// Token is no longer in URL \u2014 it was set as httpOnly cookie by /api/auth/oauth-callback
|
|
4753
|
+
|
|
4754
|
+
useEffect(() => {
|
|
4755
|
+
// Prevent double-processing in React StrictMode
|
|
4756
|
+
if (processedRef.current) return;
|
|
4757
|
+
processedRef.current = true;
|
|
4758
|
+
|
|
4759
|
+
if (oauthError) {
|
|
4760
|
+
setError(oauthError);
|
|
4761
|
+
return;
|
|
4762
|
+
}
|
|
4763
|
+
|
|
4764
|
+
if (oauthSuccess === 'true') {
|
|
4765
|
+
// Cookie was already set by the API route; refresh auth state
|
|
4766
|
+
auth.login().then(() => {
|
|
4767
|
+
router.push('/');
|
|
4768
|
+
});
|
|
4769
|
+
} else {
|
|
4770
|
+
setError(t('authFailedDesc'));
|
|
4771
|
+
}
|
|
4772
|
+
}, [oauthSuccess, oauthError, auth, router, t]);
|
|
4773
|
+
|
|
4774
|
+
if (error) {
|
|
4775
|
+
return (
|
|
4776
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
4777
|
+
<div className="w-full max-w-md space-y-4 text-center">
|
|
4778
|
+
<svg
|
|
4779
|
+
className="text-destructive mx-auto h-12 w-12"
|
|
4780
|
+
fill="none"
|
|
4781
|
+
viewBox="0 0 24 24"
|
|
4782
|
+
stroke="currentColor"
|
|
4783
|
+
>
|
|
4784
|
+
<path
|
|
4785
|
+
strokeLinecap="round"
|
|
4786
|
+
strokeLinejoin="round"
|
|
4787
|
+
strokeWidth={1.5}
|
|
4788
|
+
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"
|
|
4789
|
+
/>
|
|
4790
|
+
</svg>
|
|
4791
|
+
<h1 className="text-foreground text-2xl font-bold">{t('authFailed')}</h1>
|
|
4792
|
+
<p className="text-muted-foreground text-sm">{error}</p>
|
|
4793
|
+
<button
|
|
4794
|
+
type="button"
|
|
4795
|
+
onClick={() => router.push('/login')}
|
|
4796
|
+
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
4797
|
+
>
|
|
4798
|
+
{t('backToLogin')}
|
|
4799
|
+
</button>
|
|
4800
|
+
</div>
|
|
4801
|
+
</div>
|
|
4802
|
+
);
|
|
4803
|
+
}
|
|
4804
|
+
|
|
4805
|
+
return (
|
|
4806
|
+
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 py-12">
|
|
4807
|
+
<LoadingSpinner size="lg" />
|
|
4808
|
+
<p className="text-muted-foreground mt-4">{t('completingSignIn')}</p>
|
|
4809
|
+
</div>
|
|
4810
|
+
);
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4813
|
+
export default function OAuthCallbackPage() {
|
|
4814
|
+
return (
|
|
4815
|
+
<Suspense
|
|
4816
|
+
fallback={
|
|
4817
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
4818
|
+
<LoadingSpinner size="lg" />
|
|
4819
|
+
</div>
|
|
4820
|
+
}
|
|
4821
|
+
>
|
|
4822
|
+
<OAuthCallbackContent />
|
|
4823
|
+
</Suspense>
|
|
4824
|
+
);
|
|
4825
|
+
}
|
|
4826
|
+
`,
|
|
4827
|
+
"src/app/account/page.tsx": `'use client';
|
|
4828
|
+
|
|
4829
|
+
import { useEffect, useState } from 'react';
|
|
4830
|
+
import { useRouter } from 'next/navigation';
|
|
4831
|
+
import type { CustomerProfile, Order } from 'brainerce';
|
|
4832
|
+
import { getClient } from '@/lib/brainerce';
|
|
4833
|
+
import { useAuth } from '@/providers/store-provider';
|
|
4834
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
4835
|
+
import { ProfileSection } from '@/components/account/profile-section';
|
|
4836
|
+
import { AddressBook } from '@/components/account/address-book';
|
|
4846
4837
|
import { OrderHistory } from '@/components/account/order-history';
|
|
4847
4838
|
import { useTranslations } from '@/lib/translations';
|
|
4848
4839
|
|
|
@@ -4935,7 +4926,16 @@ export default function AccountPage() {
|
|
|
4935
4926
|
|
|
4936
4927
|
{/* Profile Section */}
|
|
4937
4928
|
{profile && (
|
|
4938
|
-
<ProfileSection profile={profile} onProfileUpdate={setProfile} className="mb-
|
|
4929
|
+
<ProfileSection profile={profile} onProfileUpdate={setProfile} className="mb-6" />
|
|
4930
|
+
)}
|
|
4931
|
+
|
|
4932
|
+
{/* Address Book */}
|
|
4933
|
+
{profile && (
|
|
4934
|
+
<AddressBook
|
|
4935
|
+
addresses={profile.addresses ?? []}
|
|
4936
|
+
onUpdate={(updated) => setProfile((p) => (p ? { ...p, addresses: updated } : p))}
|
|
4937
|
+
className="mb-8"
|
|
4938
|
+
/>
|
|
4939
4939
|
)}
|
|
4940
4940
|
|
|
4941
4941
|
{/* Order History */}
|
|
@@ -5024,9 +5024,11 @@ async function proxyRequest(
|
|
|
5024
5024
|
};
|
|
5025
5025
|
|
|
5026
5026
|
// Forward Origin/Referer so backend BrowserOriginGuard accepts proxied requests
|
|
5027
|
-
|
|
5027
|
+
// Always send Origin \u2014 same-origin GET requests may not include it, but the backend
|
|
5028
|
+
// uses its presence to distinguish fetch() calls from direct browser navigation
|
|
5029
|
+
const origin = request.headers.get('origin') || request.nextUrl.origin;
|
|
5028
5030
|
const referer = request.headers.get('referer');
|
|
5029
|
-
|
|
5031
|
+
headers['Origin'] = origin;
|
|
5030
5032
|
if (referer) headers['Referer'] = referer;
|
|
5031
5033
|
|
|
5032
5034
|
// Forward SDK version header if present
|
|
@@ -5205,7 +5207,7 @@ export async function GET(request: NextRequest) {
|
|
|
5205
5207
|
}
|
|
5206
5208
|
`,
|
|
5207
5209
|
"src/app/api/auth/me/route.ts": `import { NextResponse } from 'next/server';
|
|
5208
|
-
import { cookies } from 'next/headers';
|
|
5210
|
+
import { cookies, headers } from 'next/headers';
|
|
5209
5211
|
|
|
5210
5212
|
const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
|
|
5211
5213
|
/\\/$/,
|
|
@@ -5229,12 +5231,19 @@ export async function GET() {
|
|
|
5229
5231
|
return NextResponse.json({ isLoggedIn: false });
|
|
5230
5232
|
}
|
|
5231
5233
|
|
|
5234
|
+
// Derive Origin from the incoming request so the backend's BrowserOriginGuard accepts it
|
|
5235
|
+
const requestHeaders = await headers();
|
|
5236
|
+
const host = requestHeaders.get('host') || 'localhost:3000';
|
|
5237
|
+
const proto = requestHeaders.get('x-forwarded-proto') || 'http';
|
|
5238
|
+
const origin = requestHeaders.get('origin') || \`\${proto}://\${host}\`;
|
|
5239
|
+
|
|
5232
5240
|
try {
|
|
5233
5241
|
// Validate token by calling backend profile endpoint
|
|
5234
5242
|
const response = await fetch(\`\${BACKEND_URL}/api/vc/\${CONNECTION_ID}/customers/me\`, {
|
|
5235
5243
|
headers: {
|
|
5236
5244
|
Authorization: \`Bearer \${tokenCookie.value}\`,
|
|
5237
5245
|
'Content-Type': 'application/json',
|
|
5246
|
+
Origin: origin,
|
|
5238
5247
|
},
|
|
5239
5248
|
});
|
|
5240
5249
|
|
|
@@ -5268,6 +5277,126 @@ export async function POST() {
|
|
|
5268
5277
|
response.cookies.delete(LOGGED_IN_COOKIE);
|
|
5269
5278
|
return response;
|
|
5270
5279
|
}
|
|
5280
|
+
`,
|
|
5281
|
+
"src/app/api/auth/reset-callback/route.ts": `import { NextRequest, NextResponse } from 'next/server';
|
|
5282
|
+
|
|
5283
|
+
const RESET_TOKEN_COOKIE = 'brainerce_reset_token';
|
|
5284
|
+
const RESET_TOKEN_MAX_AGE = 10 * 60; // 10 minutes
|
|
5285
|
+
|
|
5286
|
+
function isSecure(): boolean {
|
|
5287
|
+
return process.env.NODE_ENV === 'production';
|
|
5288
|
+
}
|
|
5289
|
+
|
|
5290
|
+
/**
|
|
5291
|
+
* Password-reset callback handler.
|
|
5292
|
+
* The email link redirects here with ?token=... from the backend.
|
|
5293
|
+
* We store the token in an httpOnly cookie and redirect to /reset-password (clean URL).
|
|
5294
|
+
* This mirrors the OAuth callback pattern \u2014 the token never reaches client JS.
|
|
5295
|
+
*/
|
|
5296
|
+
export async function GET(request: NextRequest) {
|
|
5297
|
+
const { searchParams } = request.nextUrl;
|
|
5298
|
+
const token = searchParams.get('token');
|
|
5299
|
+
|
|
5300
|
+
if (!token) {
|
|
5301
|
+
const redirectUrl = new URL('/forgot-password', request.url);
|
|
5302
|
+
return NextResponse.redirect(redirectUrl);
|
|
5303
|
+
}
|
|
5304
|
+
|
|
5305
|
+
const redirectUrl = new URL('/reset-password', request.url);
|
|
5306
|
+
const response = NextResponse.redirect(redirectUrl);
|
|
5307
|
+
|
|
5308
|
+
// Set httpOnly cookie with the reset token (short-lived)
|
|
5309
|
+
response.cookies.set(RESET_TOKEN_COOKIE, token, {
|
|
5310
|
+
httpOnly: true,
|
|
5311
|
+
secure: isSecure(),
|
|
5312
|
+
sameSite: 'lax',
|
|
5313
|
+
path: '/',
|
|
5314
|
+
maxAge: RESET_TOKEN_MAX_AGE,
|
|
5315
|
+
});
|
|
5316
|
+
|
|
5317
|
+
// Prevent token leaking via Referer header
|
|
5318
|
+
response.headers.set('Referrer-Policy', 'no-referrer');
|
|
5319
|
+
|
|
5320
|
+
return response;
|
|
5321
|
+
}
|
|
5322
|
+
`,
|
|
5323
|
+
"src/app/api/auth/reset-password/route.ts": `import { NextRequest, NextResponse } from 'next/server';
|
|
5324
|
+
import { cookies, headers } from 'next/headers';
|
|
5325
|
+
|
|
5326
|
+
const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
|
|
5327
|
+
/\\/$/,
|
|
5328
|
+
''
|
|
5329
|
+
);
|
|
5330
|
+
|
|
5331
|
+
const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '';
|
|
5332
|
+
|
|
5333
|
+
const RESET_TOKEN_COOKIE = 'brainerce_reset_token';
|
|
5334
|
+
const CSRF_HEADER = 'x-requested-with';
|
|
5335
|
+
const CSRF_VALUE = 'brainerce';
|
|
5336
|
+
|
|
5337
|
+
/**
|
|
5338
|
+
* BFF endpoint for password reset.
|
|
5339
|
+
* Reads the reset token from the httpOnly cookie (set by /api/auth/reset-callback)
|
|
5340
|
+
* and proxies the request to the backend. The token never touches client JS.
|
|
5341
|
+
*/
|
|
5342
|
+
export async function POST(request: NextRequest) {
|
|
5343
|
+
// CSRF check
|
|
5344
|
+
const csrfHeader = request.headers.get(CSRF_HEADER);
|
|
5345
|
+
if (csrfHeader !== CSRF_VALUE) {
|
|
5346
|
+
return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 });
|
|
5347
|
+
}
|
|
5348
|
+
|
|
5349
|
+
// Read reset token from httpOnly cookie
|
|
5350
|
+
const cookieStore = await cookies();
|
|
5351
|
+
const resetTokenCookie = cookieStore.get(RESET_TOKEN_COOKIE);
|
|
5352
|
+
|
|
5353
|
+
if (!resetTokenCookie?.value) {
|
|
5354
|
+
return NextResponse.json(
|
|
5355
|
+
{ error: 'No reset token found. Please request a new password reset link.' },
|
|
5356
|
+
{ status: 400 }
|
|
5357
|
+
);
|
|
5358
|
+
}
|
|
5359
|
+
|
|
5360
|
+
// Parse request body
|
|
5361
|
+
const body = await request.json();
|
|
5362
|
+
const { newPassword } = body;
|
|
5363
|
+
|
|
5364
|
+
if (!newPassword) {
|
|
5365
|
+
return NextResponse.json({ error: 'New password is required' }, { status: 400 });
|
|
5366
|
+
}
|
|
5367
|
+
|
|
5368
|
+
// Derive Origin from the incoming request so the backend's BrowserOriginGuard accepts it
|
|
5369
|
+
const requestHeaders = await headers();
|
|
5370
|
+
const host = requestHeaders.get('host') || 'localhost:3000';
|
|
5371
|
+
const proto = requestHeaders.get('x-forwarded-proto') || 'http';
|
|
5372
|
+
const origin = requestHeaders.get('origin') || \`\${proto}://\${host}\`;
|
|
5373
|
+
|
|
5374
|
+
// Proxy to backend
|
|
5375
|
+
const backendUrl = \`\${BACKEND_URL}/api/vc/\${CONNECTION_ID}/customers/reset-password\`;
|
|
5376
|
+
|
|
5377
|
+
const backendResponse = await fetch(backendUrl, {
|
|
5378
|
+
method: 'POST',
|
|
5379
|
+
headers: {
|
|
5380
|
+
'Content-Type': 'application/json',
|
|
5381
|
+
Origin: origin,
|
|
5382
|
+
},
|
|
5383
|
+
body: JSON.stringify({
|
|
5384
|
+
token: resetTokenCookie.value,
|
|
5385
|
+
newPassword,
|
|
5386
|
+
}),
|
|
5387
|
+
});
|
|
5388
|
+
|
|
5389
|
+
const data = await backendResponse.json();
|
|
5390
|
+
|
|
5391
|
+
const response = NextResponse.json(data, {
|
|
5392
|
+
status: backendResponse.status,
|
|
5393
|
+
});
|
|
5394
|
+
|
|
5395
|
+
// Always clear the reset token cookie after use (success or failure)
|
|
5396
|
+
response.cookies.delete(RESET_TOKEN_COOKIE);
|
|
5397
|
+
|
|
5398
|
+
return response;
|
|
5399
|
+
}
|
|
5271
5400
|
`,
|
|
5272
5401
|
"src/middleware.ts": `import { NextRequest, NextResponse } from 'next/server';
|
|
5273
5402
|
|
|
@@ -5297,14 +5426,17 @@ export const config = {
|
|
|
5297
5426
|
`,
|
|
5298
5427
|
"src/components/products/product-card.tsx": `'use client';
|
|
5299
5428
|
|
|
5429
|
+
import { useState } from 'react';
|
|
5300
5430
|
import Link from 'next/link';
|
|
5431
|
+
import { useRouter } from 'next/navigation';
|
|
5301
5432
|
import Image from 'next/image';
|
|
5302
5433
|
import type { Product } from 'brainerce';
|
|
5303
|
-
import { getProductPriceInfo } from 'brainerce';
|
|
5434
|
+
import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
|
|
5304
5435
|
import { useTranslations } from '@/lib/translations';
|
|
5305
5436
|
import { PriceDisplay } from '@/components/shared/price-display';
|
|
5306
5437
|
import { StockBadge } from '@/components/products/stock-badge';
|
|
5307
5438
|
import { DiscountBadge } from '@/components/products/discount-badge';
|
|
5439
|
+
import { useCart, useStoreInfo } from '@/providers/store-provider';
|
|
5308
5440
|
import { cn } from '@/lib/utils';
|
|
5309
5441
|
|
|
5310
5442
|
interface ProductCardProps {
|
|
@@ -5312,60 +5444,167 @@ interface ProductCardProps {
|
|
|
5312
5444
|
className?: string;
|
|
5313
5445
|
}
|
|
5314
5446
|
|
|
5447
|
+
function VariantPriceRange({ product }: { product: Product }) {
|
|
5448
|
+
const { storeInfo } = useStoreInfo();
|
|
5449
|
+
const currency = storeInfo?.currency || 'USD';
|
|
5450
|
+
const variants = product.variants ?? [];
|
|
5451
|
+
if (variants.length === 0) return null;
|
|
5452
|
+
|
|
5453
|
+
const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
|
|
5454
|
+
const min = Math.min(...prices);
|
|
5455
|
+
const max = Math.max(...prices);
|
|
5456
|
+
|
|
5457
|
+
return (
|
|
5458
|
+
<span className="text-foreground text-sm font-medium">
|
|
5459
|
+
{min === max
|
|
5460
|
+
? (formatPrice(min, { currency }) as string)
|
|
5461
|
+
: \`\${formatPrice(min, { currency })} \u2013 \${formatPrice(max, { currency })}\`}
|
|
5462
|
+
</span>
|
|
5463
|
+
);
|
|
5464
|
+
}
|
|
5465
|
+
|
|
5315
5466
|
export function ProductCard({ product, className }: ProductCardProps) {
|
|
5316
5467
|
const t = useTranslations('common');
|
|
5317
5468
|
const tp = useTranslations('productDetail');
|
|
5469
|
+
const tProd = useTranslations('products');
|
|
5470
|
+
const router = useRouter();
|
|
5471
|
+
const { refreshCart } = useCart();
|
|
5318
5472
|
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
5319
5473
|
const mainImage = product.images?.[0];
|
|
5320
5474
|
const imageUrl = mainImage?.url || null;
|
|
5321
5475
|
const slug = product.slug || product.id;
|
|
5476
|
+
const isVariable = product.type === 'VARIABLE';
|
|
5477
|
+
|
|
5478
|
+
const [adding, setAdding] = useState(false);
|
|
5479
|
+
const [added, setAdded] = useState(false);
|
|
5480
|
+
|
|
5481
|
+
const canPurchase = product.inventory?.canPurchase !== false;
|
|
5482
|
+
|
|
5483
|
+
async function handleAddToCart(e: React.MouseEvent) {
|
|
5484
|
+
e.preventDefault();
|
|
5485
|
+
e.stopPropagation();
|
|
5486
|
+
|
|
5487
|
+
if (isVariable) {
|
|
5488
|
+
router.push(\`/products/\${slug}\`);
|
|
5489
|
+
return;
|
|
5490
|
+
}
|
|
5491
|
+
|
|
5492
|
+
if (adding || !canPurchase) return;
|
|
5493
|
+
|
|
5494
|
+
try {
|
|
5495
|
+
setAdding(true);
|
|
5496
|
+
const { getClient } = await import('@/lib/brainerce');
|
|
5497
|
+
const client = getClient();
|
|
5498
|
+
await client.smartAddToCart({ productId: product.id, quantity: 1 });
|
|
5499
|
+
await refreshCart();
|
|
5500
|
+
setAdded(true);
|
|
5501
|
+
setTimeout(() => setAdded(false), 2000);
|
|
5502
|
+
} catch (err) {
|
|
5503
|
+
console.error('Failed to add to cart:', err);
|
|
5504
|
+
} finally {
|
|
5505
|
+
setAdding(false);
|
|
5506
|
+
}
|
|
5507
|
+
}
|
|
5322
5508
|
|
|
5323
5509
|
return (
|
|
5324
|
-
<
|
|
5325
|
-
href={\`/products/\${slug}\`}
|
|
5510
|
+
<div
|
|
5326
5511
|
className={cn(
|
|
5327
5512
|
'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
|
|
5328
5513
|
className
|
|
5329
5514
|
)}
|
|
5330
5515
|
>
|
|
5331
|
-
{/* Image */}
|
|
5332
|
-
<
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
<
|
|
5344
|
-
<
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5349
|
-
|
|
5350
|
-
|
|
5516
|
+
{/* Image \u2014 clickable */}
|
|
5517
|
+
<Link href={\`/products/\${slug}\`} className="block">
|
|
5518
|
+
<div className="bg-muted relative aspect-square overflow-hidden">
|
|
5519
|
+
{imageUrl ? (
|
|
5520
|
+
<Image
|
|
5521
|
+
src={imageUrl}
|
|
5522
|
+
alt={mainImage?.alt || product.name}
|
|
5523
|
+
fill
|
|
5524
|
+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
5525
|
+
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
|
5526
|
+
/>
|
|
5527
|
+
) : (
|
|
5528
|
+
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
5529
|
+
<svg className="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
5530
|
+
<path
|
|
5531
|
+
strokeLinecap="round"
|
|
5532
|
+
strokeLinejoin="round"
|
|
5533
|
+
strokeWidth={1.5}
|
|
5534
|
+
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"
|
|
5535
|
+
/>
|
|
5536
|
+
</svg>
|
|
5537
|
+
</div>
|
|
5538
|
+
)}
|
|
5539
|
+
|
|
5540
|
+
{/* Badges */}
|
|
5541
|
+
<div className="absolute start-2 top-2 flex flex-col gap-1">
|
|
5542
|
+
{isOnSale && (
|
|
5543
|
+
<span className="bg-destructive text-destructive-foreground rounded px-2 py-1 text-xs font-bold">
|
|
5544
|
+
{t('sale')}
|
|
5545
|
+
</span>
|
|
5546
|
+
)}
|
|
5547
|
+
<DiscountBadge discount={product.discount} />
|
|
5548
|
+
{product.isDownloadable && (
|
|
5549
|
+
<span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
|
|
5550
|
+
{tp('digitalProduct')}
|
|
5551
|
+
</span>
|
|
5552
|
+
)}
|
|
5351
5553
|
</div>
|
|
5352
|
-
)}
|
|
5353
5554
|
|
|
5354
|
-
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
{
|
|
5359
|
-
|
|
5360
|
-
|
|
5361
|
-
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
5555
|
+
{/* Add to cart overlay button */}
|
|
5556
|
+
{(isVariable || canPurchase) && (
|
|
5557
|
+
<button
|
|
5558
|
+
onClick={handleAddToCart}
|
|
5559
|
+
disabled={adding}
|
|
5560
|
+
aria-label={isVariable ? tProd('selectOptions') : tp('addToCart')}
|
|
5561
|
+
className={cn(
|
|
5562
|
+
'absolute bottom-2 end-2 flex h-8 w-8 items-center justify-center rounded-full shadow-md transition-all',
|
|
5563
|
+
'translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100',
|
|
5564
|
+
added
|
|
5565
|
+
? 'bg-green-500 text-white'
|
|
5566
|
+
: 'bg-primary text-primary-foreground hover:opacity-90'
|
|
5567
|
+
)}
|
|
5568
|
+
>
|
|
5569
|
+
{added ? (
|
|
5570
|
+
<svg
|
|
5571
|
+
className="h-4 w-4"
|
|
5572
|
+
fill="none"
|
|
5573
|
+
viewBox="0 0 24 24"
|
|
5574
|
+
stroke="currentColor"
|
|
5575
|
+
strokeWidth={2.5}
|
|
5576
|
+
>
|
|
5577
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
5578
|
+
</svg>
|
|
5579
|
+
) : isVariable ? (
|
|
5580
|
+
<svg
|
|
5581
|
+
className="h-4 w-4"
|
|
5582
|
+
fill="none"
|
|
5583
|
+
viewBox="0 0 24 24"
|
|
5584
|
+
stroke="currentColor"
|
|
5585
|
+
strokeWidth={2}
|
|
5586
|
+
>
|
|
5587
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
|
5588
|
+
</svg>
|
|
5589
|
+
) : (
|
|
5590
|
+
<svg
|
|
5591
|
+
className="h-4 w-4"
|
|
5592
|
+
fill="none"
|
|
5593
|
+
viewBox="0 0 24 24"
|
|
5594
|
+
stroke="currentColor"
|
|
5595
|
+
strokeWidth={2}
|
|
5596
|
+
>
|
|
5597
|
+
<path
|
|
5598
|
+
strokeLinecap="round"
|
|
5599
|
+
strokeLinejoin="round"
|
|
5600
|
+
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"
|
|
5601
|
+
/>
|
|
5602
|
+
</svg>
|
|
5603
|
+
)}
|
|
5604
|
+
</button>
|
|
5366
5605
|
)}
|
|
5367
5606
|
</div>
|
|
5368
|
-
</
|
|
5607
|
+
</Link>
|
|
5369
5608
|
|
|
5370
5609
|
{/* Content */}
|
|
5371
5610
|
<div className="space-y-2 p-3">
|
|
@@ -5383,18 +5622,24 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
5383
5622
|
</div>
|
|
5384
5623
|
)}
|
|
5385
5624
|
|
|
5386
|
-
{/* Name */}
|
|
5387
|
-
<
|
|
5388
|
-
|
|
5389
|
-
|
|
5625
|
+
{/* Name \u2014 clickable */}
|
|
5626
|
+
<Link href={\`/products/\${slug}\`}>
|
|
5627
|
+
<h3 className="text-foreground hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
|
|
5628
|
+
{product.name}
|
|
5629
|
+
</h3>
|
|
5630
|
+
</Link>
|
|
5390
5631
|
|
|
5391
5632
|
{/* Price */}
|
|
5392
|
-
|
|
5633
|
+
{isVariable ? (
|
|
5634
|
+
<VariantPriceRange product={product} />
|
|
5635
|
+
) : (
|
|
5636
|
+
<PriceDisplay price={originalPrice} salePrice={isOnSale ? price : undefined} size="sm" />
|
|
5637
|
+
)}
|
|
5393
5638
|
|
|
5394
5639
|
{/* Stock */}
|
|
5395
5640
|
<StockBadge inventory={product.inventory} />
|
|
5396
5641
|
</div>
|
|
5397
|
-
</
|
|
5642
|
+
</div>
|
|
5398
5643
|
);
|
|
5399
5644
|
}
|
|
5400
5645
|
`,
|
|
@@ -5438,8 +5683,10 @@ export function ProductGrid({ products, className }: ProductGridProps) {
|
|
|
5438
5683
|
|
|
5439
5684
|
import { useMemo } from 'react';
|
|
5440
5685
|
import type { Product, ProductVariant } from 'brainerce';
|
|
5441
|
-
import { getVariantOptions,
|
|
5686
|
+
import { getVariantOptions, getProductSwatches, formatPrice } from 'brainerce';
|
|
5687
|
+
import type { InventoryInfo } from 'brainerce';
|
|
5442
5688
|
import { useStoreInfo } from '@/providers/store-provider';
|
|
5689
|
+
import { useTranslations } from '@/lib/translations';
|
|
5443
5690
|
import { cn } from '@/lib/utils';
|
|
5444
5691
|
|
|
5445
5692
|
interface VariantSelectorProps {
|
|
@@ -5451,8 +5698,12 @@ interface VariantSelectorProps {
|
|
|
5451
5698
|
|
|
5452
5699
|
interface AttributeGroup {
|
|
5453
5700
|
name: string;
|
|
5701
|
+
displayType: string;
|
|
5454
5702
|
values: Array<{
|
|
5455
5703
|
value: string;
|
|
5704
|
+
swatchColor?: string | null;
|
|
5705
|
+
swatchColor2?: string | null;
|
|
5706
|
+
swatchImageUrl?: string | null;
|
|
5456
5707
|
variants: ProductVariant[];
|
|
5457
5708
|
}>;
|
|
5458
5709
|
}
|
|
@@ -5464,10 +5715,49 @@ export function VariantSelector({
|
|
|
5464
5715
|
className,
|
|
5465
5716
|
}: VariantSelectorProps) {
|
|
5466
5717
|
const { storeInfo } = useStoreInfo();
|
|
5718
|
+
const t = useTranslations('productDetail');
|
|
5467
5719
|
const currency = storeInfo?.currency || 'USD';
|
|
5468
5720
|
const variants = useMemo(() => product.variants || [], [product.variants]);
|
|
5469
5721
|
|
|
5470
|
-
//
|
|
5722
|
+
// Get swatch metadata from product attribute options
|
|
5723
|
+
const swatchData = useMemo(() => getProductSwatches(product), [product]);
|
|
5724
|
+
const swatchMap = useMemo(() => {
|
|
5725
|
+
const map = new Map<
|
|
5726
|
+
string,
|
|
5727
|
+
{
|
|
5728
|
+
displayType: string;
|
|
5729
|
+
options: Map<
|
|
5730
|
+
string,
|
|
5731
|
+
{
|
|
5732
|
+
swatchColor?: string | null;
|
|
5733
|
+
swatchColor2?: string | null;
|
|
5734
|
+
swatchImageUrl?: string | null;
|
|
5735
|
+
}
|
|
5736
|
+
>;
|
|
5737
|
+
}
|
|
5738
|
+
>();
|
|
5739
|
+
for (const attr of swatchData) {
|
|
5740
|
+
const optMap = new Map<
|
|
5741
|
+
string,
|
|
5742
|
+
{
|
|
5743
|
+
swatchColor?: string | null;
|
|
5744
|
+
swatchColor2?: string | null;
|
|
5745
|
+
swatchImageUrl?: string | null;
|
|
5746
|
+
}
|
|
5747
|
+
>();
|
|
5748
|
+
for (const opt of attr.options) {
|
|
5749
|
+
optMap.set(opt.name, {
|
|
5750
|
+
swatchColor: opt.swatchColor,
|
|
5751
|
+
swatchColor2: opt.swatchColor2,
|
|
5752
|
+
swatchImageUrl: opt.swatchImageUrl,
|
|
5753
|
+
});
|
|
5754
|
+
}
|
|
5755
|
+
map.set(attr.attributeName, { displayType: attr.displayType, options: optMap });
|
|
5756
|
+
}
|
|
5757
|
+
return map;
|
|
5758
|
+
}, [swatchData]);
|
|
5759
|
+
|
|
5760
|
+
// Build attribute groups from variant data, enriched with swatch info
|
|
5471
5761
|
const attributeGroups = useMemo<AttributeGroup[]>(() => {
|
|
5472
5762
|
const groups = new Map<string, Map<string, ProductVariant[]>>();
|
|
5473
5763
|
|
|
@@ -5485,14 +5775,24 @@ export function VariantSelector({
|
|
|
5485
5775
|
}
|
|
5486
5776
|
}
|
|
5487
5777
|
|
|
5488
|
-
return Array.from(groups.entries()).map(([name, valuesMap]) =>
|
|
5489
|
-
name
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5778
|
+
return Array.from(groups.entries()).map(([name, valuesMap]) => {
|
|
5779
|
+
const attrSwatch = swatchMap.get(name);
|
|
5780
|
+
return {
|
|
5781
|
+
name,
|
|
5782
|
+
displayType: attrSwatch?.displayType || 'DROPDOWN',
|
|
5783
|
+
values: Array.from(valuesMap.entries()).map(([value, variantList]) => {
|
|
5784
|
+
const optSwatch = attrSwatch?.options.get(value);
|
|
5785
|
+
return {
|
|
5786
|
+
value,
|
|
5787
|
+
swatchColor: optSwatch?.swatchColor,
|
|
5788
|
+
swatchColor2: optSwatch?.swatchColor2,
|
|
5789
|
+
swatchImageUrl: optSwatch?.swatchImageUrl,
|
|
5790
|
+
variants: variantList,
|
|
5791
|
+
};
|
|
5792
|
+
}),
|
|
5793
|
+
};
|
|
5794
|
+
});
|
|
5795
|
+
}, [variants, swatchMap]);
|
|
5496
5796
|
|
|
5497
5797
|
// Get currently selected attribute values
|
|
5498
5798
|
const selectedOptions = useMemo(() => {
|
|
@@ -5532,33 +5832,111 @@ export function VariantSelector({
|
|
|
5532
5832
|
)}
|
|
5533
5833
|
</label>
|
|
5534
5834
|
<div className="flex flex-wrap gap-2">
|
|
5535
|
-
{group.values.map(
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5835
|
+
{group.values.map(
|
|
5836
|
+
({
|
|
5837
|
+
value,
|
|
5838
|
+
swatchColor,
|
|
5839
|
+
swatchColor2,
|
|
5840
|
+
swatchImageUrl,
|
|
5841
|
+
variants: matchingVariants,
|
|
5842
|
+
}) => {
|
|
5843
|
+
const isSelected = selectedOptions.get(group.name) === value;
|
|
5844
|
+
const matchedVariant = findMatchingVariant(group.name, value);
|
|
5845
|
+
const isAvailable = matchedVariant?.inventory?.canPurchase !== false;
|
|
5846
|
+
|
|
5847
|
+
// Color swatch rendering
|
|
5848
|
+
if (group.displayType === 'COLOR_SWATCH' && swatchColor) {
|
|
5849
|
+
return (
|
|
5850
|
+
<button
|
|
5851
|
+
key={value}
|
|
5852
|
+
type="button"
|
|
5853
|
+
disabled={!isAvailable}
|
|
5854
|
+
title={value}
|
|
5855
|
+
onClick={() => {
|
|
5856
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
5857
|
+
if (variant) onVariantChange(variant);
|
|
5858
|
+
}}
|
|
5859
|
+
className={cn(
|
|
5860
|
+
'h-9 w-9 rounded-full border-2 transition-all',
|
|
5861
|
+
isSelected
|
|
5862
|
+
? 'border-primary ring-primary/30 ring-2'
|
|
5863
|
+
: isAvailable
|
|
5864
|
+
? 'border-border hover:border-primary'
|
|
5865
|
+
: 'cursor-not-allowed opacity-40'
|
|
5866
|
+
)}
|
|
5867
|
+
style={{
|
|
5868
|
+
background: swatchColor2
|
|
5869
|
+
? \`linear-gradient(135deg, \${swatchColor} 50%, \${swatchColor2} 50%)\`
|
|
5870
|
+
: swatchColor,
|
|
5871
|
+
}}
|
|
5872
|
+
>
|
|
5873
|
+
{!isAvailable && (
|
|
5874
|
+
<span
|
|
5875
|
+
className="bg-muted-foreground block h-full w-full rounded-full opacity-50"
|
|
5876
|
+
style={{
|
|
5877
|
+
backgroundImage:
|
|
5878
|
+
'linear-gradient(135deg, transparent 45%, currentColor 45%, currentColor 55%, transparent 55%)',
|
|
5879
|
+
}}
|
|
5880
|
+
/>
|
|
5881
|
+
)}
|
|
5882
|
+
</button>
|
|
5883
|
+
);
|
|
5884
|
+
}
|
|
5539
5885
|
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5886
|
+
// Image swatch rendering
|
|
5887
|
+
if (group.displayType === 'IMAGE_SWATCH' && swatchImageUrl) {
|
|
5888
|
+
return (
|
|
5889
|
+
<button
|
|
5890
|
+
key={value}
|
|
5891
|
+
type="button"
|
|
5892
|
+
disabled={!isAvailable}
|
|
5893
|
+
title={value}
|
|
5894
|
+
onClick={() => {
|
|
5895
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
5896
|
+
if (variant) onVariantChange(variant);
|
|
5897
|
+
}}
|
|
5898
|
+
className={cn(
|
|
5899
|
+
'h-10 w-10 overflow-hidden rounded-lg border-2 transition-all',
|
|
5900
|
+
isSelected
|
|
5901
|
+
? 'border-primary ring-primary/30 ring-2'
|
|
5902
|
+
: isAvailable
|
|
5903
|
+
? 'border-border hover:border-primary'
|
|
5904
|
+
: 'cursor-not-allowed opacity-40'
|
|
5905
|
+
)}
|
|
5906
|
+
>
|
|
5907
|
+
<img
|
|
5908
|
+
src={swatchImageUrl}
|
|
5909
|
+
alt={value}
|
|
5910
|
+
className="h-full w-full object-cover"
|
|
5911
|
+
/>
|
|
5912
|
+
</button>
|
|
5913
|
+
);
|
|
5914
|
+
}
|
|
5915
|
+
|
|
5916
|
+
// Default button rendering (BUTTON, DROPDOWN, or fallback)
|
|
5917
|
+
return (
|
|
5918
|
+
<button
|
|
5919
|
+
key={value}
|
|
5920
|
+
type="button"
|
|
5921
|
+
disabled={!isAvailable}
|
|
5922
|
+
onClick={() => {
|
|
5923
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
5924
|
+
if (variant) onVariantChange(variant);
|
|
5925
|
+
}}
|
|
5926
|
+
className={cn(
|
|
5927
|
+
'rounded border px-4 py-2 text-sm transition-colors',
|
|
5928
|
+
isSelected
|
|
5929
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
5930
|
+
: isAvailable
|
|
5931
|
+
? 'border-border bg-background text-foreground hover:border-primary'
|
|
5932
|
+
: 'border-border bg-muted text-muted-foreground cursor-not-allowed line-through opacity-50'
|
|
5933
|
+
)}
|
|
5934
|
+
>
|
|
5935
|
+
{value}
|
|
5936
|
+
</button>
|
|
5937
|
+
);
|
|
5938
|
+
}
|
|
5939
|
+
)}
|
|
5562
5940
|
</div>
|
|
5563
5941
|
</div>
|
|
5564
5942
|
))}
|
|
@@ -5575,18 +5953,30 @@ export function VariantSelector({
|
|
|
5575
5953
|
}
|
|
5576
5954
|
</span>
|
|
5577
5955
|
)}
|
|
5578
|
-
<span>{
|
|
5956
|
+
<span>{getTranslatedStockStatus(selectedVariant.inventory, t)}</span>
|
|
5579
5957
|
</div>
|
|
5580
5958
|
)}
|
|
5581
5959
|
</div>
|
|
5582
5960
|
);
|
|
5583
5961
|
}
|
|
5962
|
+
|
|
5963
|
+
function getTranslatedStockStatus(
|
|
5964
|
+
inventory: InventoryInfo | null | undefined,
|
|
5965
|
+
t: (key: string) => string
|
|
5966
|
+
): string {
|
|
5967
|
+
if (!inventory) return t('outOfStock');
|
|
5968
|
+
const { trackingMode, inStock, available } = inventory;
|
|
5969
|
+
if (trackingMode === 'DISABLED') return t('unavailable');
|
|
5970
|
+
if (!inStock) return t('outOfStock');
|
|
5971
|
+
if (trackingMode === 'UNLIMITED') return t('inStock');
|
|
5972
|
+
return t('availableInStock').replace('{available}', String(available));
|
|
5973
|
+
}
|
|
5584
5974
|
`,
|
|
5585
5975
|
"src/components/products/stock-badge.tsx": `'use client';
|
|
5586
5976
|
|
|
5587
|
-
import { getStockStatus } from 'brainerce';
|
|
5588
5977
|
import type { InventoryInfo } from 'brainerce';
|
|
5589
5978
|
import { cn } from '@/lib/utils';
|
|
5979
|
+
import { useTranslations } from '@/lib/translations';
|
|
5590
5980
|
|
|
5591
5981
|
interface StockBadgeProps {
|
|
5592
5982
|
inventory: InventoryInfo | null | undefined;
|
|
@@ -5595,27 +5985,56 @@ interface StockBadgeProps {
|
|
|
5595
5985
|
}
|
|
5596
5986
|
|
|
5597
5987
|
export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
|
|
5598
|
-
const
|
|
5599
|
-
|
|
5600
|
-
const
|
|
5601
|
-
status === 'Out of Stock' || status === 'Unavailable'
|
|
5602
|
-
? 'bg-red-100 text-red-800'
|
|
5603
|
-
: status === 'Low Stock'
|
|
5604
|
-
? 'bg-yellow-100 text-yellow-800'
|
|
5605
|
-
: 'bg-green-100 text-green-800';
|
|
5988
|
+
const t = useTranslations('productDetail');
|
|
5989
|
+
const label = getStockLabel(inventory, lowStockThreshold, t);
|
|
5990
|
+
const color = getStockColor(inventory, lowStockThreshold);
|
|
5606
5991
|
|
|
5607
5992
|
return (
|
|
5608
5993
|
<span
|
|
5609
5994
|
className={cn(
|
|
5610
5995
|
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
|
5611
|
-
|
|
5996
|
+
color,
|
|
5612
5997
|
className
|
|
5613
5998
|
)}
|
|
5614
5999
|
>
|
|
5615
|
-
{
|
|
6000
|
+
{label}
|
|
5616
6001
|
</span>
|
|
5617
6002
|
);
|
|
5618
6003
|
}
|
|
6004
|
+
|
|
6005
|
+
function getStockLabel(
|
|
6006
|
+
inventory: InventoryInfo | null | undefined,
|
|
6007
|
+
lowStockThreshold: number,
|
|
6008
|
+
t: (key: string) => string
|
|
6009
|
+
): string {
|
|
6010
|
+
if (!inventory) return t('outOfStock');
|
|
6011
|
+
|
|
6012
|
+
const { trackingMode, inStock, available } = inventory;
|
|
6013
|
+
|
|
6014
|
+
if (trackingMode === 'DISABLED') return t('unavailable');
|
|
6015
|
+
if (!inStock) return t('outOfStock');
|
|
6016
|
+
if (trackingMode === 'UNLIMITED') return t('inStock');
|
|
6017
|
+
|
|
6018
|
+
// TRACKED \u2014 show actual quantity
|
|
6019
|
+
if (available <= lowStockThreshold) {
|
|
6020
|
+
return t('onlyLeft').replace('{available}', String(available));
|
|
6021
|
+
}
|
|
6022
|
+
return t('availableInStock').replace('{available}', String(available));
|
|
6023
|
+
}
|
|
6024
|
+
|
|
6025
|
+
function getStockColor(
|
|
6026
|
+
inventory: InventoryInfo | null | undefined,
|
|
6027
|
+
lowStockThreshold: number
|
|
6028
|
+
): string {
|
|
6029
|
+
if (!inventory) return 'bg-red-100 text-red-800';
|
|
6030
|
+
|
|
6031
|
+
const { trackingMode, inStock, available } = inventory;
|
|
6032
|
+
|
|
6033
|
+
if (trackingMode === 'DISABLED' || !inStock) return 'bg-red-100 text-red-800';
|
|
6034
|
+
if (trackingMode === 'TRACKED' && available <= lowStockThreshold)
|
|
6035
|
+
return 'bg-yellow-100 text-yellow-800';
|
|
6036
|
+
return 'bg-green-100 text-green-800';
|
|
6037
|
+
}
|
|
5619
6038
|
`,
|
|
5620
6039
|
"src/components/products/discount-badge.tsx": `import type { ProductDiscount } from 'brainerce';
|
|
5621
6040
|
import { cn } from '@/lib/utils';
|
|
@@ -5645,7 +6064,7 @@ export function DiscountBadge({ discount, className }: DiscountBadgeProps) {
|
|
|
5645
6064
|
import { useState } from 'react';
|
|
5646
6065
|
import Image from 'next/image';
|
|
5647
6066
|
import type { CartItem as CartItemType } from 'brainerce';
|
|
5648
|
-
import {
|
|
6067
|
+
import { getCartItemImage, formatPrice } from 'brainerce';
|
|
5649
6068
|
import { getClient } from '@/lib/brainerce';
|
|
5650
6069
|
import { useTranslations } from '@/lib/translations';
|
|
5651
6070
|
import { useStoreInfo } from '@/providers/store-provider';
|
|
@@ -5666,7 +6085,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
5666
6085
|
const [updating, setUpdating] = useState(false);
|
|
5667
6086
|
const [removing, setRemoving] = useState(false);
|
|
5668
6087
|
|
|
5669
|
-
const
|
|
6088
|
+
const productName = item.product.name;
|
|
5670
6089
|
const imageUrl = getCartItemImage(item);
|
|
5671
6090
|
const variantName = item.variant?.name;
|
|
5672
6091
|
const unitPrice = parseFloat(item.unitPrice);
|
|
@@ -5713,7 +6132,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
5713
6132
|
{/* Image */}
|
|
5714
6133
|
<div className="bg-muted relative h-20 w-20 flex-shrink-0 overflow-hidden rounded">
|
|
5715
6134
|
{imageUrl ? (
|
|
5716
|
-
<Image src={imageUrl} alt={
|
|
6135
|
+
<Image src={imageUrl} alt={productName} fill sizes="80px" className="object-cover" />
|
|
5717
6136
|
) : (
|
|
5718
6137
|
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
5719
6138
|
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
@@ -5730,7 +6149,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
5730
6149
|
|
|
5731
6150
|
{/* Details */}
|
|
5732
6151
|
<div className="min-w-0 flex-1">
|
|
5733
|
-
<h3 className="text-foreground truncate text-sm font-medium">{
|
|
6152
|
+
<h3 className="text-foreground truncate text-sm font-medium">{productName}</h3>
|
|
5734
6153
|
|
|
5735
6154
|
{/* Variant name */}
|
|
5736
6155
|
{variantName && <p className="text-muted-foreground mt-1 text-xs">{variantName}</p>}
|
|
@@ -6184,17 +6603,24 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
|
|
|
6184
6603
|
`,
|
|
6185
6604
|
"src/components/checkout/checkout-form.tsx": `'use client';
|
|
6186
6605
|
|
|
6187
|
-
import { useState } from 'react';
|
|
6606
|
+
import { useState, useEffect, useRef } from 'react';
|
|
6188
6607
|
import type { SetShippingAddressDto, ShippingDestinations } from 'brainerce';
|
|
6189
6608
|
import { useTranslations } from '@/lib/translations';
|
|
6190
6609
|
import { cn } from '@/lib/utils';
|
|
6191
6610
|
|
|
6611
|
+
interface CheckoutConsent {
|
|
6612
|
+
acceptsMarketing: boolean;
|
|
6613
|
+
saveDetails: boolean;
|
|
6614
|
+
}
|
|
6615
|
+
|
|
6192
6616
|
interface CheckoutFormProps {
|
|
6193
|
-
onSubmit: (address: SetShippingAddressDto) => void;
|
|
6617
|
+
onSubmit: (address: SetShippingAddressDto, consent: CheckoutConsent) => void;
|
|
6194
6618
|
loading?: boolean;
|
|
6195
6619
|
initialValues?: Partial<SetShippingAddressDto>;
|
|
6196
6620
|
destinations?: ShippingDestinations | null;
|
|
6197
6621
|
className?: string;
|
|
6622
|
+
showSaveDetails?: boolean;
|
|
6623
|
+
emailOnly?: boolean;
|
|
6198
6624
|
}
|
|
6199
6625
|
|
|
6200
6626
|
export function CheckoutForm({
|
|
@@ -6203,6 +6629,8 @@ export function CheckoutForm({
|
|
|
6203
6629
|
initialValues,
|
|
6204
6630
|
destinations,
|
|
6205
6631
|
className,
|
|
6632
|
+
showSaveDetails = false,
|
|
6633
|
+
emailOnly = false,
|
|
6206
6634
|
}: CheckoutFormProps) {
|
|
6207
6635
|
const [formData, setFormData] = useState<SetShippingAddressDto>({
|
|
6208
6636
|
email: initialValues?.email || '',
|
|
@@ -6217,8 +6645,30 @@ export function CheckoutForm({
|
|
|
6217
6645
|
phone: initialValues?.phone || '',
|
|
6218
6646
|
});
|
|
6219
6647
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
6648
|
+
const [privacyAccepted, setPrivacyAccepted] = useState(false);
|
|
6649
|
+
const [acceptsMarketing, setAcceptsMarketing] = useState(false);
|
|
6650
|
+
const [saveDetails, setSaveDetails] = useState(true);
|
|
6220
6651
|
const t = useTranslations('checkoutForm');
|
|
6221
6652
|
const tc = useTranslations('common');
|
|
6653
|
+
const hasAppliedPrefill = useRef(!!initialValues);
|
|
6654
|
+
|
|
6655
|
+
// Sync prefill data when it arrives async (e.g. from getCheckoutPrefillData)
|
|
6656
|
+
useEffect(() => {
|
|
6657
|
+
if (!initialValues || hasAppliedPrefill.current) return;
|
|
6658
|
+
hasAppliedPrefill.current = true;
|
|
6659
|
+
setFormData((prev) => ({
|
|
6660
|
+
email: initialValues.email || prev.email,
|
|
6661
|
+
firstName: initialValues.firstName || prev.firstName,
|
|
6662
|
+
lastName: initialValues.lastName || prev.lastName,
|
|
6663
|
+
line1: initialValues.line1 || prev.line1,
|
|
6664
|
+
line2: initialValues.line2 || prev.line2 || '',
|
|
6665
|
+
city: initialValues.city || prev.city,
|
|
6666
|
+
region: initialValues.region || prev.region || '',
|
|
6667
|
+
postalCode: initialValues.postalCode || prev.postalCode,
|
|
6668
|
+
country: initialValues.country || prev.country,
|
|
6669
|
+
phone: initialValues.phone || prev.phone || '',
|
|
6670
|
+
}));
|
|
6671
|
+
}, [initialValues]);
|
|
6222
6672
|
|
|
6223
6673
|
const hasCountryOptions = destinations && destinations.countries.length > 0;
|
|
6224
6674
|
const countryRegions = destinations?.regions[formData.country];
|
|
@@ -6239,17 +6689,22 @@ export function CheckoutForm({
|
|
|
6239
6689
|
if (!formData.lastName.trim()) {
|
|
6240
6690
|
newErrors.lastName = t('lastNameRequired');
|
|
6241
6691
|
}
|
|
6242
|
-
if (!
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
6247
|
-
|
|
6248
|
-
|
|
6249
|
-
|
|
6692
|
+
if (!emailOnly) {
|
|
6693
|
+
if (!formData.line1.trim()) {
|
|
6694
|
+
newErrors.line1 = t('addressRequired');
|
|
6695
|
+
}
|
|
6696
|
+
if (!formData.city.trim()) {
|
|
6697
|
+
newErrors.city = t('cityRequired');
|
|
6698
|
+
}
|
|
6699
|
+
if (!formData.postalCode.trim()) {
|
|
6700
|
+
newErrors.postalCode = t('postalCodeRequired');
|
|
6701
|
+
}
|
|
6702
|
+
if (!formData.country.trim()) {
|
|
6703
|
+
newErrors.country = t('countryRequired');
|
|
6704
|
+
}
|
|
6250
6705
|
}
|
|
6251
|
-
if (!
|
|
6252
|
-
newErrors.
|
|
6706
|
+
if (!privacyAccepted) {
|
|
6707
|
+
newErrors.privacy = t('privacyRequired');
|
|
6253
6708
|
}
|
|
6254
6709
|
|
|
6255
6710
|
setErrors(newErrors);
|
|
@@ -6259,7 +6714,7 @@ export function CheckoutForm({
|
|
|
6259
6714
|
function handleSubmit(e: React.FormEvent) {
|
|
6260
6715
|
e.preventDefault();
|
|
6261
6716
|
if (validate()) {
|
|
6262
|
-
onSubmit(formData);
|
|
6717
|
+
onSubmit(formData, { acceptsMarketing, saveDetails: showSaveDetails && saveDetails });
|
|
6263
6718
|
}
|
|
6264
6719
|
}
|
|
6265
6720
|
|
|
@@ -6335,154 +6790,228 @@ export function CheckoutForm({
|
|
|
6335
6790
|
</div>
|
|
6336
6791
|
</div>
|
|
6337
6792
|
|
|
6338
|
-
{
|
|
6339
|
-
|
|
6340
|
-
|
|
6341
|
-
<
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
|
|
6348
|
-
|
|
6349
|
-
|
|
6350
|
-
|
|
6351
|
-
|
|
6352
|
-
|
|
6353
|
-
|
|
6354
|
-
|
|
6355
|
-
|
|
6356
|
-
|
|
6357
|
-
|
|
6358
|
-
|
|
6793
|
+
{!emailOnly && (
|
|
6794
|
+
<>
|
|
6795
|
+
{/* Country + Region row */}
|
|
6796
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
6797
|
+
<div>
|
|
6798
|
+
<label htmlFor="country" className="text-foreground mb-1 block text-sm font-medium">
|
|
6799
|
+
{t('country')} <span className="text-destructive">*</span>
|
|
6800
|
+
</label>
|
|
6801
|
+
{hasCountryOptions ? (
|
|
6802
|
+
<select
|
|
6803
|
+
id="country"
|
|
6804
|
+
value={formData.country}
|
|
6805
|
+
onChange={(e) => updateField('country', e.target.value)}
|
|
6806
|
+
className={cn(
|
|
6807
|
+
selectClass,
|
|
6808
|
+
errors.country ? 'border-destructive' : 'border-border'
|
|
6809
|
+
)}
|
|
6810
|
+
>
|
|
6811
|
+
<option value="">{t('selectCountry')}</option>
|
|
6812
|
+
{destinations.countries.map((c) => (
|
|
6813
|
+
<option key={c.code} value={c.code}>
|
|
6814
|
+
{c.name}
|
|
6815
|
+
</option>
|
|
6816
|
+
))}
|
|
6817
|
+
</select>
|
|
6818
|
+
) : (
|
|
6819
|
+
<input
|
|
6820
|
+
id="country"
|
|
6821
|
+
type="text"
|
|
6822
|
+
value={formData.country}
|
|
6823
|
+
onChange={(e) => updateField('country', e.target.value)}
|
|
6824
|
+
className={cn(
|
|
6825
|
+
inputClass,
|
|
6826
|
+
errors.country ? 'border-destructive' : 'border-border'
|
|
6827
|
+
)}
|
|
6828
|
+
placeholder={t('countryPlaceholder')}
|
|
6829
|
+
/>
|
|
6830
|
+
)}
|
|
6831
|
+
{errors.country && <p className="text-destructive mt-1 text-xs">{errors.country}</p>}
|
|
6832
|
+
</div>
|
|
6833
|
+
|
|
6834
|
+
<div>
|
|
6835
|
+
<label htmlFor="region" className="text-foreground mb-1 block text-sm font-medium">
|
|
6836
|
+
{t('stateRegion')}
|
|
6837
|
+
</label>
|
|
6838
|
+
{hasRegionOptions ? (
|
|
6839
|
+
<select
|
|
6840
|
+
id="region"
|
|
6841
|
+
value={formData.region || ''}
|
|
6842
|
+
onChange={(e) => updateField('region', e.target.value)}
|
|
6843
|
+
className={cn(selectClass, 'border-border')}
|
|
6844
|
+
>
|
|
6845
|
+
<option value="">{t('selectRegion')}</option>
|
|
6846
|
+
{countryRegions.map((r) => (
|
|
6847
|
+
<option key={r.code} value={r.code}>
|
|
6848
|
+
{r.name}
|
|
6849
|
+
</option>
|
|
6850
|
+
))}
|
|
6851
|
+
</select>
|
|
6852
|
+
) : (
|
|
6853
|
+
<input
|
|
6854
|
+
id="region"
|
|
6855
|
+
type="text"
|
|
6856
|
+
value={formData.region || ''}
|
|
6857
|
+
onChange={(e) => updateField('region', e.target.value)}
|
|
6858
|
+
className={cn(inputClass, 'border-border')}
|
|
6859
|
+
/>
|
|
6860
|
+
)}
|
|
6861
|
+
</div>
|
|
6862
|
+
</div>
|
|
6863
|
+
|
|
6864
|
+
{/* Address line 1 */}
|
|
6865
|
+
<div>
|
|
6866
|
+
<label htmlFor="line1" className="text-foreground mb-1 block text-sm font-medium">
|
|
6867
|
+
{t('address')} <span className="text-destructive">*</span>
|
|
6868
|
+
</label>
|
|
6359
6869
|
<input
|
|
6360
|
-
id="
|
|
6870
|
+
id="line1"
|
|
6361
6871
|
type="text"
|
|
6362
|
-
value={formData.
|
|
6363
|
-
onChange={(e) => updateField('
|
|
6364
|
-
className={cn(inputClass, errors.
|
|
6365
|
-
placeholder={t('
|
|
6872
|
+
value={formData.line1}
|
|
6873
|
+
onChange={(e) => updateField('line1', e.target.value)}
|
|
6874
|
+
className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
|
|
6875
|
+
placeholder={t('streetAddress')}
|
|
6366
6876
|
/>
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
</div>
|
|
6877
|
+
{errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
|
|
6878
|
+
</div>
|
|
6370
6879
|
|
|
6371
|
-
|
|
6372
|
-
<
|
|
6373
|
-
|
|
6374
|
-
|
|
6375
|
-
|
|
6376
|
-
<select
|
|
6377
|
-
id="region"
|
|
6378
|
-
value={formData.region || ''}
|
|
6379
|
-
onChange={(e) => updateField('region', e.target.value)}
|
|
6380
|
-
className={cn(selectClass, 'border-border')}
|
|
6381
|
-
>
|
|
6382
|
-
<option value="">{t('selectRegion')}</option>
|
|
6383
|
-
{countryRegions.map((r) => (
|
|
6384
|
-
<option key={r.code} value={r.code}>
|
|
6385
|
-
{r.name}
|
|
6386
|
-
</option>
|
|
6387
|
-
))}
|
|
6388
|
-
</select>
|
|
6389
|
-
) : (
|
|
6880
|
+
{/* Address line 2 */}
|
|
6881
|
+
<div>
|
|
6882
|
+
<label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
|
|
6883
|
+
{t('apartmentSuite')}
|
|
6884
|
+
</label>
|
|
6390
6885
|
<input
|
|
6391
|
-
id="
|
|
6886
|
+
id="line2"
|
|
6392
6887
|
type="text"
|
|
6393
|
-
value={formData.
|
|
6394
|
-
onChange={(e) => updateField('
|
|
6888
|
+
value={formData.line2 || ''}
|
|
6889
|
+
onChange={(e) => updateField('line2', e.target.value)}
|
|
6395
6890
|
className={cn(inputClass, 'border-border')}
|
|
6891
|
+
placeholder={t('aptPlaceholder')}
|
|
6396
6892
|
/>
|
|
6397
|
-
|
|
6398
|
-
|
|
6399
|
-
|
|
6893
|
+
</div>
|
|
6894
|
+
|
|
6895
|
+
{/* City + Postal code row */}
|
|
6896
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
6897
|
+
<div>
|
|
6898
|
+
<label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
|
|
6899
|
+
{t('city')} <span className="text-destructive">*</span>
|
|
6900
|
+
</label>
|
|
6901
|
+
<input
|
|
6902
|
+
id="city"
|
|
6903
|
+
type="text"
|
|
6904
|
+
value={formData.city}
|
|
6905
|
+
onChange={(e) => updateField('city', e.target.value)}
|
|
6906
|
+
className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')}
|
|
6907
|
+
/>
|
|
6908
|
+
{errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
|
|
6909
|
+
</div>
|
|
6910
|
+
|
|
6911
|
+
<div>
|
|
6912
|
+
<label
|
|
6913
|
+
htmlFor="postalCode"
|
|
6914
|
+
className="text-foreground mb-1 block text-sm font-medium"
|
|
6915
|
+
>
|
|
6916
|
+
{t('postalCode')} <span className="text-destructive">*</span>
|
|
6917
|
+
</label>
|
|
6918
|
+
<input
|
|
6919
|
+
id="postalCode"
|
|
6920
|
+
type="text"
|
|
6921
|
+
value={formData.postalCode}
|
|
6922
|
+
onChange={(e) => updateField('postalCode', e.target.value)}
|
|
6923
|
+
className={cn(
|
|
6924
|
+
inputClass,
|
|
6925
|
+
errors.postalCode ? 'border-destructive' : 'border-border'
|
|
6926
|
+
)}
|
|
6927
|
+
/>
|
|
6928
|
+
{errors.postalCode && (
|
|
6929
|
+
<p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
|
|
6930
|
+
)}
|
|
6931
|
+
</div>
|
|
6932
|
+
</div>
|
|
6933
|
+
|
|
6934
|
+
{/* Phone */}
|
|
6935
|
+
<div>
|
|
6936
|
+
<label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
|
|
6937
|
+
{t('phone')}
|
|
6938
|
+
</label>
|
|
6939
|
+
<input
|
|
6940
|
+
id="phone"
|
|
6941
|
+
type="tel"
|
|
6942
|
+
value={formData.phone || ''}
|
|
6943
|
+
onChange={(e) => updateField('phone', e.target.value)}
|
|
6944
|
+
className={cn(inputClass, 'border-border')}
|
|
6945
|
+
placeholder={t('phonePlaceholder')}
|
|
6946
|
+
/>
|
|
6947
|
+
</div>
|
|
6948
|
+
</>
|
|
6949
|
+
)}
|
|
6400
6950
|
|
|
6401
|
-
{/*
|
|
6951
|
+
{/* Privacy Policy (required) */}
|
|
6402
6952
|
<div>
|
|
6403
|
-
<label
|
|
6404
|
-
|
|
6953
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
6954
|
+
<input
|
|
6955
|
+
type="checkbox"
|
|
6956
|
+
checked={privacyAccepted}
|
|
6957
|
+
onChange={(e) => {
|
|
6958
|
+
setPrivacyAccepted(e.target.checked);
|
|
6959
|
+
if (e.target.checked && errors.privacy) {
|
|
6960
|
+
setErrors((prev) => {
|
|
6961
|
+
const next = { ...prev };
|
|
6962
|
+
delete next.privacy;
|
|
6963
|
+
return next;
|
|
6964
|
+
});
|
|
6965
|
+
}
|
|
6966
|
+
}}
|
|
6967
|
+
className="accent-primary mt-0.5"
|
|
6968
|
+
/>
|
|
6969
|
+
<span className="text-muted-foreground text-sm">
|
|
6970
|
+
{t('privacyAcceptPrefix')}{' '}
|
|
6971
|
+
<a
|
|
6972
|
+
href="/privacy"
|
|
6973
|
+
target="_blank"
|
|
6974
|
+
rel="noopener noreferrer"
|
|
6975
|
+
className="text-primary underline underline-offset-2"
|
|
6976
|
+
>
|
|
6977
|
+
{t('privacyPolicyLink')}
|
|
6978
|
+
</a>{' '}
|
|
6979
|
+
<span className="text-destructive">*</span>
|
|
6980
|
+
</span>
|
|
6405
6981
|
</label>
|
|
6406
|
-
<
|
|
6407
|
-
id="line1"
|
|
6408
|
-
type="text"
|
|
6409
|
-
value={formData.line1}
|
|
6410
|
-
onChange={(e) => updateField('line1', e.target.value)}
|
|
6411
|
-
className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
|
|
6412
|
-
placeholder={t('streetAddress')}
|
|
6413
|
-
/>
|
|
6414
|
-
{errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
|
|
6982
|
+
{errors.privacy && <p className="text-destructive mt-1 text-xs">{errors.privacy}</p>}
|
|
6415
6983
|
</div>
|
|
6416
6984
|
|
|
6417
|
-
{/*
|
|
6418
|
-
<
|
|
6419
|
-
<label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
|
|
6420
|
-
{t('apartmentSuite')}
|
|
6421
|
-
</label>
|
|
6985
|
+
{/* Marketing consent (optional) */}
|
|
6986
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
6422
6987
|
<input
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
className={cn(inputClass, 'border-border')}
|
|
6428
|
-
placeholder={t('aptPlaceholder')}
|
|
6988
|
+
type="checkbox"
|
|
6989
|
+
checked={acceptsMarketing}
|
|
6990
|
+
onChange={(e) => setAcceptsMarketing(e.target.checked)}
|
|
6991
|
+
className="accent-primary mt-0.5"
|
|
6429
6992
|
/>
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
{/* City + Postal code row */}
|
|
6433
|
-
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
6434
|
-
<div>
|
|
6435
|
-
<label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
|
|
6436
|
-
{t('city')} <span className="text-destructive">*</span>
|
|
6437
|
-
</label>
|
|
6438
|
-
<input
|
|
6439
|
-
id="city"
|
|
6440
|
-
type="text"
|
|
6441
|
-
value={formData.city}
|
|
6442
|
-
onChange={(e) => updateField('city', e.target.value)}
|
|
6443
|
-
className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')}
|
|
6444
|
-
/>
|
|
6445
|
-
{errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
|
|
6446
|
-
</div>
|
|
6993
|
+
<span className="text-muted-foreground text-sm">{t('acceptsMarketing')}</span>
|
|
6994
|
+
</label>
|
|
6447
6995
|
|
|
6448
|
-
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
</label>
|
|
6996
|
+
{/* Save details for next time (logged-in users only) */}
|
|
6997
|
+
{showSaveDetails && (
|
|
6998
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
6452
6999
|
<input
|
|
6453
|
-
|
|
6454
|
-
|
|
6455
|
-
|
|
6456
|
-
|
|
6457
|
-
className={cn(inputClass, errors.postalCode ? 'border-destructive' : 'border-border')}
|
|
7000
|
+
type="checkbox"
|
|
7001
|
+
checked={saveDetails}
|
|
7002
|
+
onChange={(e) => setSaveDetails(e.target.checked)}
|
|
7003
|
+
className="accent-primary mt-0.5"
|
|
6458
7004
|
/>
|
|
6459
|
-
|
|
6460
|
-
<p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
|
|
6461
|
-
)}
|
|
6462
|
-
</div>
|
|
6463
|
-
</div>
|
|
6464
|
-
|
|
6465
|
-
{/* Phone */}
|
|
6466
|
-
<div>
|
|
6467
|
-
<label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
|
|
6468
|
-
{t('phone')}
|
|
7005
|
+
<span className="text-muted-foreground text-sm">{t('saveDetailsForNextTime')}</span>
|
|
6469
7006
|
</label>
|
|
6470
|
-
|
|
6471
|
-
id="phone"
|
|
6472
|
-
type="tel"
|
|
6473
|
-
value={formData.phone || ''}
|
|
6474
|
-
onChange={(e) => updateField('phone', e.target.value)}
|
|
6475
|
-
className={cn(inputClass, 'border-border')}
|
|
6476
|
-
placeholder={t('phonePlaceholder')}
|
|
6477
|
-
/>
|
|
6478
|
-
</div>
|
|
7007
|
+
)}
|
|
6479
7008
|
|
|
6480
7009
|
<button
|
|
6481
7010
|
type="submit"
|
|
6482
7011
|
disabled={loading}
|
|
6483
7012
|
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"
|
|
6484
7013
|
>
|
|
6485
|
-
{loading ? tc('saving') : t('continueToShipping')}
|
|
7014
|
+
{loading ? tc('saving') : emailOnly ? t('continueToPayment') : t('continueToShipping')}
|
|
6486
7015
|
</button>
|
|
6487
7016
|
</form>
|
|
6488
7017
|
);
|
|
@@ -6602,112 +7131,95 @@ export function ShippingStep({
|
|
|
6602
7131
|
"src/components/checkout/payment-step.tsx": `'use client';
|
|
6603
7132
|
|
|
6604
7133
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
6605
|
-
import type { PaymentIntent } from 'brainerce';
|
|
7134
|
+
import type { PaymentIntent, PaymentClientSdk } from 'brainerce';
|
|
6606
7135
|
import { getClient } from '@/lib/brainerce';
|
|
6607
7136
|
import { useTranslations } from '@/lib/translations';
|
|
6608
7137
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
6609
7138
|
import { cn } from '@/lib/utils';
|
|
6610
7139
|
|
|
6611
|
-
|
|
6612
|
-
|
|
6613
|
-
|
|
6614
|
-
|
|
6615
|
-
|
|
6616
|
-
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
renderPaymentOptions: (authCode: string) => void;
|
|
6629
|
-
};
|
|
6630
|
-
}
|
|
6631
|
-
}
|
|
6632
|
-
|
|
6633
|
-
// Payment SDK script URLs \u2014 resolved per provider
|
|
6634
|
-
// SRI hashes must be regenerated when the provider updates their SDK
|
|
6635
|
-
const PAYMENT_SDK_URL = 'https://cdn.meshulam.co.il/sdk/gs.min.js';
|
|
6636
|
-
const PAYMENT_SDK_SRI = 'sha384-e1OYzZERZLgbR8Zw1I4Ww/J7qXGyEsZb7LF0z5W/sbnTsFwtxop7sKZsLGNp6CB1';
|
|
6637
|
-
const APPLE_PAY_SDK_URL = 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js';
|
|
6638
|
-
const APPLE_PAY_SDK_SRI = 'sha384-CG67HXmWBeyuRR/jqFsYr6dGEMFyuZw3/hRmcyACJRYHGb/1ebEkQZyzdJXDc9/u';
|
|
7140
|
+
/**
|
|
7141
|
+
* Backward-compat defaults when backend doesn't return clientSdk.
|
|
7142
|
+
*/
|
|
7143
|
+
const LEGACY_GROW_SDK: PaymentClientSdk = {
|
|
7144
|
+
renderType: 'sdk-widget',
|
|
7145
|
+
scriptUrl: 'https://cdn.meshulam.co.il/sdk/gs.min.js',
|
|
7146
|
+
globalName: 'growPayment',
|
|
7147
|
+
initMethod: 'init',
|
|
7148
|
+
renderMethod: 'renderPaymentOptions',
|
|
7149
|
+
containerId: 'grow-payment-container',
|
|
7150
|
+
initConfig: { version: 1, environment: 'DEV' },
|
|
7151
|
+
additionalScripts: [
|
|
7152
|
+
{ url: 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js', optional: true },
|
|
7153
|
+
],
|
|
7154
|
+
bodyStyles:
|
|
7155
|
+
'[id*="Gr0W8-"],[id*="Gr0W8-"] *,[class*="Gr0W8-"],[class*="Gr0W8-"] *{direction:ltr !important;text-align:left}',
|
|
7156
|
+
};
|
|
6639
7157
|
|
|
6640
7158
|
interface PaymentStepProps {
|
|
6641
7159
|
checkoutId: string;
|
|
6642
7160
|
className?: string;
|
|
6643
7161
|
}
|
|
6644
7162
|
|
|
6645
|
-
|
|
6646
|
-
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
|
|
6650
|
-
|
|
6651
|
-
|
|
6652
|
-
|
|
6653
|
-
|
|
6654
|
-
|
|
6655
|
-
|
|
6656
|
-
|
|
6657
|
-
|
|
6658
|
-
|
|
6659
|
-
script.integrity = integrity;
|
|
6660
|
-
script.crossOrigin = 'anonymous';
|
|
6661
|
-
}
|
|
6662
|
-
script.onload = () => resolve();
|
|
6663
|
-
script.onerror = () => {
|
|
6664
|
-
if (optional) {
|
|
6665
|
-
resolve(); // Non-blocking for optional SDKs
|
|
6666
|
-
} else {
|
|
6667
|
-
resolve(); // Still resolve \u2014 caller handles missing global
|
|
6668
|
-
}
|
|
7163
|
+
function resolveClientSdk(
|
|
7164
|
+
intent: PaymentIntent | null,
|
|
7165
|
+
preloadedSdk?: PaymentClientSdk | null
|
|
7166
|
+
): PaymentClientSdk {
|
|
7167
|
+
const fullSdk = [preloadedSdk, intent?.clientSdk].find((s) => s?.renderType);
|
|
7168
|
+
const runtimeSdk = intent?.clientSdk;
|
|
7169
|
+
if (fullSdk) {
|
|
7170
|
+
if (!runtimeSdk || runtimeSdk === fullSdk) return fullSdk;
|
|
7171
|
+
return {
|
|
7172
|
+
...fullSdk,
|
|
7173
|
+
...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
|
|
7174
|
+
...(runtimeSdk.initConfig
|
|
7175
|
+
? { initConfig: { ...fullSdk.initConfig, ...runtimeSdk.initConfig } }
|
|
7176
|
+
: {}),
|
|
6669
7177
|
};
|
|
6670
|
-
|
|
6671
|
-
|
|
7178
|
+
}
|
|
7179
|
+
const legacy = intent?.provider === 'grow' ? LEGACY_GROW_SDK : null;
|
|
7180
|
+
if (legacy && runtimeSdk) {
|
|
7181
|
+
return {
|
|
7182
|
+
...legacy,
|
|
7183
|
+
...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
|
|
7184
|
+
...(runtimeSdk.initConfig
|
|
7185
|
+
? { initConfig: { ...legacy.initConfig, ...runtimeSdk.initConfig } }
|
|
7186
|
+
: {}),
|
|
7187
|
+
};
|
|
7188
|
+
}
|
|
7189
|
+
if (legacy) return legacy;
|
|
7190
|
+
return { renderType: 'redirect' };
|
|
6672
7191
|
}
|
|
6673
7192
|
|
|
6674
|
-
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
function waitForPaymentSdkGlobal(timeoutMs = 5000): Promise<boolean> {
|
|
6678
|
-
return new Promise((resolve) => {
|
|
6679
|
-
if (window.growPayment) {
|
|
6680
|
-
resolve(true);
|
|
6681
|
-
return;
|
|
6682
|
-
}
|
|
6683
|
-
const start = Date.now();
|
|
6684
|
-
const check = setInterval(() => {
|
|
6685
|
-
if (window.growPayment) {
|
|
6686
|
-
clearInterval(check);
|
|
6687
|
-
resolve(true);
|
|
6688
|
-
} else if (Date.now() - start > timeoutMs) {
|
|
6689
|
-
clearInterval(check);
|
|
6690
|
-
resolve(false);
|
|
6691
|
-
}
|
|
6692
|
-
}, 50);
|
|
6693
|
-
});
|
|
7193
|
+
function extractMessage(response: unknown): string {
|
|
7194
|
+
if (typeof response === 'string') return response;
|
|
7195
|
+
return (response as { message?: string })?.message || '';
|
|
6694
7196
|
}
|
|
6695
7197
|
|
|
6696
7198
|
export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
6697
7199
|
const t = useTranslations('checkout');
|
|
6698
7200
|
const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
|
|
7201
|
+
const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
|
|
6699
7202
|
const [loading, setLoading] = useState(true);
|
|
6700
7203
|
const [error, setError] = useState<string | null>(null);
|
|
6701
7204
|
const [sdkReady, setSdkReady] = useState(false);
|
|
6702
|
-
const
|
|
6703
|
-
const
|
|
7205
|
+
const walletOpenRef = useRef(false);
|
|
7206
|
+
const initialized = useRef(false);
|
|
7207
|
+
|
|
7208
|
+
// Stable refs for SDK event callbacks (avoids stale closures in onload)
|
|
7209
|
+
const cbRef = useRef({
|
|
7210
|
+
onSuccess: (_r: unknown) => {},
|
|
7211
|
+
onFailure: (_r: unknown) => {},
|
|
7212
|
+
onError: (_r: unknown) => {},
|
|
7213
|
+
onTimeout: () => {},
|
|
7214
|
+
onWalletChange: (_s: string) => {},
|
|
7215
|
+
retryRender: () => {},
|
|
7216
|
+
});
|
|
6704
7217
|
|
|
6705
|
-
const
|
|
7218
|
+
const handleSuccess = useCallback(
|
|
6706
7219
|
async (response: unknown) => {
|
|
6707
7220
|
console.info('Payment SDK success:', JSON.stringify(response));
|
|
6708
7221
|
try {
|
|
6709
7222
|
const client = getClient();
|
|
6710
|
-
// Try response.data first, fall back to response itself
|
|
6711
7223
|
const resp = response as Record<string, unknown>;
|
|
6712
7224
|
const data = (resp?.data && typeof resp.data === 'object' ? resp.data : resp) as
|
|
6713
7225
|
| Record<string, unknown>
|
|
@@ -6721,188 +7233,286 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
6721
7233
|
[checkoutId]
|
|
6722
7234
|
);
|
|
6723
7235
|
|
|
6724
|
-
|
|
6725
|
-
|
|
6726
|
-
|
|
6727
|
-
|
|
6728
|
-
|
|
6729
|
-
|
|
6730
|
-
|
|
6731
|
-
|
|
6732
|
-
|
|
6733
|
-
|
|
6734
|
-
|
|
6735
|
-
|
|
6736
|
-
|
|
6737
|
-
|
|
6738
|
-
|
|
6739
|
-
|
|
6740
|
-
|
|
6741
|
-
console.
|
|
6742
|
-
|
|
6743
|
-
}
|
|
6744
|
-
|
|
6745
|
-
|
|
6746
|
-
|
|
7236
|
+
cbRef.current = {
|
|
7237
|
+
onSuccess: handleSuccess,
|
|
7238
|
+
onFailure: (response: unknown) => {
|
|
7239
|
+
console.error('Payment SDK failure:', response);
|
|
7240
|
+
setError(extractMessage(response) || t('paymentError'));
|
|
7241
|
+
},
|
|
7242
|
+
onError: (response: unknown) => {
|
|
7243
|
+
const TRANSIENT = [
|
|
7244
|
+
'Wallet not initialized',
|
|
7245
|
+
"SDK was not loaded as needed and therefore can't run",
|
|
7246
|
+
];
|
|
7247
|
+
const msg = extractMessage(response);
|
|
7248
|
+
if (TRANSIENT.some((e) => msg.includes(e))) {
|
|
7249
|
+
console.info('Payment SDK: transient error, retrying render in 1s:', msg);
|
|
7250
|
+
setTimeout(() => cbRef.current.retryRender(), 1000);
|
|
7251
|
+
return;
|
|
7252
|
+
}
|
|
7253
|
+
console.error('Payment SDK error:', response);
|
|
7254
|
+
setError(msg || t('paymentError'));
|
|
7255
|
+
},
|
|
7256
|
+
onTimeout: () => {
|
|
7257
|
+
console.warn('Payment SDK: wallet timed out');
|
|
7258
|
+
setError(t('paymentTimedOut'));
|
|
7259
|
+
},
|
|
7260
|
+
onWalletChange: (state: string) => {
|
|
7261
|
+
console.info('Payment SDK wallet state:', state);
|
|
7262
|
+
if (state === 'open') {
|
|
7263
|
+
walletOpenRef.current = true;
|
|
7264
|
+
setSdkReady(true);
|
|
7265
|
+
}
|
|
7266
|
+
if (state === 'close') setSdkReady(false);
|
|
7267
|
+
},
|
|
7268
|
+
retryRender: () => {},
|
|
7269
|
+
};
|
|
6747
7270
|
|
|
6748
|
-
//
|
|
7271
|
+
// =========================================================================
|
|
7272
|
+
// MAIN EFFECT \u2014 Follows Grow SDK docs exactly:
|
|
7273
|
+
//
|
|
7274
|
+
// Step 1: Load gs.min.js (insertBefore, as docs show)
|
|
7275
|
+
// Step 2: s.onload \u2192 growPayment.init({ environment, version, events })
|
|
7276
|
+
// This triggers the SDK to load mp.min.js \u2192 CSS, HTML, params, services
|
|
7277
|
+
// Step 3: createPaymentIntent (starts wallet timer \u2014 should be AFTER init)
|
|
7278
|
+
// Step 4: growPayment.renderPaymentOptions(authCode)
|
|
7279
|
+
//
|
|
7280
|
+
// "call createPaymentProcess right before you need to render the wallet"
|
|
7281
|
+
// =========================================================================
|
|
6749
7282
|
useEffect(() => {
|
|
6750
|
-
|
|
6751
|
-
|
|
6752
|
-
async function loadSdkScripts() {
|
|
6753
|
-
// Load Apple Pay SDK (optional)
|
|
6754
|
-
await loadScript(APPLE_PAY_SDK_URL, true, APPLE_PAY_SDK_SRI);
|
|
7283
|
+
if (initialized.current) return;
|
|
7284
|
+
initialized.current = true;
|
|
6755
7285
|
|
|
6756
|
-
|
|
6757
|
-
|
|
7286
|
+
const client = getClient();
|
|
7287
|
+
const successUrl = \`\${window.location.origin}/order-confirmation?checkout_id=\${checkoutId}\`;
|
|
7288
|
+
const cancelUrl = \`\${window.location.origin}/checkout?checkout_id=\${checkoutId}&canceled=true\`;
|
|
7289
|
+
|
|
7290
|
+
let sdkInitDone = false;
|
|
7291
|
+
let currentSdk: PaymentClientSdk | null = null;
|
|
7292
|
+
const cleanups: (() => void)[] = [];
|
|
7293
|
+
|
|
7294
|
+
// --- Load SDK script exactly as Grow docs show ---
|
|
7295
|
+
function loadScript(sdk: PaymentClientSdk) {
|
|
7296
|
+
if (!sdk.scriptUrl || !sdk.globalName) return;
|
|
7297
|
+
|
|
7298
|
+
// Inject bodyStyles
|
|
7299
|
+
if (sdk.bodyStyles && !document.querySelector('style[data-payment-sdk]')) {
|
|
7300
|
+
const style = document.createElement('style');
|
|
7301
|
+
style.setAttribute('data-payment-sdk', 'true');
|
|
7302
|
+
style.textContent = sdk.bodyStyles;
|
|
7303
|
+
document.head.appendChild(style);
|
|
7304
|
+
cleanups.push(() => style.remove());
|
|
7305
|
+
}
|
|
6758
7306
|
|
|
6759
|
-
//
|
|
6760
|
-
|
|
7307
|
+
// Additional scripts (Apple Pay etc.) \u2014 fire and forget
|
|
7308
|
+
if (sdk.additionalScripts) {
|
|
7309
|
+
for (const extra of sdk.additionalScripts) {
|
|
7310
|
+
if (document.querySelector(\`script[src="\${extra.url}"]\`)) continue;
|
|
7311
|
+
const s = document.createElement('script');
|
|
7312
|
+
s.type = 'text/javascript';
|
|
7313
|
+
s.async = true;
|
|
7314
|
+
s.src = extra.url;
|
|
7315
|
+
const ref = document.getElementsByTagName('script')[0];
|
|
7316
|
+
if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
|
|
7317
|
+
else document.head.appendChild(s);
|
|
7318
|
+
}
|
|
7319
|
+
}
|
|
6761
7320
|
|
|
6762
|
-
|
|
7321
|
+
// Already loaded? Init immediately
|
|
7322
|
+
if ((window as any)[sdk.globalName]) {
|
|
7323
|
+
initSdk(sdk);
|
|
7324
|
+
return;
|
|
7325
|
+
}
|
|
6763
7326
|
|
|
6764
|
-
|
|
6765
|
-
|
|
7327
|
+
// Already loading (from a previous call)? Wait for it instead of duplicating
|
|
7328
|
+
if (document.querySelector(\`script[src="\${sdk.scriptUrl}"]\`)) {
|
|
7329
|
+
const waitId = setInterval(() => {
|
|
7330
|
+
if ((window as any)[sdk.globalName!]) {
|
|
7331
|
+
clearInterval(waitId);
|
|
7332
|
+
initSdk(sdk);
|
|
7333
|
+
}
|
|
7334
|
+
}, 100);
|
|
7335
|
+
cleanups.push(() => clearInterval(waitId));
|
|
6766
7336
|
return;
|
|
6767
7337
|
}
|
|
6768
7338
|
|
|
6769
|
-
|
|
7339
|
+
// Load main SDK \u2014 insertBefore first <script> as Grow docs show
|
|
7340
|
+
const s = document.createElement('script');
|
|
7341
|
+
s.type = 'text/javascript';
|
|
7342
|
+
s.async = true;
|
|
7343
|
+
s.src = sdk.scriptUrl;
|
|
7344
|
+
s.onload = () => initSdk(sdk); // init DIRECTLY in onload
|
|
7345
|
+
s.onerror = () => {
|
|
7346
|
+
console.error('Payment SDK: script load failed');
|
|
7347
|
+
setError(t('failedToLoadPaymentSdk'));
|
|
7348
|
+
};
|
|
7349
|
+
const ref = document.getElementsByTagName('script')[0];
|
|
7350
|
+
if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
|
|
7351
|
+
else document.head.appendChild(s);
|
|
6770
7352
|
}
|
|
6771
7353
|
|
|
6772
|
-
|
|
7354
|
+
// --- Init: called in s.onload (as Grow docs require) ---
|
|
7355
|
+
function initSdk(sdk: PaymentClientSdk) {
|
|
7356
|
+
if (sdkInitDone) return; // Guard against double init
|
|
6773
7357
|
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
6777
|
-
|
|
7358
|
+
const global = (window as any)[sdk.globalName!];
|
|
7359
|
+
if (!global) {
|
|
7360
|
+
setError(t('failedToLoadPaymentSdk'));
|
|
7361
|
+
return;
|
|
7362
|
+
}
|
|
6778
7363
|
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
|
|
6784
|
-
|
|
7364
|
+
const method = sdk.initMethod || 'init';
|
|
7365
|
+
const config = {
|
|
7366
|
+
...(sdk.initConfig || {}),
|
|
7367
|
+
events: {
|
|
7368
|
+
onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
|
|
7369
|
+
onFailure: (r: unknown) => cbRef.current.onFailure(r),
|
|
7370
|
+
onError: (r: unknown) => cbRef.current.onError(r),
|
|
7371
|
+
onTimeout: () => cbRef.current.onTimeout(),
|
|
7372
|
+
onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
|
|
7373
|
+
},
|
|
7374
|
+
};
|
|
6785
7375
|
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
const client = getClient();
|
|
7376
|
+
console.info(\`Payment SDK: calling \${method}()\`);
|
|
7377
|
+
global[method](config);
|
|
7378
|
+
sdkInitDone = true;
|
|
7379
|
+
}
|
|
6791
7380
|
|
|
6792
|
-
|
|
6793
|
-
|
|
7381
|
+
// --- Render: call once, then safety-net retries if wallet doesn't open ---
|
|
7382
|
+
// Grow SDK sometimes silently swallows renderPaymentOptions when its
|
|
7383
|
+
// internal resources (mp.min.js etc.) aren't fully loaded yet.
|
|
7384
|
+
// Strategy: render once, then retry up to 3 times with increasing delays
|
|
7385
|
+
// (2s, 3s, 4s) if onWalletChange("open") hasn't fired.
|
|
7386
|
+
let pendingRender: { sdk: PaymentClientSdk; intent: PaymentIntent } | null = null;
|
|
7387
|
+
let renderAttempts = 0;
|
|
7388
|
+
const MAX_RENDER_ATTEMPTS = 4;
|
|
6794
7389
|
|
|
6795
|
-
|
|
6796
|
-
|
|
6797
|
-
|
|
6798
|
-
});
|
|
7390
|
+
function renderPayment(sdk: PaymentClientSdk, intent: PaymentIntent) {
|
|
7391
|
+
const global = (window as any)[sdk.globalName!];
|
|
7392
|
+
if (!global || walletOpenRef.current) return;
|
|
6799
7393
|
|
|
6800
|
-
|
|
7394
|
+
const renderMethod = sdk.renderMethod || 'renderPaymentOptions';
|
|
7395
|
+
const renderArg = sdk.renderArg || intent.clientSecret;
|
|
7396
|
+
renderAttempts++;
|
|
6801
7397
|
|
|
6802
|
-
|
|
6803
|
-
|
|
6804
|
-
|
|
6805
|
-
}
|
|
7398
|
+
try {
|
|
7399
|
+
global[renderMethod](renderArg);
|
|
7400
|
+
console.info(\`Payment SDK: renderPaymentOptions called (attempt \${renderAttempts})\`);
|
|
6806
7401
|
} catch (err) {
|
|
6807
|
-
|
|
6808
|
-
setError(message);
|
|
6809
|
-
} finally {
|
|
6810
|
-
setLoading(false);
|
|
7402
|
+
console.info('Payment SDK: render threw, will retry in 1s');
|
|
6811
7403
|
}
|
|
6812
|
-
}
|
|
6813
7404
|
|
|
6814
|
-
|
|
6815
|
-
|
|
7405
|
+
// Safety net: if wallet doesn't open within a delay, retry
|
|
7406
|
+
if (renderAttempts < MAX_RENDER_ATTEMPTS) {
|
|
7407
|
+
const delay = 1000 + renderAttempts * 1000; // 2s, 3s, 4s
|
|
7408
|
+
const retryId = setTimeout(() => {
|
|
7409
|
+
if (!walletOpenRef.current) {
|
|
7410
|
+
console.info(\`Payment SDK: wallet not open after \${delay}ms, retrying render...\`);
|
|
7411
|
+
renderPayment(sdk, intent);
|
|
7412
|
+
}
|
|
7413
|
+
}, delay);
|
|
7414
|
+
cleanups.push(() => clearTimeout(retryId));
|
|
7415
|
+
}
|
|
7416
|
+
}
|
|
6816
7417
|
|
|
6817
|
-
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
useEffect(() => {
|
|
6821
|
-
if (!sdkScriptLoaded) return;
|
|
6822
|
-
if (!paymentIntent || paymentIntent.provider !== 'grow') return;
|
|
6823
|
-
if (!window.growPayment) return;
|
|
6824
|
-
if (renderAttempted.current) return;
|
|
6825
|
-
|
|
6826
|
-
renderAttempted.current = true;
|
|
6827
|
-
|
|
6828
|
-
// Parse environment and authCode from clientSecret (format: "ENV|authCode")
|
|
6829
|
-
const pipeIndex = paymentIntent.clientSecret.indexOf('|');
|
|
6830
|
-
const env = pipeIndex !== -1 ? paymentIntent.clientSecret.substring(0, pipeIndex) : 'DEV';
|
|
6831
|
-
const authCode =
|
|
6832
|
-
pipeIndex !== -1
|
|
6833
|
-
? paymentIntent.clientSecret.substring(pipeIndex + 1)
|
|
6834
|
-
: paymentIntent.clientSecret;
|
|
6835
|
-
|
|
6836
|
-
let rendered = false;
|
|
6837
|
-
let walletReady = false;
|
|
6838
|
-
let pollId: ReturnType<typeof setInterval>;
|
|
6839
|
-
|
|
6840
|
-
function tryRenderPaymentOptions() {
|
|
6841
|
-
if (rendered) return;
|
|
6842
|
-
try {
|
|
6843
|
-
window.growPayment?.renderPaymentOptions(authCode);
|
|
6844
|
-
rendered = true;
|
|
6845
|
-
clearInterval(pollId);
|
|
6846
|
-
setSdkReady(true);
|
|
6847
|
-
console.info('Payment SDK: renderPaymentOptions succeeded');
|
|
6848
|
-
} catch (err) {
|
|
6849
|
-
console.info('Payment SDK: renderPaymentOptions not ready yet', err);
|
|
7418
|
+
function retryRender() {
|
|
7419
|
+
if (pendingRender && !walletOpenRef.current) {
|
|
7420
|
+
renderPayment(pendingRender.sdk, pendingRender.intent);
|
|
6850
7421
|
}
|
|
6851
7422
|
}
|
|
6852
7423
|
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
|
|
6861
|
-
|
|
6862
|
-
|
|
6863
|
-
|
|
6864
|
-
|
|
6865
|
-
|
|
6866
|
-
|
|
7424
|
+
// =============================================
|
|
7425
|
+
// Execution flow
|
|
7426
|
+
// =============================================
|
|
7427
|
+
|
|
7428
|
+
// A) Get SDK config from providers (fast, no wallet timer)
|
|
7429
|
+
const providerPromise = client
|
|
7430
|
+
.getPaymentProviders()
|
|
7431
|
+
.then((res) => {
|
|
7432
|
+
const sdk = res.defaultProvider?.clientSdk;
|
|
7433
|
+
if (sdk) setPreloadedSdk(sdk);
|
|
7434
|
+
return sdk || null;
|
|
7435
|
+
})
|
|
7436
|
+
.catch(() => null);
|
|
7437
|
+
|
|
7438
|
+
// B) Load + init SDK as early as possible
|
|
7439
|
+
providerPromise.then((providerSdk) => {
|
|
7440
|
+
if (providerSdk?.renderType === 'sdk-widget' && providerSdk.scriptUrl) {
|
|
7441
|
+
currentSdk = providerSdk;
|
|
7442
|
+
loadScript(providerSdk);
|
|
7443
|
+
}
|
|
6867
7444
|
});
|
|
6868
7445
|
|
|
6869
|
-
//
|
|
6870
|
-
|
|
6871
|
-
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
7446
|
+
// C) Create payment intent (starts wallet timer)
|
|
7447
|
+
const intentPromise = client
|
|
7448
|
+
.createPaymentIntent(checkoutId, { successUrl, cancelUrl })
|
|
7449
|
+
.then((intent) => {
|
|
7450
|
+
setPaymentIntent(intent);
|
|
7451
|
+
return intent;
|
|
7452
|
+
})
|
|
7453
|
+
.catch((err) => {
|
|
7454
|
+
setError(err instanceof Error ? err.message : t('paymentError'));
|
|
7455
|
+
return null;
|
|
7456
|
+
})
|
|
7457
|
+
.finally(() => setLoading(false));
|
|
7458
|
+
|
|
7459
|
+
// D) When both ready: resolve final SDK config and render
|
|
7460
|
+
Promise.all([providerPromise, intentPromise]).then(([providerSdk, intent]) => {
|
|
7461
|
+
if (!intent) return;
|
|
7462
|
+
|
|
7463
|
+
const sdk = resolveClientSdk(intent, providerSdk);
|
|
7464
|
+
currentSdk = sdk;
|
|
7465
|
+
|
|
7466
|
+
if (sdk.renderType === 'redirect') {
|
|
7467
|
+
window.location.href = intent.clientSecret;
|
|
7468
|
+
return;
|
|
7469
|
+
}
|
|
7470
|
+
if (sdk.renderType !== 'sdk-widget' || !sdk.globalName) return;
|
|
7471
|
+
|
|
7472
|
+
// Store for retryRender from onError callback
|
|
7473
|
+
pendingRender = { sdk, intent };
|
|
7474
|
+
cbRef.current.retryRender = retryRender;
|
|
7475
|
+
|
|
7476
|
+
// If SDK wasn't loaded from providers, load + init now
|
|
7477
|
+
if (!sdkInitDone) {
|
|
7478
|
+
loadScript(sdk);
|
|
7479
|
+
// Wait for init to complete, then render once
|
|
7480
|
+
const id = setInterval(() => {
|
|
7481
|
+
if (sdkInitDone) {
|
|
7482
|
+
clearInterval(id);
|
|
7483
|
+
renderPayment(sdk, intent);
|
|
6890
7484
|
}
|
|
6891
|
-
},
|
|
7485
|
+
}, 100);
|
|
7486
|
+
cleanups.push(() => clearInterval(id));
|
|
7487
|
+
return;
|
|
6892
7488
|
}
|
|
6893
|
-
}, 1000);
|
|
6894
7489
|
|
|
6895
|
-
|
|
6896
|
-
|
|
6897
|
-
|
|
6898
|
-
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
|
|
7490
|
+
// Re-init with final config if environment changed
|
|
7491
|
+
if (sdk.initConfig?.environment && currentSdk) {
|
|
7492
|
+
const global = (window as any)[sdk.globalName];
|
|
7493
|
+
if (global) {
|
|
7494
|
+
const method = sdk.initMethod || 'init';
|
|
7495
|
+
global[method]({
|
|
7496
|
+
...(sdk.initConfig || {}),
|
|
7497
|
+
events: {
|
|
7498
|
+
onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
|
|
7499
|
+
onFailure: (r: unknown) => cbRef.current.onFailure(r),
|
|
7500
|
+
onError: (r: unknown) => cbRef.current.onError(r),
|
|
7501
|
+
onTimeout: () => cbRef.current.onTimeout(),
|
|
7502
|
+
onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
|
|
7503
|
+
},
|
|
7504
|
+
});
|
|
7505
|
+
}
|
|
7506
|
+
}
|
|
7507
|
+
|
|
7508
|
+
// SDK ready \u2014 render once
|
|
7509
|
+
renderPayment(sdk, intent);
|
|
7510
|
+
});
|
|
7511
|
+
|
|
7512
|
+
return () => cleanups.forEach((fn) => fn());
|
|
7513
|
+
}, [checkoutId]);
|
|
7514
|
+
|
|
7515
|
+
// --- UI ---
|
|
6906
7516
|
|
|
6907
7517
|
if (loading) {
|
|
6908
7518
|
return (
|
|
@@ -6918,7 +7528,6 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
6918
7528
|
error.toLowerCase().includes('not configured') ||
|
|
6919
7529
|
error.toLowerCase().includes('no payment') ||
|
|
6920
7530
|
error.toLowerCase().includes('provider');
|
|
6921
|
-
|
|
6922
7531
|
return (
|
|
6923
7532
|
<div className={cn('py-12 text-center', className)}>
|
|
6924
7533
|
<svg
|
|
@@ -6944,8 +7553,13 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
6944
7553
|
);
|
|
6945
7554
|
}
|
|
6946
7555
|
|
|
6947
|
-
|
|
6948
|
-
|
|
7556
|
+
if (!paymentIntent) return null;
|
|
7557
|
+
|
|
7558
|
+
const sdk = resolveClientSdk(paymentIntent, preloadedSdk);
|
|
7559
|
+
|
|
7560
|
+
if (sdk.renderType === 'sdk-widget') {
|
|
7561
|
+
const containerId =
|
|
7562
|
+
sdk.containerId || \`\${paymentIntent.provider || 'payment'}-payment-container\`;
|
|
6949
7563
|
return (
|
|
6950
7564
|
<div className={cn('py-4', className)}>
|
|
6951
7565
|
{!sdkReady && (
|
|
@@ -6954,29 +7568,38 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
6954
7568
|
<p className="text-muted-foreground mt-4 text-sm">{t('loadingPaymentOptions')}</p>
|
|
6955
7569
|
</div>
|
|
6956
7570
|
)}
|
|
6957
|
-
<div id=
|
|
7571
|
+
<div id={containerId} />
|
|
6958
7572
|
</div>
|
|
6959
7573
|
);
|
|
6960
7574
|
}
|
|
6961
7575
|
|
|
6962
|
-
|
|
6963
|
-
if (paymentIntent) {
|
|
7576
|
+
if (sdk.renderType === 'iframe') {
|
|
6964
7577
|
return (
|
|
6965
|
-
<div className={cn('
|
|
6966
|
-
<
|
|
6967
|
-
|
|
6968
|
-
|
|
6969
|
-
{
|
|
6970
|
-
|
|
6971
|
-
|
|
6972
|
-
|
|
6973
|
-
.
|
|
6974
|
-
</p>
|
|
7578
|
+
<div className={cn('py-4', className)}>
|
|
7579
|
+
<iframe
|
|
7580
|
+
src={paymentIntent.clientSecret}
|
|
7581
|
+
className="w-full border-0"
|
|
7582
|
+
style={{ minHeight: '500px' }}
|
|
7583
|
+
title={t('payment')}
|
|
7584
|
+
allow="payment"
|
|
7585
|
+
/>
|
|
6975
7586
|
</div>
|
|
6976
7587
|
);
|
|
6977
7588
|
}
|
|
6978
7589
|
|
|
6979
|
-
return
|
|
7590
|
+
return (
|
|
7591
|
+
<div className={cn('flex flex-col items-center justify-center py-12', className)}>
|
|
7592
|
+
<LoadingSpinner size="lg" />
|
|
7593
|
+
<p className="text-muted-foreground mt-4 text-sm">{t('redirectingToPayment')}</p>
|
|
7594
|
+
<p className="text-muted-foreground mt-2 text-xs">
|
|
7595
|
+
{t('redirectingHint')}
|
|
7596
|
+
<a href={paymentIntent.clientSecret} className="text-primary hover:underline">
|
|
7597
|
+
{t('clickHere')}
|
|
7598
|
+
</a>
|
|
7599
|
+
.
|
|
7600
|
+
</p>
|
|
7601
|
+
</div>
|
|
7602
|
+
);
|
|
6980
7603
|
}
|
|
6981
7604
|
`,
|
|
6982
7605
|
"src/components/checkout/tax-display.tsx": `'use client';
|
|
@@ -7159,6 +7782,7 @@ interface RegisterData {
|
|
|
7159
7782
|
lastName: string;
|
|
7160
7783
|
email: string;
|
|
7161
7784
|
password: string;
|
|
7785
|
+
acceptsMarketing: boolean;
|
|
7162
7786
|
}
|
|
7163
7787
|
|
|
7164
7788
|
interface RegisterFormProps {
|
|
@@ -7190,6 +7814,9 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
|
|
|
7190
7814
|
const [lastName, setLastName] = useState('');
|
|
7191
7815
|
const [email, setEmail] = useState('');
|
|
7192
7816
|
const [password, setPassword] = useState('');
|
|
7817
|
+
const [privacyAccepted, setPrivacyAccepted] = useState(false);
|
|
7818
|
+
const [privacyError, setPrivacyError] = useState(false);
|
|
7819
|
+
const [acceptsMarketing, setAcceptsMarketing] = useState(false);
|
|
7193
7820
|
const [loading, setLoading] = useState(false);
|
|
7194
7821
|
|
|
7195
7822
|
const strength = useMemo(() => getPasswordStrength(password), [password]);
|
|
@@ -7198,9 +7825,14 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
|
|
|
7198
7825
|
e.preventDefault();
|
|
7199
7826
|
if (loading) return;
|
|
7200
7827
|
|
|
7828
|
+
if (!privacyAccepted) {
|
|
7829
|
+
setPrivacyError(true);
|
|
7830
|
+
return;
|
|
7831
|
+
}
|
|
7832
|
+
|
|
7201
7833
|
try {
|
|
7202
7834
|
setLoading(true);
|
|
7203
|
-
await onSubmit({ firstName, lastName, email, password });
|
|
7835
|
+
await onSubmit({ firstName, lastName, email, password, acceptsMarketing });
|
|
7204
7836
|
} finally {
|
|
7205
7837
|
setLoading(false);
|
|
7206
7838
|
}
|
|
@@ -7311,6 +7943,45 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
|
|
|
7311
7943
|
)}
|
|
7312
7944
|
</div>
|
|
7313
7945
|
|
|
7946
|
+
{/* Privacy Policy (required) */}
|
|
7947
|
+
<div>
|
|
7948
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
7949
|
+
<input
|
|
7950
|
+
type="checkbox"
|
|
7951
|
+
checked={privacyAccepted}
|
|
7952
|
+
onChange={(e) => {
|
|
7953
|
+
setPrivacyAccepted(e.target.checked);
|
|
7954
|
+
setPrivacyError(false);
|
|
7955
|
+
}}
|
|
7956
|
+
className="accent-primary mt-0.5"
|
|
7957
|
+
/>
|
|
7958
|
+
<span className="text-muted-foreground text-sm">
|
|
7959
|
+
{t('privacyAcceptPrefix')}{' '}
|
|
7960
|
+
<a
|
|
7961
|
+
href="/privacy"
|
|
7962
|
+
target="_blank"
|
|
7963
|
+
rel="noopener noreferrer"
|
|
7964
|
+
className="text-primary underline underline-offset-2"
|
|
7965
|
+
>
|
|
7966
|
+
{t('privacyPolicyLink')}
|
|
7967
|
+
</a>{' '}
|
|
7968
|
+
<span className="text-destructive">*</span>
|
|
7969
|
+
</span>
|
|
7970
|
+
</label>
|
|
7971
|
+
{privacyError && <p className="text-destructive mt-1 text-xs">{t('privacyRequired')}</p>}
|
|
7972
|
+
</div>
|
|
7973
|
+
|
|
7974
|
+
{/* Marketing consent (optional) */}
|
|
7975
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
7976
|
+
<input
|
|
7977
|
+
type="checkbox"
|
|
7978
|
+
checked={acceptsMarketing}
|
|
7979
|
+
onChange={(e) => setAcceptsMarketing(e.target.checked)}
|
|
7980
|
+
className="accent-primary mt-0.5"
|
|
7981
|
+
/>
|
|
7982
|
+
<span className="text-muted-foreground text-sm">{t('acceptsMarketing')}</span>
|
|
7983
|
+
</label>
|
|
7984
|
+
|
|
7314
7985
|
<button
|
|
7315
7986
|
type="submit"
|
|
7316
7987
|
disabled={loading}
|
|
@@ -7776,7 +8447,8 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
7776
8447
|
const t = useTranslations('account');
|
|
7777
8448
|
const tc = useTranslations('common');
|
|
7778
8449
|
const [expanded, setExpanded] = useState(false);
|
|
7779
|
-
const statusConfig =
|
|
8450
|
+
const statusConfig =
|
|
8451
|
+
STATUS_CONFIG[order.status?.toLowerCase() as OrderStatus] || STATUS_CONFIG.pending;
|
|
7780
8452
|
const currency = order.currency || 'USD';
|
|
7781
8453
|
const totalAmount = order.totalAmount || order.total || '0';
|
|
7782
8454
|
|
|
@@ -7888,9 +8560,7 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
7888
8560
|
))}
|
|
7889
8561
|
|
|
7890
8562
|
{/* Downloads section */}
|
|
7891
|
-
{order.hasDownloads &&
|
|
7892
|
-
<OrderDownloads orderId={order.id} />
|
|
7893
|
-
)}
|
|
8563
|
+
{order.hasDownloads && <OrderDownloads orderId={order.id} />}
|
|
7894
8564
|
|
|
7895
8565
|
<OrderFinancialSummary order={order} currency={currency} />
|
|
7896
8566
|
</div>
|
|
@@ -7918,7 +8588,9 @@ function OrderDownloads({ orderId }: { orderId: string }) {
|
|
|
7918
8588
|
}
|
|
7919
8589
|
}
|
|
7920
8590
|
fetch();
|
|
7921
|
-
return () => {
|
|
8591
|
+
return () => {
|
|
8592
|
+
cancelled = true;
|
|
8593
|
+
};
|
|
7922
8594
|
}, [orderId]);
|
|
7923
8595
|
|
|
7924
8596
|
if (loading) {
|
|
@@ -7942,11 +8614,11 @@ function OrderDownloads({ orderId }: { orderId: string }) {
|
|
|
7942
8614
|
{link.productName}
|
|
7943
8615
|
{' \xB7 '}
|
|
7944
8616
|
{link.downloadLimit != null
|
|
7945
|
-
?
|
|
8617
|
+
? \`\${link.downloadsUsed}/\${link.downloadLimit} \${t('downloadsRemaining')}\`
|
|
7946
8618
|
: t('unlimitedDownloads')}
|
|
7947
8619
|
{' \xB7 '}
|
|
7948
8620
|
{link.expiresAt
|
|
7949
|
-
? t('expiresAt'
|
|
8621
|
+
? \`\${t('expiresAt')} \${new Date(link.expiresAt).toLocaleDateString()}\`
|
|
7950
8622
|
: t('noExpiry')}
|
|
7951
8623
|
</p>
|
|
7952
8624
|
</div>
|
|
@@ -8130,7 +8802,7 @@ export function Header() {
|
|
|
8130
8802
|
<div className="flex h-16 items-center justify-between gap-4">
|
|
8131
8803
|
{/* Logo / Store Name */}
|
|
8132
8804
|
<Link href="/" className="text-foreground flex-shrink-0 text-xl font-bold">
|
|
8133
|
-
{storeInfo?.name || tc('store')}
|
|
8805
|
+
{storeInfo?.name || process.env.NEXT_PUBLIC_STORE_NAME || tc('store')}
|
|
8134
8806
|
</Link>
|
|
8135
8807
|
|
|
8136
8808
|
{/* Desktop Navigation */}
|
|
@@ -8141,12 +8813,14 @@ export function Header() {
|
|
|
8141
8813
|
>
|
|
8142
8814
|
{t('products')}
|
|
8143
8815
|
</Link>
|
|
8144
|
-
|
|
8145
|
-
|
|
8146
|
-
|
|
8147
|
-
|
|
8148
|
-
|
|
8149
|
-
|
|
8816
|
+
{isLoggedIn && (
|
|
8817
|
+
<Link
|
|
8818
|
+
href="/account"
|
|
8819
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
8820
|
+
>
|
|
8821
|
+
{t('account')}
|
|
8822
|
+
</Link>
|
|
8823
|
+
)}
|
|
8150
8824
|
</nav>
|
|
8151
8825
|
|
|
8152
8826
|
{/* Search */}
|
|
@@ -8336,13 +9010,15 @@ export function Header() {
|
|
|
8336
9010
|
>
|
|
8337
9011
|
{t('products')}
|
|
8338
9012
|
</Link>
|
|
8339
|
-
|
|
8340
|
-
|
|
8341
|
-
|
|
8342
|
-
|
|
8343
|
-
|
|
8344
|
-
|
|
8345
|
-
|
|
9013
|
+
{isLoggedIn && (
|
|
9014
|
+
<Link
|
|
9015
|
+
href="/account"
|
|
9016
|
+
onClick={() => setMobileMenuOpen(false)}
|
|
9017
|
+
className="text-foreground hover:bg-muted block rounded px-2 py-2 text-sm"
|
|
9018
|
+
>
|
|
9019
|
+
{t('account')}
|
|
9020
|
+
</Link>
|
|
9021
|
+
)}
|
|
8346
9022
|
{isLoggedIn ? (
|
|
8347
9023
|
<button
|
|
8348
9024
|
onClick={() => {
|
|
@@ -8384,12 +9060,13 @@ export function Header() {
|
|
|
8384
9060
|
|
|
8385
9061
|
import Link from 'next/link';
|
|
8386
9062
|
import { useTranslations } from '@/lib/translations';
|
|
8387
|
-
import { useStoreInfo } from '@/providers/store-provider';
|
|
9063
|
+
import { useStoreInfo, useAuth } from '@/providers/store-provider';
|
|
8388
9064
|
|
|
8389
9065
|
export function Footer() {
|
|
8390
9066
|
const t = useTranslations('common');
|
|
8391
9067
|
const tn = useTranslations('nav');
|
|
8392
9068
|
const { storeInfo } = useStoreInfo();
|
|
9069
|
+
const { isLoggedIn } = useAuth();
|
|
8393
9070
|
const year = new Date().getFullYear();
|
|
8394
9071
|
|
|
8395
9072
|
return (
|
|
@@ -8406,12 +9083,14 @@ export function Footer() {
|
|
|
8406
9083
|
>
|
|
8407
9084
|
{tn('products')}
|
|
8408
9085
|
</Link>
|
|
8409
|
-
|
|
8410
|
-
|
|
8411
|
-
|
|
8412
|
-
|
|
8413
|
-
|
|
8414
|
-
|
|
9086
|
+
{isLoggedIn && (
|
|
9087
|
+
<Link
|
|
9088
|
+
href="/account"
|
|
9089
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
9090
|
+
>
|
|
9091
|
+
{tn('account')}
|
|
9092
|
+
</Link>
|
|
9093
|
+
)}
|
|
8415
9094
|
</nav>
|
|
8416
9095
|
</div>
|
|
8417
9096
|
</div>
|
|
@@ -8666,6 +9345,12 @@ var USE_CASE_FILES = {
|
|
|
8666
9345
|
{ path: "src/components/auth/oauth-buttons.tsx", label: "OAuth Buttons Component" }
|
|
8667
9346
|
],
|
|
8668
9347
|
"verify-email": [{ path: "src/app/verify-email/page.tsx", label: "Verify Email Page" }],
|
|
9348
|
+
"forgot-password": [{ path: "src/app/forgot-password/page.tsx", label: "Forgot Password Page" }],
|
|
9349
|
+
"reset-password": [
|
|
9350
|
+
{ path: "src/app/reset-password/page.tsx", label: "Reset Password Page" },
|
|
9351
|
+
{ path: "src/app/api/auth/reset-callback/route.ts", label: "Reset Callback API Route" },
|
|
9352
|
+
{ path: "src/app/api/auth/reset-password/route.ts", label: "Reset Password API Route" }
|
|
9353
|
+
],
|
|
8669
9354
|
"oauth-callback": [{ path: "src/app/auth/callback/page.tsx", label: "OAuth Callback Page" }],
|
|
8670
9355
|
"account-page": [
|
|
8671
9356
|
{ path: "src/app/account/page.tsx", label: "Account Page" },
|
|
@@ -8742,6 +9427,8 @@ var GET_CODE_EXAMPLE_SCHEMA = {
|
|
|
8742
9427
|
"login-page",
|
|
8743
9428
|
"register-page",
|
|
8744
9429
|
"verify-email",
|
|
9430
|
+
"forgot-password",
|
|
9431
|
+
"reset-password",
|
|
8745
9432
|
"oauth-callback",
|
|
8746
9433
|
"account-page",
|
|
8747
9434
|
"header",
|
|
@@ -8782,6 +9469,8 @@ var TOPIC_MAP = {
|
|
|
8782
9469
|
"login-page": "auth",
|
|
8783
9470
|
"register-page": "auth",
|
|
8784
9471
|
"verify-email": "auth",
|
|
9472
|
+
"forgot-password": "auth",
|
|
9473
|
+
"reset-password": "auth",
|
|
8785
9474
|
"oauth-callback": "auth",
|
|
8786
9475
|
"account-page": "auth",
|
|
8787
9476
|
header: "products",
|
|
@@ -9021,6 +9710,735 @@ async function handleGetStoreInfo(args) {
|
|
|
9021
9710
|
}
|
|
9022
9711
|
}
|
|
9023
9712
|
|
|
9713
|
+
// src/tools/get-store-capabilities.ts
|
|
9714
|
+
var import_zod6 = require("zod");
|
|
9715
|
+
|
|
9716
|
+
// src/utils/fetch-store-capabilities.ts
|
|
9717
|
+
var import_https2 = __toESM(require("https"));
|
|
9718
|
+
var import_http2 = __toESM(require("http"));
|
|
9719
|
+
async function fetchStoreCapabilities(connectionId, baseUrl = "https://api.brainerce.com") {
|
|
9720
|
+
const url = `${baseUrl}/api/vc/${connectionId}/capabilities`;
|
|
9721
|
+
return new Promise((resolve, reject) => {
|
|
9722
|
+
const client = url.startsWith("https") ? import_https2.default : import_http2.default;
|
|
9723
|
+
const req = client.get(url, { timeout: 1e4 }, (res) => {
|
|
9724
|
+
let data = "";
|
|
9725
|
+
res.on("data", (chunk) => {
|
|
9726
|
+
data += chunk;
|
|
9727
|
+
});
|
|
9728
|
+
res.on("end", () => {
|
|
9729
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
9730
|
+
if (res.statusCode === 404) {
|
|
9731
|
+
reject(new Error(`Connection ID "${connectionId}" not found. Check your dashboard.`));
|
|
9732
|
+
} else {
|
|
9733
|
+
reject(new Error(`API returned status ${res.statusCode}`));
|
|
9734
|
+
}
|
|
9735
|
+
return;
|
|
9736
|
+
}
|
|
9737
|
+
try {
|
|
9738
|
+
resolve(JSON.parse(data));
|
|
9739
|
+
} catch {
|
|
9740
|
+
reject(new Error("Invalid response from API"));
|
|
9741
|
+
}
|
|
9742
|
+
});
|
|
9743
|
+
});
|
|
9744
|
+
req.on("error", (err) => {
|
|
9745
|
+
reject(new Error(`Failed to connect to Brainerce API: ${err.message}`));
|
|
9746
|
+
});
|
|
9747
|
+
req.on("timeout", () => {
|
|
9748
|
+
req.destroy();
|
|
9749
|
+
reject(new Error("Request timed out"));
|
|
9750
|
+
});
|
|
9751
|
+
});
|
|
9752
|
+
}
|
|
9753
|
+
|
|
9754
|
+
// src/tools/get-store-capabilities.ts
|
|
9755
|
+
var GET_STORE_CAPABILITIES_NAME = "get-store-capabilities";
|
|
9756
|
+
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.";
|
|
9757
|
+
var GET_STORE_CAPABILITIES_SCHEMA = {
|
|
9758
|
+
connectionId: import_zod6.z.string().describe("Vibe-coded connection ID (starts with vc_)")
|
|
9759
|
+
};
|
|
9760
|
+
function formatCapabilities(caps) {
|
|
9761
|
+
const lines = [];
|
|
9762
|
+
lines.push(`## Store: ${caps.store.name} (${caps.store.currency})`);
|
|
9763
|
+
lines.push(`Language: ${caps.store.language}`);
|
|
9764
|
+
lines.push("");
|
|
9765
|
+
lines.push("## Configured Features");
|
|
9766
|
+
if (caps.features.paymentProviders.length > 0) {
|
|
9767
|
+
const providers = caps.features.paymentProviders.map((p) => p.name).join(", ");
|
|
9768
|
+
lines.push(`\u2705 Payment: ${providers}`);
|
|
9769
|
+
} else {
|
|
9770
|
+
lines.push("\u274C No payment providers configured \u2014 customers cannot pay!");
|
|
9771
|
+
}
|
|
9772
|
+
if (caps.features.oauthProviders.length > 0) {
|
|
9773
|
+
const enabled = caps.features.oauthProviders.filter((o) => o.isEnabled).map((o) => o.provider).join(", ");
|
|
9774
|
+
if (enabled) {
|
|
9775
|
+
lines.push(`\u2705 OAuth: ${enabled}`);
|
|
9776
|
+
} else {
|
|
9777
|
+
lines.push("\u26A0\uFE0F OAuth providers configured but disabled");
|
|
9778
|
+
}
|
|
9779
|
+
} else {
|
|
9780
|
+
lines.push("\u274C No OAuth providers \u2014 email/password login only");
|
|
9781
|
+
}
|
|
9782
|
+
lines.push(
|
|
9783
|
+
caps.features.hasShippingZones ? "\u2705 Shipping zones configured" : "\u274C No shipping zones"
|
|
9784
|
+
);
|
|
9785
|
+
lines.push(caps.features.hasDiscountRules ? "\u2705 Discount rules active" : "\u274C No discount rules");
|
|
9786
|
+
lines.push(
|
|
9787
|
+
caps.features.hasDownloadableProducts ? "\u2705 Downloadable products available" : "\u274C No downloadable products"
|
|
9788
|
+
);
|
|
9789
|
+
lines.push(caps.features.hasCoupons ? "\u2705 Coupons available" : "\u274C No coupons");
|
|
9790
|
+
lines.push("");
|
|
9791
|
+
lines.push("## Connection Settings");
|
|
9792
|
+
lines.push(
|
|
9793
|
+
`- Guest checkout tracking: ${caps.connection.guestCheckoutTracking ? "enabled" : "disabled"}`
|
|
9794
|
+
);
|
|
9795
|
+
lines.push(
|
|
9796
|
+
`- Email verification: ${caps.connection.requireEmailVerification ? "required" : "not required"}`
|
|
9797
|
+
);
|
|
9798
|
+
lines.push(
|
|
9799
|
+
`- Inventory reservation: ${caps.connection.reservationStrategy} (${caps.connection.reservationTimeout} min timeout)`
|
|
9800
|
+
);
|
|
9801
|
+
if (caps.connection.lowStockWarning) {
|
|
9802
|
+
lines.push(`- Low stock warning: enabled (threshold: ${caps.connection.lowStockThreshold})`);
|
|
9803
|
+
}
|
|
9804
|
+
lines.push(`- Orders write: ${caps.connection.ordersWriteEnabled ? "enabled" : "disabled"}`);
|
|
9805
|
+
lines.push(`- Scopes: ${caps.connection.allowedScopes.join(", ")}`);
|
|
9806
|
+
lines.push("");
|
|
9807
|
+
lines.push("## Build Suggestions");
|
|
9808
|
+
const suggestions = [];
|
|
9809
|
+
if (caps.features.paymentProviders.length > 0) {
|
|
9810
|
+
suggestions.push(
|
|
9811
|
+
'Build a checkout page with payment. Use get-code-example("checkout-page") and get-code-example("stripe-payment").'
|
|
9812
|
+
);
|
|
9813
|
+
}
|
|
9814
|
+
if (caps.features.oauthProviders.some((o) => o.isEnabled)) {
|
|
9815
|
+
suggestions.push(
|
|
9816
|
+
'Add OAuth login buttons. Use get-code-example("login-page") \u2014 it includes OAuth support.'
|
|
9817
|
+
);
|
|
9818
|
+
}
|
|
9819
|
+
if (caps.features.hasDownloadableProducts) {
|
|
9820
|
+
suggestions.push(
|
|
9821
|
+
"Build a downloads page for digital products. Customers need to access their purchased files."
|
|
9822
|
+
);
|
|
9823
|
+
}
|
|
9824
|
+
if (caps.features.hasDiscountRules) {
|
|
9825
|
+
suggestions.push(
|
|
9826
|
+
'Show discount badges and banners. Use get-code-example("discount-badge") and get-sdk-docs("discounts").'
|
|
9827
|
+
);
|
|
9828
|
+
}
|
|
9829
|
+
if (caps.features.hasCoupons) {
|
|
9830
|
+
suggestions.push('Add a coupon input to the cart. Use get-code-example("coupon-input").');
|
|
9831
|
+
}
|
|
9832
|
+
if (caps.connection.requireEmailVerification) {
|
|
9833
|
+
suggestions.push(
|
|
9834
|
+
'Email verification is required \u2014 ALWAYS build the verify-email page. Use get-code-example("verify-email").'
|
|
9835
|
+
);
|
|
9836
|
+
}
|
|
9837
|
+
if (caps.connection.reservationStrategy !== "ON_PAYMENT") {
|
|
9838
|
+
suggestions.push(
|
|
9839
|
+
'Inventory reservation is active \u2014 show a countdown timer. Use get-code-example("reservation-countdown").'
|
|
9840
|
+
);
|
|
9841
|
+
}
|
|
9842
|
+
if (suggestions.length === 0) {
|
|
9843
|
+
suggestions.push(
|
|
9844
|
+
"Start by building the required pages. Use get-required-pages() for the checklist."
|
|
9845
|
+
);
|
|
9846
|
+
}
|
|
9847
|
+
suggestions.forEach((s) => lines.push(`- ${s}`));
|
|
9848
|
+
return lines.join("\n");
|
|
9849
|
+
}
|
|
9850
|
+
async function handleGetStoreCapabilities(args) {
|
|
9851
|
+
try {
|
|
9852
|
+
const caps = await fetchStoreCapabilities(args.connectionId);
|
|
9853
|
+
return {
|
|
9854
|
+
content: [{ type: "text", text: formatCapabilities(caps) }]
|
|
9855
|
+
};
|
|
9856
|
+
} catch (error) {
|
|
9857
|
+
const message = error instanceof Error ? error.message : "Failed to fetch store capabilities";
|
|
9858
|
+
return {
|
|
9859
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
9860
|
+
isError: true
|
|
9861
|
+
};
|
|
9862
|
+
}
|
|
9863
|
+
}
|
|
9864
|
+
|
|
9865
|
+
// src/tools/build-store.ts
|
|
9866
|
+
var import_zod7 = require("zod");
|
|
9867
|
+
|
|
9868
|
+
// src/content/compressed-bundle.ts
|
|
9869
|
+
var SETUP_FILES = [
|
|
9870
|
+
"src/lib/brainerce.ts.ejs",
|
|
9871
|
+
"src/lib/auth.ts",
|
|
9872
|
+
"src/lib/utils.ts",
|
|
9873
|
+
"src/providers/store-provider.tsx.ejs",
|
|
9874
|
+
"src/app/layout.tsx.ejs",
|
|
9875
|
+
"src/middleware.ts"
|
|
9876
|
+
];
|
|
9877
|
+
var PAGE_FILES = [
|
|
9878
|
+
{ path: "src/app/page.tsx", route: "/", label: "Home" },
|
|
9879
|
+
{ path: "src/app/products/page.tsx", route: "/products", label: "Product Listing" },
|
|
9880
|
+
{ path: "src/app/products/[slug]/page.tsx", route: "/products/[slug]", label: "Product Detail" },
|
|
9881
|
+
{ path: "src/app/cart/page.tsx", route: "/cart", label: "Cart" },
|
|
9882
|
+
{ path: "src/app/checkout/page.tsx", route: "/checkout", label: "Checkout" },
|
|
9883
|
+
{
|
|
9884
|
+
path: "src/app/order-confirmation/page.tsx",
|
|
9885
|
+
route: "/order-confirmation",
|
|
9886
|
+
label: "Order Confirmation"
|
|
9887
|
+
},
|
|
9888
|
+
{ path: "src/app/login/page.tsx", route: "/login", label: "Login" },
|
|
9889
|
+
{ path: "src/app/register/page.tsx", route: "/register", label: "Register" },
|
|
9890
|
+
{ path: "src/app/verify-email/page.tsx", route: "/verify-email", label: "Verify Email" },
|
|
9891
|
+
{ path: "src/app/forgot-password/page.tsx", route: "/forgot-password", label: "Forgot Password" },
|
|
9892
|
+
{ path: "src/app/reset-password/page.tsx", route: "/reset-password", label: "Reset Password" },
|
|
9893
|
+
{ path: "src/app/auth/callback/page.tsx", route: "/auth/callback", label: "OAuth Callback" },
|
|
9894
|
+
{ path: "src/app/account/page.tsx", route: "/account", label: "Account" }
|
|
9895
|
+
];
|
|
9896
|
+
var COMPONENT_FILES = [
|
|
9897
|
+
{ path: "src/components/layout/header.tsx", label: "Header" },
|
|
9898
|
+
{ path: "src/components/layout/footer.tsx", label: "Footer" },
|
|
9899
|
+
{ path: "src/components/products/product-card.tsx", label: "Product Card" },
|
|
9900
|
+
{ path: "src/components/products/product-grid.tsx", label: "Product Grid" },
|
|
9901
|
+
{ path: "src/components/products/variant-selector.tsx", label: "Variant Selector" },
|
|
9902
|
+
{ path: "src/components/products/stock-badge.tsx", label: "Stock Badge" },
|
|
9903
|
+
{ path: "src/components/products/discount-badge.tsx", label: "Discount Badge" },
|
|
9904
|
+
{ path: "src/components/cart/cart-item.tsx", label: "Cart Item" },
|
|
9905
|
+
{ path: "src/components/cart/cart-summary.tsx", label: "Cart Summary" },
|
|
9906
|
+
{ path: "src/components/cart/coupon-input.tsx", label: "Coupon Input" },
|
|
9907
|
+
{ path: "src/components/cart/cart-nudges.tsx", label: "Cart Nudges" },
|
|
9908
|
+
{ path: "src/components/cart/reservation-countdown.tsx", label: "Reservation Countdown" },
|
|
9909
|
+
{ path: "src/components/checkout/checkout-form.tsx", label: "Checkout Form" },
|
|
9910
|
+
{ path: "src/components/checkout/shipping-step.tsx", label: "Shipping Step" },
|
|
9911
|
+
{ path: "src/components/checkout/payment-step.tsx", label: "Payment Step" },
|
|
9912
|
+
{ path: "src/components/checkout/tax-display.tsx", label: "Tax Display" },
|
|
9913
|
+
{ path: "src/components/auth/login-form.tsx", label: "Login Form" },
|
|
9914
|
+
{ path: "src/components/auth/register-form.tsx", label: "Register Form" },
|
|
9915
|
+
{ path: "src/components/auth/oauth-buttons.tsx", label: "OAuth Buttons" },
|
|
9916
|
+
{ path: "src/components/account/profile-section.tsx", label: "Profile Section" },
|
|
9917
|
+
{ path: "src/components/account/order-history.tsx", label: "Order History" },
|
|
9918
|
+
{ path: "src/components/shared/price-display.tsx", label: "Price Display" },
|
|
9919
|
+
{ path: "src/components/shared/loading-spinner.tsx", label: "Loading Spinner" },
|
|
9920
|
+
{ path: "src/hooks/use-search.ts", label: "Search Hook" }
|
|
9921
|
+
];
|
|
9922
|
+
var API_ROUTE_FILES = [
|
|
9923
|
+
"src/app/api/store/[...path]/route.ts",
|
|
9924
|
+
"src/app/api/auth/oauth-callback/route.ts",
|
|
9925
|
+
"src/app/api/auth/me/route.ts",
|
|
9926
|
+
"src/app/api/auth/logout/route.ts",
|
|
9927
|
+
"src/app/api/auth/reset-callback/route.ts",
|
|
9928
|
+
"src/app/api/auth/reset-password/route.ts"
|
|
9929
|
+
];
|
|
9930
|
+
function replaceEjsVars(content, vars) {
|
|
9931
|
+
let result = content;
|
|
9932
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
9933
|
+
result = result.replace(new RegExp(`<%[=-]\\s*${key}\\s*%>`, "g"), value);
|
|
9934
|
+
}
|
|
9935
|
+
return result;
|
|
9936
|
+
}
|
|
9937
|
+
function renderFile(path, ejsVars) {
|
|
9938
|
+
const content = EMBEDDED_TEMPLATES[path];
|
|
9939
|
+
if (!content) return null;
|
|
9940
|
+
const rendered = path.endsWith(".ejs") ? replaceEjsVars(content, ejsVars) : content;
|
|
9941
|
+
const displayPath = path.replace(/\.ejs$/, "");
|
|
9942
|
+
return `// \u2500\u2500 ${displayPath} \u2500\u2500
|
|
9943
|
+
${rendered}`;
|
|
9944
|
+
}
|
|
9945
|
+
function formatCapabilitiesSummary(caps) {
|
|
9946
|
+
const lines = [];
|
|
9947
|
+
lines.push(`## Store: ${caps.store.name}`);
|
|
9948
|
+
lines.push(`Currency: ${caps.store.currency} | Language: ${caps.store.language}`);
|
|
9949
|
+
lines.push("");
|
|
9950
|
+
lines.push("### Configured Features");
|
|
9951
|
+
if (caps.features.paymentProviders.length > 0) {
|
|
9952
|
+
const providers = caps.features.paymentProviders.map((p) => `${p.name} (${p.provider})`).join(", ");
|
|
9953
|
+
lines.push(`- Payment: ${providers}`);
|
|
9954
|
+
} else {
|
|
9955
|
+
lines.push(
|
|
9956
|
+
"- Payment: NONE configured \u2014 customers cannot pay! Tell the store owner to configure a payment provider."
|
|
9957
|
+
);
|
|
9958
|
+
}
|
|
9959
|
+
const enabledOAuth = caps.features.oauthProviders.filter((o) => o.isEnabled).map((o) => o.provider);
|
|
9960
|
+
if (enabledOAuth.length > 0) {
|
|
9961
|
+
lines.push(`- OAuth: ${enabledOAuth.join(", ")} \u2014 show OAuth buttons on login/register`);
|
|
9962
|
+
} else {
|
|
9963
|
+
lines.push(
|
|
9964
|
+
"- 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)"
|
|
9965
|
+
);
|
|
9966
|
+
}
|
|
9967
|
+
lines.push(
|
|
9968
|
+
caps.features.hasShippingZones ? "- Shipping: configured" : "- Shipping: no zones configured"
|
|
9969
|
+
);
|
|
9970
|
+
lines.push(
|
|
9971
|
+
caps.features.hasDiscountRules ? "- Discounts: active \u2014 show banners and badges" : "- Discounts: none active"
|
|
9972
|
+
);
|
|
9973
|
+
lines.push(
|
|
9974
|
+
caps.features.hasDownloadableProducts ? "- Downloadable products: yes \u2014 show download badges" : "- Downloadable products: no"
|
|
9975
|
+
);
|
|
9976
|
+
lines.push(
|
|
9977
|
+
caps.features.hasCoupons ? "- Coupons: active \u2014 show coupon input on cart" : "- Coupons: none"
|
|
9978
|
+
);
|
|
9979
|
+
lines.push("");
|
|
9980
|
+
lines.push("### Connection Settings");
|
|
9981
|
+
lines.push(
|
|
9982
|
+
`- Email verification: ${caps.connection.requireEmailVerification ? "REQUIRED \u2014 verify-email page is critical" : "not required (still build the page)"}`
|
|
9983
|
+
);
|
|
9984
|
+
lines.push(
|
|
9985
|
+
`- Inventory reservation: ${caps.connection.reservationStrategy} (${caps.connection.reservationTimeout} min)`
|
|
9986
|
+
);
|
|
9987
|
+
lines.push(
|
|
9988
|
+
`- Guest checkout tracking: ${caps.connection.guestCheckoutTracking ? "enabled" : "disabled"}`
|
|
9989
|
+
);
|
|
9990
|
+
return lines.join("\n");
|
|
9991
|
+
}
|
|
9992
|
+
function buildStoreBundle(options) {
|
|
9993
|
+
const { connectionId, storeName, storeType, currency, storeStyle, capabilities } = options;
|
|
9994
|
+
const styleDesc = storeStyle ? ` with a ${storeStyle} aesthetic` : "";
|
|
9995
|
+
const ejsVars = {
|
|
9996
|
+
connectionId,
|
|
9997
|
+
storeName,
|
|
9998
|
+
currency,
|
|
9999
|
+
language: capabilities?.store.language || "en",
|
|
10000
|
+
apiUrl: "https://api.brainerce.com"
|
|
10001
|
+
};
|
|
10002
|
+
const sections = [];
|
|
10003
|
+
sections.push(`# Build: ${storeName} \u2014 ${storeType}${styleDesc}
|
|
10004
|
+
Connection ID: \`${connectionId}\` | Currency: ${currency}
|
|
10005
|
+
|
|
10006
|
+
Install the SDK first: \`npm install brainerce @stripe/stripe-js @stripe/react-stripe-js\`
|
|
10007
|
+
|
|
10008
|
+
## MANDATORY: Every page and component below MUST be built
|
|
10009
|
+
|
|
10010
|
+
**Do NOT skip any page or component.** Even features that appear unused (OAuth buttons, coupon input, discount badges, verify-email) MUST be included because:
|
|
10011
|
+
- They auto-hide when not configured \u2014 zero visual impact
|
|
10012
|
+
- Store owners can enable them at any time from the dashboard
|
|
10013
|
+
- A store without these is incomplete and will break when features are enabled
|
|
10014
|
+
|
|
10015
|
+
**Every file in this bundle is required. Build them ALL.**`);
|
|
10016
|
+
if (capabilities) {
|
|
10017
|
+
sections.push(formatCapabilitiesSummary(capabilities));
|
|
10018
|
+
}
|
|
10019
|
+
sections.push(getCriticalRulesSection());
|
|
10020
|
+
sections.push(getTypeQuickReference());
|
|
10021
|
+
const setupSection = ["## Setup Files\n"];
|
|
10022
|
+
for (const path of SETUP_FILES) {
|
|
10023
|
+
const rendered = renderFile(path, ejsVars);
|
|
10024
|
+
if (rendered) setupSection.push(rendered);
|
|
10025
|
+
}
|
|
10026
|
+
sections.push(setupSection.join("\n\n"));
|
|
10027
|
+
const pagesSection = ["## Pages (ALL 13 required \u2014 build every single one!)\n"];
|
|
10028
|
+
for (const page of PAGE_FILES) {
|
|
10029
|
+
const rendered = renderFile(page.path, ejsVars);
|
|
10030
|
+
if (rendered) {
|
|
10031
|
+
pagesSection.push(`### ${page.route} \u2014 ${page.label}
|
|
10032
|
+
|
|
10033
|
+
${rendered}`);
|
|
10034
|
+
}
|
|
10035
|
+
}
|
|
10036
|
+
sections.push(pagesSection.join("\n\n"));
|
|
10037
|
+
const componentsSection = ["## Components\n"];
|
|
10038
|
+
for (const comp of COMPONENT_FILES) {
|
|
10039
|
+
const rendered = renderFile(comp.path, ejsVars);
|
|
10040
|
+
if (rendered) componentsSection.push(rendered);
|
|
10041
|
+
}
|
|
10042
|
+
sections.push(componentsSection.join("\n\n"));
|
|
10043
|
+
const apiSection = ["## API Routes (BFF Proxy + Auth)\n"];
|
|
10044
|
+
for (const path of API_ROUTE_FILES) {
|
|
10045
|
+
const rendered = renderFile(path, ejsVars);
|
|
10046
|
+
if (rendered) apiSection.push(rendered);
|
|
10047
|
+
}
|
|
10048
|
+
sections.push(apiSection.join("\n\n"));
|
|
10049
|
+
sections.push(`## Verification Checklist
|
|
10050
|
+
|
|
10051
|
+
After building, verify you created ALL of these files:
|
|
10052
|
+
|
|
10053
|
+
### Setup
|
|
10054
|
+
- [ ] \`lib/brainerce.ts\` \u2014 SDK client singleton
|
|
10055
|
+
- [ ] \`lib/auth.ts\` \u2014 Auth helpers (BFF pattern)
|
|
10056
|
+
- [ ] \`providers/store-provider.tsx\` \u2014 Global store context
|
|
10057
|
+
- [ ] \`app/layout.tsx\` \u2014 Root layout with StoreProvider
|
|
10058
|
+
- [ ] \`middleware.ts\` \u2014 Auth middleware
|
|
10059
|
+
|
|
10060
|
+
### Pages (ALL 13 required!)
|
|
10061
|
+
- [ ] \`app/page.tsx\` \u2014 Home (hero, featured products)
|
|
10062
|
+
- [ ] \`app/products/page.tsx\` \u2014 Product listing with filters
|
|
10063
|
+
- [ ] \`app/products/[slug]/page.tsx\` \u2014 Product detail
|
|
10064
|
+
- [ ] \`app/cart/page.tsx\` \u2014 Cart with checkboxes, coupon
|
|
10065
|
+
- [ ] \`app/checkout/page.tsx\` \u2014 Address \u2192 Shipping \u2192 Payment
|
|
10066
|
+
- [ ] \`app/order-confirmation/page.tsx\` \u2014 waitForOrder() + handlePaymentSuccess()
|
|
10067
|
+
- [ ] \`app/login/page.tsx\` \u2014 Login
|
|
10068
|
+
- [ ] \`app/register/page.tsx\` \u2014 Register
|
|
10069
|
+
- [ ] \`app/verify-email/page.tsx\` \u2014 Email verification
|
|
10070
|
+
- [ ] \`app/forgot-password/page.tsx\` \u2014 Forgot password
|
|
10071
|
+
- [ ] \`app/reset-password/page.tsx\` \u2014 Reset password
|
|
10072
|
+
- [ ] \`app/auth/callback/page.tsx\` \u2014 OAuth callback
|
|
10073
|
+
- [ ] \`app/account/page.tsx\` \u2014 Account dashboard
|
|
10074
|
+
|
|
10075
|
+
### Components
|
|
10076
|
+
- [ ] Header with cart count + search autocomplete
|
|
10077
|
+
- [ ] Product card, grid, variant selector
|
|
10078
|
+
- [ ] Cart item, summary, coupon input
|
|
10079
|
+
- [ ] Checkout form, shipping step, payment step
|
|
10080
|
+
- [ ] Auth forms (login, register, OAuth buttons)
|
|
10081
|
+
|
|
10082
|
+
### API Routes
|
|
10083
|
+
- [ ] \`api/store/[...path]/route.ts\` \u2014 BFF proxy
|
|
10084
|
+
- [ ] \`api/auth/\` routes \u2014 OAuth callback, me, logout, reset
|
|
10085
|
+
|
|
10086
|
+
DO NOT STOP until every item is checked. A store with missing pages is broken.`);
|
|
10087
|
+
return sections.join("\n\n---\n\n");
|
|
10088
|
+
}
|
|
10089
|
+
|
|
10090
|
+
// src/tools/build-store.ts
|
|
10091
|
+
var BUILD_STORE_NAME = "build-store";
|
|
10092
|
+
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.";
|
|
10093
|
+
var BUILD_STORE_SCHEMA = {
|
|
10094
|
+
connectionId: import_zod7.z.string().describe("Vibe-coded connection ID (starts with vc_)"),
|
|
10095
|
+
storeName: import_zod7.z.string().describe('Name of the store (e.g., "Urban Threads")'),
|
|
10096
|
+
storeType: import_zod7.z.string().describe('Type of store (e.g., "Clothing store", "Electronics shop", "Bakery")'),
|
|
10097
|
+
currency: import_zod7.z.string().optional().default("USD").describe("Store currency code (e.g., USD, ILS, EUR)"),
|
|
10098
|
+
storeStyle: import_zod7.z.string().optional().describe('Design style (e.g., "minimalist", "luxury", "playful", "dark mode")')
|
|
10099
|
+
};
|
|
10100
|
+
async function handleBuildStore(args) {
|
|
10101
|
+
let capabilities = null;
|
|
10102
|
+
try {
|
|
10103
|
+
capabilities = await fetchStoreCapabilities(args.connectionId);
|
|
10104
|
+
} catch {
|
|
10105
|
+
}
|
|
10106
|
+
const bundle = buildStoreBundle({
|
|
10107
|
+
connectionId: args.connectionId,
|
|
10108
|
+
storeName: args.storeName,
|
|
10109
|
+
storeType: args.storeType,
|
|
10110
|
+
currency: args.currency || capabilities?.store.currency || "USD",
|
|
10111
|
+
storeStyle: args.storeStyle,
|
|
10112
|
+
capabilities
|
|
10113
|
+
});
|
|
10114
|
+
return {
|
|
10115
|
+
content: [{ type: "text", text: bundle }]
|
|
10116
|
+
};
|
|
10117
|
+
}
|
|
10118
|
+
|
|
10119
|
+
// src/tools/validate-store.ts
|
|
10120
|
+
var import_zod8 = require("zod");
|
|
10121
|
+
var VALIDATE_STORE_NAME = "validate-store";
|
|
10122
|
+
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.";
|
|
10123
|
+
var VALIDATE_STORE_SCHEMA = {
|
|
10124
|
+
files: import_zod8.z.array(import_zod8.z.string()).describe('List of file paths created (e.g., ["app/page.tsx", "app/products/page.tsx", ...])')
|
|
10125
|
+
};
|
|
10126
|
+
var REQUIRED_PAGES = [
|
|
10127
|
+
{
|
|
10128
|
+
route: "/",
|
|
10129
|
+
label: "Home",
|
|
10130
|
+
patterns: ["app/page.tsx", "page.tsx", "pages/index"],
|
|
10131
|
+
critical: true
|
|
10132
|
+
},
|
|
10133
|
+
{
|
|
10134
|
+
route: "/products",
|
|
10135
|
+
label: "Product Listing",
|
|
10136
|
+
patterns: ["app/products/page.tsx", "products/page.tsx", "pages/products"],
|
|
10137
|
+
critical: true
|
|
10138
|
+
},
|
|
10139
|
+
{
|
|
10140
|
+
route: "/products/[slug]",
|
|
10141
|
+
label: "Product Detail",
|
|
10142
|
+
patterns: ["app/products/[slug]", "products/[slug]", "pages/products/[slug]"],
|
|
10143
|
+
critical: true
|
|
10144
|
+
},
|
|
10145
|
+
{
|
|
10146
|
+
route: "/cart",
|
|
10147
|
+
label: "Cart",
|
|
10148
|
+
patterns: ["app/cart/page.tsx", "cart/page.tsx", "pages/cart"],
|
|
10149
|
+
critical: true
|
|
10150
|
+
},
|
|
10151
|
+
{
|
|
10152
|
+
route: "/checkout",
|
|
10153
|
+
label: "Checkout",
|
|
10154
|
+
patterns: ["app/checkout/page.tsx", "checkout/page.tsx", "pages/checkout"],
|
|
10155
|
+
critical: true
|
|
10156
|
+
},
|
|
10157
|
+
{
|
|
10158
|
+
route: "/order-confirmation",
|
|
10159
|
+
label: "Order Confirmation",
|
|
10160
|
+
patterns: ["app/order-confirmation", "order-confirmation", "checkout/success"],
|
|
10161
|
+
critical: true
|
|
10162
|
+
},
|
|
10163
|
+
{
|
|
10164
|
+
route: "/login",
|
|
10165
|
+
label: "Login",
|
|
10166
|
+
patterns: ["app/login/page.tsx", "login/page.tsx", "pages/login"],
|
|
10167
|
+
critical: true
|
|
10168
|
+
},
|
|
10169
|
+
{
|
|
10170
|
+
route: "/register",
|
|
10171
|
+
label: "Register",
|
|
10172
|
+
patterns: ["app/register/page.tsx", "register/page.tsx", "pages/register", "signup"],
|
|
10173
|
+
critical: true
|
|
10174
|
+
},
|
|
10175
|
+
{
|
|
10176
|
+
route: "/verify-email",
|
|
10177
|
+
label: "Verify Email",
|
|
10178
|
+
patterns: ["app/verify-email", "verify-email", "email-verification"],
|
|
10179
|
+
critical: true
|
|
10180
|
+
},
|
|
10181
|
+
{
|
|
10182
|
+
route: "/forgot-password",
|
|
10183
|
+
label: "Forgot Password",
|
|
10184
|
+
patterns: ["app/forgot-password", "forgot-password"],
|
|
10185
|
+
critical: true
|
|
10186
|
+
},
|
|
10187
|
+
{
|
|
10188
|
+
route: "/reset-password",
|
|
10189
|
+
label: "Reset Password",
|
|
10190
|
+
patterns: ["app/reset-password", "reset-password"],
|
|
10191
|
+
critical: true
|
|
10192
|
+
},
|
|
10193
|
+
{
|
|
10194
|
+
route: "/auth/callback",
|
|
10195
|
+
label: "OAuth Callback",
|
|
10196
|
+
patterns: ["app/auth/callback", "auth/callback", "oauth/callback"],
|
|
10197
|
+
critical: true
|
|
10198
|
+
},
|
|
10199
|
+
{
|
|
10200
|
+
route: "/account",
|
|
10201
|
+
label: "Account",
|
|
10202
|
+
patterns: ["app/account/page.tsx", "account/page.tsx", "pages/account", "profile"],
|
|
10203
|
+
critical: true
|
|
10204
|
+
}
|
|
10205
|
+
];
|
|
10206
|
+
var REQUIRED_COMPONENTS = [
|
|
10207
|
+
{ label: "Header", patterns: ["header", "Header", "navbar", "Navbar", "nav-bar"] },
|
|
10208
|
+
{ label: "SDK Setup (lib/brainerce.ts)", patterns: ["lib/brainerce", "brainerce.ts", "sdk"] },
|
|
10209
|
+
{ label: "Store Provider", patterns: ["store-provider", "StoreProvider", "provider"] },
|
|
10210
|
+
{ label: "BFF Proxy (api/store)", patterns: ["api/store", "api/proxy"] }
|
|
10211
|
+
];
|
|
10212
|
+
function fileMatchesPattern(file, patterns) {
|
|
10213
|
+
const normalized = file.replace(/\\/g, "/").toLowerCase();
|
|
10214
|
+
return patterns.some((pattern) => normalized.includes(pattern.toLowerCase()));
|
|
10215
|
+
}
|
|
10216
|
+
async function handleValidateStore(args) {
|
|
10217
|
+
const { files } = args;
|
|
10218
|
+
const missingPages = [];
|
|
10219
|
+
const foundPages = [];
|
|
10220
|
+
for (const page of REQUIRED_PAGES) {
|
|
10221
|
+
if (files.some((f) => fileMatchesPattern(f, page.patterns))) {
|
|
10222
|
+
foundPages.push({ route: page.route, label: page.label });
|
|
10223
|
+
} else {
|
|
10224
|
+
missingPages.push({ route: page.route, label: page.label });
|
|
10225
|
+
}
|
|
10226
|
+
}
|
|
10227
|
+
const missingComponents = [];
|
|
10228
|
+
const foundComponents = [];
|
|
10229
|
+
for (const comp of REQUIRED_COMPONENTS) {
|
|
10230
|
+
if (files.some((f) => fileMatchesPattern(f, comp.patterns))) {
|
|
10231
|
+
foundComponents.push(comp.label);
|
|
10232
|
+
} else {
|
|
10233
|
+
missingComponents.push(comp.label);
|
|
10234
|
+
}
|
|
10235
|
+
}
|
|
10236
|
+
const totalRequired = REQUIRED_PAGES.length + REQUIRED_COMPONENTS.length;
|
|
10237
|
+
const totalFound = foundPages.length + foundComponents.length;
|
|
10238
|
+
const lines = [];
|
|
10239
|
+
if (missingPages.length === 0 && missingComponents.length === 0) {
|
|
10240
|
+
lines.push("# Store Validation: COMPLETE");
|
|
10241
|
+
lines.push(`All ${totalRequired} required items found. The store is complete.`);
|
|
10242
|
+
lines.push("");
|
|
10243
|
+
lines.push("## Pages Found");
|
|
10244
|
+
for (const page of foundPages) {
|
|
10245
|
+
lines.push(`- [x] ${page.route} \u2014 ${page.label}`);
|
|
10246
|
+
}
|
|
10247
|
+
lines.push("");
|
|
10248
|
+
lines.push("## Components Found");
|
|
10249
|
+
for (const comp of foundComponents) {
|
|
10250
|
+
lines.push(`- [x] ${comp}`);
|
|
10251
|
+
}
|
|
10252
|
+
} else {
|
|
10253
|
+
lines.push(`# Store Validation: INCOMPLETE (${totalFound}/${totalRequired})`);
|
|
10254
|
+
lines.push("");
|
|
10255
|
+
if (missingPages.length > 0) {
|
|
10256
|
+
lines.push(`## Missing Pages (${missingPages.length})`);
|
|
10257
|
+
for (const page of missingPages) {
|
|
10258
|
+
lines.push(
|
|
10259
|
+
`- [ ] **${page.route}** \u2014 ${page.label} \u2014 Use \`get-page-code\` with page "${page.label.toLowerCase().replace(/\s+/g, "-")}" to get the template`
|
|
10260
|
+
);
|
|
10261
|
+
}
|
|
10262
|
+
lines.push("");
|
|
10263
|
+
}
|
|
10264
|
+
if (missingComponents.length > 0) {
|
|
10265
|
+
lines.push(`## Missing Components (${missingComponents.length})`);
|
|
10266
|
+
for (const comp of missingComponents) {
|
|
10267
|
+
lines.push(`- [ ] **${comp}**`);
|
|
10268
|
+
}
|
|
10269
|
+
lines.push("");
|
|
10270
|
+
}
|
|
10271
|
+
lines.push("## Found");
|
|
10272
|
+
for (const page of foundPages) {
|
|
10273
|
+
lines.push(`- [x] ${page.route} \u2014 ${page.label}`);
|
|
10274
|
+
}
|
|
10275
|
+
for (const comp of foundComponents) {
|
|
10276
|
+
lines.push(`- [x] ${comp}`);
|
|
10277
|
+
}
|
|
10278
|
+
lines.push("");
|
|
10279
|
+
lines.push("**Build the missing pages before considering the store complete!**");
|
|
10280
|
+
}
|
|
10281
|
+
return {
|
|
10282
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
10283
|
+
};
|
|
10284
|
+
}
|
|
10285
|
+
|
|
10286
|
+
// src/tools/get-page-code.ts
|
|
10287
|
+
var import_zod9 = require("zod");
|
|
10288
|
+
var GET_PAGE_CODE_NAME = "get-page-code";
|
|
10289
|
+
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.";
|
|
10290
|
+
var GET_PAGE_CODE_SCHEMA = {
|
|
10291
|
+
pages: import_zod9.z.array(
|
|
10292
|
+
import_zod9.z.enum([
|
|
10293
|
+
"home",
|
|
10294
|
+
"products",
|
|
10295
|
+
"product-detail",
|
|
10296
|
+
"cart",
|
|
10297
|
+
"checkout",
|
|
10298
|
+
"order-confirmation",
|
|
10299
|
+
"login",
|
|
10300
|
+
"register",
|
|
10301
|
+
"verify-email",
|
|
10302
|
+
"forgot-password",
|
|
10303
|
+
"reset-password",
|
|
10304
|
+
"oauth-callback",
|
|
10305
|
+
"account",
|
|
10306
|
+
"header",
|
|
10307
|
+
"setup"
|
|
10308
|
+
])
|
|
10309
|
+
).describe('Pages to get code for (e.g., ["checkout", "login", "account"])')
|
|
10310
|
+
};
|
|
10311
|
+
var PAGE_TO_FILES = {
|
|
10312
|
+
setup: [
|
|
10313
|
+
{ path: "src/lib/brainerce.ts.ejs", label: "SDK Client" },
|
|
10314
|
+
{ path: "src/lib/auth.ts", label: "Auth Helpers" },
|
|
10315
|
+
{ path: "src/lib/utils.ts", label: "Utilities" },
|
|
10316
|
+
{ path: "src/providers/store-provider.tsx.ejs", label: "Store Provider" },
|
|
10317
|
+
{ path: "src/app/layout.tsx.ejs", label: "Root Layout" },
|
|
10318
|
+
{ path: "src/middleware.ts", label: "Middleware" },
|
|
10319
|
+
{ path: "src/app/api/store/[...path]/route.ts", label: "BFF Proxy" }
|
|
10320
|
+
],
|
|
10321
|
+
home: [
|
|
10322
|
+
{ path: "src/app/page.tsx", label: "Home Page" },
|
|
10323
|
+
{ path: "src/components/products/product-card.tsx", label: "Product Card" },
|
|
10324
|
+
{ path: "src/components/products/product-grid.tsx", label: "Product Grid" },
|
|
10325
|
+
{ path: "src/components/shared/price-display.tsx", label: "Price Display" }
|
|
10326
|
+
],
|
|
10327
|
+
products: [
|
|
10328
|
+
{ path: "src/app/products/page.tsx", label: "Products Page" },
|
|
10329
|
+
{ path: "src/components/products/product-card.tsx", label: "Product Card" },
|
|
10330
|
+
{ path: "src/components/products/product-grid.tsx", label: "Product Grid" },
|
|
10331
|
+
{ path: "src/components/shared/price-display.tsx", label: "Price Display" }
|
|
10332
|
+
],
|
|
10333
|
+
"product-detail": [
|
|
10334
|
+
{ path: "src/app/products/[slug]/page.tsx", label: "Product Detail Page" },
|
|
10335
|
+
{ path: "src/components/products/variant-selector.tsx", label: "Variant Selector" },
|
|
10336
|
+
{ path: "src/components/products/stock-badge.tsx", label: "Stock Badge" },
|
|
10337
|
+
{ path: "src/components/products/discount-badge.tsx", label: "Discount Badge" },
|
|
10338
|
+
{ path: "src/components/shared/price-display.tsx", label: "Price Display" }
|
|
10339
|
+
],
|
|
10340
|
+
cart: [
|
|
10341
|
+
{ path: "src/app/cart/page.tsx", label: "Cart Page" },
|
|
10342
|
+
{ path: "src/components/cart/cart-item.tsx", label: "Cart Item" },
|
|
10343
|
+
{ path: "src/components/cart/cart-summary.tsx", label: "Cart Summary" },
|
|
10344
|
+
{ path: "src/components/cart/coupon-input.tsx", label: "Coupon Input" },
|
|
10345
|
+
{ path: "src/components/cart/cart-nudges.tsx", label: "Cart Nudges" },
|
|
10346
|
+
{ path: "src/components/cart/reservation-countdown.tsx", label: "Reservation Countdown" }
|
|
10347
|
+
],
|
|
10348
|
+
checkout: [
|
|
10349
|
+
{ path: "src/app/checkout/page.tsx", label: "Checkout Page" },
|
|
10350
|
+
{ path: "src/components/checkout/checkout-form.tsx", label: "Checkout Form" },
|
|
10351
|
+
{ path: "src/components/checkout/shipping-step.tsx", label: "Shipping Step" },
|
|
10352
|
+
{ path: "src/components/checkout/payment-step.tsx", label: "Payment Step" },
|
|
10353
|
+
{ path: "src/components/checkout/tax-display.tsx", label: "Tax Display" }
|
|
10354
|
+
],
|
|
10355
|
+
"order-confirmation": [
|
|
10356
|
+
{ path: "src/app/order-confirmation/page.tsx", label: "Order Confirmation Page" }
|
|
10357
|
+
],
|
|
10358
|
+
login: [
|
|
10359
|
+
{ path: "src/app/login/page.tsx", label: "Login Page" },
|
|
10360
|
+
{ path: "src/components/auth/login-form.tsx", label: "Login Form" },
|
|
10361
|
+
{ path: "src/components/auth/oauth-buttons.tsx", label: "OAuth Buttons" }
|
|
10362
|
+
],
|
|
10363
|
+
register: [
|
|
10364
|
+
{ path: "src/app/register/page.tsx", label: "Register Page" },
|
|
10365
|
+
{ path: "src/components/auth/register-form.tsx", label: "Register Form" },
|
|
10366
|
+
{ path: "src/components/auth/oauth-buttons.tsx", label: "OAuth Buttons" }
|
|
10367
|
+
],
|
|
10368
|
+
"verify-email": [{ path: "src/app/verify-email/page.tsx", label: "Verify Email Page" }],
|
|
10369
|
+
"forgot-password": [{ path: "src/app/forgot-password/page.tsx", label: "Forgot Password Page" }],
|
|
10370
|
+
"reset-password": [
|
|
10371
|
+
{ path: "src/app/reset-password/page.tsx", label: "Reset Password Page" },
|
|
10372
|
+
{ path: "src/app/api/auth/reset-callback/route.ts", label: "Reset Callback API" },
|
|
10373
|
+
{ path: "src/app/api/auth/reset-password/route.ts", label: "Reset Password API" }
|
|
10374
|
+
],
|
|
10375
|
+
"oauth-callback": [
|
|
10376
|
+
{ path: "src/app/auth/callback/page.tsx", label: "OAuth Callback Page" },
|
|
10377
|
+
{ path: "src/app/api/auth/oauth-callback/route.ts", label: "OAuth Callback API" }
|
|
10378
|
+
],
|
|
10379
|
+
account: [
|
|
10380
|
+
{ path: "src/app/account/page.tsx", label: "Account Page" },
|
|
10381
|
+
{ path: "src/components/account/profile-section.tsx", label: "Profile Section" },
|
|
10382
|
+
{ path: "src/components/account/order-history.tsx", label: "Order History" }
|
|
10383
|
+
],
|
|
10384
|
+
header: [
|
|
10385
|
+
{ path: "src/components/layout/header.tsx", label: "Header" },
|
|
10386
|
+
{ path: "src/components/layout/footer.tsx", label: "Footer" },
|
|
10387
|
+
{ path: "src/hooks/use-search.ts", label: "Search Hook" }
|
|
10388
|
+
]
|
|
10389
|
+
};
|
|
10390
|
+
async function handleGetPageCode(args) {
|
|
10391
|
+
const emitted = /* @__PURE__ */ new Set();
|
|
10392
|
+
const results = [];
|
|
10393
|
+
for (const page of args.pages) {
|
|
10394
|
+
const files = PAGE_TO_FILES[page];
|
|
10395
|
+
if (!files) {
|
|
10396
|
+
results.push(
|
|
10397
|
+
`# Unknown page: "${page}"
|
|
10398
|
+
Available: ${Object.keys(PAGE_TO_FILES).join(", ")}`
|
|
10399
|
+
);
|
|
10400
|
+
continue;
|
|
10401
|
+
}
|
|
10402
|
+
const pageResults = [];
|
|
10403
|
+
for (const file of files) {
|
|
10404
|
+
if (emitted.has(file.path)) continue;
|
|
10405
|
+
emitted.add(file.path);
|
|
10406
|
+
const content = EMBEDDED_TEMPLATES[file.path];
|
|
10407
|
+
if (!content) continue;
|
|
10408
|
+
const displayPath = file.path.replace(/\.ejs$/, "");
|
|
10409
|
+
pageResults.push(`// \u2500\u2500 ${displayPath} (${file.label}) \u2500\u2500
|
|
10410
|
+
${content}`);
|
|
10411
|
+
}
|
|
10412
|
+
if (pageResults.length > 0) {
|
|
10413
|
+
results.push(`# ${page}
|
|
10414
|
+
|
|
10415
|
+
${pageResults.join("\n\n")}`);
|
|
10416
|
+
}
|
|
10417
|
+
}
|
|
10418
|
+
if (results.length === 0) {
|
|
10419
|
+
return {
|
|
10420
|
+
content: [
|
|
10421
|
+
{
|
|
10422
|
+
type: "text",
|
|
10423
|
+
text: `No files found. Available pages: ${Object.keys(PAGE_TO_FILES).join(", ")}`
|
|
10424
|
+
}
|
|
10425
|
+
]
|
|
10426
|
+
};
|
|
10427
|
+
}
|
|
10428
|
+
return {
|
|
10429
|
+
content: [
|
|
10430
|
+
{
|
|
10431
|
+
type: "text",
|
|
10432
|
+
text: `# Page Code (${args.pages.join(", ")})
|
|
10433
|
+
|
|
10434
|
+
Production-ready template code. Use as-is \u2014 customize only the design/styling.
|
|
10435
|
+
|
|
10436
|
+
${results.join("\n\n---\n\n")}`
|
|
10437
|
+
}
|
|
10438
|
+
]
|
|
10439
|
+
};
|
|
10440
|
+
}
|
|
10441
|
+
|
|
9024
10442
|
// src/resources/sdk-types.ts
|
|
9025
10443
|
var SDK_TYPES_URI = "brainerce://sdk/types";
|
|
9026
10444
|
var SDK_TYPES_NAME = "Brainerce SDK Types";
|
|
@@ -9175,24 +10593,57 @@ async function handleProjectTemplate(uri) {
|
|
|
9175
10593
|
}
|
|
9176
10594
|
|
|
9177
10595
|
// src/prompts/create-store.ts
|
|
9178
|
-
var
|
|
10596
|
+
var import_zod10 = require("zod");
|
|
9179
10597
|
var CREATE_STORE_NAME = "create-store";
|
|
9180
|
-
var CREATE_STORE_DESCRIPTION = "Generate a personalized prompt for building a complete Brainerce e-commerce store.
|
|
10598
|
+
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.";
|
|
9181
10599
|
var CREATE_STORE_SCHEMA = {
|
|
9182
|
-
connectionId:
|
|
9183
|
-
storeName:
|
|
9184
|
-
storeType:
|
|
9185
|
-
currency:
|
|
9186
|
-
storeStyle:
|
|
10600
|
+
connectionId: import_zod10.z.string().describe("Vibe-coded connection ID (starts with vc_)"),
|
|
10601
|
+
storeName: import_zod10.z.string().describe('Name of the store (e.g., "Urban Threads")'),
|
|
10602
|
+
storeType: import_zod10.z.string().describe('Type of store (e.g., "Clothing store", "Electronics shop", "Bakery")'),
|
|
10603
|
+
currency: import_zod10.z.string().optional().default("USD").describe("Store currency code (e.g., USD, ILS, EUR)"),
|
|
10604
|
+
storeStyle: import_zod10.z.string().optional().describe('Design style (e.g., "minimalist", "luxury", "playful", "dark mode")')
|
|
9187
10605
|
};
|
|
9188
10606
|
function handleCreateStore(args) {
|
|
9189
|
-
const
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
10607
|
+
const styleDesc = args.storeStyle ? ` with a ${args.storeStyle} design style` : "";
|
|
10608
|
+
const prompt = `# Build a Brainerce E-Commerce Store
|
|
10609
|
+
|
|
10610
|
+
## Task
|
|
10611
|
+
Build a complete **${args.storeType}** called **${args.storeName}**${styleDesc}.
|
|
10612
|
+
Connection ID: \`${args.connectionId}\` | Currency: ${args.currency || "USD"}
|
|
10613
|
+
|
|
10614
|
+
## Step 1: Get Everything You Need (ONE call)
|
|
10615
|
+
Call the \`build-store\` tool with:
|
|
10616
|
+
- connectionId: "${args.connectionId}"
|
|
10617
|
+
- storeName: "${args.storeName}"
|
|
10618
|
+
- storeType: "${args.storeType}"
|
|
10619
|
+
- currency: "${args.currency || "USD"}"${args.storeStyle ? `
|
|
10620
|
+
- storeStyle: "${args.storeStyle}"` : ""}
|
|
10621
|
+
|
|
10622
|
+
This returns ALL the code templates, critical rules, type reference, and component code you need.
|
|
10623
|
+
|
|
10624
|
+
## Step 2: Build ALL 13 Pages + Header
|
|
10625
|
+
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.
|
|
10626
|
+
|
|
10627
|
+
DO NOT STOP until all 13 pages + header are implemented:
|
|
10628
|
+
1. \`/\` \u2014 Home
|
|
10629
|
+
2. \`/products\` \u2014 Product listing
|
|
10630
|
+
3. \`/products/[slug]\` \u2014 Product detail
|
|
10631
|
+
4. \`/cart\` \u2014 Cart
|
|
10632
|
+
5. \`/checkout\` \u2014 Checkout
|
|
10633
|
+
6. \`/order-confirmation\` \u2014 Order confirmation
|
|
10634
|
+
7. \`/login\` \u2014 Login
|
|
10635
|
+
8. \`/register\` \u2014 Register
|
|
10636
|
+
9. \`/verify-email\` \u2014 Email verification
|
|
10637
|
+
10. \`/forgot-password\` \u2014 Forgot password
|
|
10638
|
+
11. \`/reset-password\` \u2014 Reset password
|
|
10639
|
+
12. \`/auth/callback\` \u2014 OAuth callback
|
|
10640
|
+
13. \`/account\` \u2014 Account dashboard
|
|
10641
|
+
14. Header component with cart count + search
|
|
10642
|
+
|
|
10643
|
+
## Step 3: Validate
|
|
10644
|
+
After building all pages, call \`validate-store\` with the list of files you created to verify nothing is missing.
|
|
10645
|
+
|
|
10646
|
+
**Start now \u2014 call \`build-store\` to get all the templates.**`;
|
|
9196
10647
|
return {
|
|
9197
10648
|
messages: [
|
|
9198
10649
|
{
|
|
@@ -9207,11 +10658,11 @@ function handleCreateStore(args) {
|
|
|
9207
10658
|
}
|
|
9208
10659
|
|
|
9209
10660
|
// src/prompts/add-feature.ts
|
|
9210
|
-
var
|
|
10661
|
+
var import_zod11 = require("zod");
|
|
9211
10662
|
var ADD_FEATURE_NAME = "add-feature";
|
|
9212
10663
|
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.";
|
|
9213
10664
|
var ADD_FEATURE_SCHEMA = {
|
|
9214
|
-
feature:
|
|
10665
|
+
feature: import_zod11.z.enum([
|
|
9215
10666
|
"products",
|
|
9216
10667
|
"cart",
|
|
9217
10668
|
"checkout",
|
|
@@ -9224,8 +10675,8 @@ var ADD_FEATURE_SCHEMA = {
|
|
|
9224
10675
|
"search",
|
|
9225
10676
|
"tax"
|
|
9226
10677
|
]).describe("The feature to add"),
|
|
9227
|
-
connectionId:
|
|
9228
|
-
currency:
|
|
10678
|
+
connectionId: import_zod11.z.string().optional().describe("Vibe-coded connection ID (starts with vc_)"),
|
|
10679
|
+
currency: import_zod11.z.string().optional().default("USD").describe("Store currency code")
|
|
9229
10680
|
};
|
|
9230
10681
|
var FEATURE_TO_TOPICS = {
|
|
9231
10682
|
products: { topics: ["products"], domains: ["products"] },
|
|
@@ -9294,7 +10745,7 @@ ${types}
|
|
|
9294
10745
|
function createServer() {
|
|
9295
10746
|
const server = new import_mcp.McpServer({
|
|
9296
10747
|
name: "brainerce",
|
|
9297
|
-
version: "
|
|
10748
|
+
version: "2.0.0"
|
|
9298
10749
|
});
|
|
9299
10750
|
server.tool(GET_SDK_DOCS_NAME, GET_SDK_DOCS_DESCRIPTION, GET_SDK_DOCS_SCHEMA, handleGetSdkDocs);
|
|
9300
10751
|
server.tool(
|
|
@@ -9321,6 +10772,25 @@ function createServer() {
|
|
|
9321
10772
|
GET_STORE_INFO_SCHEMA,
|
|
9322
10773
|
handleGetStoreInfo
|
|
9323
10774
|
);
|
|
10775
|
+
server.tool(
|
|
10776
|
+
GET_STORE_CAPABILITIES_NAME,
|
|
10777
|
+
GET_STORE_CAPABILITIES_DESCRIPTION,
|
|
10778
|
+
GET_STORE_CAPABILITIES_SCHEMA,
|
|
10779
|
+
handleGetStoreCapabilities
|
|
10780
|
+
);
|
|
10781
|
+
server.tool(BUILD_STORE_NAME, BUILD_STORE_DESCRIPTION, BUILD_STORE_SCHEMA, handleBuildStore);
|
|
10782
|
+
server.tool(
|
|
10783
|
+
VALIDATE_STORE_NAME,
|
|
10784
|
+
VALIDATE_STORE_DESCRIPTION,
|
|
10785
|
+
VALIDATE_STORE_SCHEMA,
|
|
10786
|
+
handleValidateStore
|
|
10787
|
+
);
|
|
10788
|
+
server.tool(
|
|
10789
|
+
GET_PAGE_CODE_NAME,
|
|
10790
|
+
GET_PAGE_CODE_DESCRIPTION,
|
|
10791
|
+
GET_PAGE_CODE_SCHEMA,
|
|
10792
|
+
handleGetPageCode
|
|
10793
|
+
);
|
|
9324
10794
|
server.resource(
|
|
9325
10795
|
SDK_TYPES_NAME,
|
|
9326
10796
|
SDK_TYPES_URI,
|