Merge dev into master: resolve middleware conflict (use dev version with full locale detection)
Some checks failed
Build and Deploy / build (push) Has been cancelled

This commit is contained in:
Unchained
2026-03-24 14:08:17 +02:00
31 changed files with 587 additions and 162 deletions

View File

@@ -1,40 +1,45 @@
import createMiddleware from "next-intl/middleware";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { routing } from "./src/i18n/routing";
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, LOCALE_COOKIE, getPathWithoutLocale, buildLocalePath, isValidLocale } from "@/lib/i18n/locales";
import type { Locale } from "@/lib/i18n/locales";
const oldSerbianPaths = ["", "products", "about", "contact", "checkout"];
const OLD_SERBIAN_PATHS = ["products", "about", "contact", "checkout"];
function detectLocale(cookieLocale: string | undefined, acceptLanguage: string): Locale {
if (cookieLocale && isValidLocale(cookieLocale)) {
return cookieLocale;
}
if (acceptLanguage.includes("en")) {
return "en";
}
return DEFAULT_LOCALE;
}
export default function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
const acceptLanguage = request.headers.get("accept-language") || "";
const isOldSerbianPath = oldSerbianPaths.some((path) => {
if (path === "") {
return pathname === "/";
}
return pathname === `/${path}` || pathname.startsWith(`/${path}/`);
});
const hasLocalePrefix = routing.locales.some(
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`)
);
if (isOldSerbianPath && !hasLocalePrefix) {
const newPathname = pathname === "/"
? "/sr"
: `/sr${pathname}`;
if (pathname === "/" || pathname === "") {
const locale = detectLocale(cookieLocale, acceptLanguage);
const url = request.nextUrl.clone();
url.pathname = newPathname;
url.pathname = buildLocalePath(locale, "/");
return NextResponse.redirect(url, 301);
}
const intlMiddleware = createMiddleware({
...routing,
});
const isOldSerbianPath = OLD_SERBIAN_PATHS.some(
(path) => pathname === `/${path}` || pathname.startsWith(`/${path}/`)
);
return intlMiddleware(request);
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 = {

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
public/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -1,6 +1,8 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
interface AboutPageProps {
params: Promise<{ locale: string }>;
@@ -8,19 +10,19 @@ interface AboutPageProps {
export async function generateMetadata({ params }: AboutPageProps) {
const { locale } = await params;
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const metadata = getPageMetadata(validLocale as Locale);
return {
title: locale === "sr"
? "O nama - ManoonOils"
: "About - ManoonOils",
description: locale === "sr"
? "Saznajte više o ManoonOils - naša priča, misija i posvećenost prirodnoj lepoti."
: "Learn more about ManoonOils - our story, mission, and commitment to natural beauty.",
title: metadata.about.title,
description: metadata.about.description,
};
}
export default async function AboutPage({ params }: AboutPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const metadata = getPageMetadata(validLocale as Locale);
setRequestLocale(validLocale);
const t = await getTranslations("About");
return (
@@ -43,7 +45,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
<div className="relative h-[400px] md:h-[500px] overflow-hidden">
<img
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop"
alt={locale === "sr" ? "Proizvodnja prirodnih ulja" : "Natural oils production"}
alt={metadata.productionAlt}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/20" />

View File

@@ -212,7 +212,7 @@ export default function CheckoutPage() {
return (
<>
<Header />
<Header locale={locale} />
<main className="min-h-screen">
<section className="pt-[120px] pb-20 px-4">
<div className="max-w-7xl mx-auto">
@@ -377,7 +377,7 @@ export default function CheckoutPage() {
</main>
<div className="pt-16">
<Footer />
<Footer locale={locale} />
</div>
</>
);

View File

@@ -1,9 +1,35 @@
import { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server";
import { routing } from "@/i18n/routing";
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
return SUPPORTED_LOCALES.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`;
const languages: Record<string, string> = {};
for (const loc of SUPPORTED_LOCALES) {
const prefix = loc === DEFAULT_LOCALE ? "" : `/${loc}`;
languages[loc] = `${baseUrl}${prefix}`;
}
return {
alternates: {
canonical: `${baseUrl}${localePrefix}`,
languages,
},
};
}
export default async function LocaleLayout({

View File

@@ -10,30 +10,32 @@ import ProductReviews from "@/components/product/ProductReviews";
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
import ProblemSection from "@/components/home/ProblemSection";
import HowItWorks from "@/components/home/HowItWorks";
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const metadata = getPageMetadata(validLocale as Locale);
setRequestLocale(validLocale);
return {
title: locale === "sr"
? "ManoonOils - Premium prirodna ulja za negu kose i kože"
: "ManoonOils - Premium Natural Oils for Hair & Skin",
description: locale === "sr"
? "Otkrijte našu premium kolekciju prirodnih ulja za negu kose i kože."
: "Discover our premium collection of natural oils for hair and skin care.",
title: metadata.home.title,
description: metadata.home.description,
};
}
export default async function Homepage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
setRequestLocale(validLocale);
const t = await getTranslations("Home");
const tBenefits = await getTranslations("Benefits");
const metadata = getPageMetadata(validLocale as Locale);
const productLocale = locale === "sr" ? "SR" : "EN";
const saleorLocale = getSaleorLocale(validLocale as Locale);
let products: any[] = [];
try {
products = await getProducts(productLocale);
products = await getProducts(saleorLocale);
} catch (e) {
console.log("Failed to fetch products during build");
}
@@ -41,7 +43,7 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
const featuredProducts = products?.slice(0, 4) || [];
const hasProducts = featuredProducts.length > 0;
const basePath = `/${locale}`;
const basePath = `/${validLocale}`;
return (
<>
@@ -78,7 +80,7 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
{featuredProducts.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} locale={productLocale} />
<ProductCard key={product.id} product={product} index={index} locale={locale} />
))}
</div>
@@ -122,7 +124,7 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
<div className="relative aspect-[4/3] bg-[#e8f0f5] rounded-lg overflow-hidden">
<img
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=800&auto=format&fit=crop"
alt={locale === "sr" ? "Proizvodnja prirodnih ulja" : "Natural oils production"}
alt={metadata.home.productionAlt}
className="w-full h-full object-cover"
/>
</div>
@@ -204,11 +206,11 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
<input
type="email"
placeholder={t("emailPlaceholder")}
className="flex-1 min-w-0 px-5 h-14 bg-white/10 border border-white/20 border-b-0 sm:border-b border-r-0 sm:border-r border-white/20 text-white placeholder:text-white/50 focus:border-white focus:outline-none transition-colors text-base text-center sm:text-left rounded-t sm:rounded-l sm:rounded-tr-none"
className="flex-1 min-w-0 px-5 !h-16 bg-white/10 border border-white/20 border-b-0 sm:border-b border-r-0 sm:border-r border-white/20 text-white placeholder:text-white/50 focus:border-white focus:outline-none transition-colors text-base text-center sm:text-left rounded-t sm:rounded-l sm:rounded-tr-none"
/>
<button
type="submit"
className="px-8 h-14 bg-white text-black text-sm uppercase tracking-[0.1em] font-medium hover:bg-white/90 transition-colors whitespace-nowrap flex-shrink-0 rounded-b sm:rounded-r sm:rounded-bl-none"
className="px-8 bg-white text-black text-sm uppercase tracking-[0.1em] font-medium hover:bg-white/90 transition-colors whitespace-nowrap flex-shrink-0 rounded-b sm:rounded-r sm:rounded-bl-none"
>
{t("subscribe")}
</button>

View File

@@ -5,6 +5,8 @@ import Footer from "@/components/layout/Footer";
import ProductDetail from "@/components/product/ProductDetail";
import type { Product } from "@/types/saleor";
import { routing } from "@/i18n/routing";
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
interface ProductPageProps {
params: Promise<{ locale: string; slug: string }>;
@@ -16,8 +18,8 @@ export async function generateStaticParams() {
for (const locale of locales) {
try {
const productLocale = locale === "sr" ? "SR" : "EN";
const products = await getProducts(productLocale, 100);
const saleorLocale = locale === "sr" ? "SR" : "EN";
const products = await getProducts(saleorLocale, 100);
products.forEach((product: Product) => {
params.push({ locale, slug: product.slug });
});
@@ -29,16 +31,18 @@ export async function generateStaticParams() {
export async function generateMetadata({ params }: ProductPageProps) {
const { locale, slug } = await params;
const productLocale = locale === "sr" ? "SR" : "EN";
const product = await getProductBySlug(slug, productLocale);
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const metadata = getPageMetadata(validLocale as Locale);
const saleorLocale = validLocale === "sr" ? "SR" : "EN";
const product = await getProductBySlug(slug, saleorLocale);
if (!product) {
return {
title: locale === "sr" ? "Proizvod nije pronađen" : "Product not found",
title: metadata.productNotFound,
};
}
const localized = getLocalizedProduct(product, productLocale);
const localized = getLocalizedProduct(product, saleorLocale);
return {
title: localized.name,
@@ -48,12 +52,13 @@ export async function generateMetadata({ params }: ProductPageProps) {
export default async function ProductPage({ params }: ProductPageProps) {
const { locale, slug } = await params;
setRequestLocale(locale);
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
setRequestLocale(validLocale);
const t = await getTranslations("Product");
const productLocale = locale === "sr" ? "SR" : "EN";
const product = await getProductBySlug(slug, productLocale);
const saleorLocale = getSaleorLocale(validLocale as Locale);
const product = await getProductBySlug(slug, saleorLocale);
const basePath = locale === "sr" ? "" : `/${locale}`;
const basePath = `/${validLocale}`;
if (!product) {
return (
@@ -95,10 +100,10 @@ export default async function ProductPage({ params }: ProductPageProps) {
<ProductDetail
product={product}
relatedProducts={relatedProducts}
locale={productLocale}
locale={locale}
/>
</main>
<Footer />
<Footer locale={locale} />
</>
);
}

View File

@@ -4,6 +4,8 @@ import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer";
import ProductCard from "@/components/product/ProductCard";
import { ChevronDown } from "lucide-react";
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
interface ProductsPageProps {
params: Promise<{ locale: string }>;
@@ -11,22 +13,21 @@ interface ProductsPageProps {
export async function generateMetadata({ params }: ProductsPageProps) {
const { locale } = await params;
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const metadata = getPageMetadata(validLocale as Locale);
return {
title: locale === "sr"
? "Proizvodi - ManoonOils"
: "Products - ManoonOils",
description: locale === "sr"
? "Pregledajte našu kolekciju premium prirodnih ulja za negu kose i kože."
: "Browse our collection of premium natural oils for hair and skin care.",
title: metadata.products.title,
description: metadata.products.description,
};
}
export default async function ProductsPage({ params }: ProductsPageProps) {
const { locale } = await params;
setRequestLocale(locale);
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
setRequestLocale(validLocale);
const t = await getTranslations("Products");
const productLocale = locale === "sr" ? "SR" : "EN";
const products = await getProducts(productLocale);
const saleorLocale = getSaleorLocale(validLocale as Locale);
const products = await getProducts(saleorLocale);
return (
<>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

BIN
src/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -1,6 +1,9 @@
import "./globals.css";
import type { Metadata, Viewport } from "next";
import ErrorBoundary from "@/components/providers/ErrorBoundary";
import { SUPPORTED_LOCALES } from "@/lib/i18n/locales";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
export const metadata: Metadata = {
title: {
@@ -9,6 +12,12 @@ export const metadata: Metadata = {
},
description: "Discover our premium collection of natural oils for hair and skin care.",
robots: "index, follow",
alternates: {
canonical: baseUrl,
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? baseUrl : `${baseUrl}/${locale}`])
),
},
openGraph: {
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
description: "Discover our premium collection of natural oils for hair and skin care.",

View File

@@ -1,5 +1,18 @@
import { redirect } from "next/navigation";
import { cookies, headers } from "next/headers";
export default function RootPage() {
redirect("/sr");
export default async function RootPage() {
const headersList = await headers();
const cookieStore = await cookies();
const acceptLanguage = headersList.get("accept-language") || "";
const cookieLocale = cookieStore.get("NEXT_LOCALE")?.value;
let locale = "sr";
if (cookieLocale && ["sr", "en", "de", "fr"].includes(cookieLocale)) {
locale = cookieLocale;
} else if (acceptLanguage.includes("en")) {
locale = "en";
}
redirect(`/${locale}`);
}

View File

@@ -1,48 +1,107 @@
import { MetadataRoute } from "next";
import { getProducts } from "@/lib/saleor";
import { SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/locales";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
interface SitemapEntry {
url: string;
lastModified: Date;
changeFrequency: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
priority: number;
alternates?: {
languages?: Record<string, string>;
};
}
export default async function sitemap(): Promise<SitemapEntry[]> {
let products: any[] = [];
try {
products = await getProducts("SR", 100);
} catch (e) {
console.log('Failed to fetch products for sitemap during build');
console.log("Failed to fetch products for sitemap during build");
}
const productUrls = products.map((product) => ({
url: `${baseUrl}/products/${product.slug}`,
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: 0.8,
}));
return [
const staticPages: SitemapEntry[] = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: "daily",
priority: 1,
alternates: {
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? baseUrl : `${baseUrl}/${locale}`])
),
},
},
{
url: `${baseUrl}/products`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.9,
alternates: {
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/products` : `${baseUrl}/${locale}/products`])
),
},
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.6,
alternates: {
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/about` : `${baseUrl}/${locale}/about`])
),
},
},
{
url: `${baseUrl}/contact`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.6,
alternates: {
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/contact` : `${baseUrl}/${locale}/contact`])
),
},
},
{
url: `${baseUrl}/checkout`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.5,
alternates: {
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/checkout` : `${baseUrl}/${locale}/checkout`])
),
},
},
...productUrls,
];
const productUrls: SitemapEntry[] = [];
for (const product of products) {
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];
}

View File

@@ -54,7 +54,7 @@ export default function NewsletterSection() {
onChange={(e) => setEmail(e.target.value)}
placeholder={t("emailPlaceholder")}
required
className="flex-1 px-4 py-3 border border-[#1A1A1A]/10 rounded-[4px] text-sm focus:outline-none focus:border-[#1A1A1A]/30 transition-colors"
className="flex-1 px-4 py-4 h-14 border border-[#1A1A1A]/10 rounded-[4px] text-base focus:outline-none focus:border-[#1A1A1A]/30 transition-colors"
/>
<button
type="submit"

View File

@@ -9,7 +9,7 @@ interface ProductShowcaseProps {
locale?: string;
}
export default function ProductShowcase({ products, locale = "SR" }: ProductShowcaseProps) {
export default function ProductShowcase({ products, locale = "sr" }: ProductShowcaseProps) {
if (!products || products.length === 0) return null;
return (

View File

@@ -140,6 +140,18 @@ export default function Footer({ locale = "sr" }: FooterProps) {
&copy; {currentYear} ManoonOils. {t("allRights")}
</p>
<p className="text-xs text-[#999999]">
<strong>{t("madeWith")} by{" "}
<a
href="https://nodecrew.me"
target="_blank"
rel="noopener noreferrer"
className="text-[#c9a962] hover:text-[#b8944f] transition-colors"
>
Nodecrew
</a></strong>
</p>
<div className="flex items-center gap-3">
<span className="text-xs text-[#999999]">{t("weAccept")}</span>
<div className="flex items-center gap-2">

View File

@@ -1,13 +1,16 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslations } from "next-intl";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { User, ShoppingBag, Menu, X } from "lucide-react";
import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
import CartDrawer from "@/components/cart/CartDrawer";
import { SUPPORTED_LOCALES, LOCALE_COOKIE, LOCALE_CONFIG, isValidLocale, getPathWithoutLocale, buildLocalePath } from "@/lib/i18n/locales";
import type { Locale } from "@/lib/i18n/locales";
interface HeaderProps {
locale?: string;
@@ -15,12 +18,41 @@ interface HeaderProps {
export default function Header({ locale = "sr" }: HeaderProps) {
const t = useTranslations("Header");
const pathname = usePathname();
const dropdownRef = useRef<HTMLDivElement>(null);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [langDropdownOpen, setLangDropdownOpen] = useState(false);
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
const itemCount = getLineCount();
const localePath = `/${locale}`;
const currentLocale = isValidLocale(locale) ? LOCALE_CONFIG[locale] : LOCALE_CONFIG.sr;
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setLangDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const switchLocale = (newLocale: string) => {
if (newLocale === locale) {
setLangDropdownOpen(false);
return;
}
if (!isValidLocale(newLocale)) {
setLangDropdownOpen(false);
return;
}
document.cookie = `${LOCALE_COOKIE}=${newLocale}; path=/; max-age=31536000`;
const pathWithoutLocale = getPathWithoutLocale(pathname);
const newPath = buildLocalePath(newLocale as Locale, pathWithoutLocale);
window.location.replace(newPath);
setLangDropdownOpen(false);
};
useEffect(() => {
initCheckout();
@@ -46,9 +78,9 @@ export default function Header({ locale = "sr" }: HeaderProps) {
}, [mobileMenuOpen]);
const navLinks = [
{ href: `${localePath}/products`, label: t("products") },
{ href: `${localePath}/about`, label: t("about") },
{ href: `${localePath}/contact`, label: t("contact") },
{ href: `/${locale}/products`, label: t("products") },
{ href: `/${locale}/about`, label: t("about") },
{ href: `/${locale}/contact`, label: t("contact") },
];
return (
@@ -82,7 +114,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
))}
</nav>
<Link href={localePath || "/"} className="flex-shrink-0 lg:absolute lg:left-1/2 lg:-translate-x-1/2">
<Link href={`/${locale}`} className="flex-shrink-0 lg:absolute lg:left-1/2 lg:-translate-x-1/2">
<Image
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
alt="ManoonOils"
@@ -94,6 +126,40 @@ export default function Header({ locale = "sr" }: HeaderProps) {
</Link>
<div className="flex items-center gap-1">
<div ref={dropdownRef} className="relative">
<button
className="p-2 hover:bg-black/5 rounded-full transition-colors flex items-center gap-1"
onClick={() => setLangDropdownOpen(!langDropdownOpen)}
aria-label="Select language"
>
<Globe className="w-5 h-5" strokeWidth={1.5} />
<span className="text-sm">{currentLocale.flag}</span>
</button>
<AnimatePresence>
{langDropdownOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute right-0 top-full mt-1 bg-white border border-[#e5e5e5] shadow-lg rounded-md overflow-hidden z-50"
>
{SUPPORTED_LOCALES.map((loc) => (
<button
key={loc}
onClick={() => switchLocale(loc)}
className={`flex items-center gap-2 px-4 py-2 text-sm hover:bg-black/5 transition-colors w-full text-left ${
loc === locale ? "bg-black/5 font-medium" : ""
}`}
>
<span>{LOCALE_CONFIG[loc].flag}</span>
<span>{LOCALE_CONFIG[loc].label}</span>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<button
className="p-2 hover:bg-black/5 rounded-full transition-colors hidden sm:block"
aria-label={t("account")}
@@ -128,7 +194,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
>
<div className="container h-full flex flex-col">
<div className="flex items-center justify-between h-[72px]">
<Link href={localePath || "/"} onClick={() => setMobileMenuOpen(false)}>
<Link href={`/${locale}`} onClick={() => setMobileMenuOpen(false)}>
<Image
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
alt="ManoonOils"

View File

@@ -7,7 +7,7 @@ interface ProductBenefitsProps {
locale?: string;
}
export default function ProductBenefits({ locale = "SR" }: ProductBenefitsProps) {
export default function ProductBenefits({ locale = "sr" }: ProductBenefitsProps) {
const t = useTranslations("ProductBenefits");
const benefits = [

View File

@@ -6,6 +6,7 @@ import Link from "next/link";
import { useTranslations } from "next-intl";
import type { Product } from "@/types/saleor";
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
import { isValidLocale, getSaleorLocale } from "@/lib/i18n/locales";
interface ProductCardProps {
product: Product;
@@ -13,13 +14,13 @@ interface ProductCardProps {
locale?: string;
}
export default function ProductCard({ product, index = 0, locale = "SR" }: ProductCardProps) {
export default function ProductCard({ product, index = 0, locale = "sr" }: ProductCardProps) {
const t = useTranslations("ProductCard");
const image = getProductImage(product);
const price = getProductPrice(product);
const localized = getLocalizedProduct(product, locale);
const saleorLocale = isValidLocale(locale) ? getSaleorLocale(locale) : "SR";
const localized = getLocalizedProduct(product, saleorLocale);
const isAvailable = product.variants?.[0]?.quantityAvailable > 0;
const urlLocale = locale === "SR" ? "sr" : "en";
return (
<motion.div
@@ -28,7 +29,7 @@ export default function ProductCard({ product, index = 0, locale = "SR" }: Produ
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Link href={`/${urlLocale}/products/${localized.slug}`} className="group block">
<Link href={`/${locale}/products/${localized.slug}`} className="group block">
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
{image ? (
<img

View File

@@ -9,6 +9,8 @@ import { useTranslations } from "next-intl";
import type { Product } from "@/types/saleor";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { getProductPrice, getProductPriceAmount, getLocalizedProduct, formatPrice } from "@/lib/saleor";
import { getTranslatedShortDescription, getTranslatedBenefits } from "@/lib/i18n/productText";
import { isValidLocale } from "@/lib/i18n/locales";
import ProductCard from "@/components/product/ProductCard";
import ProductBenefits from "@/components/product/ProductBenefits";
import ProductReviews from "@/components/product/ProductReviews";
@@ -86,7 +88,7 @@ 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, locale = "sr" }: ProductDetailProps) {
const t = useTranslations("ProductDetail");
const tProduct = useTranslations("Product");
const [selectedImage, setSelectedImage] = useState(0);
@@ -94,6 +96,7 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
const [isAdding, setIsAdding] = useState(false);
const [urgencyIndex, setUrgencyIndex] = useState(0);
const { addLine, openCart } = useSaleorCheckoutStore();
const validLocale = isValidLocale(locale) ? locale : "sr";
useEffect(() => {
const interval = setInterval(() => {
@@ -132,15 +135,10 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
const priceAmount = getProductPriceAmount(product);
const originalPrice = priceAmount > 0 ? formatPrice(Math.round(priceAmount * 1.30)) : null;
const shortDescription = localized.description
? localized.description.split('.')[0] + '.'
: locale === "EN" ? "Premium natural oil for your beauty routine." : "Premium prirodno ulje za vašu rutinu lepote.";
const shortDescription = getTranslatedShortDescription(localized.description, validLocale);
const benefits = product.metadata?.find(m => m.key === "benefits")?.value?.split(',') || [
locale === "EN" ? "Natural" : "Prirodno",
locale === "EN" ? "Organic" : "Organsko",
locale === "EN" ? "Cruelty-free" : "Bez okrutnosti",
];
const metadataBenefits = product.metadata?.find(m => m.key === "benefits")?.value?.split(',');
const benefits = getTranslatedBenefits(metadataBenefits, validLocale);
return (
<>
@@ -148,7 +146,7 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
<div className="border-b border-[#e5e5e5] pt-[72px] lg:pt-[72px]">
<div className="container py-5">
<nav className="flex items-center gap-2 text-sm">
<Link href={`/${locale.toLowerCase()}`} className="text-[#666666] hover:text-black transition-colors">
<Link href={`/${validLocale}`} className="text-[#666666] hover:text-black transition-colors">
{t("home")}
</Link>
<span className="text-[#999999]">/</span>
@@ -469,7 +467,7 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
</section>
)}
<ProductBenefits locale={locale} />
<ProductBenefits key={locale} locale={locale} />
<TrustBadges />

View File

@@ -3,6 +3,7 @@
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation";
import { SUPPORTED_LOCALES, isValidLocale } from "@/lib/i18n/locales";
export default async function LocaleProvider({
children,
@@ -11,8 +12,7 @@ export default async function LocaleProvider({
children: React.ReactNode;
locale: string;
}) {
const locales = ["en", "sr"];
if (!locales.includes(locale)) notFound();
if (!isValidLocale(locale)) notFound();
const messages = await getMessages();

View File

@@ -68,18 +68,27 @@
},
"Cart": {
"title": "Ihr Warenkorb",
"yourCart": "Ihr Warenkorb",
"closeCart": "Warenkorb schließen",
"empty": "Ihr Warenkorb ist leer",
"emptyDesc": "Es sieht so aus, als hätten Sie noch nichts in Ihren Warenkorb gelegt.",
"continueShopping": "Weiter einkaufen",
"startShopping": "Einkauf starten",
"checkout": "Zur Kasse",
"subtotal": "Zwischensumme",
"shipping": "Versand",
"shippingCalc": "Wird an der Kasse berechnet",
"calculatedAtCheckout": "Wird an der Kasse berechnet",
"dismiss": "Schließen",
"yourCartEmpty": "Ihr Warenkorb ist leer",
"looksLikeEmpty": "Es sieht so aus, als hätten Sie nichts hinzugefügt",
"total": "Gesamt",
"freeShipping": "Kostenloser Versand bei Bestellungen über {amount}",
"remove": "Entfernen",
"removeItem": "Artikel entfernen",
"processes": "Wird bearbeitet...",
"cartEmpty": "Ihr Warenkorb ist leer"
"cartEmpty": "Ihr Warenkorb ist leer",
"qty": "Menge"
},
"About": {
"title": "Über uns",
@@ -156,7 +165,8 @@
"help": "Hilfe",
"contactUs": "Kontaktieren Sie uns",
"brandDescription": "Premium natürliche Öle für Haar- und Hautpflege. Handgefertigt mit Liebe unter Verwendung traditioneller Methoden.",
"weAccept": "Wir akzeptieren:"
"weAccept": "Wir akzeptieren:",
"madeWith": "Erstellt mit"
},
"Common": {
"loading": "Laden...",

View File

@@ -132,19 +132,6 @@
"faq4q": "Do you offer wholesale?",
"faq4a": "Yes, we offer wholesale pricing for bulk orders. Please contact us at hello@manoonoils.com for more information."
},
"Footer": {
"quickLinks": "Quick Links",
"customerService": "Customer Service",
"contact": "Contact",
"shipping": "Shipping",
"returns": "Returns",
"faq": "FAQ",
"followUs": "Follow Us",
"newsletter": "Newsletter",
"newsletterDesc": "Subscribe to our newsletter for exclusive offers and updates.",
"copyright": "All rights reserved.",
"allRights": "© {year} ManoonOils."
},
"Common": {
"loading": "Loading...",
"error": "An error occurred",
@@ -321,7 +308,8 @@
"contactUs": "Contact Us",
"brandDescription": "Premium natural oils for hair and skin care. Handcrafted with love using traditional methods.",
"weAccept": "We accept:",
"allRights": "All rights reserved."
"allRights": "All rights reserved.",
"madeWith": "Made with"
},
"ProductCard": {
"noImage": "No image",

View File

@@ -68,18 +68,27 @@
},
"Cart": {
"title": "Votre Panier",
"yourCart": "Votre Panier",
"closeCart": "Fermer le panier",
"empty": "Votre panier est vide",
"emptyDesc": "Il semble que vous n'ayez pas encore ajouté d'articles à votre panier.",
"continueShopping": "Continuer les Achats",
"startShopping": "Commencer vos Achats",
"checkout": "Commander",
"subtotal": "Sous-total",
"shipping": "Livraison",
"shippingCalc": "Calculé à la caisse",
"calculatedAtCheckout": "Calculé à la caisse",
"dismiss": "Fermer",
"yourCartEmpty": "Votre panier est vide",
"looksLikeEmpty": "On dirait que vous n'avez rien ajouté",
"total": "Total",
"freeShipping": "Livraison gratuite sur les commandes de {amount}",
"remove": "Supprimer",
"removeItem": "Supprimer l'article",
"processes": "En cours...",
"cartEmpty": "Votre panier est vide"
"cartEmpty": "Votre panier est vide",
"qty": "Qté"
},
"About": {
"title": "À Propos",
@@ -156,7 +165,8 @@
"help": "Aide",
"contactUs": "Contactez-nous",
"brandDescription": "Huiles naturelles premium pour les soins capillaires et cutanés. Fait main avec amour en utilisant des méthodes traditionnelles.",
"weAccept": "Nous acceptons:"
"weAccept": "Nous acceptons:",
"madeWith": "Fait avec"
},
"Common": {
"loading": "Chargement...",

View File

@@ -132,19 +132,6 @@
"faq4q": "Da li nudite veleprodaju?",
"faq4a": "Da, nudimo veleprodajne cene za narudžbine u velikim količinama. Molimo kontaktirajte nas na hello@manoonoils.com za više informacija."
},
"Footer": {
"quickLinks": "Brze veze",
"customerService": "Korisnička podrška",
"contact": "Kontakt",
"shipping": "Dostava",
"returns": "Povrat",
"faq": "Česta pitanja",
"followUs": "Pratite nas",
"newsletter": "Newsletter",
"newsletterDesc": "Pretplatite se na naš newsletter za ekskluzivne ponude i novosti.",
"copyright": "Sva prava zadržana.",
"allRights": "© {year} ManoonOils."
},
"Common": {
"loading": "Učitavanje...",
"error": "Došlo je do greške",
@@ -321,7 +308,8 @@
"contactUs": "Kontaktirajte nas",
"brandDescription": "Premium prirodna ulja za negu kose i kože. Ručno pravljena sa ljubavlju, korišćenjem tradicionalnih metoda.",
"weAccept": "Prihvatamo:",
"allRights": "Sva prava zadržana."
"allRights": "Sva prava zadržana.",
"madeWith": "Napravljeno sa"
},
"ProductCard": {
"noImage": "Nema slike",

View File

@@ -1,7 +1,8 @@
import { defineRouting } from "next-intl/routing";
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from "@/lib/i18n/locales";
export const routing = defineRouting({
locales: ["sr", "en", "de", "fr"],
defaultLocale: "sr",
locales: SUPPORTED_LOCALES,
defaultLocale: DEFAULT_LOCALE,
localePrefix: "as-needed",
});

37
src/lib/i18n/locales.ts Normal file
View File

@@ -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}`;
}

37
src/lib/i18n/metadata.ts Normal file
View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}