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

@@ -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 { getTranslations, setRequestLocale } from "next-intl/server";
import Header from "@/components/layout/Header"; import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer"; import Footer from "@/components/layout/Footer";
@@ -86,6 +86,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
} }
let relatedProducts: Product[] = []; let relatedProducts: Product[] = [];
let bundleProducts: Product[] = [];
try { try {
const allProducts = await getProducts(saleorLocale, 8); const allProducts = await getProducts(saleorLocale, 8);
relatedProducts = allProducts relatedProducts = allProducts
@@ -93,6 +94,20 @@ export default async function ProductPage({ params }: ProductPageProps) {
.slice(0, 4); .slice(0, 4);
} catch (e) {} } 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 ( return (
<> <>
<Header locale={locale} /> <Header locale={locale} />
@@ -100,6 +115,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
<ProductDetail <ProductDetail
product={product} product={product}
relatedProducts={relatedProducts} relatedProducts={relatedProducts}
bundleProducts={bundleProducts}
locale={locale} locale={locale}
/> />
</main> </main>

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 BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
import HowItWorks from "@/components/home/HowItWorks"; import HowItWorks from "@/components/home/HowItWorks";
import NewsletterSection from "@/components/home/NewsletterSection"; import NewsletterSection from "@/components/home/NewsletterSection";
import BundleSelector from "@/components/product/BundleSelector";
interface ProductDetailProps { interface ProductDetailProps {
product: Product; product: Product;
relatedProducts: Product[]; relatedProducts: Product[];
bundleProducts?: Product[];
locale?: string; 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 t = useTranslations("ProductDetail");
const tProduct = useTranslations("Product"); const tProduct = useTranslations("Product");
const [selectedImage, setSelectedImage] = useState(0); const [selectedImage, setSelectedImage] = useState(0);
const [quantity, setQuantity] = useState(1); const [quantity, setQuantity] = useState(1);
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [urgencyIndex, setUrgencyIndex] = useState(0); const [urgencyIndex, setUrgencyIndex] = useState(0);
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
const { addLine, openCart } = useSaleorCheckoutStore(); const { addLine, openCart } = useSaleorCheckoutStore();
const validLocale = isValidLocale(locale) ? locale : "sr"; const validLocale = isValidLocale(locale) ? locale : "sr";
@@ -112,28 +115,53 @@ export default function ProductDetail({ product, relatedProducts, locale = "sr"
]; ];
const localized = getLocalizedProduct(product, locale); 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 const images = product.media?.length > 0
? product.media.filter(m => m.type === "IMAGE") ? product.media.filter(m => m.type === "IMAGE")
: [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" as const }]; : [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" as const }];
const handleAddToCart = async () => { const handleAddToCart = async () => {
if (!variant?.id) return; if (!selectedVariantId) return;
setIsAdding(true); setIsAdding(true);
try { try {
await addLine(variant.id, quantity); await addLine(selectedVariantId, 1);
openCart(); openCart();
} finally { } finally {
setIsAdding(false); setIsAdding(false);
} }
}; };
const isAvailable = variant?.quantityAvailable > 0; const handleSelectVariant = (variantId: string, qty: number, price: number) => {
const price = getProductPrice(product); setSelectedBundleVariantId(variantId);
const priceAmount = getProductPriceAmount(product); setQuantity(qty);
const originalPrice = priceAmount > 0 ? formatPrice(Math.round(priceAmount * 1.30)) : null; };
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); 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" /> <div className="border-t border-[#e5e5e5] mb-8" />
{product.variants && product.variants.length > 1 && ( {bundleProducts.length > 0 ? (
<div className="mb-8"> <BundleSelector
<div className="flex items-center justify-between mb-4"> baseProduct={product}
<span className="text-sm uppercase tracking-[0.1em] font-medium"> bundleProducts={bundleProducts}
{t("size")} selectedVariantId={selectedBundleVariantId || baseVariant?.id || null}
</span> 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>
<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"> <div className="flex items-center gap-4 mb-8">
@@ -425,9 +463,9 @@ export default function ProductDetail({ product, relatedProducts, locale = "sr"
</ExpandableSection> </ExpandableSection>
</div> </div>
{variant?.sku && ( {selectedVariant?.sku && (
<p className="text-xs text-[#999999] mt-8"> <p className="text-xs text-[#999999] mt-8">
SKU: {variant.sku} SKU: {selectedVariant.sku}
</p> </p>
)} )}
</motion.div> </motion.div>

View File

@@ -312,6 +312,13 @@
"urgency2": "In den Warenkörben von 2,5K Menschen - kaufen Sie, bevor es weg ist!", "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!" "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": { "Newsletter": {
"stayConnected": "Bleiben Sie verbunden", "stayConnected": "Bleiben Sie verbunden",
"joinCommunity": "Werden Sie Teil unserer Gemeinschaft", "joinCommunity": "Werden Sie Teil unserer Gemeinschaft",

View File

@@ -341,6 +341,13 @@
"urgency2": "In the carts of 2.5K people - buy before its gone!", "urgency2": "In the carts of 2.5K people - buy before its gone!",
"urgency3": "7,562 people viewed this product in the last 24 hours!" "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": { "Newsletter": {
"stayConnected": "Stay Connected", "stayConnected": "Stay Connected",
"joinCommunity": "Join Our Community", "joinCommunity": "Join Our Community",

View File

@@ -312,6 +312,13 @@
"urgency2": "Dans les paniers de 2,5K personnes - achetez avant qu'il ne disparaisse!", "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!" "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": { "Newsletter": {
"stayConnected": "Restez Connectés", "stayConnected": "Restez Connectés",
"joinCommunity": "Rejoignez Notre Communauté", "joinCommunity": "Rejoignez Notre Communauté",

View File

@@ -341,6 +341,13 @@
"urgency2": "U korpama 2.5K ljudi - kupi pre nego što nestane!", "urgency2": "U korpama 2.5K ljudi - kupi pre nego što nestane!",
"urgency3": "7.562 osobe su pogledale ovaj proizvod u poslednja 24 sata!" "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": { "Newsletter": {
"stayConnected": "Ostanite povezani", "stayConnected": "Ostanite povezani",
"joinCommunity": "Pridružite se našoj zajednici", "joinCommunity": "Pridružite se našoj zajednici",

View File

@@ -35,6 +35,18 @@ export const PRODUCT_FRAGMENT = gql`
key key
value value
} }
attributes {
attribute {
id
name
slug
}
values {
id
name
slug
}
}
} }
${PRODUCT_VARIANT_FRAGMENT} ${PRODUCT_VARIANT_FRAGMENT}
`; `;

View File

@@ -7,7 +7,7 @@ export { PRODUCT_VARIANT_FRAGMENT, CHECKOUT_LINE_FRAGMENT } from "./fragments/Va
export { CHECKOUT_FRAGMENT, ADDRESS_FRAGMENT } from "./fragments/Checkout"; export { CHECKOUT_FRAGMENT, ADDRESS_FRAGMENT } from "./fragments/Checkout";
// Queries // 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"; export { GET_CHECKOUT, GET_CHECKOUT_BY_ID } from "./queries/Checkout";
// Mutations // Mutations
@@ -34,4 +34,5 @@ export {
formatPrice, formatPrice,
getLocalizedProduct, getLocalizedProduct,
parseDescription, parseDescription,
getBundleProducts,
} from "./products"; } from "./products";

View File

@@ -1,5 +1,5 @@
import { saleorClient } from "./client"; 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"; import type { Product } from "@/types/saleor";
const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel"; const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
@@ -155,3 +155,65 @@ export function getLocalizedProduct(
seoDescription: translation?.seoDescription || product.seoDescription, seoDescription: translation?.seoDescription || product.seoDescription,
}; };
} }
interface ProductsResponse {
products?: {
edges: Array<{ node: Product }>;
};
}
export async function getBundleProducts(
locale: string = "SR",
first: number = 50
): Promise<Product[]> {
try {
const { data } = await saleorClient.query<ProductsResponse>({
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;
}

View File

@@ -49,3 +49,16 @@ export const GET_PRODUCTS_BY_CATEGORY = gql`
} }
${PRODUCT_LIST_ITEM_FRAGMENT} ${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}
`;

View File

@@ -22,12 +22,14 @@ export interface ProductMedia {
} }
export interface ProductAttributeValue { export interface ProductAttributeValue {
id: string;
name: string; name: string;
slug: string; slug: string;
} }
export interface ProductAttribute { export interface ProductAttribute {
attribute: { attribute: {
id: string;
name: string; name: string;
slug: string; slug: string;
}; };
@@ -82,6 +84,7 @@ export interface Product {
key: string; key: string;
value: string; value: string;
}[]; }[];
attributes?: ProductAttribute[];
} }
export interface ProductEdge { export interface ProductEdge {