@brainerce/mcp-server 1.3.0 → 2.0.1

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