feat: add bundle feature with 2x/3x set options
Some checks failed
Build and Deploy / build (push) Has been cancelled
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Created BundleSelector component for selecting bundle options - Updated ProductDetail to show bundle options - Added bundle translations for all 4 locales - Added GraphQL query for bundle products - Updated TypeScript types for attributes - Saleor backend: created bundle products for all base products
This commit is contained in:
163
src/components/product/BundleSelector.tsx
Normal file
163
src/components/product/BundleSelector.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Product } from "@/types/saleor";
|
||||
import { getProductPrice, formatPrice } from "@/lib/saleor";
|
||||
|
||||
interface BundleSelectorProps {
|
||||
baseProduct: Product;
|
||||
bundleProducts: Product[];
|
||||
selectedVariantId: string | null;
|
||||
onSelectVariant: (variantId: string, quantity: number, price: number) => void;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
interface BundleOption {
|
||||
product: Product;
|
||||
quantity: number;
|
||||
price: number;
|
||||
pricePerUnit: number;
|
||||
savings: number;
|
||||
isBase: boolean;
|
||||
}
|
||||
|
||||
export default function BundleSelector({
|
||||
baseProduct,
|
||||
bundleProducts,
|
||||
selectedVariantId,
|
||||
onSelectVariant,
|
||||
locale,
|
||||
}: BundleSelectorProps) {
|
||||
const t = useTranslations("Bundle");
|
||||
|
||||
if (bundleProducts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseVariant = baseProduct.variants?.[0];
|
||||
const basePrice = baseVariant?.pricing?.price?.gross?.amount || 0;
|
||||
|
||||
const options: BundleOption[] = [];
|
||||
|
||||
options.push({
|
||||
product: baseProduct,
|
||||
quantity: 1,
|
||||
price: basePrice,
|
||||
pricePerUnit: basePrice,
|
||||
savings: 0,
|
||||
isBase: true,
|
||||
});
|
||||
|
||||
bundleProducts.forEach((bundle) => {
|
||||
const variant = bundle.variants?.[0];
|
||||
if (!variant?.pricing?.price?.gross?.amount) return;
|
||||
|
||||
const price = variant.pricing.price.gross.amount;
|
||||
const quantityMatch = bundle.name.match(/(\d+)x/i);
|
||||
const quantity = quantityMatch ? parseInt(quantityMatch[1], 10) : 1;
|
||||
const pricePerUnit = price / quantity;
|
||||
const savings = (basePrice * quantity) - price;
|
||||
|
||||
options.push({
|
||||
product: bundle,
|
||||
quantity,
|
||||
price,
|
||||
pricePerUnit,
|
||||
savings,
|
||||
isBase: false,
|
||||
});
|
||||
});
|
||||
|
||||
options.sort((a, b) => a.quantity - b.quantity);
|
||||
|
||||
const formatPriceWithLocale = (amount: number, currency: string = "RSD") => {
|
||||
const localeMap: Record<string, string> = {
|
||||
sr: "sr-RS",
|
||||
en: "en-US",
|
||||
de: "de-DE",
|
||||
fr: "fr-FR",
|
||||
};
|
||||
const numLocale = localeMap[locale] || "sr-RS";
|
||||
return new Intl.NumberFormat(numLocale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm uppercase tracking-[0.1em] font-medium">
|
||||
{t("selectBundle")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{options.map((option) => {
|
||||
const variantId = option.isBase
|
||||
? baseVariant?.id
|
||||
: option.product.variants?.[0]?.id;
|
||||
const isSelected = selectedVariantId === variantId;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={option.product.id}
|
||||
onClick={() => variantId && onSelectVariant(variantId, option.quantity, option.price)}
|
||||
className={`w-full p-4 border-2 transition-all text-left ${
|
||||
isSelected
|
||||
? "border-black bg-black text-white"
|
||||
: "border-[#e5e5e5] hover:border-[#999999]"
|
||||
}`}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
isSelected
|
||||
? "border-white bg-white"
|
||||
: "border-[#999999]"
|
||||
}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="w-2.5 h-2.5 rounded-full bg-black"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{option.isBase ? t("singleUnit") : t("xSet", { count: option.quantity })}
|
||||
</span>
|
||||
{!option.isBase && option.savings > 0 && (
|
||||
<span className="ml-2 text-xs text-green-500">
|
||||
{t("save", { amount: formatPriceWithLocale(option.savings) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className={`font-bold ${isSelected ? "text-white" : "text-black"}`}>
|
||||
{formatPriceWithLocale(option.price)}
|
||||
</div>
|
||||
{!option.isBase && (
|
||||
<div className={`text-xs ${isSelected ? "text-white/70" : "text-[#666666]"}`}>
|
||||
{formatPriceWithLocale(option.pricePerUnit)} {t("perUnit")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user