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

@@ -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>