Compare commits

...
Author SHA1 Message Date
Unchained b18ab349b6 fix: revert newsletter form, keep only taller input 2026-03-24 13:43:02 +02:00
Unchained 855215badd fix: update newsletter form layout for taller input 2026-03-24 13:40:08 +02:00
Unchained f40e661bf3 fix: make newsletter input taller without changing button height 2026-03-24 13:38:59 +02:00
Unchained 080a9e4e21 fix: increase homepage newsletter input height to h-16 2026-03-24 13:36:07 +02:00
Unchained 44f4e548c8 fix: make newsletter input taller with h-12 2026-03-24 12:49:27 +02:00
Unchained 5ae79716a3 fix: increase newsletter email input height 2026-03-24 12:48:15 +02:00
Unchained 922978bf80 feat: add ManoonOils logo as app icon and favicon 2026-03-24 12:45:18 +02:00
Unchained 930a9a7614 refactor: eliminate hardcoded locale comparisons for antifragility
Created centralized helpers:
- src/lib/i18n/pageMetadata.ts: All page metadata (titles, descriptions, alt text)
- src/lib/i18n/productText.ts: Product-specific translated text (shortDescription, benefits)
- src/lib/i18n/metadata.ts: Helper functions for locale handling

Updated all pages to use centralized metadata:
- Homepage: Uses getPageMetadata for title, description, productionAlt
- Products page: Uses getPageMetadata
- Product detail: Uses getPageMetadata + getTranslatedShortDescription/getTranslatedBenefits
- About page: Uses getPageMetadata

ProductDetail component now uses:
- getTranslatedShortDescription() instead of locale comparison
- getTranslatedBenefits() instead of locale comparison

All user-facing text now goes through translation files or centralized helpers.
Adding a new language now requires only:
1. Add to SUPPORTED_LOCALES in locales.ts
2. Add LOCALE_CONFIG entry
3. Add entries to pageMetadata.ts and productText.ts
4. Add translation keys to message files
2026-03-24 12:39:38 +02:00
Unchained 3d895f4d7a refactor: improve locale modularity
- Added src/lib/i18n/metadata.ts with helper functions
- Updated [locale]/layout.tsx to use DEFAULT_LOCALE constant
- routing.ts already uses centralized SUPPORTED_LOCALES

Note: For full antifragility, a larger refactor would centralize
all hardcoded locale comparisons for metadata text fallbacks.
Currently adding a new language requires:
1. SUPPORTED_LOCALES in locales.ts
2. LOCALE_CONFIG entry
3. Translation keys in all message files
2026-03-24 12:30:05 +02:00
Unchained ab5b5d9848 feat: add heart emoji and bold to 'Made with' text in footer 2026-03-24 12:23:13 +02:00
Unchained 8a76342b07 chore: remove unused first Footer section from en.json and sr.json 2026-03-24 12:21:33 +02:00
Unchained 95c844ad2b fix: add madeWith to second English Footer section 2026-03-24 12:16:50 +02:00
Unchained 22b0b2c31a fix: Serbian madeWith uses Latin script not Cyrillic 2026-03-24 12:14:25 +02:00
Unchained 5f0ef80fe7 feat: add 'Made with ❤️ by Nodecrew' to footer with translations 2026-03-24 12:13:07 +02:00
Unchained 9a72e46d39 fix: add missing French and German translations for CartDrawer 2026-03-24 12:07:49 +02:00
Unchained 8120f2b908 fix: add missing removeItem translation to French and German 2026-03-24 12:04:17 +02:00
Unchained b7914303ee fix: add missing French and German Cart translations 2026-03-24 12:01:49 +02:00
Unchained c40d91e35b fix: buildLocalePath always includes locale prefix - required by routing 2026-03-24 11:56:54 +02:00
Unchained 5ee3ab6713 fix: revert dynamic matcher - Next.js requires static config 2026-03-24 11:54:22 +02:00
Unchained 03becb6ce7 refactor: make locale handling truly centralized and robust
- Added getPathWithoutLocale() and buildLocalePath() helpers to locales.ts
- Updated Header to use centralized helpers instead of hardcoded regex
- Updated middleware to use SUPPORTED_LOCALES in matcher config
- Updated LocaleProvider to use isValidLocale() instead of hardcoded array

To add a new language now, only update:
1. SUPPORTED_LOCALES in locales.ts
2. LOCALE_CONFIG entry with label, flag, saleorLocale
3. Add translation keys to all message files

All routing now uses centralized constants - no more hardcoded locale lists.
2026-03-24 11:52:22 +02:00
Unchained 0a7c555549 fix: ProductDetail using wrong locale comparisons
- Default locale was "SR" instead of "sr"
- Comparisons used "EN" instead of "en" for shortDescription and benefits
- These hardcoded English strings were being skipped due to wrong comparison
2026-03-24 11:46:05 +02:00
Unchained 74ab98ad2f fix: multiple components using wrong locale for ProductCard links
- homepage page.tsx was passing productLocale (SR/EN) instead of locale (sr/en) to ProductCard
- ProductShowcase default locale was "SR" instead of "sr"
- ProductBenefits default locale was "SR" instead of "sr"

These caused URLs like /en/SR/products/... when clicking products
2026-03-24 11:38:14 +02:00
Unchained ead03bc04f fix: product detail page passing wrong locale to ProductDetail and Footer 2026-03-24 11:34:52 +02:00
Unchained a5cd048a6e refactor: centralize locale constants to prevent breaking changes
Created src/lib/i18n/locales.ts as single source of truth for:
- SUPPORTED_LOCALES array
- LOCALE_COOKIE name
- DEFAULT_LOCALE
- LOCALE_CONFIG (labels, flags, Saleor locale mapping)
- Helper functions (isValidLocale, getSaleorLocale, getLocaleFromPath)

Updated all files to use centralized constants:
- middleware.ts
- Header.tsx
- ProductCard.tsx
- sitemap.ts
- root layout and locale layout
- routing.ts

Benefits:
- Adding new locale only requires updating ONE file (locales.ts)
- No more hardcoded locale lists scattered across codebase
- Cookie name defined in one place
- Type-safe locale validation
2026-03-24 11:27:55 +02:00
Unchained a4e7a07adb feat: add hreflang tags and international sitemap for SEO
- Added hreflang alternates to root layout for all locales (sr, en, de, fr)
- Added hreflang alternates to [locale] layout for all locales
- Updated sitemap to include all locale variants for every page
- Google will now properly index all language versions
2026-03-24 11:22:22 +02:00
Unchained 52b2eac5b5 fix: ensure navLinks use correct locale from prop
The navLinks were using localePath which was derived from locale
but the switchLocale was using pathname directly. This caused
mismatch when switching languages.
2026-03-24 11:11:15 +02:00
Unchained bd95705d72 debug: add console logs to switchLocale 2026-03-24 11:01:28 +02:00
Unchained 75b258330a fix: use window.location for locale switch to ensure URL change 2026-03-24 10:59:32 +02:00
Unchained 4d078677cb fix: change root / redirect from 302 to 301 for SEO 2026-03-24 08:24:59 +02:00
Unchained b488671bc3 fix: language switcher always includes locale prefix in path 2026-03-24 08:17:30 +02:00
Unchained b70d46ff95 fix: checkout page not passing locale prop to Header/Footer 2026-03-24 08:15:45 +02:00
Unchained f95585af58 fix: language switcher path bug causing /en/en/checkout 2026-03-24 08:12:11 +02:00
Unchained a84647db6c feat: add language switcher with cookie persistence and browser detection
Changes:
- Root page.tsx now detects browser language (Accept-Language header)
  and cookie preference to redirect to correct locale (/sr or /en)
- Middleware handles old Serbian URLs (/products, /about, etc.) with
  301 redirects to /sr/* while respecting locale cookie
- Header component now includes language switcher dropdown with
  flag icons (Serbian and English)
- Language selection sets NEXT_LOCALE cookie and persists preference

Behavior:
- User visits / → redirects to /sr or /en based on cookie/browser
- User selects English from dropdown → cookie set, redirects to /en/*
- User visits /products with en cookie → /en/products (301)
- User visits /products with sr/no cookie → /sr/products (301)
2026-03-24 08:09:27 +02:00
Unchained 8244ba161b feat: enable browser language detection for locale routing
- Root / now uses next-intl locale detection to redirect based on
  Accept-Language header (English browser → /en, Serbian → /sr, etc.)
- Old Serbian URLs (/products, /about, etc.) still redirect to /sr/* with 301
- English URLs (/en/*) remain unchanged
2026-03-24 07:42:18 +02:00
Unchained 887cd7c610 feat: add 301 redirects for old Serbian URLs to preserve SEO
Redirect old Serbian URLs (without /sr/ prefix) to new /sr/ URLs:
- / → /sr (301)
- /products → /sr/products (301)
- /about → /sr/about (301)
- /contact → /sr/contact (301)
- /checkout → /sr/checkout (301)

English URLs (/en/*) remain unchanged. This preserves SEO value
as Google treats 301 as permanent redirect passing ~90-99% PageRank.
2026-03-24 07:36:55 +02:00
31 changed files with 598 additions and 141 deletions
+42 -5
View File
@@ -1,9 +1,46 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./src/i18n/routing";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, LOCALE_COOKIE, getPathWithoutLocale, buildLocalePath, isValidLocale } from "@/lib/i18n/locales";
import type { Locale } from "@/lib/i18n/locales";
export default createMiddleware({
...routing,
});
const OLD_SERBIAN_PATHS = ["products", "about", "contact", "checkout"];
function detectLocale(cookieLocale: string | undefined, acceptLanguage: string): Locale {
if (cookieLocale && isValidLocale(cookieLocale)) {
return cookieLocale;
}
if (acceptLanguage.includes("en")) {
return "en";
}
return DEFAULT_LOCALE;
}
export default function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
const acceptLanguage = request.headers.get("accept-language") || "";
if (pathname === "/" || pathname === "") {
const locale = detectLocale(cookieLocale, acceptLanguage);
const url = request.nextUrl.clone();
url.pathname = buildLocalePath(locale, "/");
return NextResponse.redirect(url, 301);
}
const isOldSerbianPath = OLD_SERBIAN_PATHS.some(
(path) => pathname === `/${path}` || pathname.startsWith(`/${path}/`)
);
if (isOldSerbianPath) {
const locale = detectLocale(cookieLocale, acceptLanguage);
const newPath = buildLocalePath(locale, pathname);
const url = request.nextUrl.clone();
url.pathname = newPath;
return NextResponse.redirect(url, 301);
}
return NextResponse.next();
}
export const config = {
matcher: [
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+10 -8
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" />
+2 -2
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>
</>
);
+28 -2
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({
+17 -15
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-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"
/>
<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>
+17 -12
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} />
</>
);
}
+10 -9
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
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

+9
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.",
+15 -2
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}`);
}
+70 -11
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];
}
+1 -1
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-12 border border-[#1A1A1A]/10 rounded-[4px] text-sm focus:outline-none focus:border-[#1A1A1A]/30 transition-colors"
/>
<button
type="submit"
+1 -1
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 (
+12
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">
+74 -8
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"
+1 -1
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 = [
+5 -4
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
+9 -11
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 />
+2 -2
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();
+12 -2
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...",
+2 -14
View File
@@ -132,19 +132,6 @@
"faq4q": "Do you offer wholesale?",
"faq4a": "Yes, we offer wholesale pricing for bulk orders. Please contact us at [email protected] for more information."
},
"Footer": {
"quickLinks": "Quick Links",
"customerService": "Customer Service",
"contact": "Contact",
"shipping": "Shipping",
"returns": "Returns",
"faq": "FAQ",
"followUs": "Follow Us",
"newsletter": "Newsletter",
"newsletterDesc": "Subscribe to our newsletter for exclusive offers and updates.",
"copyright": "All rights reserved.",
"allRights": "© {year} ManoonOils."
},
"Common": {
"loading": "Loading...",
"error": "An error occurred",
@@ -321,7 +308,8 @@
"contactUs": "Contact Us",
"brandDescription": "Premium natural oils for hair and skin care. Handcrafted with love using traditional methods.",
"weAccept": "We accept:",
"allRights": "All rights reserved."
"allRights": "All rights reserved.",
"madeWith": "Made with"
},
"ProductCard": {
"noImage": "No image",
+12 -2
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...",
+2 -14
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 [email protected] za više informacija."
},
"Footer": {
"quickLinks": "Brze veze",
"customerService": "Korisnička podrška",
"contact": "Kontakt",
"shipping": "Dostava",
"returns": "Povrat",
"faq": "Česta pitanja",
"followUs": "Pratite nas",
"newsletter": "Newsletter",
"newsletterDesc": "Pretplatite se na naš newsletter za ekskluzivne ponude i novosti.",
"copyright": "Sva prava zadržana.",
"allRights": "© {year} ManoonOils."
},
"Common": {
"loading": "Učitavanje...",
"error": "Došlo je do greške",
@@ -321,7 +308,8 @@
"contactUs": "Kontaktirajte nas",
"brandDescription": "Premium prirodna ulja za negu kose i kože. Ručno pravljena sa ljubavlju, korišćenjem tradicionalnih metoda.",
"weAccept": "Prihvatamo:",
"allRights": "Sva prava zadržana."
"allRights": "Sva prava zadržana.",
"madeWith": "Napravljeno sa"
},
"ProductCard": {
"noImage": "Nema slike",
+3 -2
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
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
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;
}
+98
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;
}
+57
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;
}