@brainerce/mcp-server 1.3.0 → 2.0.1

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