@brainerce/mcp-server 1.3.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/http.js +2750 -1280
- package/dist/bin/stdio.js +2750 -1280
- package/dist/index.d.mts +50 -1
- package/dist/index.d.ts +50 -1
- package/dist/index.js +2754 -1280
- package/dist/index.mjs +2752 -1280
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -73,7 +73,7 @@ function getTypeQuickReference() {
|
|
|
73
73
|
- \`SetShippingAddressDto.email\` is **required** \u2014 always include email
|
|
74
74
|
|
|
75
75
|
**Mandatory helpers (import from \`'brainerce'\`):**
|
|
76
|
-
\`getCartTotals\`, \`getCartItemName\`, \`getCartItemImage\`, \`formatPrice\`, \`getProductPriceInfo\`, \`getVariantPrice\`, \`getStockStatus\`, \`getVariantOptions\`, \`getDescriptionContent\`, \`getProductMetafieldValue\``;
|
|
76
|
+
\`getCartTotals\`, \`getCartItemName\`, \`getCartItemImage\`, \`formatPrice\`, \`getProductPriceInfo\`, \`getVariantPrice\`, \`getStockStatus\`, \`getVariantOptions\`, \`getProductSwatches\`, \`getDescriptionContent\`, \`getProductMetafieldValue\``;
|
|
77
77
|
}
|
|
78
78
|
function getSdkSetupSection(connectionId) {
|
|
79
79
|
return `## SDK Setup
|
|
@@ -561,6 +561,25 @@ const material = getProductMetafieldValue(product, 'material');
|
|
|
561
561
|
|
|
562
562
|
**Metafield fields:** \`definitionName\` (display label), \`definitionKey\` (lookup key), \`value\`, \`type\` (IMAGE, GALLERY, URL, COLOR, BOOLEAN, DATE, DATETIME, TEXT, TEXTAREA, NUMBER, DIMENSION, WEIGHT, JSON)
|
|
563
563
|
|
|
564
|
+
### Downloadable / Digital Products
|
|
565
|
+
|
|
566
|
+
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.
|
|
567
|
+
|
|
568
|
+
\`\`\`typescript
|
|
569
|
+
import type { DownloadFile } from 'brainerce';
|
|
570
|
+
|
|
571
|
+
// Check if product is digital
|
|
572
|
+
if (product.isDownloadable) {
|
|
573
|
+
// Show "Instant Download" badge instead of StockBadge
|
|
574
|
+
// List files: product.downloads?.map((file: DownloadFile) => file.name)
|
|
575
|
+
// Show "Download available after purchase" note
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// After purchase \u2014 get download links from order
|
|
579
|
+
const downloads = await client.getOrderDownloads(orderId);
|
|
580
|
+
// downloads[]: { fileName, downloadUrl, downloadsUsed, downloadLimit, expiresAt }
|
|
581
|
+
\`\`\`
|
|
582
|
+
|
|
564
583
|
### Categories
|
|
565
584
|
|
|
566
585
|
\`\`\`typescript
|
|
@@ -1141,60 +1160,6 @@ ${getAdminApiSection()}
|
|
|
1141
1160
|
|
|
1142
1161
|
Start building with the SDK!`;
|
|
1143
1162
|
}
|
|
1144
|
-
function buildMcpStorePrompt(options) {
|
|
1145
|
-
const { connectionId, storeName, currency, storeType, storeStyle } = options;
|
|
1146
|
-
const styleDesc = storeStyle ? ` with a ${storeStyle} aesthetic` : "";
|
|
1147
|
-
return `# Build a Brainerce E-Commerce Store
|
|
1148
|
-
|
|
1149
|
-
## Task
|
|
1150
|
-
Build a **${storeType}** called **${storeName}**${styleDesc}.
|
|
1151
|
-
Use the Brainerce SDK (\`npm install brainerce\`) to fetch all data \u2014 never hardcode products.
|
|
1152
|
-
|
|
1153
|
-
## Connection
|
|
1154
|
-
- **Connection ID:** \`${connectionId}\`
|
|
1155
|
-
- **Currency:** ${currency}
|
|
1156
|
-
|
|
1157
|
-
${getCriticalRulesSection()}
|
|
1158
|
-
|
|
1159
|
-
${getTypeQuickReference()}
|
|
1160
|
-
|
|
1161
|
-
## Required Pages
|
|
1162
|
-
- \`/\` \u2014 Home (hero, featured products, categories)
|
|
1163
|
-
- \`/products\` \u2014 Product list with infinite scroll
|
|
1164
|
-
- \`/products/[slug]\` \u2014 Product detail (images, variants, add to cart)
|
|
1165
|
-
- \`/cart\` \u2014 Cart with checkboxes, quantities, coupon input
|
|
1166
|
-
- \`/checkout\` \u2014 Customer info \u2192 Shipping \u2192 Payment
|
|
1167
|
-
- \`/order-confirmation\` \u2014 MUST call \`waitForOrder()\` + \`handlePaymentSuccess()\`
|
|
1168
|
-
- \`/login\` \u2014 Email/password + social login
|
|
1169
|
-
- \`/register\` \u2014 Registration + social signup
|
|
1170
|
-
- \`/verify-email\` \u2014 6-digit code verification (always build, even if disabled)
|
|
1171
|
-
- \`/auth/callback\` \u2014 OAuth redirect handler
|
|
1172
|
-
- \`/account\` \u2014 Profile + order history (protected)
|
|
1173
|
-
- Header \u2014 Logo, nav, cart count, search bar with autocomplete
|
|
1174
|
-
|
|
1175
|
-
## How to Build (Use MCP Tools!)
|
|
1176
|
-
Build pages in this order. For each page, call the MCP tools to get the code and docs you need:
|
|
1177
|
-
|
|
1178
|
-
1. **SDK Setup** \u2014 \`get-code-example\` \u2192 \`sdk-setup\`, then \`store-provider\`
|
|
1179
|
-
2. **Layout** \u2014 \`get-code-example\` \u2192 \`layout\`
|
|
1180
|
-
3. **Header** \u2014 \`get-code-example\` \u2192 \`header\`
|
|
1181
|
-
4. **Home** \u2014 \`get-code-example\` \u2192 \`home-page\`
|
|
1182
|
-
5. **Products** \u2014 \`get-code-example\` \u2192 \`product-listing\`, then \`product-detail\`
|
|
1183
|
-
6. **Cart** \u2014 \`get-code-example\` \u2192 \`cart-page\`, then \`coupon-input\`
|
|
1184
|
-
7. **Checkout** \u2014 \`get-code-example\` \u2192 \`checkout-page\` | \`get-sdk-docs\` \u2192 topic: \`checkout\`
|
|
1185
|
-
8. **Payment** \u2014 \`get-code-example\` \u2192 \`stripe-payment\` / \`grow-payment\` / \`paypal-payment\`
|
|
1186
|
-
9. **Order Confirmation** \u2014 \`get-code-example\` \u2192 \`order-confirmation\`
|
|
1187
|
-
10. **Auth** \u2014 \`get-code-example\` \u2192 \`login-page\`, \`register-page\`, \`verify-email\`, \`oauth-callback\`
|
|
1188
|
-
11. **Account** \u2014 \`get-code-example\` \u2192 \`account-page\`
|
|
1189
|
-
|
|
1190
|
-
**Additional tools:**
|
|
1191
|
-
- \`get-sdk-docs\` \u2014 Detailed API docs per topic (setup, products, cart, checkout, payment, auth, inventory, discounts, tax, etc.)
|
|
1192
|
-
- \`get-type-definitions\` \u2014 Full TypeScript type definitions for any SDK type
|
|
1193
|
-
- \`get-required-pages\` \u2014 Complete pages checklist with design guidelines
|
|
1194
|
-
- \`get-store-info\` \u2014 Fetch live store data (name, currency, product count)
|
|
1195
|
-
|
|
1196
|
-
Start with step 1 \u2014 call \`get-code-example\` with useCase \`sdk-setup\` now!`;
|
|
1197
|
-
}
|
|
1198
1163
|
function getSectionByTopic(topic, connectionId, currency) {
|
|
1199
1164
|
const cid = connectionId || "vc_YOUR_CONNECTION_ID";
|
|
1200
1165
|
const cur = currency || "USD";
|
|
@@ -1297,10 +1262,21 @@ interface Product {
|
|
|
1297
1262
|
brands?: Array<{ id: string; name: string }>;
|
|
1298
1263
|
tags?: string[];
|
|
1299
1264
|
metafields?: ProductMetafield[];
|
|
1265
|
+
productAttributeOptions?: Array<{
|
|
1266
|
+
id: string;
|
|
1267
|
+
attributeId: string;
|
|
1268
|
+
attributeOptionId: string;
|
|
1269
|
+
platform: string;
|
|
1270
|
+
attribute: { id: string; name: string; displayType?: 'DEFAULT' | 'COLOR_SWATCH' | 'IMAGE_SWATCH' } | null;
|
|
1271
|
+
attributeOption: { id: string; name: string; value?: string | null; swatchColor?: string | null; swatchColor2?: string | null; swatchImageUrl?: string | null } | null;
|
|
1272
|
+
}>;
|
|
1300
1273
|
createdAt: string;
|
|
1301
1274
|
updatedAt: string;
|
|
1302
1275
|
}
|
|
1303
1276
|
|
|
1277
|
+
// Use getProductSwatches(product) to get grouped swatch data for storefront rendering.
|
|
1278
|
+
// Returns Array<{ attributeName, displayType, options: Array<{ name, swatchColor?, swatchColor2?, swatchImageUrl? }> }>
|
|
1279
|
+
|
|
1304
1280
|
interface ProductImage {
|
|
1305
1281
|
url: string;
|
|
1306
1282
|
position?: number;
|
|
@@ -1348,7 +1324,7 @@ interface ProductQueryParams {
|
|
|
1348
1324
|
page?: number;
|
|
1349
1325
|
limit?: number;
|
|
1350
1326
|
search?: string;
|
|
1351
|
-
status?: 'active' | 'draft'
|
|
1327
|
+
status?: 'active' | 'draft';
|
|
1352
1328
|
categories?: string | string[];
|
|
1353
1329
|
brands?: string | string[];
|
|
1354
1330
|
tags?: string | string[];
|
|
@@ -1908,6 +1884,17 @@ export function setStoredCartId(cartId: string | null): void {
|
|
|
1908
1884
|
export function initClient(): BrainerceClient {
|
|
1909
1885
|
return getClient();
|
|
1910
1886
|
}
|
|
1887
|
+
|
|
1888
|
+
// Server-side client \u2014 calls backend directly (no proxy needed for public data)
|
|
1889
|
+
// Used by Server Components for SSR data fetching (generateMetadata, page rendering)
|
|
1890
|
+
export function getServerClient(): BrainerceClient {
|
|
1891
|
+
const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
|
|
1892
|
+
return new BrainerceClient({
|
|
1893
|
+
connectionId: CONNECTION_ID,
|
|
1894
|
+
baseUrl: apiUrl,
|
|
1895
|
+
origin: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
|
1896
|
+
});
|
|
1897
|
+
}
|
|
1911
1898
|
`,
|
|
1912
1899
|
"src/lib/auth.ts": `/**
|
|
1913
1900
|
* Client-side auth helpers that call the BFF proxy API routes.
|
|
@@ -1992,6 +1979,7 @@ export async function proxyRegister(data: {
|
|
|
1992
1979
|
lastName: string;
|
|
1993
1980
|
email: string;
|
|
1994
1981
|
password: string;
|
|
1982
|
+
acceptsMarketing?: boolean;
|
|
1995
1983
|
}): Promise<RegisterResult> {
|
|
1996
1984
|
const response = await fetch(\`/api/store/api/vc/\${CONNECTION_ID}/customers/register\`, {
|
|
1997
1985
|
method: 'POST',
|
|
@@ -2347,12 +2335,34 @@ import './globals.css';
|
|
|
2347
2335
|
|
|
2348
2336
|
<%- fontVariable %>
|
|
2349
2337
|
|
|
2338
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
2339
|
+
|
|
2350
2340
|
export const metadata: Metadata = {
|
|
2351
|
-
|
|
2341
|
+
metadataBase: new URL(baseUrl),
|
|
2342
|
+
title: {
|
|
2343
|
+
default: 'My Store',
|
|
2344
|
+
template: \`%s | My Store\`,
|
|
2345
|
+
},
|
|
2352
2346
|
description: 'My Store',
|
|
2347
|
+
alternates: {
|
|
2348
|
+
canonical: '/',
|
|
2349
|
+
},
|
|
2353
2350
|
openGraph: {
|
|
2351
|
+
siteName: 'My Store',
|
|
2354
2352
|
locale: '<%= ogLocale %>',
|
|
2353
|
+
type: 'website',
|
|
2355
2354
|
},
|
|
2355
|
+
robots: {
|
|
2356
|
+
index: true,
|
|
2357
|
+
follow: true,
|
|
2358
|
+
},
|
|
2359
|
+
};
|
|
2360
|
+
|
|
2361
|
+
const organizationJsonLd = {
|
|
2362
|
+
'@context': 'https://schema.org',
|
|
2363
|
+
'@type': 'Organization',
|
|
2364
|
+
name: 'My Store',
|
|
2365
|
+
url: baseUrl,
|
|
2356
2366
|
};
|
|
2357
2367
|
|
|
2358
2368
|
export default function RootLayout({
|
|
@@ -2362,6 +2372,12 @@ export default function RootLayout({
|
|
|
2362
2372
|
}) {
|
|
2363
2373
|
return (
|
|
2364
2374
|
<html lang="<%= language %>" dir="<%= direction %>">
|
|
2375
|
+
<head>
|
|
2376
|
+
<script
|
|
2377
|
+
type="application/ld+json"
|
|
2378
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
|
|
2379
|
+
/>
|
|
2380
|
+
</head>
|
|
2365
2381
|
<body className={font.className}>
|
|
2366
2382
|
<StoreProvider>
|
|
2367
2383
|
<div className="min-h-screen flex flex-col">
|
|
@@ -2406,6 +2422,7 @@ const sortOptions: SortOption[] = [
|
|
|
2406
2422
|
interface CategoryNode {
|
|
2407
2423
|
id: string;
|
|
2408
2424
|
name: string;
|
|
2425
|
+
image?: string | null;
|
|
2409
2426
|
parentId?: string | null;
|
|
2410
2427
|
children: CategoryNode[];
|
|
2411
2428
|
}
|
|
@@ -2446,24 +2463,24 @@ function ChevronDown({ className }: { className?: string }) {
|
|
|
2446
2463
|
|
|
2447
2464
|
/** Recursive dropdown items for nested categories */
|
|
2448
2465
|
function CategoryDropdownItems({
|
|
2449
|
-
|
|
2466
|
+
items,
|
|
2450
2467
|
depth,
|
|
2451
2468
|
selectedId,
|
|
2452
2469
|
onSelect,
|
|
2453
2470
|
}: {
|
|
2454
|
-
|
|
2471
|
+
items: CategoryNode[];
|
|
2455
2472
|
depth: number;
|
|
2456
2473
|
selectedId: string;
|
|
2457
2474
|
onSelect: (id: string) => void;
|
|
2458
2475
|
}) {
|
|
2459
2476
|
return (
|
|
2460
2477
|
<>
|
|
2461
|
-
{
|
|
2478
|
+
{items.map((child) => (
|
|
2462
2479
|
<div key={child.id}>
|
|
2463
2480
|
<button
|
|
2464
2481
|
onClick={() => onSelect(child.id)}
|
|
2465
2482
|
className={cn(
|
|
2466
|
-
'w-full
|
|
2483
|
+
'hover:bg-muted w-full px-4 py-2 text-start text-sm transition-colors',
|
|
2467
2484
|
selectedId === child.id && 'bg-primary/10 text-primary font-medium'
|
|
2468
2485
|
)}
|
|
2469
2486
|
style={{ paddingInlineStart: \`\${(depth + 1) * 16}px\` }}
|
|
@@ -2472,7 +2489,7 @@ function CategoryDropdownItems({
|
|
|
2472
2489
|
</button>
|
|
2473
2490
|
{child.children.length > 0 && (
|
|
2474
2491
|
<CategoryDropdownItems
|
|
2475
|
-
|
|
2492
|
+
items={child.children}
|
|
2476
2493
|
depth={depth + 1}
|
|
2477
2494
|
selectedId={selectedId}
|
|
2478
2495
|
onSelect={onSelect}
|
|
@@ -2559,9 +2576,7 @@ function CategoryChip({
|
|
|
2559
2576
|
{'\xB7'} {selectedChildName}
|
|
2560
2577
|
</span>
|
|
2561
2578
|
)}
|
|
2562
|
-
<ChevronDown
|
|
2563
|
-
className={cn('transition-transform ms-0.5', open && 'rotate-180')}
|
|
2564
|
-
/>
|
|
2579
|
+
<ChevronDown className={cn('ms-0.5 transition-transform', open && 'rotate-180')} />
|
|
2565
2580
|
</button>
|
|
2566
2581
|
|
|
2567
2582
|
{open && (
|
|
@@ -2573,7 +2588,7 @@ function CategoryChip({
|
|
|
2573
2588
|
setOpen(false);
|
|
2574
2589
|
}}
|
|
2575
2590
|
className={cn(
|
|
2576
|
-
'w-full
|
|
2591
|
+
'hover:bg-muted w-full px-4 py-2 text-start text-sm font-medium transition-colors',
|
|
2577
2592
|
selectedId === category.id && 'bg-primary/10 text-primary'
|
|
2578
2593
|
)}
|
|
2579
2594
|
>
|
|
@@ -2581,11 +2596,9 @@ function CategoryChip({
|
|
|
2581
2596
|
</button>
|
|
2582
2597
|
<div className="bg-border mx-2 h-px" />
|
|
2583
2598
|
{/* Recursive children */}
|
|
2584
|
-
<div
|
|
2585
|
-
onClick={() => setOpen(false)}
|
|
2586
|
-
>
|
|
2599
|
+
<div onClick={() => setOpen(false)}>
|
|
2587
2600
|
<CategoryDropdownItems
|
|
2588
|
-
|
|
2601
|
+
items={category.children}
|
|
2589
2602
|
depth={0}
|
|
2590
2603
|
selectedId={selectedId}
|
|
2591
2604
|
onSelect={onSelect}
|
|
@@ -2810,513 +2823,71 @@ export default function ProductsPage() {
|
|
|
2810
2823
|
);
|
|
2811
2824
|
}
|
|
2812
2825
|
`,
|
|
2813
|
-
"src/app/products/[slug]/page.tsx": `
|
|
2826
|
+
"src/app/products/[slug]/page.tsx": `import type { Metadata } from 'next';
|
|
2827
|
+
import { notFound } from 'next/navigation';
|
|
2828
|
+
import { getServerClient } from '@/lib/brainerce';
|
|
2829
|
+
import { ProductJsonLd } from '@/components/seo/product-json-ld';
|
|
2830
|
+
import { ProductClientSection } from './product-client-section';
|
|
2831
|
+
|
|
2832
|
+
type Props = {
|
|
2833
|
+
params: Promise<{ slug: string }>;
|
|
2834
|
+
};
|
|
2814
2835
|
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
import Image from 'next/image';
|
|
2818
|
-
import Link from 'next/link';
|
|
2819
|
-
import type {
|
|
2820
|
-
Product,
|
|
2821
|
-
ProductVariant,
|
|
2822
|
-
ProductImage,
|
|
2823
|
-
ProductMetafield,
|
|
2824
|
-
ProductRecommendationsResponse,
|
|
2825
|
-
DownloadFile,
|
|
2826
|
-
} from 'brainerce';
|
|
2836
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
2837
|
+
const { slug } = await params;
|
|
2827
2838
|
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
import { useCart } from '@/providers/store-provider';
|
|
2834
|
-
import { PriceDisplay } from '@/components/shared/price-display';
|
|
2835
|
-
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
2836
|
-
import { VariantSelector } from '@/components/products/variant-selector';
|
|
2837
|
-
import { StockBadge } from '@/components/products/stock-badge';
|
|
2838
|
-
import { RecommendationSection } from '@/components/products/recommendation-section';
|
|
2839
|
-
import { useTranslations } from '@/lib/translations';
|
|
2840
|
-
import { cn } from '@/lib/utils';
|
|
2839
|
+
try {
|
|
2840
|
+
const client = getServerClient();
|
|
2841
|
+
const product = await client.getProductBySlug(slug);
|
|
2842
|
+
const imageUrl = product.images?.[0]?.url;
|
|
2843
|
+
const description = product.description?.substring(0, 160) || product.name;
|
|
2841
2844
|
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
}
|
|
2866
|
-
if (urls.length === 0) return <span className="text-muted-foreground">-</span>;
|
|
2867
|
-
return (
|
|
2868
|
-
<div className="flex flex-wrap gap-2">
|
|
2869
|
-
{urls.map((url, i) => (
|
|
2870
|
-
<img
|
|
2871
|
-
key={i}
|
|
2872
|
-
src={url}
|
|
2873
|
-
alt={\`\${field.definitionName} \${i + 1}\`}
|
|
2874
|
-
className="h-16 w-16 rounded object-cover"
|
|
2875
|
-
/>
|
|
2876
|
-
))}
|
|
2877
|
-
</div>
|
|
2878
|
-
);
|
|
2879
|
-
}
|
|
2880
|
-
case 'URL':
|
|
2881
|
-
return field.value ? (
|
|
2882
|
-
<a
|
|
2883
|
-
href={field.value}
|
|
2884
|
-
target="_blank"
|
|
2885
|
-
rel="noopener noreferrer"
|
|
2886
|
-
className="text-primary break-all hover:underline"
|
|
2887
|
-
>
|
|
2888
|
-
{field.value}
|
|
2889
|
-
</a>
|
|
2890
|
-
) : (
|
|
2891
|
-
<span className="text-muted-foreground">-</span>
|
|
2892
|
-
);
|
|
2893
|
-
case 'COLOR':
|
|
2894
|
-
return field.value ? (
|
|
2895
|
-
<span className="inline-flex items-center gap-2">
|
|
2896
|
-
<span
|
|
2897
|
-
className="border-border inline-block h-4 w-4 rounded-full border"
|
|
2898
|
-
style={{ backgroundColor: field.value }}
|
|
2899
|
-
/>
|
|
2900
|
-
{field.value}
|
|
2901
|
-
</span>
|
|
2902
|
-
) : (
|
|
2903
|
-
<span className="text-muted-foreground">-</span>
|
|
2904
|
-
);
|
|
2905
|
-
case 'BOOLEAN':
|
|
2906
|
-
return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
|
|
2907
|
-
case 'DATE':
|
|
2908
|
-
case 'DATETIME': {
|
|
2909
|
-
if (!field.value) return <span className="text-muted-foreground">-</span>;
|
|
2910
|
-
try {
|
|
2911
|
-
const date = new Date(field.value);
|
|
2912
|
-
return (
|
|
2913
|
-
<span>
|
|
2914
|
-
{field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
|
|
2915
|
-
</span>
|
|
2916
|
-
);
|
|
2917
|
-
} catch {
|
|
2918
|
-
return <span>{field.value}</span>;
|
|
2919
|
-
}
|
|
2920
|
-
}
|
|
2921
|
-
default:
|
|
2922
|
-
return <span>{field.value || '-'}</span>;
|
|
2845
|
+
return {
|
|
2846
|
+
title: product.name,
|
|
2847
|
+
description,
|
|
2848
|
+
alternates: {
|
|
2849
|
+
canonical: \`/products/\${slug}\`,
|
|
2850
|
+
},
|
|
2851
|
+
openGraph: {
|
|
2852
|
+
title: product.name,
|
|
2853
|
+
description,
|
|
2854
|
+
images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
|
|
2855
|
+
type: 'website',
|
|
2856
|
+
},
|
|
2857
|
+
twitter: {
|
|
2858
|
+
card: 'summary_large_image',
|
|
2859
|
+
title: product.name,
|
|
2860
|
+
description,
|
|
2861
|
+
images: imageUrl ? [imageUrl] : [],
|
|
2862
|
+
},
|
|
2863
|
+
};
|
|
2864
|
+
} catch {
|
|
2865
|
+
return {
|
|
2866
|
+
title: 'Product not found',
|
|
2867
|
+
};
|
|
2923
2868
|
}
|
|
2924
2869
|
}
|
|
2925
2870
|
|
|
2926
|
-
export default function ProductDetailPage() {
|
|
2927
|
-
const
|
|
2928
|
-
const slug = params.slug as string;
|
|
2929
|
-
const { refreshCart } = useCart();
|
|
2930
|
-
const t = useTranslations('productDetail');
|
|
2931
|
-
const tc = useTranslations('common');
|
|
2932
|
-
const [product, setProduct] = useState<ProductWithRecommendations | null>(null);
|
|
2933
|
-
const [loading, setLoading] = useState(true);
|
|
2934
|
-
const [error, setError] = useState<string | null>(null);
|
|
2935
|
-
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
|
2936
|
-
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
|
2937
|
-
const [quantity, setQuantity] = useState(1);
|
|
2938
|
-
const [addingToCart, setAddingToCart] = useState(false);
|
|
2939
|
-
const [addedMessage, setAddedMessage] = useState(false);
|
|
2940
|
-
|
|
2941
|
-
const recommendations = product?.recommendations ?? null;
|
|
2942
|
-
|
|
2943
|
-
// Load product
|
|
2944
|
-
useEffect(() => {
|
|
2945
|
-
async function load() {
|
|
2946
|
-
try {
|
|
2947
|
-
setLoading(true);
|
|
2948
|
-
setError(null);
|
|
2949
|
-
const client = getClient();
|
|
2950
|
-
const p = await client.getProductBySlug(slug);
|
|
2951
|
-
setProduct(p as ProductWithRecommendations);
|
|
2952
|
-
|
|
2953
|
-
// Auto-select first variant
|
|
2954
|
-
if (p.variants && p.variants.length > 0) {
|
|
2955
|
-
setSelectedVariant(p.variants[0]);
|
|
2956
|
-
}
|
|
2957
|
-
} catch {
|
|
2958
|
-
setError(t('notFound'));
|
|
2959
|
-
} finally {
|
|
2960
|
-
setLoading(false);
|
|
2961
|
-
}
|
|
2962
|
-
}
|
|
2963
|
-
load();
|
|
2964
|
-
}, [slug]);
|
|
2965
|
-
|
|
2966
|
-
// Images list - switch main image when variant changes
|
|
2967
|
-
const images: ProductImage[] = useMemo(() => {
|
|
2968
|
-
return product?.images || [];
|
|
2969
|
-
}, [product]);
|
|
2970
|
-
|
|
2971
|
-
// When variant changes, update selected image to variant image if available
|
|
2972
|
-
useEffect(() => {
|
|
2973
|
-
if (!selectedVariant?.image || !product) return;
|
|
2974
|
-
|
|
2975
|
-
const variantImgUrl =
|
|
2976
|
-
typeof selectedVariant.image === 'string' ? selectedVariant.image : selectedVariant.image.url;
|
|
2977
|
-
|
|
2978
|
-
// Find if variant image exists in product images
|
|
2979
|
-
const idx = images.findIndex((img) => img.url === variantImgUrl);
|
|
2980
|
-
if (idx >= 0) {
|
|
2981
|
-
setSelectedImageIndex(idx);
|
|
2982
|
-
} else {
|
|
2983
|
-
// Variant image not in product images - select index 0 as fallback
|
|
2984
|
-
// (The variant image will be shown as the main image via override)
|
|
2985
|
-
setSelectedImageIndex(-1);
|
|
2986
|
-
}
|
|
2987
|
-
}, [selectedVariant, images, product]);
|
|
2988
|
-
|
|
2989
|
-
// Determine which image to show
|
|
2990
|
-
const mainImageUrl = useMemo(() => {
|
|
2991
|
-
if (selectedImageIndex === -1 && selectedVariant?.image) {
|
|
2992
|
-
const img = selectedVariant.image;
|
|
2993
|
-
return typeof img === 'string' ? img : img.url;
|
|
2994
|
-
}
|
|
2995
|
-
return images[selectedImageIndex]?.url || null;
|
|
2996
|
-
}, [selectedImageIndex, selectedVariant, images]);
|
|
2997
|
-
|
|
2998
|
-
// Price info - use variant price if selected, else product price
|
|
2999
|
-
const priceInfo = useMemo(() => {
|
|
3000
|
-
if (selectedVariant?.price) {
|
|
3001
|
-
return {
|
|
3002
|
-
price: parseFloat(selectedVariant.salePrice || selectedVariant.price),
|
|
3003
|
-
originalPrice: parseFloat(selectedVariant.price),
|
|
3004
|
-
isOnSale:
|
|
3005
|
-
selectedVariant.salePrice != null &&
|
|
3006
|
-
parseFloat(selectedVariant.salePrice) < parseFloat(selectedVariant.price),
|
|
3007
|
-
discountPercent:
|
|
3008
|
-
selectedVariant.salePrice != null &&
|
|
3009
|
-
parseFloat(selectedVariant.salePrice) < parseFloat(selectedVariant.price)
|
|
3010
|
-
? Math.round(
|
|
3011
|
-
((parseFloat(selectedVariant.price) - parseFloat(selectedVariant.salePrice)) /
|
|
3012
|
-
parseFloat(selectedVariant.price)) *
|
|
3013
|
-
100
|
|
3014
|
-
)
|
|
3015
|
-
: 0,
|
|
3016
|
-
};
|
|
3017
|
-
}
|
|
3018
|
-
return getProductPriceInfo(product);
|
|
3019
|
-
}, [product, selectedVariant]);
|
|
3020
|
-
|
|
3021
|
-
// Inventory: use variant inventory if selected, else product inventory
|
|
3022
|
-
const inventory = selectedVariant?.inventory ?? product?.inventory ?? null;
|
|
3023
|
-
const canPurchase = inventory?.canPurchase !== false;
|
|
3024
|
-
|
|
3025
|
-
// Description
|
|
3026
|
-
const description = useMemo(() => {
|
|
3027
|
-
return product ? getDescriptionContent(product) : null;
|
|
3028
|
-
}, [product]);
|
|
3029
|
-
|
|
3030
|
-
async function handleAddToCart() {
|
|
3031
|
-
if (!product || addingToCart) return;
|
|
3032
|
-
|
|
3033
|
-
try {
|
|
3034
|
-
setAddingToCart(true);
|
|
3035
|
-
const client = getClient();
|
|
3036
|
-
await client.smartAddToCart({
|
|
3037
|
-
productId: product.id,
|
|
3038
|
-
variantId: selectedVariant?.id,
|
|
3039
|
-
quantity,
|
|
3040
|
-
name: product.name,
|
|
3041
|
-
price: String(priceInfo.price),
|
|
3042
|
-
image: mainImageUrl || undefined,
|
|
3043
|
-
});
|
|
3044
|
-
await refreshCart();
|
|
3045
|
-
setAddedMessage(true);
|
|
3046
|
-
setTimeout(() => setAddedMessage(false), 2000);
|
|
3047
|
-
} catch (err) {
|
|
3048
|
-
console.error('Failed to add to cart:', err);
|
|
3049
|
-
} finally {
|
|
3050
|
-
setAddingToCart(false);
|
|
3051
|
-
}
|
|
3052
|
-
}
|
|
2871
|
+
export default async function ProductDetailPage({ params }: Props) {
|
|
2872
|
+
const { slug } = await params;
|
|
3053
2873
|
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
);
|
|
2874
|
+
let product;
|
|
2875
|
+
try {
|
|
2876
|
+
const client = getServerClient();
|
|
2877
|
+
product = await client.getProductBySlug(slug);
|
|
2878
|
+
} catch {
|
|
2879
|
+
notFound();
|
|
3060
2880
|
}
|
|
3061
2881
|
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
<h1 className="text-foreground text-2xl font-bold">{error || t('notFound')}</h1>
|
|
3066
|
-
<Link href="/products" className="text-primary mt-4 inline-block hover:underline">
|
|
3067
|
-
{t('backToProducts')}
|
|
3068
|
-
</Link>
|
|
3069
|
-
</div>
|
|
3070
|
-
);
|
|
3071
|
-
}
|
|
2882
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
2883
|
+
const productUrl = \`\${baseUrl}/products/\${slug}\`;
|
|
2884
|
+
const currency = process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
|
|
3072
2885
|
|
|
3073
2886
|
return (
|
|
3074
|
-
|
|
3075
|
-
<
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
{/* Main Image */}
|
|
3079
|
-
<div className="bg-muted relative aspect-square overflow-hidden rounded-lg">
|
|
3080
|
-
{mainImageUrl ? (
|
|
3081
|
-
<Image
|
|
3082
|
-
src={mainImageUrl}
|
|
3083
|
-
alt={product.name}
|
|
3084
|
-
fill
|
|
3085
|
-
sizes="(max-width: 1024px) 100vw, 50vw"
|
|
3086
|
-
className="object-contain"
|
|
3087
|
-
priority
|
|
3088
|
-
/>
|
|
3089
|
-
) : (
|
|
3090
|
-
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
3091
|
-
<svg className="h-24 w-24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3092
|
-
<path
|
|
3093
|
-
strokeLinecap="round"
|
|
3094
|
-
strokeLinejoin="round"
|
|
3095
|
-
strokeWidth={1}
|
|
3096
|
-
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"
|
|
3097
|
-
/>
|
|
3098
|
-
</svg>
|
|
3099
|
-
</div>
|
|
3100
|
-
)}
|
|
3101
|
-
</div>
|
|
3102
|
-
|
|
3103
|
-
{/* Thumbnails */}
|
|
3104
|
-
{images.length > 1 && (
|
|
3105
|
-
<div className="flex gap-2 overflow-x-auto pb-2">
|
|
3106
|
-
{images.map((img, idx) => (
|
|
3107
|
-
<button
|
|
3108
|
-
key={idx}
|
|
3109
|
-
type="button"
|
|
3110
|
-
onClick={() => setSelectedImageIndex(idx)}
|
|
3111
|
-
className={cn(
|
|
3112
|
-
'relative h-16 w-16 flex-shrink-0 overflow-hidden rounded border-2 transition-colors',
|
|
3113
|
-
selectedImageIndex === idx
|
|
3114
|
-
? 'border-primary'
|
|
3115
|
-
: 'border-border hover:border-muted-foreground'
|
|
3116
|
-
)}
|
|
3117
|
-
>
|
|
3118
|
-
<Image
|
|
3119
|
-
src={img.url}
|
|
3120
|
-
alt={img.alt || \`\${product.name} \${idx + 1}\`}
|
|
3121
|
-
fill
|
|
3122
|
-
sizes="64px"
|
|
3123
|
-
className="object-cover"
|
|
3124
|
-
/>
|
|
3125
|
-
</button>
|
|
3126
|
-
))}
|
|
3127
|
-
</div>
|
|
3128
|
-
)}
|
|
3129
|
-
</div>
|
|
3130
|
-
|
|
3131
|
-
{/* Product Info */}
|
|
3132
|
-
<div className="space-y-6">
|
|
3133
|
-
{/* Categories */}
|
|
3134
|
-
{product.categories && product.categories.length > 0 && (
|
|
3135
|
-
<div className="flex flex-wrap gap-2">
|
|
3136
|
-
{product.categories.map((cat) => (
|
|
3137
|
-
<span
|
|
3138
|
-
key={cat.id}
|
|
3139
|
-
className="text-muted-foreground bg-muted rounded px-2 py-1 text-xs"
|
|
3140
|
-
>
|
|
3141
|
-
{cat.name}
|
|
3142
|
-
</span>
|
|
3143
|
-
))}
|
|
3144
|
-
</div>
|
|
3145
|
-
)}
|
|
3146
|
-
|
|
3147
|
-
{/* Title */}
|
|
3148
|
-
<h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
|
|
3149
|
-
|
|
3150
|
-
{/* Price */}
|
|
3151
|
-
<PriceDisplay
|
|
3152
|
-
price={priceInfo.originalPrice}
|
|
3153
|
-
salePrice={priceInfo.isOnSale ? priceInfo.price : undefined}
|
|
3154
|
-
size="lg"
|
|
3155
|
-
/>
|
|
3156
|
-
|
|
3157
|
-
{/* Stock / Digital badge */}
|
|
3158
|
-
{product.isDownloadable ? (
|
|
3159
|
-
<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">
|
|
3160
|
-
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3161
|
-
<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" />
|
|
3162
|
-
</svg>
|
|
3163
|
-
{t('instantDownload')}
|
|
3164
|
-
</span>
|
|
3165
|
-
) : (
|
|
3166
|
-
<StockBadge inventory={inventory} lowStockThreshold={5} />
|
|
3167
|
-
)}
|
|
3168
|
-
|
|
3169
|
-
{/* Downloadable files info */}
|
|
3170
|
-
{product.isDownloadable && product.downloads && product.downloads.length > 0 && (
|
|
3171
|
-
<div className="bg-muted/50 rounded-lg border p-4">
|
|
3172
|
-
<p className="text-foreground mb-2 text-sm font-medium">
|
|
3173
|
-
{t('filesIncluded', { count: product.downloads.length })}
|
|
3174
|
-
</p>
|
|
3175
|
-
<ul className="space-y-1.5">
|
|
3176
|
-
{product.downloads.map((file: DownloadFile) => (
|
|
3177
|
-
<li key={file.id} className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
3178
|
-
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
3179
|
-
<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" />
|
|
3180
|
-
</svg>
|
|
3181
|
-
<span className="truncate">{file.name}</span>
|
|
3182
|
-
{file.size && (
|
|
3183
|
-
<span className="flex-shrink-0 text-xs">
|
|
3184
|
-
({file.size < 1024 * 1024
|
|
3185
|
-
? \`\${(file.size / 1024).toFixed(0)} KB\`
|
|
3186
|
-
: \`\${(file.size / (1024 * 1024)).toFixed(1)} MB\`})
|
|
3187
|
-
</span>
|
|
3188
|
-
)}
|
|
3189
|
-
</li>
|
|
3190
|
-
))}
|
|
3191
|
-
</ul>
|
|
3192
|
-
</div>
|
|
3193
|
-
)}
|
|
3194
|
-
|
|
3195
|
-
{/* Variant Selector */}
|
|
3196
|
-
{product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
|
|
3197
|
-
<VariantSelector
|
|
3198
|
-
product={product}
|
|
3199
|
-
selectedVariant={selectedVariant}
|
|
3200
|
-
onVariantChange={setSelectedVariant}
|
|
3201
|
-
/>
|
|
3202
|
-
)}
|
|
3203
|
-
|
|
3204
|
-
{/* Quantity + Add to Cart */}
|
|
3205
|
-
<div className="flex items-center gap-4">
|
|
3206
|
-
<div className="border-border flex items-center rounded border">
|
|
3207
|
-
<button
|
|
3208
|
-
type="button"
|
|
3209
|
-
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
|
3210
|
-
className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
|
|
3211
|
-
aria-label={t('decreaseQuantity')}
|
|
3212
|
-
>
|
|
3213
|
-
-
|
|
3214
|
-
</button>
|
|
3215
|
-
<span className="text-foreground min-w-[3rem] px-4 py-2 text-center text-sm font-medium">
|
|
3216
|
-
{quantity}
|
|
3217
|
-
</span>
|
|
3218
|
-
<button
|
|
3219
|
-
type="button"
|
|
3220
|
-
onClick={() => setQuantity((q) => q + 1)}
|
|
3221
|
-
className="text-foreground hover:bg-muted px-3 py-2 transition-colors"
|
|
3222
|
-
aria-label={t('increaseQuantity')}
|
|
3223
|
-
>
|
|
3224
|
-
+
|
|
3225
|
-
</button>
|
|
3226
|
-
</div>
|
|
3227
|
-
|
|
3228
|
-
<button
|
|
3229
|
-
type="button"
|
|
3230
|
-
onClick={handleAddToCart}
|
|
3231
|
-
disabled={!canPurchase || addingToCart}
|
|
3232
|
-
className={cn(
|
|
3233
|
-
'flex-1 rounded px-6 py-3 text-sm font-medium transition-all',
|
|
3234
|
-
canPurchase
|
|
3235
|
-
? 'bg-primary text-primary-foreground hover:opacity-90'
|
|
3236
|
-
: 'bg-muted text-muted-foreground cursor-not-allowed'
|
|
3237
|
-
)}
|
|
3238
|
-
>
|
|
3239
|
-
{addingToCart ? (
|
|
3240
|
-
<span className="inline-flex items-center gap-2">
|
|
3241
|
-
<LoadingSpinner
|
|
3242
|
-
size="sm"
|
|
3243
|
-
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
3244
|
-
/>
|
|
3245
|
-
{t('addingToCart')}
|
|
3246
|
-
</span>
|
|
3247
|
-
) : addedMessage ? (
|
|
3248
|
-
t('addedToCart')
|
|
3249
|
-
) : !canPurchase ? (
|
|
3250
|
-
t('outOfStock')
|
|
3251
|
-
) : (
|
|
3252
|
-
t('addToCart')
|
|
3253
|
-
)}
|
|
3254
|
-
</button>
|
|
3255
|
-
</div>
|
|
3256
|
-
|
|
3257
|
-
{/* Download after purchase note */}
|
|
3258
|
-
{product.isDownloadable && (
|
|
3259
|
-
<p className="text-muted-foreground text-sm">
|
|
3260
|
-
{t('downloadAfterPurchase')}
|
|
3261
|
-
</p>
|
|
3262
|
-
)}
|
|
3263
|
-
|
|
3264
|
-
{/* Description */}
|
|
3265
|
-
{description && (
|
|
3266
|
-
<div className="border-border border-t pt-4">
|
|
3267
|
-
<h2 className="text-foreground mb-3 text-lg font-semibold">{t('description')}</h2>
|
|
3268
|
-
{'html' in description ? (
|
|
3269
|
-
<div
|
|
3270
|
-
className="prose prose-sm text-muted-foreground max-w-none"
|
|
3271
|
-
dangerouslySetInnerHTML={{ __html: description.html }}
|
|
3272
|
-
/>
|
|
3273
|
-
) : (
|
|
3274
|
-
<p className="text-muted-foreground whitespace-pre-wrap">{description.text}</p>
|
|
3275
|
-
)}
|
|
3276
|
-
</div>
|
|
3277
|
-
)}
|
|
3278
|
-
|
|
3279
|
-
{/* Metafields / Specifications */}
|
|
3280
|
-
{product.metafields && product.metafields.length > 0 && (
|
|
3281
|
-
<div className="border-border border-t pt-4">
|
|
3282
|
-
<h2 className="text-foreground mb-3 text-lg font-semibold">{t('specifications')}</h2>
|
|
3283
|
-
<table className="w-full text-sm">
|
|
3284
|
-
<tbody>
|
|
3285
|
-
{product.metafields.map((field) => (
|
|
3286
|
-
<tr key={field.id} className="border-border border-b last:border-0">
|
|
3287
|
-
<td className="text-foreground whitespace-nowrap py-2 pe-4 font-medium">
|
|
3288
|
-
{field.definitionName}
|
|
3289
|
-
</td>
|
|
3290
|
-
<td className="text-muted-foreground py-2">
|
|
3291
|
-
<MetafieldValue field={field} />
|
|
3292
|
-
</td>
|
|
3293
|
-
</tr>
|
|
3294
|
-
))}
|
|
3295
|
-
</tbody>
|
|
3296
|
-
</table>
|
|
3297
|
-
</div>
|
|
3298
|
-
)}
|
|
3299
|
-
</div>
|
|
3300
|
-
</div>
|
|
3301
|
-
|
|
3302
|
-
{/* Upsells \u2014 premium alternatives (product page) */}
|
|
3303
|
-
{recommendations?.upsells && recommendations.upsells.length > 0 && (
|
|
3304
|
-
<RecommendationSection
|
|
3305
|
-
title={t('upgradeYourChoice')}
|
|
3306
|
-
items={recommendations.upsells}
|
|
3307
|
-
className="mt-12"
|
|
3308
|
-
/>
|
|
3309
|
-
)}
|
|
3310
|
-
|
|
3311
|
-
{/* Related products \u2014 similar items (bottom of product page) */}
|
|
3312
|
-
{recommendations?.related && recommendations.related.length > 0 && (
|
|
3313
|
-
<RecommendationSection
|
|
3314
|
-
title={t('similarProducts')}
|
|
3315
|
-
items={recommendations.related}
|
|
3316
|
-
className="mt-12"
|
|
3317
|
-
/>
|
|
3318
|
-
)}
|
|
3319
|
-
</div>
|
|
2887
|
+
<>
|
|
2888
|
+
<ProductJsonLd product={product} url={productUrl} currency={currency} />
|
|
2889
|
+
<ProductClientSection product={product} />
|
|
2890
|
+
</>
|
|
3320
2891
|
);
|
|
3321
2892
|
}
|
|
3322
2893
|
`,
|
|
@@ -3349,7 +2920,10 @@ export default function CartPage() {
|
|
|
3349
2920
|
return;
|
|
3350
2921
|
}
|
|
3351
2922
|
const client = getClient();
|
|
3352
|
-
client
|
|
2923
|
+
client
|
|
2924
|
+
.getCartRecommendations(cart.id, 4)
|
|
2925
|
+
.then(setCartRecs)
|
|
2926
|
+
.catch(() => {});
|
|
3353
2927
|
}, [cart?.id, cart?.items.length]);
|
|
3354
2928
|
|
|
3355
2929
|
if (cartLoading) {
|
|
@@ -3457,7 +3031,7 @@ export default function CartPage() {
|
|
|
3457
3031
|
`,
|
|
3458
3032
|
"src/app/checkout/page.tsx": `'use client';
|
|
3459
3033
|
|
|
3460
|
-
import { Suspense, useEffect, useState, useCallback } from 'react';
|
|
3034
|
+
import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
|
|
3461
3035
|
import { useSearchParams } from 'next/navigation';
|
|
3462
3036
|
import Image from 'next/image';
|
|
3463
3037
|
import Link from 'next/link';
|
|
@@ -3470,7 +3044,7 @@ import type {
|
|
|
3470
3044
|
} from 'brainerce';
|
|
3471
3045
|
import { formatPrice } from 'brainerce';
|
|
3472
3046
|
import { getClient } from '@/lib/brainerce';
|
|
3473
|
-
import { useStoreInfo,
|
|
3047
|
+
import { useStoreInfo, useCart, useAuth } from '@/providers/store-provider';
|
|
3474
3048
|
import { CheckoutForm } from '@/components/checkout/checkout-form';
|
|
3475
3049
|
import { ShippingStep } from '@/components/checkout/shipping-step';
|
|
3476
3050
|
import { PaymentStep } from '@/components/checkout/payment-step';
|
|
@@ -3488,8 +3062,8 @@ type CheckoutStep = 'method' | 'address' | 'shipping' | 'pickup' | 'payment';
|
|
|
3488
3062
|
function CheckoutContent() {
|
|
3489
3063
|
const searchParams = useSearchParams();
|
|
3490
3064
|
const { storeInfo } = useStoreInfo();
|
|
3491
|
-
const { isLoggedIn } = useAuth();
|
|
3492
3065
|
const { cart, refreshCart } = useCart();
|
|
3066
|
+
const { isLoggedIn } = useAuth();
|
|
3493
3067
|
const currency = storeInfo?.currency || 'USD';
|
|
3494
3068
|
const t = useTranslations('checkout');
|
|
3495
3069
|
const tc = useTranslations('common');
|
|
@@ -3504,90 +3078,125 @@ function CheckoutContent() {
|
|
|
3504
3078
|
const [destinations, setDestinations] = useState<ShippingDestinations | null>(null);
|
|
3505
3079
|
const [pickupLocations, setPickupLocations] = useState<PickupLocation[]>([]);
|
|
3506
3080
|
const [deliveryType, setDeliveryType] = useState<'shipping' | 'pickup'>('shipping');
|
|
3081
|
+
const [isAllDigital, setIsAllDigital] = useState(false);
|
|
3082
|
+
const [prefillAddress, setPrefillAddress] = useState<SetShippingAddressDto | null>(null);
|
|
3083
|
+
const [prefillCustomer, setPrefillCustomer] = useState<{
|
|
3084
|
+
email: string;
|
|
3085
|
+
firstName?: string;
|
|
3086
|
+
lastName?: string;
|
|
3087
|
+
phone?: string;
|
|
3088
|
+
} | null>(null);
|
|
3089
|
+
const [hasSavedAddress, setHasSavedAddress] = useState(false);
|
|
3507
3090
|
|
|
3508
3091
|
// Check for returning from canceled payment
|
|
3509
3092
|
const canceled = searchParams.get('canceled') === 'true';
|
|
3510
3093
|
const existingCheckoutId = searchParams.get('checkout_id');
|
|
3511
3094
|
|
|
3512
|
-
//
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
.then(setDestinations)
|
|
3523
|
-
.catch(() => {});
|
|
3524
|
-
|
|
3525
|
-
const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]);
|
|
3526
|
-
setPickupLocations(locations);
|
|
3527
|
-
|
|
3528
|
-
// If returning with existing checkout ID, resume it
|
|
3529
|
-
if (existingCheckoutId) {
|
|
3530
|
-
const existing = await client.getCheckout(existingCheckoutId);
|
|
3531
|
-
setCheckout(existing);
|
|
3532
|
-
|
|
3533
|
-
// Determine step based on checkout state
|
|
3534
|
-
if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
|
|
3535
|
-
setDeliveryType('pickup');
|
|
3536
|
-
setStep('payment');
|
|
3537
|
-
} else if (existing.shippingAddress && existing.shippingRateId) {
|
|
3538
|
-
setStep('payment');
|
|
3539
|
-
} else if (existing.shippingAddress) {
|
|
3540
|
-
// Fetch shipping rates
|
|
3541
|
-
const rates = await client.getShippingRates(existing.id);
|
|
3542
|
-
setShippingRates(rates);
|
|
3543
|
-
setStep('shipping');
|
|
3544
|
-
} else if (locations.length > 0) {
|
|
3545
|
-
setStep('method');
|
|
3095
|
+
// Pre-fill address and customer data from profile when logged in
|
|
3096
|
+
useEffect(() => {
|
|
3097
|
+
if (!isLoggedIn) return;
|
|
3098
|
+
getClient()
|
|
3099
|
+
.getCheckoutPrefillData()
|
|
3100
|
+
.then((data) => {
|
|
3101
|
+
if (data.customer) setPrefillCustomer(data.customer);
|
|
3102
|
+
if (data.shippingAddress) {
|
|
3103
|
+
setPrefillAddress(data.shippingAddress);
|
|
3104
|
+
setHasSavedAddress(true);
|
|
3546
3105
|
}
|
|
3547
|
-
|
|
3548
|
-
}
|
|
3106
|
+
})
|
|
3107
|
+
.catch(() => {});
|
|
3108
|
+
}, [isLoggedIn]);
|
|
3549
3109
|
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3110
|
+
// Initialize or resume checkout (only once)
|
|
3111
|
+
const checkoutInitRef = useRef(false);
|
|
3112
|
+
const cartIdRef = useRef<string | null>(null);
|
|
3113
|
+
|
|
3114
|
+
useEffect(() => {
|
|
3115
|
+
// Only init once, or if cart ID actually changed (e.g. cart was replaced)
|
|
3116
|
+
if (!cart?.id) return;
|
|
3117
|
+
if (checkoutInitRef.current && cartIdRef.current === cart.id) return;
|
|
3118
|
+
checkoutInitRef.current = true;
|
|
3119
|
+
cartIdRef.current = cart.id;
|
|
3120
|
+
|
|
3121
|
+
const initCheckout = async () => {
|
|
3122
|
+
try {
|
|
3123
|
+
setInitializing(true);
|
|
3124
|
+
setError(null);
|
|
3125
|
+
const client = getClient();
|
|
3126
|
+
|
|
3127
|
+
// Fetch shipping destinations and pickup locations in parallel
|
|
3128
|
+
client
|
|
3129
|
+
.getShippingDestinations()
|
|
3130
|
+
.then(setDestinations)
|
|
3131
|
+
.catch(() => {});
|
|
3132
|
+
|
|
3133
|
+
const locations = await client.getPickupLocations().catch(() => [] as PickupLocation[]);
|
|
3134
|
+
setPickupLocations(locations);
|
|
3135
|
+
|
|
3136
|
+
// If returning with existing checkout ID, resume it
|
|
3137
|
+
if (existingCheckoutId) {
|
|
3138
|
+
const existing = await client.getCheckout(existingCheckoutId);
|
|
3139
|
+
setCheckout(existing);
|
|
3140
|
+
|
|
3141
|
+
// Determine step based on checkout state
|
|
3142
|
+
const allDigital = existing.lineItems.every(
|
|
3143
|
+
(i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
|
|
3144
|
+
);
|
|
3145
|
+
setIsAllDigital(allDigital);
|
|
3146
|
+
if (allDigital) {
|
|
3147
|
+
// Digital products: show contact info step if email not set, else payment
|
|
3148
|
+
setStep(existing.email ? 'payment' : 'address');
|
|
3149
|
+
} else if (existing.deliveryType === 'pickup' && existing.pickupLocation) {
|
|
3150
|
+
setDeliveryType('pickup');
|
|
3151
|
+
setStep('payment');
|
|
3152
|
+
} else if (existing.shippingAddress && existing.shippingRateId) {
|
|
3153
|
+
setStep('payment');
|
|
3154
|
+
} else if (existing.shippingAddress) {
|
|
3155
|
+
// Fetch shipping rates
|
|
3156
|
+
const rates = await client.getShippingRates(existing.id);
|
|
3157
|
+
setShippingRates(rates);
|
|
3158
|
+
setStep('shipping');
|
|
3159
|
+
} else if (locations.length > 0) {
|
|
3160
|
+
setStep('method');
|
|
3564
3161
|
}
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
// Create new checkout \u2014 cart is always server-side now
|
|
3166
|
+
const newCheckout = await client.createCheckout({ cartId: cart.id });
|
|
3167
|
+
setCheckout(newCheckout);
|
|
3168
|
+
|
|
3169
|
+
// If all items are downloadable, skip shipping \u2014 show contact info step
|
|
3170
|
+
const allDigital = newCheckout.lineItems.every(
|
|
3171
|
+
(i) => (i.product as unknown as { isDownloadable?: boolean }).isDownloadable
|
|
3172
|
+
);
|
|
3173
|
+
setIsAllDigital(allDigital);
|
|
3174
|
+
if (allDigital) {
|
|
3175
|
+
setStep('address');
|
|
3176
|
+
return;
|
|
3565
3177
|
}
|
|
3566
|
-
} else {
|
|
3567
|
-
setError(t('cartIsEmpty'));
|
|
3568
|
-
}
|
|
3569
3178
|
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3179
|
+
// If pickup locations exist, start with delivery method selection
|
|
3180
|
+
if (locations.length > 0) {
|
|
3181
|
+
setStep('method');
|
|
3182
|
+
}
|
|
3183
|
+
} catch (err) {
|
|
3184
|
+
const message = err instanceof Error ? err.message : t('failedToInitCheckout');
|
|
3185
|
+
setError(message);
|
|
3186
|
+
} finally {
|
|
3187
|
+
setInitializing(false);
|
|
3573
3188
|
}
|
|
3574
|
-
}
|
|
3575
|
-
const message = err instanceof Error ? err.message : t('failedToInitCheckout');
|
|
3576
|
-
setError(message);
|
|
3577
|
-
} finally {
|
|
3578
|
-
setInitializing(false);
|
|
3579
|
-
}
|
|
3580
|
-
}, [existingCheckoutId, isLoggedIn, cart]);
|
|
3189
|
+
};
|
|
3581
3190
|
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
initCheckout();
|
|
3586
|
-
}
|
|
3587
|
-
}, [cartLoaded, initCheckout]);
|
|
3191
|
+
initCheckout();
|
|
3192
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3193
|
+
}, [cart?.id, existingCheckoutId]);
|
|
3588
3194
|
|
|
3589
3195
|
// Handle shipping address submission
|
|
3590
|
-
async function handleAddressSubmit(
|
|
3196
|
+
async function handleAddressSubmit(
|
|
3197
|
+
address: SetShippingAddressDto,
|
|
3198
|
+
consent: { acceptsMarketing: boolean; saveDetails: boolean }
|
|
3199
|
+
) {
|
|
3591
3200
|
if (!checkout) return;
|
|
3592
3201
|
|
|
3593
3202
|
try {
|
|
@@ -3595,10 +3204,52 @@ function CheckoutContent() {
|
|
|
3595
3204
|
setError(null);
|
|
3596
3205
|
const client = getClient();
|
|
3597
3206
|
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3207
|
+
if (isAllDigital) {
|
|
3208
|
+
// Digital products: set customer info only, skip shipping
|
|
3209
|
+
const updated = await client.setCheckoutCustomer(checkout.id, {
|
|
3210
|
+
email: address.email,
|
|
3211
|
+
firstName: address.firstName,
|
|
3212
|
+
lastName: address.lastName,
|
|
3213
|
+
phone: address.phone,
|
|
3214
|
+
acceptsMarketing: consent.acceptsMarketing,
|
|
3215
|
+
});
|
|
3216
|
+
setCheckout(updated);
|
|
3217
|
+
setStep('payment');
|
|
3218
|
+
} else {
|
|
3219
|
+
const response = await client.setShippingAddress(checkout.id, address);
|
|
3220
|
+
setCheckout(response.checkout);
|
|
3221
|
+
setShippingRates(response.rates);
|
|
3222
|
+
setStep('shipping');
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
// Update marketing preference for logged-in users
|
|
3226
|
+
if (isLoggedIn) {
|
|
3227
|
+
try {
|
|
3228
|
+
await client.updateMyProfile({ acceptsMarketing: consent.acceptsMarketing });
|
|
3229
|
+
} catch {
|
|
3230
|
+
// non-critical
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
// Save address to profile if checkbox was checked and no existing saved address
|
|
3235
|
+
if (isLoggedIn && consent.saveDetails && !hasSavedAddress && !isAllDigital) {
|
|
3236
|
+
try {
|
|
3237
|
+
await client.addMyAddress({
|
|
3238
|
+
firstName: address.firstName,
|
|
3239
|
+
lastName: address.lastName,
|
|
3240
|
+
line1: address.line1,
|
|
3241
|
+
line2: address.line2,
|
|
3242
|
+
city: address.city,
|
|
3243
|
+
region: address.region,
|
|
3244
|
+
postalCode: address.postalCode,
|
|
3245
|
+
country: address.country,
|
|
3246
|
+
phone: address.phone,
|
|
3247
|
+
isDefault: true,
|
|
3248
|
+
});
|
|
3249
|
+
} catch {
|
|
3250
|
+
// non-critical
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3602
3253
|
} catch (err) {
|
|
3603
3254
|
const message = err instanceof Error ? err.message : t('failedToSaveAddress');
|
|
3604
3255
|
setError(message);
|
|
@@ -3735,8 +3386,12 @@ function CheckoutContent() {
|
|
|
3735
3386
|
);
|
|
3736
3387
|
}
|
|
3737
3388
|
|
|
3738
|
-
const steps: { key: CheckoutStep; label: string }[] =
|
|
3739
|
-
|
|
3389
|
+
const steps: { key: CheckoutStep; label: string }[] = isAllDigital
|
|
3390
|
+
? [
|
|
3391
|
+
{ key: 'address', label: t('stepContactInfo') },
|
|
3392
|
+
{ key: 'payment', label: t('stepPayment') },
|
|
3393
|
+
]
|
|
3394
|
+
: pickupLocations.length > 0
|
|
3740
3395
|
? deliveryType === 'pickup'
|
|
3741
3396
|
? [
|
|
3742
3397
|
{ key: 'method', label: t('stepMethod') },
|
|
@@ -3846,8 +3501,10 @@ function CheckoutContent() {
|
|
|
3846
3501
|
{step === 'address' && (
|
|
3847
3502
|
<div>
|
|
3848
3503
|
<div className="mb-4 flex items-center justify-between">
|
|
3849
|
-
<h2 className="text-foreground text-lg font-semibold">
|
|
3850
|
-
|
|
3504
|
+
<h2 className="text-foreground text-lg font-semibold">
|
|
3505
|
+
{isAllDigital ? t('contactInfo') : t('shippingAddress')}
|
|
3506
|
+
</h2>
|
|
3507
|
+
{!isAllDigital && pickupLocations.length > 0 && (
|
|
3851
3508
|
<button
|
|
3852
3509
|
type="button"
|
|
3853
3510
|
onClick={() => setStep('method')}
|
|
@@ -3860,7 +3517,9 @@ function CheckoutContent() {
|
|
|
3860
3517
|
<CheckoutForm
|
|
3861
3518
|
onSubmit={handleAddressSubmit}
|
|
3862
3519
|
loading={loading}
|
|
3863
|
-
destinations={destinations}
|
|
3520
|
+
destinations={isAllDigital ? null : destinations}
|
|
3521
|
+
showSaveDetails={isLoggedIn && !hasSavedAddress && !isAllDigital}
|
|
3522
|
+
emailOnly={isAllDigital}
|
|
3864
3523
|
initialValues={
|
|
3865
3524
|
checkout?.shippingAddress
|
|
3866
3525
|
? {
|
|
@@ -3875,7 +3534,27 @@ function CheckoutContent() {
|
|
|
3875
3534
|
country: checkout.shippingAddress.country,
|
|
3876
3535
|
phone: checkout.shippingAddress.phone || '',
|
|
3877
3536
|
}
|
|
3878
|
-
:
|
|
3537
|
+
: prefillAddress
|
|
3538
|
+
? {
|
|
3539
|
+
email: prefillAddress.email,
|
|
3540
|
+
firstName: prefillAddress.firstName,
|
|
3541
|
+
lastName: prefillAddress.lastName,
|
|
3542
|
+
line1: prefillAddress.line1,
|
|
3543
|
+
line2: prefillAddress.line2 || '',
|
|
3544
|
+
city: prefillAddress.city,
|
|
3545
|
+
region: prefillAddress.region || '',
|
|
3546
|
+
postalCode: prefillAddress.postalCode,
|
|
3547
|
+
country: prefillAddress.country,
|
|
3548
|
+
phone: prefillAddress.phone || '',
|
|
3549
|
+
}
|
|
3550
|
+
: prefillCustomer
|
|
3551
|
+
? {
|
|
3552
|
+
email: prefillCustomer.email,
|
|
3553
|
+
firstName: prefillCustomer.firstName || '',
|
|
3554
|
+
lastName: prefillCustomer.lastName || '',
|
|
3555
|
+
phone: prefillCustomer.phone || '',
|
|
3556
|
+
}
|
|
3557
|
+
: undefined
|
|
3879
3558
|
}
|
|
3880
3559
|
/>
|
|
3881
3560
|
</div>
|
|
@@ -3931,13 +3610,15 @@ function CheckoutContent() {
|
|
|
3931
3610
|
<div>
|
|
3932
3611
|
<div className="mb-4 flex items-center justify-between">
|
|
3933
3612
|
<h2 className="text-foreground text-lg font-semibold">{t('payment')}</h2>
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3613
|
+
{!isAllDigital && (
|
|
3614
|
+
<button
|
|
3615
|
+
type="button"
|
|
3616
|
+
onClick={() => setStep(deliveryType === 'pickup' ? 'pickup' : 'shipping')}
|
|
3617
|
+
className="text-primary text-sm hover:underline"
|
|
3618
|
+
>
|
|
3619
|
+
{deliveryType === 'pickup' ? t('changePickup') : t('changeShipping')}
|
|
3620
|
+
</button>
|
|
3621
|
+
)}
|
|
3941
3622
|
</div>
|
|
3942
3623
|
|
|
3943
3624
|
<PaymentStep checkoutId={checkout.id} />
|
|
@@ -4013,12 +3694,13 @@ function CheckoutContent() {
|
|
|
4013
3694
|
)
|
|
4014
3695
|
)}
|
|
4015
3696
|
|
|
4016
|
-
{/* Coupon input \u2014 show from shipping/pickup step onwards */}
|
|
4017
|
-
{cart &&
|
|
4018
|
-
|
|
4019
|
-
<
|
|
4020
|
-
|
|
4021
|
-
|
|
3697
|
+
{/* Coupon input \u2014 show from shipping/pickup step onwards (or immediately if digital) */}
|
|
3698
|
+
{cart &&
|
|
3699
|
+
(isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
|
|
3700
|
+
<div className="border-border border-t pt-4">
|
|
3701
|
+
<CouponInput cart={cart} onUpdate={handleCouponUpdate} />
|
|
3702
|
+
</div>
|
|
3703
|
+
)}
|
|
4022
3704
|
|
|
4023
3705
|
{/* Totals */}
|
|
4024
3706
|
{checkout && (
|
|
@@ -4140,7 +3822,7 @@ export default function CheckoutPage() {
|
|
|
4140
3822
|
import { Suspense, useEffect, useState } from 'react';
|
|
4141
3823
|
import { useSearchParams } from 'next/navigation';
|
|
4142
3824
|
import Link from 'next/link';
|
|
4143
|
-
import type { WaitForOrderResult } from 'brainerce';
|
|
3825
|
+
import type { WaitForOrderResult, OrderDownloadLink } from 'brainerce';
|
|
4144
3826
|
import { getClient } from '@/lib/brainerce';
|
|
4145
3827
|
import { useCart } from '@/providers/store-provider';
|
|
4146
3828
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
@@ -4256,6 +3938,10 @@ function OrderConfirmationContent() {
|
|
|
4256
3938
|
|
|
4257
3939
|
<p className="text-muted-foreground mt-2">{t('confirmationEmail')}</p>
|
|
4258
3940
|
|
|
3941
|
+
{result.status.orderId && (
|
|
3942
|
+
<ConfirmationDownloads orderId={result.status.orderId} checkoutId={checkoutId!} />
|
|
3943
|
+
)}
|
|
3944
|
+
|
|
4259
3945
|
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
|
|
4260
3946
|
<Link
|
|
4261
3947
|
href="/products"
|
|
@@ -4315,6 +4001,63 @@ function OrderConfirmationContent() {
|
|
|
4315
4001
|
);
|
|
4316
4002
|
}
|
|
4317
4003
|
|
|
4004
|
+
function ConfirmationDownloads({ orderId, checkoutId }: { orderId: string; checkoutId: string }) {
|
|
4005
|
+
const t = useTranslations('orderConfirmation');
|
|
4006
|
+
const [downloads, setDownloads] = useState<OrderDownloadLink[] | null>(null);
|
|
4007
|
+
|
|
4008
|
+
useEffect(() => {
|
|
4009
|
+
let cancelled = false;
|
|
4010
|
+
async function fetchDownloads() {
|
|
4011
|
+
const client = getClient();
|
|
4012
|
+
// Retry a few times \u2014 the worker may still be writing downloadMeta
|
|
4013
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
4014
|
+
try {
|
|
4015
|
+
const links = await client.getOrderDownloads(orderId, { checkoutId });
|
|
4016
|
+
if (!cancelled && links.length > 0) {
|
|
4017
|
+
setDownloads(links);
|
|
4018
|
+
return;
|
|
4019
|
+
}
|
|
4020
|
+
} catch {
|
|
4021
|
+
// Not all orders have downloads
|
|
4022
|
+
}
|
|
4023
|
+
if (attempt < 2 && !cancelled) {
|
|
4024
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
4025
|
+
}
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
4028
|
+
fetchDownloads();
|
|
4029
|
+
return () => {
|
|
4030
|
+
cancelled = true;
|
|
4031
|
+
};
|
|
4032
|
+
}, [orderId, checkoutId]);
|
|
4033
|
+
|
|
4034
|
+
if (!downloads || downloads.length === 0) return null;
|
|
4035
|
+
|
|
4036
|
+
return (
|
|
4037
|
+
<div className="border-border bg-muted/30 mx-auto mt-8 max-w-md rounded-lg border p-6 text-start">
|
|
4038
|
+
<h3 className="text-foreground mb-3 text-sm font-semibold">{t('yourDownloads')}</h3>
|
|
4039
|
+
<div className="space-y-2">
|
|
4040
|
+
{downloads.map((link, idx) => (
|
|
4041
|
+
<div key={idx} className="flex items-center justify-between gap-3">
|
|
4042
|
+
<div className="min-w-0 flex-1">
|
|
4043
|
+
<p className="text-foreground truncate text-sm">{link.fileName}</p>
|
|
4044
|
+
<p className="text-muted-foreground truncate text-xs">{link.productName}</p>
|
|
4045
|
+
</div>
|
|
4046
|
+
<a
|
|
4047
|
+
href={link.downloadUrl}
|
|
4048
|
+
target="_blank"
|
|
4049
|
+
rel="noopener noreferrer"
|
|
4050
|
+
className="bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1.5 text-xs font-medium hover:opacity-90"
|
|
4051
|
+
>
|
|
4052
|
+
{t('download')}
|
|
4053
|
+
</a>
|
|
4054
|
+
</div>
|
|
4055
|
+
))}
|
|
4056
|
+
</div>
|
|
4057
|
+
</div>
|
|
4058
|
+
);
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4318
4061
|
export default function OrderConfirmationPage() {
|
|
4319
4062
|
return (
|
|
4320
4063
|
<Suspense
|
|
@@ -4411,6 +4154,7 @@ export default function RegisterPage() {
|
|
|
4411
4154
|
lastName: string;
|
|
4412
4155
|
email: string;
|
|
4413
4156
|
password: string;
|
|
4157
|
+
acceptsMarketing: boolean;
|
|
4414
4158
|
}) {
|
|
4415
4159
|
try {
|
|
4416
4160
|
setError(null);
|
|
@@ -4636,7 +4380,7 @@ function VerifyEmailContent() {
|
|
|
4636
4380
|
|
|
4637
4381
|
<form onSubmit={handleFormSubmit} className="space-y-6">
|
|
4638
4382
|
{/* Digit inputs */}
|
|
4639
|
-
<div className="flex justify-center gap-2 sm:gap-3" onPaste={handlePaste}>
|
|
4383
|
+
<div dir="ltr" className="flex justify-center gap-2 sm:gap-3" onPaste={handlePaste}>
|
|
4640
4384
|
{digits.map((digit, index) => (
|
|
4641
4385
|
<input
|
|
4642
4386
|
key={index}
|
|
@@ -4713,108 +4457,355 @@ export default function VerifyEmailPage() {
|
|
|
4713
4457
|
);
|
|
4714
4458
|
}
|
|
4715
4459
|
`,
|
|
4716
|
-
"src/app/
|
|
4460
|
+
"src/app/forgot-password/page.tsx": `'use client';
|
|
4717
4461
|
|
|
4718
|
-
import {
|
|
4719
|
-
import
|
|
4720
|
-
import {
|
|
4462
|
+
import { useState } from 'react';
|
|
4463
|
+
import Link from 'next/link';
|
|
4464
|
+
import { getClient } from '@/lib/brainerce';
|
|
4721
4465
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
4722
4466
|
import { useTranslations } from '@/lib/translations';
|
|
4723
4467
|
|
|
4724
|
-
function
|
|
4725
|
-
const router = useRouter();
|
|
4726
|
-
const searchParams = useSearchParams();
|
|
4727
|
-
const auth = useAuth();
|
|
4728
|
-
const [error, setError] = useState<string | null>(null);
|
|
4729
|
-
const processedRef = useRef(false);
|
|
4468
|
+
export default function ForgotPasswordPage() {
|
|
4730
4469
|
const t = useTranslations('auth');
|
|
4470
|
+
const [email, setEmail] = useState('');
|
|
4471
|
+
const [loading, setLoading] = useState(false);
|
|
4472
|
+
const [sent, setSent] = useState(false);
|
|
4473
|
+
const [error, setError] = useState<string | null>(null);
|
|
4731
4474
|
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
useEffect(() => {
|
|
4737
|
-
// Prevent double-processing in React StrictMode
|
|
4738
|
-
if (processedRef.current) return;
|
|
4739
|
-
processedRef.current = true;
|
|
4740
|
-
|
|
4741
|
-
if (oauthError) {
|
|
4742
|
-
setError(oauthError);
|
|
4743
|
-
return;
|
|
4744
|
-
}
|
|
4475
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
4476
|
+
e.preventDefault();
|
|
4477
|
+
if (loading) return;
|
|
4745
4478
|
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
}
|
|
4751
|
-
|
|
4752
|
-
|
|
4479
|
+
try {
|
|
4480
|
+
setLoading(true);
|
|
4481
|
+
setError(null);
|
|
4482
|
+
const client = getClient();
|
|
4483
|
+
const resetUrl = \`\${window.location.origin}/api/auth/reset-callback\`;
|
|
4484
|
+
await client.forgotPassword(email, { resetUrl });
|
|
4485
|
+
setSent(true);
|
|
4486
|
+
} catch (err) {
|
|
4487
|
+
const message =
|
|
4488
|
+
err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
|
4489
|
+
setError(message);
|
|
4490
|
+
} finally {
|
|
4491
|
+
setLoading(false);
|
|
4753
4492
|
}
|
|
4754
|
-
}, [oauthSuccess, oauthError, auth, router, t]);
|
|
4755
|
-
|
|
4756
|
-
if (error) {
|
|
4757
|
-
return (
|
|
4758
|
-
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
4759
|
-
<div className="w-full max-w-md space-y-4 text-center">
|
|
4760
|
-
<svg
|
|
4761
|
-
className="text-destructive mx-auto h-12 w-12"
|
|
4762
|
-
fill="none"
|
|
4763
|
-
viewBox="0 0 24 24"
|
|
4764
|
-
stroke="currentColor"
|
|
4765
|
-
>
|
|
4766
|
-
<path
|
|
4767
|
-
strokeLinecap="round"
|
|
4768
|
-
strokeLinejoin="round"
|
|
4769
|
-
strokeWidth={1.5}
|
|
4770
|
-
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"
|
|
4771
|
-
/>
|
|
4772
|
-
</svg>
|
|
4773
|
-
<h1 className="text-foreground text-2xl font-bold">{t('authFailed')}</h1>
|
|
4774
|
-
<p className="text-muted-foreground text-sm">{error}</p>
|
|
4775
|
-
<button
|
|
4776
|
-
type="button"
|
|
4777
|
-
onClick={() => router.push('/login')}
|
|
4778
|
-
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
4779
|
-
>
|
|
4780
|
-
{t('backToLogin')}
|
|
4781
|
-
</button>
|
|
4782
|
-
</div>
|
|
4783
|
-
</div>
|
|
4784
|
-
);
|
|
4785
4493
|
}
|
|
4786
4494
|
|
|
4787
4495
|
return (
|
|
4788
|
-
<div className="flex min-h-[60vh]
|
|
4789
|
-
<
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
4793
|
-
}
|
|
4794
|
-
|
|
4795
|
-
export default function OAuthCallbackPage() {
|
|
4796
|
-
return (
|
|
4797
|
-
<Suspense
|
|
4798
|
-
fallback={
|
|
4799
|
-
<div className="flex min-h-[60vh] items-center justify-center">
|
|
4800
|
-
<LoadingSpinner size="lg" />
|
|
4496
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
4497
|
+
<div className="w-full max-w-md space-y-6">
|
|
4498
|
+
<div className="text-center">
|
|
4499
|
+
<h1 className="text-foreground text-2xl font-bold">{t('forgotPasswordTitle')}</h1>
|
|
4500
|
+
<p className="text-muted-foreground mt-1 text-sm">{t('forgotPasswordSubtitle')}</p>
|
|
4801
4501
|
</div>
|
|
4802
|
-
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4502
|
+
|
|
4503
|
+
{error && (
|
|
4504
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive rounded-lg border px-4 py-3 text-sm">
|
|
4505
|
+
{error}
|
|
4506
|
+
</div>
|
|
4507
|
+
)}
|
|
4508
|
+
|
|
4509
|
+
{sent ? (
|
|
4510
|
+
<div className="space-y-4">
|
|
4511
|
+
<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">
|
|
4512
|
+
{t('resetLinkSent')}
|
|
4513
|
+
</div>
|
|
4514
|
+
<Link
|
|
4515
|
+
href="/login"
|
|
4516
|
+
className="text-primary block text-center text-sm font-medium hover:underline"
|
|
4517
|
+
>
|
|
4518
|
+
{t('backToLogin')}
|
|
4519
|
+
</Link>
|
|
4520
|
+
</div>
|
|
4521
|
+
) : (
|
|
4522
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
4523
|
+
<div>
|
|
4524
|
+
<label
|
|
4525
|
+
htmlFor="forgot-email"
|
|
4526
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
4527
|
+
>
|
|
4528
|
+
{t('email')}
|
|
4529
|
+
</label>
|
|
4530
|
+
<input
|
|
4531
|
+
id="forgot-email"
|
|
4532
|
+
type="email"
|
|
4533
|
+
required
|
|
4534
|
+
value={email}
|
|
4535
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
4536
|
+
placeholder={t('emailPlaceholder')}
|
|
4537
|
+
autoComplete="email"
|
|
4538
|
+
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"
|
|
4539
|
+
/>
|
|
4540
|
+
</div>
|
|
4541
|
+
|
|
4542
|
+
<button
|
|
4543
|
+
type="submit"
|
|
4544
|
+
disabled={loading}
|
|
4545
|
+
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"
|
|
4546
|
+
>
|
|
4547
|
+
{loading ? (
|
|
4548
|
+
<>
|
|
4549
|
+
<LoadingSpinner
|
|
4550
|
+
size="sm"
|
|
4551
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
4552
|
+
/>
|
|
4553
|
+
{t('sendingResetLink')}
|
|
4554
|
+
</>
|
|
4555
|
+
) : (
|
|
4556
|
+
t('sendResetLink')
|
|
4557
|
+
)}
|
|
4558
|
+
</button>
|
|
4559
|
+
|
|
4560
|
+
<Link
|
|
4561
|
+
href="/login"
|
|
4562
|
+
className="text-muted-foreground block text-center text-sm hover:underline"
|
|
4563
|
+
>
|
|
4564
|
+
{t('backToLogin')}
|
|
4565
|
+
</Link>
|
|
4566
|
+
</form>
|
|
4567
|
+
)}
|
|
4568
|
+
</div>
|
|
4569
|
+
</div>
|
|
4806
4570
|
);
|
|
4807
4571
|
}
|
|
4808
4572
|
`,
|
|
4809
|
-
"src/app/
|
|
4573
|
+
"src/app/reset-password/page.tsx": `'use client';
|
|
4810
4574
|
|
|
4811
|
-
import {
|
|
4575
|
+
import { useState } from 'react';
|
|
4812
4576
|
import { useRouter } from 'next/navigation';
|
|
4813
|
-
import
|
|
4814
|
-
import {
|
|
4815
|
-
import { useAuth } from '@/providers/store-provider';
|
|
4577
|
+
import Link from 'next/link';
|
|
4578
|
+
import { proxyResetPassword } from '@/lib/auth';
|
|
4816
4579
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
4817
|
-
import {
|
|
4580
|
+
import { useTranslations } from '@/lib/translations';
|
|
4581
|
+
|
|
4582
|
+
export default function ResetPasswordPage() {
|
|
4583
|
+
const router = useRouter();
|
|
4584
|
+
const t = useTranslations('auth');
|
|
4585
|
+
|
|
4586
|
+
const [newPassword, setNewPassword] = useState('');
|
|
4587
|
+
const [confirmPassword, setConfirmPassword] = useState('');
|
|
4588
|
+
const [loading, setLoading] = useState(false);
|
|
4589
|
+
const [error, setError] = useState<string | null>(null);
|
|
4590
|
+
const [success, setSuccess] = useState(false);
|
|
4591
|
+
|
|
4592
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
4593
|
+
e.preventDefault();
|
|
4594
|
+
if (loading) return;
|
|
4595
|
+
|
|
4596
|
+
if (newPassword !== confirmPassword) {
|
|
4597
|
+
setError(t('passwordsMustMatch'));
|
|
4598
|
+
return;
|
|
4599
|
+
}
|
|
4600
|
+
|
|
4601
|
+
try {
|
|
4602
|
+
setLoading(true);
|
|
4603
|
+
setError(null);
|
|
4604
|
+
await proxyResetPassword(newPassword);
|
|
4605
|
+
setSuccess(true);
|
|
4606
|
+
setTimeout(() => router.push('/login'), 2000);
|
|
4607
|
+
} catch (err) {
|
|
4608
|
+
const message =
|
|
4609
|
+
err instanceof Error ? err.message : 'Something went wrong. Please try again.';
|
|
4610
|
+
setError(message);
|
|
4611
|
+
} finally {
|
|
4612
|
+
setLoading(false);
|
|
4613
|
+
}
|
|
4614
|
+
}
|
|
4615
|
+
|
|
4616
|
+
return (
|
|
4617
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
4618
|
+
<div className="w-full max-w-md space-y-6">
|
|
4619
|
+
<div className="text-center">
|
|
4620
|
+
<h1 className="text-foreground text-2xl font-bold">{t('resetPasswordTitle')}</h1>
|
|
4621
|
+
<p className="text-muted-foreground mt-1 text-sm">{t('resetPasswordSubtitle')}</p>
|
|
4622
|
+
</div>
|
|
4623
|
+
|
|
4624
|
+
{error && (
|
|
4625
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive space-y-2 rounded-lg border px-4 py-3 text-sm">
|
|
4626
|
+
<p>{error}</p>
|
|
4627
|
+
<Link
|
|
4628
|
+
href="/forgot-password"
|
|
4629
|
+
className="text-primary block font-medium hover:underline"
|
|
4630
|
+
>
|
|
4631
|
+
{t('sendResetLink')}
|
|
4632
|
+
</Link>
|
|
4633
|
+
</div>
|
|
4634
|
+
)}
|
|
4635
|
+
|
|
4636
|
+
{success ? (
|
|
4637
|
+
<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">
|
|
4638
|
+
{t('passwordResetSuccess')}
|
|
4639
|
+
</div>
|
|
4640
|
+
) : (
|
|
4641
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
4642
|
+
<div>
|
|
4643
|
+
<label
|
|
4644
|
+
htmlFor="new-password"
|
|
4645
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
4646
|
+
>
|
|
4647
|
+
{t('newPassword')}
|
|
4648
|
+
</label>
|
|
4649
|
+
<input
|
|
4650
|
+
id="new-password"
|
|
4651
|
+
type="password"
|
|
4652
|
+
required
|
|
4653
|
+
minLength={8}
|
|
4654
|
+
value={newPassword}
|
|
4655
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
4656
|
+
placeholder={t('newPasswordPlaceholder')}
|
|
4657
|
+
autoComplete="new-password"
|
|
4658
|
+
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"
|
|
4659
|
+
/>
|
|
4660
|
+
</div>
|
|
4661
|
+
|
|
4662
|
+
<div>
|
|
4663
|
+
<label
|
|
4664
|
+
htmlFor="confirm-password"
|
|
4665
|
+
className="text-foreground mb-1.5 block text-sm font-medium"
|
|
4666
|
+
>
|
|
4667
|
+
{t('confirmPassword')}
|
|
4668
|
+
</label>
|
|
4669
|
+
<input
|
|
4670
|
+
id="confirm-password"
|
|
4671
|
+
type="password"
|
|
4672
|
+
required
|
|
4673
|
+
minLength={8}
|
|
4674
|
+
value={confirmPassword}
|
|
4675
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
4676
|
+
placeholder={t('confirmPasswordPlaceholder')}
|
|
4677
|
+
autoComplete="new-password"
|
|
4678
|
+
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"
|
|
4679
|
+
/>
|
|
4680
|
+
</div>
|
|
4681
|
+
|
|
4682
|
+
<button
|
|
4683
|
+
type="submit"
|
|
4684
|
+
disabled={loading}
|
|
4685
|
+
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"
|
|
4686
|
+
>
|
|
4687
|
+
{loading ? (
|
|
4688
|
+
<>
|
|
4689
|
+
<LoadingSpinner
|
|
4690
|
+
size="sm"
|
|
4691
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
4692
|
+
/>
|
|
4693
|
+
{t('resettingPassword')}
|
|
4694
|
+
</>
|
|
4695
|
+
) : (
|
|
4696
|
+
t('resetPassword')
|
|
4697
|
+
)}
|
|
4698
|
+
</button>
|
|
4699
|
+
</form>
|
|
4700
|
+
)}
|
|
4701
|
+
</div>
|
|
4702
|
+
</div>
|
|
4703
|
+
);
|
|
4704
|
+
}
|
|
4705
|
+
`,
|
|
4706
|
+
"src/app/auth/callback/page.tsx": `'use client';
|
|
4707
|
+
|
|
4708
|
+
import { Suspense, useEffect, useState, useRef } from 'react';
|
|
4709
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
4710
|
+
import { useAuth } from '@/providers/store-provider';
|
|
4711
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
4712
|
+
import { useTranslations } from '@/lib/translations';
|
|
4713
|
+
|
|
4714
|
+
function OAuthCallbackContent() {
|
|
4715
|
+
const router = useRouter();
|
|
4716
|
+
const searchParams = useSearchParams();
|
|
4717
|
+
const auth = useAuth();
|
|
4718
|
+
const [error, setError] = useState<string | null>(null);
|
|
4719
|
+
const processedRef = useRef(false);
|
|
4720
|
+
const t = useTranslations('auth');
|
|
4721
|
+
|
|
4722
|
+
const oauthSuccess = searchParams.get('oauth_success');
|
|
4723
|
+
const oauthError = searchParams.get('oauth_error');
|
|
4724
|
+
// Token is no longer in URL \u2014 it was set as httpOnly cookie by /api/auth/oauth-callback
|
|
4725
|
+
|
|
4726
|
+
useEffect(() => {
|
|
4727
|
+
// Prevent double-processing in React StrictMode
|
|
4728
|
+
if (processedRef.current) return;
|
|
4729
|
+
processedRef.current = true;
|
|
4730
|
+
|
|
4731
|
+
if (oauthError) {
|
|
4732
|
+
setError(oauthError);
|
|
4733
|
+
return;
|
|
4734
|
+
}
|
|
4735
|
+
|
|
4736
|
+
if (oauthSuccess === 'true') {
|
|
4737
|
+
// Cookie was already set by the API route; refresh auth state
|
|
4738
|
+
auth.login().then(() => {
|
|
4739
|
+
router.push('/');
|
|
4740
|
+
});
|
|
4741
|
+
} else {
|
|
4742
|
+
setError(t('authFailedDesc'));
|
|
4743
|
+
}
|
|
4744
|
+
}, [oauthSuccess, oauthError, auth, router, t]);
|
|
4745
|
+
|
|
4746
|
+
if (error) {
|
|
4747
|
+
return (
|
|
4748
|
+
<div className="flex min-h-[60vh] items-center justify-center px-4 py-12">
|
|
4749
|
+
<div className="w-full max-w-md space-y-4 text-center">
|
|
4750
|
+
<svg
|
|
4751
|
+
className="text-destructive mx-auto h-12 w-12"
|
|
4752
|
+
fill="none"
|
|
4753
|
+
viewBox="0 0 24 24"
|
|
4754
|
+
stroke="currentColor"
|
|
4755
|
+
>
|
|
4756
|
+
<path
|
|
4757
|
+
strokeLinecap="round"
|
|
4758
|
+
strokeLinejoin="round"
|
|
4759
|
+
strokeWidth={1.5}
|
|
4760
|
+
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"
|
|
4761
|
+
/>
|
|
4762
|
+
</svg>
|
|
4763
|
+
<h1 className="text-foreground text-2xl font-bold">{t('authFailed')}</h1>
|
|
4764
|
+
<p className="text-muted-foreground text-sm">{error}</p>
|
|
4765
|
+
<button
|
|
4766
|
+
type="button"
|
|
4767
|
+
onClick={() => router.push('/login')}
|
|
4768
|
+
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
4769
|
+
>
|
|
4770
|
+
{t('backToLogin')}
|
|
4771
|
+
</button>
|
|
4772
|
+
</div>
|
|
4773
|
+
</div>
|
|
4774
|
+
);
|
|
4775
|
+
}
|
|
4776
|
+
|
|
4777
|
+
return (
|
|
4778
|
+
<div className="flex min-h-[60vh] flex-col items-center justify-center px-4 py-12">
|
|
4779
|
+
<LoadingSpinner size="lg" />
|
|
4780
|
+
<p className="text-muted-foreground mt-4">{t('completingSignIn')}</p>
|
|
4781
|
+
</div>
|
|
4782
|
+
);
|
|
4783
|
+
}
|
|
4784
|
+
|
|
4785
|
+
export default function OAuthCallbackPage() {
|
|
4786
|
+
return (
|
|
4787
|
+
<Suspense
|
|
4788
|
+
fallback={
|
|
4789
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
4790
|
+
<LoadingSpinner size="lg" />
|
|
4791
|
+
</div>
|
|
4792
|
+
}
|
|
4793
|
+
>
|
|
4794
|
+
<OAuthCallbackContent />
|
|
4795
|
+
</Suspense>
|
|
4796
|
+
);
|
|
4797
|
+
}
|
|
4798
|
+
`,
|
|
4799
|
+
"src/app/account/page.tsx": `'use client';
|
|
4800
|
+
|
|
4801
|
+
import { useEffect, useState } from 'react';
|
|
4802
|
+
import { useRouter } from 'next/navigation';
|
|
4803
|
+
import type { CustomerProfile, Order } from 'brainerce';
|
|
4804
|
+
import { getClient } from '@/lib/brainerce';
|
|
4805
|
+
import { useAuth } from '@/providers/store-provider';
|
|
4806
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
4807
|
+
import { ProfileSection } from '@/components/account/profile-section';
|
|
4808
|
+
import { AddressBook } from '@/components/account/address-book';
|
|
4818
4809
|
import { OrderHistory } from '@/components/account/order-history';
|
|
4819
4810
|
import { useTranslations } from '@/lib/translations';
|
|
4820
4811
|
|
|
@@ -4907,7 +4898,16 @@ export default function AccountPage() {
|
|
|
4907
4898
|
|
|
4908
4899
|
{/* Profile Section */}
|
|
4909
4900
|
{profile && (
|
|
4910
|
-
<ProfileSection profile={profile} onProfileUpdate={setProfile} className="mb-
|
|
4901
|
+
<ProfileSection profile={profile} onProfileUpdate={setProfile} className="mb-6" />
|
|
4902
|
+
)}
|
|
4903
|
+
|
|
4904
|
+
{/* Address Book */}
|
|
4905
|
+
{profile && (
|
|
4906
|
+
<AddressBook
|
|
4907
|
+
addresses={profile.addresses ?? []}
|
|
4908
|
+
onUpdate={(updated) => setProfile((p) => (p ? { ...p, addresses: updated } : p))}
|
|
4909
|
+
className="mb-8"
|
|
4910
|
+
/>
|
|
4911
4911
|
)}
|
|
4912
4912
|
|
|
4913
4913
|
{/* Order History */}
|
|
@@ -4996,9 +4996,11 @@ async function proxyRequest(
|
|
|
4996
4996
|
};
|
|
4997
4997
|
|
|
4998
4998
|
// Forward Origin/Referer so backend BrowserOriginGuard accepts proxied requests
|
|
4999
|
-
|
|
4999
|
+
// Always send Origin \u2014 same-origin GET requests may not include it, but the backend
|
|
5000
|
+
// uses its presence to distinguish fetch() calls from direct browser navigation
|
|
5001
|
+
const origin = request.headers.get('origin') || request.nextUrl.origin;
|
|
5000
5002
|
const referer = request.headers.get('referer');
|
|
5001
|
-
|
|
5003
|
+
headers['Origin'] = origin;
|
|
5002
5004
|
if (referer) headers['Referer'] = referer;
|
|
5003
5005
|
|
|
5004
5006
|
// Forward SDK version header if present
|
|
@@ -5177,7 +5179,7 @@ export async function GET(request: NextRequest) {
|
|
|
5177
5179
|
}
|
|
5178
5180
|
`,
|
|
5179
5181
|
"src/app/api/auth/me/route.ts": `import { NextResponse } from 'next/server';
|
|
5180
|
-
import { cookies } from 'next/headers';
|
|
5182
|
+
import { cookies, headers } from 'next/headers';
|
|
5181
5183
|
|
|
5182
5184
|
const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
|
|
5183
5185
|
/\\/$/,
|
|
@@ -5201,12 +5203,19 @@ export async function GET() {
|
|
|
5201
5203
|
return NextResponse.json({ isLoggedIn: false });
|
|
5202
5204
|
}
|
|
5203
5205
|
|
|
5206
|
+
// Derive Origin from the incoming request so the backend's BrowserOriginGuard accepts it
|
|
5207
|
+
const requestHeaders = await headers();
|
|
5208
|
+
const host = requestHeaders.get('host') || 'localhost:3000';
|
|
5209
|
+
const proto = requestHeaders.get('x-forwarded-proto') || 'http';
|
|
5210
|
+
const origin = requestHeaders.get('origin') || \`\${proto}://\${host}\`;
|
|
5211
|
+
|
|
5204
5212
|
try {
|
|
5205
5213
|
// Validate token by calling backend profile endpoint
|
|
5206
5214
|
const response = await fetch(\`\${BACKEND_URL}/api/vc/\${CONNECTION_ID}/customers/me\`, {
|
|
5207
5215
|
headers: {
|
|
5208
5216
|
Authorization: \`Bearer \${tokenCookie.value}\`,
|
|
5209
5217
|
'Content-Type': 'application/json',
|
|
5218
|
+
Origin: origin,
|
|
5210
5219
|
},
|
|
5211
5220
|
});
|
|
5212
5221
|
|
|
@@ -5240,6 +5249,126 @@ export async function POST() {
|
|
|
5240
5249
|
response.cookies.delete(LOGGED_IN_COOKIE);
|
|
5241
5250
|
return response;
|
|
5242
5251
|
}
|
|
5252
|
+
`,
|
|
5253
|
+
"src/app/api/auth/reset-callback/route.ts": `import { NextRequest, NextResponse } from 'next/server';
|
|
5254
|
+
|
|
5255
|
+
const RESET_TOKEN_COOKIE = 'brainerce_reset_token';
|
|
5256
|
+
const RESET_TOKEN_MAX_AGE = 10 * 60; // 10 minutes
|
|
5257
|
+
|
|
5258
|
+
function isSecure(): boolean {
|
|
5259
|
+
return process.env.NODE_ENV === 'production';
|
|
5260
|
+
}
|
|
5261
|
+
|
|
5262
|
+
/**
|
|
5263
|
+
* Password-reset callback handler.
|
|
5264
|
+
* The email link redirects here with ?token=... from the backend.
|
|
5265
|
+
* We store the token in an httpOnly cookie and redirect to /reset-password (clean URL).
|
|
5266
|
+
* This mirrors the OAuth callback pattern \u2014 the token never reaches client JS.
|
|
5267
|
+
*/
|
|
5268
|
+
export async function GET(request: NextRequest) {
|
|
5269
|
+
const { searchParams } = request.nextUrl;
|
|
5270
|
+
const token = searchParams.get('token');
|
|
5271
|
+
|
|
5272
|
+
if (!token) {
|
|
5273
|
+
const redirectUrl = new URL('/forgot-password', request.url);
|
|
5274
|
+
return NextResponse.redirect(redirectUrl);
|
|
5275
|
+
}
|
|
5276
|
+
|
|
5277
|
+
const redirectUrl = new URL('/reset-password', request.url);
|
|
5278
|
+
const response = NextResponse.redirect(redirectUrl);
|
|
5279
|
+
|
|
5280
|
+
// Set httpOnly cookie with the reset token (short-lived)
|
|
5281
|
+
response.cookies.set(RESET_TOKEN_COOKIE, token, {
|
|
5282
|
+
httpOnly: true,
|
|
5283
|
+
secure: isSecure(),
|
|
5284
|
+
sameSite: 'lax',
|
|
5285
|
+
path: '/',
|
|
5286
|
+
maxAge: RESET_TOKEN_MAX_AGE,
|
|
5287
|
+
});
|
|
5288
|
+
|
|
5289
|
+
// Prevent token leaking via Referer header
|
|
5290
|
+
response.headers.set('Referrer-Policy', 'no-referrer');
|
|
5291
|
+
|
|
5292
|
+
return response;
|
|
5293
|
+
}
|
|
5294
|
+
`,
|
|
5295
|
+
"src/app/api/auth/reset-password/route.ts": `import { NextRequest, NextResponse } from 'next/server';
|
|
5296
|
+
import { cookies, headers } from 'next/headers';
|
|
5297
|
+
|
|
5298
|
+
const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
|
|
5299
|
+
/\\/$/,
|
|
5300
|
+
''
|
|
5301
|
+
);
|
|
5302
|
+
|
|
5303
|
+
const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '';
|
|
5304
|
+
|
|
5305
|
+
const RESET_TOKEN_COOKIE = 'brainerce_reset_token';
|
|
5306
|
+
const CSRF_HEADER = 'x-requested-with';
|
|
5307
|
+
const CSRF_VALUE = 'brainerce';
|
|
5308
|
+
|
|
5309
|
+
/**
|
|
5310
|
+
* BFF endpoint for password reset.
|
|
5311
|
+
* Reads the reset token from the httpOnly cookie (set by /api/auth/reset-callback)
|
|
5312
|
+
* and proxies the request to the backend. The token never touches client JS.
|
|
5313
|
+
*/
|
|
5314
|
+
export async function POST(request: NextRequest) {
|
|
5315
|
+
// CSRF check
|
|
5316
|
+
const csrfHeader = request.headers.get(CSRF_HEADER);
|
|
5317
|
+
if (csrfHeader !== CSRF_VALUE) {
|
|
5318
|
+
return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 });
|
|
5319
|
+
}
|
|
5320
|
+
|
|
5321
|
+
// Read reset token from httpOnly cookie
|
|
5322
|
+
const cookieStore = await cookies();
|
|
5323
|
+
const resetTokenCookie = cookieStore.get(RESET_TOKEN_COOKIE);
|
|
5324
|
+
|
|
5325
|
+
if (!resetTokenCookie?.value) {
|
|
5326
|
+
return NextResponse.json(
|
|
5327
|
+
{ error: 'No reset token found. Please request a new password reset link.' },
|
|
5328
|
+
{ status: 400 }
|
|
5329
|
+
);
|
|
5330
|
+
}
|
|
5331
|
+
|
|
5332
|
+
// Parse request body
|
|
5333
|
+
const body = await request.json();
|
|
5334
|
+
const { newPassword } = body;
|
|
5335
|
+
|
|
5336
|
+
if (!newPassword) {
|
|
5337
|
+
return NextResponse.json({ error: 'New password is required' }, { status: 400 });
|
|
5338
|
+
}
|
|
5339
|
+
|
|
5340
|
+
// Derive Origin from the incoming request so the backend's BrowserOriginGuard accepts it
|
|
5341
|
+
const requestHeaders = await headers();
|
|
5342
|
+
const host = requestHeaders.get('host') || 'localhost:3000';
|
|
5343
|
+
const proto = requestHeaders.get('x-forwarded-proto') || 'http';
|
|
5344
|
+
const origin = requestHeaders.get('origin') || \`\${proto}://\${host}\`;
|
|
5345
|
+
|
|
5346
|
+
// Proxy to backend
|
|
5347
|
+
const backendUrl = \`\${BACKEND_URL}/api/vc/\${CONNECTION_ID}/customers/reset-password\`;
|
|
5348
|
+
|
|
5349
|
+
const backendResponse = await fetch(backendUrl, {
|
|
5350
|
+
method: 'POST',
|
|
5351
|
+
headers: {
|
|
5352
|
+
'Content-Type': 'application/json',
|
|
5353
|
+
Origin: origin,
|
|
5354
|
+
},
|
|
5355
|
+
body: JSON.stringify({
|
|
5356
|
+
token: resetTokenCookie.value,
|
|
5357
|
+
newPassword,
|
|
5358
|
+
}),
|
|
5359
|
+
});
|
|
5360
|
+
|
|
5361
|
+
const data = await backendResponse.json();
|
|
5362
|
+
|
|
5363
|
+
const response = NextResponse.json(data, {
|
|
5364
|
+
status: backendResponse.status,
|
|
5365
|
+
});
|
|
5366
|
+
|
|
5367
|
+
// Always clear the reset token cookie after use (success or failure)
|
|
5368
|
+
response.cookies.delete(RESET_TOKEN_COOKIE);
|
|
5369
|
+
|
|
5370
|
+
return response;
|
|
5371
|
+
}
|
|
5243
5372
|
`,
|
|
5244
5373
|
"src/middleware.ts": `import { NextRequest, NextResponse } from 'next/server';
|
|
5245
5374
|
|
|
@@ -5269,14 +5398,17 @@ export const config = {
|
|
|
5269
5398
|
`,
|
|
5270
5399
|
"src/components/products/product-card.tsx": `'use client';
|
|
5271
5400
|
|
|
5401
|
+
import { useState } from 'react';
|
|
5272
5402
|
import Link from 'next/link';
|
|
5403
|
+
import { useRouter } from 'next/navigation';
|
|
5273
5404
|
import Image from 'next/image';
|
|
5274
5405
|
import type { Product } from 'brainerce';
|
|
5275
|
-
import { getProductPriceInfo } from 'brainerce';
|
|
5406
|
+
import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
|
|
5276
5407
|
import { useTranslations } from '@/lib/translations';
|
|
5277
5408
|
import { PriceDisplay } from '@/components/shared/price-display';
|
|
5278
5409
|
import { StockBadge } from '@/components/products/stock-badge';
|
|
5279
5410
|
import { DiscountBadge } from '@/components/products/discount-badge';
|
|
5411
|
+
import { useCart, useStoreInfo } from '@/providers/store-provider';
|
|
5280
5412
|
import { cn } from '@/lib/utils';
|
|
5281
5413
|
|
|
5282
5414
|
interface ProductCardProps {
|
|
@@ -5284,60 +5416,167 @@ interface ProductCardProps {
|
|
|
5284
5416
|
className?: string;
|
|
5285
5417
|
}
|
|
5286
5418
|
|
|
5419
|
+
function VariantPriceRange({ product }: { product: Product }) {
|
|
5420
|
+
const { storeInfo } = useStoreInfo();
|
|
5421
|
+
const currency = storeInfo?.currency || 'USD';
|
|
5422
|
+
const variants = product.variants ?? [];
|
|
5423
|
+
if (variants.length === 0) return null;
|
|
5424
|
+
|
|
5425
|
+
const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
|
|
5426
|
+
const min = Math.min(...prices);
|
|
5427
|
+
const max = Math.max(...prices);
|
|
5428
|
+
|
|
5429
|
+
return (
|
|
5430
|
+
<span className="text-foreground text-sm font-medium">
|
|
5431
|
+
{min === max
|
|
5432
|
+
? (formatPrice(min, { currency }) as string)
|
|
5433
|
+
: \`\${formatPrice(min, { currency })} \u2013 \${formatPrice(max, { currency })}\`}
|
|
5434
|
+
</span>
|
|
5435
|
+
);
|
|
5436
|
+
}
|
|
5437
|
+
|
|
5287
5438
|
export function ProductCard({ product, className }: ProductCardProps) {
|
|
5288
5439
|
const t = useTranslations('common');
|
|
5289
5440
|
const tp = useTranslations('productDetail');
|
|
5441
|
+
const tProd = useTranslations('products');
|
|
5442
|
+
const router = useRouter();
|
|
5443
|
+
const { refreshCart } = useCart();
|
|
5290
5444
|
const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
|
|
5291
5445
|
const mainImage = product.images?.[0];
|
|
5292
5446
|
const imageUrl = mainImage?.url || null;
|
|
5293
5447
|
const slug = product.slug || product.id;
|
|
5448
|
+
const isVariable = product.type === 'VARIABLE';
|
|
5449
|
+
|
|
5450
|
+
const [adding, setAdding] = useState(false);
|
|
5451
|
+
const [added, setAdded] = useState(false);
|
|
5452
|
+
|
|
5453
|
+
const canPurchase = product.inventory?.canPurchase !== false;
|
|
5454
|
+
|
|
5455
|
+
async function handleAddToCart(e: React.MouseEvent) {
|
|
5456
|
+
e.preventDefault();
|
|
5457
|
+
e.stopPropagation();
|
|
5458
|
+
|
|
5459
|
+
if (isVariable) {
|
|
5460
|
+
router.push(\`/products/\${slug}\`);
|
|
5461
|
+
return;
|
|
5462
|
+
}
|
|
5463
|
+
|
|
5464
|
+
if (adding || !canPurchase) return;
|
|
5465
|
+
|
|
5466
|
+
try {
|
|
5467
|
+
setAdding(true);
|
|
5468
|
+
const { getClient } = await import('@/lib/brainerce');
|
|
5469
|
+
const client = getClient();
|
|
5470
|
+
await client.smartAddToCart({ productId: product.id, quantity: 1 });
|
|
5471
|
+
await refreshCart();
|
|
5472
|
+
setAdded(true);
|
|
5473
|
+
setTimeout(() => setAdded(false), 2000);
|
|
5474
|
+
} catch (err) {
|
|
5475
|
+
console.error('Failed to add to cart:', err);
|
|
5476
|
+
} finally {
|
|
5477
|
+
setAdding(false);
|
|
5478
|
+
}
|
|
5479
|
+
}
|
|
5294
5480
|
|
|
5295
5481
|
return (
|
|
5296
|
-
<
|
|
5297
|
-
href={\`/products/\${slug}\`}
|
|
5482
|
+
<div
|
|
5298
5483
|
className={cn(
|
|
5299
5484
|
'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
|
|
5300
5485
|
className
|
|
5301
5486
|
)}
|
|
5302
5487
|
>
|
|
5303
|
-
{/* Image */}
|
|
5304
|
-
<
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
<
|
|
5316
|
-
<
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
5488
|
+
{/* Image \u2014 clickable */}
|
|
5489
|
+
<Link href={\`/products/\${slug}\`} className="block">
|
|
5490
|
+
<div className="bg-muted relative aspect-square overflow-hidden">
|
|
5491
|
+
{imageUrl ? (
|
|
5492
|
+
<Image
|
|
5493
|
+
src={imageUrl}
|
|
5494
|
+
alt={mainImage?.alt || product.name}
|
|
5495
|
+
fill
|
|
5496
|
+
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
|
5497
|
+
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
|
5498
|
+
/>
|
|
5499
|
+
) : (
|
|
5500
|
+
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
5501
|
+
<svg className="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
5502
|
+
<path
|
|
5503
|
+
strokeLinecap="round"
|
|
5504
|
+
strokeLinejoin="round"
|
|
5505
|
+
strokeWidth={1.5}
|
|
5506
|
+
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"
|
|
5507
|
+
/>
|
|
5508
|
+
</svg>
|
|
5509
|
+
</div>
|
|
5510
|
+
)}
|
|
5511
|
+
|
|
5512
|
+
{/* Badges */}
|
|
5513
|
+
<div className="absolute start-2 top-2 flex flex-col gap-1">
|
|
5514
|
+
{isOnSale && (
|
|
5515
|
+
<span className="bg-destructive text-destructive-foreground rounded px-2 py-1 text-xs font-bold">
|
|
5516
|
+
{t('sale')}
|
|
5517
|
+
</span>
|
|
5518
|
+
)}
|
|
5519
|
+
<DiscountBadge discount={product.discount} />
|
|
5520
|
+
{product.isDownloadable && (
|
|
5521
|
+
<span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
|
|
5522
|
+
{tp('digitalProduct')}
|
|
5523
|
+
</span>
|
|
5524
|
+
)}
|
|
5323
5525
|
</div>
|
|
5324
|
-
)}
|
|
5325
5526
|
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
{
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5527
|
+
{/* Add to cart overlay button */}
|
|
5528
|
+
{(isVariable || canPurchase) && (
|
|
5529
|
+
<button
|
|
5530
|
+
onClick={handleAddToCart}
|
|
5531
|
+
disabled={adding}
|
|
5532
|
+
aria-label={isVariable ? tProd('selectOptions') : tp('addToCart')}
|
|
5533
|
+
className={cn(
|
|
5534
|
+
'absolute bottom-2 end-2 flex h-8 w-8 items-center justify-center rounded-full shadow-md transition-all',
|
|
5535
|
+
'translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100',
|
|
5536
|
+
added
|
|
5537
|
+
? 'bg-green-500 text-white'
|
|
5538
|
+
: 'bg-primary text-primary-foreground hover:opacity-90'
|
|
5539
|
+
)}
|
|
5540
|
+
>
|
|
5541
|
+
{added ? (
|
|
5542
|
+
<svg
|
|
5543
|
+
className="h-4 w-4"
|
|
5544
|
+
fill="none"
|
|
5545
|
+
viewBox="0 0 24 24"
|
|
5546
|
+
stroke="currentColor"
|
|
5547
|
+
strokeWidth={2.5}
|
|
5548
|
+
>
|
|
5549
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
5550
|
+
</svg>
|
|
5551
|
+
) : isVariable ? (
|
|
5552
|
+
<svg
|
|
5553
|
+
className="h-4 w-4"
|
|
5554
|
+
fill="none"
|
|
5555
|
+
viewBox="0 0 24 24"
|
|
5556
|
+
stroke="currentColor"
|
|
5557
|
+
strokeWidth={2}
|
|
5558
|
+
>
|
|
5559
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
|
|
5560
|
+
</svg>
|
|
5561
|
+
) : (
|
|
5562
|
+
<svg
|
|
5563
|
+
className="h-4 w-4"
|
|
5564
|
+
fill="none"
|
|
5565
|
+
viewBox="0 0 24 24"
|
|
5566
|
+
stroke="currentColor"
|
|
5567
|
+
strokeWidth={2}
|
|
5568
|
+
>
|
|
5569
|
+
<path
|
|
5570
|
+
strokeLinecap="round"
|
|
5571
|
+
strokeLinejoin="round"
|
|
5572
|
+
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"
|
|
5573
|
+
/>
|
|
5574
|
+
</svg>
|
|
5575
|
+
)}
|
|
5576
|
+
</button>
|
|
5338
5577
|
)}
|
|
5339
5578
|
</div>
|
|
5340
|
-
</
|
|
5579
|
+
</Link>
|
|
5341
5580
|
|
|
5342
5581
|
{/* Content */}
|
|
5343
5582
|
<div className="space-y-2 p-3">
|
|
@@ -5355,18 +5594,24 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
5355
5594
|
</div>
|
|
5356
5595
|
)}
|
|
5357
5596
|
|
|
5358
|
-
{/* Name */}
|
|
5359
|
-
<
|
|
5360
|
-
|
|
5361
|
-
|
|
5597
|
+
{/* Name \u2014 clickable */}
|
|
5598
|
+
<Link href={\`/products/\${slug}\`}>
|
|
5599
|
+
<h3 className="text-foreground hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
|
|
5600
|
+
{product.name}
|
|
5601
|
+
</h3>
|
|
5602
|
+
</Link>
|
|
5362
5603
|
|
|
5363
5604
|
{/* Price */}
|
|
5364
|
-
|
|
5605
|
+
{isVariable ? (
|
|
5606
|
+
<VariantPriceRange product={product} />
|
|
5607
|
+
) : (
|
|
5608
|
+
<PriceDisplay price={originalPrice} salePrice={isOnSale ? price : undefined} size="sm" />
|
|
5609
|
+
)}
|
|
5365
5610
|
|
|
5366
5611
|
{/* Stock */}
|
|
5367
5612
|
<StockBadge inventory={product.inventory} />
|
|
5368
5613
|
</div>
|
|
5369
|
-
</
|
|
5614
|
+
</div>
|
|
5370
5615
|
);
|
|
5371
5616
|
}
|
|
5372
5617
|
`,
|
|
@@ -5410,8 +5655,10 @@ export function ProductGrid({ products, className }: ProductGridProps) {
|
|
|
5410
5655
|
|
|
5411
5656
|
import { useMemo } from 'react';
|
|
5412
5657
|
import type { Product, ProductVariant } from 'brainerce';
|
|
5413
|
-
import { getVariantOptions,
|
|
5658
|
+
import { getVariantOptions, getProductSwatches, formatPrice } from 'brainerce';
|
|
5659
|
+
import type { InventoryInfo } from 'brainerce';
|
|
5414
5660
|
import { useStoreInfo } from '@/providers/store-provider';
|
|
5661
|
+
import { useTranslations } from '@/lib/translations';
|
|
5415
5662
|
import { cn } from '@/lib/utils';
|
|
5416
5663
|
|
|
5417
5664
|
interface VariantSelectorProps {
|
|
@@ -5423,8 +5670,12 @@ interface VariantSelectorProps {
|
|
|
5423
5670
|
|
|
5424
5671
|
interface AttributeGroup {
|
|
5425
5672
|
name: string;
|
|
5673
|
+
displayType: string;
|
|
5426
5674
|
values: Array<{
|
|
5427
5675
|
value: string;
|
|
5676
|
+
swatchColor?: string | null;
|
|
5677
|
+
swatchColor2?: string | null;
|
|
5678
|
+
swatchImageUrl?: string | null;
|
|
5428
5679
|
variants: ProductVariant[];
|
|
5429
5680
|
}>;
|
|
5430
5681
|
}
|
|
@@ -5436,10 +5687,49 @@ export function VariantSelector({
|
|
|
5436
5687
|
className,
|
|
5437
5688
|
}: VariantSelectorProps) {
|
|
5438
5689
|
const { storeInfo } = useStoreInfo();
|
|
5690
|
+
const t = useTranslations('productDetail');
|
|
5439
5691
|
const currency = storeInfo?.currency || 'USD';
|
|
5440
5692
|
const variants = useMemo(() => product.variants || [], [product.variants]);
|
|
5441
5693
|
|
|
5442
|
-
//
|
|
5694
|
+
// Get swatch metadata from product attribute options
|
|
5695
|
+
const swatchData = useMemo(() => getProductSwatches(product), [product]);
|
|
5696
|
+
const swatchMap = useMemo(() => {
|
|
5697
|
+
const map = new Map<
|
|
5698
|
+
string,
|
|
5699
|
+
{
|
|
5700
|
+
displayType: string;
|
|
5701
|
+
options: Map<
|
|
5702
|
+
string,
|
|
5703
|
+
{
|
|
5704
|
+
swatchColor?: string | null;
|
|
5705
|
+
swatchColor2?: string | null;
|
|
5706
|
+
swatchImageUrl?: string | null;
|
|
5707
|
+
}
|
|
5708
|
+
>;
|
|
5709
|
+
}
|
|
5710
|
+
>();
|
|
5711
|
+
for (const attr of swatchData) {
|
|
5712
|
+
const optMap = new Map<
|
|
5713
|
+
string,
|
|
5714
|
+
{
|
|
5715
|
+
swatchColor?: string | null;
|
|
5716
|
+
swatchColor2?: string | null;
|
|
5717
|
+
swatchImageUrl?: string | null;
|
|
5718
|
+
}
|
|
5719
|
+
>();
|
|
5720
|
+
for (const opt of attr.options) {
|
|
5721
|
+
optMap.set(opt.name, {
|
|
5722
|
+
swatchColor: opt.swatchColor,
|
|
5723
|
+
swatchColor2: opt.swatchColor2,
|
|
5724
|
+
swatchImageUrl: opt.swatchImageUrl,
|
|
5725
|
+
});
|
|
5726
|
+
}
|
|
5727
|
+
map.set(attr.attributeName, { displayType: attr.displayType, options: optMap });
|
|
5728
|
+
}
|
|
5729
|
+
return map;
|
|
5730
|
+
}, [swatchData]);
|
|
5731
|
+
|
|
5732
|
+
// Build attribute groups from variant data, enriched with swatch info
|
|
5443
5733
|
const attributeGroups = useMemo<AttributeGroup[]>(() => {
|
|
5444
5734
|
const groups = new Map<string, Map<string, ProductVariant[]>>();
|
|
5445
5735
|
|
|
@@ -5457,14 +5747,24 @@ export function VariantSelector({
|
|
|
5457
5747
|
}
|
|
5458
5748
|
}
|
|
5459
5749
|
|
|
5460
|
-
return Array.from(groups.entries()).map(([name, valuesMap]) =>
|
|
5461
|
-
name
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5750
|
+
return Array.from(groups.entries()).map(([name, valuesMap]) => {
|
|
5751
|
+
const attrSwatch = swatchMap.get(name);
|
|
5752
|
+
return {
|
|
5753
|
+
name,
|
|
5754
|
+
displayType: attrSwatch?.displayType || 'DROPDOWN',
|
|
5755
|
+
values: Array.from(valuesMap.entries()).map(([value, variantList]) => {
|
|
5756
|
+
const optSwatch = attrSwatch?.options.get(value);
|
|
5757
|
+
return {
|
|
5758
|
+
value,
|
|
5759
|
+
swatchColor: optSwatch?.swatchColor,
|
|
5760
|
+
swatchColor2: optSwatch?.swatchColor2,
|
|
5761
|
+
swatchImageUrl: optSwatch?.swatchImageUrl,
|
|
5762
|
+
variants: variantList,
|
|
5763
|
+
};
|
|
5764
|
+
}),
|
|
5765
|
+
};
|
|
5766
|
+
});
|
|
5767
|
+
}, [variants, swatchMap]);
|
|
5468
5768
|
|
|
5469
5769
|
// Get currently selected attribute values
|
|
5470
5770
|
const selectedOptions = useMemo(() => {
|
|
@@ -5504,33 +5804,111 @@ export function VariantSelector({
|
|
|
5504
5804
|
)}
|
|
5505
5805
|
</label>
|
|
5506
5806
|
<div className="flex flex-wrap gap-2">
|
|
5507
|
-
{group.values.map(
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5807
|
+
{group.values.map(
|
|
5808
|
+
({
|
|
5809
|
+
value,
|
|
5810
|
+
swatchColor,
|
|
5811
|
+
swatchColor2,
|
|
5812
|
+
swatchImageUrl,
|
|
5813
|
+
variants: matchingVariants,
|
|
5814
|
+
}) => {
|
|
5815
|
+
const isSelected = selectedOptions.get(group.name) === value;
|
|
5816
|
+
const matchedVariant = findMatchingVariant(group.name, value);
|
|
5817
|
+
const isAvailable = matchedVariant?.inventory?.canPurchase !== false;
|
|
5818
|
+
|
|
5819
|
+
// Color swatch rendering
|
|
5820
|
+
if (group.displayType === 'COLOR_SWATCH' && swatchColor) {
|
|
5821
|
+
return (
|
|
5822
|
+
<button
|
|
5823
|
+
key={value}
|
|
5824
|
+
type="button"
|
|
5825
|
+
disabled={!isAvailable}
|
|
5826
|
+
title={value}
|
|
5827
|
+
onClick={() => {
|
|
5828
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
5829
|
+
if (variant) onVariantChange(variant);
|
|
5830
|
+
}}
|
|
5831
|
+
className={cn(
|
|
5832
|
+
'h-9 w-9 rounded-full border-2 transition-all',
|
|
5833
|
+
isSelected
|
|
5834
|
+
? 'border-primary ring-primary/30 ring-2'
|
|
5835
|
+
: isAvailable
|
|
5836
|
+
? 'border-border hover:border-primary'
|
|
5837
|
+
: 'cursor-not-allowed opacity-40'
|
|
5838
|
+
)}
|
|
5839
|
+
style={{
|
|
5840
|
+
background: swatchColor2
|
|
5841
|
+
? \`linear-gradient(135deg, \${swatchColor} 50%, \${swatchColor2} 50%)\`
|
|
5842
|
+
: swatchColor,
|
|
5843
|
+
}}
|
|
5844
|
+
>
|
|
5845
|
+
{!isAvailable && (
|
|
5846
|
+
<span
|
|
5847
|
+
className="bg-muted-foreground block h-full w-full rounded-full opacity-50"
|
|
5848
|
+
style={{
|
|
5849
|
+
backgroundImage:
|
|
5850
|
+
'linear-gradient(135deg, transparent 45%, currentColor 45%, currentColor 55%, transparent 55%)',
|
|
5851
|
+
}}
|
|
5852
|
+
/>
|
|
5853
|
+
)}
|
|
5854
|
+
</button>
|
|
5855
|
+
);
|
|
5856
|
+
}
|
|
5511
5857
|
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5858
|
+
// Image swatch rendering
|
|
5859
|
+
if (group.displayType === 'IMAGE_SWATCH' && swatchImageUrl) {
|
|
5860
|
+
return (
|
|
5861
|
+
<button
|
|
5862
|
+
key={value}
|
|
5863
|
+
type="button"
|
|
5864
|
+
disabled={!isAvailable}
|
|
5865
|
+
title={value}
|
|
5866
|
+
onClick={() => {
|
|
5867
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
5868
|
+
if (variant) onVariantChange(variant);
|
|
5869
|
+
}}
|
|
5870
|
+
className={cn(
|
|
5871
|
+
'h-10 w-10 overflow-hidden rounded-lg border-2 transition-all',
|
|
5872
|
+
isSelected
|
|
5873
|
+
? 'border-primary ring-primary/30 ring-2'
|
|
5874
|
+
: isAvailable
|
|
5875
|
+
? 'border-border hover:border-primary'
|
|
5876
|
+
: 'cursor-not-allowed opacity-40'
|
|
5877
|
+
)}
|
|
5878
|
+
>
|
|
5879
|
+
<img
|
|
5880
|
+
src={swatchImageUrl}
|
|
5881
|
+
alt={value}
|
|
5882
|
+
className="h-full w-full object-cover"
|
|
5883
|
+
/>
|
|
5884
|
+
</button>
|
|
5885
|
+
);
|
|
5886
|
+
}
|
|
5887
|
+
|
|
5888
|
+
// Default button rendering (BUTTON, DROPDOWN, or fallback)
|
|
5889
|
+
return (
|
|
5890
|
+
<button
|
|
5891
|
+
key={value}
|
|
5892
|
+
type="button"
|
|
5893
|
+
disabled={!isAvailable}
|
|
5894
|
+
onClick={() => {
|
|
5895
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
5896
|
+
if (variant) onVariantChange(variant);
|
|
5897
|
+
}}
|
|
5898
|
+
className={cn(
|
|
5899
|
+
'rounded border px-4 py-2 text-sm transition-colors',
|
|
5900
|
+
isSelected
|
|
5901
|
+
? 'border-primary bg-primary text-primary-foreground'
|
|
5902
|
+
: isAvailable
|
|
5903
|
+
? 'border-border bg-background text-foreground hover:border-primary'
|
|
5904
|
+
: 'border-border bg-muted text-muted-foreground cursor-not-allowed line-through opacity-50'
|
|
5905
|
+
)}
|
|
5906
|
+
>
|
|
5907
|
+
{value}
|
|
5908
|
+
</button>
|
|
5909
|
+
);
|
|
5910
|
+
}
|
|
5911
|
+
)}
|
|
5534
5912
|
</div>
|
|
5535
5913
|
</div>
|
|
5536
5914
|
))}
|
|
@@ -5547,18 +5925,30 @@ export function VariantSelector({
|
|
|
5547
5925
|
}
|
|
5548
5926
|
</span>
|
|
5549
5927
|
)}
|
|
5550
|
-
<span>{
|
|
5928
|
+
<span>{getTranslatedStockStatus(selectedVariant.inventory, t)}</span>
|
|
5551
5929
|
</div>
|
|
5552
5930
|
)}
|
|
5553
5931
|
</div>
|
|
5554
5932
|
);
|
|
5555
5933
|
}
|
|
5934
|
+
|
|
5935
|
+
function getTranslatedStockStatus(
|
|
5936
|
+
inventory: InventoryInfo | null | undefined,
|
|
5937
|
+
t: (key: string) => string
|
|
5938
|
+
): string {
|
|
5939
|
+
if (!inventory) return t('outOfStock');
|
|
5940
|
+
const { trackingMode, inStock, available } = inventory;
|
|
5941
|
+
if (trackingMode === 'DISABLED') return t('unavailable');
|
|
5942
|
+
if (!inStock) return t('outOfStock');
|
|
5943
|
+
if (trackingMode === 'UNLIMITED') return t('inStock');
|
|
5944
|
+
return t('availableInStock').replace('{available}', String(available));
|
|
5945
|
+
}
|
|
5556
5946
|
`,
|
|
5557
5947
|
"src/components/products/stock-badge.tsx": `'use client';
|
|
5558
5948
|
|
|
5559
|
-
import { getStockStatus } from 'brainerce';
|
|
5560
5949
|
import type { InventoryInfo } from 'brainerce';
|
|
5561
5950
|
import { cn } from '@/lib/utils';
|
|
5951
|
+
import { useTranslations } from '@/lib/translations';
|
|
5562
5952
|
|
|
5563
5953
|
interface StockBadgeProps {
|
|
5564
5954
|
inventory: InventoryInfo | null | undefined;
|
|
@@ -5567,27 +5957,56 @@ interface StockBadgeProps {
|
|
|
5567
5957
|
}
|
|
5568
5958
|
|
|
5569
5959
|
export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
|
|
5570
|
-
const
|
|
5571
|
-
|
|
5572
|
-
const
|
|
5573
|
-
status === 'Out of Stock' || status === 'Unavailable'
|
|
5574
|
-
? 'bg-red-100 text-red-800'
|
|
5575
|
-
: status === 'Low Stock'
|
|
5576
|
-
? 'bg-yellow-100 text-yellow-800'
|
|
5577
|
-
: 'bg-green-100 text-green-800';
|
|
5960
|
+
const t = useTranslations('productDetail');
|
|
5961
|
+
const label = getStockLabel(inventory, lowStockThreshold, t);
|
|
5962
|
+
const color = getStockColor(inventory, lowStockThreshold);
|
|
5578
5963
|
|
|
5579
5964
|
return (
|
|
5580
5965
|
<span
|
|
5581
5966
|
className={cn(
|
|
5582
5967
|
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
|
5583
|
-
|
|
5968
|
+
color,
|
|
5584
5969
|
className
|
|
5585
5970
|
)}
|
|
5586
5971
|
>
|
|
5587
|
-
{
|
|
5972
|
+
{label}
|
|
5588
5973
|
</span>
|
|
5589
5974
|
);
|
|
5590
5975
|
}
|
|
5976
|
+
|
|
5977
|
+
function getStockLabel(
|
|
5978
|
+
inventory: InventoryInfo | null | undefined,
|
|
5979
|
+
lowStockThreshold: number,
|
|
5980
|
+
t: (key: string) => string
|
|
5981
|
+
): string {
|
|
5982
|
+
if (!inventory) return t('outOfStock');
|
|
5983
|
+
|
|
5984
|
+
const { trackingMode, inStock, available } = inventory;
|
|
5985
|
+
|
|
5986
|
+
if (trackingMode === 'DISABLED') return t('unavailable');
|
|
5987
|
+
if (!inStock) return t('outOfStock');
|
|
5988
|
+
if (trackingMode === 'UNLIMITED') return t('inStock');
|
|
5989
|
+
|
|
5990
|
+
// TRACKED \u2014 show actual quantity
|
|
5991
|
+
if (available <= lowStockThreshold) {
|
|
5992
|
+
return t('onlyLeft').replace('{available}', String(available));
|
|
5993
|
+
}
|
|
5994
|
+
return t('availableInStock').replace('{available}', String(available));
|
|
5995
|
+
}
|
|
5996
|
+
|
|
5997
|
+
function getStockColor(
|
|
5998
|
+
inventory: InventoryInfo | null | undefined,
|
|
5999
|
+
lowStockThreshold: number
|
|
6000
|
+
): string {
|
|
6001
|
+
if (!inventory) return 'bg-red-100 text-red-800';
|
|
6002
|
+
|
|
6003
|
+
const { trackingMode, inStock, available } = inventory;
|
|
6004
|
+
|
|
6005
|
+
if (trackingMode === 'DISABLED' || !inStock) return 'bg-red-100 text-red-800';
|
|
6006
|
+
if (trackingMode === 'TRACKED' && available <= lowStockThreshold)
|
|
6007
|
+
return 'bg-yellow-100 text-yellow-800';
|
|
6008
|
+
return 'bg-green-100 text-green-800';
|
|
6009
|
+
}
|
|
5591
6010
|
`,
|
|
5592
6011
|
"src/components/products/discount-badge.tsx": `import type { ProductDiscount } from 'brainerce';
|
|
5593
6012
|
import { cn } from '@/lib/utils';
|
|
@@ -5617,7 +6036,7 @@ export function DiscountBadge({ discount, className }: DiscountBadgeProps) {
|
|
|
5617
6036
|
import { useState } from 'react';
|
|
5618
6037
|
import Image from 'next/image';
|
|
5619
6038
|
import type { CartItem as CartItemType } from 'brainerce';
|
|
5620
|
-
import {
|
|
6039
|
+
import { getCartItemImage, formatPrice } from 'brainerce';
|
|
5621
6040
|
import { getClient } from '@/lib/brainerce';
|
|
5622
6041
|
import { useTranslations } from '@/lib/translations';
|
|
5623
6042
|
import { useStoreInfo } from '@/providers/store-provider';
|
|
@@ -5638,7 +6057,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
5638
6057
|
const [updating, setUpdating] = useState(false);
|
|
5639
6058
|
const [removing, setRemoving] = useState(false);
|
|
5640
6059
|
|
|
5641
|
-
const
|
|
6060
|
+
const productName = item.product.name;
|
|
5642
6061
|
const imageUrl = getCartItemImage(item);
|
|
5643
6062
|
const variantName = item.variant?.name;
|
|
5644
6063
|
const unitPrice = parseFloat(item.unitPrice);
|
|
@@ -5685,7 +6104,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
5685
6104
|
{/* Image */}
|
|
5686
6105
|
<div className="bg-muted relative h-20 w-20 flex-shrink-0 overflow-hidden rounded">
|
|
5687
6106
|
{imageUrl ? (
|
|
5688
|
-
<Image src={imageUrl} alt={
|
|
6107
|
+
<Image src={imageUrl} alt={productName} fill sizes="80px" className="object-cover" />
|
|
5689
6108
|
) : (
|
|
5690
6109
|
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
5691
6110
|
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
@@ -5702,7 +6121,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
5702
6121
|
|
|
5703
6122
|
{/* Details */}
|
|
5704
6123
|
<div className="min-w-0 flex-1">
|
|
5705
|
-
<h3 className="text-foreground truncate text-sm font-medium">{
|
|
6124
|
+
<h3 className="text-foreground truncate text-sm font-medium">{productName}</h3>
|
|
5706
6125
|
|
|
5707
6126
|
{/* Variant name */}
|
|
5708
6127
|
{variantName && <p className="text-muted-foreground mt-1 text-xs">{variantName}</p>}
|
|
@@ -6156,17 +6575,24 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
|
|
|
6156
6575
|
`,
|
|
6157
6576
|
"src/components/checkout/checkout-form.tsx": `'use client';
|
|
6158
6577
|
|
|
6159
|
-
import { useState } from 'react';
|
|
6578
|
+
import { useState, useEffect, useRef } from 'react';
|
|
6160
6579
|
import type { SetShippingAddressDto, ShippingDestinations } from 'brainerce';
|
|
6161
6580
|
import { useTranslations } from '@/lib/translations';
|
|
6162
6581
|
import { cn } from '@/lib/utils';
|
|
6163
6582
|
|
|
6583
|
+
interface CheckoutConsent {
|
|
6584
|
+
acceptsMarketing: boolean;
|
|
6585
|
+
saveDetails: boolean;
|
|
6586
|
+
}
|
|
6587
|
+
|
|
6164
6588
|
interface CheckoutFormProps {
|
|
6165
|
-
onSubmit: (address: SetShippingAddressDto) => void;
|
|
6589
|
+
onSubmit: (address: SetShippingAddressDto, consent: CheckoutConsent) => void;
|
|
6166
6590
|
loading?: boolean;
|
|
6167
6591
|
initialValues?: Partial<SetShippingAddressDto>;
|
|
6168
6592
|
destinations?: ShippingDestinations | null;
|
|
6169
6593
|
className?: string;
|
|
6594
|
+
showSaveDetails?: boolean;
|
|
6595
|
+
emailOnly?: boolean;
|
|
6170
6596
|
}
|
|
6171
6597
|
|
|
6172
6598
|
export function CheckoutForm({
|
|
@@ -6175,6 +6601,8 @@ export function CheckoutForm({
|
|
|
6175
6601
|
initialValues,
|
|
6176
6602
|
destinations,
|
|
6177
6603
|
className,
|
|
6604
|
+
showSaveDetails = false,
|
|
6605
|
+
emailOnly = false,
|
|
6178
6606
|
}: CheckoutFormProps) {
|
|
6179
6607
|
const [formData, setFormData] = useState<SetShippingAddressDto>({
|
|
6180
6608
|
email: initialValues?.email || '',
|
|
@@ -6189,8 +6617,30 @@ export function CheckoutForm({
|
|
|
6189
6617
|
phone: initialValues?.phone || '',
|
|
6190
6618
|
});
|
|
6191
6619
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
6620
|
+
const [privacyAccepted, setPrivacyAccepted] = useState(false);
|
|
6621
|
+
const [acceptsMarketing, setAcceptsMarketing] = useState(false);
|
|
6622
|
+
const [saveDetails, setSaveDetails] = useState(true);
|
|
6192
6623
|
const t = useTranslations('checkoutForm');
|
|
6193
6624
|
const tc = useTranslations('common');
|
|
6625
|
+
const hasAppliedPrefill = useRef(!!initialValues);
|
|
6626
|
+
|
|
6627
|
+
// Sync prefill data when it arrives async (e.g. from getCheckoutPrefillData)
|
|
6628
|
+
useEffect(() => {
|
|
6629
|
+
if (!initialValues || hasAppliedPrefill.current) return;
|
|
6630
|
+
hasAppliedPrefill.current = true;
|
|
6631
|
+
setFormData((prev) => ({
|
|
6632
|
+
email: initialValues.email || prev.email,
|
|
6633
|
+
firstName: initialValues.firstName || prev.firstName,
|
|
6634
|
+
lastName: initialValues.lastName || prev.lastName,
|
|
6635
|
+
line1: initialValues.line1 || prev.line1,
|
|
6636
|
+
line2: initialValues.line2 || prev.line2 || '',
|
|
6637
|
+
city: initialValues.city || prev.city,
|
|
6638
|
+
region: initialValues.region || prev.region || '',
|
|
6639
|
+
postalCode: initialValues.postalCode || prev.postalCode,
|
|
6640
|
+
country: initialValues.country || prev.country,
|
|
6641
|
+
phone: initialValues.phone || prev.phone || '',
|
|
6642
|
+
}));
|
|
6643
|
+
}, [initialValues]);
|
|
6194
6644
|
|
|
6195
6645
|
const hasCountryOptions = destinations && destinations.countries.length > 0;
|
|
6196
6646
|
const countryRegions = destinations?.regions[formData.country];
|
|
@@ -6211,17 +6661,22 @@ export function CheckoutForm({
|
|
|
6211
6661
|
if (!formData.lastName.trim()) {
|
|
6212
6662
|
newErrors.lastName = t('lastNameRequired');
|
|
6213
6663
|
}
|
|
6214
|
-
if (!
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6664
|
+
if (!emailOnly) {
|
|
6665
|
+
if (!formData.line1.trim()) {
|
|
6666
|
+
newErrors.line1 = t('addressRequired');
|
|
6667
|
+
}
|
|
6668
|
+
if (!formData.city.trim()) {
|
|
6669
|
+
newErrors.city = t('cityRequired');
|
|
6670
|
+
}
|
|
6671
|
+
if (!formData.postalCode.trim()) {
|
|
6672
|
+
newErrors.postalCode = t('postalCodeRequired');
|
|
6673
|
+
}
|
|
6674
|
+
if (!formData.country.trim()) {
|
|
6675
|
+
newErrors.country = t('countryRequired');
|
|
6676
|
+
}
|
|
6222
6677
|
}
|
|
6223
|
-
if (!
|
|
6224
|
-
newErrors.
|
|
6678
|
+
if (!privacyAccepted) {
|
|
6679
|
+
newErrors.privacy = t('privacyRequired');
|
|
6225
6680
|
}
|
|
6226
6681
|
|
|
6227
6682
|
setErrors(newErrors);
|
|
@@ -6231,7 +6686,7 @@ export function CheckoutForm({
|
|
|
6231
6686
|
function handleSubmit(e: React.FormEvent) {
|
|
6232
6687
|
e.preventDefault();
|
|
6233
6688
|
if (validate()) {
|
|
6234
|
-
onSubmit(formData);
|
|
6689
|
+
onSubmit(formData, { acceptsMarketing, saveDetails: showSaveDetails && saveDetails });
|
|
6235
6690
|
}
|
|
6236
6691
|
}
|
|
6237
6692
|
|
|
@@ -6307,154 +6762,228 @@ export function CheckoutForm({
|
|
|
6307
6762
|
</div>
|
|
6308
6763
|
</div>
|
|
6309
6764
|
|
|
6310
|
-
{
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
<
|
|
6314
|
-
|
|
6315
|
-
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
|
|
6325
|
-
|
|
6326
|
-
|
|
6327
|
-
|
|
6328
|
-
|
|
6329
|
-
|
|
6330
|
-
|
|
6765
|
+
{!emailOnly && (
|
|
6766
|
+
<>
|
|
6767
|
+
{/* Country + Region row */}
|
|
6768
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
6769
|
+
<div>
|
|
6770
|
+
<label htmlFor="country" className="text-foreground mb-1 block text-sm font-medium">
|
|
6771
|
+
{t('country')} <span className="text-destructive">*</span>
|
|
6772
|
+
</label>
|
|
6773
|
+
{hasCountryOptions ? (
|
|
6774
|
+
<select
|
|
6775
|
+
id="country"
|
|
6776
|
+
value={formData.country}
|
|
6777
|
+
onChange={(e) => updateField('country', e.target.value)}
|
|
6778
|
+
className={cn(
|
|
6779
|
+
selectClass,
|
|
6780
|
+
errors.country ? 'border-destructive' : 'border-border'
|
|
6781
|
+
)}
|
|
6782
|
+
>
|
|
6783
|
+
<option value="">{t('selectCountry')}</option>
|
|
6784
|
+
{destinations.countries.map((c) => (
|
|
6785
|
+
<option key={c.code} value={c.code}>
|
|
6786
|
+
{c.name}
|
|
6787
|
+
</option>
|
|
6788
|
+
))}
|
|
6789
|
+
</select>
|
|
6790
|
+
) : (
|
|
6791
|
+
<input
|
|
6792
|
+
id="country"
|
|
6793
|
+
type="text"
|
|
6794
|
+
value={formData.country}
|
|
6795
|
+
onChange={(e) => updateField('country', e.target.value)}
|
|
6796
|
+
className={cn(
|
|
6797
|
+
inputClass,
|
|
6798
|
+
errors.country ? 'border-destructive' : 'border-border'
|
|
6799
|
+
)}
|
|
6800
|
+
placeholder={t('countryPlaceholder')}
|
|
6801
|
+
/>
|
|
6802
|
+
)}
|
|
6803
|
+
{errors.country && <p className="text-destructive mt-1 text-xs">{errors.country}</p>}
|
|
6804
|
+
</div>
|
|
6805
|
+
|
|
6806
|
+
<div>
|
|
6807
|
+
<label htmlFor="region" className="text-foreground mb-1 block text-sm font-medium">
|
|
6808
|
+
{t('stateRegion')}
|
|
6809
|
+
</label>
|
|
6810
|
+
{hasRegionOptions ? (
|
|
6811
|
+
<select
|
|
6812
|
+
id="region"
|
|
6813
|
+
value={formData.region || ''}
|
|
6814
|
+
onChange={(e) => updateField('region', e.target.value)}
|
|
6815
|
+
className={cn(selectClass, 'border-border')}
|
|
6816
|
+
>
|
|
6817
|
+
<option value="">{t('selectRegion')}</option>
|
|
6818
|
+
{countryRegions.map((r) => (
|
|
6819
|
+
<option key={r.code} value={r.code}>
|
|
6820
|
+
{r.name}
|
|
6821
|
+
</option>
|
|
6822
|
+
))}
|
|
6823
|
+
</select>
|
|
6824
|
+
) : (
|
|
6825
|
+
<input
|
|
6826
|
+
id="region"
|
|
6827
|
+
type="text"
|
|
6828
|
+
value={formData.region || ''}
|
|
6829
|
+
onChange={(e) => updateField('region', e.target.value)}
|
|
6830
|
+
className={cn(inputClass, 'border-border')}
|
|
6831
|
+
/>
|
|
6832
|
+
)}
|
|
6833
|
+
</div>
|
|
6834
|
+
</div>
|
|
6835
|
+
|
|
6836
|
+
{/* Address line 1 */}
|
|
6837
|
+
<div>
|
|
6838
|
+
<label htmlFor="line1" className="text-foreground mb-1 block text-sm font-medium">
|
|
6839
|
+
{t('address')} <span className="text-destructive">*</span>
|
|
6840
|
+
</label>
|
|
6331
6841
|
<input
|
|
6332
|
-
id="
|
|
6842
|
+
id="line1"
|
|
6333
6843
|
type="text"
|
|
6334
|
-
value={formData.
|
|
6335
|
-
onChange={(e) => updateField('
|
|
6336
|
-
className={cn(inputClass, errors.
|
|
6337
|
-
placeholder={t('
|
|
6844
|
+
value={formData.line1}
|
|
6845
|
+
onChange={(e) => updateField('line1', e.target.value)}
|
|
6846
|
+
className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
|
|
6847
|
+
placeholder={t('streetAddress')}
|
|
6338
6848
|
/>
|
|
6339
|
-
|
|
6340
|
-
|
|
6341
|
-
</div>
|
|
6849
|
+
{errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
|
|
6850
|
+
</div>
|
|
6342
6851
|
|
|
6343
|
-
|
|
6344
|
-
<
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
|
|
6348
|
-
<select
|
|
6349
|
-
id="region"
|
|
6350
|
-
value={formData.region || ''}
|
|
6351
|
-
onChange={(e) => updateField('region', e.target.value)}
|
|
6352
|
-
className={cn(selectClass, 'border-border')}
|
|
6353
|
-
>
|
|
6354
|
-
<option value="">{t('selectRegion')}</option>
|
|
6355
|
-
{countryRegions.map((r) => (
|
|
6356
|
-
<option key={r.code} value={r.code}>
|
|
6357
|
-
{r.name}
|
|
6358
|
-
</option>
|
|
6359
|
-
))}
|
|
6360
|
-
</select>
|
|
6361
|
-
) : (
|
|
6852
|
+
{/* Address line 2 */}
|
|
6853
|
+
<div>
|
|
6854
|
+
<label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
|
|
6855
|
+
{t('apartmentSuite')}
|
|
6856
|
+
</label>
|
|
6362
6857
|
<input
|
|
6363
|
-
id="
|
|
6858
|
+
id="line2"
|
|
6364
6859
|
type="text"
|
|
6365
|
-
value={formData.
|
|
6366
|
-
onChange={(e) => updateField('
|
|
6860
|
+
value={formData.line2 || ''}
|
|
6861
|
+
onChange={(e) => updateField('line2', e.target.value)}
|
|
6367
6862
|
className={cn(inputClass, 'border-border')}
|
|
6863
|
+
placeholder={t('aptPlaceholder')}
|
|
6368
6864
|
/>
|
|
6369
|
-
|
|
6370
|
-
|
|
6371
|
-
|
|
6865
|
+
</div>
|
|
6866
|
+
|
|
6867
|
+
{/* City + Postal code row */}
|
|
6868
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
6869
|
+
<div>
|
|
6870
|
+
<label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
|
|
6871
|
+
{t('city')} <span className="text-destructive">*</span>
|
|
6872
|
+
</label>
|
|
6873
|
+
<input
|
|
6874
|
+
id="city"
|
|
6875
|
+
type="text"
|
|
6876
|
+
value={formData.city}
|
|
6877
|
+
onChange={(e) => updateField('city', e.target.value)}
|
|
6878
|
+
className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')}
|
|
6879
|
+
/>
|
|
6880
|
+
{errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
|
|
6881
|
+
</div>
|
|
6882
|
+
|
|
6883
|
+
<div>
|
|
6884
|
+
<label
|
|
6885
|
+
htmlFor="postalCode"
|
|
6886
|
+
className="text-foreground mb-1 block text-sm font-medium"
|
|
6887
|
+
>
|
|
6888
|
+
{t('postalCode')} <span className="text-destructive">*</span>
|
|
6889
|
+
</label>
|
|
6890
|
+
<input
|
|
6891
|
+
id="postalCode"
|
|
6892
|
+
type="text"
|
|
6893
|
+
value={formData.postalCode}
|
|
6894
|
+
onChange={(e) => updateField('postalCode', e.target.value)}
|
|
6895
|
+
className={cn(
|
|
6896
|
+
inputClass,
|
|
6897
|
+
errors.postalCode ? 'border-destructive' : 'border-border'
|
|
6898
|
+
)}
|
|
6899
|
+
/>
|
|
6900
|
+
{errors.postalCode && (
|
|
6901
|
+
<p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
|
|
6902
|
+
)}
|
|
6903
|
+
</div>
|
|
6904
|
+
</div>
|
|
6905
|
+
|
|
6906
|
+
{/* Phone */}
|
|
6907
|
+
<div>
|
|
6908
|
+
<label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
|
|
6909
|
+
{t('phone')}
|
|
6910
|
+
</label>
|
|
6911
|
+
<input
|
|
6912
|
+
id="phone"
|
|
6913
|
+
type="tel"
|
|
6914
|
+
value={formData.phone || ''}
|
|
6915
|
+
onChange={(e) => updateField('phone', e.target.value)}
|
|
6916
|
+
className={cn(inputClass, 'border-border')}
|
|
6917
|
+
placeholder={t('phonePlaceholder')}
|
|
6918
|
+
/>
|
|
6919
|
+
</div>
|
|
6920
|
+
</>
|
|
6921
|
+
)}
|
|
6372
6922
|
|
|
6373
|
-
{/*
|
|
6923
|
+
{/* Privacy Policy (required) */}
|
|
6374
6924
|
<div>
|
|
6375
|
-
<label
|
|
6376
|
-
|
|
6925
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
6926
|
+
<input
|
|
6927
|
+
type="checkbox"
|
|
6928
|
+
checked={privacyAccepted}
|
|
6929
|
+
onChange={(e) => {
|
|
6930
|
+
setPrivacyAccepted(e.target.checked);
|
|
6931
|
+
if (e.target.checked && errors.privacy) {
|
|
6932
|
+
setErrors((prev) => {
|
|
6933
|
+
const next = { ...prev };
|
|
6934
|
+
delete next.privacy;
|
|
6935
|
+
return next;
|
|
6936
|
+
});
|
|
6937
|
+
}
|
|
6938
|
+
}}
|
|
6939
|
+
className="accent-primary mt-0.5"
|
|
6940
|
+
/>
|
|
6941
|
+
<span className="text-muted-foreground text-sm">
|
|
6942
|
+
{t('privacyAcceptPrefix')}{' '}
|
|
6943
|
+
<a
|
|
6944
|
+
href="/privacy"
|
|
6945
|
+
target="_blank"
|
|
6946
|
+
rel="noopener noreferrer"
|
|
6947
|
+
className="text-primary underline underline-offset-2"
|
|
6948
|
+
>
|
|
6949
|
+
{t('privacyPolicyLink')}
|
|
6950
|
+
</a>{' '}
|
|
6951
|
+
<span className="text-destructive">*</span>
|
|
6952
|
+
</span>
|
|
6377
6953
|
</label>
|
|
6378
|
-
<
|
|
6379
|
-
id="line1"
|
|
6380
|
-
type="text"
|
|
6381
|
-
value={formData.line1}
|
|
6382
|
-
onChange={(e) => updateField('line1', e.target.value)}
|
|
6383
|
-
className={cn(inputClass, errors.line1 ? 'border-destructive' : 'border-border')}
|
|
6384
|
-
placeholder={t('streetAddress')}
|
|
6385
|
-
/>
|
|
6386
|
-
{errors.line1 && <p className="text-destructive mt-1 text-xs">{errors.line1}</p>}
|
|
6954
|
+
{errors.privacy && <p className="text-destructive mt-1 text-xs">{errors.privacy}</p>}
|
|
6387
6955
|
</div>
|
|
6388
6956
|
|
|
6389
|
-
{/*
|
|
6390
|
-
<
|
|
6391
|
-
<label htmlFor="line2" className="text-foreground mb-1 block text-sm font-medium">
|
|
6392
|
-
{t('apartmentSuite')}
|
|
6393
|
-
</label>
|
|
6957
|
+
{/* Marketing consent (optional) */}
|
|
6958
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
6394
6959
|
<input
|
|
6395
|
-
|
|
6396
|
-
|
|
6397
|
-
|
|
6398
|
-
|
|
6399
|
-
className={cn(inputClass, 'border-border')}
|
|
6400
|
-
placeholder={t('aptPlaceholder')}
|
|
6960
|
+
type="checkbox"
|
|
6961
|
+
checked={acceptsMarketing}
|
|
6962
|
+
onChange={(e) => setAcceptsMarketing(e.target.checked)}
|
|
6963
|
+
className="accent-primary mt-0.5"
|
|
6401
6964
|
/>
|
|
6402
|
-
|
|
6403
|
-
|
|
6404
|
-
{/* City + Postal code row */}
|
|
6405
|
-
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
6406
|
-
<div>
|
|
6407
|
-
<label htmlFor="city" className="text-foreground mb-1 block text-sm font-medium">
|
|
6408
|
-
{t('city')} <span className="text-destructive">*</span>
|
|
6409
|
-
</label>
|
|
6410
|
-
<input
|
|
6411
|
-
id="city"
|
|
6412
|
-
type="text"
|
|
6413
|
-
value={formData.city}
|
|
6414
|
-
onChange={(e) => updateField('city', e.target.value)}
|
|
6415
|
-
className={cn(inputClass, errors.city ? 'border-destructive' : 'border-border')}
|
|
6416
|
-
/>
|
|
6417
|
-
{errors.city && <p className="text-destructive mt-1 text-xs">{errors.city}</p>}
|
|
6418
|
-
</div>
|
|
6965
|
+
<span className="text-muted-foreground text-sm">{t('acceptsMarketing')}</span>
|
|
6966
|
+
</label>
|
|
6419
6967
|
|
|
6420
|
-
|
|
6421
|
-
|
|
6422
|
-
|
|
6423
|
-
</label>
|
|
6968
|
+
{/* Save details for next time (logged-in users only) */}
|
|
6969
|
+
{showSaveDetails && (
|
|
6970
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
6424
6971
|
<input
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
className={cn(inputClass, errors.postalCode ? 'border-destructive' : 'border-border')}
|
|
6972
|
+
type="checkbox"
|
|
6973
|
+
checked={saveDetails}
|
|
6974
|
+
onChange={(e) => setSaveDetails(e.target.checked)}
|
|
6975
|
+
className="accent-primary mt-0.5"
|
|
6430
6976
|
/>
|
|
6431
|
-
|
|
6432
|
-
<p className="text-destructive mt-1 text-xs">{errors.postalCode}</p>
|
|
6433
|
-
)}
|
|
6434
|
-
</div>
|
|
6435
|
-
</div>
|
|
6436
|
-
|
|
6437
|
-
{/* Phone */}
|
|
6438
|
-
<div>
|
|
6439
|
-
<label htmlFor="phone" className="text-foreground mb-1 block text-sm font-medium">
|
|
6440
|
-
{t('phone')}
|
|
6977
|
+
<span className="text-muted-foreground text-sm">{t('saveDetailsForNextTime')}</span>
|
|
6441
6978
|
</label>
|
|
6442
|
-
|
|
6443
|
-
id="phone"
|
|
6444
|
-
type="tel"
|
|
6445
|
-
value={formData.phone || ''}
|
|
6446
|
-
onChange={(e) => updateField('phone', e.target.value)}
|
|
6447
|
-
className={cn(inputClass, 'border-border')}
|
|
6448
|
-
placeholder={t('phonePlaceholder')}
|
|
6449
|
-
/>
|
|
6450
|
-
</div>
|
|
6979
|
+
)}
|
|
6451
6980
|
|
|
6452
6981
|
<button
|
|
6453
6982
|
type="submit"
|
|
6454
6983
|
disabled={loading}
|
|
6455
6984
|
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"
|
|
6456
6985
|
>
|
|
6457
|
-
{loading ? tc('saving') : t('continueToShipping')}
|
|
6986
|
+
{loading ? tc('saving') : emailOnly ? t('continueToPayment') : t('continueToShipping')}
|
|
6458
6987
|
</button>
|
|
6459
6988
|
</form>
|
|
6460
6989
|
);
|
|
@@ -6574,112 +7103,95 @@ export function ShippingStep({
|
|
|
6574
7103
|
"src/components/checkout/payment-step.tsx": `'use client';
|
|
6575
7104
|
|
|
6576
7105
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
|
6577
|
-
import type { PaymentIntent } from 'brainerce';
|
|
7106
|
+
import type { PaymentIntent, PaymentClientSdk } from 'brainerce';
|
|
6578
7107
|
import { getClient } from '@/lib/brainerce';
|
|
6579
7108
|
import { useTranslations } from '@/lib/translations';
|
|
6580
7109
|
import { LoadingSpinner } from '@/components/shared/loading-spinner';
|
|
6581
7110
|
import { cn } from '@/lib/utils';
|
|
6582
7111
|
|
|
6583
|
-
|
|
6584
|
-
|
|
6585
|
-
|
|
6586
|
-
|
|
6587
|
-
|
|
6588
|
-
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
renderPaymentOptions: (authCode: string) => void;
|
|
6601
|
-
};
|
|
6602
|
-
}
|
|
6603
|
-
}
|
|
6604
|
-
|
|
6605
|
-
// Payment SDK script URLs \u2014 resolved per provider
|
|
6606
|
-
// SRI hashes must be regenerated when the provider updates their SDK
|
|
6607
|
-
const PAYMENT_SDK_URL = 'https://cdn.meshulam.co.il/sdk/gs.min.js';
|
|
6608
|
-
const PAYMENT_SDK_SRI = 'sha384-e1OYzZERZLgbR8Zw1I4Ww/J7qXGyEsZb7LF0z5W/sbnTsFwtxop7sKZsLGNp6CB1';
|
|
6609
|
-
const APPLE_PAY_SDK_URL = 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js';
|
|
6610
|
-
const APPLE_PAY_SDK_SRI = 'sha384-CG67HXmWBeyuRR/jqFsYr6dGEMFyuZw3/hRmcyACJRYHGb/1ebEkQZyzdJXDc9/u';
|
|
7112
|
+
/**
|
|
7113
|
+
* Backward-compat defaults when backend doesn't return clientSdk.
|
|
7114
|
+
*/
|
|
7115
|
+
const LEGACY_GROW_SDK: PaymentClientSdk = {
|
|
7116
|
+
renderType: 'sdk-widget',
|
|
7117
|
+
scriptUrl: 'https://cdn.meshulam.co.il/sdk/gs.min.js',
|
|
7118
|
+
globalName: 'growPayment',
|
|
7119
|
+
initMethod: 'init',
|
|
7120
|
+
renderMethod: 'renderPaymentOptions',
|
|
7121
|
+
containerId: 'grow-payment-container',
|
|
7122
|
+
initConfig: { version: 1, environment: 'DEV' },
|
|
7123
|
+
additionalScripts: [
|
|
7124
|
+
{ url: 'https://meshulam.co.il/_media/js/apple_pay_sdk/sdk.min.js', optional: true },
|
|
7125
|
+
],
|
|
7126
|
+
bodyStyles:
|
|
7127
|
+
'[id*="Gr0W8-"],[id*="Gr0W8-"] *,[class*="Gr0W8-"],[class*="Gr0W8-"] *{direction:ltr !important;text-align:left}',
|
|
7128
|
+
};
|
|
6611
7129
|
|
|
6612
7130
|
interface PaymentStepProps {
|
|
6613
7131
|
checkoutId: string;
|
|
6614
7132
|
className?: string;
|
|
6615
7133
|
}
|
|
6616
7134
|
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
6630
|
-
|
|
6631
|
-
script.integrity = integrity;
|
|
6632
|
-
script.crossOrigin = 'anonymous';
|
|
6633
|
-
}
|
|
6634
|
-
script.onload = () => resolve();
|
|
6635
|
-
script.onerror = () => {
|
|
6636
|
-
if (optional) {
|
|
6637
|
-
resolve(); // Non-blocking for optional SDKs
|
|
6638
|
-
} else {
|
|
6639
|
-
resolve(); // Still resolve \u2014 caller handles missing global
|
|
6640
|
-
}
|
|
7135
|
+
function resolveClientSdk(
|
|
7136
|
+
intent: PaymentIntent | null,
|
|
7137
|
+
preloadedSdk?: PaymentClientSdk | null
|
|
7138
|
+
): PaymentClientSdk {
|
|
7139
|
+
const fullSdk = [preloadedSdk, intent?.clientSdk].find((s) => s?.renderType);
|
|
7140
|
+
const runtimeSdk = intent?.clientSdk;
|
|
7141
|
+
if (fullSdk) {
|
|
7142
|
+
if (!runtimeSdk || runtimeSdk === fullSdk) return fullSdk;
|
|
7143
|
+
return {
|
|
7144
|
+
...fullSdk,
|
|
7145
|
+
...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
|
|
7146
|
+
...(runtimeSdk.initConfig
|
|
7147
|
+
? { initConfig: { ...fullSdk.initConfig, ...runtimeSdk.initConfig } }
|
|
7148
|
+
: {}),
|
|
6641
7149
|
};
|
|
6642
|
-
|
|
6643
|
-
|
|
7150
|
+
}
|
|
7151
|
+
const legacy = intent?.provider === 'grow' ? LEGACY_GROW_SDK : null;
|
|
7152
|
+
if (legacy && runtimeSdk) {
|
|
7153
|
+
return {
|
|
7154
|
+
...legacy,
|
|
7155
|
+
...(runtimeSdk.renderArg ? { renderArg: runtimeSdk.renderArg } : {}),
|
|
7156
|
+
...(runtimeSdk.initConfig
|
|
7157
|
+
? { initConfig: { ...legacy.initConfig, ...runtimeSdk.initConfig } }
|
|
7158
|
+
: {}),
|
|
7159
|
+
};
|
|
7160
|
+
}
|
|
7161
|
+
if (legacy) return legacy;
|
|
7162
|
+
return { renderType: 'redirect' };
|
|
6644
7163
|
}
|
|
6645
7164
|
|
|
6646
|
-
|
|
6647
|
-
|
|
6648
|
-
|
|
6649
|
-
function waitForPaymentSdkGlobal(timeoutMs = 5000): Promise<boolean> {
|
|
6650
|
-
return new Promise((resolve) => {
|
|
6651
|
-
if (window.growPayment) {
|
|
6652
|
-
resolve(true);
|
|
6653
|
-
return;
|
|
6654
|
-
}
|
|
6655
|
-
const start = Date.now();
|
|
6656
|
-
const check = setInterval(() => {
|
|
6657
|
-
if (window.growPayment) {
|
|
6658
|
-
clearInterval(check);
|
|
6659
|
-
resolve(true);
|
|
6660
|
-
} else if (Date.now() - start > timeoutMs) {
|
|
6661
|
-
clearInterval(check);
|
|
6662
|
-
resolve(false);
|
|
6663
|
-
}
|
|
6664
|
-
}, 50);
|
|
6665
|
-
});
|
|
7165
|
+
function extractMessage(response: unknown): string {
|
|
7166
|
+
if (typeof response === 'string') return response;
|
|
7167
|
+
return (response as { message?: string })?.message || '';
|
|
6666
7168
|
}
|
|
6667
7169
|
|
|
6668
7170
|
export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
6669
7171
|
const t = useTranslations('checkout');
|
|
6670
7172
|
const [paymentIntent, setPaymentIntent] = useState<PaymentIntent | null>(null);
|
|
7173
|
+
const [preloadedSdk, setPreloadedSdk] = useState<PaymentClientSdk | null>(null);
|
|
6671
7174
|
const [loading, setLoading] = useState(true);
|
|
6672
7175
|
const [error, setError] = useState<string | null>(null);
|
|
6673
7176
|
const [sdkReady, setSdkReady] = useState(false);
|
|
6674
|
-
const
|
|
6675
|
-
const
|
|
7177
|
+
const walletOpenRef = useRef(false);
|
|
7178
|
+
const initialized = useRef(false);
|
|
7179
|
+
|
|
7180
|
+
// Stable refs for SDK event callbacks (avoids stale closures in onload)
|
|
7181
|
+
const cbRef = useRef({
|
|
7182
|
+
onSuccess: (_r: unknown) => {},
|
|
7183
|
+
onFailure: (_r: unknown) => {},
|
|
7184
|
+
onError: (_r: unknown) => {},
|
|
7185
|
+
onTimeout: () => {},
|
|
7186
|
+
onWalletChange: (_s: string) => {},
|
|
7187
|
+
retryRender: () => {},
|
|
7188
|
+
});
|
|
6676
7189
|
|
|
6677
|
-
const
|
|
7190
|
+
const handleSuccess = useCallback(
|
|
6678
7191
|
async (response: unknown) => {
|
|
6679
7192
|
console.info('Payment SDK success:', JSON.stringify(response));
|
|
6680
7193
|
try {
|
|
6681
7194
|
const client = getClient();
|
|
6682
|
-
// Try response.data first, fall back to response itself
|
|
6683
7195
|
const resp = response as Record<string, unknown>;
|
|
6684
7196
|
const data = (resp?.data && typeof resp.data === 'object' ? resp.data : resp) as
|
|
6685
7197
|
| Record<string, unknown>
|
|
@@ -6693,188 +7205,286 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
6693
7205
|
[checkoutId]
|
|
6694
7206
|
);
|
|
6695
7207
|
|
|
6696
|
-
|
|
6697
|
-
|
|
6698
|
-
|
|
6699
|
-
|
|
6700
|
-
|
|
6701
|
-
|
|
6702
|
-
|
|
6703
|
-
|
|
6704
|
-
|
|
6705
|
-
|
|
6706
|
-
|
|
6707
|
-
|
|
6708
|
-
|
|
6709
|
-
|
|
6710
|
-
|
|
6711
|
-
|
|
6712
|
-
|
|
6713
|
-
console.
|
|
6714
|
-
|
|
6715
|
-
}
|
|
6716
|
-
|
|
6717
|
-
|
|
6718
|
-
|
|
7208
|
+
cbRef.current = {
|
|
7209
|
+
onSuccess: handleSuccess,
|
|
7210
|
+
onFailure: (response: unknown) => {
|
|
7211
|
+
console.error('Payment SDK failure:', response);
|
|
7212
|
+
setError(extractMessage(response) || t('paymentError'));
|
|
7213
|
+
},
|
|
7214
|
+
onError: (response: unknown) => {
|
|
7215
|
+
const TRANSIENT = [
|
|
7216
|
+
'Wallet not initialized',
|
|
7217
|
+
"SDK was not loaded as needed and therefore can't run",
|
|
7218
|
+
];
|
|
7219
|
+
const msg = extractMessage(response);
|
|
7220
|
+
if (TRANSIENT.some((e) => msg.includes(e))) {
|
|
7221
|
+
console.info('Payment SDK: transient error, retrying render in 1s:', msg);
|
|
7222
|
+
setTimeout(() => cbRef.current.retryRender(), 1000);
|
|
7223
|
+
return;
|
|
7224
|
+
}
|
|
7225
|
+
console.error('Payment SDK error:', response);
|
|
7226
|
+
setError(msg || t('paymentError'));
|
|
7227
|
+
},
|
|
7228
|
+
onTimeout: () => {
|
|
7229
|
+
console.warn('Payment SDK: wallet timed out');
|
|
7230
|
+
setError(t('paymentTimedOut'));
|
|
7231
|
+
},
|
|
7232
|
+
onWalletChange: (state: string) => {
|
|
7233
|
+
console.info('Payment SDK wallet state:', state);
|
|
7234
|
+
if (state === 'open') {
|
|
7235
|
+
walletOpenRef.current = true;
|
|
7236
|
+
setSdkReady(true);
|
|
7237
|
+
}
|
|
7238
|
+
if (state === 'close') setSdkReady(false);
|
|
7239
|
+
},
|
|
7240
|
+
retryRender: () => {},
|
|
7241
|
+
};
|
|
6719
7242
|
|
|
6720
|
-
//
|
|
7243
|
+
// =========================================================================
|
|
7244
|
+
// MAIN EFFECT \u2014 Follows Grow SDK docs exactly:
|
|
7245
|
+
//
|
|
7246
|
+
// Step 1: Load gs.min.js (insertBefore, as docs show)
|
|
7247
|
+
// Step 2: s.onload \u2192 growPayment.init({ environment, version, events })
|
|
7248
|
+
// This triggers the SDK to load mp.min.js \u2192 CSS, HTML, params, services
|
|
7249
|
+
// Step 3: createPaymentIntent (starts wallet timer \u2014 should be AFTER init)
|
|
7250
|
+
// Step 4: growPayment.renderPaymentOptions(authCode)
|
|
7251
|
+
//
|
|
7252
|
+
// "call createPaymentProcess right before you need to render the wallet"
|
|
7253
|
+
// =========================================================================
|
|
6721
7254
|
useEffect(() => {
|
|
6722
|
-
|
|
6723
|
-
|
|
6724
|
-
async function loadSdkScripts() {
|
|
6725
|
-
// Load Apple Pay SDK (optional)
|
|
6726
|
-
await loadScript(APPLE_PAY_SDK_URL, true, APPLE_PAY_SDK_SRI);
|
|
7255
|
+
if (initialized.current) return;
|
|
7256
|
+
initialized.current = true;
|
|
6727
7257
|
|
|
6728
|
-
|
|
6729
|
-
|
|
7258
|
+
const client = getClient();
|
|
7259
|
+
const successUrl = \`\${window.location.origin}/order-confirmation?checkout_id=\${checkoutId}\`;
|
|
7260
|
+
const cancelUrl = \`\${window.location.origin}/checkout?checkout_id=\${checkoutId}&canceled=true\`;
|
|
7261
|
+
|
|
7262
|
+
let sdkInitDone = false;
|
|
7263
|
+
let currentSdk: PaymentClientSdk | null = null;
|
|
7264
|
+
const cleanups: (() => void)[] = [];
|
|
7265
|
+
|
|
7266
|
+
// --- Load SDK script exactly as Grow docs show ---
|
|
7267
|
+
function loadScript(sdk: PaymentClientSdk) {
|
|
7268
|
+
if (!sdk.scriptUrl || !sdk.globalName) return;
|
|
7269
|
+
|
|
7270
|
+
// Inject bodyStyles
|
|
7271
|
+
if (sdk.bodyStyles && !document.querySelector('style[data-payment-sdk]')) {
|
|
7272
|
+
const style = document.createElement('style');
|
|
7273
|
+
style.setAttribute('data-payment-sdk', 'true');
|
|
7274
|
+
style.textContent = sdk.bodyStyles;
|
|
7275
|
+
document.head.appendChild(style);
|
|
7276
|
+
cleanups.push(() => style.remove());
|
|
7277
|
+
}
|
|
6730
7278
|
|
|
6731
|
-
//
|
|
6732
|
-
|
|
7279
|
+
// Additional scripts (Apple Pay etc.) \u2014 fire and forget
|
|
7280
|
+
if (sdk.additionalScripts) {
|
|
7281
|
+
for (const extra of sdk.additionalScripts) {
|
|
7282
|
+
if (document.querySelector(\`script[src="\${extra.url}"]\`)) continue;
|
|
7283
|
+
const s = document.createElement('script');
|
|
7284
|
+
s.type = 'text/javascript';
|
|
7285
|
+
s.async = true;
|
|
7286
|
+
s.src = extra.url;
|
|
7287
|
+
const ref = document.getElementsByTagName('script')[0];
|
|
7288
|
+
if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
|
|
7289
|
+
else document.head.appendChild(s);
|
|
7290
|
+
}
|
|
7291
|
+
}
|
|
6733
7292
|
|
|
6734
|
-
|
|
7293
|
+
// Already loaded? Init immediately
|
|
7294
|
+
if ((window as any)[sdk.globalName]) {
|
|
7295
|
+
initSdk(sdk);
|
|
7296
|
+
return;
|
|
7297
|
+
}
|
|
6735
7298
|
|
|
6736
|
-
|
|
6737
|
-
|
|
7299
|
+
// Already loading (from a previous call)? Wait for it instead of duplicating
|
|
7300
|
+
if (document.querySelector(\`script[src="\${sdk.scriptUrl}"]\`)) {
|
|
7301
|
+
const waitId = setInterval(() => {
|
|
7302
|
+
if ((window as any)[sdk.globalName!]) {
|
|
7303
|
+
clearInterval(waitId);
|
|
7304
|
+
initSdk(sdk);
|
|
7305
|
+
}
|
|
7306
|
+
}, 100);
|
|
7307
|
+
cleanups.push(() => clearInterval(waitId));
|
|
6738
7308
|
return;
|
|
6739
7309
|
}
|
|
6740
7310
|
|
|
6741
|
-
|
|
7311
|
+
// Load main SDK \u2014 insertBefore first <script> as Grow docs show
|
|
7312
|
+
const s = document.createElement('script');
|
|
7313
|
+
s.type = 'text/javascript';
|
|
7314
|
+
s.async = true;
|
|
7315
|
+
s.src = sdk.scriptUrl;
|
|
7316
|
+
s.onload = () => initSdk(sdk); // init DIRECTLY in onload
|
|
7317
|
+
s.onerror = () => {
|
|
7318
|
+
console.error('Payment SDK: script load failed');
|
|
7319
|
+
setError(t('failedToLoadPaymentSdk'));
|
|
7320
|
+
};
|
|
7321
|
+
const ref = document.getElementsByTagName('script')[0];
|
|
7322
|
+
if (ref?.parentNode) ref.parentNode.insertBefore(s, ref);
|
|
7323
|
+
else document.head.appendChild(s);
|
|
6742
7324
|
}
|
|
6743
7325
|
|
|
6744
|
-
|
|
7326
|
+
// --- Init: called in s.onload (as Grow docs require) ---
|
|
7327
|
+
function initSdk(sdk: PaymentClientSdk) {
|
|
7328
|
+
if (sdkInitDone) return; // Guard against double init
|
|
6745
7329
|
|
|
6746
|
-
|
|
6747
|
-
|
|
6748
|
-
|
|
6749
|
-
|
|
7330
|
+
const global = (window as any)[sdk.globalName!];
|
|
7331
|
+
if (!global) {
|
|
7332
|
+
setError(t('failedToLoadPaymentSdk'));
|
|
7333
|
+
return;
|
|
7334
|
+
}
|
|
6750
7335
|
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
|
|
6756
|
-
|
|
7336
|
+
const method = sdk.initMethod || 'init';
|
|
7337
|
+
const config = {
|
|
7338
|
+
...(sdk.initConfig || {}),
|
|
7339
|
+
events: {
|
|
7340
|
+
onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
|
|
7341
|
+
onFailure: (r: unknown) => cbRef.current.onFailure(r),
|
|
7342
|
+
onError: (r: unknown) => cbRef.current.onError(r),
|
|
7343
|
+
onTimeout: () => cbRef.current.onTimeout(),
|
|
7344
|
+
onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
|
|
7345
|
+
},
|
|
7346
|
+
};
|
|
6757
7347
|
|
|
6758
|
-
|
|
6759
|
-
|
|
6760
|
-
|
|
6761
|
-
|
|
6762
|
-
const client = getClient();
|
|
7348
|
+
console.info(\`Payment SDK: calling \${method}()\`);
|
|
7349
|
+
global[method](config);
|
|
7350
|
+
sdkInitDone = true;
|
|
7351
|
+
}
|
|
6763
7352
|
|
|
6764
|
-
|
|
6765
|
-
|
|
7353
|
+
// --- Render: call once, then safety-net retries if wallet doesn't open ---
|
|
7354
|
+
// Grow SDK sometimes silently swallows renderPaymentOptions when its
|
|
7355
|
+
// internal resources (mp.min.js etc.) aren't fully loaded yet.
|
|
7356
|
+
// Strategy: render once, then retry up to 3 times with increasing delays
|
|
7357
|
+
// (2s, 3s, 4s) if onWalletChange("open") hasn't fired.
|
|
7358
|
+
let pendingRender: { sdk: PaymentClientSdk; intent: PaymentIntent } | null = null;
|
|
7359
|
+
let renderAttempts = 0;
|
|
7360
|
+
const MAX_RENDER_ATTEMPTS = 4;
|
|
6766
7361
|
|
|
6767
|
-
|
|
6768
|
-
|
|
6769
|
-
|
|
6770
|
-
});
|
|
7362
|
+
function renderPayment(sdk: PaymentClientSdk, intent: PaymentIntent) {
|
|
7363
|
+
const global = (window as any)[sdk.globalName!];
|
|
7364
|
+
if (!global || walletOpenRef.current) return;
|
|
6771
7365
|
|
|
6772
|
-
|
|
7366
|
+
const renderMethod = sdk.renderMethod || 'renderPaymentOptions';
|
|
7367
|
+
const renderArg = sdk.renderArg || intent.clientSecret;
|
|
7368
|
+
renderAttempts++;
|
|
6773
7369
|
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
6777
|
-
}
|
|
7370
|
+
try {
|
|
7371
|
+
global[renderMethod](renderArg);
|
|
7372
|
+
console.info(\`Payment SDK: renderPaymentOptions called (attempt \${renderAttempts})\`);
|
|
6778
7373
|
} catch (err) {
|
|
6779
|
-
|
|
6780
|
-
setError(message);
|
|
6781
|
-
} finally {
|
|
6782
|
-
setLoading(false);
|
|
7374
|
+
console.info('Payment SDK: render threw, will retry in 1s');
|
|
6783
7375
|
}
|
|
6784
|
-
}
|
|
6785
7376
|
|
|
6786
|
-
|
|
6787
|
-
|
|
7377
|
+
// Safety net: if wallet doesn't open within a delay, retry
|
|
7378
|
+
if (renderAttempts < MAX_RENDER_ATTEMPTS) {
|
|
7379
|
+
const delay = 1000 + renderAttempts * 1000; // 2s, 3s, 4s
|
|
7380
|
+
const retryId = setTimeout(() => {
|
|
7381
|
+
if (!walletOpenRef.current) {
|
|
7382
|
+
console.info(\`Payment SDK: wallet not open after \${delay}ms, retrying render...\`);
|
|
7383
|
+
renderPayment(sdk, intent);
|
|
7384
|
+
}
|
|
7385
|
+
}, delay);
|
|
7386
|
+
cleanups.push(() => clearTimeout(retryId));
|
|
7387
|
+
}
|
|
7388
|
+
}
|
|
6788
7389
|
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
useEffect(() => {
|
|
6793
|
-
if (!sdkScriptLoaded) return;
|
|
6794
|
-
if (!paymentIntent || paymentIntent.provider !== 'grow') return;
|
|
6795
|
-
if (!window.growPayment) return;
|
|
6796
|
-
if (renderAttempted.current) return;
|
|
6797
|
-
|
|
6798
|
-
renderAttempted.current = true;
|
|
6799
|
-
|
|
6800
|
-
// Parse environment and authCode from clientSecret (format: "ENV|authCode")
|
|
6801
|
-
const pipeIndex = paymentIntent.clientSecret.indexOf('|');
|
|
6802
|
-
const env = pipeIndex !== -1 ? paymentIntent.clientSecret.substring(0, pipeIndex) : 'DEV';
|
|
6803
|
-
const authCode =
|
|
6804
|
-
pipeIndex !== -1
|
|
6805
|
-
? paymentIntent.clientSecret.substring(pipeIndex + 1)
|
|
6806
|
-
: paymentIntent.clientSecret;
|
|
6807
|
-
|
|
6808
|
-
let rendered = false;
|
|
6809
|
-
let walletReady = false;
|
|
6810
|
-
let pollId: ReturnType<typeof setInterval>;
|
|
6811
|
-
|
|
6812
|
-
function tryRenderPaymentOptions() {
|
|
6813
|
-
if (rendered) return;
|
|
6814
|
-
try {
|
|
6815
|
-
window.growPayment?.renderPaymentOptions(authCode);
|
|
6816
|
-
rendered = true;
|
|
6817
|
-
clearInterval(pollId);
|
|
6818
|
-
setSdkReady(true);
|
|
6819
|
-
console.info('Payment SDK: renderPaymentOptions succeeded');
|
|
6820
|
-
} catch (err) {
|
|
6821
|
-
console.info('Payment SDK: renderPaymentOptions not ready yet', err);
|
|
7390
|
+
function retryRender() {
|
|
7391
|
+
if (pendingRender && !walletOpenRef.current) {
|
|
7392
|
+
renderPayment(pendingRender.sdk, pendingRender.intent);
|
|
6822
7393
|
}
|
|
6823
7394
|
}
|
|
6824
7395
|
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
6828
|
-
|
|
6829
|
-
|
|
6830
|
-
|
|
6831
|
-
|
|
6832
|
-
|
|
6833
|
-
|
|
6834
|
-
|
|
6835
|
-
|
|
6836
|
-
|
|
6837
|
-
|
|
6838
|
-
|
|
7396
|
+
// =============================================
|
|
7397
|
+
// Execution flow
|
|
7398
|
+
// =============================================
|
|
7399
|
+
|
|
7400
|
+
// A) Get SDK config from providers (fast, no wallet timer)
|
|
7401
|
+
const providerPromise = client
|
|
7402
|
+
.getPaymentProviders()
|
|
7403
|
+
.then((res) => {
|
|
7404
|
+
const sdk = res.defaultProvider?.clientSdk;
|
|
7405
|
+
if (sdk) setPreloadedSdk(sdk);
|
|
7406
|
+
return sdk || null;
|
|
7407
|
+
})
|
|
7408
|
+
.catch(() => null);
|
|
7409
|
+
|
|
7410
|
+
// B) Load + init SDK as early as possible
|
|
7411
|
+
providerPromise.then((providerSdk) => {
|
|
7412
|
+
if (providerSdk?.renderType === 'sdk-widget' && providerSdk.scriptUrl) {
|
|
7413
|
+
currentSdk = providerSdk;
|
|
7414
|
+
loadScript(providerSdk);
|
|
7415
|
+
}
|
|
6839
7416
|
});
|
|
6840
7417
|
|
|
6841
|
-
//
|
|
6842
|
-
|
|
6843
|
-
|
|
6844
|
-
|
|
6845
|
-
|
|
6846
|
-
|
|
6847
|
-
|
|
6848
|
-
|
|
6849
|
-
|
|
6850
|
-
|
|
6851
|
-
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
|
|
6855
|
-
|
|
6856
|
-
|
|
6857
|
-
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
|
|
6861
|
-
|
|
7418
|
+
// C) Create payment intent (starts wallet timer)
|
|
7419
|
+
const intentPromise = client
|
|
7420
|
+
.createPaymentIntent(checkoutId, { successUrl, cancelUrl })
|
|
7421
|
+
.then((intent) => {
|
|
7422
|
+
setPaymentIntent(intent);
|
|
7423
|
+
return intent;
|
|
7424
|
+
})
|
|
7425
|
+
.catch((err) => {
|
|
7426
|
+
setError(err instanceof Error ? err.message : t('paymentError'));
|
|
7427
|
+
return null;
|
|
7428
|
+
})
|
|
7429
|
+
.finally(() => setLoading(false));
|
|
7430
|
+
|
|
7431
|
+
// D) When both ready: resolve final SDK config and render
|
|
7432
|
+
Promise.all([providerPromise, intentPromise]).then(([providerSdk, intent]) => {
|
|
7433
|
+
if (!intent) return;
|
|
7434
|
+
|
|
7435
|
+
const sdk = resolveClientSdk(intent, providerSdk);
|
|
7436
|
+
currentSdk = sdk;
|
|
7437
|
+
|
|
7438
|
+
if (sdk.renderType === 'redirect') {
|
|
7439
|
+
window.location.href = intent.clientSecret;
|
|
7440
|
+
return;
|
|
7441
|
+
}
|
|
7442
|
+
if (sdk.renderType !== 'sdk-widget' || !sdk.globalName) return;
|
|
7443
|
+
|
|
7444
|
+
// Store for retryRender from onError callback
|
|
7445
|
+
pendingRender = { sdk, intent };
|
|
7446
|
+
cbRef.current.retryRender = retryRender;
|
|
7447
|
+
|
|
7448
|
+
// If SDK wasn't loaded from providers, load + init now
|
|
7449
|
+
if (!sdkInitDone) {
|
|
7450
|
+
loadScript(sdk);
|
|
7451
|
+
// Wait for init to complete, then render once
|
|
7452
|
+
const id = setInterval(() => {
|
|
7453
|
+
if (sdkInitDone) {
|
|
7454
|
+
clearInterval(id);
|
|
7455
|
+
renderPayment(sdk, intent);
|
|
6862
7456
|
}
|
|
6863
|
-
},
|
|
7457
|
+
}, 100);
|
|
7458
|
+
cleanups.push(() => clearInterval(id));
|
|
7459
|
+
return;
|
|
6864
7460
|
}
|
|
6865
|
-
}, 1000);
|
|
6866
7461
|
|
|
6867
|
-
|
|
6868
|
-
|
|
6869
|
-
|
|
6870
|
-
|
|
6871
|
-
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
7462
|
+
// Re-init with final config if environment changed
|
|
7463
|
+
if (sdk.initConfig?.environment && currentSdk) {
|
|
7464
|
+
const global = (window as any)[sdk.globalName];
|
|
7465
|
+
if (global) {
|
|
7466
|
+
const method = sdk.initMethod || 'init';
|
|
7467
|
+
global[method]({
|
|
7468
|
+
...(sdk.initConfig || {}),
|
|
7469
|
+
events: {
|
|
7470
|
+
onSuccess: (r: unknown) => cbRef.current.onSuccess(r),
|
|
7471
|
+
onFailure: (r: unknown) => cbRef.current.onFailure(r),
|
|
7472
|
+
onError: (r: unknown) => cbRef.current.onError(r),
|
|
7473
|
+
onTimeout: () => cbRef.current.onTimeout(),
|
|
7474
|
+
onWalletChange: (s: string) => cbRef.current.onWalletChange(s),
|
|
7475
|
+
},
|
|
7476
|
+
});
|
|
7477
|
+
}
|
|
7478
|
+
}
|
|
7479
|
+
|
|
7480
|
+
// SDK ready \u2014 render once
|
|
7481
|
+
renderPayment(sdk, intent);
|
|
7482
|
+
});
|
|
7483
|
+
|
|
7484
|
+
return () => cleanups.forEach((fn) => fn());
|
|
7485
|
+
}, [checkoutId]);
|
|
7486
|
+
|
|
7487
|
+
// --- UI ---
|
|
6878
7488
|
|
|
6879
7489
|
if (loading) {
|
|
6880
7490
|
return (
|
|
@@ -6890,7 +7500,6 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
6890
7500
|
error.toLowerCase().includes('not configured') ||
|
|
6891
7501
|
error.toLowerCase().includes('no payment') ||
|
|
6892
7502
|
error.toLowerCase().includes('provider');
|
|
6893
|
-
|
|
6894
7503
|
return (
|
|
6895
7504
|
<div className={cn('py-12 text-center', className)}>
|
|
6896
7505
|
<svg
|
|
@@ -6916,8 +7525,13 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
6916
7525
|
);
|
|
6917
7526
|
}
|
|
6918
7527
|
|
|
6919
|
-
|
|
6920
|
-
|
|
7528
|
+
if (!paymentIntent) return null;
|
|
7529
|
+
|
|
7530
|
+
const sdk = resolveClientSdk(paymentIntent, preloadedSdk);
|
|
7531
|
+
|
|
7532
|
+
if (sdk.renderType === 'sdk-widget') {
|
|
7533
|
+
const containerId =
|
|
7534
|
+
sdk.containerId || \`\${paymentIntent.provider || 'payment'}-payment-container\`;
|
|
6921
7535
|
return (
|
|
6922
7536
|
<div className={cn('py-4', className)}>
|
|
6923
7537
|
{!sdkReady && (
|
|
@@ -6926,29 +7540,38 @@ export function PaymentStep({ checkoutId, className }: PaymentStepProps) {
|
|
|
6926
7540
|
<p className="text-muted-foreground mt-4 text-sm">{t('loadingPaymentOptions')}</p>
|
|
6927
7541
|
</div>
|
|
6928
7542
|
)}
|
|
6929
|
-
<div id=
|
|
7543
|
+
<div id={containerId} />
|
|
6930
7544
|
</div>
|
|
6931
7545
|
);
|
|
6932
7546
|
}
|
|
6933
7547
|
|
|
6934
|
-
|
|
6935
|
-
if (paymentIntent) {
|
|
7548
|
+
if (sdk.renderType === 'iframe') {
|
|
6936
7549
|
return (
|
|
6937
|
-
<div className={cn('
|
|
6938
|
-
<
|
|
6939
|
-
|
|
6940
|
-
|
|
6941
|
-
{
|
|
6942
|
-
|
|
6943
|
-
|
|
6944
|
-
|
|
6945
|
-
.
|
|
6946
|
-
</p>
|
|
7550
|
+
<div className={cn('py-4', className)}>
|
|
7551
|
+
<iframe
|
|
7552
|
+
src={paymentIntent.clientSecret}
|
|
7553
|
+
className="w-full border-0"
|
|
7554
|
+
style={{ minHeight: '500px' }}
|
|
7555
|
+
title={t('payment')}
|
|
7556
|
+
allow="payment"
|
|
7557
|
+
/>
|
|
6947
7558
|
</div>
|
|
6948
7559
|
);
|
|
6949
7560
|
}
|
|
6950
7561
|
|
|
6951
|
-
return
|
|
7562
|
+
return (
|
|
7563
|
+
<div className={cn('flex flex-col items-center justify-center py-12', className)}>
|
|
7564
|
+
<LoadingSpinner size="lg" />
|
|
7565
|
+
<p className="text-muted-foreground mt-4 text-sm">{t('redirectingToPayment')}</p>
|
|
7566
|
+
<p className="text-muted-foreground mt-2 text-xs">
|
|
7567
|
+
{t('redirectingHint')}
|
|
7568
|
+
<a href={paymentIntent.clientSecret} className="text-primary hover:underline">
|
|
7569
|
+
{t('clickHere')}
|
|
7570
|
+
</a>
|
|
7571
|
+
.
|
|
7572
|
+
</p>
|
|
7573
|
+
</div>
|
|
7574
|
+
);
|
|
6952
7575
|
}
|
|
6953
7576
|
`,
|
|
6954
7577
|
"src/components/checkout/tax-display.tsx": `'use client';
|
|
@@ -7131,6 +7754,7 @@ interface RegisterData {
|
|
|
7131
7754
|
lastName: string;
|
|
7132
7755
|
email: string;
|
|
7133
7756
|
password: string;
|
|
7757
|
+
acceptsMarketing: boolean;
|
|
7134
7758
|
}
|
|
7135
7759
|
|
|
7136
7760
|
interface RegisterFormProps {
|
|
@@ -7162,6 +7786,9 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
|
|
|
7162
7786
|
const [lastName, setLastName] = useState('');
|
|
7163
7787
|
const [email, setEmail] = useState('');
|
|
7164
7788
|
const [password, setPassword] = useState('');
|
|
7789
|
+
const [privacyAccepted, setPrivacyAccepted] = useState(false);
|
|
7790
|
+
const [privacyError, setPrivacyError] = useState(false);
|
|
7791
|
+
const [acceptsMarketing, setAcceptsMarketing] = useState(false);
|
|
7165
7792
|
const [loading, setLoading] = useState(false);
|
|
7166
7793
|
|
|
7167
7794
|
const strength = useMemo(() => getPasswordStrength(password), [password]);
|
|
@@ -7170,9 +7797,14 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
|
|
|
7170
7797
|
e.preventDefault();
|
|
7171
7798
|
if (loading) return;
|
|
7172
7799
|
|
|
7800
|
+
if (!privacyAccepted) {
|
|
7801
|
+
setPrivacyError(true);
|
|
7802
|
+
return;
|
|
7803
|
+
}
|
|
7804
|
+
|
|
7173
7805
|
try {
|
|
7174
7806
|
setLoading(true);
|
|
7175
|
-
await onSubmit({ firstName, lastName, email, password });
|
|
7807
|
+
await onSubmit({ firstName, lastName, email, password, acceptsMarketing });
|
|
7176
7808
|
} finally {
|
|
7177
7809
|
setLoading(false);
|
|
7178
7810
|
}
|
|
@@ -7283,6 +7915,45 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
|
|
|
7283
7915
|
)}
|
|
7284
7916
|
</div>
|
|
7285
7917
|
|
|
7918
|
+
{/* Privacy Policy (required) */}
|
|
7919
|
+
<div>
|
|
7920
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
7921
|
+
<input
|
|
7922
|
+
type="checkbox"
|
|
7923
|
+
checked={privacyAccepted}
|
|
7924
|
+
onChange={(e) => {
|
|
7925
|
+
setPrivacyAccepted(e.target.checked);
|
|
7926
|
+
setPrivacyError(false);
|
|
7927
|
+
}}
|
|
7928
|
+
className="accent-primary mt-0.5"
|
|
7929
|
+
/>
|
|
7930
|
+
<span className="text-muted-foreground text-sm">
|
|
7931
|
+
{t('privacyAcceptPrefix')}{' '}
|
|
7932
|
+
<a
|
|
7933
|
+
href="/privacy"
|
|
7934
|
+
target="_blank"
|
|
7935
|
+
rel="noopener noreferrer"
|
|
7936
|
+
className="text-primary underline underline-offset-2"
|
|
7937
|
+
>
|
|
7938
|
+
{t('privacyPolicyLink')}
|
|
7939
|
+
</a>{' '}
|
|
7940
|
+
<span className="text-destructive">*</span>
|
|
7941
|
+
</span>
|
|
7942
|
+
</label>
|
|
7943
|
+
{privacyError && <p className="text-destructive mt-1 text-xs">{t('privacyRequired')}</p>}
|
|
7944
|
+
</div>
|
|
7945
|
+
|
|
7946
|
+
{/* Marketing consent (optional) */}
|
|
7947
|
+
<label className="flex cursor-pointer items-start gap-2">
|
|
7948
|
+
<input
|
|
7949
|
+
type="checkbox"
|
|
7950
|
+
checked={acceptsMarketing}
|
|
7951
|
+
onChange={(e) => setAcceptsMarketing(e.target.checked)}
|
|
7952
|
+
className="accent-primary mt-0.5"
|
|
7953
|
+
/>
|
|
7954
|
+
<span className="text-muted-foreground text-sm">{t('acceptsMarketing')}</span>
|
|
7955
|
+
</label>
|
|
7956
|
+
|
|
7286
7957
|
<button
|
|
7287
7958
|
type="submit"
|
|
7288
7959
|
disabled={loading}
|
|
@@ -7748,7 +8419,8 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
7748
8419
|
const t = useTranslations('account');
|
|
7749
8420
|
const tc = useTranslations('common');
|
|
7750
8421
|
const [expanded, setExpanded] = useState(false);
|
|
7751
|
-
const statusConfig =
|
|
8422
|
+
const statusConfig =
|
|
8423
|
+
STATUS_CONFIG[order.status?.toLowerCase() as OrderStatus] || STATUS_CONFIG.pending;
|
|
7752
8424
|
const currency = order.currency || 'USD';
|
|
7753
8425
|
const totalAmount = order.totalAmount || order.total || '0';
|
|
7754
8426
|
|
|
@@ -7860,9 +8532,7 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
7860
8532
|
))}
|
|
7861
8533
|
|
|
7862
8534
|
{/* Downloads section */}
|
|
7863
|
-
{order.hasDownloads &&
|
|
7864
|
-
<OrderDownloads orderId={order.id} />
|
|
7865
|
-
)}
|
|
8535
|
+
{order.hasDownloads && <OrderDownloads orderId={order.id} />}
|
|
7866
8536
|
|
|
7867
8537
|
<OrderFinancialSummary order={order} currency={currency} />
|
|
7868
8538
|
</div>
|
|
@@ -7890,7 +8560,9 @@ function OrderDownloads({ orderId }: { orderId: string }) {
|
|
|
7890
8560
|
}
|
|
7891
8561
|
}
|
|
7892
8562
|
fetch();
|
|
7893
|
-
return () => {
|
|
8563
|
+
return () => {
|
|
8564
|
+
cancelled = true;
|
|
8565
|
+
};
|
|
7894
8566
|
}, [orderId]);
|
|
7895
8567
|
|
|
7896
8568
|
if (loading) {
|
|
@@ -7914,11 +8586,11 @@ function OrderDownloads({ orderId }: { orderId: string }) {
|
|
|
7914
8586
|
{link.productName}
|
|
7915
8587
|
{' \xB7 '}
|
|
7916
8588
|
{link.downloadLimit != null
|
|
7917
|
-
?
|
|
8589
|
+
? \`\${link.downloadsUsed}/\${link.downloadLimit} \${t('downloadsRemaining')}\`
|
|
7918
8590
|
: t('unlimitedDownloads')}
|
|
7919
8591
|
{' \xB7 '}
|
|
7920
8592
|
{link.expiresAt
|
|
7921
|
-
? t('expiresAt'
|
|
8593
|
+
? \`\${t('expiresAt')} \${new Date(link.expiresAt).toLocaleDateString()}\`
|
|
7922
8594
|
: t('noExpiry')}
|
|
7923
8595
|
</p>
|
|
7924
8596
|
</div>
|
|
@@ -8102,7 +8774,7 @@ export function Header() {
|
|
|
8102
8774
|
<div className="flex h-16 items-center justify-between gap-4">
|
|
8103
8775
|
{/* Logo / Store Name */}
|
|
8104
8776
|
<Link href="/" className="text-foreground flex-shrink-0 text-xl font-bold">
|
|
8105
|
-
{storeInfo?.name || tc('store')}
|
|
8777
|
+
{storeInfo?.name || process.env.NEXT_PUBLIC_STORE_NAME || tc('store')}
|
|
8106
8778
|
</Link>
|
|
8107
8779
|
|
|
8108
8780
|
{/* Desktop Navigation */}
|
|
@@ -8113,12 +8785,14 @@ export function Header() {
|
|
|
8113
8785
|
>
|
|
8114
8786
|
{t('products')}
|
|
8115
8787
|
</Link>
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8119
|
-
|
|
8120
|
-
|
|
8121
|
-
|
|
8788
|
+
{isLoggedIn && (
|
|
8789
|
+
<Link
|
|
8790
|
+
href="/account"
|
|
8791
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
8792
|
+
>
|
|
8793
|
+
{t('account')}
|
|
8794
|
+
</Link>
|
|
8795
|
+
)}
|
|
8122
8796
|
</nav>
|
|
8123
8797
|
|
|
8124
8798
|
{/* Search */}
|
|
@@ -8308,13 +8982,15 @@ export function Header() {
|
|
|
8308
8982
|
>
|
|
8309
8983
|
{t('products')}
|
|
8310
8984
|
</Link>
|
|
8311
|
-
|
|
8312
|
-
|
|
8313
|
-
|
|
8314
|
-
|
|
8315
|
-
|
|
8316
|
-
|
|
8317
|
-
|
|
8985
|
+
{isLoggedIn && (
|
|
8986
|
+
<Link
|
|
8987
|
+
href="/account"
|
|
8988
|
+
onClick={() => setMobileMenuOpen(false)}
|
|
8989
|
+
className="text-foreground hover:bg-muted block rounded px-2 py-2 text-sm"
|
|
8990
|
+
>
|
|
8991
|
+
{t('account')}
|
|
8992
|
+
</Link>
|
|
8993
|
+
)}
|
|
8318
8994
|
{isLoggedIn ? (
|
|
8319
8995
|
<button
|
|
8320
8996
|
onClick={() => {
|
|
@@ -8356,12 +9032,13 @@ export function Header() {
|
|
|
8356
9032
|
|
|
8357
9033
|
import Link from 'next/link';
|
|
8358
9034
|
import { useTranslations } from '@/lib/translations';
|
|
8359
|
-
import { useStoreInfo } from '@/providers/store-provider';
|
|
9035
|
+
import { useStoreInfo, useAuth } from '@/providers/store-provider';
|
|
8360
9036
|
|
|
8361
9037
|
export function Footer() {
|
|
8362
9038
|
const t = useTranslations('common');
|
|
8363
9039
|
const tn = useTranslations('nav');
|
|
8364
9040
|
const { storeInfo } = useStoreInfo();
|
|
9041
|
+
const { isLoggedIn } = useAuth();
|
|
8365
9042
|
const year = new Date().getFullYear();
|
|
8366
9043
|
|
|
8367
9044
|
return (
|
|
@@ -8378,12 +9055,14 @@ export function Footer() {
|
|
|
8378
9055
|
>
|
|
8379
9056
|
{tn('products')}
|
|
8380
9057
|
</Link>
|
|
8381
|
-
|
|
8382
|
-
|
|
8383
|
-
|
|
8384
|
-
|
|
8385
|
-
|
|
8386
|
-
|
|
9058
|
+
{isLoggedIn && (
|
|
9059
|
+
<Link
|
|
9060
|
+
href="/account"
|
|
9061
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
9062
|
+
>
|
|
9063
|
+
{tn('account')}
|
|
9064
|
+
</Link>
|
|
9065
|
+
)}
|
|
8387
9066
|
</nav>
|
|
8388
9067
|
</div>
|
|
8389
9068
|
</div>
|
|
@@ -8638,6 +9317,12 @@ var USE_CASE_FILES = {
|
|
|
8638
9317
|
{ path: "src/components/auth/oauth-buttons.tsx", label: "OAuth Buttons Component" }
|
|
8639
9318
|
],
|
|
8640
9319
|
"verify-email": [{ path: "src/app/verify-email/page.tsx", label: "Verify Email Page" }],
|
|
9320
|
+
"forgot-password": [{ path: "src/app/forgot-password/page.tsx", label: "Forgot Password Page" }],
|
|
9321
|
+
"reset-password": [
|
|
9322
|
+
{ path: "src/app/reset-password/page.tsx", label: "Reset Password Page" },
|
|
9323
|
+
{ path: "src/app/api/auth/reset-callback/route.ts", label: "Reset Callback API Route" },
|
|
9324
|
+
{ path: "src/app/api/auth/reset-password/route.ts", label: "Reset Password API Route" }
|
|
9325
|
+
],
|
|
8641
9326
|
"oauth-callback": [{ path: "src/app/auth/callback/page.tsx", label: "OAuth Callback Page" }],
|
|
8642
9327
|
"account-page": [
|
|
8643
9328
|
{ path: "src/app/account/page.tsx", label: "Account Page" },
|
|
@@ -8714,6 +9399,8 @@ var GET_CODE_EXAMPLE_SCHEMA = {
|
|
|
8714
9399
|
"login-page",
|
|
8715
9400
|
"register-page",
|
|
8716
9401
|
"verify-email",
|
|
9402
|
+
"forgot-password",
|
|
9403
|
+
"reset-password",
|
|
8717
9404
|
"oauth-callback",
|
|
8718
9405
|
"account-page",
|
|
8719
9406
|
"header",
|
|
@@ -8754,6 +9441,8 @@ var TOPIC_MAP = {
|
|
|
8754
9441
|
"login-page": "auth",
|
|
8755
9442
|
"register-page": "auth",
|
|
8756
9443
|
"verify-email": "auth",
|
|
9444
|
+
"forgot-password": "auth",
|
|
9445
|
+
"reset-password": "auth",
|
|
8757
9446
|
"oauth-callback": "auth",
|
|
8758
9447
|
"account-page": "auth",
|
|
8759
9448
|
header: "products",
|
|
@@ -8993,6 +9682,735 @@ async function handleGetStoreInfo(args) {
|
|
|
8993
9682
|
}
|
|
8994
9683
|
}
|
|
8995
9684
|
|
|
9685
|
+
// src/tools/get-store-capabilities.ts
|
|
9686
|
+
import { z as z6 } from "zod";
|
|
9687
|
+
|
|
9688
|
+
// src/utils/fetch-store-capabilities.ts
|
|
9689
|
+
import https2 from "https";
|
|
9690
|
+
import http2 from "http";
|
|
9691
|
+
async function fetchStoreCapabilities(connectionId, baseUrl = "https://api.brainerce.com") {
|
|
9692
|
+
const url = `${baseUrl}/api/vc/${connectionId}/capabilities`;
|
|
9693
|
+
return new Promise((resolve, reject) => {
|
|
9694
|
+
const client = url.startsWith("https") ? https2 : http2;
|
|
9695
|
+
const req = client.get(url, { timeout: 1e4 }, (res) => {
|
|
9696
|
+
let data = "";
|
|
9697
|
+
res.on("data", (chunk) => {
|
|
9698
|
+
data += chunk;
|
|
9699
|
+
});
|
|
9700
|
+
res.on("end", () => {
|
|
9701
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
9702
|
+
if (res.statusCode === 404) {
|
|
9703
|
+
reject(new Error(`Connection ID "${connectionId}" not found. Check your dashboard.`));
|
|
9704
|
+
} else {
|
|
9705
|
+
reject(new Error(`API returned status ${res.statusCode}`));
|
|
9706
|
+
}
|
|
9707
|
+
return;
|
|
9708
|
+
}
|
|
9709
|
+
try {
|
|
9710
|
+
resolve(JSON.parse(data));
|
|
9711
|
+
} catch {
|
|
9712
|
+
reject(new Error("Invalid response from API"));
|
|
9713
|
+
}
|
|
9714
|
+
});
|
|
9715
|
+
});
|
|
9716
|
+
req.on("error", (err) => {
|
|
9717
|
+
reject(new Error(`Failed to connect to Brainerce API: ${err.message}`));
|
|
9718
|
+
});
|
|
9719
|
+
req.on("timeout", () => {
|
|
9720
|
+
req.destroy();
|
|
9721
|
+
reject(new Error("Request timed out"));
|
|
9722
|
+
});
|
|
9723
|
+
});
|
|
9724
|
+
}
|
|
9725
|
+
|
|
9726
|
+
// src/tools/get-store-capabilities.ts
|
|
9727
|
+
var GET_STORE_CAPABILITIES_NAME = "get-store-capabilities";
|
|
9728
|
+
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.";
|
|
9729
|
+
var GET_STORE_CAPABILITIES_SCHEMA = {
|
|
9730
|
+
connectionId: z6.string().describe("Vibe-coded connection ID (starts with vc_)")
|
|
9731
|
+
};
|
|
9732
|
+
function formatCapabilities(caps) {
|
|
9733
|
+
const lines = [];
|
|
9734
|
+
lines.push(`## Store: ${caps.store.name} (${caps.store.currency})`);
|
|
9735
|
+
lines.push(`Language: ${caps.store.language}`);
|
|
9736
|
+
lines.push("");
|
|
9737
|
+
lines.push("## Configured Features");
|
|
9738
|
+
if (caps.features.paymentProviders.length > 0) {
|
|
9739
|
+
const providers = caps.features.paymentProviders.map((p) => p.name).join(", ");
|
|
9740
|
+
lines.push(`\u2705 Payment: ${providers}`);
|
|
9741
|
+
} else {
|
|
9742
|
+
lines.push("\u274C No payment providers configured \u2014 customers cannot pay!");
|
|
9743
|
+
}
|
|
9744
|
+
if (caps.features.oauthProviders.length > 0) {
|
|
9745
|
+
const enabled = caps.features.oauthProviders.filter((o) => o.isEnabled).map((o) => o.provider).join(", ");
|
|
9746
|
+
if (enabled) {
|
|
9747
|
+
lines.push(`\u2705 OAuth: ${enabled}`);
|
|
9748
|
+
} else {
|
|
9749
|
+
lines.push("\u26A0\uFE0F OAuth providers configured but disabled");
|
|
9750
|
+
}
|
|
9751
|
+
} else {
|
|
9752
|
+
lines.push("\u274C No OAuth providers \u2014 email/password login only");
|
|
9753
|
+
}
|
|
9754
|
+
lines.push(
|
|
9755
|
+
caps.features.hasShippingZones ? "\u2705 Shipping zones configured" : "\u274C No shipping zones"
|
|
9756
|
+
);
|
|
9757
|
+
lines.push(caps.features.hasDiscountRules ? "\u2705 Discount rules active" : "\u274C No discount rules");
|
|
9758
|
+
lines.push(
|
|
9759
|
+
caps.features.hasDownloadableProducts ? "\u2705 Downloadable products available" : "\u274C No downloadable products"
|
|
9760
|
+
);
|
|
9761
|
+
lines.push(caps.features.hasCoupons ? "\u2705 Coupons available" : "\u274C No coupons");
|
|
9762
|
+
lines.push("");
|
|
9763
|
+
lines.push("## Connection Settings");
|
|
9764
|
+
lines.push(
|
|
9765
|
+
`- Guest checkout tracking: ${caps.connection.guestCheckoutTracking ? "enabled" : "disabled"}`
|
|
9766
|
+
);
|
|
9767
|
+
lines.push(
|
|
9768
|
+
`- Email verification: ${caps.connection.requireEmailVerification ? "required" : "not required"}`
|
|
9769
|
+
);
|
|
9770
|
+
lines.push(
|
|
9771
|
+
`- Inventory reservation: ${caps.connection.reservationStrategy} (${caps.connection.reservationTimeout} min timeout)`
|
|
9772
|
+
);
|
|
9773
|
+
if (caps.connection.lowStockWarning) {
|
|
9774
|
+
lines.push(`- Low stock warning: enabled (threshold: ${caps.connection.lowStockThreshold})`);
|
|
9775
|
+
}
|
|
9776
|
+
lines.push(`- Orders write: ${caps.connection.ordersWriteEnabled ? "enabled" : "disabled"}`);
|
|
9777
|
+
lines.push(`- Scopes: ${caps.connection.allowedScopes.join(", ")}`);
|
|
9778
|
+
lines.push("");
|
|
9779
|
+
lines.push("## Build Suggestions");
|
|
9780
|
+
const suggestions = [];
|
|
9781
|
+
if (caps.features.paymentProviders.length > 0) {
|
|
9782
|
+
suggestions.push(
|
|
9783
|
+
'Build a checkout page with payment. Use get-code-example("checkout-page") and get-code-example("stripe-payment").'
|
|
9784
|
+
);
|
|
9785
|
+
}
|
|
9786
|
+
if (caps.features.oauthProviders.some((o) => o.isEnabled)) {
|
|
9787
|
+
suggestions.push(
|
|
9788
|
+
'Add OAuth login buttons. Use get-code-example("login-page") \u2014 it includes OAuth support.'
|
|
9789
|
+
);
|
|
9790
|
+
}
|
|
9791
|
+
if (caps.features.hasDownloadableProducts) {
|
|
9792
|
+
suggestions.push(
|
|
9793
|
+
"Build a downloads page for digital products. Customers need to access their purchased files."
|
|
9794
|
+
);
|
|
9795
|
+
}
|
|
9796
|
+
if (caps.features.hasDiscountRules) {
|
|
9797
|
+
suggestions.push(
|
|
9798
|
+
'Show discount badges and banners. Use get-code-example("discount-badge") and get-sdk-docs("discounts").'
|
|
9799
|
+
);
|
|
9800
|
+
}
|
|
9801
|
+
if (caps.features.hasCoupons) {
|
|
9802
|
+
suggestions.push('Add a coupon input to the cart. Use get-code-example("coupon-input").');
|
|
9803
|
+
}
|
|
9804
|
+
if (caps.connection.requireEmailVerification) {
|
|
9805
|
+
suggestions.push(
|
|
9806
|
+
'Email verification is required \u2014 ALWAYS build the verify-email page. Use get-code-example("verify-email").'
|
|
9807
|
+
);
|
|
9808
|
+
}
|
|
9809
|
+
if (caps.connection.reservationStrategy !== "ON_PAYMENT") {
|
|
9810
|
+
suggestions.push(
|
|
9811
|
+
'Inventory reservation is active \u2014 show a countdown timer. Use get-code-example("reservation-countdown").'
|
|
9812
|
+
);
|
|
9813
|
+
}
|
|
9814
|
+
if (suggestions.length === 0) {
|
|
9815
|
+
suggestions.push(
|
|
9816
|
+
"Start by building the required pages. Use get-required-pages() for the checklist."
|
|
9817
|
+
);
|
|
9818
|
+
}
|
|
9819
|
+
suggestions.forEach((s) => lines.push(`- ${s}`));
|
|
9820
|
+
return lines.join("\n");
|
|
9821
|
+
}
|
|
9822
|
+
async function handleGetStoreCapabilities(args) {
|
|
9823
|
+
try {
|
|
9824
|
+
const caps = await fetchStoreCapabilities(args.connectionId);
|
|
9825
|
+
return {
|
|
9826
|
+
content: [{ type: "text", text: formatCapabilities(caps) }]
|
|
9827
|
+
};
|
|
9828
|
+
} catch (error) {
|
|
9829
|
+
const message = error instanceof Error ? error.message : "Failed to fetch store capabilities";
|
|
9830
|
+
return {
|
|
9831
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
9832
|
+
isError: true
|
|
9833
|
+
};
|
|
9834
|
+
}
|
|
9835
|
+
}
|
|
9836
|
+
|
|
9837
|
+
// src/tools/build-store.ts
|
|
9838
|
+
import { z as z7 } from "zod";
|
|
9839
|
+
|
|
9840
|
+
// src/content/compressed-bundle.ts
|
|
9841
|
+
var SETUP_FILES = [
|
|
9842
|
+
"src/lib/brainerce.ts.ejs",
|
|
9843
|
+
"src/lib/auth.ts",
|
|
9844
|
+
"src/lib/utils.ts",
|
|
9845
|
+
"src/providers/store-provider.tsx.ejs",
|
|
9846
|
+
"src/app/layout.tsx.ejs",
|
|
9847
|
+
"src/middleware.ts"
|
|
9848
|
+
];
|
|
9849
|
+
var PAGE_FILES = [
|
|
9850
|
+
{ path: "src/app/page.tsx", route: "/", label: "Home" },
|
|
9851
|
+
{ path: "src/app/products/page.tsx", route: "/products", label: "Product Listing" },
|
|
9852
|
+
{ path: "src/app/products/[slug]/page.tsx", route: "/products/[slug]", label: "Product Detail" },
|
|
9853
|
+
{ path: "src/app/cart/page.tsx", route: "/cart", label: "Cart" },
|
|
9854
|
+
{ path: "src/app/checkout/page.tsx", route: "/checkout", label: "Checkout" },
|
|
9855
|
+
{
|
|
9856
|
+
path: "src/app/order-confirmation/page.tsx",
|
|
9857
|
+
route: "/order-confirmation",
|
|
9858
|
+
label: "Order Confirmation"
|
|
9859
|
+
},
|
|
9860
|
+
{ path: "src/app/login/page.tsx", route: "/login", label: "Login" },
|
|
9861
|
+
{ path: "src/app/register/page.tsx", route: "/register", label: "Register" },
|
|
9862
|
+
{ path: "src/app/verify-email/page.tsx", route: "/verify-email", label: "Verify Email" },
|
|
9863
|
+
{ path: "src/app/forgot-password/page.tsx", route: "/forgot-password", label: "Forgot Password" },
|
|
9864
|
+
{ path: "src/app/reset-password/page.tsx", route: "/reset-password", label: "Reset Password" },
|
|
9865
|
+
{ path: "src/app/auth/callback/page.tsx", route: "/auth/callback", label: "OAuth Callback" },
|
|
9866
|
+
{ path: "src/app/account/page.tsx", route: "/account", label: "Account" }
|
|
9867
|
+
];
|
|
9868
|
+
var COMPONENT_FILES = [
|
|
9869
|
+
{ path: "src/components/layout/header.tsx", label: "Header" },
|
|
9870
|
+
{ path: "src/components/layout/footer.tsx", label: "Footer" },
|
|
9871
|
+
{ path: "src/components/products/product-card.tsx", label: "Product Card" },
|
|
9872
|
+
{ path: "src/components/products/product-grid.tsx", label: "Product Grid" },
|
|
9873
|
+
{ path: "src/components/products/variant-selector.tsx", label: "Variant Selector" },
|
|
9874
|
+
{ path: "src/components/products/stock-badge.tsx", label: "Stock Badge" },
|
|
9875
|
+
{ path: "src/components/products/discount-badge.tsx", label: "Discount Badge" },
|
|
9876
|
+
{ path: "src/components/cart/cart-item.tsx", label: "Cart Item" },
|
|
9877
|
+
{ path: "src/components/cart/cart-summary.tsx", label: "Cart Summary" },
|
|
9878
|
+
{ path: "src/components/cart/coupon-input.tsx", label: "Coupon Input" },
|
|
9879
|
+
{ path: "src/components/cart/cart-nudges.tsx", label: "Cart Nudges" },
|
|
9880
|
+
{ path: "src/components/cart/reservation-countdown.tsx", label: "Reservation Countdown" },
|
|
9881
|
+
{ path: "src/components/checkout/checkout-form.tsx", label: "Checkout Form" },
|
|
9882
|
+
{ path: "src/components/checkout/shipping-step.tsx", label: "Shipping Step" },
|
|
9883
|
+
{ path: "src/components/checkout/payment-step.tsx", label: "Payment Step" },
|
|
9884
|
+
{ path: "src/components/checkout/tax-display.tsx", label: "Tax Display" },
|
|
9885
|
+
{ path: "src/components/auth/login-form.tsx", label: "Login Form" },
|
|
9886
|
+
{ path: "src/components/auth/register-form.tsx", label: "Register Form" },
|
|
9887
|
+
{ path: "src/components/auth/oauth-buttons.tsx", label: "OAuth Buttons" },
|
|
9888
|
+
{ path: "src/components/account/profile-section.tsx", label: "Profile Section" },
|
|
9889
|
+
{ path: "src/components/account/order-history.tsx", label: "Order History" },
|
|
9890
|
+
{ path: "src/components/shared/price-display.tsx", label: "Price Display" },
|
|
9891
|
+
{ path: "src/components/shared/loading-spinner.tsx", label: "Loading Spinner" },
|
|
9892
|
+
{ path: "src/hooks/use-search.ts", label: "Search Hook" }
|
|
9893
|
+
];
|
|
9894
|
+
var API_ROUTE_FILES = [
|
|
9895
|
+
"src/app/api/store/[...path]/route.ts",
|
|
9896
|
+
"src/app/api/auth/oauth-callback/route.ts",
|
|
9897
|
+
"src/app/api/auth/me/route.ts",
|
|
9898
|
+
"src/app/api/auth/logout/route.ts",
|
|
9899
|
+
"src/app/api/auth/reset-callback/route.ts",
|
|
9900
|
+
"src/app/api/auth/reset-password/route.ts"
|
|
9901
|
+
];
|
|
9902
|
+
function replaceEjsVars(content, vars) {
|
|
9903
|
+
let result = content;
|
|
9904
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
9905
|
+
result = result.replace(new RegExp(`<%[=-]\\s*${key}\\s*%>`, "g"), value);
|
|
9906
|
+
}
|
|
9907
|
+
return result;
|
|
9908
|
+
}
|
|
9909
|
+
function renderFile(path, ejsVars) {
|
|
9910
|
+
const content = EMBEDDED_TEMPLATES[path];
|
|
9911
|
+
if (!content) return null;
|
|
9912
|
+
const rendered = path.endsWith(".ejs") ? replaceEjsVars(content, ejsVars) : content;
|
|
9913
|
+
const displayPath = path.replace(/\.ejs$/, "");
|
|
9914
|
+
return `// \u2500\u2500 ${displayPath} \u2500\u2500
|
|
9915
|
+
${rendered}`;
|
|
9916
|
+
}
|
|
9917
|
+
function formatCapabilitiesSummary(caps) {
|
|
9918
|
+
const lines = [];
|
|
9919
|
+
lines.push(`## Store: ${caps.store.name}`);
|
|
9920
|
+
lines.push(`Currency: ${caps.store.currency} | Language: ${caps.store.language}`);
|
|
9921
|
+
lines.push("");
|
|
9922
|
+
lines.push("### Configured Features");
|
|
9923
|
+
if (caps.features.paymentProviders.length > 0) {
|
|
9924
|
+
const providers = caps.features.paymentProviders.map((p) => `${p.name} (${p.provider})`).join(", ");
|
|
9925
|
+
lines.push(`- Payment: ${providers}`);
|
|
9926
|
+
} else {
|
|
9927
|
+
lines.push(
|
|
9928
|
+
"- Payment: NONE configured \u2014 customers cannot pay! Tell the store owner to configure a payment provider."
|
|
9929
|
+
);
|
|
9930
|
+
}
|
|
9931
|
+
const enabledOAuth = caps.features.oauthProviders.filter((o) => o.isEnabled).map((o) => o.provider);
|
|
9932
|
+
if (enabledOAuth.length > 0) {
|
|
9933
|
+
lines.push(`- OAuth: ${enabledOAuth.join(", ")} \u2014 show OAuth buttons on login/register`);
|
|
9934
|
+
} else {
|
|
9935
|
+
lines.push(
|
|
9936
|
+
"- 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)"
|
|
9937
|
+
);
|
|
9938
|
+
}
|
|
9939
|
+
lines.push(
|
|
9940
|
+
caps.features.hasShippingZones ? "- Shipping: configured" : "- Shipping: no zones configured"
|
|
9941
|
+
);
|
|
9942
|
+
lines.push(
|
|
9943
|
+
caps.features.hasDiscountRules ? "- Discounts: active \u2014 show banners and badges" : "- Discounts: none active"
|
|
9944
|
+
);
|
|
9945
|
+
lines.push(
|
|
9946
|
+
caps.features.hasDownloadableProducts ? "- Downloadable products: yes \u2014 show download badges" : "- Downloadable products: no"
|
|
9947
|
+
);
|
|
9948
|
+
lines.push(
|
|
9949
|
+
caps.features.hasCoupons ? "- Coupons: active \u2014 show coupon input on cart" : "- Coupons: none"
|
|
9950
|
+
);
|
|
9951
|
+
lines.push("");
|
|
9952
|
+
lines.push("### Connection Settings");
|
|
9953
|
+
lines.push(
|
|
9954
|
+
`- Email verification: ${caps.connection.requireEmailVerification ? "REQUIRED \u2014 verify-email page is critical" : "not required (still build the page)"}`
|
|
9955
|
+
);
|
|
9956
|
+
lines.push(
|
|
9957
|
+
`- Inventory reservation: ${caps.connection.reservationStrategy} (${caps.connection.reservationTimeout} min)`
|
|
9958
|
+
);
|
|
9959
|
+
lines.push(
|
|
9960
|
+
`- Guest checkout tracking: ${caps.connection.guestCheckoutTracking ? "enabled" : "disabled"}`
|
|
9961
|
+
);
|
|
9962
|
+
return lines.join("\n");
|
|
9963
|
+
}
|
|
9964
|
+
function buildStoreBundle(options) {
|
|
9965
|
+
const { connectionId, storeName, storeType, currency, storeStyle, capabilities } = options;
|
|
9966
|
+
const styleDesc = storeStyle ? ` with a ${storeStyle} aesthetic` : "";
|
|
9967
|
+
const ejsVars = {
|
|
9968
|
+
connectionId,
|
|
9969
|
+
storeName,
|
|
9970
|
+
currency,
|
|
9971
|
+
language: capabilities?.store.language || "en",
|
|
9972
|
+
apiUrl: "https://api.brainerce.com"
|
|
9973
|
+
};
|
|
9974
|
+
const sections = [];
|
|
9975
|
+
sections.push(`# Build: ${storeName} \u2014 ${storeType}${styleDesc}
|
|
9976
|
+
Connection ID: \`${connectionId}\` | Currency: ${currency}
|
|
9977
|
+
|
|
9978
|
+
Install the SDK first: \`npm install brainerce @stripe/stripe-js @stripe/react-stripe-js\`
|
|
9979
|
+
|
|
9980
|
+
## MANDATORY: Every page and component below MUST be built
|
|
9981
|
+
|
|
9982
|
+
**Do NOT skip any page or component.** Even features that appear unused (OAuth buttons, coupon input, discount badges, verify-email) MUST be included because:
|
|
9983
|
+
- They auto-hide when not configured \u2014 zero visual impact
|
|
9984
|
+
- Store owners can enable them at any time from the dashboard
|
|
9985
|
+
- A store without these is incomplete and will break when features are enabled
|
|
9986
|
+
|
|
9987
|
+
**Every file in this bundle is required. Build them ALL.**`);
|
|
9988
|
+
if (capabilities) {
|
|
9989
|
+
sections.push(formatCapabilitiesSummary(capabilities));
|
|
9990
|
+
}
|
|
9991
|
+
sections.push(getCriticalRulesSection());
|
|
9992
|
+
sections.push(getTypeQuickReference());
|
|
9993
|
+
const setupSection = ["## Setup Files\n"];
|
|
9994
|
+
for (const path of SETUP_FILES) {
|
|
9995
|
+
const rendered = renderFile(path, ejsVars);
|
|
9996
|
+
if (rendered) setupSection.push(rendered);
|
|
9997
|
+
}
|
|
9998
|
+
sections.push(setupSection.join("\n\n"));
|
|
9999
|
+
const pagesSection = ["## Pages (ALL 13 required \u2014 build every single one!)\n"];
|
|
10000
|
+
for (const page of PAGE_FILES) {
|
|
10001
|
+
const rendered = renderFile(page.path, ejsVars);
|
|
10002
|
+
if (rendered) {
|
|
10003
|
+
pagesSection.push(`### ${page.route} \u2014 ${page.label}
|
|
10004
|
+
|
|
10005
|
+
${rendered}`);
|
|
10006
|
+
}
|
|
10007
|
+
}
|
|
10008
|
+
sections.push(pagesSection.join("\n\n"));
|
|
10009
|
+
const componentsSection = ["## Components\n"];
|
|
10010
|
+
for (const comp of COMPONENT_FILES) {
|
|
10011
|
+
const rendered = renderFile(comp.path, ejsVars);
|
|
10012
|
+
if (rendered) componentsSection.push(rendered);
|
|
10013
|
+
}
|
|
10014
|
+
sections.push(componentsSection.join("\n\n"));
|
|
10015
|
+
const apiSection = ["## API Routes (BFF Proxy + Auth)\n"];
|
|
10016
|
+
for (const path of API_ROUTE_FILES) {
|
|
10017
|
+
const rendered = renderFile(path, ejsVars);
|
|
10018
|
+
if (rendered) apiSection.push(rendered);
|
|
10019
|
+
}
|
|
10020
|
+
sections.push(apiSection.join("\n\n"));
|
|
10021
|
+
sections.push(`## Verification Checklist
|
|
10022
|
+
|
|
10023
|
+
After building, verify you created ALL of these files:
|
|
10024
|
+
|
|
10025
|
+
### Setup
|
|
10026
|
+
- [ ] \`lib/brainerce.ts\` \u2014 SDK client singleton
|
|
10027
|
+
- [ ] \`lib/auth.ts\` \u2014 Auth helpers (BFF pattern)
|
|
10028
|
+
- [ ] \`providers/store-provider.tsx\` \u2014 Global store context
|
|
10029
|
+
- [ ] \`app/layout.tsx\` \u2014 Root layout with StoreProvider
|
|
10030
|
+
- [ ] \`middleware.ts\` \u2014 Auth middleware
|
|
10031
|
+
|
|
10032
|
+
### Pages (ALL 13 required!)
|
|
10033
|
+
- [ ] \`app/page.tsx\` \u2014 Home (hero, featured products)
|
|
10034
|
+
- [ ] \`app/products/page.tsx\` \u2014 Product listing with filters
|
|
10035
|
+
- [ ] \`app/products/[slug]/page.tsx\` \u2014 Product detail
|
|
10036
|
+
- [ ] \`app/cart/page.tsx\` \u2014 Cart with checkboxes, coupon
|
|
10037
|
+
- [ ] \`app/checkout/page.tsx\` \u2014 Address \u2192 Shipping \u2192 Payment
|
|
10038
|
+
- [ ] \`app/order-confirmation/page.tsx\` \u2014 waitForOrder() + handlePaymentSuccess()
|
|
10039
|
+
- [ ] \`app/login/page.tsx\` \u2014 Login
|
|
10040
|
+
- [ ] \`app/register/page.tsx\` \u2014 Register
|
|
10041
|
+
- [ ] \`app/verify-email/page.tsx\` \u2014 Email verification
|
|
10042
|
+
- [ ] \`app/forgot-password/page.tsx\` \u2014 Forgot password
|
|
10043
|
+
- [ ] \`app/reset-password/page.tsx\` \u2014 Reset password
|
|
10044
|
+
- [ ] \`app/auth/callback/page.tsx\` \u2014 OAuth callback
|
|
10045
|
+
- [ ] \`app/account/page.tsx\` \u2014 Account dashboard
|
|
10046
|
+
|
|
10047
|
+
### Components
|
|
10048
|
+
- [ ] Header with cart count + search autocomplete
|
|
10049
|
+
- [ ] Product card, grid, variant selector
|
|
10050
|
+
- [ ] Cart item, summary, coupon input
|
|
10051
|
+
- [ ] Checkout form, shipping step, payment step
|
|
10052
|
+
- [ ] Auth forms (login, register, OAuth buttons)
|
|
10053
|
+
|
|
10054
|
+
### API Routes
|
|
10055
|
+
- [ ] \`api/store/[...path]/route.ts\` \u2014 BFF proxy
|
|
10056
|
+
- [ ] \`api/auth/\` routes \u2014 OAuth callback, me, logout, reset
|
|
10057
|
+
|
|
10058
|
+
DO NOT STOP until every item is checked. A store with missing pages is broken.`);
|
|
10059
|
+
return sections.join("\n\n---\n\n");
|
|
10060
|
+
}
|
|
10061
|
+
|
|
10062
|
+
// src/tools/build-store.ts
|
|
10063
|
+
var BUILD_STORE_NAME = "build-store";
|
|
10064
|
+
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.";
|
|
10065
|
+
var BUILD_STORE_SCHEMA = {
|
|
10066
|
+
connectionId: z7.string().describe("Vibe-coded connection ID (starts with vc_)"),
|
|
10067
|
+
storeName: z7.string().describe('Name of the store (e.g., "Urban Threads")'),
|
|
10068
|
+
storeType: z7.string().describe('Type of store (e.g., "Clothing store", "Electronics shop", "Bakery")'),
|
|
10069
|
+
currency: z7.string().optional().default("USD").describe("Store currency code (e.g., USD, ILS, EUR)"),
|
|
10070
|
+
storeStyle: z7.string().optional().describe('Design style (e.g., "minimalist", "luxury", "playful", "dark mode")')
|
|
10071
|
+
};
|
|
10072
|
+
async function handleBuildStore(args) {
|
|
10073
|
+
let capabilities = null;
|
|
10074
|
+
try {
|
|
10075
|
+
capabilities = await fetchStoreCapabilities(args.connectionId);
|
|
10076
|
+
} catch {
|
|
10077
|
+
}
|
|
10078
|
+
const bundle = buildStoreBundle({
|
|
10079
|
+
connectionId: args.connectionId,
|
|
10080
|
+
storeName: args.storeName,
|
|
10081
|
+
storeType: args.storeType,
|
|
10082
|
+
currency: args.currency || capabilities?.store.currency || "USD",
|
|
10083
|
+
storeStyle: args.storeStyle,
|
|
10084
|
+
capabilities
|
|
10085
|
+
});
|
|
10086
|
+
return {
|
|
10087
|
+
content: [{ type: "text", text: bundle }]
|
|
10088
|
+
};
|
|
10089
|
+
}
|
|
10090
|
+
|
|
10091
|
+
// src/tools/validate-store.ts
|
|
10092
|
+
import { z as z8 } from "zod";
|
|
10093
|
+
var VALIDATE_STORE_NAME = "validate-store";
|
|
10094
|
+
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.";
|
|
10095
|
+
var VALIDATE_STORE_SCHEMA = {
|
|
10096
|
+
files: z8.array(z8.string()).describe('List of file paths created (e.g., ["app/page.tsx", "app/products/page.tsx", ...])')
|
|
10097
|
+
};
|
|
10098
|
+
var REQUIRED_PAGES = [
|
|
10099
|
+
{
|
|
10100
|
+
route: "/",
|
|
10101
|
+
label: "Home",
|
|
10102
|
+
patterns: ["app/page.tsx", "page.tsx", "pages/index"],
|
|
10103
|
+
critical: true
|
|
10104
|
+
},
|
|
10105
|
+
{
|
|
10106
|
+
route: "/products",
|
|
10107
|
+
label: "Product Listing",
|
|
10108
|
+
patterns: ["app/products/page.tsx", "products/page.tsx", "pages/products"],
|
|
10109
|
+
critical: true
|
|
10110
|
+
},
|
|
10111
|
+
{
|
|
10112
|
+
route: "/products/[slug]",
|
|
10113
|
+
label: "Product Detail",
|
|
10114
|
+
patterns: ["app/products/[slug]", "products/[slug]", "pages/products/[slug]"],
|
|
10115
|
+
critical: true
|
|
10116
|
+
},
|
|
10117
|
+
{
|
|
10118
|
+
route: "/cart",
|
|
10119
|
+
label: "Cart",
|
|
10120
|
+
patterns: ["app/cart/page.tsx", "cart/page.tsx", "pages/cart"],
|
|
10121
|
+
critical: true
|
|
10122
|
+
},
|
|
10123
|
+
{
|
|
10124
|
+
route: "/checkout",
|
|
10125
|
+
label: "Checkout",
|
|
10126
|
+
patterns: ["app/checkout/page.tsx", "checkout/page.tsx", "pages/checkout"],
|
|
10127
|
+
critical: true
|
|
10128
|
+
},
|
|
10129
|
+
{
|
|
10130
|
+
route: "/order-confirmation",
|
|
10131
|
+
label: "Order Confirmation",
|
|
10132
|
+
patterns: ["app/order-confirmation", "order-confirmation", "checkout/success"],
|
|
10133
|
+
critical: true
|
|
10134
|
+
},
|
|
10135
|
+
{
|
|
10136
|
+
route: "/login",
|
|
10137
|
+
label: "Login",
|
|
10138
|
+
patterns: ["app/login/page.tsx", "login/page.tsx", "pages/login"],
|
|
10139
|
+
critical: true
|
|
10140
|
+
},
|
|
10141
|
+
{
|
|
10142
|
+
route: "/register",
|
|
10143
|
+
label: "Register",
|
|
10144
|
+
patterns: ["app/register/page.tsx", "register/page.tsx", "pages/register", "signup"],
|
|
10145
|
+
critical: true
|
|
10146
|
+
},
|
|
10147
|
+
{
|
|
10148
|
+
route: "/verify-email",
|
|
10149
|
+
label: "Verify Email",
|
|
10150
|
+
patterns: ["app/verify-email", "verify-email", "email-verification"],
|
|
10151
|
+
critical: true
|
|
10152
|
+
},
|
|
10153
|
+
{
|
|
10154
|
+
route: "/forgot-password",
|
|
10155
|
+
label: "Forgot Password",
|
|
10156
|
+
patterns: ["app/forgot-password", "forgot-password"],
|
|
10157
|
+
critical: true
|
|
10158
|
+
},
|
|
10159
|
+
{
|
|
10160
|
+
route: "/reset-password",
|
|
10161
|
+
label: "Reset Password",
|
|
10162
|
+
patterns: ["app/reset-password", "reset-password"],
|
|
10163
|
+
critical: true
|
|
10164
|
+
},
|
|
10165
|
+
{
|
|
10166
|
+
route: "/auth/callback",
|
|
10167
|
+
label: "OAuth Callback",
|
|
10168
|
+
patterns: ["app/auth/callback", "auth/callback", "oauth/callback"],
|
|
10169
|
+
critical: true
|
|
10170
|
+
},
|
|
10171
|
+
{
|
|
10172
|
+
route: "/account",
|
|
10173
|
+
label: "Account",
|
|
10174
|
+
patterns: ["app/account/page.tsx", "account/page.tsx", "pages/account", "profile"],
|
|
10175
|
+
critical: true
|
|
10176
|
+
}
|
|
10177
|
+
];
|
|
10178
|
+
var REQUIRED_COMPONENTS = [
|
|
10179
|
+
{ label: "Header", patterns: ["header", "Header", "navbar", "Navbar", "nav-bar"] },
|
|
10180
|
+
{ label: "SDK Setup (lib/brainerce.ts)", patterns: ["lib/brainerce", "brainerce.ts", "sdk"] },
|
|
10181
|
+
{ label: "Store Provider", patterns: ["store-provider", "StoreProvider", "provider"] },
|
|
10182
|
+
{ label: "BFF Proxy (api/store)", patterns: ["api/store", "api/proxy"] }
|
|
10183
|
+
];
|
|
10184
|
+
function fileMatchesPattern(file, patterns) {
|
|
10185
|
+
const normalized = file.replace(/\\/g, "/").toLowerCase();
|
|
10186
|
+
return patterns.some((pattern) => normalized.includes(pattern.toLowerCase()));
|
|
10187
|
+
}
|
|
10188
|
+
async function handleValidateStore(args) {
|
|
10189
|
+
const { files } = args;
|
|
10190
|
+
const missingPages = [];
|
|
10191
|
+
const foundPages = [];
|
|
10192
|
+
for (const page of REQUIRED_PAGES) {
|
|
10193
|
+
if (files.some((f) => fileMatchesPattern(f, page.patterns))) {
|
|
10194
|
+
foundPages.push({ route: page.route, label: page.label });
|
|
10195
|
+
} else {
|
|
10196
|
+
missingPages.push({ route: page.route, label: page.label });
|
|
10197
|
+
}
|
|
10198
|
+
}
|
|
10199
|
+
const missingComponents = [];
|
|
10200
|
+
const foundComponents = [];
|
|
10201
|
+
for (const comp of REQUIRED_COMPONENTS) {
|
|
10202
|
+
if (files.some((f) => fileMatchesPattern(f, comp.patterns))) {
|
|
10203
|
+
foundComponents.push(comp.label);
|
|
10204
|
+
} else {
|
|
10205
|
+
missingComponents.push(comp.label);
|
|
10206
|
+
}
|
|
10207
|
+
}
|
|
10208
|
+
const totalRequired = REQUIRED_PAGES.length + REQUIRED_COMPONENTS.length;
|
|
10209
|
+
const totalFound = foundPages.length + foundComponents.length;
|
|
10210
|
+
const lines = [];
|
|
10211
|
+
if (missingPages.length === 0 && missingComponents.length === 0) {
|
|
10212
|
+
lines.push("# Store Validation: COMPLETE");
|
|
10213
|
+
lines.push(`All ${totalRequired} required items found. The store is complete.`);
|
|
10214
|
+
lines.push("");
|
|
10215
|
+
lines.push("## Pages Found");
|
|
10216
|
+
for (const page of foundPages) {
|
|
10217
|
+
lines.push(`- [x] ${page.route} \u2014 ${page.label}`);
|
|
10218
|
+
}
|
|
10219
|
+
lines.push("");
|
|
10220
|
+
lines.push("## Components Found");
|
|
10221
|
+
for (const comp of foundComponents) {
|
|
10222
|
+
lines.push(`- [x] ${comp}`);
|
|
10223
|
+
}
|
|
10224
|
+
} else {
|
|
10225
|
+
lines.push(`# Store Validation: INCOMPLETE (${totalFound}/${totalRequired})`);
|
|
10226
|
+
lines.push("");
|
|
10227
|
+
if (missingPages.length > 0) {
|
|
10228
|
+
lines.push(`## Missing Pages (${missingPages.length})`);
|
|
10229
|
+
for (const page of missingPages) {
|
|
10230
|
+
lines.push(
|
|
10231
|
+
`- [ ] **${page.route}** \u2014 ${page.label} \u2014 Use \`get-page-code\` with page "${page.label.toLowerCase().replace(/\s+/g, "-")}" to get the template`
|
|
10232
|
+
);
|
|
10233
|
+
}
|
|
10234
|
+
lines.push("");
|
|
10235
|
+
}
|
|
10236
|
+
if (missingComponents.length > 0) {
|
|
10237
|
+
lines.push(`## Missing Components (${missingComponents.length})`);
|
|
10238
|
+
for (const comp of missingComponents) {
|
|
10239
|
+
lines.push(`- [ ] **${comp}**`);
|
|
10240
|
+
}
|
|
10241
|
+
lines.push("");
|
|
10242
|
+
}
|
|
10243
|
+
lines.push("## Found");
|
|
10244
|
+
for (const page of foundPages) {
|
|
10245
|
+
lines.push(`- [x] ${page.route} \u2014 ${page.label}`);
|
|
10246
|
+
}
|
|
10247
|
+
for (const comp of foundComponents) {
|
|
10248
|
+
lines.push(`- [x] ${comp}`);
|
|
10249
|
+
}
|
|
10250
|
+
lines.push("");
|
|
10251
|
+
lines.push("**Build the missing pages before considering the store complete!**");
|
|
10252
|
+
}
|
|
10253
|
+
return {
|
|
10254
|
+
content: [{ type: "text", text: lines.join("\n") }]
|
|
10255
|
+
};
|
|
10256
|
+
}
|
|
10257
|
+
|
|
10258
|
+
// src/tools/get-page-code.ts
|
|
10259
|
+
import { z as z9 } from "zod";
|
|
10260
|
+
var GET_PAGE_CODE_NAME = "get-page-code";
|
|
10261
|
+
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.";
|
|
10262
|
+
var GET_PAGE_CODE_SCHEMA = {
|
|
10263
|
+
pages: z9.array(
|
|
10264
|
+
z9.enum([
|
|
10265
|
+
"home",
|
|
10266
|
+
"products",
|
|
10267
|
+
"product-detail",
|
|
10268
|
+
"cart",
|
|
10269
|
+
"checkout",
|
|
10270
|
+
"order-confirmation",
|
|
10271
|
+
"login",
|
|
10272
|
+
"register",
|
|
10273
|
+
"verify-email",
|
|
10274
|
+
"forgot-password",
|
|
10275
|
+
"reset-password",
|
|
10276
|
+
"oauth-callback",
|
|
10277
|
+
"account",
|
|
10278
|
+
"header",
|
|
10279
|
+
"setup"
|
|
10280
|
+
])
|
|
10281
|
+
).describe('Pages to get code for (e.g., ["checkout", "login", "account"])')
|
|
10282
|
+
};
|
|
10283
|
+
var PAGE_TO_FILES = {
|
|
10284
|
+
setup: [
|
|
10285
|
+
{ path: "src/lib/brainerce.ts.ejs", label: "SDK Client" },
|
|
10286
|
+
{ path: "src/lib/auth.ts", label: "Auth Helpers" },
|
|
10287
|
+
{ path: "src/lib/utils.ts", label: "Utilities" },
|
|
10288
|
+
{ path: "src/providers/store-provider.tsx.ejs", label: "Store Provider" },
|
|
10289
|
+
{ path: "src/app/layout.tsx.ejs", label: "Root Layout" },
|
|
10290
|
+
{ path: "src/middleware.ts", label: "Middleware" },
|
|
10291
|
+
{ path: "src/app/api/store/[...path]/route.ts", label: "BFF Proxy" }
|
|
10292
|
+
],
|
|
10293
|
+
home: [
|
|
10294
|
+
{ path: "src/app/page.tsx", label: "Home Page" },
|
|
10295
|
+
{ path: "src/components/products/product-card.tsx", label: "Product Card" },
|
|
10296
|
+
{ path: "src/components/products/product-grid.tsx", label: "Product Grid" },
|
|
10297
|
+
{ path: "src/components/shared/price-display.tsx", label: "Price Display" }
|
|
10298
|
+
],
|
|
10299
|
+
products: [
|
|
10300
|
+
{ path: "src/app/products/page.tsx", label: "Products Page" },
|
|
10301
|
+
{ path: "src/components/products/product-card.tsx", label: "Product Card" },
|
|
10302
|
+
{ path: "src/components/products/product-grid.tsx", label: "Product Grid" },
|
|
10303
|
+
{ path: "src/components/shared/price-display.tsx", label: "Price Display" }
|
|
10304
|
+
],
|
|
10305
|
+
"product-detail": [
|
|
10306
|
+
{ path: "src/app/products/[slug]/page.tsx", label: "Product Detail Page" },
|
|
10307
|
+
{ path: "src/components/products/variant-selector.tsx", label: "Variant Selector" },
|
|
10308
|
+
{ path: "src/components/products/stock-badge.tsx", label: "Stock Badge" },
|
|
10309
|
+
{ path: "src/components/products/discount-badge.tsx", label: "Discount Badge" },
|
|
10310
|
+
{ path: "src/components/shared/price-display.tsx", label: "Price Display" }
|
|
10311
|
+
],
|
|
10312
|
+
cart: [
|
|
10313
|
+
{ path: "src/app/cart/page.tsx", label: "Cart Page" },
|
|
10314
|
+
{ path: "src/components/cart/cart-item.tsx", label: "Cart Item" },
|
|
10315
|
+
{ path: "src/components/cart/cart-summary.tsx", label: "Cart Summary" },
|
|
10316
|
+
{ path: "src/components/cart/coupon-input.tsx", label: "Coupon Input" },
|
|
10317
|
+
{ path: "src/components/cart/cart-nudges.tsx", label: "Cart Nudges" },
|
|
10318
|
+
{ path: "src/components/cart/reservation-countdown.tsx", label: "Reservation Countdown" }
|
|
10319
|
+
],
|
|
10320
|
+
checkout: [
|
|
10321
|
+
{ path: "src/app/checkout/page.tsx", label: "Checkout Page" },
|
|
10322
|
+
{ path: "src/components/checkout/checkout-form.tsx", label: "Checkout Form" },
|
|
10323
|
+
{ path: "src/components/checkout/shipping-step.tsx", label: "Shipping Step" },
|
|
10324
|
+
{ path: "src/components/checkout/payment-step.tsx", label: "Payment Step" },
|
|
10325
|
+
{ path: "src/components/checkout/tax-display.tsx", label: "Tax Display" }
|
|
10326
|
+
],
|
|
10327
|
+
"order-confirmation": [
|
|
10328
|
+
{ path: "src/app/order-confirmation/page.tsx", label: "Order Confirmation Page" }
|
|
10329
|
+
],
|
|
10330
|
+
login: [
|
|
10331
|
+
{ path: "src/app/login/page.tsx", label: "Login Page" },
|
|
10332
|
+
{ path: "src/components/auth/login-form.tsx", label: "Login Form" },
|
|
10333
|
+
{ path: "src/components/auth/oauth-buttons.tsx", label: "OAuth Buttons" }
|
|
10334
|
+
],
|
|
10335
|
+
register: [
|
|
10336
|
+
{ path: "src/app/register/page.tsx", label: "Register Page" },
|
|
10337
|
+
{ path: "src/components/auth/register-form.tsx", label: "Register Form" },
|
|
10338
|
+
{ path: "src/components/auth/oauth-buttons.tsx", label: "OAuth Buttons" }
|
|
10339
|
+
],
|
|
10340
|
+
"verify-email": [{ path: "src/app/verify-email/page.tsx", label: "Verify Email Page" }],
|
|
10341
|
+
"forgot-password": [{ path: "src/app/forgot-password/page.tsx", label: "Forgot Password Page" }],
|
|
10342
|
+
"reset-password": [
|
|
10343
|
+
{ path: "src/app/reset-password/page.tsx", label: "Reset Password Page" },
|
|
10344
|
+
{ path: "src/app/api/auth/reset-callback/route.ts", label: "Reset Callback API" },
|
|
10345
|
+
{ path: "src/app/api/auth/reset-password/route.ts", label: "Reset Password API" }
|
|
10346
|
+
],
|
|
10347
|
+
"oauth-callback": [
|
|
10348
|
+
{ path: "src/app/auth/callback/page.tsx", label: "OAuth Callback Page" },
|
|
10349
|
+
{ path: "src/app/api/auth/oauth-callback/route.ts", label: "OAuth Callback API" }
|
|
10350
|
+
],
|
|
10351
|
+
account: [
|
|
10352
|
+
{ path: "src/app/account/page.tsx", label: "Account Page" },
|
|
10353
|
+
{ path: "src/components/account/profile-section.tsx", label: "Profile Section" },
|
|
10354
|
+
{ path: "src/components/account/order-history.tsx", label: "Order History" }
|
|
10355
|
+
],
|
|
10356
|
+
header: [
|
|
10357
|
+
{ path: "src/components/layout/header.tsx", label: "Header" },
|
|
10358
|
+
{ path: "src/components/layout/footer.tsx", label: "Footer" },
|
|
10359
|
+
{ path: "src/hooks/use-search.ts", label: "Search Hook" }
|
|
10360
|
+
]
|
|
10361
|
+
};
|
|
10362
|
+
async function handleGetPageCode(args) {
|
|
10363
|
+
const emitted = /* @__PURE__ */ new Set();
|
|
10364
|
+
const results = [];
|
|
10365
|
+
for (const page of args.pages) {
|
|
10366
|
+
const files = PAGE_TO_FILES[page];
|
|
10367
|
+
if (!files) {
|
|
10368
|
+
results.push(
|
|
10369
|
+
`# Unknown page: "${page}"
|
|
10370
|
+
Available: ${Object.keys(PAGE_TO_FILES).join(", ")}`
|
|
10371
|
+
);
|
|
10372
|
+
continue;
|
|
10373
|
+
}
|
|
10374
|
+
const pageResults = [];
|
|
10375
|
+
for (const file of files) {
|
|
10376
|
+
if (emitted.has(file.path)) continue;
|
|
10377
|
+
emitted.add(file.path);
|
|
10378
|
+
const content = EMBEDDED_TEMPLATES[file.path];
|
|
10379
|
+
if (!content) continue;
|
|
10380
|
+
const displayPath = file.path.replace(/\.ejs$/, "");
|
|
10381
|
+
pageResults.push(`// \u2500\u2500 ${displayPath} (${file.label}) \u2500\u2500
|
|
10382
|
+
${content}`);
|
|
10383
|
+
}
|
|
10384
|
+
if (pageResults.length > 0) {
|
|
10385
|
+
results.push(`# ${page}
|
|
10386
|
+
|
|
10387
|
+
${pageResults.join("\n\n")}`);
|
|
10388
|
+
}
|
|
10389
|
+
}
|
|
10390
|
+
if (results.length === 0) {
|
|
10391
|
+
return {
|
|
10392
|
+
content: [
|
|
10393
|
+
{
|
|
10394
|
+
type: "text",
|
|
10395
|
+
text: `No files found. Available pages: ${Object.keys(PAGE_TO_FILES).join(", ")}`
|
|
10396
|
+
}
|
|
10397
|
+
]
|
|
10398
|
+
};
|
|
10399
|
+
}
|
|
10400
|
+
return {
|
|
10401
|
+
content: [
|
|
10402
|
+
{
|
|
10403
|
+
type: "text",
|
|
10404
|
+
text: `# Page Code (${args.pages.join(", ")})
|
|
10405
|
+
|
|
10406
|
+
Production-ready template code. Use as-is \u2014 customize only the design/styling.
|
|
10407
|
+
|
|
10408
|
+
${results.join("\n\n---\n\n")}`
|
|
10409
|
+
}
|
|
10410
|
+
]
|
|
10411
|
+
};
|
|
10412
|
+
}
|
|
10413
|
+
|
|
8996
10414
|
// src/resources/sdk-types.ts
|
|
8997
10415
|
var SDK_TYPES_URI = "brainerce://sdk/types";
|
|
8998
10416
|
var SDK_TYPES_NAME = "Brainerce SDK Types";
|
|
@@ -9147,24 +10565,57 @@ async function handleProjectTemplate(uri) {
|
|
|
9147
10565
|
}
|
|
9148
10566
|
|
|
9149
10567
|
// src/prompts/create-store.ts
|
|
9150
|
-
import { z as
|
|
10568
|
+
import { z as z10 } from "zod";
|
|
9151
10569
|
var CREATE_STORE_NAME = "create-store";
|
|
9152
|
-
var CREATE_STORE_DESCRIPTION = "Generate a personalized prompt for building a complete Brainerce e-commerce store.
|
|
10570
|
+
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.";
|
|
9153
10571
|
var CREATE_STORE_SCHEMA = {
|
|
9154
|
-
connectionId:
|
|
9155
|
-
storeName:
|
|
9156
|
-
storeType:
|
|
9157
|
-
currency:
|
|
9158
|
-
storeStyle:
|
|
10572
|
+
connectionId: z10.string().describe("Vibe-coded connection ID (starts with vc_)"),
|
|
10573
|
+
storeName: z10.string().describe('Name of the store (e.g., "Urban Threads")'),
|
|
10574
|
+
storeType: z10.string().describe('Type of store (e.g., "Clothing store", "Electronics shop", "Bakery")'),
|
|
10575
|
+
currency: z10.string().optional().default("USD").describe("Store currency code (e.g., USD, ILS, EUR)"),
|
|
10576
|
+
storeStyle: z10.string().optional().describe('Design style (e.g., "minimalist", "luxury", "playful", "dark mode")')
|
|
9159
10577
|
};
|
|
9160
10578
|
function handleCreateStore(args) {
|
|
9161
|
-
const
|
|
9162
|
-
|
|
9163
|
-
|
|
9164
|
-
|
|
9165
|
-
|
|
9166
|
-
|
|
9167
|
-
|
|
10579
|
+
const styleDesc = args.storeStyle ? ` with a ${args.storeStyle} design style` : "";
|
|
10580
|
+
const prompt = `# Build a Brainerce E-Commerce Store
|
|
10581
|
+
|
|
10582
|
+
## Task
|
|
10583
|
+
Build a complete **${args.storeType}** called **${args.storeName}**${styleDesc}.
|
|
10584
|
+
Connection ID: \`${args.connectionId}\` | Currency: ${args.currency || "USD"}
|
|
10585
|
+
|
|
10586
|
+
## Step 1: Get Everything You Need (ONE call)
|
|
10587
|
+
Call the \`build-store\` tool with:
|
|
10588
|
+
- connectionId: "${args.connectionId}"
|
|
10589
|
+
- storeName: "${args.storeName}"
|
|
10590
|
+
- storeType: "${args.storeType}"
|
|
10591
|
+
- currency: "${args.currency || "USD"}"${args.storeStyle ? `
|
|
10592
|
+
- storeStyle: "${args.storeStyle}"` : ""}
|
|
10593
|
+
|
|
10594
|
+
This returns ALL the code templates, critical rules, type reference, and component code you need.
|
|
10595
|
+
|
|
10596
|
+
## Step 2: Build ALL 13 Pages + Header
|
|
10597
|
+
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.
|
|
10598
|
+
|
|
10599
|
+
DO NOT STOP until all 13 pages + header are implemented:
|
|
10600
|
+
1. \`/\` \u2014 Home
|
|
10601
|
+
2. \`/products\` \u2014 Product listing
|
|
10602
|
+
3. \`/products/[slug]\` \u2014 Product detail
|
|
10603
|
+
4. \`/cart\` \u2014 Cart
|
|
10604
|
+
5. \`/checkout\` \u2014 Checkout
|
|
10605
|
+
6. \`/order-confirmation\` \u2014 Order confirmation
|
|
10606
|
+
7. \`/login\` \u2014 Login
|
|
10607
|
+
8. \`/register\` \u2014 Register
|
|
10608
|
+
9. \`/verify-email\` \u2014 Email verification
|
|
10609
|
+
10. \`/forgot-password\` \u2014 Forgot password
|
|
10610
|
+
11. \`/reset-password\` \u2014 Reset password
|
|
10611
|
+
12. \`/auth/callback\` \u2014 OAuth callback
|
|
10612
|
+
13. \`/account\` \u2014 Account dashboard
|
|
10613
|
+
14. Header component with cart count + search
|
|
10614
|
+
|
|
10615
|
+
## Step 3: Validate
|
|
10616
|
+
After building all pages, call \`validate-store\` with the list of files you created to verify nothing is missing.
|
|
10617
|
+
|
|
10618
|
+
**Start now \u2014 call \`build-store\` to get all the templates.**`;
|
|
9168
10619
|
return {
|
|
9169
10620
|
messages: [
|
|
9170
10621
|
{
|
|
@@ -9179,11 +10630,11 @@ function handleCreateStore(args) {
|
|
|
9179
10630
|
}
|
|
9180
10631
|
|
|
9181
10632
|
// src/prompts/add-feature.ts
|
|
9182
|
-
import { z as
|
|
10633
|
+
import { z as z11 } from "zod";
|
|
9183
10634
|
var ADD_FEATURE_NAME = "add-feature";
|
|
9184
10635
|
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.";
|
|
9185
10636
|
var ADD_FEATURE_SCHEMA = {
|
|
9186
|
-
feature:
|
|
10637
|
+
feature: z11.enum([
|
|
9187
10638
|
"products",
|
|
9188
10639
|
"cart",
|
|
9189
10640
|
"checkout",
|
|
@@ -9196,8 +10647,8 @@ var ADD_FEATURE_SCHEMA = {
|
|
|
9196
10647
|
"search",
|
|
9197
10648
|
"tax"
|
|
9198
10649
|
]).describe("The feature to add"),
|
|
9199
|
-
connectionId:
|
|
9200
|
-
currency:
|
|
10650
|
+
connectionId: z11.string().optional().describe("Vibe-coded connection ID (starts with vc_)"),
|
|
10651
|
+
currency: z11.string().optional().default("USD").describe("Store currency code")
|
|
9201
10652
|
};
|
|
9202
10653
|
var FEATURE_TO_TOPICS = {
|
|
9203
10654
|
products: { topics: ["products"], domains: ["products"] },
|
|
@@ -9266,7 +10717,7 @@ ${types}
|
|
|
9266
10717
|
function createServer() {
|
|
9267
10718
|
const server = new McpServer({
|
|
9268
10719
|
name: "brainerce",
|
|
9269
|
-
version: "
|
|
10720
|
+
version: "2.0.0"
|
|
9270
10721
|
});
|
|
9271
10722
|
server.tool(GET_SDK_DOCS_NAME, GET_SDK_DOCS_DESCRIPTION, GET_SDK_DOCS_SCHEMA, handleGetSdkDocs);
|
|
9272
10723
|
server.tool(
|
|
@@ -9293,6 +10744,25 @@ function createServer() {
|
|
|
9293
10744
|
GET_STORE_INFO_SCHEMA,
|
|
9294
10745
|
handleGetStoreInfo
|
|
9295
10746
|
);
|
|
10747
|
+
server.tool(
|
|
10748
|
+
GET_STORE_CAPABILITIES_NAME,
|
|
10749
|
+
GET_STORE_CAPABILITIES_DESCRIPTION,
|
|
10750
|
+
GET_STORE_CAPABILITIES_SCHEMA,
|
|
10751
|
+
handleGetStoreCapabilities
|
|
10752
|
+
);
|
|
10753
|
+
server.tool(BUILD_STORE_NAME, BUILD_STORE_DESCRIPTION, BUILD_STORE_SCHEMA, handleBuildStore);
|
|
10754
|
+
server.tool(
|
|
10755
|
+
VALIDATE_STORE_NAME,
|
|
10756
|
+
VALIDATE_STORE_DESCRIPTION,
|
|
10757
|
+
VALIDATE_STORE_SCHEMA,
|
|
10758
|
+
handleValidateStore
|
|
10759
|
+
);
|
|
10760
|
+
server.tool(
|
|
10761
|
+
GET_PAGE_CODE_NAME,
|
|
10762
|
+
GET_PAGE_CODE_DESCRIPTION,
|
|
10763
|
+
GET_PAGE_CODE_SCHEMA,
|
|
10764
|
+
handleGetPageCode
|
|
10765
|
+
);
|
|
9296
10766
|
server.resource(
|
|
9297
10767
|
SDK_TYPES_NAME,
|
|
9298
10768
|
SDK_TYPES_URI,
|
|
@@ -9318,7 +10788,9 @@ export {
|
|
|
9318
10788
|
AVAILABLE_DOMAINS,
|
|
9319
10789
|
PROJECT_TEMPLATE,
|
|
9320
10790
|
buildFullStorePrompt,
|
|
10791
|
+
buildStoreBundle,
|
|
9321
10792
|
createServer,
|
|
10793
|
+
fetchStoreCapabilities,
|
|
9322
10794
|
fetchStoreInfo,
|
|
9323
10795
|
getAllTypes,
|
|
9324
10796
|
getSectionByTopic,
|