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
164 lines
4.9 KiB
TypeScript
164 lines
4.9 KiB
TypeScript
"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>
|
|
);
|
|
}
|