feat: add bundle feature with 2x/3x set options
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:
Unchained
2026-03-24 16:00:07 +02:00
parent 28a6e58dba
commit 9a61564e3c
12 changed files with 370 additions and 34 deletions

View 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>
);
}

View File

@@ -19,10 +19,12 @@ import TrustBadges from "@/components/home/TrustBadges";
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
import HowItWorks from "@/components/home/HowItWorks";
import NewsletterSection from "@/components/home/NewsletterSection";
import BundleSelector from "@/components/product/BundleSelector";
interface ProductDetailProps {
product: Product;
relatedProducts: Product[];
bundleProducts?: Product[];
locale?: string;
}
@@ -88,13 +90,14 @@ function StarRating({ rating = 5, count = 0 }: { rating?: number; count?: number
);
}
export default function ProductDetail({ product, relatedProducts, locale = "sr" }: ProductDetailProps) {
export default function ProductDetail({ product, relatedProducts, bundleProducts = [], locale = "sr" }: ProductDetailProps) {
const t = useTranslations("ProductDetail");
const tProduct = useTranslations("Product");
const [selectedImage, setSelectedImage] = useState(0);
const [quantity, setQuantity] = useState(1);
const [isAdding, setIsAdding] = useState(false);
const [urgencyIndex, setUrgencyIndex] = useState(0);
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
const { addLine, openCart } = useSaleorCheckoutStore();
const validLocale = isValidLocale(locale) ? locale : "sr";
@@ -112,28 +115,53 @@ export default function ProductDetail({ product, relatedProducts, locale = "sr"
];
const localized = getLocalizedProduct(product, locale);
const variant = product.variants?.[0];
const baseVariant = product.variants?.[0];
const selectedVariantId = selectedBundleVariantId || baseVariant?.id;
const selectedVariant = selectedVariantId === baseVariant?.id
? baseVariant
: bundleProducts.find(p => p.variants?.[0]?.id === selectedVariantId)?.variants?.[0];
const images = product.media?.length > 0
? product.media.filter(m => m.type === "IMAGE")
: [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" as const }];
const handleAddToCart = async () => {
if (!variant?.id) return;
if (!selectedVariantId) return;
setIsAdding(true);
try {
await addLine(variant.id, quantity);
await addLine(selectedVariantId, 1);
openCart();
} finally {
setIsAdding(false);
}
};
const isAvailable = variant?.quantityAvailable > 0;
const price = getProductPrice(product);
const priceAmount = getProductPriceAmount(product);
const originalPrice = priceAmount > 0 ? formatPrice(Math.round(priceAmount * 1.30)) : null;
const handleSelectVariant = (variantId: string, qty: number, price: number) => {
setSelectedBundleVariantId(variantId);
setQuantity(qty);
};
const isAvailable = (selectedVariant?.quantityAvailable ?? 0) > 0;
const selectedPrice = selectedVariant?.pricing?.price?.gross?.amount || 0;
const price = selectedPrice > 0
? new Intl.NumberFormat(validLocale === "en" ? "en-US" : validLocale === "de" ? "de-DE" : validLocale === "fr" ? "fr-FR" : "sr-RS", {
style: "currency",
currency: selectedVariant?.pricing?.price?.gross?.currency || "RSD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(selectedPrice)
: "";
const priceAmount = selectedPrice;
const originalPrice = priceAmount > 0 ? new Intl.NumberFormat(validLocale === "en" ? "en-US" : validLocale === "de" ? "de-DE" : validLocale === "fr" ? "fr-FR" : "sr-RS", {
style: "currency",
currency: "RSD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(Math.round(priceAmount * 1.30)) : null;
const shortDescription = getTranslatedShortDescription(localized.description, validLocale);
@@ -292,28 +320,38 @@ export default function ProductDetail({ product, relatedProducts, locale = "sr"
<div className="border-t border-[#e5e5e5] mb-8" />
{product.variants && product.variants.length > 1 && (
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<span className="text-sm uppercase tracking-[0.1em] font-medium">
{t("size")}
</span>
{bundleProducts.length > 0 ? (
<BundleSelector
baseProduct={product}
bundleProducts={bundleProducts}
selectedVariantId={selectedBundleVariantId || baseVariant?.id || null}
onSelectVariant={handleSelectVariant}
locale={validLocale}
/>
) : (
product.variants && product.variants.length > 1 && (
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<span className="text-sm uppercase tracking-[0.1em] font-medium">
{t("size")}
</span>
</div>
<div className="flex gap-3">
{product.variants.map((v) => (
<button
key={v.id}
className={`px-5 py-3 text-sm border-2 transition-colors ${
v.id === baseVariant?.id
? "border-black bg-black text-white"
: "border-[#e5e5e5] hover:border-[#999999]"
}`}
>
{v.name}
</button>
))}
</div>
</div>
<div className="flex gap-3">
{product.variants.map((v) => (
<button
key={v.id}
className={`px-5 py-3 text-sm border-2 transition-colors ${
v.id === variant?.id
? "border-black bg-black text-white"
: "border-[#e5e5e5] hover:border-[#999999]"
}`}
>
{v.name}
</button>
))}
</div>
</div>
)
)}
<div className="flex items-center gap-4 mb-8">
@@ -425,9 +463,9 @@ export default function ProductDetail({ product, relatedProducts, locale = "sr"
</ExpandableSection>
</div>
{variant?.sku && (
{selectedVariant?.sku && (
<p className="text-xs text-[#999999] mt-8">
SKU: {variant.sku}
SKU: {selectedVariant.sku}
</p>
)}
</motion.div>