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