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 { NextResponse } from "next/server";
|
||||||
import { routing } from "./src/i18n/routing";
|
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({
|
const OLD_SERBIAN_PATHS = ["products", "about", "contact", "checkout"];
|
||||||
...routing,
|
|
||||||
});
|
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 = {
|
export const config = {
|
||||||
matcher: [
|
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 { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
import Header from "@/components/layout/Header";
|
import Header from "@/components/layout/Header";
|
||||||
import Footer from "@/components/layout/Footer";
|
import Footer from "@/components/layout/Footer";
|
||||||
|
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
||||||
|
import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
interface AboutPageProps {
|
interface AboutPageProps {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
@@ -8,19 +10,19 @@ interface AboutPageProps {
|
|||||||
|
|
||||||
export async function generateMetadata({ params }: AboutPageProps) {
|
export async function generateMetadata({ params }: AboutPageProps) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
const metadata = getPageMetadata(validLocale as Locale);
|
||||||
return {
|
return {
|
||||||
title: locale === "sr"
|
title: metadata.about.title,
|
||||||
? "O nama - ManoonOils"
|
description: metadata.about.description,
|
||||||
: "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.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AboutPage({ params }: AboutPageProps) {
|
export default async function AboutPage({ params }: AboutPageProps) {
|
||||||
const { locale } = await params;
|
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");
|
const t = await getTranslations("About");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +45,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
<div className="relative h-[400px] md:h-[500px] overflow-hidden">
|
<div className="relative h-[400px] md:h-[500px] overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop"
|
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"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/20" />
|
<div className="absolute inset-0 bg-black/20" />
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ export default function CheckoutPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header locale={locale} />
|
||||||
<main className="min-h-screen">
|
<main className="min-h-screen">
|
||||||
<section className="pt-[120px] pb-20 px-4">
|
<section className="pt-[120px] pb-20 px-4">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
@@ -377,7 +377,7 @@ export default function CheckoutPage() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div className="pt-16">
|
<div className="pt-16">
|
||||||
<Footer />
|
<Footer locale={locale} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,35 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
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() {
|
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({
|
export default async function LocaleLayout({
|
||||||
|
|||||||
+24
-21
@@ -1,4 +1,4 @@
|
|||||||
import { getProducts } from "@/lib/saleor";
|
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
import Header from "@/components/layout/Header";
|
import Header from "@/components/layout/Header";
|
||||||
import Footer from "@/components/layout/Footer";
|
import Footer from "@/components/layout/Footer";
|
||||||
@@ -10,38 +10,41 @@ import ProductReviews from "@/components/product/ProductReviews";
|
|||||||
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
||||||
import ProblemSection from "@/components/home/ProblemSection";
|
import ProblemSection from "@/components/home/ProblemSection";
|
||||||
import HowItWorks from "@/components/home/HowItWorks";
|
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 }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
setRequestLocale(locale);
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
const metadata = getPageMetadata(validLocale as Locale);
|
||||||
|
setRequestLocale(validLocale);
|
||||||
return {
|
return {
|
||||||
title: locale === "sr"
|
title: metadata.home.title,
|
||||||
? "ManoonOils - Premium prirodna ulja za negu kose i kože"
|
description: metadata.home.description,
|
||||||
: "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.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function Homepage({ params }: { params: Promise<{ locale: string }> }) {
|
export default async function Homepage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
setRequestLocale(locale);
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
setRequestLocale(validLocale);
|
||||||
const t = await getTranslations("Home");
|
const t = await getTranslations("Home");
|
||||||
const tBenefits = await getTranslations("Benefits");
|
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[] = [];
|
let products: any[] = [];
|
||||||
try {
|
try {
|
||||||
products = await getProducts(productLocale);
|
products = await getProducts(saleorLocale);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("Failed to fetch products during build");
|
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 hasProducts = featuredProducts.length > 0;
|
||||||
|
|
||||||
const basePath = `/${locale}`;
|
const basePath = `/${validLocale}`;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||||
{featuredProducts.map((product, index) => (
|
{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>
|
</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">
|
<div className="relative aspect-[4/3] bg-[#e8f0f5] rounded-lg overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=800&auto=format&fit=crop"
|
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"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,14 +207,14 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
|
|||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder={t("emailPlaceholder")}
|
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
|
<button
|
||||||
type="submit"
|
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")}
|
{t("subscribe")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
import Header from "@/components/layout/Header";
|
import Header from "@/components/layout/Header";
|
||||||
import Footer from "@/components/layout/Footer";
|
import Footer from "@/components/layout/Footer";
|
||||||
import ProductDetail from "@/components/product/ProductDetail";
|
import ProductDetail from "@/components/product/ProductDetail";
|
||||||
import type { Product } from "@/types/saleor";
|
import type { Product } from "@/types/saleor";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
|
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
||||||
|
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
interface ProductPageProps {
|
interface ProductPageProps {
|
||||||
params: Promise<{ locale: string; slug: string }>;
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
@@ -16,9 +18,10 @@ export async function generateStaticParams() {
|
|||||||
|
|
||||||
for (const locale of locales) {
|
for (const locale of locales) {
|
||||||
try {
|
try {
|
||||||
const productLocale = locale === "sr" ? "SR" : "EN";
|
const saleorLocale = locale === "sr" ? "SR" : "EN";
|
||||||
const products = await getProducts(productLocale, 100);
|
const products = await getProducts(saleorLocale, 100);
|
||||||
products.forEach((product: Product) => {
|
const filteredProducts = filterOutBundles(products);
|
||||||
|
filteredProducts.forEach((product: Product) => {
|
||||||
params.push({ locale, slug: product.slug });
|
params.push({ locale, slug: product.slug });
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -29,16 +32,18 @@ export async function generateStaticParams() {
|
|||||||
|
|
||||||
export async function generateMetadata({ params }: ProductPageProps) {
|
export async function generateMetadata({ params }: ProductPageProps) {
|
||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
const productLocale = locale === "sr" ? "SR" : "EN";
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
const product = await getProductBySlug(slug, productLocale);
|
const metadata = getPageMetadata(validLocale as Locale);
|
||||||
|
const saleorLocale = validLocale === "sr" ? "SR" : "EN";
|
||||||
|
const product = await getProductBySlug(slug, saleorLocale);
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return {
|
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 {
|
return {
|
||||||
title: localized.name,
|
title: localized.name,
|
||||||
@@ -48,12 +53,13 @@ export async function generateMetadata({ params }: ProductPageProps) {
|
|||||||
|
|
||||||
export default async function ProductPage({ params }: ProductPageProps) {
|
export default async function ProductPage({ params }: ProductPageProps) {
|
||||||
const { locale, slug } = await params;
|
const { locale, slug } = await params;
|
||||||
setRequestLocale(locale);
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
setRequestLocale(validLocale);
|
||||||
const t = await getTranslations("Product");
|
const t = await getTranslations("Product");
|
||||||
const productLocale = locale === "sr" ? "SR" : "EN";
|
const saleorLocale = getSaleorLocale(validLocale as Locale);
|
||||||
const product = await getProductBySlug(slug, productLocale);
|
const product = await getProductBySlug(slug, saleorLocale);
|
||||||
|
|
||||||
const basePath = locale === "sr" ? "" : `/${locale}`;
|
const basePath = `/${validLocale}`;
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return (
|
return (
|
||||||
@@ -81,13 +87,27 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let relatedProducts: Product[] = [];
|
let relatedProducts: Product[] = [];
|
||||||
|
let bundleProducts: Product[] = [];
|
||||||
try {
|
try {
|
||||||
const allProducts = await getProducts(productLocale, 8);
|
const allProducts = await getProducts(saleorLocale, 50);
|
||||||
relatedProducts = allProducts
|
relatedProducts = filterOutBundles(allProducts)
|
||||||
.filter((p: Product) => p.id !== product.id)
|
.filter((p: Product) => p.id !== product.id)
|
||||||
.slice(0, 4);
|
.slice(0, 4);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allBundleProducts = await getBundleProducts(saleorLocale, 50);
|
||||||
|
bundleProducts = allBundleProducts.filter((p) => {
|
||||||
|
const bundleAttr = p.attributes?.find(
|
||||||
|
(attr) => attr.attribute.slug === "bundle-items"
|
||||||
|
);
|
||||||
|
if (!bundleAttr || 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header locale={locale} />
|
<Header locale={locale} />
|
||||||
@@ -95,10 +115,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<ProductDetail
|
<ProductDetail
|
||||||
product={product}
|
product={product}
|
||||||
relatedProducts={relatedProducts}
|
relatedProducts={relatedProducts}
|
||||||
locale={productLocale}
|
bundleProducts={bundleProducts}
|
||||||
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</main>
|
</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 { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
import Header from "@/components/layout/Header";
|
import Header from "@/components/layout/Header";
|
||||||
import Footer from "@/components/layout/Footer";
|
import Footer from "@/components/layout/Footer";
|
||||||
import ProductCard from "@/components/product/ProductCard";
|
import ProductCard from "@/components/product/ProductCard";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
|
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
||||||
|
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
interface ProductsPageProps {
|
interface ProductsPageProps {
|
||||||
params: Promise<{ locale: string }>;
|
params: Promise<{ locale: string }>;
|
||||||
@@ -11,22 +13,23 @@ interface ProductsPageProps {
|
|||||||
|
|
||||||
export async function generateMetadata({ params }: ProductsPageProps) {
|
export async function generateMetadata({ params }: ProductsPageProps) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
const metadata = getPageMetadata(validLocale as Locale);
|
||||||
return {
|
return {
|
||||||
title: locale === "sr"
|
title: metadata.products.title,
|
||||||
? "Proizvodi - ManoonOils"
|
description: metadata.products.description,
|
||||||
: "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.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProductsPage({ params }: ProductsPageProps) {
|
export default async function ProductsPage({ params }: ProductsPageProps) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
setRequestLocale(locale);
|
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||||
|
setRequestLocale(validLocale);
|
||||||
const t = await getTranslations("Products");
|
const t = await getTranslations("Products");
|
||||||
const productLocale = locale === "sr" ? "SR" : "EN";
|
const saleorLocale = getSaleorLocale(validLocale as Locale);
|
||||||
const products = await getProducts(productLocale);
|
const allProducts = await getProducts(saleorLocale);
|
||||||
|
|
||||||
|
const products = filterOutBundles(allProducts);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -85,7 +88,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
key={product.id}
|
key={product.id}
|
||||||
product={product}
|
product={product}
|
||||||
index={index}
|
index={index}
|
||||||
locale={productLocale}
|
locale={validLocale}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 "./globals.css";
|
||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import ErrorBoundary from "@/components/providers/ErrorBoundary";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
title: {
|
||||||
@@ -9,6 +12,12 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
description: "Discover our premium collection of natural oils for hair and skin care.",
|
description: "Discover our premium collection of natural oils for hair and skin care.",
|
||||||
robots: "index, follow",
|
robots: "index, follow",
|
||||||
|
alternates: {
|
||||||
|
canonical: baseUrl,
|
||||||
|
languages: Object.fromEntries(
|
||||||
|
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? baseUrl : `${baseUrl}/${locale}`])
|
||||||
|
),
|
||||||
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
||||||
description: "Discover our premium collection of natural oils for hair and skin care.",
|
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 { redirect } from "next/navigation";
|
||||||
|
import { cookies, headers } from "next/headers";
|
||||||
|
|
||||||
export default function RootPage() {
|
export default async function RootPage() {
|
||||||
redirect("/sr");
|
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}`);
|
||||||
}
|
}
|
||||||
+74
-13
@@ -1,48 +1,109 @@
|
|||||||
import { MetadataRoute } from "next";
|
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";
|
||||||
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[] = [];
|
let products: any[] = [];
|
||||||
try {
|
try {
|
||||||
products = await getProducts("SR", 100);
|
products = await getProducts("SR", 100);
|
||||||
} catch (e) {
|
} 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) => ({
|
const staticPages: SitemapEntry[] = [
|
||||||
url: `${baseUrl}/products/${product.slug}`,
|
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: "weekly" as const,
|
|
||||||
priority: 0.8,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
{
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 1,
|
priority: 1,
|
||||||
|
alternates: {
|
||||||
|
languages: Object.fromEntries(
|
||||||
|
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? baseUrl : `${baseUrl}/${locale}`])
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/products`,
|
url: `${baseUrl}/products`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 0.9,
|
priority: 0.9,
|
||||||
|
alternates: {
|
||||||
|
languages: Object.fromEntries(
|
||||||
|
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/products` : `${baseUrl}/${locale}/products`])
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/about`,
|
url: `${baseUrl}/about`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
|
alternates: {
|
||||||
|
languages: Object.fromEntries(
|
||||||
|
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/about` : `${baseUrl}/${locale}/about`])
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: `${baseUrl}/contact`,
|
url: `${baseUrl}/contact`,
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "monthly",
|
||||||
priority: 0.6,
|
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)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder={t("emailPlaceholder")}
|
placeholder={t("emailPlaceholder")}
|
||||||
required
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ interface ProductShowcaseProps {
|
|||||||
locale?: string;
|
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;
|
if (!products || products.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -140,6 +140,18 @@ export default function Footer({ locale = "sr" }: FooterProps) {
|
|||||||
© {currentYear} ManoonOils. {t("allRights")}
|
© {currentYear} ManoonOils. {t("allRights")}
|
||||||
</p>
|
</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">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-[#999999]">{t("weAccept")}</span>
|
<span className="text-xs text-[#999999]">{t("weAccept")}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
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 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 {
|
interface HeaderProps {
|
||||||
locale?: string;
|
locale?: string;
|
||||||
@@ -15,12 +18,41 @@ interface HeaderProps {
|
|||||||
|
|
||||||
export default function Header({ locale = "sr" }: HeaderProps) {
|
export default function Header({ locale = "sr" }: HeaderProps) {
|
||||||
const t = useTranslations("Header");
|
const t = useTranslations("Header");
|
||||||
|
const pathname = usePathname();
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
const [langDropdownOpen, setLangDropdownOpen] = useState(false);
|
||||||
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
|
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
|
||||||
|
|
||||||
const itemCount = getLineCount();
|
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(() => {
|
useEffect(() => {
|
||||||
initCheckout();
|
initCheckout();
|
||||||
@@ -46,9 +78,9 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
|||||||
}, [mobileMenuOpen]);
|
}, [mobileMenuOpen]);
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: `${localePath}/products`, label: t("products") },
|
{ href: `/${locale}/products`, label: t("products") },
|
||||||
{ href: `${localePath}/about`, label: t("about") },
|
{ href: `/${locale}/about`, label: t("about") },
|
||||||
{ href: `${localePath}/contact`, label: t("contact") },
|
{ href: `/${locale}/contact`, label: t("contact") },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,7 +114,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</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
|
<Image
|
||||||
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
||||||
alt="ManoonOils"
|
alt="ManoonOils"
|
||||||
@@ -94,6 +126,40 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<button
|
||||||
className="p-2 hover:bg-black/5 rounded-full transition-colors hidden sm:block"
|
className="p-2 hover:bg-black/5 rounded-full transition-colors hidden sm:block"
|
||||||
aria-label={t("account")}
|
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="container h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between h-[72px]">
|
<div className="flex items-center justify-between h-[72px]">
|
||||||
<Link href={localePath || "/"} onClick={() => setMobileMenuOpen(false)}>
|
<Link href={`/${locale}`} onClick={() => setMobileMenuOpen(false)}>
|
||||||
<Image
|
<Image
|
||||||
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
||||||
alt="ManoonOils"
|
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;
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductBenefits({ locale = "SR" }: ProductBenefitsProps) {
|
export default function ProductBenefits({ locale = "sr" }: ProductBenefitsProps) {
|
||||||
const t = useTranslations("ProductBenefits");
|
const t = useTranslations("ProductBenefits");
|
||||||
|
|
||||||
const benefits = [
|
const benefits = [
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { Product } from "@/types/saleor";
|
import type { Product } from "@/types/saleor";
|
||||||
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
|
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
|
||||||
|
import { isValidLocale, getSaleorLocale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
product: Product;
|
product: Product;
|
||||||
@@ -13,13 +14,13 @@ interface ProductCardProps {
|
|||||||
locale?: string;
|
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 t = useTranslations("ProductCard");
|
||||||
const image = getProductImage(product);
|
const image = getProductImage(product);
|
||||||
const price = getProductPrice(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 isAvailable = product.variants?.[0]?.quantityAvailable > 0;
|
||||||
const urlLocale = locale === "SR" ? "sr" : "en";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -28,7 +29,7 @@ export default function ProductCard({ product, index = 0, locale = "SR" }: Produ
|
|||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
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">
|
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
|
||||||
{image ? (
|
{image ? (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useTranslations } from "next-intl";
|
|||||||
import type { Product } from "@/types/saleor";
|
import type { Product } from "@/types/saleor";
|
||||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||||
import { getProductPrice, getProductPriceAmount, getLocalizedProduct, formatPrice } from "@/lib/saleor";
|
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 ProductCard from "@/components/product/ProductCard";
|
||||||
import ProductBenefits from "@/components/product/ProductBenefits";
|
import ProductBenefits from "@/components/product/ProductBenefits";
|
||||||
import ProductReviews from "@/components/product/ProductReviews";
|
import ProductReviews from "@/components/product/ProductReviews";
|
||||||
@@ -17,10 +19,12 @@ import TrustBadges from "@/components/home/TrustBadges";
|
|||||||
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
||||||
import HowItWorks from "@/components/home/HowItWorks";
|
import HowItWorks from "@/components/home/HowItWorks";
|
||||||
import NewsletterSection from "@/components/home/NewsletterSection";
|
import NewsletterSection from "@/components/home/NewsletterSection";
|
||||||
|
import BundleSelector from "@/components/product/BundleSelector";
|
||||||
|
|
||||||
interface ProductDetailProps {
|
interface ProductDetailProps {
|
||||||
product: Product;
|
product: Product;
|
||||||
relatedProducts: Product[];
|
relatedProducts: Product[];
|
||||||
|
bundleProducts?: Product[];
|
||||||
locale?: string;
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 t = useTranslations("ProductDetail");
|
||||||
const tProduct = useTranslations("Product");
|
const tProduct = useTranslations("Product");
|
||||||
const [selectedImage, setSelectedImage] = useState(0);
|
const [selectedImage, setSelectedImage] = useState(0);
|
||||||
const [quantity, setQuantity] = useState(1);
|
const [quantity, setQuantity] = useState(1);
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
||||||
|
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
|
||||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
const { addLine, openCart } = useSaleorCheckoutStore();
|
||||||
|
const validLocale = isValidLocale(locale) ? locale : "sr";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -109,38 +115,58 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
|
|||||||
];
|
];
|
||||||
|
|
||||||
const localized = getLocalizedProduct(product, locale);
|
const localized = getLocalizedProduct(product, locale);
|
||||||
const variant = product.variants?.[0];
|
const baseVariant = product.variants?.[0];
|
||||||
|
const selectedVariantId = selectedBundleVariantId || baseVariant?.id;
|
||||||
|
|
||||||
|
const selectedVariant = selectedVariantId === baseVariant?.id
|
||||||
|
? baseVariant
|
||||||
|
: bundleProducts.find(p => p.variants?.[0]?.id === selectedVariantId)?.variants?.[0];
|
||||||
|
|
||||||
const images = product.media?.length > 0
|
const images = product.media?.length > 0
|
||||||
? product.media.filter(m => m.type === "IMAGE")
|
? product.media.filter(m => m.type === "IMAGE")
|
||||||
: [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" as const }];
|
: [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" as const }];
|
||||||
|
|
||||||
const handleAddToCart = async () => {
|
const handleAddToCart = async () => {
|
||||||
if (!variant?.id) return;
|
if (!selectedVariantId) return;
|
||||||
|
|
||||||
setIsAdding(true);
|
setIsAdding(true);
|
||||||
try {
|
try {
|
||||||
await addLine(variant.id, quantity);
|
await addLine(selectedVariantId, 1);
|
||||||
openCart();
|
openCart();
|
||||||
} finally {
|
} finally {
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAvailable = variant?.quantityAvailable > 0;
|
const handleSelectVariant = (variantId: string, qty: number, price: number) => {
|
||||||
const price = getProductPrice(product);
|
setSelectedBundleVariantId(variantId);
|
||||||
const priceAmount = getProductPriceAmount(product);
|
setQuantity(qty);
|
||||||
const originalPrice = priceAmount > 0 ? formatPrice(Math.round(priceAmount * 1.30)) : null;
|
};
|
||||||
|
|
||||||
const shortDescription = localized.description
|
const isAvailable = (selectedVariant?.quantityAvailable ?? 0) > 0;
|
||||||
? localized.description.split('.')[0] + '.'
|
|
||||||
: locale === "EN" ? "Premium natural oil for your beauty routine." : "Premium prirodno ulje za vašu rutinu lepote.";
|
|
||||||
|
|
||||||
const benefits = product.metadata?.find(m => m.key === "benefits")?.value?.split(',') || [
|
const selectedPrice = selectedVariant?.pricing?.price?.gross?.amount || 0;
|
||||||
locale === "EN" ? "Natural" : "Prirodno",
|
const price = selectedPrice > 0
|
||||||
locale === "EN" ? "Organic" : "Organsko",
|
? new Intl.NumberFormat(validLocale === "en" ? "en-US" : validLocale === "de" ? "de-DE" : validLocale === "fr" ? "fr-FR" : "sr-RS", {
|
||||||
locale === "EN" ? "Cruelty-free" : "Bez okrutnosti",
|
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 (
|
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="border-b border-[#e5e5e5] pt-[72px] lg:pt-[72px]">
|
||||||
<div className="container py-5">
|
<div className="container py-5">
|
||||||
<nav className="flex items-center gap-2 text-sm">
|
<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")}
|
{t("home")}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-[#999999]">/</span>
|
<span className="text-[#999999]">/</span>
|
||||||
@@ -294,52 +320,40 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
|
|||||||
|
|
||||||
<div className="border-t border-[#e5e5e5] mb-8" />
|
<div className="border-t border-[#e5e5e5] mb-8" />
|
||||||
|
|
||||||
{product.variants && product.variants.length > 1 && (
|
{bundleProducts.length > 0 ? (
|
||||||
<div className="mb-8">
|
<BundleSelector
|
||||||
<div className="flex items-center justify-between mb-4">
|
baseProduct={product}
|
||||||
<span className="text-sm uppercase tracking-[0.1em] font-medium">
|
bundleProducts={bundleProducts}
|
||||||
{t("size")}
|
selectedVariantId={selectedBundleVariantId || baseVariant?.id || null}
|
||||||
</span>
|
onSelectVariant={handleSelectVariant}
|
||||||
|
locale={validLocale}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
product.variants && product.variants.length > 1 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm uppercase tracking-[0.1em] font-medium">
|
||||||
|
{t("size")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{product.variants.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
className={`px-5 py-3 text-sm border-2 transition-colors ${
|
||||||
|
v.id === baseVariant?.id
|
||||||
|
? "border-black bg-black text-white"
|
||||||
|
: "border-[#e5e5e5] hover:border-[#999999]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{v.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
)
|
||||||
{product.variants.map((v) => (
|
|
||||||
<button
|
|
||||||
key={v.id}
|
|
||||||
className={`px-5 py-3 text-sm border-2 transition-colors ${
|
|
||||||
v.id === variant?.id
|
|
||||||
? "border-black bg-black text-white"
|
|
||||||
: "border-[#e5e5e5] hover:border-[#999999]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{v.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mb-8">
|
|
||||||
<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 ? (
|
{isAvailable ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleAddToCart}
|
onClick={handleAddToCart}
|
||||||
@@ -427,9 +441,9 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
|
|||||||
</ExpandableSection>
|
</ExpandableSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{variant?.sku && (
|
{selectedVariant?.sku && (
|
||||||
<p className="text-xs text-[#999999] mt-8">
|
<p className="text-xs text-[#999999] mt-8">
|
||||||
SKU: {variant.sku}
|
SKU: {selectedVariant.sku}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -469,7 +483,7 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ProductBenefits locale={locale} />
|
<ProductBenefits key={locale} locale={locale} />
|
||||||
|
|
||||||
<TrustBadges />
|
<TrustBadges />
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages } from "next-intl/server";
|
import { getMessages } from "next-intl/server";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { SUPPORTED_LOCALES, isValidLocale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
export default async function LocaleProvider({
|
export default async function LocaleProvider({
|
||||||
children,
|
children,
|
||||||
@@ -11,8 +12,7 @@ export default async function LocaleProvider({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
locale: string;
|
locale: string;
|
||||||
}) {
|
}) {
|
||||||
const locales = ["en", "sr"];
|
if (!isValidLocale(locale)) notFound();
|
||||||
if (!locales.includes(locale)) notFound();
|
|
||||||
|
|
||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
|
||||||
|
|||||||
@@ -68,18 +68,27 @@
|
|||||||
},
|
},
|
||||||
"Cart": {
|
"Cart": {
|
||||||
"title": "Ihr Warenkorb",
|
"title": "Ihr Warenkorb",
|
||||||
|
"yourCart": "Ihr Warenkorb",
|
||||||
|
"closeCart": "Warenkorb schließen",
|
||||||
"empty": "Ihr Warenkorb ist leer",
|
"empty": "Ihr Warenkorb ist leer",
|
||||||
"emptyDesc": "Es sieht so aus, als hätten Sie noch nichts in Ihren Warenkorb gelegt.",
|
"emptyDesc": "Es sieht so aus, als hätten Sie noch nichts in Ihren Warenkorb gelegt.",
|
||||||
"continueShopping": "Weiter einkaufen",
|
"continueShopping": "Weiter einkaufen",
|
||||||
|
"startShopping": "Einkauf starten",
|
||||||
"checkout": "Zur Kasse",
|
"checkout": "Zur Kasse",
|
||||||
"subtotal": "Zwischensumme",
|
"subtotal": "Zwischensumme",
|
||||||
"shipping": "Versand",
|
"shipping": "Versand",
|
||||||
"shippingCalc": "Wird an der Kasse berechnet",
|
"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",
|
"total": "Gesamt",
|
||||||
"freeShipping": "Kostenloser Versand bei Bestellungen über {amount}",
|
"freeShipping": "Kostenloser Versand bei Bestellungen über {amount}",
|
||||||
"remove": "Entfernen",
|
"remove": "Entfernen",
|
||||||
|
"removeItem": "Artikel entfernen",
|
||||||
"processes": "Wird bearbeitet...",
|
"processes": "Wird bearbeitet...",
|
||||||
"cartEmpty": "Ihr Warenkorb ist leer"
|
"cartEmpty": "Ihr Warenkorb ist leer",
|
||||||
|
"qty": "Menge"
|
||||||
},
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "Über uns",
|
"title": "Über uns",
|
||||||
@@ -156,7 +165,8 @@
|
|||||||
"help": "Hilfe",
|
"help": "Hilfe",
|
||||||
"contactUs": "Kontaktieren Sie uns",
|
"contactUs": "Kontaktieren Sie uns",
|
||||||
"brandDescription": "Premium natürliche Öle für Haar- und Hautpflege. Handgefertigt mit Liebe unter Verwendung traditioneller Methoden.",
|
"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": {
|
"Common": {
|
||||||
"loading": "Laden...",
|
"loading": "Laden...",
|
||||||
@@ -302,6 +312,13 @@
|
|||||||
"urgency2": "In den Warenkörben von 2,5K Menschen - kaufen Sie, bevor es weg ist!",
|
"urgency2": "In den Warenkörben von 2,5K Menschen - kaufen Sie, bevor es weg ist!",
|
||||||
"urgency3": "7.562 Personen haben sich dieses Produkt in den letzten 24 Stunden angesehen!"
|
"urgency3": "7.562 Personen haben sich dieses Produkt in den letzten 24 Stunden angesehen!"
|
||||||
},
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Paket wählen",
|
||||||
|
"singleUnit": "1 Stück",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Spare {amount}",
|
||||||
|
"perUnit": "pro Stück"
|
||||||
|
},
|
||||||
"Newsletter": {
|
"Newsletter": {
|
||||||
"stayConnected": "Bleiben Sie verbunden",
|
"stayConnected": "Bleiben Sie verbunden",
|
||||||
"joinCommunity": "Werden Sie Teil unserer Gemeinschaft",
|
"joinCommunity": "Werden Sie Teil unserer Gemeinschaft",
|
||||||
|
|||||||
@@ -132,19 +132,6 @@
|
|||||||
"faq4q": "Do you offer wholesale?",
|
"faq4q": "Do you offer wholesale?",
|
||||||
"faq4a": "Yes, we offer wholesale pricing for bulk orders. Please contact us at [email protected] for more information."
|
"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": {
|
"Common": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"error": "An error occurred",
|
"error": "An error occurred",
|
||||||
@@ -321,7 +308,8 @@
|
|||||||
"contactUs": "Contact Us",
|
"contactUs": "Contact Us",
|
||||||
"brandDescription": "Premium natural oils for hair and skin care. Handcrafted with love using traditional methods.",
|
"brandDescription": "Premium natural oils for hair and skin care. Handcrafted with love using traditional methods.",
|
||||||
"weAccept": "We accept:",
|
"weAccept": "We accept:",
|
||||||
"allRights": "All rights reserved."
|
"allRights": "All rights reserved.",
|
||||||
|
"madeWith": "Made with"
|
||||||
},
|
},
|
||||||
"ProductCard": {
|
"ProductCard": {
|
||||||
"noImage": "No image",
|
"noImage": "No image",
|
||||||
@@ -353,6 +341,13 @@
|
|||||||
"urgency2": "In the carts of 2.5K people - buy before its gone!",
|
"urgency2": "In the carts of 2.5K people - buy before its gone!",
|
||||||
"urgency3": "7,562 people viewed this product in the last 24 hours!"
|
"urgency3": "7,562 people viewed this product in the last 24 hours!"
|
||||||
},
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Select Package",
|
||||||
|
"singleUnit": "1 Unit",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Save {amount}",
|
||||||
|
"perUnit": "per unit"
|
||||||
|
},
|
||||||
"Newsletter": {
|
"Newsletter": {
|
||||||
"stayConnected": "Stay Connected",
|
"stayConnected": "Stay Connected",
|
||||||
"joinCommunity": "Join Our Community",
|
"joinCommunity": "Join Our Community",
|
||||||
|
|||||||
@@ -68,18 +68,27 @@
|
|||||||
},
|
},
|
||||||
"Cart": {
|
"Cart": {
|
||||||
"title": "Votre Panier",
|
"title": "Votre Panier",
|
||||||
|
"yourCart": "Votre Panier",
|
||||||
|
"closeCart": "Fermer le panier",
|
||||||
"empty": "Votre panier est vide",
|
"empty": "Votre panier est vide",
|
||||||
"emptyDesc": "Il semble que vous n'ayez pas encore ajouté d'articles à votre panier.",
|
"emptyDesc": "Il semble que vous n'ayez pas encore ajouté d'articles à votre panier.",
|
||||||
"continueShopping": "Continuer les Achats",
|
"continueShopping": "Continuer les Achats",
|
||||||
|
"startShopping": "Commencer vos Achats",
|
||||||
"checkout": "Commander",
|
"checkout": "Commander",
|
||||||
"subtotal": "Sous-total",
|
"subtotal": "Sous-total",
|
||||||
"shipping": "Livraison",
|
"shipping": "Livraison",
|
||||||
"shippingCalc": "Calculé à la caisse",
|
"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",
|
"total": "Total",
|
||||||
"freeShipping": "Livraison gratuite sur les commandes de {amount}",
|
"freeShipping": "Livraison gratuite sur les commandes de {amount}",
|
||||||
"remove": "Supprimer",
|
"remove": "Supprimer",
|
||||||
|
"removeItem": "Supprimer l'article",
|
||||||
"processes": "En cours...",
|
"processes": "En cours...",
|
||||||
"cartEmpty": "Votre panier est vide"
|
"cartEmpty": "Votre panier est vide",
|
||||||
|
"qty": "Qté"
|
||||||
},
|
},
|
||||||
"About": {
|
"About": {
|
||||||
"title": "À Propos",
|
"title": "À Propos",
|
||||||
@@ -156,7 +165,8 @@
|
|||||||
"help": "Aide",
|
"help": "Aide",
|
||||||
"contactUs": "Contactez-nous",
|
"contactUs": "Contactez-nous",
|
||||||
"brandDescription": "Huiles naturelles premium pour les soins capillaires et cutanés. Fait main avec amour en utilisant des méthodes traditionnelles.",
|
"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": {
|
"Common": {
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
@@ -302,6 +312,13 @@
|
|||||||
"urgency2": "Dans les paniers de 2,5K personnes - achetez avant qu'il ne disparaisse!",
|
"urgency2": "Dans les paniers de 2,5K personnes - achetez avant qu'il ne disparaisse!",
|
||||||
"urgency3": "7 562 personnes ont vu ce produit ces dernières 24 heures!"
|
"urgency3": "7 562 personnes ont vu ce produit ces dernières 24 heures!"
|
||||||
},
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Choisir le Pack",
|
||||||
|
"singleUnit": "1 Unité",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Économisez {amount}",
|
||||||
|
"perUnit": "par unité"
|
||||||
|
},
|
||||||
"Newsletter": {
|
"Newsletter": {
|
||||||
"stayConnected": "Restez Connectés",
|
"stayConnected": "Restez Connectés",
|
||||||
"joinCommunity": "Rejoignez Notre Communauté",
|
"joinCommunity": "Rejoignez Notre Communauté",
|
||||||
|
|||||||
@@ -132,19 +132,6 @@
|
|||||||
"faq4q": "Da li nudite veleprodaju?",
|
"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."
|
"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": {
|
"Common": {
|
||||||
"loading": "Učitavanje...",
|
"loading": "Učitavanje...",
|
||||||
"error": "Došlo je do greške",
|
"error": "Došlo je do greške",
|
||||||
@@ -321,7 +308,8 @@
|
|||||||
"contactUs": "Kontaktirajte nas",
|
"contactUs": "Kontaktirajte nas",
|
||||||
"brandDescription": "Premium prirodna ulja za negu kose i kože. Ručno pravljena sa ljubavlju, korišćenjem tradicionalnih metoda.",
|
"brandDescription": "Premium prirodna ulja za negu kose i kože. Ručno pravljena sa ljubavlju, korišćenjem tradicionalnih metoda.",
|
||||||
"weAccept": "Prihvatamo:",
|
"weAccept": "Prihvatamo:",
|
||||||
"allRights": "Sva prava zadržana."
|
"allRights": "Sva prava zadržana.",
|
||||||
|
"madeWith": "Napravljeno sa"
|
||||||
},
|
},
|
||||||
"ProductCard": {
|
"ProductCard": {
|
||||||
"noImage": "Nema slike",
|
"noImage": "Nema slike",
|
||||||
@@ -353,6 +341,13 @@
|
|||||||
"urgency2": "U korpama 2.5K ljudi - kupi pre nego što nestane!",
|
"urgency2": "U korpama 2.5K ljudi - kupi pre nego što nestane!",
|
||||||
"urgency3": "7.562 osobe su pogledale ovaj proizvod u poslednja 24 sata!"
|
"urgency3": "7.562 osobe su pogledale ovaj proizvod u poslednja 24 sata!"
|
||||||
},
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Izaberi pakovanje",
|
||||||
|
"singleUnit": "1 komad",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Štedi {amount}",
|
||||||
|
"perUnit": "po komadu"
|
||||||
|
},
|
||||||
"Newsletter": {
|
"Newsletter": {
|
||||||
"stayConnected": "Ostanite povezani",
|
"stayConnected": "Ostanite povezani",
|
||||||
"joinCommunity": "Pridružite se našoj zajednici",
|
"joinCommunity": "Pridružite se našoj zajednici",
|
||||||
|
|||||||
+3
-2
@@ -1,7 +1,8 @@
|
|||||||
import { defineRouting } from "next-intl/routing";
|
import { defineRouting } from "next-intl/routing";
|
||||||
|
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
export const routing = defineRouting({
|
export const routing = defineRouting({
|
||||||
locales: ["sr", "en", "de", "fr"],
|
locales: SUPPORTED_LOCALES,
|
||||||
defaultLocale: "sr",
|
defaultLocale: DEFAULT_LOCALE,
|
||||||
localePrefix: "as-needed",
|
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
|
key
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
attributes {
|
||||||
|
attribute {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
values {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
${PRODUCT_VARIANT_FRAGMENT}
|
${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";
|
export { CHECKOUT_FRAGMENT, ADDRESS_FRAGMENT } from "./fragments/Checkout";
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
export { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_PRODUCTS_BY_CATEGORY } from "./queries/Products";
|
export { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_PRODUCTS_BY_CATEGORY, GET_BUNDLE_PRODUCTS } from "./queries/Products";
|
||||||
export { GET_CHECKOUT, GET_CHECKOUT_BY_ID } from "./queries/Checkout";
|
export { GET_CHECKOUT, GET_CHECKOUT_BY_ID } from "./queries/Checkout";
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
@@ -34,4 +34,9 @@ export {
|
|||||||
formatPrice,
|
formatPrice,
|
||||||
getLocalizedProduct,
|
getLocalizedProduct,
|
||||||
parseDescription,
|
parseDescription,
|
||||||
|
getBundleProducts,
|
||||||
|
getBundleProductsForProduct,
|
||||||
|
getProductBundleComponents,
|
||||||
|
isBundleProduct,
|
||||||
|
filterOutBundles,
|
||||||
} from "./products";
|
} from "./products";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { saleorClient } from "./client";
|
import { saleorClient } from "./client";
|
||||||
import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG } from "./queries/Products";
|
import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_BUNDLE_PRODUCTS } from "./queries/Products";
|
||||||
import type { Product } from "@/types/saleor";
|
import type { Product } from "@/types/saleor";
|
||||||
|
|
||||||
const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
|
const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
|
||||||
@@ -155,3 +155,69 @@ export function getLocalizedProduct(
|
|||||||
seoDescription: translation?.seoDescription || product.seoDescription,
|
seoDescription: translation?.seoDescription || product.seoDescription,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProductsResponse {
|
||||||
|
products?: {
|
||||||
|
edges: Array<{ node: Product }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBundleProducts(
|
||||||
|
locale: string = "SR",
|
||||||
|
first: number = 50
|
||||||
|
): Promise<Product[]> {
|
||||||
|
try {
|
||||||
|
const { data } = await saleorClient.query<ProductsResponse>({
|
||||||
|
query: GET_BUNDLE_PRODUCTS,
|
||||||
|
variables: {
|
||||||
|
channel: CHANNEL,
|
||||||
|
locale: locale.toUpperCase(),
|
||||||
|
first,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return data?.products?.edges.map((edge) => edge.node) || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching bundle products from Saleor:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBundleProductsForProduct(
|
||||||
|
allProducts: Product[],
|
||||||
|
baseProductId: string
|
||||||
|
): Product[] {
|
||||||
|
return allProducts.filter((product) => {
|
||||||
|
const bundleItemsAttr = product.attributes?.find(
|
||||||
|
(attr) => attr.attribute.slug === "bundle-items"
|
||||||
|
);
|
||||||
|
if (!bundleItemsAttr) return false;
|
||||||
|
return bundleItemsAttr.values.some((val) => {
|
||||||
|
const referencedId = Buffer.from(val.slug.split(":")[1] || val.id).toString("base64");
|
||||||
|
const expectedId = `UHJvZHVjdDo${baseProductId.split("UHJvZHVjdDo")[1]}`;
|
||||||
|
return referencedId.includes(baseProductId.split("UHJvZHVjdDo")[1] || "") ||
|
||||||
|
val.slug.includes(baseProductId.split("UHJvZHVjdDo")[1] || "");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProductBundleComponents(product: Product): number | null {
|
||||||
|
const bundleAttr = product.attributes?.find(
|
||||||
|
(attr) => attr.attribute.slug === "bundle-items"
|
||||||
|
);
|
||||||
|
if (!bundleAttr) return null;
|
||||||
|
|
||||||
|
const bundleAttrMatch = product.name.match(/(\d+)x/i);
|
||||||
|
if (bundleAttrMatch) {
|
||||||
|
return parseInt(bundleAttrMatch[1], 10);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBundleProduct(product: Product): boolean {
|
||||||
|
return getProductBundleComponents(product) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
products(channel: $channel, first: $first) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
...ProductListItemFragment
|
...ProductFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pageInfo {
|
pageInfo {
|
||||||
@@ -15,7 +15,7 @@ export const GET_PRODUCTS = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
${PRODUCT_LIST_ITEM_FRAGMENT}
|
${PRODUCT_FRAGMENT}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const GET_PRODUCT_BY_SLUG = gql`
|
export const GET_PRODUCT_BY_SLUG = gql`
|
||||||
@@ -49,3 +49,16 @@ export const GET_PRODUCTS_BY_CATEGORY = gql`
|
|||||||
}
|
}
|
||||||
${PRODUCT_LIST_ITEM_FRAGMENT}
|
${PRODUCT_LIST_ITEM_FRAGMENT}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const GET_BUNDLE_PRODUCTS = gql`
|
||||||
|
query GetBundleProducts($channel: String!, $locale: LanguageCodeEnum!, $first: Int!) {
|
||||||
|
products(channel: $channel, first: $first) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
...ProductFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${PRODUCT_FRAGMENT}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ export interface ProductMedia {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductAttributeValue {
|
export interface ProductAttributeValue {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductAttribute {
|
export interface ProductAttribute {
|
||||||
attribute: {
|
attribute: {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
};
|
||||||
@@ -82,6 +84,7 @@ export interface Product {
|
|||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
}[];
|
}[];
|
||||||
|
attributes?: ProductAttribute[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductEdge {
|
export interface ProductEdge {
|
||||||
|
|||||||
Reference in New Issue
Block a user