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