@brainerce/mcp-server 1.3.0 → 2.0.1

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