@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/index.mjs CHANGED
@@ -702,27 +702,109 @@ const nudges: CartNudge[] = await client.getCartNudges(cartId);
702
702
  Display: banners in header, badges on product cards (strikethrough + discounted price), nudges below cart totals, applied discounts as line items in order summary.`;
703
703
  }
704
704
  function getRecommendationsSection() {
705
- return `## Product Recommendations (Cross-Sells & Upsells)
705
+ return `## Product Recommendations & Upsell Features
706
706
 
707
- Recommendations come **embedded in the product response** \u2014 no extra API call needed for product pages.
707
+ ### Relation Types
708
+ - **Cross-sell**: Complementary products ("Bought a phone? Add a case") \u2014 shown on product page + cart
709
+ - **Upsell**: Premium alternatives ("Upgrade to Pro for $20 more") \u2014 shown on product page + cart upgrade banner
710
+ - **Related**: Similar products ("Other shirts in this collection") \u2014 shown on product page
711
+
712
+ ### Product Page Recommendations
713
+ Recommendations come **embedded in the product response** \u2014 no extra API call needed.
708
714
 
709
715
  \`\`\`typescript
710
- import type { ProductRecommendation, ProductRecommendationsResponse, CartRecommendationsResponse } from 'brainerce';
716
+ import type { ProductRecommendation, ProductRecommendationsResponse } from 'brainerce';
711
717
 
712
- // Product page \u2014 recommendations are embedded in the product response
713
718
  const product = await client.getProductBySlug('some-slug');
714
719
  const recs = (product as any).recommendations as ProductRecommendationsResponse | undefined;
715
- // recs?.upsells \u2014 premium alternatives (show on product page)
716
- // recs?.related \u2014 similar products (show at bottom of product page)
717
- // recs?.crossSells \u2014 complementary products (typically used on cart page)
718
720
 
719
- // Cart page \u2014 cross-sell suggestions for cart items (separate call)
720
- const cartRecs: CartRecommendationsResponse = await client.getCartRecommendations(cartId, 4);
721
+ // recs?.crossSells \u2014 complementary products \u2192 "Frequently Bought Together" section
722
+ // recs?.upsells \u2014 premium alternatives \u2192 "Upgrade Your Choice" section
723
+ // recs?.related \u2014 similar products \u2192 "Similar Products" section
724
+ \`\`\`
725
+
726
+ ### Cart Page Features
727
+
728
+ **Cross-sell recommendations** (existing products the customer might also want):
729
+ \`\`\`typescript
730
+ import type { CartRecommendationsResponse } from 'brainerce';
731
+ const cartRecs = await client.getCartRecommendations(cartId, 4);
721
732
  // cartRecs.recommendations \u2014 deduplicated cross-sells (excludes items already in cart)
722
733
  \`\`\`
723
734
 
724
- ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`.
725
- Display upsells + related on product pages, cross-sells on cart page.`;
735
+ **Cart upgrade banner** (upsell relations with small price delta \u2014 "Upgrade to X for +$Y"):
736
+ \`\`\`typescript
737
+ import type { CartUpgradesResponse } from 'brainerce';
738
+ const { upgrades } = await client.getCartUpgrades(cartId);
739
+ // upgrades is a Record<sourceProductId, { targetProduct, priceDelta, deltaPercent }>
740
+ // Show inline banner per cart item if upgrade exists
741
+ \`\`\`
742
+
743
+ **Bundle offers** (discounted complementary products configured by the store owner):
744
+ \`\`\`typescript
745
+ import type { CartBundlesResponse } from 'brainerce';
746
+ const { bundles } = await client.getCartBundles(cartId);
747
+ // bundles[].bundleProduct \u2014 the product to offer
748
+ // bundles[].originalPrice / bundles[].discountedPrice \u2014 prices
749
+ // bundles[].discountType ('PERCENTAGE' | 'FIXED_AMOUNT') + discountValue
750
+
751
+ // Add a bundle to cart (applies the discount automatically):
752
+ await client.addBundleToCart(cartId, bundleOfferId);
753
+ // Remove a bundle from cart:
754
+ await client.removeBundleFromCart(cartId, bundleOfferId);
755
+ // Detect already-added bundles: check cart.items for metadata?.isBundleItem === true
756
+ \`\`\`
757
+
758
+ **Free shipping progress bar** (configured threshold in channel settings):
759
+ \`\`\`typescript
760
+ const { storeInfo } = useStoreInfo(); // from store provider
761
+ const threshold = storeInfo?.upsell?.freeShippingThreshold;
762
+ const subtotal = parseFloat(cart.subtotal);
763
+ const remaining = threshold ? threshold - subtotal : 0;
764
+ const qualified = threshold ? subtotal >= threshold : false;
765
+ // Show progress bar: remaining > 0 ? "You're $X away from free shipping!" : "You've got free shipping!"
766
+ \`\`\`
767
+
768
+ ### Checkout Features
769
+
770
+ **Order bumps** (one-click add-ons at checkout):
771
+ \`\`\`typescript
772
+ import type { CheckoutBumpsResponse } from 'brainerce';
773
+ const { bumps } = await client.getCheckoutBumps(checkoutId);
774
+ // bumps[].bumpProduct \u2014 product to offer
775
+ // bumps[].originalPrice / discountedPrice \u2014 with optional discount
776
+ // bumps[].title / description \u2014 display text
777
+
778
+ // Add a bump to cart:
779
+ await client.addOrderBump(cartId, bumpId);
780
+ // Remove a bump:
781
+ await client.removeOrderBump(cartId, bumpId);
782
+ // Detect already-added bumps: check cart.items for metadata?.isOrderBump === true
783
+ \`\`\`
784
+
785
+ ### Feature Flags (per-channel)
786
+ All upsell features are controlled by \`storeInfo.upsell.*\`:
787
+ - \`freeShippingBarEnabled\` \u2014 free shipping progress bar in cart
788
+ - \`frequentlyBoughtTogetherEnabled\` \u2014 cross-sell section on product page
789
+ - \`cartUpgradeBannerEnabled\` \u2014 upgrade banner per cart item
790
+ - \`cartBundleEnabled\` \u2014 bundle offers in cart
791
+ - \`checkoutOrderBumpEnabled\` \u2014 order bumps at checkout
792
+ - \`freeShippingThreshold\` \u2014 threshold amount for free shipping bar
793
+
794
+ Always check the flag before rendering: \`if (storeInfo?.upsell?.featureName !== false)\`
795
+
796
+ ### Components Reference
797
+ | Component | Location | Purpose |
798
+ |-----------|----------|---------|
799
+ | FrequentlyBoughtTogether | products/ | Cross-sells with "Add all to cart" on product page |
800
+ | RecommendationSection | products/ | Grid of upsells or related products on product page |
801
+ | CartRecommendationSection | products/ | Cross-sell grid at bottom of cart page |
802
+ | FreeShippingBar | cart/ | Progress bar toward free shipping threshold |
803
+ | CartUpgradeBanner | cart/ | Inline "Upgrade to X for +$Y" per cart item |
804
+ | CartBundleOfferCard | cart/ | Discounted bundle offer card in cart |
805
+ | OrderBumpCard | checkout/ | Checkbox add-on card in checkout sidebar |
806
+
807
+ ProductRecommendation: \`id\`, \`name\`, \`slug\`, \`basePrice\`, \`salePrice\`, \`images\`, \`type\`, \`inventory\`, \`relationType\`.`;
726
808
  }
727
809
  function getInventorySection() {
728
810
  return `## Inventory, Stock Display & Reservation Countdown
@@ -2895,13 +2977,21 @@ export default async function ProductDetailPage({ params }: Props) {
2895
2977
 
2896
2978
  import { useEffect, useState } from 'react';
2897
2979
  import Link from 'next/link';
2898
- import type { CartRecommendationsResponse } from 'brainerce';
2980
+ import type {
2981
+ CartRecommendationsResponse,
2982
+ CartUpgradesResponse,
2983
+ CartBundlesResponse,
2984
+ } from 'brainerce';
2899
2985
  import { getClient } from '@/lib/brainerce';
2900
2986
  import { useCart } from '@/providers/store-provider';
2987
+ import { useStoreInfo } from '@/providers/store-provider';
2901
2988
  import { CartItem } from '@/components/cart/cart-item';
2989
+ import { CartUpgradeBanner } from '@/components/cart/cart-upgrade-banner';
2990
+ import { CartBundleOfferCard } from '@/components/cart/cart-bundle-offer';
2902
2991
  import { CartSummary } from '@/components/cart/cart-summary';
2903
2992
  import { CouponInput } from '@/components/cart/coupon-input';
2904
2993
  import { CartNudges } from '@/components/cart/cart-nudges';
2994
+ import { FreeShippingBar } from '@/components/cart/free-shipping-bar';
2905
2995
  import { ReservationCountdown } from '@/components/cart/reservation-countdown';
2906
2996
  import { CartRecommendationSection } from '@/components/products/recommendation-section';
2907
2997
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
@@ -2909,9 +2999,12 @@ import { useTranslations } from '@/lib/translations';
2909
2999
 
2910
3000
  export default function CartPage() {
2911
3001
  const { cart, cartLoading, refreshCart, itemCount } = useCart();
3002
+ const { storeInfo } = useStoreInfo();
2912
3003
  const t = useTranslations('cart');
2913
3004
  const tc = useTranslations('common');
2914
3005
  const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
3006
+ const [upgrades, setUpgrades] = useState<CartUpgradesResponse | null>(null);
3007
+ const [bundles, setBundles] = useState<CartBundlesResponse | null>(null);
2915
3008
 
2916
3009
  // Load cross-sell recommendations when cart changes
2917
3010
  useEffect(() => {
@@ -2926,6 +3019,36 @@ export default function CartPage() {
2926
3019
  .catch(() => {});
2927
3020
  }, [cart?.id, cart?.items.length]);
2928
3021
 
3022
+ // Load upgrade suggestions when cart changes
3023
+ useEffect(() => {
3024
+ if (
3025
+ !cart?.id ||
3026
+ cart.items.length === 0 ||
3027
+ storeInfo?.upsell?.cartUpgradeBannerEnabled === false
3028
+ ) {
3029
+ setUpgrades(null);
3030
+ return;
3031
+ }
3032
+ const client = getClient();
3033
+ client
3034
+ .getCartUpgrades(cart.id)
3035
+ .then(setUpgrades)
3036
+ .catch(() => {});
3037
+ }, [cart?.id, cart?.items.length, storeInfo?.upsell?.cartUpgradeBannerEnabled]);
3038
+
3039
+ // Load bundle offers when cart changes
3040
+ useEffect(() => {
3041
+ if (!cart?.id || cart.items.length === 0 || storeInfo?.upsell?.cartBundleEnabled === false) {
3042
+ setBundles(null);
3043
+ return;
3044
+ }
3045
+ const client = getClient();
3046
+ client
3047
+ .getCartBundles(cart.id)
3048
+ .then(setBundles)
3049
+ .catch(() => {});
3050
+ }, [cart?.id, cart?.items.length, storeInfo?.upsell?.cartBundleEnabled]);
3051
+
2929
3052
  if (cartLoading) {
2930
3053
  return (
2931
3054
  <div className="flex min-h-[60vh] items-center justify-center">
@@ -2985,10 +3108,30 @@ export default function CartPage() {
2985
3108
  {/* Cart items */}
2986
3109
  <div>
2987
3110
  {cart.items.map((item) => (
2988
- <CartItem key={item.id} item={item} onUpdate={refreshCart} />
3111
+ <div key={item.id}>
3112
+ <CartItem item={item} onUpdate={refreshCart} />
3113
+ {upgrades?.upgrades?.[item.productId] && (
3114
+ <CartUpgradeBanner
3115
+ suggestion={upgrades.upgrades[item.productId]}
3116
+ cartItem={item}
3117
+ onUpgrade={refreshCart}
3118
+ className="mb-2 ms-24"
3119
+ />
3120
+ )}
3121
+ </div>
2989
3122
  ))}
2990
3123
  </div>
2991
3124
 
3125
+ {/* Bundle offers */}
3126
+ {bundles?.bundles && bundles.bundles.length > 0 && (
3127
+ <div className="mt-6 space-y-3">
3128
+ <h3 className="text-foreground text-sm font-semibold">{t('bundleOffers')}</h3>
3129
+ {bundles.bundles.map((offer) => (
3130
+ <CartBundleOfferCard key={offer.id} offer={offer} onAdd={refreshCart} />
3131
+ ))}
3132
+ </div>
3133
+ )}
3134
+
2992
3135
  {/* Coupon input */}
2993
3136
  <div className="border-border mt-6 border-t pt-4">
2994
3137
  <CouponInput cart={cart} onUpdate={refreshCart} />
@@ -2998,6 +3141,7 @@ export default function CartPage() {
2998
3141
  {/* Summary sidebar */}
2999
3142
  <div className="lg:col-span-1">
3000
3143
  <div className="bg-muted/50 border-border sticky top-24 rounded-lg border p-6">
3144
+ <FreeShippingBar className="mb-4" />
3001
3145
  <CartSummary />
3002
3146
 
3003
3147
  <Link
@@ -3041,6 +3185,7 @@ import type {
3041
3185
  SetShippingAddressDto,
3042
3186
  ShippingDestinations,
3043
3187
  PickupLocation,
3188
+ CheckoutBumpsResponse,
3044
3189
  } from 'brainerce';
3045
3190
  import { formatPrice } from 'brainerce';
3046
3191
  import { getClient } from '@/lib/brainerce';
@@ -3051,6 +3196,7 @@ import { PaymentStep } from '@/components/checkout/payment-step';
3051
3196
  import { DeliveryMethodStep } from '@/components/checkout/delivery-method-step';
3052
3197
  import { PickupStep } from '@/components/checkout/pickup-step';
3053
3198
  import { TaxDisplay } from '@/components/checkout/tax-display';
3199
+ import { OrderBumpCard } from '@/components/checkout/order-bump-card';
3054
3200
  import { CouponInput } from '@/components/cart/coupon-input';
3055
3201
  import { ReservationCountdown } from '@/components/cart/reservation-countdown';
3056
3202
  import { LoadingSpinner } from '@/components/shared/loading-spinner';
@@ -3087,6 +3233,9 @@ function CheckoutContent() {
3087
3233
  phone?: string;
3088
3234
  } | null>(null);
3089
3235
  const [hasSavedAddress, setHasSavedAddress] = useState(false);
3236
+ const [orderBumps, setOrderBumps] = useState<CheckoutBumpsResponse | null>(null);
3237
+ const [addedBumpIds, setAddedBumpIds] = useState<Set<string>>(new Set());
3238
+ const [bumpLoading, setBumpLoading] = useState<string | null>(null);
3090
3239
 
3091
3240
  // Check for returning from canceled payment
3092
3241
  const canceled = searchParams.get('canceled') === 'true';
@@ -3189,9 +3338,59 @@ function CheckoutContent() {
3189
3338
  };
3190
3339
 
3191
3340
  initCheckout();
3192
- // eslint-disable-next-line react-hooks/exhaustive-deps
3193
3341
  }, [cart?.id, existingCheckoutId]);
3194
3342
 
3343
+ // Load order bumps when checkout is available
3344
+ useEffect(() => {
3345
+ if (!checkout?.id || storeInfo?.upsell?.checkoutOrderBumpEnabled === false) {
3346
+ setOrderBumps(null);
3347
+ return;
3348
+ }
3349
+ const client = getClient();
3350
+ client
3351
+ .getCheckoutBumps(checkout.id)
3352
+ .then((data) => {
3353
+ setOrderBumps(data);
3354
+ // Detect already-added bumps from cart
3355
+ if (cart?.items) {
3356
+ const existingBumpIds = new Set<string>();
3357
+ for (const item of cart.items) {
3358
+ const meta = item.metadata as Record<string, unknown> | undefined;
3359
+ if (meta?.isOrderBump && meta?.orderBumpId) {
3360
+ existingBumpIds.add(meta.orderBumpId as string);
3361
+ }
3362
+ }
3363
+ setAddedBumpIds(existingBumpIds);
3364
+ }
3365
+ })
3366
+ .catch(() => {});
3367
+ }, [checkout?.id, storeInfo?.upsell?.checkoutOrderBumpEnabled]);
3368
+
3369
+ // Handle bump toggle
3370
+ async function handleBumpToggle(bumpId: string, add: boolean) {
3371
+ if (!cart?.id || bumpLoading) return;
3372
+ try {
3373
+ setBumpLoading(bumpId);
3374
+ const client = getClient();
3375
+ if (add) {
3376
+ await client.addOrderBump(cart.id, bumpId);
3377
+ setAddedBumpIds((prev) => new Set([...prev, bumpId]));
3378
+ } else {
3379
+ await client.removeOrderBump(cart.id, bumpId);
3380
+ setAddedBumpIds((prev) => {
3381
+ const next = new Set(prev);
3382
+ next.delete(bumpId);
3383
+ return next;
3384
+ });
3385
+ }
3386
+ await refreshCart();
3387
+ } catch (err) {
3388
+ console.error('Failed to toggle order bump:', err);
3389
+ } finally {
3390
+ setBumpLoading(null);
3391
+ }
3392
+ }
3393
+
3195
3394
  // Handle shipping address submission
3196
3395
  async function handleAddressSubmit(
3197
3396
  address: SetShippingAddressDto,
@@ -3694,6 +3893,24 @@ function CheckoutContent() {
3694
3893
  )
3695
3894
  )}
3696
3895
 
3896
+ {/* Order bumps */}
3897
+ {orderBumps?.bumps && orderBumps.bumps.length > 0 && (
3898
+ <div className="border-border space-y-2 border-t pt-4">
3899
+ <p className="text-foreground text-xs font-semibold uppercase tracking-wide">
3900
+ {t('addToYourOrder')}
3901
+ </p>
3902
+ {orderBumps.bumps.map((bump) => (
3903
+ <OrderBumpCard
3904
+ key={bump.id}
3905
+ bump={bump}
3906
+ isAdded={addedBumpIds.has(bump.id)}
3907
+ onToggle={handleBumpToggle}
3908
+ loading={bumpLoading === bump.id}
3909
+ />
3910
+ ))}
3911
+ </div>
3912
+ )}
3913
+
3697
3914
  {/* Coupon input \u2014 show from shipping/pickup step onwards (or immediately if digital) */}
3698
3915
  {cart &&
3699
3916
  (isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
@@ -9165,6 +9382,719 @@ export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps)
9165
9382
  </div>
9166
9383
  );
9167
9384
  }
9385
+ `,
9386
+ "src/components/products/recommendation-section.tsx": `'use client';
9387
+
9388
+ import { useEffect, useState } from 'react';
9389
+ import Link from 'next/link';
9390
+ import Image from 'next/image';
9391
+ import type { ProductRecommendation } from 'brainerce';
9392
+ import { PriceDisplay } from '@/components/shared/price-display';
9393
+ import { cn } from '@/lib/utils';
9394
+
9395
+ interface RecommendationCardProps {
9396
+ item: ProductRecommendation;
9397
+ className?: string;
9398
+ }
9399
+
9400
+ function RecommendationCard({ item, className }: RecommendationCardProps) {
9401
+ const firstImage = item.images?.[0];
9402
+ const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
9403
+ const slug = item.slug || item.id;
9404
+ const basePrice = parseFloat(item.basePrice);
9405
+ const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
9406
+ const isOnSale = salePrice != null && salePrice < basePrice;
9407
+
9408
+ return (
9409
+ <Link
9410
+ href={\`/products/\${slug}\`}
9411
+ className={cn(
9412
+ 'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
9413
+ className
9414
+ )}
9415
+ >
9416
+ <div className="bg-muted relative aspect-square overflow-hidden">
9417
+ {imageUrl ? (
9418
+ <Image
9419
+ src={imageUrl}
9420
+ alt={item.name}
9421
+ fill
9422
+ sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
9423
+ className="object-cover transition-transform duration-300 group-hover:scale-105"
9424
+ />
9425
+ ) : (
9426
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
9427
+ <svg className="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
9428
+ <path
9429
+ strokeLinecap="round"
9430
+ strokeLinejoin="round"
9431
+ strokeWidth={1.5}
9432
+ 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"
9433
+ />
9434
+ </svg>
9435
+ </div>
9436
+ )}
9437
+ </div>
9438
+ <div className="space-y-1.5 p-3">
9439
+ <h3 className="text-foreground group-hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
9440
+ {item.name}
9441
+ </h3>
9442
+ <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
9443
+ </div>
9444
+ </Link>
9445
+ );
9446
+ }
9447
+
9448
+ interface RecommendationSectionProps {
9449
+ title: string;
9450
+ items: ProductRecommendation[];
9451
+ className?: string;
9452
+ }
9453
+
9454
+ export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
9455
+ if (items.length === 0) return null;
9456
+
9457
+ return (
9458
+ <div className={cn('', className)}>
9459
+ <h2 className="text-foreground mb-4 text-xl font-semibold">{title}</h2>
9460
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
9461
+ {items.map((item) => (
9462
+ <RecommendationCard key={item.id} item={item} />
9463
+ ))}
9464
+ </div>
9465
+ </div>
9466
+ );
9467
+ }
9468
+
9469
+ interface CartRecommendationSectionProps {
9470
+ title: string;
9471
+ items: ProductRecommendation[];
9472
+ className?: string;
9473
+ }
9474
+
9475
+ export function CartRecommendationSection({
9476
+ title,
9477
+ items,
9478
+ className,
9479
+ }: CartRecommendationSectionProps) {
9480
+ if (items.length === 0) return null;
9481
+
9482
+ return (
9483
+ <div className={cn('', className)}>
9484
+ <h2 className="text-foreground mb-4 text-lg font-semibold">{title}</h2>
9485
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
9486
+ {items.map((item) => (
9487
+ <RecommendationCard key={item.id} item={item} />
9488
+ ))}
9489
+ </div>
9490
+ </div>
9491
+ );
9492
+ }
9493
+ `,
9494
+ "src/components/products/frequently-bought-together.tsx": `'use client';
9495
+
9496
+ import { useState } from 'react';
9497
+ import Image from 'next/image';
9498
+ import type { Product, ProductRecommendation } from 'brainerce';
9499
+ import { formatPrice } from 'brainerce';
9500
+ import { useCart } from '@/providers/store-provider';
9501
+ import { useStoreInfo } from '@/providers/store-provider';
9502
+ import { useTranslations } from '@/lib/translations';
9503
+ import { cn } from '@/lib/utils';
9504
+
9505
+ interface FrequentlyBoughtTogetherProps {
9506
+ items: ProductRecommendation[];
9507
+ currentProduct: Product;
9508
+ className?: string;
9509
+ }
9510
+
9511
+ function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
9512
+ const sale = item.salePrice ? parseFloat(item.salePrice) : null;
9513
+ const base = parseFloat(item.basePrice);
9514
+ return sale != null && sale < base ? sale : base;
9515
+ }
9516
+
9517
+ function ProductThumb({
9518
+ name,
9519
+ imageUrl,
9520
+ price,
9521
+ currency,
9522
+ checked,
9523
+ onToggle,
9524
+ disabled,
9525
+ }: {
9526
+ name: string;
9527
+ imageUrl: string | null;
9528
+ price: number;
9529
+ currency: string;
9530
+ checked: boolean;
9531
+ onToggle?: () => void;
9532
+ disabled?: boolean;
9533
+ }) {
9534
+ return (
9535
+ <label
9536
+ className={cn(
9537
+ 'border-border bg-background relative flex cursor-pointer flex-col items-center rounded-lg border p-3 transition-all',
9538
+ checked ? 'ring-primary ring-2' : 'opacity-60',
9539
+ disabled && 'pointer-events-none'
9540
+ )}
9541
+ >
9542
+ {onToggle && (
9543
+ <input
9544
+ type="checkbox"
9545
+ checked={checked}
9546
+ onChange={onToggle}
9547
+ className="absolute start-2 top-2 h-4 w-4 rounded"
9548
+ />
9549
+ )}
9550
+ <div className="bg-muted relative mb-2 h-20 w-20 overflow-hidden rounded">
9551
+ {imageUrl ? (
9552
+ <Image src={imageUrl} alt={name} fill sizes="80px" className="object-cover" />
9553
+ ) : (
9554
+ <div className="flex h-full w-full items-center justify-center">
9555
+ <svg
9556
+ className="text-muted-foreground h-8 w-8"
9557
+ fill="none"
9558
+ viewBox="0 0 24 24"
9559
+ stroke="currentColor"
9560
+ >
9561
+ <path
9562
+ strokeLinecap="round"
9563
+ strokeLinejoin="round"
9564
+ strokeWidth={1.5}
9565
+ 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"
9566
+ />
9567
+ </svg>
9568
+ </div>
9569
+ )}
9570
+ </div>
9571
+ <span className="text-foreground line-clamp-2 text-center text-xs font-medium">{name}</span>
9572
+ <span className="text-muted-foreground mt-1 text-xs">
9573
+ {formatPrice(price, { currency }) as string}
9574
+ </span>
9575
+ </label>
9576
+ );
9577
+ }
9578
+
9579
+ export function FrequentlyBoughtTogether({
9580
+ items,
9581
+ currentProduct,
9582
+ className,
9583
+ }: FrequentlyBoughtTogetherProps) {
9584
+ const { storeInfo } = useStoreInfo();
9585
+ const { refreshCart } = useCart();
9586
+ const t = useTranslations('productDetail');
9587
+
9588
+ // Only show up to 3 cross-sells
9589
+ const crossSells = items.slice(0, 3);
9590
+
9591
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
9592
+ const [adding, setAdding] = useState(false);
9593
+
9594
+ if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
9595
+ if (crossSells.length === 0) return null;
9596
+
9597
+ const currency = storeInfo.currency || 'USD';
9598
+
9599
+ const currentPrice = getEffectivePrice(currentProduct);
9600
+ const currentImage = currentProduct.images?.[0];
9601
+ const currentImageUrl = currentImage
9602
+ ? typeof currentImage === 'string'
9603
+ ? currentImage
9604
+ : currentImage.url
9605
+ : null;
9606
+
9607
+ const totalPrice = crossSells
9608
+ .filter((item) => selected.has(item.id))
9609
+ .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
9610
+
9611
+ const toggleItem = (id: string) => {
9612
+ setSelected((prev) => {
9613
+ const next = new Set(prev);
9614
+ if (next.has(id)) {
9615
+ next.delete(id);
9616
+ } else {
9617
+ next.add(id);
9618
+ }
9619
+ return next;
9620
+ });
9621
+ };
9622
+
9623
+ async function handleAddAll() {
9624
+ if (adding || selected.size === 0) return;
9625
+ try {
9626
+ setAdding(true);
9627
+ const { getClient } = await import('@/lib/brainerce');
9628
+ const client = getClient();
9629
+ const selectedItems = crossSells.filter((item) => selected.has(item.id));
9630
+ for (const item of selectedItems) {
9631
+ await client.smartAddToCart({ productId: item.id, quantity: 1 });
9632
+ }
9633
+ await refreshCart();
9634
+ } catch (err) {
9635
+ console.error('Failed to add items to cart:', err);
9636
+ } finally {
9637
+ setAdding(false);
9638
+ }
9639
+ }
9640
+
9641
+ return (
9642
+ <div className={cn('border-border rounded-lg border p-6', className)}>
9643
+ <h2 className="text-foreground mb-4 text-xl font-semibold">
9644
+ {t('frequentlyBoughtTogether')}
9645
+ </h2>
9646
+
9647
+ <div className="flex flex-wrap items-center gap-3">
9648
+ {/* Current product (always included, no checkbox) */}
9649
+ <ProductThumb
9650
+ name={currentProduct.name}
9651
+ imageUrl={currentImageUrl}
9652
+ price={currentPrice}
9653
+ currency={currency}
9654
+ checked={true}
9655
+ disabled
9656
+ />
9657
+
9658
+ {crossSells.map((item) => {
9659
+ const img = item.images?.[0];
9660
+ const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
9661
+ return (
9662
+ <div key={item.id} className="flex items-center gap-3">
9663
+ <span className="text-muted-foreground text-lg font-light">+</span>
9664
+ <ProductThumb
9665
+ name={item.name}
9666
+ imageUrl={imgUrl}
9667
+ price={getEffectivePrice(item)}
9668
+ currency={currency}
9669
+ checked={selected.has(item.id)}
9670
+ onToggle={() => toggleItem(item.id)}
9671
+ />
9672
+ </div>
9673
+ );
9674
+ })}
9675
+ </div>
9676
+
9677
+ {/* Total + Add button */}
9678
+ <div className="mt-4 flex flex-wrap items-center gap-4">
9679
+ <span className="text-foreground text-lg font-semibold">
9680
+ {t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}
9681
+ </span>
9682
+ <button
9683
+ onClick={handleAddAll}
9684
+ disabled={adding || selected.size === 0}
9685
+ className={cn(
9686
+ 'bg-primary text-primary-foreground rounded px-5 py-2.5 text-sm font-medium transition-opacity hover:opacity-90',
9687
+ 'disabled:cursor-not-allowed disabled:opacity-50'
9688
+ )}
9689
+ >
9690
+ {adding ? t('addingAll') : t('addSelectedToCart')}
9691
+ </button>
9692
+ </div>
9693
+ </div>
9694
+ );
9695
+ }
9696
+ `,
9697
+ "src/components/cart/free-shipping-bar.tsx": `'use client';
9698
+
9699
+ import { formatPrice } from 'brainerce';
9700
+ import { useStoreInfo, useCart } from '@/providers/store-provider';
9701
+ import { useTranslations } from '@/lib/translations';
9702
+ import { cn } from '@/lib/utils';
9703
+
9704
+ interface FreeShippingBarProps {
9705
+ className?: string;
9706
+ }
9707
+
9708
+ export function FreeShippingBar({ className }: FreeShippingBarProps) {
9709
+ const t = useTranslations('cart');
9710
+ const { storeInfo } = useStoreInfo();
9711
+ const { totals } = useCart();
9712
+
9713
+ const upsell = storeInfo?.upsell;
9714
+ const threshold = upsell?.freeShippingThreshold;
9715
+ const enabled = upsell?.freeShippingBarEnabled !== false;
9716
+
9717
+ // Don't render if disabled or no threshold configured
9718
+ if (!enabled || !threshold || threshold <= 0) return null;
9719
+
9720
+ const subtotal = totals.subtotal;
9721
+ const remaining = Math.max(0, threshold - subtotal);
9722
+ const progress = Math.min(100, (subtotal / threshold) * 100);
9723
+ const qualified = remaining <= 0;
9724
+ const currency = storeInfo?.currency || 'USD';
9725
+
9726
+ // Don't show if already qualified
9727
+ if (qualified) {
9728
+ return (
9729
+ <div className={cn('rounded-lg border border-green-200 bg-green-50 p-3', className)}>
9730
+ <div className="flex items-center gap-2 text-sm font-medium text-green-700">
9731
+ <svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
9732
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
9733
+ </svg>
9734
+ {t('freeShippingQualified')}
9735
+ </div>
9736
+ </div>
9737
+ );
9738
+ }
9739
+
9740
+ return (
9741
+ <div className={cn('rounded-lg border border-amber-200 bg-amber-50 p-3', className)}>
9742
+ <p className="mb-2 text-sm text-amber-800">
9743
+ {t('freeShippingRemaining', {
9744
+ amount: formatPrice(remaining, { currency }) as string,
9745
+ })}
9746
+ </p>
9747
+ <div className="h-2 w-full overflow-hidden rounded-full bg-amber-200">
9748
+ <div
9749
+ className="h-full rounded-full bg-amber-500 transition-all duration-500 ease-out"
9750
+ style={{ width: \`\${progress}%\` }}
9751
+ />
9752
+ </div>
9753
+ </div>
9754
+ );
9755
+ }
9756
+ `,
9757
+ "src/components/cart/cart-upgrade-banner.tsx": `'use client';
9758
+
9759
+ import { useState, useEffect } from 'react';
9760
+ import Image from 'next/image';
9761
+ import type { CartUpgradeSuggestion, CartItem as CartItemType } from 'brainerce';
9762
+ import { formatPrice } from 'brainerce';
9763
+ import { getClient } from '@/lib/brainerce';
9764
+ import { useStoreInfo } from '@/providers/store-provider';
9765
+ import { useTranslations } from '@/lib/translations';
9766
+ import { cn } from '@/lib/utils';
9767
+
9768
+ interface CartUpgradeBannerProps {
9769
+ suggestion: CartUpgradeSuggestion;
9770
+ cartItem: CartItemType;
9771
+ onUpgrade: () => void;
9772
+ className?: string;
9773
+ }
9774
+
9775
+ export function CartUpgradeBanner({
9776
+ suggestion,
9777
+ cartItem,
9778
+ onUpgrade,
9779
+ className,
9780
+ }: CartUpgradeBannerProps) {
9781
+ const { storeInfo } = useStoreInfo();
9782
+ const t = useTranslations('cart');
9783
+ const currency = storeInfo?.currency || 'USD';
9784
+ const [upgrading, setUpgrading] = useState(false);
9785
+ const [dismissed, setDismissed] = useState(false);
9786
+
9787
+ const storageKey = \`dismissed_upgrade_\${suggestion.sourceProductId}\`;
9788
+
9789
+ useEffect(() => {
9790
+ try {
9791
+ if (sessionStorage.getItem(storageKey)) {
9792
+ setDismissed(true);
9793
+ }
9794
+ } catch {
9795
+ /* ignore */
9796
+ }
9797
+ }, [storageKey]);
9798
+
9799
+ if (dismissed) return null;
9800
+
9801
+ const target = suggestion.targetProduct;
9802
+ const firstImage = target.images?.[0];
9803
+ const imageUrl = firstImage
9804
+ ? typeof firstImage === 'string'
9805
+ ? firstImage
9806
+ : firstImage.url
9807
+ : null;
9808
+ const formattedDelta = formatPrice(parseFloat(suggestion.priceDelta), { currency }) as string;
9809
+
9810
+ function handleDismiss() {
9811
+ try {
9812
+ sessionStorage.setItem(storageKey, '1');
9813
+ } catch {
9814
+ /* ignore */
9815
+ }
9816
+ setDismissed(true);
9817
+ }
9818
+
9819
+ async function handleUpgrade() {
9820
+ if (upgrading) return;
9821
+ try {
9822
+ setUpgrading(true);
9823
+ const client = getClient();
9824
+ await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
9825
+ await client.smartAddToCart({ productId: target.id, quantity: cartItem.quantity });
9826
+ onUpgrade();
9827
+ } catch (err) {
9828
+ console.error('Failed to upgrade cart item:', err);
9829
+ } finally {
9830
+ setUpgrading(false);
9831
+ }
9832
+ }
9833
+
9834
+ return (
9835
+ <div
9836
+ className={cn(
9837
+ 'bg-primary/5 border-primary/20 relative flex items-center gap-3 rounded-lg border px-4 py-3',
9838
+ className
9839
+ )}
9840
+ >
9841
+ {/* Dismiss button */}
9842
+ <button
9843
+ type="button"
9844
+ onClick={handleDismiss}
9845
+ className="text-muted-foreground hover:text-foreground absolute end-2 top-2 text-xs"
9846
+ aria-label={t('dismissUpgrade')}
9847
+ >
9848
+ <svg
9849
+ className="h-4 w-4"
9850
+ fill="none"
9851
+ viewBox="0 0 24 24"
9852
+ stroke="currentColor"
9853
+ strokeWidth={2}
9854
+ >
9855
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
9856
+ </svg>
9857
+ </button>
9858
+
9859
+ {/* Product image */}
9860
+ <div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
9861
+ {imageUrl ? (
9862
+ <Image src={imageUrl} alt={target.name} fill sizes="48px" className="object-cover" />
9863
+ ) : (
9864
+ <div className="text-muted-foreground flex h-full w-full items-center justify-center">
9865
+ <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
9866
+ <path
9867
+ strokeLinecap="round"
9868
+ strokeLinejoin="round"
9869
+ strokeWidth={1.5}
9870
+ 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"
9871
+ />
9872
+ </svg>
9873
+ </div>
9874
+ )}
9875
+ </div>
9876
+
9877
+ {/* Text */}
9878
+ <div className="min-w-0 flex-1">
9879
+ <p className="text-foreground text-sm font-medium">
9880
+ {t('upgradeFor', { name: target.name, amount: formattedDelta })}
9881
+ </p>
9882
+ </div>
9883
+
9884
+ {/* Upgrade button */}
9885
+ <button
9886
+ type="button"
9887
+ onClick={handleUpgrade}
9888
+ disabled={upgrading}
9889
+ className={cn(
9890
+ 'bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-opacity hover:opacity-90',
9891
+ 'disabled:cursor-not-allowed disabled:opacity-50'
9892
+ )}
9893
+ >
9894
+ {upgrading ? t('upgrading') : t('upgrade')}
9895
+ </button>
9896
+ </div>
9897
+ );
9898
+ }
9899
+ `,
9900
+ "src/components/cart/cart-bundle-offer.tsx": `'use client';
9901
+
9902
+ import { useState } from 'react';
9903
+ import Image from 'next/image';
9904
+ import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
9905
+ import { formatPrice } from 'brainerce';
9906
+ import { getClient } from '@/lib/brainerce';
9907
+ import { useStoreInfo } from '@/providers/store-provider';
9908
+ import { useTranslations } from '@/lib/translations';
9909
+ import { cn } from '@/lib/utils';
9910
+
9911
+ interface CartBundleOfferCardProps {
9912
+ offer: CartBundleOfferType;
9913
+ onAdd: () => void;
9914
+ className?: string;
9915
+ }
9916
+
9917
+ export function CartBundleOfferCard({ offer, onAdd, className }: CartBundleOfferCardProps) {
9918
+ const { storeInfo } = useStoreInfo();
9919
+ const t = useTranslations('cart');
9920
+ const currency = storeInfo?.currency || 'USD';
9921
+ const [adding, setAdding] = useState(false);
9922
+
9923
+ const product = offer.bundleProduct;
9924
+ const firstImage = product.images?.[0];
9925
+ const imageUrl = firstImage
9926
+ ? typeof firstImage === 'string'
9927
+ ? firstImage
9928
+ : firstImage.url
9929
+ : null;
9930
+ const originalPrice = parseFloat(offer.originalPrice);
9931
+ const discountedPrice = parseFloat(offer.discountedPrice);
9932
+ const discountLabel =
9933
+ offer.discountType === 'PERCENTAGE'
9934
+ ? \`\${offer.discountValue}%\`
9935
+ : (formatPrice(parseFloat(offer.discountValue), { currency }) as string);
9936
+
9937
+ async function handleAdd() {
9938
+ if (adding) return;
9939
+ try {
9940
+ setAdding(true);
9941
+ const client = getClient();
9942
+ await client.smartAddToCart({
9943
+ productId: product.id,
9944
+ variantId: offer.bundleVariantId || undefined,
9945
+ quantity: 1,
9946
+ });
9947
+ onAdd();
9948
+ } catch (err) {
9949
+ console.error('Failed to add bundle item:', err);
9950
+ } finally {
9951
+ setAdding(false);
9952
+ }
9953
+ }
9954
+
9955
+ return (
9956
+ <div
9957
+ className={cn(
9958
+ 'bg-background border-border flex items-center gap-4 rounded-lg border p-4',
9959
+ className
9960
+ )}
9961
+ >
9962
+ {/* Product image */}
9963
+ <div className="bg-muted relative h-16 w-16 flex-shrink-0 overflow-hidden rounded">
9964
+ {imageUrl ? (
9965
+ <Image src={imageUrl} alt={product.name} fill sizes="64px" className="object-cover" />
9966
+ ) : (
9967
+ <div className="text-muted-foreground flex h-full w-full items-center justify-center">
9968
+ <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
9969
+ <path
9970
+ strokeLinecap="round"
9971
+ strokeLinejoin="round"
9972
+ strokeWidth={1.5}
9973
+ 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"
9974
+ />
9975
+ </svg>
9976
+ </div>
9977
+ )}
9978
+ </div>
9979
+
9980
+ {/* Details */}
9981
+ <div className="min-w-0 flex-1">
9982
+ <p className="text-foreground text-sm font-medium">{offer.name}</p>
9983
+ {offer.description && (
9984
+ <p className="text-muted-foreground mt-0.5 text-xs">{offer.description}</p>
9985
+ )}
9986
+ <div className="mt-1 flex items-center gap-2">
9987
+ <span className="text-muted-foreground text-sm line-through">
9988
+ {formatPrice(originalPrice, { currency }) as string}
9989
+ </span>
9990
+ <span className="text-foreground text-sm font-semibold">
9991
+ {formatPrice(discountedPrice, { currency }) as string}
9992
+ </span>
9993
+ <span className="bg-destructive/10 text-destructive rounded px-1.5 py-0.5 text-xs font-medium">
9994
+ -{discountLabel}
9995
+ </span>
9996
+ </div>
9997
+ </div>
9998
+
9999
+ {/* Add button */}
10000
+ <button
10001
+ type="button"
10002
+ onClick={handleAdd}
10003
+ disabled={adding}
10004
+ className={cn(
10005
+ 'bg-primary text-primary-foreground flex-shrink-0 rounded px-4 py-2 text-xs font-medium transition-opacity hover:opacity-90',
10006
+ 'disabled:cursor-not-allowed disabled:opacity-50'
10007
+ )}
10008
+ >
10009
+ {adding ? t('addingBundle') : t('addBundleItem')}
10010
+ </button>
10011
+ </div>
10012
+ );
10013
+ }
10014
+ `,
10015
+ "src/components/checkout/order-bump-card.tsx": `'use client';
10016
+
10017
+ import Image from 'next/image';
10018
+ import type { OrderBump } from 'brainerce';
10019
+ import { formatPrice } from 'brainerce';
10020
+ import { useStoreInfo } from '@/providers/store-provider';
10021
+ import { useTranslations } from '@/lib/translations';
10022
+ import { cn } from '@/lib/utils';
10023
+
10024
+ interface OrderBumpCardProps {
10025
+ bump: OrderBump;
10026
+ isAdded: boolean;
10027
+ onToggle: (bumpId: string, add: boolean) => void;
10028
+ loading: boolean;
10029
+ className?: string;
10030
+ }
10031
+
10032
+ export function OrderBumpCard({ bump, isAdded, onToggle, loading, className }: OrderBumpCardProps) {
10033
+ const { storeInfo } = useStoreInfo();
10034
+ const t = useTranslations('checkout');
10035
+ const currency = storeInfo?.currency || 'USD';
10036
+
10037
+ const product = bump.bumpProduct;
10038
+ const firstImage = product.images?.[0];
10039
+ const imageUrl = firstImage
10040
+ ? typeof firstImage === 'string'
10041
+ ? firstImage
10042
+ : firstImage.url
10043
+ : null;
10044
+ const originalPrice = parseFloat(bump.originalPrice);
10045
+ const hasDiscount = bump.discountedPrice != null;
10046
+ const discountedPrice = hasDiscount ? parseFloat(bump.discountedPrice!) : null;
10047
+
10048
+ return (
10049
+ <label
10050
+ className={cn(
10051
+ 'border-border hover:border-primary/50 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
10052
+ isAdded && 'border-primary bg-primary/5',
10053
+ loading && 'pointer-events-none opacity-60',
10054
+ className
10055
+ )}
10056
+ >
10057
+ <input
10058
+ type="checkbox"
10059
+ checked={isAdded}
10060
+ onChange={() => onToggle(bump.id, !isAdded)}
10061
+ disabled={loading}
10062
+ className="mt-1 h-4 w-4 shrink-0 rounded"
10063
+ />
10064
+
10065
+ {/* Image */}
10066
+ {imageUrl && (
10067
+ <div className="bg-muted relative h-10 w-10 shrink-0 overflow-hidden rounded">
10068
+ <Image src={imageUrl} alt={product.name} fill sizes="40px" className="object-cover" />
10069
+ </div>
10070
+ )}
10071
+
10072
+ {/* Content */}
10073
+ <div className="min-w-0 flex-1">
10074
+ <p className="text-foreground text-sm font-medium">{bump.title}</p>
10075
+ {bump.description && (
10076
+ <p className="text-muted-foreground mt-0.5 text-xs">{bump.description}</p>
10077
+ )}
10078
+ <div className="mt-1 flex items-center gap-2">
10079
+ {hasDiscount ? (
10080
+ <>
10081
+ <span className="text-muted-foreground text-xs line-through">
10082
+ {formatPrice(originalPrice, { currency }) as string}
10083
+ </span>
10084
+ <span className="text-foreground text-sm font-semibold">
10085
+ {formatPrice(discountedPrice!, { currency }) as string}
10086
+ </span>
10087
+ </>
10088
+ ) : (
10089
+ <span className="text-foreground text-sm font-semibold">
10090
+ {formatPrice(originalPrice, { currency }) as string}
10091
+ </span>
10092
+ )}
10093
+ </div>
10094
+ </div>
10095
+ </label>
10096
+ );
10097
+ }
9168
10098
  `,
9169
10099
  "src/hooks/use-search.ts": `'use client';
9170
10100