diff --git a/src/app/[locale]/products/[slug]/page.tsx b/src/app/[locale]/products/[slug]/page.tsx index cc6b20d..60983c9 100644 --- a/src/app/[locale]/products/[slug]/page.tsx +++ b/src/app/[locale]/products/[slug]/page.tsx @@ -1,4 +1,4 @@ -import { getProductBySlug, getProducts, getLocalizedProduct } from "@/lib/saleor"; +import { getProductBySlug, getProducts, getLocalizedProduct, getBundleProducts } from "@/lib/saleor"; import { getTranslations, setRequestLocale } from "next-intl/server"; import Header from "@/components/layout/Header"; import Footer from "@/components/layout/Footer"; @@ -86,6 +86,7 @@ export default async function ProductPage({ params }: ProductPageProps) { } let relatedProducts: Product[] = []; + let bundleProducts: Product[] = []; try { const allProducts = await getProducts(saleorLocale, 8); relatedProducts = allProducts @@ -93,6 +94,20 @@ export default async function ProductPage({ params }: ProductPageProps) { .slice(0, 4); } catch (e) {} + try { + const allBundleProducts = await getBundleProducts(saleorLocale, 50); + bundleProducts = allBundleProducts.filter((p) => { + const bundleAttr = p.attributes?.find( + (attr) => attr.attribute.slug === "bundle-items" + ); + if (!bundleAttr) return false; + return bundleAttr.values.some((val) => { + const baseIdPart = product.id.replace("UHJvZHVjdDo", ""); + return val.slug.includes(baseIdPart); + }); + }); + } catch (e) {} + return ( <>
@@ -100,6 +115,7 @@ export default async function ProductPage({ params }: ProductPageProps) { diff --git a/src/components/product/BundleSelector.tsx b/src/components/product/BundleSelector.tsx new file mode 100644 index 0000000..338b8f8 --- /dev/null +++ b/src/components/product/BundleSelector.tsx @@ -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 = { + 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 ( +
+
+ + {t("selectBundle")} + +
+ +
+ {options.map((option) => { + const variantId = option.isBase + ? baseVariant?.id + : option.product.variants?.[0]?.id; + const isSelected = selectedVariantId === variantId; + + return ( + 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 }} + > +
+
+
+ {isSelected && ( + + )} +
+
+ + {option.isBase ? t("singleUnit") : t("xSet", { count: option.quantity })} + + {!option.isBase && option.savings > 0 && ( + + {t("save", { amount: formatPriceWithLocale(option.savings) })} + + )} +
+
+ +
+
+ {formatPriceWithLocale(option.price)} +
+ {!option.isBase && ( +
+ {formatPriceWithLocale(option.pricePerUnit)} {t("perUnit")} +
+ )} +
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/product/ProductDetail.tsx b/src/components/product/ProductDetail.tsx index ef7e372..7e35bda 100644 --- a/src/components/product/ProductDetail.tsx +++ b/src/components/product/ProductDetail.tsx @@ -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(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"
- {product.variants && product.variants.length > 1 && ( -
-
- - {t("size")} - + {bundleProducts.length > 0 ? ( + + ) : ( + product.variants && product.variants.length > 1 && ( +
+
+ + {t("size")} + +
+
+ {product.variants.map((v) => ( + + ))} +
-
- {product.variants.map((v) => ( - - ))} -
-
+ ) )}
@@ -425,9 +463,9 @@ export default function ProductDetail({ product, relatedProducts, locale = "sr"
- {variant?.sku && ( + {selectedVariant?.sku && (

- SKU: {variant.sku} + SKU: {selectedVariant.sku}

)} diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 3ff1862..89356d8 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -312,6 +312,13 @@ "urgency2": "In den Warenkörben von 2,5K Menschen - kaufen Sie, bevor es weg ist!", "urgency3": "7.562 Personen haben sich dieses Produkt in den letzten 24 Stunden angesehen!" }, + "Bundle": { + "selectBundle": "Paket wählen", + "singleUnit": "1 Stück", + "xSet": "{count}x Set", + "save": "Spare {amount}", + "perUnit": "pro Stück" + }, "Newsletter": { "stayConnected": "Bleiben Sie verbunden", "joinCommunity": "Werden Sie Teil unserer Gemeinschaft", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index bd7b819..2ecdd23 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -341,6 +341,13 @@ "urgency2": "In the carts of 2.5K people - buy before its gone!", "urgency3": "7,562 people viewed this product in the last 24 hours!" }, + "Bundle": { + "selectBundle": "Select Package", + "singleUnit": "1 Unit", + "xSet": "{count}x Set", + "save": "Save {amount}", + "perUnit": "per unit" + }, "Newsletter": { "stayConnected": "Stay Connected", "joinCommunity": "Join Our Community", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 9caa718..163b427 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -312,6 +312,13 @@ "urgency2": "Dans les paniers de 2,5K personnes - achetez avant qu'il ne disparaisse!", "urgency3": "7 562 personnes ont vu ce produit ces dernières 24 heures!" }, + "Bundle": { + "selectBundle": "Choisir le Pack", + "singleUnit": "1 Unité", + "xSet": "{count}x Set", + "save": "Économisez {amount}", + "perUnit": "par unité" + }, "Newsletter": { "stayConnected": "Restez Connectés", "joinCommunity": "Rejoignez Notre Communauté", diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json index c2e3c5d..d21670b 100644 --- a/src/i18n/messages/sr.json +++ b/src/i18n/messages/sr.json @@ -341,6 +341,13 @@ "urgency2": "U korpama 2.5K ljudi - kupi pre nego što nestane!", "urgency3": "7.562 osobe su pogledale ovaj proizvod u poslednja 24 sata!" }, + "Bundle": { + "selectBundle": "Izaberi pakovanje", + "singleUnit": "1 komad", + "xSet": "{count}x Set", + "save": "Štedi {amount}", + "perUnit": "po komadu" + }, "Newsletter": { "stayConnected": "Ostanite povezani", "joinCommunity": "Pridružite se našoj zajednici", diff --git a/src/lib/saleor/fragments/Product.ts b/src/lib/saleor/fragments/Product.ts index 89876b1..2ec186c 100644 --- a/src/lib/saleor/fragments/Product.ts +++ b/src/lib/saleor/fragments/Product.ts @@ -35,6 +35,18 @@ export const PRODUCT_FRAGMENT = gql` key value } + attributes { + attribute { + id + name + slug + } + values { + id + name + slug + } + } } ${PRODUCT_VARIANT_FRAGMENT} `; diff --git a/src/lib/saleor/index.ts b/src/lib/saleor/index.ts index a3cfca2..4c2f602 100644 --- a/src/lib/saleor/index.ts +++ b/src/lib/saleor/index.ts @@ -7,7 +7,7 @@ export { PRODUCT_VARIANT_FRAGMENT, CHECKOUT_LINE_FRAGMENT } from "./fragments/Va export { CHECKOUT_FRAGMENT, ADDRESS_FRAGMENT } from "./fragments/Checkout"; // Queries -export { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_PRODUCTS_BY_CATEGORY } from "./queries/Products"; +export { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_PRODUCTS_BY_CATEGORY, GET_BUNDLE_PRODUCTS } from "./queries/Products"; export { GET_CHECKOUT, GET_CHECKOUT_BY_ID } from "./queries/Checkout"; // Mutations @@ -34,4 +34,5 @@ export { formatPrice, getLocalizedProduct, parseDescription, + getBundleProducts, } from "./products"; diff --git a/src/lib/saleor/products.ts b/src/lib/saleor/products.ts index ded4d41..e47f5bf 100644 --- a/src/lib/saleor/products.ts +++ b/src/lib/saleor/products.ts @@ -1,5 +1,5 @@ import { saleorClient } from "./client"; -import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG } from "./queries/Products"; +import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_BUNDLE_PRODUCTS } from "./queries/Products"; import type { Product } from "@/types/saleor"; const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel"; @@ -155,3 +155,65 @@ export function getLocalizedProduct( seoDescription: translation?.seoDescription || product.seoDescription, }; } + +interface ProductsResponse { + products?: { + edges: Array<{ node: Product }>; + }; +} + +export async function getBundleProducts( + locale: string = "SR", + first: number = 50 +): Promise { + try { + const { data } = await saleorClient.query({ + query: GET_BUNDLE_PRODUCTS, + variables: { + channel: CHANNEL, + locale: locale.toUpperCase(), + first, + }, + }); + + return data?.products?.edges.map((edge) => edge.node) || []; + } catch (error) { + console.error("Error fetching bundle products from Saleor:", error); + return []; + } +} + +export function getBundleProductsForProduct( + allProducts: Product[], + baseProductId: string +): Product[] { + return allProducts.filter((product) => { + const bundleItemsAttr = product.attributes?.find( + (attr) => attr.attribute.slug === "bundle-items" + ); + if (!bundleItemsAttr) return false; + return bundleItemsAttr.values.some((val) => { + const referencedId = Buffer.from(val.slug.split(":")[1] || val.id).toString("base64"); + const expectedId = `UHJvZHVjdDo${baseProductId.split("UHJvZHVjdDo")[1]}`; + return referencedId.includes(baseProductId.split("UHJvZHVjdDo")[1] || "") || + val.slug.includes(baseProductId.split("UHJvZHVjdDo")[1] || ""); + }); + }); +} + +export function getProductBundleComponents(product: Product): number | null { + const bundleAttr = product.attributes?.find( + (attr) => attr.attribute.slug === "bundle-items" + ); + if (!bundleAttr) return null; + + const bundleAttrMatch = product.name.match(/(\d+)x/i); + if (bundleAttrMatch) { + return parseInt(bundleAttrMatch[1], 10); + } + return null; +} + +export function isBundleProduct(product: Product): boolean { + return getProductBundleComponents(product) !== null; +} diff --git a/src/lib/saleor/queries/Products.ts b/src/lib/saleor/queries/Products.ts index 7a225b4..05173f1 100644 --- a/src/lib/saleor/queries/Products.ts +++ b/src/lib/saleor/queries/Products.ts @@ -49,3 +49,16 @@ export const GET_PRODUCTS_BY_CATEGORY = gql` } ${PRODUCT_LIST_ITEM_FRAGMENT} `; + +export const GET_BUNDLE_PRODUCTS = gql` + query GetBundleProducts($channel: String!, $locale: LanguageCodeEnum!, $first: Int!) { + products(channel: $channel, first: $first) { + edges { + node { + ...ProductFragment + } + } + } + } + ${PRODUCT_FRAGMENT} +`; diff --git a/src/types/saleor.ts b/src/types/saleor.ts index 4731336..0f723e0 100644 --- a/src/types/saleor.ts +++ b/src/types/saleor.ts @@ -22,12 +22,14 @@ export interface ProductMedia { } export interface ProductAttributeValue { + id: string; name: string; slug: string; } export interface ProductAttribute { attribute: { + id: string; name: string; slug: string; }; @@ -82,6 +84,7 @@ export interface Product { key: string; value: string; }[]; + attributes?: ProductAttribute[]; } export interface ProductEdge {