Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00f63c32f8 | ||
|
|
3d8a77dafa | ||
|
|
bfce7dcca0 | ||
|
|
8f780c3585 | ||
|
|
9a61564e3c | ||
|
|
28a6e58dba | ||
|
|
569a3e65fe | ||
|
|
1ba81a1fde | ||
|
|
df95e729fc | ||
|
|
b18ab349b6 | ||
|
|
855215badd | ||
|
|
f40e661bf3 | ||
|
|
080a9e4e21 | ||
|
|
44f4e548c8 | ||
|
|
5ae79716a3 | ||
|
|
922978bf80 | ||
|
|
930a9a7614 | ||
|
|
3d895f4d7a | ||
|
|
ab5b5d9848 | ||
|
|
8a76342b07 | ||
|
|
95c844ad2b | ||
|
|
22b0b2c31a | ||
|
|
5f0ef80fe7 | ||
|
|
9a72e46d39 | ||
|
|
8120f2b908 | ||
|
|
b7914303ee | ||
|
|
c40d91e35b | ||
|
|
5ee3ab6713 | ||
|
|
03becb6ce7 | ||
|
|
0a7c555549 | ||
|
|
74ab98ad2f | ||
|
|
ead03bc04f | ||
|
|
a5cd048a6e | ||
|
|
a4e7a07adb | ||
|
|
52b2eac5b5 | ||
|
|
bd95705d72 | ||
|
|
75b258330a | ||
|
|
4d078677cb | ||
|
|
b488671bc3 | ||
|
|
b70d46ff95 | ||
|
|
f95585af58 | ||
|
|
a84647db6c | ||
|
|
8244ba161b | ||
|
|
887cd7c610 | ||
|
|
513dcb7fea |
+42
-5
@@ -1,9 +1,46 @@
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
import { routing } from "./src/i18n/routing";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, LOCALE_COOKIE, getPathWithoutLocale, buildLocalePath, isValidLocale } from "@/lib/i18n/locales";
|
||||
import type { Locale } from "@/lib/i18n/locales";
|
||||
|
||||
export default createMiddleware({
|
||||
...routing,
|
||||
});
|
||||
const OLD_SERBIAN_PATHS = ["products", "about", "contact", "checkout"];
|
||||
|
||||
function detectLocale(cookieLocale: string | undefined, acceptLanguage: string): Locale {
|
||||
if (cookieLocale && isValidLocale(cookieLocale)) {
|
||||
return cookieLocale;
|
||||
}
|
||||
if (acceptLanguage.includes("en")) {
|
||||
return "en";
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export default function middleware(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname;
|
||||
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
|
||||
const acceptLanguage = request.headers.get("accept-language") || "";
|
||||
|
||||
if (pathname === "/" || pathname === "") {
|
||||
const locale = detectLocale(cookieLocale, acceptLanguage);
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = buildLocalePath(locale, "/");
|
||||
return NextResponse.redirect(url, 301);
|
||||
}
|
||||
|
||||
const isOldSerbianPath = OLD_SERBIAN_PATHS.some(
|
||||
(path) => pathname === `/${path}` || pathname.startsWith(`/${path}/`)
|
||||
);
|
||||
|
||||
if (isOldSerbianPath) {
|
||||
const locale = detectLocale(cookieLocale, acceptLanguage);
|
||||
const newPath = buildLocalePath(locale, pathname);
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = newPath;
|
||||
return NextResponse.redirect(url, 301);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
@@ -1,6 +1,8 @@
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
||||
import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
|
||||
|
||||
interface AboutPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
@@ -8,19 +10,19 @@ interface AboutPageProps {
|
||||
|
||||
export async function generateMetadata({ params }: AboutPageProps) {
|
||||
const { locale } = await params;
|
||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
const metadata = getPageMetadata(validLocale as Locale);
|
||||
return {
|
||||
title: locale === "sr"
|
||||
? "O nama - ManoonOils"
|
||||
: "About - ManoonOils",
|
||||
description: locale === "sr"
|
||||
? "Saznajte više o ManoonOils - naša priča, misija i posvećenost prirodnoj lepoti."
|
||||
: "Learn more about ManoonOils - our story, mission, and commitment to natural beauty.",
|
||||
title: metadata.about.title,
|
||||
description: metadata.about.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AboutPage({ params }: AboutPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
const metadata = getPageMetadata(validLocale as Locale);
|
||||
setRequestLocale(validLocale);
|
||||
const t = await getTranslations("About");
|
||||
|
||||
return (
|
||||
@@ -43,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={locale === "sr" ? "Proizvodnja prirodnih ulja" : "Natural oils production"}
|
||||
alt={metadata.about.productionAlt}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
|
||||
@@ -212,7 +212,7 @@ export default function CheckoutPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Header locale={locale} />
|
||||
<main className="min-h-screen">
|
||||
<section className="pt-[120px] pb-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
@@ -377,7 +377,7 @@ export default function CheckoutPage() {
|
||||
</main>
|
||||
|
||||
<div className="pt-16">
|
||||
<Footer />
|
||||
<Footer locale={locale} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import { Metadata } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
return SUPPORTED_LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`;
|
||||
|
||||
const languages: Record<string, string> = {};
|
||||
for (const loc of SUPPORTED_LOCALES) {
|
||||
const prefix = loc === DEFAULT_LOCALE ? "" : `/${loc}`;
|
||||
languages[loc] = `${baseUrl}${prefix}`;
|
||||
}
|
||||
|
||||
return {
|
||||
alternates: {
|
||||
canonical: `${baseUrl}${localePrefix}`,
|
||||
languages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
|
||||
+20
-17
@@ -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";
|
||||
@@ -10,38 +10,41 @@ import ProductReviews from "@/components/product/ProductReviews";
|
||||
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
||||
import ProblemSection from "@/components/home/ProblemSection";
|
||||
import HowItWorks from "@/components/home/HowItWorks";
|
||||
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
||||
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
const metadata = getPageMetadata(validLocale as Locale);
|
||||
setRequestLocale(validLocale);
|
||||
return {
|
||||
title: locale === "sr"
|
||||
? "ManoonOils - Premium prirodna ulja za negu kose i kože"
|
||||
: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
||||
description: locale === "sr"
|
||||
? "Otkrijte našu premium kolekciju prirodnih ulja za negu kose i kože."
|
||||
: "Discover our premium collection of natural oils for hair and skin care.",
|
||||
title: metadata.home.title,
|
||||
description: metadata.home.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Homepage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
setRequestLocale(validLocale);
|
||||
const t = await getTranslations("Home");
|
||||
const tBenefits = await getTranslations("Benefits");
|
||||
const metadata = getPageMetadata(validLocale as Locale);
|
||||
|
||||
const productLocale = locale === "sr" ? "SR" : "EN";
|
||||
const saleorLocale = getSaleorLocale(validLocale as Locale);
|
||||
let products: any[] = [];
|
||||
try {
|
||||
products = await getProducts(productLocale);
|
||||
products = await getProducts(saleorLocale);
|
||||
} catch (e) {
|
||||
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 = `/${locale}`;
|
||||
const basePath = `/${validLocale}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -78,7 +81,7 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||
{featuredProducts.map((product, index) => (
|
||||
<ProductCard key={product.id} product={product} index={index} locale={productLocale} />
|
||||
<ProductCard key={product.id} product={product} index={index} locale={locale} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -122,7 +125,7 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
|
||||
<div className="relative aspect-[4/3] bg-[#e8f0f5] rounded-lg overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=800&auto=format&fit=crop"
|
||||
alt={locale === "sr" ? "Proizvodnja prirodnih ulja" : "Natural oils production"}
|
||||
alt={metadata.home.productionAlt}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
@@ -204,11 +207,11 @@ 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"
|
||||
className="px-8 h-14 bg-white text-black text-sm uppercase tracking-[0.1em] font-medium hover:bg-white/90 transition-colors whitespace-nowrap flex-shrink-0 rounded-b sm:rounded-r sm:rounded-bl-none"
|
||||
className="px-8 bg-white text-black text-sm uppercase tracking-[0.1em] font-medium hover:bg-white/90 transition-colors whitespace-nowrap flex-shrink-0 rounded-b sm:rounded-r sm:rounded-bl-none"
|
||||
>
|
||||
{t("subscribe")}
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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";
|
||||
import ProductDetail from "@/components/product/ProductDetail";
|
||||
import type { Product } from "@/types/saleor";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
||||
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
|
||||
|
||||
interface ProductPageProps {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
@@ -16,9 +18,10 @@ export async function generateStaticParams() {
|
||||
|
||||
for (const locale of locales) {
|
||||
try {
|
||||
const productLocale = locale === "sr" ? "SR" : "EN";
|
||||
const products = await getProducts(productLocale, 100);
|
||||
products.forEach((product: Product) => {
|
||||
const saleorLocale = locale === "sr" ? "SR" : "EN";
|
||||
const products = await getProducts(saleorLocale, 100);
|
||||
const filteredProducts = filterOutBundles(products);
|
||||
filteredProducts.forEach((product: Product) => {
|
||||
params.push({ locale, slug: product.slug });
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -29,16 +32,18 @@ export async function generateStaticParams() {
|
||||
|
||||
export async function generateMetadata({ params }: ProductPageProps) {
|
||||
const { locale, slug } = await params;
|
||||
const productLocale = locale === "sr" ? "SR" : "EN";
|
||||
const product = await getProductBySlug(slug, productLocale);
|
||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
const metadata = getPageMetadata(validLocale as Locale);
|
||||
const saleorLocale = validLocale === "sr" ? "SR" : "EN";
|
||||
const product = await getProductBySlug(slug, saleorLocale);
|
||||
|
||||
if (!product) {
|
||||
return {
|
||||
title: locale === "sr" ? "Proizvod nije pronađen" : "Product not found",
|
||||
title: metadata.productNotFound,
|
||||
};
|
||||
}
|
||||
|
||||
const localized = getLocalizedProduct(product, productLocale);
|
||||
const localized = getLocalizedProduct(product, saleorLocale);
|
||||
|
||||
return {
|
||||
title: localized.name,
|
||||
@@ -48,12 +53,13 @@ export async function generateMetadata({ params }: ProductPageProps) {
|
||||
|
||||
export default async function ProductPage({ params }: ProductPageProps) {
|
||||
const { locale, slug } = await params;
|
||||
setRequestLocale(locale);
|
||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
setRequestLocale(validLocale);
|
||||
const t = await getTranslations("Product");
|
||||
const productLocale = locale === "sr" ? "SR" : "EN";
|
||||
const product = await getProductBySlug(slug, productLocale);
|
||||
const saleorLocale = getSaleorLocale(validLocale as Locale);
|
||||
const product = await getProductBySlug(slug, saleorLocale);
|
||||
|
||||
const basePath = locale === "sr" ? "" : `/${locale}`;
|
||||
const basePath = `/${validLocale}`;
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
@@ -81,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} />
|
||||
@@ -95,10 +115,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
||||
<ProductDetail
|
||||
product={product}
|
||||
relatedProducts={relatedProducts}
|
||||
locale={productLocale}
|
||||
bundleProducts={bundleProducts}
|
||||
locale={locale}
|
||||
/>
|
||||
</main>
|
||||
<Footer />
|
||||
<Footer locale={locale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
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";
|
||||
import ProductCard from "@/components/product/ProductCard";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
||||
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
|
||||
|
||||
interface ProductsPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
@@ -11,22 +13,23 @@ interface ProductsPageProps {
|
||||
|
||||
export async function generateMetadata({ params }: ProductsPageProps) {
|
||||
const { locale } = await params;
|
||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
const metadata = getPageMetadata(validLocale as Locale);
|
||||
return {
|
||||
title: locale === "sr"
|
||||
? "Proizvodi - ManoonOils"
|
||||
: "Products - ManoonOils",
|
||||
description: locale === "sr"
|
||||
? "Pregledajte našu kolekciju premium prirodnih ulja za negu kose i kože."
|
||||
: "Browse our collection of premium natural oils for hair and skin care.",
|
||||
title: metadata.products.title,
|
||||
description: metadata.products.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
setRequestLocale(validLocale);
|
||||
const t = await getTranslations("Products");
|
||||
const productLocale = locale === "sr" ? "SR" : "EN";
|
||||
const products = await getProducts(productLocale);
|
||||
const saleorLocale = getSaleorLocale(validLocale as Locale);
|
||||
const allProducts = await getProducts(saleorLocale);
|
||||
|
||||
const products = filterOutBundles(allProducts);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -85,7 +88,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||
key={product.id}
|
||||
product={product}
|
||||
index={index}
|
||||
locale={productLocale}
|
||||
locale={validLocale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
@@ -1,6 +1,9 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import ErrorBoundary from "@/components/providers/ErrorBoundary";
|
||||
import { SUPPORTED_LOCALES } from "@/lib/i18n/locales";
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -9,6 +12,12 @@ export const metadata: Metadata = {
|
||||
},
|
||||
description: "Discover our premium collection of natural oils for hair and skin care.",
|
||||
robots: "index, follow",
|
||||
alternates: {
|
||||
canonical: baseUrl,
|
||||
languages: Object.fromEntries(
|
||||
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? baseUrl : `${baseUrl}/${locale}`])
|
||||
),
|
||||
},
|
||||
openGraph: {
|
||||
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
||||
description: "Discover our premium collection of natural oils for hair and skin care.",
|
||||
|
||||
+15
-2
@@ -1,5 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/sr");
|
||||
export default async function RootPage() {
|
||||
const headersList = await headers();
|
||||
const cookieStore = await cookies();
|
||||
const acceptLanguage = headersList.get("accept-language") || "";
|
||||
const cookieLocale = cookieStore.get("NEXT_LOCALE")?.value;
|
||||
|
||||
let locale = "sr";
|
||||
if (cookieLocale && ["sr", "en", "de", "fr"].includes(cookieLocale)) {
|
||||
locale = cookieLocale;
|
||||
} else if (acceptLanguage.includes("en")) {
|
||||
locale = "en";
|
||||
}
|
||||
|
||||
redirect(`/${locale}`);
|
||||
}
|
||||
+73
-12
@@ -1,48 +1,109 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import { getProducts } from "@/lib/saleor";
|
||||
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||
import { SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/locales";
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||
|
||||
interface SitemapEntry {
|
||||
url: string;
|
||||
lastModified: Date;
|
||||
changeFrequency: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
|
||||
priority: number;
|
||||
alternates?: {
|
||||
languages?: Record<string, string>;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<SitemapEntry[]> {
|
||||
let products: any[] = [];
|
||||
try {
|
||||
products = await getProducts("SR", 100);
|
||||
} catch (e) {
|
||||
console.log('Failed to fetch products for sitemap during build');
|
||||
console.log("Failed to fetch products for sitemap during build");
|
||||
}
|
||||
|
||||
const productUrls = products.map((product) => ({
|
||||
url: `${baseUrl}/products/${product.slug}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly" as const,
|
||||
priority: 0.8,
|
||||
}));
|
||||
|
||||
return [
|
||||
const staticPages: SitemapEntry[] = [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 1,
|
||||
alternates: {
|
||||
languages: Object.fromEntries(
|
||||
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? baseUrl : `${baseUrl}/${locale}`])
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/products`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
alternates: {
|
||||
languages: Object.fromEntries(
|
||||
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/products` : `${baseUrl}/${locale}/products`])
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/about`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
alternates: {
|
||||
languages: Object.fromEntries(
|
||||
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/about` : `${baseUrl}/${locale}/about`])
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/contact`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
alternates: {
|
||||
languages: Object.fromEntries(
|
||||
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/contact` : `${baseUrl}/${locale}/contact`])
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/checkout`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.5,
|
||||
alternates: {
|
||||
languages: Object.fromEntries(
|
||||
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/checkout` : `${baseUrl}/${locale}/checkout`])
|
||||
),
|
||||
},
|
||||
},
|
||||
...productUrls,
|
||||
];
|
||||
|
||||
const filteredProducts = filterOutBundles(products);
|
||||
|
||||
const productUrls: SitemapEntry[] = [];
|
||||
|
||||
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}`;
|
||||
hreflangs[locale] = `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const localePrefix = locale === "sr" ? "" : `/${locale}`;
|
||||
productUrls.push({
|
||||
url: `${baseUrl}${localePrefix}/products/${product.slug}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.8,
|
||||
alternates: {
|
||||
languages: hreflangs,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...staticPages, ...productUrls];
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function NewsletterSection() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t("emailPlaceholder")}
|
||||
required
|
||||
className="flex-1 px-4 py-3 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"
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ProductShowcaseProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export default function ProductShowcase({ products, locale = "SR" }: ProductShowcaseProps) {
|
||||
export default function ProductShowcase({ products, locale = "sr" }: ProductShowcaseProps) {
|
||||
if (!products || products.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -140,6 +140,18 @@ export default function Footer({ locale = "sr" }: FooterProps) {
|
||||
© {currentYear} ManoonOils. {t("allRights")}
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-[#999999]">
|
||||
<strong>{t("madeWith")} ❤️ by{" "}
|
||||
<a
|
||||
href="https://nodecrew.me"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#c9a962] hover:text-[#b8944f] transition-colors"
|
||||
>
|
||||
Nodecrew
|
||||
</a></strong>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#999999]">{t("weAccept")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||
import { User, ShoppingBag, Menu, X } from "lucide-react";
|
||||
import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
|
||||
import CartDrawer from "@/components/cart/CartDrawer";
|
||||
import { SUPPORTED_LOCALES, LOCALE_COOKIE, LOCALE_CONFIG, isValidLocale, getPathWithoutLocale, buildLocalePath } from "@/lib/i18n/locales";
|
||||
import type { Locale } from "@/lib/i18n/locales";
|
||||
|
||||
interface HeaderProps {
|
||||
locale?: string;
|
||||
@@ -15,12 +18,41 @@ interface HeaderProps {
|
||||
|
||||
export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
const t = useTranslations("Header");
|
||||
const pathname = usePathname();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [langDropdownOpen, setLangDropdownOpen] = useState(false);
|
||||
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
|
||||
|
||||
const itemCount = getLineCount();
|
||||
const localePath = `/${locale}`;
|
||||
const currentLocale = isValidLocale(locale) ? LOCALE_CONFIG[locale] : LOCALE_CONFIG.sr;
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setLangDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const switchLocale = (newLocale: string) => {
|
||||
if (newLocale === locale) {
|
||||
setLangDropdownOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!isValidLocale(newLocale)) {
|
||||
setLangDropdownOpen(false);
|
||||
return;
|
||||
}
|
||||
document.cookie = `${LOCALE_COOKIE}=${newLocale}; path=/; max-age=31536000`;
|
||||
const pathWithoutLocale = getPathWithoutLocale(pathname);
|
||||
const newPath = buildLocalePath(newLocale as Locale, pathWithoutLocale);
|
||||
window.location.replace(newPath);
|
||||
setLangDropdownOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
initCheckout();
|
||||
@@ -46,9 +78,9 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
const navLinks = [
|
||||
{ href: `${localePath}/products`, label: t("products") },
|
||||
{ href: `${localePath}/about`, label: t("about") },
|
||||
{ href: `${localePath}/contact`, label: t("contact") },
|
||||
{ href: `/${locale}/products`, label: t("products") },
|
||||
{ href: `/${locale}/about`, label: t("about") },
|
||||
{ href: `/${locale}/contact`, label: t("contact") },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -82,7 +114,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<Link href={localePath || "/"} className="flex-shrink-0 lg:absolute lg:left-1/2 lg:-translate-x-1/2">
|
||||
<Link href={`/${locale}`} className="flex-shrink-0 lg:absolute lg:left-1/2 lg:-translate-x-1/2">
|
||||
<Image
|
||||
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
||||
alt="ManoonOils"
|
||||
@@ -94,6 +126,40 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<button
|
||||
className="p-2 hover:bg-black/5 rounded-full transition-colors flex items-center gap-1"
|
||||
onClick={() => setLangDropdownOpen(!langDropdownOpen)}
|
||||
aria-label="Select language"
|
||||
>
|
||||
<Globe className="w-5 h-5" strokeWidth={1.5} />
|
||||
<span className="text-sm">{currentLocale.flag}</span>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{langDropdownOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="absolute right-0 top-full mt-1 bg-white border border-[#e5e5e5] shadow-lg rounded-md overflow-hidden z-50"
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
onClick={() => switchLocale(loc)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm hover:bg-black/5 transition-colors w-full text-left ${
|
||||
loc === locale ? "bg-black/5 font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
<span>{LOCALE_CONFIG[loc].flag}</span>
|
||||
<span>{LOCALE_CONFIG[loc].label}</span>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="p-2 hover:bg-black/5 rounded-full transition-colors hidden sm:block"
|
||||
aria-label={t("account")}
|
||||
@@ -128,7 +194,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
>
|
||||
<div className="container h-full flex flex-col">
|
||||
<div className="flex items-center justify-between h-[72px]">
|
||||
<Link href={localePath || "/"} onClick={() => setMobileMenuOpen(false)}>
|
||||
<Link href={`/${locale}`} onClick={() => setMobileMenuOpen(false)}>
|
||||
<Image
|
||||
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
||||
alt="ManoonOils"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ interface ProductBenefitsProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export default function ProductBenefits({ locale = "SR" }: ProductBenefitsProps) {
|
||||
export default function ProductBenefits({ locale = "sr" }: ProductBenefitsProps) {
|
||||
const t = useTranslations("ProductBenefits");
|
||||
|
||||
const benefits = [
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Product } from "@/types/saleor";
|
||||
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
|
||||
import { isValidLocale, getSaleorLocale } from "@/lib/i18n/locales";
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product;
|
||||
@@ -13,13 +14,13 @@ interface ProductCardProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export default function ProductCard({ product, index = 0, locale = "SR" }: ProductCardProps) {
|
||||
export default function ProductCard({ product, index = 0, locale = "sr" }: ProductCardProps) {
|
||||
const t = useTranslations("ProductCard");
|
||||
const image = getProductImage(product);
|
||||
const price = getProductPrice(product);
|
||||
const localized = getLocalizedProduct(product, locale);
|
||||
const saleorLocale = isValidLocale(locale) ? getSaleorLocale(locale) : "SR";
|
||||
const localized = getLocalizedProduct(product, saleorLocale);
|
||||
const isAvailable = product.variants?.[0]?.quantityAvailable > 0;
|
||||
const urlLocale = locale === "SR" ? "sr" : "en";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -28,7 +29,7 @@ export default function ProductCard({ product, index = 0, locale = "SR" }: Produ
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Link href={`/${urlLocale}/products/${localized.slug}`} className="group block">
|
||||
<Link href={`/${locale}/products/${localized.slug}`} className="group block">
|
||||
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
|
||||
{image ? (
|
||||
<img
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useTranslations } from "next-intl";
|
||||
import type { Product } from "@/types/saleor";
|
||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||
import { getProductPrice, getProductPriceAmount, getLocalizedProduct, formatPrice } from "@/lib/saleor";
|
||||
import { getTranslatedShortDescription, getTranslatedBenefits } from "@/lib/i18n/productText";
|
||||
import { isValidLocale } from "@/lib/i18n/locales";
|
||||
import ProductCard from "@/components/product/ProductCard";
|
||||
import ProductBenefits from "@/components/product/ProductBenefits";
|
||||
import ProductReviews from "@/components/product/ProductReviews";
|
||||
@@ -17,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;
|
||||
}
|
||||
|
||||
@@ -86,14 +90,16 @@ 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";
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
@@ -109,38 +115,58 @@ 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 shortDescription = localized.description
|
||||
? localized.description.split('.')[0] + '.'
|
||||
: locale === "EN" ? "Premium natural oil for your beauty routine." : "Premium prirodno ulje za vašu rutinu lepote.";
|
||||
const isAvailable = (selectedVariant?.quantityAvailable ?? 0) > 0;
|
||||
|
||||
const benefits = product.metadata?.find(m => m.key === "benefits")?.value?.split(',') || [
|
||||
locale === "EN" ? "Natural" : "Prirodno",
|
||||
locale === "EN" ? "Organic" : "Organsko",
|
||||
locale === "EN" ? "Cruelty-free" : "Bez okrutnosti",
|
||||
];
|
||||
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 metadataBenefits = product.metadata?.find(m => m.key === "benefits")?.value?.split(',');
|
||||
const benefits = getTranslatedBenefits(metadataBenefits, validLocale);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -148,7 +174,7 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
|
||||
<div className="border-b border-[#e5e5e5] pt-[72px] lg:pt-[72px]">
|
||||
<div className="container py-5">
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<Link href={`/${locale.toLowerCase()}`} className="text-[#666666] hover:text-black transition-colors">
|
||||
<Link href={`/${validLocale}`} className="text-[#666666] hover:text-black transition-colors">
|
||||
{t("home")}
|
||||
</Link>
|
||||
<span className="text-[#999999]">/</span>
|
||||
@@ -294,7 +320,16 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
|
||||
|
||||
<div className="border-t border-[#e5e5e5] mb-8" />
|
||||
|
||||
{product.variants && product.variants.length > 1 && (
|
||||
{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">
|
||||
@@ -306,7 +341,7 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
|
||||
<button
|
||||
key={v.id}
|
||||
className={`px-5 py-3 text-sm border-2 transition-colors ${
|
||||
v.id === variant?.id
|
||||
v.id === baseVariant?.id
|
||||
? "border-black bg-black text-white"
|
||||
: "border-[#e5e5e5] hover:border-[#999999]"
|
||||
}`}
|
||||
@@ -316,30 +351,9 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
|
||||
))}
|
||||
</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}
|
||||
@@ -427,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>
|
||||
@@ -469,7 +483,7 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
|
||||
</section>
|
||||
)}
|
||||
|
||||
<ProductBenefits locale={locale} />
|
||||
<ProductBenefits key={locale} locale={locale} />
|
||||
|
||||
<TrustBadges />
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SUPPORTED_LOCALES, isValidLocale } from "@/lib/i18n/locales";
|
||||
|
||||
export default async function LocaleProvider({
|
||||
children,
|
||||
@@ -11,8 +12,7 @@ export default async function LocaleProvider({
|
||||
children: React.ReactNode;
|
||||
locale: string;
|
||||
}) {
|
||||
const locales = ["en", "sr"];
|
||||
if (!locales.includes(locale)) notFound();
|
||||
if (!isValidLocale(locale)) notFound();
|
||||
|
||||
const messages = await getMessages();
|
||||
|
||||
|
||||
@@ -68,18 +68,27 @@
|
||||
},
|
||||
"Cart": {
|
||||
"title": "Ihr Warenkorb",
|
||||
"yourCart": "Ihr Warenkorb",
|
||||
"closeCart": "Warenkorb schließen",
|
||||
"empty": "Ihr Warenkorb ist leer",
|
||||
"emptyDesc": "Es sieht so aus, als hätten Sie noch nichts in Ihren Warenkorb gelegt.",
|
||||
"continueShopping": "Weiter einkaufen",
|
||||
"startShopping": "Einkauf starten",
|
||||
"checkout": "Zur Kasse",
|
||||
"subtotal": "Zwischensumme",
|
||||
"shipping": "Versand",
|
||||
"shippingCalc": "Wird an der Kasse berechnet",
|
||||
"calculatedAtCheckout": "Wird an der Kasse berechnet",
|
||||
"dismiss": "Schließen",
|
||||
"yourCartEmpty": "Ihr Warenkorb ist leer",
|
||||
"looksLikeEmpty": "Es sieht so aus, als hätten Sie nichts hinzugefügt",
|
||||
"total": "Gesamt",
|
||||
"freeShipping": "Kostenloser Versand bei Bestellungen über {amount}",
|
||||
"remove": "Entfernen",
|
||||
"removeItem": "Artikel entfernen",
|
||||
"processes": "Wird bearbeitet...",
|
||||
"cartEmpty": "Ihr Warenkorb ist leer"
|
||||
"cartEmpty": "Ihr Warenkorb ist leer",
|
||||
"qty": "Menge"
|
||||
},
|
||||
"About": {
|
||||
"title": "Über uns",
|
||||
@@ -156,7 +165,8 @@
|
||||
"help": "Hilfe",
|
||||
"contactUs": "Kontaktieren Sie uns",
|
||||
"brandDescription": "Premium natürliche Öle für Haar- und Hautpflege. Handgefertigt mit Liebe unter Verwendung traditioneller Methoden.",
|
||||
"weAccept": "Wir akzeptieren:"
|
||||
"weAccept": "Wir akzeptieren:",
|
||||
"madeWith": "Erstellt mit"
|
||||
},
|
||||
"Common": {
|
||||
"loading": "Laden...",
|
||||
@@ -302,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",
|
||||
|
||||
@@ -132,19 +132,6 @@
|
||||
"faq4q": "Do you offer wholesale?",
|
||||
"faq4a": "Yes, we offer wholesale pricing for bulk orders. Please contact us at [email protected] for more information."
|
||||
},
|
||||
"Footer": {
|
||||
"quickLinks": "Quick Links",
|
||||
"customerService": "Customer Service",
|
||||
"contact": "Contact",
|
||||
"shipping": "Shipping",
|
||||
"returns": "Returns",
|
||||
"faq": "FAQ",
|
||||
"followUs": "Follow Us",
|
||||
"newsletter": "Newsletter",
|
||||
"newsletterDesc": "Subscribe to our newsletter for exclusive offers and updates.",
|
||||
"copyright": "All rights reserved.",
|
||||
"allRights": "© {year} ManoonOils."
|
||||
},
|
||||
"Common": {
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred",
|
||||
@@ -321,7 +308,8 @@
|
||||
"contactUs": "Contact Us",
|
||||
"brandDescription": "Premium natural oils for hair and skin care. Handcrafted with love using traditional methods.",
|
||||
"weAccept": "We accept:",
|
||||
"allRights": "All rights reserved."
|
||||
"allRights": "All rights reserved.",
|
||||
"madeWith": "Made with"
|
||||
},
|
||||
"ProductCard": {
|
||||
"noImage": "No image",
|
||||
@@ -353,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",
|
||||
|
||||
@@ -68,18 +68,27 @@
|
||||
},
|
||||
"Cart": {
|
||||
"title": "Votre Panier",
|
||||
"yourCart": "Votre Panier",
|
||||
"closeCart": "Fermer le panier",
|
||||
"empty": "Votre panier est vide",
|
||||
"emptyDesc": "Il semble que vous n'ayez pas encore ajouté d'articles à votre panier.",
|
||||
"continueShopping": "Continuer les Achats",
|
||||
"startShopping": "Commencer vos Achats",
|
||||
"checkout": "Commander",
|
||||
"subtotal": "Sous-total",
|
||||
"shipping": "Livraison",
|
||||
"shippingCalc": "Calculé à la caisse",
|
||||
"calculatedAtCheckout": "Calculé à la caisse",
|
||||
"dismiss": "Fermer",
|
||||
"yourCartEmpty": "Votre panier est vide",
|
||||
"looksLikeEmpty": "On dirait que vous n'avez rien ajouté",
|
||||
"total": "Total",
|
||||
"freeShipping": "Livraison gratuite sur les commandes de {amount}",
|
||||
"remove": "Supprimer",
|
||||
"removeItem": "Supprimer l'article",
|
||||
"processes": "En cours...",
|
||||
"cartEmpty": "Votre panier est vide"
|
||||
"cartEmpty": "Votre panier est vide",
|
||||
"qty": "Qté"
|
||||
},
|
||||
"About": {
|
||||
"title": "À Propos",
|
||||
@@ -156,7 +165,8 @@
|
||||
"help": "Aide",
|
||||
"contactUs": "Contactez-nous",
|
||||
"brandDescription": "Huiles naturelles premium pour les soins capillaires et cutanés. Fait main avec amour en utilisant des méthodes traditionnelles.",
|
||||
"weAccept": "Nous acceptons:"
|
||||
"weAccept": "Nous acceptons:",
|
||||
"madeWith": "Fait avec"
|
||||
},
|
||||
"Common": {
|
||||
"loading": "Chargement...",
|
||||
@@ -302,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é",
|
||||
|
||||
@@ -132,19 +132,6 @@
|
||||
"faq4q": "Da li nudite veleprodaju?",
|
||||
"faq4a": "Da, nudimo veleprodajne cene za narudžbine u velikim količinama. Molimo kontaktirajte nas na [email protected] za više informacija."
|
||||
},
|
||||
"Footer": {
|
||||
"quickLinks": "Brze veze",
|
||||
"customerService": "Korisnička podrška",
|
||||
"contact": "Kontakt",
|
||||
"shipping": "Dostava",
|
||||
"returns": "Povrat",
|
||||
"faq": "Česta pitanja",
|
||||
"followUs": "Pratite nas",
|
||||
"newsletter": "Newsletter",
|
||||
"newsletterDesc": "Pretplatite se na naš newsletter za ekskluzivne ponude i novosti.",
|
||||
"copyright": "Sva prava zadržana.",
|
||||
"allRights": "© {year} ManoonOils."
|
||||
},
|
||||
"Common": {
|
||||
"loading": "Učitavanje...",
|
||||
"error": "Došlo je do greške",
|
||||
@@ -321,7 +308,8 @@
|
||||
"contactUs": "Kontaktirajte nas",
|
||||
"brandDescription": "Premium prirodna ulja za negu kose i kože. Ručno pravljena sa ljubavlju, korišćenjem tradicionalnih metoda.",
|
||||
"weAccept": "Prihvatamo:",
|
||||
"allRights": "Sva prava zadržana."
|
||||
"allRights": "Sva prava zadržana.",
|
||||
"madeWith": "Napravljeno sa"
|
||||
},
|
||||
"ProductCard": {
|
||||
"noImage": "Nema slike",
|
||||
@@ -353,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",
|
||||
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
import { defineRouting } from "next-intl/routing";
|
||||
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from "@/lib/i18n/locales";
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ["sr", "en", "de", "fr"],
|
||||
defaultLocale: "sr",
|
||||
locales: SUPPORTED_LOCALES,
|
||||
defaultLocale: DEFAULT_LOCALE,
|
||||
localePrefix: "as-needed",
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
export const SUPPORTED_LOCALES = ["sr", "en", "de", "fr"] as const;
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: Locale = "sr";
|
||||
|
||||
export const LOCALE_COOKIE = "NEXT_LOCALE";
|
||||
|
||||
export const LOCALE_CONFIG: Record<Locale, { label: string; flag: string; saleorLocale: string }> = {
|
||||
sr: { label: "Srpski", flag: "🇷🇸", saleorLocale: "SR" },
|
||||
en: { label: "English", flag: "🇬🇧", saleorLocale: "EN" },
|
||||
de: { label: "Deutsch", flag: "🇩🇪", saleorLocale: "EN" },
|
||||
fr: { label: "Français", flag: "🇫🇷", saleorLocale: "EN" },
|
||||
};
|
||||
|
||||
export function isValidLocale(locale: string): locale is Locale {
|
||||
return SUPPORTED_LOCALES.includes(locale as Locale);
|
||||
}
|
||||
|
||||
export function getSaleorLocale(locale: Locale): string {
|
||||
return LOCALE_CONFIG[locale].saleorLocale;
|
||||
}
|
||||
|
||||
export function getLocaleFromPath(pathname: string): string {
|
||||
const pattern = SUPPORTED_LOCALES.join("|");
|
||||
const match = pathname.match(new RegExp(`^\\/(${pattern})`));
|
||||
return match ? match[1] : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function getPathWithoutLocale(pathname: string): string {
|
||||
const pattern = SUPPORTED_LOCALES.join("|");
|
||||
return pathname.replace(new RegExp(`^\\/(${pattern})`), "") || "/";
|
||||
}
|
||||
|
||||
export function buildLocalePath(locale: Locale, path: string): string {
|
||||
const pathPart = path === "/" ? "" : path;
|
||||
return `/${locale}${pathPart}`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { DEFAULT_LOCALE, LOCALE_CONFIG, SUPPORTED_LOCALES, type Locale } from "./locales";
|
||||
|
||||
export function getSaleorLocale(locale: Locale): string {
|
||||
return LOCALE_CONFIG[locale].saleorLocale;
|
||||
}
|
||||
|
||||
export function getLocaleLabel(locale: Locale): string {
|
||||
return LOCALE_CONFIG[locale].label;
|
||||
}
|
||||
|
||||
export function isDefaultLocale(locale: string): boolean {
|
||||
return locale === DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function getLocaleFromParams(params: { locale: string }): Locale {
|
||||
const { locale } = params;
|
||||
if (SUPPORTED_LOCALES.includes(locale as Locale)) {
|
||||
return locale as Locale;
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function getProductLocale(locale: Locale): string {
|
||||
return getSaleorLocale(locale);
|
||||
}
|
||||
|
||||
export function buildHreflangAlternates(baseUrl: string): Record<string, string> {
|
||||
const alternates: Record<string, string> = {};
|
||||
for (const loc of SUPPORTED_LOCALES) {
|
||||
if (loc === DEFAULT_LOCALE) {
|
||||
alternates[loc] = baseUrl;
|
||||
} else {
|
||||
alternates[loc] = `${baseUrl}/${loc}`;
|
||||
}
|
||||
}
|
||||
return alternates;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Locale } from "./locales";
|
||||
|
||||
const PAGE_METADATA: Record<Locale, {
|
||||
home: { title: string; description: string; productionAlt: string };
|
||||
products: { title: string; description: string };
|
||||
productNotFound: string;
|
||||
about: { title: string; description: string; productionAlt: string };
|
||||
contact: { title: string; description: string };
|
||||
}> = {
|
||||
sr: {
|
||||
home: {
|
||||
title: "ManoonOils - Premium prirodna ulja za negu kose i kože",
|
||||
description: "Otkrijte našu premium kolekciju prirodnih ulja za negu kose i kože.",
|
||||
productionAlt: "Proizvodnja prirodnih ulja",
|
||||
},
|
||||
products: {
|
||||
title: "Proizvodi - ManoonOils",
|
||||
description: "Pregledajte našu kolekciju premium prirodnih ulja za negu kose i kože.",
|
||||
},
|
||||
productNotFound: "Proizvod nije pronađen",
|
||||
about: {
|
||||
title: "O nama - ManoonOils",
|
||||
description: "Saznajte više o ManoonOils - naša priča, misija i posvećenost prirodnoj lepoti.",
|
||||
productionAlt: "Proizvodnja prirodnih ulja",
|
||||
},
|
||||
contact: {
|
||||
title: "Kontakt - ManoonOils",
|
||||
description: "Kontaktirajte nas za sva pitanja o proizvodima, narudžbinama ili saradnji.",
|
||||
},
|
||||
},
|
||||
en: {
|
||||
home: {
|
||||
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
||||
description: "Discover our premium collection of natural oils for hair and skin care.",
|
||||
productionAlt: "Natural oils production",
|
||||
},
|
||||
products: {
|
||||
title: "Products - ManoonOils",
|
||||
description: "Browse our collection of premium natural oils for hair and skin care.",
|
||||
},
|
||||
productNotFound: "Product not found",
|
||||
about: {
|
||||
title: "About - ManoonOils",
|
||||
description: "Learn more about ManoonOils - our story, mission, and commitment to natural beauty.",
|
||||
productionAlt: "Natural oils production",
|
||||
},
|
||||
contact: {
|
||||
title: "Contact - ManoonOils",
|
||||
description: "Contact us for any questions about products, orders, or collaborations.",
|
||||
},
|
||||
},
|
||||
de: {
|
||||
home: {
|
||||
title: "ManoonOils - Premium natürliche Öle für Haar & Haut",
|
||||
description: "Entdecken Sie unsere Premium-Kollektion natürlicher Öle für Haar- und Hautpflege.",
|
||||
productionAlt: "Natürliche Ölproduktion",
|
||||
},
|
||||
products: {
|
||||
title: "Produkte - ManoonOils",
|
||||
description: "Durchsuchen Sie unsere Kollektion premium natürlicher Öle für Haar- und Hautpflege.",
|
||||
},
|
||||
productNotFound: "Produkt nicht gefunden",
|
||||
about: {
|
||||
title: "Über uns - ManoonOils",
|
||||
description: "Erfahren Sie mehr über ManoonOils und unsere Mission, premium natürliche Produkte anzubieten.",
|
||||
productionAlt: "Natürliche Ölproduktion",
|
||||
},
|
||||
contact: {
|
||||
title: "Kontakt - ManoonOils",
|
||||
description: "Kontaktieren Sie uns für Fragen zu Produkten, Bestellungen oder Zusammenarbeit.",
|
||||
},
|
||||
},
|
||||
fr: {
|
||||
home: {
|
||||
title: "ManoonOils - Huiles Naturelles Premium pour Cheveux & Peau",
|
||||
description: "Découvrez notre collection premium d'huiles naturelles pour les soins capillaires et cutanés.",
|
||||
productionAlt: "Production d'huiles naturelles",
|
||||
},
|
||||
products: {
|
||||
title: "Produits - ManoonOils",
|
||||
description: "Parcourez notre collection d'huiles naturelles premium pour les soins capillaires et cutanés.",
|
||||
},
|
||||
productNotFound: "Produit non trouvé",
|
||||
about: {
|
||||
title: "À propos - ManoonOils",
|
||||
description: "En savoir plus sur ManoonOils et notre mission de fournir des produits naturels premium.",
|
||||
productionAlt: "Production d'huiles naturelles",
|
||||
},
|
||||
contact: {
|
||||
title: "Contact - ManoonOils",
|
||||
description: "Contactez-nous pour toute question sur les produits, commandes ou collaborations.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getPageMetadata(locale: Locale) {
|
||||
return PAGE_METADATA[locale] || PAGE_METADATA.en;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Locale } from "./locales";
|
||||
|
||||
const PRODUCT_TEXT: Record<Locale, {
|
||||
defaultShortDescription: string;
|
||||
defaultBenefits: string[];
|
||||
}> = {
|
||||
sr: {
|
||||
defaultShortDescription: "Premium prirodno ulje za vašu rutinu lepote.",
|
||||
defaultBenefits: ["Prirodno", "Organsko", "Bez okrutnosti"],
|
||||
},
|
||||
en: {
|
||||
defaultShortDescription: "Premium natural oil for your beauty routine.",
|
||||
defaultBenefits: ["Natural", "Organic", "Cruelty-free"],
|
||||
},
|
||||
de: {
|
||||
defaultShortDescription: "Premium natürliches Öl für Ihre Schönheitsroutine.",
|
||||
defaultBenefits: ["Natürlich", "Bio", "Tierversuchsfrei"],
|
||||
},
|
||||
fr: {
|
||||
defaultShortDescription: "Huile naturelle premium pour votre routine beauté.",
|
||||
defaultBenefits: ["Naturel", "Bio", "Sans cruauté"],
|
||||
},
|
||||
};
|
||||
|
||||
export function getProductDefaults(locale: Locale) {
|
||||
return PRODUCT_TEXT[locale] || PRODUCT_TEXT.en;
|
||||
}
|
||||
|
||||
export function getTranslatedBenefits(
|
||||
metadataBenefits: string[] | undefined,
|
||||
locale: Locale
|
||||
): string[] {
|
||||
const defaults = PRODUCT_TEXT[locale] || PRODUCT_TEXT.en;
|
||||
|
||||
if (!metadataBenefits || metadataBenefits.length === 0) {
|
||||
return defaults.defaultBenefits;
|
||||
}
|
||||
|
||||
return metadataBenefits.map((benefit, index) => {
|
||||
const trimmed = benefit.trim();
|
||||
if (!trimmed) {
|
||||
return defaults.defaultBenefits[index] || trimmed;
|
||||
}
|
||||
return trimmed;
|
||||
});
|
||||
}
|
||||
|
||||
export function getTranslatedShortDescription(
|
||||
description: string | undefined,
|
||||
locale: Locale
|
||||
): string {
|
||||
if (description && description.trim()) {
|
||||
return description.split('.')[0] + '.';
|
||||
}
|
||||
const defaults = PRODUCT_TEXT[locale] || PRODUCT_TEXT.en;
|
||||
return defaults.defaultShortDescription;
|
||||
}
|
||||
@@ -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