@brainerce/mcp-server 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin/stdio.js CHANGED
@@ -730,27 +730,103 @@ const nudges: CartNudge[] = await client.getCartNudges(cartId);
730
730
  Display: banners in header, badges on product cards (strikethrough + discounted price), nudges below cart totals, applied discounts as line items in order summary.`;
731
731
  }
732
732
  function getRecommendationsSection() {
733
- return `## Product Recommendations (Cross-Sells & Upsells)
733
+ return `## Product Recommendations & Upsell Features
734
734
 
735
- Recommendations come **embedded in the product response** \u2014 no extra API call needed for product pages.
735
+ ### Relation Types
736
+ - **Cross-sell**: Complementary products ("Bought a phone? Add a case") \u2014 shown on product page + cart
737
+ - **Upsell**: Premium alternatives ("Upgrade to Pro for $20 more") \u2014 shown on product page + cart upgrade banner
738
+ - **Related**: Similar products ("Other shirts in this collection") \u2014 shown on product page
739
+
740
+ ### Product Page Recommendations
741
+ Recommendations come **embedded in the product response** \u2014 no extra API call needed.
736
742
 
737
743
  \`\`\`typescript
738
- import type { ProductRecommendation, ProductRecommendationsResponse, CartRecommendationsResponse } from 'brainerce';
744
+ import type { ProductRecommendation, ProductRecommendationsResponse } from 'brainerce';
739
745
 
740
- // Product page \u2014 recommendations are embedded in the product response
741
746
  const product = await client.getProductBySlug('some-slug');
742
747
  const recs = (product as any).recommendations as ProductRecommendationsResponse | undefined;
743
- // recs?.upsells \u2014 premium alternatives (show on product page)
744
- // recs?.related \u2014 similar products (show at bottom of product page)
745
- // recs?.crossSells \u2014 complementary products (typically used on cart page)
746
748
 
747
- // Cart page \u2014 cross-sell suggestions for cart items (separate call)
748
- const cartRecs: CartRecommendationsResponse = await client.getCartRecommendations(cartId, 4);
749
+ // recs?.crossSells \u2014 complementary products \u2192 "Frequently Bought Together" section
750
+ // recs?.upsells \u2014 premium alternatives \u2192 "Upgrade Your Choice" section
751
+ // recs?.related \u2014 similar products \u2192 "Similar Products" section
752
+ \`\`\`
753
+
754
+ ### Cart Page Features
755
+
756
+ **Cross-sell recommendations** (existing products the customer might also want):
757
+ \`\`\`typescript
758
+ import type { CartRecommendationsResponse } from 'brainerce';
759
+ const cartRecs = await client.getCartRecommendations(cartId, 4);
749
760
  // cartRecs.recommendations \u2014 deduplicated cross-sells (excludes items already in cart)
750
761
  \`\`\`
751
762
 
752
- ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`.
753
- Display upsells + related on product pages, cross-sells on cart page.`;
763
+ **Cart upgrade banner** (upsell relations with small price delta \u2014 "Upgrade to X for +$Y"):
764
+ \`\`\`typescript
765
+ import type { CartUpgradesResponse } from 'brainerce';
766
+ const { upgrades } = await client.getCartUpgrades(cartId);
767
+ // upgrades is a Record<sourceProductId, { targetProduct, priceDelta, deltaPercent }>
768
+ // Show inline banner per cart item if upgrade exists
769
+ \`\`\`
770
+
771
+ **Bundle offers** (discounted complementary products configured by the store owner):
772
+ \`\`\`typescript
773
+ import type { CartBundlesResponse } from 'brainerce';
774
+ const { bundles } = await client.getCartBundles(cartId);
775
+ // bundles[].bundleProduct \u2014 the product to offer
776
+ // bundles[].originalPrice / bundles[].discountedPrice \u2014 prices
777
+ // bundles[].discountType ('PERCENTAGE' | 'FIXED_AMOUNT') + discountValue
778
+ \`\`\`
779
+
780
+ **Free shipping progress bar** (configured threshold in channel settings):
781
+ \`\`\`typescript
782
+ const { storeInfo } = useStoreInfo(); // from store provider
783
+ const threshold = storeInfo?.upsell?.freeShippingThreshold;
784
+ const subtotal = parseFloat(cart.subtotal);
785
+ const remaining = threshold ? threshold - subtotal : 0;
786
+ const qualified = threshold ? subtotal >= threshold : false;
787
+ // Show progress bar: remaining > 0 ? "You're $X away from free shipping!" : "You've got free shipping!"
788
+ \`\`\`
789
+
790
+ ### Checkout Features
791
+
792
+ **Order bumps** (one-click add-ons at checkout):
793
+ \`\`\`typescript
794
+ import type { CheckoutBumpsResponse } from 'brainerce';
795
+ const { bumps } = await client.getCheckoutBumps(checkoutId);
796
+ // bumps[].bumpProduct \u2014 product to offer
797
+ // bumps[].originalPrice / discountedPrice \u2014 with optional discount
798
+ // bumps[].title / description \u2014 display text
799
+
800
+ // Add a bump to cart:
801
+ await client.addOrderBump(cartId, bumpId);
802
+ // Remove a bump:
803
+ await client.removeOrderBump(cartId, bumpId);
804
+ // Detect already-added bumps: check cart.items for metadata?.isOrderBump === true
805
+ \`\`\`
806
+
807
+ ### Feature Flags (per-channel)
808
+ All upsell features are controlled by \`storeInfo.upsell.*\`:
809
+ - \`freeShippingBarEnabled\` \u2014 free shipping progress bar in cart
810
+ - \`frequentlyBoughtTogetherEnabled\` \u2014 cross-sell section on product page
811
+ - \`cartUpgradeBannerEnabled\` \u2014 upgrade banner per cart item
812
+ - \`cartBundleEnabled\` \u2014 bundle offers in cart
813
+ - \`checkoutOrderBumpEnabled\` \u2014 order bumps at checkout
814
+ - \`freeShippingThreshold\` \u2014 threshold amount for free shipping bar
815
+
816
+ Always check the flag before rendering: \`if (storeInfo?.upsell?.featureName !== false)\`
817
+
818
+ ### Components Reference
819
+ | Component | Location | Purpose |
820
+ |-----------|----------|---------|
821
+ | FrequentlyBoughtTogether | products/ | Cross-sells with "Add all to cart" on product page |
822
+ | RecommendationSection | products/ | Grid of upsells or related products on product page |
823
+ | CartRecommendationSection | products/ | Cross-sell grid at bottom of cart page |
824
+ | FreeShippingBar | cart/ | Progress bar toward free shipping threshold |
825
+ | CartUpgradeBanner | cart/ | Inline "Upgrade to X for +$Y" per cart item |
826
+ | CartBundleOfferCard | cart/ | Discounted bundle offer card in cart |
827
+ | OrderBumpCard | checkout/ | Checkbox add-on card in checkout sidebar |
828
+
829
+ ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`.`;
754
830
  }
755
831
  function getInventorySection() {
756
832
  return `## Inventory, Stock Display & Reservation Countdown
@@ -2923,13 +2999,17 @@ export default async function ProductDetailPage({ params }: Props) {
2923
2999
 
2924
3000
  import { useEffect, useState } from 'react';
2925
3001
  import Link from 'next/link';
2926
- import type { CartRecommendationsResponse } from 'brainerce';
3002
+ import type { CartRecommendationsResponse, CartUpgradesResponse, CartBundlesResponse } from 'brainerce';
2927
3003
  import { getClient } from '@/lib/brainerce';
2928
3004
  import { useCart } from '@/providers/store-provider';
3005
+ import { useStoreInfo } from '@/providers/store-provider';
2929
3006
  import { CartItem } from '@/components/cart/cart-item';
3007
+ import { CartUpgradeBanner } from '@/components/cart/cart-upgrade-banner';
3008
+ import { CartBundleOfferCard } from '@/components/cart/cart-bundle-offer';
2930
3009
  import { CartSummary } from '@/components/cart/cart-summary';
2931
3010
  import { CouponInput } from '@/components/cart/coupon-input';
2932
3011
  import { CartNudges } from '@/components/cart/cart-nudges';
3012
+ import { FreeShippingBar } from '@/components/cart/free-shipping-bar';
2933
3013
  import { ReservationCountdown } from '@/components/cart/reservation-countdown';
2934
3014
  import { CartRecommendationSection } from '@/components/products/recommendation-section';
2935
3015
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
@@ -2937,9 +3017,12 @@ import { useTranslations } from '@/lib/translations';
2937
3017
 
2938
3018
  export default function CartPage() {
2939
3019
  const { cart, cartLoading, refreshCart, itemCount } = useCart();
3020
+ const { storeInfo } = useStoreInfo();
2940
3021
  const t = useTranslations('cart');
2941
3022
  const tc = useTranslations('common');
2942
3023
  const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
3024
+ const [upgrades, setUpgrades] = useState<CartUpgradesResponse | null>(null);
3025
+ const [bundles, setBundles] = useState<CartBundlesResponse | null>(null);
2943
3026
 
2944
3027
  // Load cross-sell recommendations when cart changes
2945
3028
  useEffect(() => {
@@ -2954,6 +3037,32 @@ export default function CartPage() {
2954
3037
  .catch(() => {});
2955
3038
  }, [cart?.id, cart?.items.length]);
2956
3039
 
3040
+ // Load upgrade suggestions when cart changes
3041
+ useEffect(() => {
3042
+ if (!cart?.id || cart.items.length === 0 || storeInfo?.upsell?.cartUpgradeBannerEnabled === false) {
3043
+ setUpgrades(null);
3044
+ return;
3045
+ }
3046
+ const client = getClient();
3047
+ client
3048
+ .getCartUpgrades(cart.id)
3049
+ .then(setUpgrades)
3050
+ .catch(() => {});
3051
+ }, [cart?.id, cart?.items.length, storeInfo?.upsell?.cartUpgradeBannerEnabled]);
3052
+
3053
+ // Load bundle offers when cart changes
3054
+ useEffect(() => {
3055
+ if (!cart?.id || cart.items.length === 0 || storeInfo?.upsell?.cartBundleEnabled === false) {
3056
+ setBundles(null);
3057
+ return;
3058
+ }
3059
+ const client = getClient();
3060
+ client
3061
+ .getCartBundles(cart.id)
3062
+ .then(setBundles)
3063
+ .catch(() => {});
3064
+ }, [cart?.id, cart?.items.length, storeInfo?.upsell?.cartBundleEnabled]);
3065
+
2957
3066
  if (cartLoading) {
2958
3067
  return (
2959
3068
  <div className="flex min-h-[60vh] items-center justify-center">
@@ -3013,10 +3122,30 @@ export default function CartPage() {
3013
3122
  {/* Cart items */}
3014
3123
  <div>
3015
3124
  {cart.items.map((item) => (
3016
- <CartItem key={item.id} item={item} onUpdate={refreshCart} />
3125
+ <div key={item.id}>
3126
+ <CartItem item={item} onUpdate={refreshCart} />
3127
+ {upgrades?.upgrades?.[item.productId] && (
3128
+ <CartUpgradeBanner
3129
+ suggestion={upgrades.upgrades[item.productId]}
3130
+ cartItem={item}
3131
+ onUpgrade={refreshCart}
3132
+ className="mb-2 ms-24"
3133
+ />
3134
+ )}
3135
+ </div>
3017
3136
  ))}
3018
3137
  </div>
3019
3138
 
3139
+ {/* Bundle offers */}
3140
+ {bundles?.bundles && bundles.bundles.length > 0 && (
3141
+ <div className="mt-6 space-y-3">
3142
+ <h3 className="text-foreground text-sm font-semibold">{t('bundleOffers')}</h3>
3143
+ {bundles.bundles.map((offer) => (
3144
+ <CartBundleOfferCard key={offer.id} offer={offer} onAdd={refreshCart} />
3145
+ ))}
3146
+ </div>
3147
+ )}
3148
+
3020
3149
  {/* Coupon input */}
3021
3150
  <div className="border-border mt-6 border-t pt-4">
3022
3151
  <CouponInput cart={cart} onUpdate={refreshCart} />
@@ -3026,6 +3155,7 @@ export default function CartPage() {
3026
3155
  {/* Summary sidebar */}
3027
3156
  <div className="lg:col-span-1">
3028
3157
  <div className="bg-muted/50 border-border sticky top-24 rounded-lg border p-6">
3158
+ <FreeShippingBar className="mb-4" />
3029
3159
  <CartSummary />
3030
3160
 
3031
3161
  <Link
@@ -3069,6 +3199,7 @@ import type {
3069
3199
  SetShippingAddressDto,
3070
3200
  ShippingDestinations,
3071
3201
  PickupLocation,
3202
+ CheckoutBumpsResponse,
3072
3203
  } from 'brainerce';
3073
3204
  import { formatPrice } from 'brainerce';
3074
3205
  import { getClient } from '@/lib/brainerce';
@@ -3079,6 +3210,7 @@ import { PaymentStep } from '@/components/checkout/payment-step';
3079
3210
  import { DeliveryMethodStep } from '@/components/checkout/delivery-method-step';
3080
3211
  import { PickupStep } from '@/components/checkout/pickup-step';
3081
3212
  import { TaxDisplay } from '@/components/checkout/tax-display';
3213
+ import { OrderBumpCard } from '@/components/checkout/order-bump-card';
3082
3214
  import { CouponInput } from '@/components/cart/coupon-input';
3083
3215
  import { ReservationCountdown } from '@/components/cart/reservation-countdown';
3084
3216
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
@@ -3115,6 +3247,9 @@ function CheckoutContent() {
3115
3247
  phone?: string;
3116
3248
  } | null>(null);
3117
3249
  const [hasSavedAddress, setHasSavedAddress] = useState(false);
3250
+ const [orderBumps, setOrderBumps] = useState<CheckoutBumpsResponse | null>(null);
3251
+ const [addedBumpIds, setAddedBumpIds] = useState<Set<string>>(new Set());
3252
+ const [bumpLoading, setBumpLoading] = useState<string | null>(null);
3118
3253
 
3119
3254
  // Check for returning from canceled payment
3120
3255
  const canceled = searchParams.get('canceled') === 'true';
@@ -3217,9 +3352,59 @@ function CheckoutContent() {
3217
3352
  };
3218
3353
 
3219
3354
  initCheckout();
3220
- // eslint-disable-next-line react-hooks/exhaustive-deps
3221
3355
  }, [cart?.id, existingCheckoutId]);
3222
3356
 
3357
+ // Load order bumps when checkout is available
3358
+ useEffect(() => {
3359
+ if (!checkout?.id || storeInfo?.upsell?.checkoutOrderBumpEnabled === false) {
3360
+ setOrderBumps(null);
3361
+ return;
3362
+ }
3363
+ const client = getClient();
3364
+ client
3365
+ .getCheckoutBumps(checkout.id)
3366
+ .then((data) => {
3367
+ setOrderBumps(data);
3368
+ // Detect already-added bumps from cart
3369
+ if (cart?.items) {
3370
+ const existingBumpIds = new Set<string>();
3371
+ for (const item of cart.items) {
3372
+ const meta = item.metadata as Record<string, unknown> | undefined;
3373
+ if (meta?.isOrderBump && meta?.orderBumpId) {
3374
+ existingBumpIds.add(meta.orderBumpId as string);
3375
+ }
3376
+ }
3377
+ setAddedBumpIds(existingBumpIds);
3378
+ }
3379
+ })
3380
+ .catch(() => {});
3381
+ }, [checkout?.id, storeInfo?.upsell?.checkoutOrderBumpEnabled]);
3382
+
3383
+ // Handle bump toggle
3384
+ async function handleBumpToggle(bumpId: string, add: boolean) {
3385
+ if (!cart?.id || bumpLoading) return;
3386
+ try {
3387
+ setBumpLoading(bumpId);
3388
+ const client = getClient();
3389
+ if (add) {
3390
+ await client.addOrderBump(cart.id, bumpId);
3391
+ setAddedBumpIds((prev) => new Set([...prev, bumpId]));
3392
+ } else {
3393
+ await client.removeOrderBump(cart.id, bumpId);
3394
+ setAddedBumpIds((prev) => {
3395
+ const next = new Set(prev);
3396
+ next.delete(bumpId);
3397
+ return next;
3398
+ });
3399
+ }
3400
+ await refreshCart();
3401
+ } catch (err) {
3402
+ console.error('Failed to toggle order bump:', err);
3403
+ } finally {
3404
+ setBumpLoading(null);
3405
+ }
3406
+ }
3407
+
3223
3408
  // Handle shipping address submission
3224
3409
  async function handleAddressSubmit(
3225
3410
  address: SetShippingAddressDto,
@@ -3722,6 +3907,24 @@ function CheckoutContent() {
3722
3907
  )
3723
3908
  )}
3724
3909
 
3910
+ {/* Order bumps */}
3911
+ {orderBumps?.bumps && orderBumps.bumps.length > 0 && (
3912
+ <div className="border-border space-y-2 border-t pt-4">
3913
+ <p className="text-foreground text-xs font-semibold uppercase tracking-wide">
3914
+ {t('addToYourOrder')}
3915
+ </p>
3916
+ {orderBumps.bumps.map((bump) => (
3917
+ <OrderBumpCard
3918
+ key={bump.id}
3919
+ bump={bump}
3920
+ isAdded={addedBumpIds.has(bump.id)}
3921
+ onToggle={handleBumpToggle}
3922
+ loading={bumpLoading === bump.id}
3923
+ />
3924
+ ))}
3925
+ </div>
3926
+ )}
3927
+
3725
3928
  {/* Coupon input \u2014 show from shipping/pickup step onwards (or immediately if digital) */}
3726
3929
  {cart &&
3727
3930
  (isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
@@ -9193,6 +9396,680 @@ export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps)
9193
9396
  </div>
9194
9397
  );
9195
9398
  }
9399
+ `,
9400
+ "src/components/products/recommendation-section.tsx": `'use client';
9401
+
9402
+ import { useEffect, useState } from 'react';
9403
+ import Link from 'next/link';
9404
+ import Image from 'next/image';
9405
+ import type { ProductRecommendation } from 'brainerce';
9406
+ import { PriceDisplay } from '@/components/shared/price-display';
9407
+ import { cn } from '@/lib/utils';
9408
+
9409
+ interface RecommendationCardProps {
9410
+ item: ProductRecommendation;
9411
+ className?: string;
9412
+ }
9413
+
9414
+ function RecommendationCard({ item, className }: RecommendationCardProps) {
9415
+ const firstImage = item.images?.[0];
9416
+ const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
9417
+ const slug = item.slug || item.id;
9418
+ const basePrice = parseFloat(item.basePrice);
9419
+ const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
9420
+ const isOnSale = salePrice != null && salePrice < basePrice;
9421
+
9422
+ return (
9423
+ <Link
9424
+ href={\`/products/\${slug}\`}
9425
+ className={cn(
9426
+ 'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
9427
+ className
9428
+ )}
9429
+ >
9430
+ <div className="bg-muted relative aspect-square overflow-hidden">
9431
+ {imageUrl ? (
9432
+ <Image
9433
+ src={imageUrl}
9434
+ alt={item.name}
9435
+ fill
9436
+ sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
9437
+ className="object-cover transition-transform duration-300 group-hover:scale-105"
9438
+ />
9439
+ ) : (
9440
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
9441
+ <svg className="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
9442
+ <path
9443
+ strokeLinecap="round"
9444
+ strokeLinejoin="round"
9445
+ strokeWidth={1.5}
9446
+ 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"
9447
+ />
9448
+ </svg>
9449
+ </div>
9450
+ )}
9451
+ </div>
9452
+ <div className="space-y-1.5 p-3">
9453
+ <h3 className="text-foreground group-hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
9454
+ {item.name}
9455
+ </h3>
9456
+ <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
9457
+ </div>
9458
+ </Link>
9459
+ );
9460
+ }
9461
+
9462
+ interface RecommendationSectionProps {
9463
+ title: string;
9464
+ items: ProductRecommendation[];
9465
+ className?: string;
9466
+ }
9467
+
9468
+ export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
9469
+ if (items.length === 0) return null;
9470
+
9471
+ return (
9472
+ <div className={cn('', className)}>
9473
+ <h2 className="text-foreground mb-4 text-xl font-semibold">{title}</h2>
9474
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
9475
+ {items.map((item) => (
9476
+ <RecommendationCard key={item.id} item={item} />
9477
+ ))}
9478
+ </div>
9479
+ </div>
9480
+ );
9481
+ }
9482
+
9483
+ interface CartRecommendationSectionProps {
9484
+ title: string;
9485
+ items: ProductRecommendation[];
9486
+ className?: string;
9487
+ }
9488
+
9489
+ export function CartRecommendationSection({
9490
+ title,
9491
+ items,
9492
+ className,
9493
+ }: CartRecommendationSectionProps) {
9494
+ if (items.length === 0) return null;
9495
+
9496
+ return (
9497
+ <div className={cn('', className)}>
9498
+ <h2 className="text-foreground mb-4 text-lg font-semibold">{title}</h2>
9499
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
9500
+ {items.map((item) => (
9501
+ <RecommendationCard key={item.id} item={item} />
9502
+ ))}
9503
+ </div>
9504
+ </div>
9505
+ );
9506
+ }
9507
+ `,
9508
+ "src/components/products/frequently-bought-together.tsx": `'use client';
9509
+
9510
+ import { useState } from 'react';
9511
+ import Image from 'next/image';
9512
+ import type { Product, ProductRecommendation } from 'brainerce';
9513
+ import { formatPrice } from 'brainerce';
9514
+ import { useCart } from '@/providers/store-provider';
9515
+ import { useStoreInfo } from '@/providers/store-provider';
9516
+ import { useTranslations } from '@/lib/translations';
9517
+ import { cn } from '@/lib/utils';
9518
+
9519
+ interface FrequentlyBoughtTogetherProps {
9520
+ items: ProductRecommendation[];
9521
+ currentProduct: Product;
9522
+ className?: string;
9523
+ }
9524
+
9525
+ function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
9526
+ const sale = item.salePrice ? parseFloat(item.salePrice) : null;
9527
+ const base = parseFloat(item.basePrice);
9528
+ return sale != null && sale < base ? sale : base;
9529
+ }
9530
+
9531
+ function ProductThumb({
9532
+ name,
9533
+ imageUrl,
9534
+ price,
9535
+ currency,
9536
+ checked,
9537
+ onToggle,
9538
+ disabled,
9539
+ }: {
9540
+ name: string;
9541
+ imageUrl: string | null;
9542
+ price: number;
9543
+ currency: string;
9544
+ checked: boolean;
9545
+ onToggle?: () => void;
9546
+ disabled?: boolean;
9547
+ }) {
9548
+ return (
9549
+ <label
9550
+ className={cn(
9551
+ 'border-border bg-background relative flex cursor-pointer flex-col items-center rounded-lg border p-3 transition-all',
9552
+ checked ? 'ring-primary ring-2' : 'opacity-60',
9553
+ disabled && 'pointer-events-none'
9554
+ )}
9555
+ >
9556
+ {onToggle && (
9557
+ <input
9558
+ type="checkbox"
9559
+ checked={checked}
9560
+ onChange={onToggle}
9561
+ className="absolute start-2 top-2 h-4 w-4 rounded"
9562
+ />
9563
+ )}
9564
+ <div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
9565
+ {imageUrl ? (
9566
+ <Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
9567
+ ) : (
9568
+ <div className="flex h-full w-full items-center justify-center">
9569
+ <svg className="text-muted-foreground h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
9570
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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" />
9571
+ </svg>
9572
+ </div>
9573
+ )}
9574
+ </div>
9575
+ <span className="text-foreground line-clamp-2 text-center text-xs font-medium">{name}</span>
9576
+ <span className="text-muted-foreground mt-1 text-xs">
9577
+ {formatPrice(price, { currency }) as string}
9578
+ </span>
9579
+ </label>
9580
+ );
9581
+ }
9582
+
9583
+ export function FrequentlyBoughtTogether({
9584
+ items,
9585
+ currentProduct,
9586
+ className,
9587
+ }: FrequentlyBoughtTogetherProps) {
9588
+ const { storeInfo } = useStoreInfo();
9589
+ const { refreshCart } = useCart();
9590
+ const t = useTranslations('productDetail');
9591
+
9592
+ // Only show up to 3 cross-sells
9593
+ const crossSells = items.slice(0, 3);
9594
+
9595
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
9596
+ const [adding, setAdding] = useState(false);
9597
+
9598
+ if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
9599
+ if (crossSells.length === 0) return null;
9600
+
9601
+ const currency = storeInfo.currency || 'USD';
9602
+
9603
+ const currentPrice = getEffectivePrice(currentProduct);
9604
+ const currentImage = currentProduct.images?.[0];
9605
+ const currentImageUrl = currentImage
9606
+ ? typeof currentImage === 'string'
9607
+ ? currentImage
9608
+ : currentImage.url
9609
+ : null;
9610
+
9611
+ const totalPrice = crossSells
9612
+ .filter((item) => selected.has(item.id))
9613
+ .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
9614
+
9615
+ const toggleItem = (id: string) => {
9616
+ setSelected((prev) => {
9617
+ const next = new Set(prev);
9618
+ if (next.has(id)) {
9619
+ next.delete(id);
9620
+ } else {
9621
+ next.add(id);
9622
+ }
9623
+ return next;
9624
+ });
9625
+ };
9626
+
9627
+ async function handleAddAll() {
9628
+ if (adding || selected.size === 0) return;
9629
+ try {
9630
+ setAdding(true);
9631
+ const { getClient } = await import('@/lib/brainerce');
9632
+ const client = getClient();
9633
+ const selectedItems = crossSells.filter((item) => selected.has(item.id));
9634
+ for (const item of selectedItems) {
9635
+ await client.smartAddToCart({ productId: item.id, quantity: 1 });
9636
+ }
9637
+ await refreshCart();
9638
+ } catch (err) {
9639
+ console.error('Failed to add items to cart:', err);
9640
+ } finally {
9641
+ setAdding(false);
9642
+ }
9643
+ }
9644
+
9645
+ return (
9646
+ <div className={cn('border-border rounded-lg border p-6', className)}>
9647
+ <h2 className="text-foreground mb-4 text-xl font-semibold">{t('frequentlyBoughtTogether')}</h2>
9648
+
9649
+ <div className="flex flex-wrap items-center gap-3">
9650
+ {/* Current product (always included, no checkbox) */}
9651
+ <ProductThumb
9652
+ name={currentProduct.name}
9653
+ imageUrl={currentImageUrl}
9654
+ price={currentPrice}
9655
+ currency={currency}
9656
+ checked={true}
9657
+ disabled
9658
+ />
9659
+
9660
+ {crossSells.map((item) => {
9661
+ const img = item.images?.[0];
9662
+ const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
9663
+ return (
9664
+ <div key={item.id} className="flex items-center gap-3">
9665
+ <span className="text-muted-foreground text-lg font-light">+</span>
9666
+ <ProductThumb
9667
+ name={item.name}
9668
+ imageUrl={imgUrl}
9669
+ price={getEffectivePrice(item)}
9670
+ currency={currency}
9671
+ checked={selected.has(item.id)}
9672
+ onToggle={() => toggleItem(item.id)}
9673
+ />
9674
+ </div>
9675
+ );
9676
+ })}
9677
+ </div>
9678
+
9679
+ {/* Total + Add button */}
9680
+ <div className="mt-4 flex flex-wrap items-center gap-4">
9681
+ <span className="text-foreground text-lg font-semibold">
9682
+ {t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
9683
+ </span>
9684
+ <button
9685
+ onClick={handleAddAll}
9686
+ disabled={adding || selected.size === 0}
9687
+ className={cn(
9688
+ 'bg-primary text-primary-foreground rounded px-5 py-2.5 text-sm font-medium transition-opacity hover:opacity-90',
9689
+ 'disabled:cursor-not-allowed disabled:opacity-50'
9690
+ )}
9691
+ >
9692
+ {adding ? t('addingAll') : t('addSelectedToCart')}
9693
+ </button>
9694
+ </div>
9695
+ </div>
9696
+ );
9697
+ }
9698
+ `,
9699
+ "src/components/cart/free-shipping-bar.tsx": `'use client';
9700
+
9701
+ import { formatPrice } from 'brainerce';
9702
+ import { useStoreInfo, useCart } from '@/providers/store-provider';
9703
+ import { useTranslations } from '@/lib/translations';
9704
+ import { cn } from '@/lib/utils';
9705
+
9706
+ interface FreeShippingBarProps {
9707
+ className?: string;
9708
+ }
9709
+
9710
+ export function FreeShippingBar({ className }: FreeShippingBarProps) {
9711
+ const t = useTranslations('cart');
9712
+ const { storeInfo } = useStoreInfo();
9713
+ const { totals } = useCart();
9714
+
9715
+ const upsell = storeInfo?.upsell;
9716
+ const threshold = upsell?.freeShippingThreshold;
9717
+ const enabled = upsell?.freeShippingBarEnabled !== false;
9718
+
9719
+ // Don't render if disabled or no threshold configured
9720
+ if (!enabled || !threshold || threshold <= 0) return null;
9721
+
9722
+ const subtotal = totals.subtotal;
9723
+ const remaining = Math.max(0, threshold - subtotal);
9724
+ const progress = Math.min(100, (subtotal / threshold) * 100);
9725
+ const qualified = remaining <= 0;
9726
+ const currency = storeInfo?.currency || 'USD';
9727
+
9728
+ // Don't show if already qualified
9729
+ if (qualified) {
9730
+ return (
9731
+ <div className={cn('rounded-lg border border-green-200 bg-green-50 p-3', className)}>
9732
+ <div className="flex items-center gap-2 text-sm font-medium text-green-700">
9733
+ <svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
9734
+ <path
9735
+ strokeLinecap="round"
9736
+ strokeLinejoin="round"
9737
+ strokeWidth={2}
9738
+ d="M5 13l4 4L19 7"
9739
+ />
9740
+ </svg>
9741
+ {t('freeShippingQualified')}
9742
+ </div>
9743
+ </div>
9744
+ );
9745
+ }
9746
+
9747
+ return (
9748
+ <div className={cn('rounded-lg border border-amber-200 bg-amber-50 p-3', className)}>
9749
+ <p className="mb-2 text-sm text-amber-800">
9750
+ {t('freeShippingRemaining', {
9751
+ amount: formatPrice(remaining, { currency }) as string,
9752
+ })}
9753
+ </p>
9754
+ <div className="h-2 w-full overflow-hidden rounded-full bg-amber-200">
9755
+ <div
9756
+ className="h-full rounded-full bg-amber-500 transition-all duration-500 ease-out"
9757
+ style={{ width: \`\${progress}%\` }}
9758
+ />
9759
+ </div>
9760
+ </div>
9761
+ );
9762
+ }
9763
+ `,
9764
+ "src/components/cart/cart-upgrade-banner.tsx": `'use client';
9765
+
9766
+ import { useState, useEffect } from 'react';
9767
+ import Image from 'next/image';
9768
+ import type { CartUpgradeSuggestion, CartItem as CartItemType } from 'brainerce';
9769
+ import { formatPrice } from 'brainerce';
9770
+ import { getClient } from '@/lib/brainerce';
9771
+ import { useStoreInfo } from '@/providers/store-provider';
9772
+ import { useTranslations } from '@/lib/translations';
9773
+ import { cn } from '@/lib/utils';
9774
+
9775
+ interface CartUpgradeBannerProps {
9776
+ suggestion: CartUpgradeSuggestion;
9777
+ cartItem: CartItemType;
9778
+ onUpgrade: () => void;
9779
+ className?: string;
9780
+ }
9781
+
9782
+ export function CartUpgradeBanner({
9783
+ suggestion,
9784
+ cartItem,
9785
+ onUpgrade,
9786
+ className,
9787
+ }: CartUpgradeBannerProps) {
9788
+ const { storeInfo } = useStoreInfo();
9789
+ const t = useTranslations('cart');
9790
+ const currency = storeInfo?.currency || 'USD';
9791
+ const [upgrading, setUpgrading] = useState(false);
9792
+ const [dismissed, setDismissed] = useState(false);
9793
+
9794
+ const storageKey = \`dismissed_upgrade_\${suggestion.sourceProductId}\`;
9795
+
9796
+ useEffect(() => {
9797
+ try {
9798
+ if (sessionStorage.getItem(storageKey)) {
9799
+ setDismissed(true);
9800
+ }
9801
+ } catch {}
9802
+ }, [storageKey]);
9803
+
9804
+ if (dismissed) return null;
9805
+
9806
+ const target = suggestion.targetProduct;
9807
+ const firstImage = target.images?.[0];
9808
+ const imageUrl = firstImage ? (typeof firstImage === 'string' ? firstImage : firstImage.url) : null;
9809
+ const formattedDelta = formatPrice(parseFloat(suggestion.priceDelta), { currency }) as string;
9810
+
9811
+ function handleDismiss() {
9812
+ try {
9813
+ sessionStorage.setItem(storageKey, '1');
9814
+ } catch {}
9815
+ setDismissed(true);
9816
+ }
9817
+
9818
+ async function handleUpgrade() {
9819
+ if (upgrading) return;
9820
+ try {
9821
+ setUpgrading(true);
9822
+ const client = getClient();
9823
+ await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
9824
+ await client.smartAddToCart({ productId: target.id, quantity: cartItem.quantity });
9825
+ onUpgrade();
9826
+ } catch (err) {
9827
+ console.error('Failed to upgrade cart item:', err);
9828
+ } finally {
9829
+ setUpgrading(false);
9830
+ }
9831
+ }
9832
+
9833
+ return (
9834
+ <div
9835
+ className={cn(
9836
+ 'bg-primary/5 border-primary/20 relative flex items-center gap-3 rounded-lg border px-4 py-3',
9837
+ className
9838
+ )}
9839
+ >
9840
+ {/* Dismiss button */}
9841
+ <button
9842
+ type="button"
9843
+ onClick={handleDismiss}
9844
+ className="text-muted-foreground hover:text-foreground absolute end-2 top-2 text-xs"
9845
+ aria-label={t('dismissUpgrade')}
9846
+ >
9847
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
9848
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
9849
+ </svg>
9850
+ </button>
9851
+
9852
+ {/* Product image */}
9853
+ <div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
9854
+ {imageUrl ? (
9855
+ <Image src={imageUrl} alt={target.name} fill sizes="48px" className="object-cover" />
9856
+ ) : (
9857
+ <div className="text-muted-foreground flex h-full w-full items-center justify-center">
9858
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
9859
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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" />
9860
+ </svg>
9861
+ </div>
9862
+ )}
9863
+ </div>
9864
+
9865
+ {/* Text */}
9866
+ <div className="min-w-0 flex-1">
9867
+ <p className="text-foreground text-sm font-medium">
9868
+ {t('upgradeFor', { name: target.name, amount: formattedDelta })}
9869
+ </p>
9870
+ </div>
9871
+
9872
+ {/* Upgrade button */}
9873
+ <button
9874
+ type="button"
9875
+ onClick={handleUpgrade}
9876
+ disabled={upgrading}
9877
+ className={cn(
9878
+ 'bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-opacity hover:opacity-90',
9879
+ 'disabled:cursor-not-allowed disabled:opacity-50'
9880
+ )}
9881
+ >
9882
+ {upgrading ? t('upgrading') : t('upgrade')}
9883
+ </button>
9884
+ </div>
9885
+ );
9886
+ }
9887
+ `,
9888
+ "src/components/cart/cart-bundle-offer.tsx": `'use client';
9889
+
9890
+ import { useState } from 'react';
9891
+ import Image from 'next/image';
9892
+ import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
9893
+ import { formatPrice } from 'brainerce';
9894
+ import { getClient } from '@/lib/brainerce';
9895
+ import { useStoreInfo } from '@/providers/store-provider';
9896
+ import { useTranslations } from '@/lib/translations';
9897
+ import { cn } from '@/lib/utils';
9898
+
9899
+ interface CartBundleOfferCardProps {
9900
+ offer: CartBundleOfferType;
9901
+ onAdd: () => void;
9902
+ className?: string;
9903
+ }
9904
+
9905
+ export function CartBundleOfferCard({ offer, onAdd, className }: CartBundleOfferCardProps) {
9906
+ const { storeInfo } = useStoreInfo();
9907
+ const t = useTranslations('cart');
9908
+ const currency = storeInfo?.currency || 'USD';
9909
+ const [adding, setAdding] = useState(false);
9910
+
9911
+ const product = offer.bundleProduct;
9912
+ const firstImage = product.images?.[0];
9913
+ const imageUrl = firstImage ? (typeof firstImage === 'string' ? firstImage : firstImage.url) : null;
9914
+ const originalPrice = parseFloat(offer.originalPrice);
9915
+ const discountedPrice = parseFloat(offer.discountedPrice);
9916
+ const discountLabel =
9917
+ offer.discountType === 'PERCENTAGE'
9918
+ ? \`\${offer.discountValue}%\`
9919
+ : formatPrice(parseFloat(offer.discountValue), { currency }) as string;
9920
+
9921
+ async function handleAdd() {
9922
+ if (adding) return;
9923
+ try {
9924
+ setAdding(true);
9925
+ const client = getClient();
9926
+ await client.smartAddToCart({
9927
+ productId: product.id,
9928
+ variantId: offer.bundleVariantId || undefined,
9929
+ quantity: 1,
9930
+ });
9931
+ onAdd();
9932
+ } catch (err) {
9933
+ console.error('Failed to add bundle item:', err);
9934
+ } finally {
9935
+ setAdding(false);
9936
+ }
9937
+ }
9938
+
9939
+ return (
9940
+ <div
9941
+ className={cn(
9942
+ 'bg-background border-border flex items-center gap-4 rounded-lg border p-4',
9943
+ className
9944
+ )}
9945
+ >
9946
+ {/* Product image */}
9947
+ <div className="bg-muted relative h-16 w-16 flex-shrink-0 overflow-hidden rounded">
9948
+ {imageUrl ? (
9949
+ <Image src={imageUrl} alt={product.name} fill sizes="64px" className="object-cover" />
9950
+ ) : (
9951
+ <div className="text-muted-foreground flex h-full w-full items-center justify-center">
9952
+ <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
9953
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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" />
9954
+ </svg>
9955
+ </div>
9956
+ )}
9957
+ </div>
9958
+
9959
+ {/* Details */}
9960
+ <div className="min-w-0 flex-1">
9961
+ <p className="text-foreground text-sm font-medium">{offer.name}</p>
9962
+ {offer.description && (
9963
+ <p className="text-muted-foreground mt-0.5 text-xs">{offer.description}</p>
9964
+ )}
9965
+ <div className="mt-1 flex items-center gap-2">
9966
+ <span className="text-muted-foreground text-sm line-through">
9967
+ {formatPrice(originalPrice, { currency }) as string}
9968
+ </span>
9969
+ <span className="text-foreground text-sm font-semibold">
9970
+ {formatPrice(discountedPrice, { currency }) as string}
9971
+ </span>
9972
+ <span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-xs font-medium">
9973
+ -{discountLabel}
9974
+ </span>
9975
+ </div>
9976
+ </div>
9977
+
9978
+ {/* Add button */}
9979
+ <button
9980
+ type="button"
9981
+ onClick={handleAdd}
9982
+ disabled={adding}
9983
+ className={cn(
9984
+ 'bg-primary text-primary-foreground flex-shrink-0 rounded px-4 py-2 text-xs font-medium transition-opacity hover:opacity-90',
9985
+ 'disabled:cursor-not-allowed disabled:opacity-50'
9986
+ )}
9987
+ >
9988
+ {adding ? t('addingBundle') : t('addBundleItem')}
9989
+ </button>
9990
+ </div>
9991
+ );
9992
+ }
9993
+ `,
9994
+ "src/components/checkout/order-bump-card.tsx": `'use client';
9995
+
9996
+ import Image from 'next/image';
9997
+ import type { OrderBump } from 'brainerce';
9998
+ import { formatPrice } from 'brainerce';
9999
+ import { useStoreInfo } from '@/providers/store-provider';
10000
+ import { useTranslations } from '@/lib/translations';
10001
+ import { cn } from '@/lib/utils';
10002
+
10003
+ interface OrderBumpCardProps {
10004
+ bump: OrderBump;
10005
+ isAdded: boolean;
10006
+ onToggle: (bumpId: string, add: boolean) => void;
10007
+ loading: boolean;
10008
+ className?: string;
10009
+ }
10010
+
10011
+ export function OrderBumpCard({ bump, isAdded, onToggle, loading, className }: OrderBumpCardProps) {
10012
+ const { storeInfo } = useStoreInfo();
10013
+ const t = useTranslations('checkout');
10014
+ const currency = storeInfo?.currency || 'USD';
10015
+
10016
+ const product = bump.bumpProduct;
10017
+ const firstImage = product.images?.[0];
10018
+ const imageUrl = firstImage ? (typeof firstImage === 'string' ? firstImage : firstImage.url) : null;
10019
+ const originalPrice = parseFloat(bump.originalPrice);
10020
+ const hasDiscount = bump.discountedPrice != null;
10021
+ const discountedPrice = hasDiscount ? parseFloat(bump.discountedPrice!) : null;
10022
+
10023
+ return (
10024
+ <label
10025
+ className={cn(
10026
+ 'border-border hover:border-primary/50 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
10027
+ isAdded && 'border-primary bg-primary/5',
10028
+ loading && 'pointer-events-none opacity-60',
10029
+ className
10030
+ )}
10031
+ >
10032
+ <input
10033
+ type="checkbox"
10034
+ checked={isAdded}
10035
+ onChange={() => onToggle(bump.id, !isAdded)}
10036
+ disabled={loading}
10037
+ className="mt-1 h-4 w-4 shrink-0 rounded"
10038
+ />
10039
+
10040
+ {/* Image */}
10041
+ {imageUrl && (
10042
+ <div className="bg-muted relative h-10 w-10 shrink-0 overflow-hidden rounded">
10043
+ <Image src={imageUrl} alt={product.name} fill sizes="40px" className="object-cover" />
10044
+ </div>
10045
+ )}
10046
+
10047
+ {/* Content */}
10048
+ <div className="min-w-0 flex-1">
10049
+ <p className="text-foreground text-sm font-medium">{bump.title}</p>
10050
+ {bump.description && (
10051
+ <p className="text-muted-foreground mt-0.5 text-xs">{bump.description}</p>
10052
+ )}
10053
+ <div className="mt-1 flex items-center gap-2">
10054
+ {hasDiscount ? (
10055
+ <>
10056
+ <span className="text-muted-foreground text-xs line-through">
10057
+ {formatPrice(originalPrice, { currency }) as string}
10058
+ </span>
10059
+ <span className="text-foreground text-sm font-semibold">
10060
+ {formatPrice(discountedPrice!, { currency }) as string}
10061
+ </span>
10062
+ </>
10063
+ ) : (
10064
+ <span className="text-foreground text-sm font-semibold">
10065
+ {formatPrice(originalPrice, { currency }) as string}
10066
+ </span>
10067
+ )}
10068
+ </div>
10069
+ </div>
10070
+ </label>
10071
+ );
10072
+ }
9196
10073
  `,
9197
10074
  "src/hooks/use-search.ts": `'use client';
9198
10075