@brainerce/mcp-server 2.0.1 → 2.1.1

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