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