Compare commits
10 Commits
feature/i1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00f63c32f8 | ||
|
|
3d8a77dafa | ||
|
|
bfce7dcca0 | ||
|
|
8f780c3585 | ||
|
|
9a61564e3c | ||
|
|
28a6e58dba | ||
|
|
569a3e65fe | ||
|
|
1ba81a1fde | ||
|
|
df95e729fc | ||
|
|
513dcb7fea |
@@ -48,4 +48,4 @@ export const config = {
|
||||
"/(sr|en|de|fr)/:path*",
|
||||
"/((?!api|_next|_vercel|.*\\..*).*)",
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -45,7 +45,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
<div className="relative h-[400px] md:h-[500px] overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop"
|
||||
alt={metadata.productionAlt}
|
||||
alt={metadata.about.productionAlt}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getProducts } from "@/lib/saleor";
|
||||
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
@@ -40,7 +40,8 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
|
||||
console.log("Failed to fetch products during build");
|
||||
}
|
||||
|
||||
const featuredProducts = products?.slice(0, 4) || [];
|
||||
const filteredProducts = filterOutBundles(products);
|
||||
const featuredProducts = filteredProducts.slice(0, 4);
|
||||
const hasProducts = featuredProducts.length > 0;
|
||||
|
||||
const basePath = `/${validLocale}`;
|
||||
@@ -206,7 +207,7 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
|
||||
<input
|
||||
type="email"
|
||||
placeholder={t("emailPlaceholder")}
|
||||
className="flex-1 min-w-0 px-5 !h-14 bg-white/10 border border-white/20 border-b-0 sm:border-b border-r-0 sm:border-r border-white/20 text-white placeholder:text-white/50 focus:border-white focus:outline-none transition-colors text-base text-center sm:text-left rounded-t sm:rounded-l sm:rounded-tr-none"
|
||||
className="flex-1 min-w-0 px-5 !h-16 bg-white/10 border border-white/20 border-b-0 sm:border-b border-r-0 sm:border-r border-white/20 text-white placeholder:text-white/50 focus:border-white focus:outline-none transition-colors text-base text-center sm:text-left rounded-t sm:rounded-l sm:rounded-tr-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getProductBySlug, getProducts, getLocalizedProduct } from "@/lib/saleor";
|
||||
import { getProductBySlug, getProducts, getLocalizedProduct, getBundleProducts, filterOutBundles } from "@/lib/saleor";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
@@ -20,7 +20,8 @@ export async function generateStaticParams() {
|
||||
try {
|
||||
const saleorLocale = locale === "sr" ? "SR" : "EN";
|
||||
const products = await getProducts(saleorLocale, 100);
|
||||
products.forEach((product: Product) => {
|
||||
const filteredProducts = filterOutBundles(products);
|
||||
filteredProducts.forEach((product: Product) => {
|
||||
params.push({ locale, slug: product.slug });
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -86,13 +87,27 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
}
|
||||
|
||||
let relatedProducts: Product[] = [];
|
||||
let bundleProducts: Product[] = [];
|
||||
try {
|
||||
const allProducts = await getProducts(productLocale, 8);
|
||||
relatedProducts = allProducts
|
||||
const allProducts = await getProducts(saleorLocale, 50);
|
||||
relatedProducts = filterOutBundles(allProducts)
|
||||
.filter((p: Product) => p.id !== product.id)
|
||||
.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 || bundleAttr.values.length === 0) return false;
|
||||
return bundleAttr.values.some((val) => {
|
||||
return val.name === product.name || p.name.includes(product.name.split(" - ")[0]);
|
||||
});
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header locale={locale} />
|
||||
@@ -100,6 +115,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<ProductDetail
|
||||
product={product}
|
||||
relatedProducts={relatedProducts}
|
||||
bundleProducts={bundleProducts}
|
||||
locale={locale}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getProducts } from "@/lib/saleor";
|
||||
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
@@ -27,7 +27,9 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
setRequestLocale(validLocale);
|
||||
const t = await getTranslations("Products");
|
||||
const saleorLocale = getSaleorLocale(validLocale as Locale);
|
||||
const products = await getProducts(saleorLocale);
|
||||
const allProducts = await getProducts(saleorLocale);
|
||||
|
||||
const products = filterOutBundles(allProducts);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -86,7 +88,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
key={product.id}
|
||||
product={product}
|
||||
index={index}
|
||||
locale={productLocale}
|
||||
locale={validLocale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import { getProducts } from "@/lib/saleor";
|
||||
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||
import { SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/locales";
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||
@@ -80,9 +80,11 @@ export default async function sitemap(): Promise<SitemapEntry[]> {
|
||||
},
|
||||
];
|
||||
|
||||
const filteredProducts = filterOutBundles(products);
|
||||
|
||||
const productUrls: SitemapEntry[] = [];
|
||||
|
||||
for (const product of products) {
|
||||
for (const product of filteredProducts) {
|
||||
const hreflangs: Record<string, string> = {};
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const path = locale === "sr" ? `/products/${product.slug}` : `/${locale}/products/${product.slug}`;
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function NewsletterSection() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t("emailPlaceholder")}
|
||||
required
|
||||
className="flex-1 px-4 py-4 h-12 border border-[#1A1A1A]/10 rounded-[4px] text-sm focus:outline-none focus:border-[#1A1A1A]/30 transition-colors"
|
||||
className="flex-1 px-4 py-4 h-14 border border-[#1A1A1A]/10 rounded-[4px] text-base focus:outline-none focus:border-[#1A1A1A]/30 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
163
src/components/product/BundleSelector.tsx
Normal file
163
src/components/product/BundleSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,52 +320,40 @@ 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">
|
||||
<span className="text-sm uppercase tracking-[0.1em] font-medium w-16">
|
||||
{t("qty")}
|
||||
</span>
|
||||
<div className="flex items-center border-2 border-[#1a1a1a]">
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
className="w-12 h-12 flex items-center justify-center hover:bg-[#f8f9fa] transition-colors"
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-14 text-center text-base font-medium">{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
className="w-12 h-12 flex items-center justify-center hover:bg-[#f8f9fa] transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAvailable ? (
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
@@ -425,9 +441,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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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é",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -35,6 +35,18 @@ export const PRODUCT_FRAGMENT = gql`
|
||||
key
|
||||
value
|
||||
}
|
||||
attributes {
|
||||
attribute {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
||||
values {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
${PRODUCT_VARIANT_FRAGMENT}
|
||||
`;
|
||||
|
||||
@@ -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,9 @@ export {
|
||||
formatPrice,
|
||||
getLocalizedProduct,
|
||||
parseDescription,
|
||||
getBundleProducts,
|
||||
getBundleProductsForProduct,
|
||||
getProductBundleComponents,
|
||||
isBundleProduct,
|
||||
filterOutBundles,
|
||||
} from "./products";
|
||||
|
||||
@@ -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,69 @@ 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<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;
|
||||
}
|
||||
|
||||
export function filterOutBundles(products: Product[]): Product[] {
|
||||
return products.filter((product) => !isBundleProduct(product));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export const GET_PRODUCTS = gql`
|
||||
products(channel: $channel, first: $first) {
|
||||
edges {
|
||||
node {
|
||||
...ProductListItemFragment
|
||||
...ProductFragment
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
@@ -15,7 +15,7 @@ export const GET_PRODUCTS = gql`
|
||||
}
|
||||
}
|
||||
}
|
||||
${PRODUCT_LIST_ITEM_FRAGMENT}
|
||||
${PRODUCT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const GET_PRODUCT_BY_SLUG = gql`
|
||||
@@ -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}
|
||||
`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user