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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user