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