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
Some checks failed
Build and Deploy / build (push) Has been cancelled
This commit is contained in:
@@ -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 = {
|
||||
@@ -43,4 +48,4 @@ export const config = {
|
||||
"/(sr|en|de|fr)/:path*",
|
||||
"/((?!api|_next|_vercel|.*\\..*).*)",
|
||||
],
|
||||
};
|
||||
};
|
||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
BIN
public/icon.png
Normal file
BIN
public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
@@ -1,6 +1,8 @@
|
||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
|
||||
import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
|
||||
|
||||
interface AboutPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
@@ -8,19 +10,19 @@ interface AboutPageProps {
|
||||
|
||||
export async function generateMetadata({ params }: AboutPageProps) {
|
||||
const { locale } = await params;
|
||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
const metadata = getPageMetadata(validLocale as Locale);
|
||||
return {
|
||||
title: locale === "sr"
|
||||
? "O nama - ManoonOils"
|
||||
: "About - ManoonOils",
|
||||
description: locale === "sr"
|
||||
? "Saznajte više o ManoonOils - naša priča, misija i posvećenost prirodnoj lepoti."
|
||||
: "Learn more about ManoonOils - our story, mission, and commitment to natural beauty.",
|
||||
title: metadata.about.title,
|
||||
description: metadata.about.description,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AboutPage({ params }: AboutPageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
const metadata = getPageMetadata(validLocale as Locale);
|
||||
setRequestLocale(validLocale);
|
||||
const t = await getTranslations("About");
|
||||
|
||||
return (
|
||||
@@ -43,7 +45,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
<div className="relative h-[400px] md:h-[500px] overflow-hidden">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop"
|
||||
alt={locale === "sr" ? "Proizvodnja prirodnih ulja" : "Natural oils production"}
|
||||
alt={metadata.productionAlt}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
|
||||
@@ -212,7 +212,7 @@ export default function CheckoutPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Header locale={locale} />
|
||||
<main className="min-h-screen">
|
||||
<section className="pt-[120px] pb-20 px-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
@@ -377,7 +377,7 @@ export default function CheckoutPage() {
|
||||
</main>
|
||||
|
||||
<div className="pt-16">
|
||||
<Footer />
|
||||
<Footer locale={locale} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
import { Metadata } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
return SUPPORTED_LOCALES.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`;
|
||||
|
||||
const languages: Record<string, string> = {};
|
||||
for (const loc of SUPPORTED_LOCALES) {
|
||||
const prefix = loc === DEFAULT_LOCALE ? "" : `/${loc}`;
|
||||
languages[loc] = `${baseUrl}${prefix}`;
|
||||
}
|
||||
|
||||
return {
|
||||
alternates: {
|
||||
canonical: `${baseUrl}${localePrefix}`,
|
||||
languages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
@@ -22,4 +48,4 @@ export default async function LocaleLayout({
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,14 +206,14 @@ 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"
|
||||
>
|
||||
{t("subscribe")}
|
||||
</button>
|
||||
type="submit"
|
||||
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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
BIN
src/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
@@ -1,6 +1,9 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import ErrorBoundary from "@/components/providers/ErrorBoundary";
|
||||
import { SUPPORTED_LOCALES } from "@/lib/i18n/locales";
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -9,6 +12,12 @@ export const metadata: Metadata = {
|
||||
},
|
||||
description: "Discover our premium collection of natural oils for hair and skin care.",
|
||||
robots: "index, follow",
|
||||
alternates: {
|
||||
canonical: baseUrl,
|
||||
languages: Object.fromEntries(
|
||||
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? baseUrl : `${baseUrl}/${locale}`])
|
||||
),
|
||||
},
|
||||
openGraph: {
|
||||
title: "ManoonOils - Premium Natural Oils for Hair & Skin",
|
||||
description: "Discover our premium collection of natural oils for hair and skin care.",
|
||||
@@ -37,4 +46,4 @@ export default async function RootLayout({
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function NewsletterSection() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t("emailPlaceholder")}
|
||||
required
|
||||
className="flex-1 px-4 py-3 border border-[#1A1A1A]/10 rounded-[4px] text-sm focus:outline-none focus:border-[#1A1A1A]/30 transition-colors"
|
||||
className="flex-1 px-4 py-4 h-14 border border-[#1A1A1A]/10 rounded-[4px] text-base focus:outline-none focus:border-[#1A1A1A]/30 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ProductShowcaseProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export default function ProductShowcase({ products, locale = "SR" }: ProductShowcaseProps) {
|
||||
export default function ProductShowcase({ products, locale = "sr" }: ProductShowcaseProps) {
|
||||
if (!products || products.length === 0) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -140,6 +140,18 @@ export default function Footer({ locale = "sr" }: FooterProps) {
|
||||
© {currentYear} ManoonOils. {t("allRights")}
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-[#999999]">
|
||||
<strong>{t("madeWith")} ❤️ by{" "}
|
||||
<a
|
||||
href="https://nodecrew.me"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#c9a962] hover:text-[#b8944f] transition-colors"
|
||||
>
|
||||
Nodecrew
|
||||
</a></strong>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#999999]">{t("weAccept")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||
import { User, ShoppingBag, Menu, X } from "lucide-react";
|
||||
import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
|
||||
import CartDrawer from "@/components/cart/CartDrawer";
|
||||
import { SUPPORTED_LOCALES, LOCALE_COOKIE, LOCALE_CONFIG, isValidLocale, getPathWithoutLocale, buildLocalePath } from "@/lib/i18n/locales";
|
||||
import type { Locale } from "@/lib/i18n/locales";
|
||||
|
||||
interface HeaderProps {
|
||||
locale?: string;
|
||||
@@ -15,12 +18,41 @@ interface HeaderProps {
|
||||
|
||||
export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
const t = useTranslations("Header");
|
||||
const pathname = usePathname();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [langDropdownOpen, setLangDropdownOpen] = useState(false);
|
||||
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
|
||||
|
||||
const itemCount = getLineCount();
|
||||
const localePath = `/${locale}`;
|
||||
const currentLocale = isValidLocale(locale) ? LOCALE_CONFIG[locale] : LOCALE_CONFIG.sr;
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setLangDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const switchLocale = (newLocale: string) => {
|
||||
if (newLocale === locale) {
|
||||
setLangDropdownOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!isValidLocale(newLocale)) {
|
||||
setLangDropdownOpen(false);
|
||||
return;
|
||||
}
|
||||
document.cookie = `${LOCALE_COOKIE}=${newLocale}; path=/; max-age=31536000`;
|
||||
const pathWithoutLocale = getPathWithoutLocale(pathname);
|
||||
const newPath = buildLocalePath(newLocale as Locale, pathWithoutLocale);
|
||||
window.location.replace(newPath);
|
||||
setLangDropdownOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
initCheckout();
|
||||
@@ -46,9 +78,9 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
const navLinks = [
|
||||
{ href: `${localePath}/products`, label: t("products") },
|
||||
{ href: `${localePath}/about`, label: t("about") },
|
||||
{ href: `${localePath}/contact`, label: t("contact") },
|
||||
{ href: `/${locale}/products`, label: t("products") },
|
||||
{ href: `/${locale}/about`, label: t("about") },
|
||||
{ href: `/${locale}/contact`, label: t("contact") },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -82,7 +114,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<Link href={localePath || "/"} className="flex-shrink-0 lg:absolute lg:left-1/2 lg:-translate-x-1/2">
|
||||
<Link href={`/${locale}`} className="flex-shrink-0 lg:absolute lg:left-1/2 lg:-translate-x-1/2">
|
||||
<Image
|
||||
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
||||
alt="ManoonOils"
|
||||
@@ -94,6 +126,40 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<button
|
||||
className="p-2 hover:bg-black/5 rounded-full transition-colors flex items-center gap-1"
|
||||
onClick={() => setLangDropdownOpen(!langDropdownOpen)}
|
||||
aria-label="Select language"
|
||||
>
|
||||
<Globe className="w-5 h-5" strokeWidth={1.5} />
|
||||
<span className="text-sm">{currentLocale.flag}</span>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{langDropdownOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="absolute right-0 top-full mt-1 bg-white border border-[#e5e5e5] shadow-lg rounded-md overflow-hidden z-50"
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
onClick={() => switchLocale(loc)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm hover:bg-black/5 transition-colors w-full text-left ${
|
||||
loc === locale ? "bg-black/5 font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
<span>{LOCALE_CONFIG[loc].flag}</span>
|
||||
<span>{LOCALE_CONFIG[loc].label}</span>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="p-2 hover:bg-black/5 rounded-full transition-colors hidden sm:block"
|
||||
aria-label={t("account")}
|
||||
@@ -128,7 +194,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
>
|
||||
<div className="container h-full flex flex-col">
|
||||
<div className="flex items-center justify-between h-[72px]">
|
||||
<Link href={localePath || "/"} onClick={() => setMobileMenuOpen(false)}>
|
||||
<Link href={`/${locale}`} onClick={() => setMobileMenuOpen(false)}>
|
||||
<Image
|
||||
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
||||
alt="ManoonOils"
|
||||
@@ -193,4 +259,4 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
<CartDrawer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ interface ProductBenefitsProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export default function ProductBenefits({ locale = "SR" }: ProductBenefitsProps) {
|
||||
export default function ProductBenefits({ locale = "sr" }: ProductBenefitsProps) {
|
||||
const t = useTranslations("ProductBenefits");
|
||||
|
||||
const benefits = [
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Product } from "@/types/saleor";
|
||||
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
|
||||
import { isValidLocale, getSaleorLocale } from "@/lib/i18n/locales";
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product;
|
||||
@@ -13,13 +14,13 @@ interface ProductCardProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export default function ProductCard({ product, index = 0, locale = "SR" }: ProductCardProps) {
|
||||
export default function ProductCard({ product, index = 0, locale = "sr" }: ProductCardProps) {
|
||||
const t = useTranslations("ProductCard");
|
||||
const image = getProductImage(product);
|
||||
const price = getProductPrice(product);
|
||||
const localized = getLocalizedProduct(product, locale);
|
||||
const saleorLocale = isValidLocale(locale) ? getSaleorLocale(locale) : "SR";
|
||||
const localized = getLocalizedProduct(product, saleorLocale);
|
||||
const isAvailable = product.variants?.[0]?.quantityAvailable > 0;
|
||||
const urlLocale = locale === "SR" ? "sr" : "en";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -28,7 +29,7 @@ export default function ProductCard({ product, index = 0, locale = "SR" }: Produ
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Link href={`/${urlLocale}/products/${localized.slug}`} className="group block">
|
||||
<Link href={`/${locale}/products/${localized.slug}`} className="group block">
|
||||
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
|
||||
{image ? (
|
||||
<img
|
||||
@@ -75,4 +76,4 @@ export default function ProductCard({ product, index = 0, locale = "SR" }: Produ
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { SUPPORTED_LOCALES, isValidLocale } from "@/lib/i18n/locales";
|
||||
|
||||
export default async function LocaleProvider({
|
||||
children,
|
||||
@@ -11,8 +12,7 @@ export default async function LocaleProvider({
|
||||
children: React.ReactNode;
|
||||
locale: string;
|
||||
}) {
|
||||
const locales = ["en", "sr"];
|
||||
if (!locales.includes(locale)) notFound();
|
||||
if (!isValidLocale(locale)) notFound();
|
||||
|
||||
const messages = await getMessages();
|
||||
|
||||
|
||||
@@ -68,18 +68,27 @@
|
||||
},
|
||||
"Cart": {
|
||||
"title": "Ihr Warenkorb",
|
||||
"yourCart": "Ihr Warenkorb",
|
||||
"closeCart": "Warenkorb schließen",
|
||||
"empty": "Ihr Warenkorb ist leer",
|
||||
"emptyDesc": "Es sieht so aus, als hätten Sie noch nichts in Ihren Warenkorb gelegt.",
|
||||
"continueShopping": "Weiter einkaufen",
|
||||
"startShopping": "Einkauf starten",
|
||||
"checkout": "Zur Kasse",
|
||||
"subtotal": "Zwischensumme",
|
||||
"shipping": "Versand",
|
||||
"shippingCalc": "Wird an der Kasse berechnet",
|
||||
"calculatedAtCheckout": "Wird an der Kasse berechnet",
|
||||
"dismiss": "Schließen",
|
||||
"yourCartEmpty": "Ihr Warenkorb ist leer",
|
||||
"looksLikeEmpty": "Es sieht so aus, als hätten Sie nichts hinzugefügt",
|
||||
"total": "Gesamt",
|
||||
"freeShipping": "Kostenloser Versand bei Bestellungen über {amount}",
|
||||
"remove": "Entfernen",
|
||||
"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...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
37
src/lib/i18n/locales.ts
Normal 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
37
src/lib/i18n/metadata.ts
Normal 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;
|
||||
}
|
||||
98
src/lib/i18n/pageMetadata.ts
Normal file
98
src/lib/i18n/pageMetadata.ts
Normal 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;
|
||||
}
|
||||
57
src/lib/i18n/productText.ts
Normal file
57
src/lib/i18n/productText.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user