Compare commits

..
Author SHA1 Message Date
Unchained 00f63c32f8 fix: use PRODUCT_FRAGMENT to get attributes for bundle detection
Build and Deploy / build (push) Has been cancelled
2026-03-24 20:26:42 +02:00
Unchained 3d8a77dafa refactor: centralize bundle filtering with filterOutBundles helper
Build and Deploy / build (push) Has been cancelled
2026-03-24 20:18:06 +02:00
Unchained bfce7dcca0 fix: filter bundles from homepage, sitemap, and static params
Build and Deploy / build (push) Has been cancelled
2026-03-24 20:14:42 +02:00
Unchained 8f780c3585 fix: bundle UI improvements - remove QTY selector, filter bundles from similar products
Build and Deploy / build (push) Has been cancelled
2026-03-24 18:50:39 +02:00
Unchained 9a61564e3c feat: add bundle feature with 2x/3x set options
Build and Deploy / build (push) Has been cancelled
- Created BundleSelector component for selecting bundle options
- Updated ProductDetail to show bundle options
- Added bundle translations for all 4 locales
- Added GraphQL query for bundle products
- Updated TypeScript types for attributes
- Saleor backend: created bundle products for all base products
2026-03-24 16:00:07 +02:00
Unchained 28a6e58dba Merge branch 'dev'
Build and Deploy / build (push) Has been cancelled
2026-03-24 14:17:00 +02:00
Unchained 569a3e65fe fix: correct metadata access and locale variable names 2026-03-24 14:16:49 +02:00
Unchained 1ba81a1fde Merge dev into master: resolve middleware conflict (use dev version with full locale detection)
Build and Deploy / build (push) Has been cancelled
2026-03-24 14:08:17 +02:00
Unchained df95e729fc CSS polish: increase newsletter input height for better UX 2026-03-24 13:46:38 +02:00
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
Unchained 513dcb7fea feat: add 301 redirects for old Serbian URLs to preserve SEO
Build and Deploy / build (push) Has been cancelled
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:35:07 +02:00
37 changed files with 991 additions and 207 deletions
+42 -5
View File
@@ -1,9 +1,46 @@
import createMiddleware from "next-intl/middleware"; import { NextResponse } from "next/server";
import { routing } from "./src/i18n/routing"; import type { NextRequest } from "next/server";
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, LOCALE_COOKIE, getPathWithoutLocale, buildLocalePath, isValidLocale } from "@/lib/i18n/locales";
import type { Locale } from "@/lib/i18n/locales";
export default createMiddleware({ const OLD_SERBIAN_PATHS = ["products", "about", "contact", "checkout"];
...routing,
}); function detectLocale(cookieLocale: string | undefined, acceptLanguage: string): Locale {
if (cookieLocale && isValidLocale(cookieLocale)) {
return cookieLocale;
}
if (acceptLanguage.includes("en")) {
return "en";
}
return DEFAULT_LOCALE;
}
export default function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
const acceptLanguage = request.headers.get("accept-language") || "";
if (pathname === "/" || pathname === "") {
const locale = detectLocale(cookieLocale, acceptLanguage);
const url = request.nextUrl.clone();
url.pathname = buildLocalePath(locale, "/");
return NextResponse.redirect(url, 301);
}
const isOldSerbianPath = OLD_SERBIAN_PATHS.some(
(path) => pathname === `/${path}` || pathname.startsWith(`/${path}/`)
);
if (isOldSerbianPath) {
const locale = detectLocale(cookieLocale, acceptLanguage);
const newPath = buildLocalePath(locale, pathname);
const url = request.nextUrl.clone();
url.pathname = newPath;
return NextResponse.redirect(url, 301);
}
return NextResponse.next();
}
export const config = { export const config = {
matcher: [ matcher: [
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

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 { getTranslations, setRequestLocale } from "next-intl/server";
import Header from "@/components/layout/Header"; import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer"; import Footer from "@/components/layout/Footer";
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
interface AboutPageProps { interface AboutPageProps {
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
@@ -8,19 +10,19 @@ interface AboutPageProps {
export async function generateMetadata({ params }: AboutPageProps) { export async function generateMetadata({ params }: AboutPageProps) {
const { locale } = await params; const { locale } = await params;
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const metadata = getPageMetadata(validLocale as Locale);
return { return {
title: locale === "sr" title: metadata.about.title,
? "O nama - ManoonOils" description: metadata.about.description,
: "About - ManoonOils",
description: locale === "sr"
? "Saznajte više o ManoonOils - naša priča, misija i posvećenost prirodnoj lepoti."
: "Learn more about ManoonOils - our story, mission, and commitment to natural beauty.",
}; };
} }
export default async function AboutPage({ params }: AboutPageProps) { export default async function AboutPage({ params }: AboutPageProps) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const metadata = getPageMetadata(validLocale as Locale);
setRequestLocale(validLocale);
const t = await getTranslations("About"); const t = await getTranslations("About");
return ( return (
@@ -43,7 +45,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
<div className="relative h-[400px] md:h-[500px] overflow-hidden"> <div className="relative h-[400px] md:h-[500px] overflow-hidden">
<img <img
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop" src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop"
alt={locale === "sr" ? "Proizvodnja prirodnih ulja" : "Natural oils production"} alt={metadata.about.productionAlt}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
<div className="absolute inset-0 bg-black/20" /> <div className="absolute inset-0 bg-black/20" />
+2 -2
View File
@@ -212,7 +212,7 @@ export default function CheckoutPage() {
return ( return (
<> <>
<Header /> <Header locale={locale} />
<main className="min-h-screen"> <main className="min-h-screen">
<section className="pt-[120px] pb-20 px-4"> <section className="pt-[120px] pb-20 px-4">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
@@ -377,7 +377,7 @@ export default function CheckoutPage() {
</main> </main>
<div className="pt-16"> <div className="pt-16">
<Footer /> <Footer locale={locale} />
</div> </div>
</> </>
); );
+28 -2
View File
@@ -1,9 +1,35 @@
import { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server"; import { getMessages, setRequestLocale } from "next-intl/server";
import { routing } from "@/i18n/routing"; import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
export function generateStaticParams() { export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale })); return SUPPORTED_LOCALES.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`;
const languages: Record<string, string> = {};
for (const loc of SUPPORTED_LOCALES) {
const prefix = loc === DEFAULT_LOCALE ? "" : `/${loc}`;
languages[loc] = `${baseUrl}${prefix}`;
}
return {
alternates: {
canonical: `${baseUrl}${localePrefix}`,
languages,
},
};
} }
export default async function LocaleLayout({ export default async function LocaleLayout({
+20 -17
View File
@@ -1,4 +1,4 @@
import { getProducts } from "@/lib/saleor"; import { getProducts, filterOutBundles } from "@/lib/saleor";
import { getTranslations, setRequestLocale } from "next-intl/server"; import { getTranslations, setRequestLocale } from "next-intl/server";
import Header from "@/components/layout/Header"; import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer"; import Footer from "@/components/layout/Footer";
@@ -10,38 +10,41 @@ import ProductReviews from "@/components/product/ProductReviews";
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery"; import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
import ProblemSection from "@/components/home/ProblemSection"; import ProblemSection from "@/components/home/ProblemSection";
import HowItWorks from "@/components/home/HowItWorks"; import HowItWorks from "@/components/home/HowItWorks";
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const metadata = getPageMetadata(validLocale as Locale);
setRequestLocale(validLocale);
return { return {
title: locale === "sr" title: metadata.home.title,
? "ManoonOils - Premium prirodna ulja za negu kose i kože" description: metadata.home.description,
: "ManoonOils - Premium Natural Oils for Hair & Skin",
description: locale === "sr"
? "Otkrijte našu premium kolekciju prirodnih ulja za negu kose i kože."
: "Discover our premium collection of natural oils for hair and skin care.",
}; };
} }
export default async function Homepage({ params }: { params: Promise<{ locale: string }> }) { export default async function Homepage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
setRequestLocale(validLocale);
const t = await getTranslations("Home"); const t = await getTranslations("Home");
const tBenefits = await getTranslations("Benefits"); const tBenefits = await getTranslations("Benefits");
const metadata = getPageMetadata(validLocale as Locale);
const productLocale = locale === "sr" ? "SR" : "EN"; const saleorLocale = getSaleorLocale(validLocale as Locale);
let products: any[] = []; let products: any[] = [];
try { try {
products = await getProducts(productLocale); products = await getProducts(saleorLocale);
} catch (e) { } catch (e) {
console.log("Failed to fetch products during build"); console.log("Failed to fetch products during build");
} }
const featuredProducts = products?.slice(0, 4) || []; const filteredProducts = filterOutBundles(products);
const featuredProducts = filteredProducts.slice(0, 4);
const hasProducts = featuredProducts.length > 0; const hasProducts = featuredProducts.length > 0;
const basePath = `/${locale}`; const basePath = `/${validLocale}`;
return ( return (
<> <>
@@ -78,7 +81,7 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
{featuredProducts.map((product, index) => ( {featuredProducts.map((product, index) => (
<ProductCard key={product.id} product={product} index={index} locale={productLocale} /> <ProductCard key={product.id} product={product} index={index} locale={locale} />
))} ))}
</div> </div>
@@ -122,7 +125,7 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
<div className="relative aspect-[4/3] bg-[#e8f0f5] rounded-lg overflow-hidden"> <div className="relative aspect-[4/3] bg-[#e8f0f5] rounded-lg overflow-hidden">
<img <img
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=800&auto=format&fit=crop" src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=800&auto=format&fit=crop"
alt={locale === "sr" ? "Proizvodnja prirodnih ulja" : "Natural oils production"} alt={metadata.home.productionAlt}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
</div> </div>
@@ -204,11 +207,11 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
<input <input
type="email" type="email"
placeholder={t("emailPlaceholder")} placeholder={t("emailPlaceholder")}
className="flex-1 min-w-0 px-5 h-14 bg-white/10 border border-white/20 border-b-0 sm:border-b border-r-0 sm:border-r border-white/20 text-white placeholder:text-white/50 focus:border-white focus:outline-none transition-colors text-base text-center sm:text-left rounded-t sm:rounded-l sm:rounded-tr-none" className="flex-1 min-w-0 px-5 !h-16 bg-white/10 border border-white/20 border-b-0 sm:border-b border-r-0 sm:border-r border-white/20 text-white placeholder:text-white/50 focus:border-white focus:outline-none transition-colors text-base text-center sm:text-left rounded-t sm:rounded-l sm:rounded-tr-none"
/> />
<button <button
type="submit" type="submit"
className="px-8 h-14 bg-white text-black text-sm uppercase tracking-[0.1em] font-medium hover:bg-white/90 transition-colors whitespace-nowrap flex-shrink-0 rounded-b sm:rounded-r sm:rounded-bl-none" className="px-8 bg-white text-black text-sm uppercase tracking-[0.1em] font-medium hover:bg-white/90 transition-colors whitespace-nowrap flex-shrink-0 rounded-b sm:rounded-r sm:rounded-bl-none"
> >
{t("subscribe")} {t("subscribe")}
</button> </button>
+37 -16
View File
@@ -1,10 +1,12 @@
import { getProductBySlug, getProducts, getLocalizedProduct } from "@/lib/saleor"; import { getProductBySlug, getProducts, getLocalizedProduct, getBundleProducts, filterOutBundles } from "@/lib/saleor";
import { getTranslations, setRequestLocale } from "next-intl/server"; import { getTranslations, setRequestLocale } from "next-intl/server";
import Header from "@/components/layout/Header"; import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer"; import Footer from "@/components/layout/Footer";
import ProductDetail from "@/components/product/ProductDetail"; import ProductDetail from "@/components/product/ProductDetail";
import type { Product } from "@/types/saleor"; import type { Product } from "@/types/saleor";
import { routing } from "@/i18n/routing"; import { routing } from "@/i18n/routing";
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
interface ProductPageProps { interface ProductPageProps {
params: Promise<{ locale: string; slug: string }>; params: Promise<{ locale: string; slug: string }>;
@@ -16,9 +18,10 @@ export async function generateStaticParams() {
for (const locale of locales) { for (const locale of locales) {
try { try {
const productLocale = locale === "sr" ? "SR" : "EN"; const saleorLocale = locale === "sr" ? "SR" : "EN";
const products = await getProducts(productLocale, 100); const products = await getProducts(saleorLocale, 100);
products.forEach((product: Product) => { const filteredProducts = filterOutBundles(products);
filteredProducts.forEach((product: Product) => {
params.push({ locale, slug: product.slug }); params.push({ locale, slug: product.slug });
}); });
} catch (e) { } catch (e) {
@@ -29,16 +32,18 @@ export async function generateStaticParams() {
export async function generateMetadata({ params }: ProductPageProps) { export async function generateMetadata({ params }: ProductPageProps) {
const { locale, slug } = await params; const { locale, slug } = await params;
const productLocale = locale === "sr" ? "SR" : "EN"; const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const product = await getProductBySlug(slug, productLocale); const metadata = getPageMetadata(validLocale as Locale);
const saleorLocale = validLocale === "sr" ? "SR" : "EN";
const product = await getProductBySlug(slug, saleorLocale);
if (!product) { if (!product) {
return { return {
title: locale === "sr" ? "Proizvod nije pronađen" : "Product not found", title: metadata.productNotFound,
}; };
} }
const localized = getLocalizedProduct(product, productLocale); const localized = getLocalizedProduct(product, saleorLocale);
return { return {
title: localized.name, title: localized.name,
@@ -48,12 +53,13 @@ export async function generateMetadata({ params }: ProductPageProps) {
export default async function ProductPage({ params }: ProductPageProps) { export default async function ProductPage({ params }: ProductPageProps) {
const { locale, slug } = await params; const { locale, slug } = await params;
setRequestLocale(locale); const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
setRequestLocale(validLocale);
const t = await getTranslations("Product"); const t = await getTranslations("Product");
const productLocale = locale === "sr" ? "SR" : "EN"; const saleorLocale = getSaleorLocale(validLocale as Locale);
const product = await getProductBySlug(slug, productLocale); const product = await getProductBySlug(slug, saleorLocale);
const basePath = locale === "sr" ? "" : `/${locale}`; const basePath = `/${validLocale}`;
if (!product) { if (!product) {
return ( return (
@@ -81,13 +87,27 @@ export default async function ProductPage({ params }: ProductPageProps) {
} }
let relatedProducts: Product[] = []; let relatedProducts: Product[] = [];
let bundleProducts: Product[] = [];
try { try {
const allProducts = await getProducts(productLocale, 8); const allProducts = await getProducts(saleorLocale, 50);
relatedProducts = allProducts relatedProducts = filterOutBundles(allProducts)
.filter((p: Product) => p.id !== product.id) .filter((p: Product) => p.id !== product.id)
.slice(0, 4); .slice(0, 4);
} catch (e) {} } catch (e) {}
try {
const allBundleProducts = await getBundleProducts(saleorLocale, 50);
bundleProducts = allBundleProducts.filter((p) => {
const bundleAttr = p.attributes?.find(
(attr) => attr.attribute.slug === "bundle-items"
);
if (!bundleAttr || bundleAttr.values.length === 0) return false;
return bundleAttr.values.some((val) => {
return val.name === product.name || p.name.includes(product.name.split(" - ")[0]);
});
});
} catch (e) {}
return ( return (
<> <>
<Header locale={locale} /> <Header locale={locale} />
@@ -95,10 +115,11 @@ export default async function ProductPage({ params }: ProductPageProps) {
<ProductDetail <ProductDetail
product={product} product={product}
relatedProducts={relatedProducts} relatedProducts={relatedProducts}
locale={productLocale} bundleProducts={bundleProducts}
locale={locale}
/> />
</main> </main>
<Footer /> <Footer locale={locale} />
</> </>
); );
} }
+14 -11
View File
@@ -1,9 +1,11 @@
import { getProducts } from "@/lib/saleor"; import { getProducts, filterOutBundles } from "@/lib/saleor";
import { getTranslations, setRequestLocale } from "next-intl/server"; import { getTranslations, setRequestLocale } from "next-intl/server";
import Header from "@/components/layout/Header"; import Header from "@/components/layout/Header";
import Footer from "@/components/layout/Footer"; import Footer from "@/components/layout/Footer";
import ProductCard from "@/components/product/ProductCard"; import ProductCard from "@/components/product/ProductCard";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { getPageMetadata } from "@/lib/i18n/pageMetadata";
import { isValidLocale, DEFAULT_LOCALE, getSaleorLocale, type Locale } from "@/lib/i18n/locales";
interface ProductsPageProps { interface ProductsPageProps {
params: Promise<{ locale: string }>; params: Promise<{ locale: string }>;
@@ -11,22 +13,23 @@ interface ProductsPageProps {
export async function generateMetadata({ params }: ProductsPageProps) { export async function generateMetadata({ params }: ProductsPageProps) {
const { locale } = await params; const { locale } = await params;
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
const metadata = getPageMetadata(validLocale as Locale);
return { return {
title: locale === "sr" title: metadata.products.title,
? "Proizvodi - ManoonOils" description: metadata.products.description,
: "Products - ManoonOils",
description: locale === "sr"
? "Pregledajte našu kolekciju premium prirodnih ulja za negu kose i kože."
: "Browse our collection of premium natural oils for hair and skin care.",
}; };
} }
export default async function ProductsPage({ params }: ProductsPageProps) { export default async function ProductsPage({ params }: ProductsPageProps) {
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
setRequestLocale(validLocale);
const t = await getTranslations("Products"); const t = await getTranslations("Products");
const productLocale = locale === "sr" ? "SR" : "EN"; const saleorLocale = getSaleorLocale(validLocale as Locale);
const products = await getProducts(productLocale); const allProducts = await getProducts(saleorLocale);
const products = filterOutBundles(allProducts);
return ( return (
<> <>
@@ -85,7 +88,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
key={product.id} key={product.id}
product={product} product={product}
index={index} index={index}
locale={productLocale} locale={validLocale}
/> />
))} ))}
</div> </div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

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 "./globals.css";
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import ErrorBoundary from "@/components/providers/ErrorBoundary"; import ErrorBoundary from "@/components/providers/ErrorBoundary";
import { SUPPORTED_LOCALES } from "@/lib/i18n/locales";
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@@ -9,6 +12,12 @@ export const metadata: Metadata = {
}, },
description: "Discover our premium collection of natural oils for hair and skin care.", description: "Discover our premium collection of natural oils for hair and skin care.",
robots: "index, follow", robots: "index, follow",
alternates: {
canonical: baseUrl,
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? baseUrl : `${baseUrl}/${locale}`])
),
},
openGraph: { openGraph: {
title: "ManoonOils - Premium Natural Oils for Hair & Skin", title: "ManoonOils - Premium Natural Oils for Hair & Skin",
description: "Discover our premium collection of natural oils for hair and skin care.", description: "Discover our premium collection of natural oils for hair and skin care.",
+15 -2
View File
@@ -1,5 +1,18 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cookies, headers } from "next/headers";
export default function RootPage() { export default async function RootPage() {
redirect("/sr"); const headersList = await headers();
const cookieStore = await cookies();
const acceptLanguage = headersList.get("accept-language") || "";
const cookieLocale = cookieStore.get("NEXT_LOCALE")?.value;
let locale = "sr";
if (cookieLocale && ["sr", "en", "de", "fr"].includes(cookieLocale)) {
locale = cookieLocale;
} else if (acceptLanguage.includes("en")) {
locale = "en";
}
redirect(`/${locale}`);
} }
+73 -12
View File
@@ -1,48 +1,109 @@
import { MetadataRoute } from "next"; import { MetadataRoute } from "next";
import { getProducts } from "@/lib/saleor"; import { getProducts, filterOutBundles } from "@/lib/saleor";
import { SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/locales";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com"; const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
interface SitemapEntry {
url: string;
lastModified: Date;
changeFrequency: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
priority: number;
alternates?: {
languages?: Record<string, string>;
};
}
export default async function sitemap(): Promise<SitemapEntry[]> {
let products: any[] = []; let products: any[] = [];
try { try {
products = await getProducts("SR", 100); products = await getProducts("SR", 100);
} catch (e) { } catch (e) {
console.log('Failed to fetch products for sitemap during build'); console.log("Failed to fetch products for sitemap during build");
} }
const productUrls = products.map((product) => ({ const staticPages: SitemapEntry[] = [
url: `${baseUrl}/products/${product.slug}`,
lastModified: new Date(),
changeFrequency: "weekly" as const,
priority: 0.8,
}));
return [
{ {
url: baseUrl, url: baseUrl,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "daily", changeFrequency: "daily",
priority: 1, priority: 1,
alternates: {
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? baseUrl : `${baseUrl}/${locale}`])
),
},
}, },
{ {
url: `${baseUrl}/products`, url: `${baseUrl}/products`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.9, priority: 0.9,
alternates: {
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/products` : `${baseUrl}/${locale}/products`])
),
},
}, },
{ {
url: `${baseUrl}/about`, url: `${baseUrl}/about`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.6, priority: 0.6,
alternates: {
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/about` : `${baseUrl}/${locale}/about`])
),
},
}, },
{ {
url: `${baseUrl}/contact`, url: `${baseUrl}/contact`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.6, priority: 0.6,
alternates: {
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/contact` : `${baseUrl}/${locale}/contact`])
),
},
},
{
url: `${baseUrl}/checkout`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.5,
alternates: {
languages: Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, locale === "sr" ? `${baseUrl}/checkout` : `${baseUrl}/${locale}/checkout`])
),
},
}, },
...productUrls,
]; ];
const filteredProducts = filterOutBundles(products);
const productUrls: SitemapEntry[] = [];
for (const product of filteredProducts) {
const hreflangs: Record<string, string> = {};
for (const locale of SUPPORTED_LOCALES) {
const path = locale === "sr" ? `/products/${product.slug}` : `/${locale}/products/${product.slug}`;
hreflangs[locale] = `${baseUrl}${path}`;
}
for (const locale of SUPPORTED_LOCALES) {
const localePrefix = locale === "sr" ? "" : `/${locale}`;
productUrls.push({
url: `${baseUrl}${localePrefix}/products/${product.slug}`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.8,
alternates: {
languages: hreflangs,
},
});
}
}
return [...staticPages, ...productUrls];
} }
+1 -1
View File
@@ -54,7 +54,7 @@ export default function NewsletterSection() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder={t("emailPlaceholder")} placeholder={t("emailPlaceholder")}
required required
className="flex-1 px-4 py-3 border border-[#1A1A1A]/10 rounded-[4px] text-sm focus:outline-none focus:border-[#1A1A1A]/30 transition-colors" className="flex-1 px-4 py-4 h-14 border border-[#1A1A1A]/10 rounded-[4px] text-base focus:outline-none focus:border-[#1A1A1A]/30 transition-colors"
/> />
<button <button
type="submit" type="submit"
+1 -1
View File
@@ -9,7 +9,7 @@ interface ProductShowcaseProps {
locale?: string; locale?: string;
} }
export default function ProductShowcase({ products, locale = "SR" }: ProductShowcaseProps) { export default function ProductShowcase({ products, locale = "sr" }: ProductShowcaseProps) {
if (!products || products.length === 0) return null; if (!products || products.length === 0) return null;
return ( return (
+12
View File
@@ -140,6 +140,18 @@ export default function Footer({ locale = "sr" }: FooterProps) {
&copy; {currentYear} ManoonOils. {t("allRights")} &copy; {currentYear} ManoonOils. {t("allRights")}
</p> </p>
<p className="text-xs text-[#999999]">
<strong>{t("madeWith")} by{" "}
<a
href="https://nodecrew.me"
target="_blank"
rel="noopener noreferrer"
className="text-[#c9a962] hover:text-[#b8944f] transition-colors"
>
Nodecrew
</a></strong>
</p>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs text-[#999999]">{t("weAccept")}</span> <span className="text-xs text-[#999999]">{t("weAccept")}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+74 -8
View File
@@ -1,13 +1,16 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore"; import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { User, ShoppingBag, Menu, X } from "lucide-react"; import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
import CartDrawer from "@/components/cart/CartDrawer"; import CartDrawer from "@/components/cart/CartDrawer";
import { SUPPORTED_LOCALES, LOCALE_COOKIE, LOCALE_CONFIG, isValidLocale, getPathWithoutLocale, buildLocalePath } from "@/lib/i18n/locales";
import type { Locale } from "@/lib/i18n/locales";
interface HeaderProps { interface HeaderProps {
locale?: string; locale?: string;
@@ -15,12 +18,41 @@ interface HeaderProps {
export default function Header({ locale = "sr" }: HeaderProps) { export default function Header({ locale = "sr" }: HeaderProps) {
const t = useTranslations("Header"); const t = useTranslations("Header");
const pathname = usePathname();
const dropdownRef = useRef<HTMLDivElement>(null);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false); const [scrolled, setScrolled] = useState(false);
const [langDropdownOpen, setLangDropdownOpen] = useState(false);
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore(); const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
const itemCount = getLineCount(); const itemCount = getLineCount();
const localePath = `/${locale}`; const currentLocale = isValidLocale(locale) ? LOCALE_CONFIG[locale] : LOCALE_CONFIG.sr;
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setLangDropdownOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const switchLocale = (newLocale: string) => {
if (newLocale === locale) {
setLangDropdownOpen(false);
return;
}
if (!isValidLocale(newLocale)) {
setLangDropdownOpen(false);
return;
}
document.cookie = `${LOCALE_COOKIE}=${newLocale}; path=/; max-age=31536000`;
const pathWithoutLocale = getPathWithoutLocale(pathname);
const newPath = buildLocalePath(newLocale as Locale, pathWithoutLocale);
window.location.replace(newPath);
setLangDropdownOpen(false);
};
useEffect(() => { useEffect(() => {
initCheckout(); initCheckout();
@@ -46,9 +78,9 @@ export default function Header({ locale = "sr" }: HeaderProps) {
}, [mobileMenuOpen]); }, [mobileMenuOpen]);
const navLinks = [ const navLinks = [
{ href: `${localePath}/products`, label: t("products") }, { href: `/${locale}/products`, label: t("products") },
{ href: `${localePath}/about`, label: t("about") }, { href: `/${locale}/about`, label: t("about") },
{ href: `${localePath}/contact`, label: t("contact") }, { href: `/${locale}/contact`, label: t("contact") },
]; ];
return ( return (
@@ -82,7 +114,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
))} ))}
</nav> </nav>
<Link href={localePath || "/"} className="flex-shrink-0 lg:absolute lg:left-1/2 lg:-translate-x-1/2"> <Link href={`/${locale}`} className="flex-shrink-0 lg:absolute lg:left-1/2 lg:-translate-x-1/2">
<Image <Image
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png" src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
alt="ManoonOils" alt="ManoonOils"
@@ -94,6 +126,40 @@ export default function Header({ locale = "sr" }: HeaderProps) {
</Link> </Link>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div ref={dropdownRef} className="relative">
<button
className="p-2 hover:bg-black/5 rounded-full transition-colors flex items-center gap-1"
onClick={() => setLangDropdownOpen(!langDropdownOpen)}
aria-label="Select language"
>
<Globe className="w-5 h-5" strokeWidth={1.5} />
<span className="text-sm">{currentLocale.flag}</span>
</button>
<AnimatePresence>
{langDropdownOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute right-0 top-full mt-1 bg-white border border-[#e5e5e5] shadow-lg rounded-md overflow-hidden z-50"
>
{SUPPORTED_LOCALES.map((loc) => (
<button
key={loc}
onClick={() => switchLocale(loc)}
className={`flex items-center gap-2 px-4 py-2 text-sm hover:bg-black/5 transition-colors w-full text-left ${
loc === locale ? "bg-black/5 font-medium" : ""
}`}
>
<span>{LOCALE_CONFIG[loc].flag}</span>
<span>{LOCALE_CONFIG[loc].label}</span>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<button <button
className="p-2 hover:bg-black/5 rounded-full transition-colors hidden sm:block" className="p-2 hover:bg-black/5 rounded-full transition-colors hidden sm:block"
aria-label={t("account")} aria-label={t("account")}
@@ -128,7 +194,7 @@ export default function Header({ locale = "sr" }: HeaderProps) {
> >
<div className="container h-full flex flex-col"> <div className="container h-full flex flex-col">
<div className="flex items-center justify-between h-[72px]"> <div className="flex items-center justify-between h-[72px]">
<Link href={localePath || "/"} onClick={() => setMobileMenuOpen(false)}> <Link href={`/${locale}`} onClick={() => setMobileMenuOpen(false)}>
<Image <Image
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png" src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
alt="ManoonOils" alt="ManoonOils"
+163
View File
@@ -0,0 +1,163 @@
"use client";
import { motion } from "framer-motion";
import { useTranslations } from "next-intl";
import type { Product } from "@/types/saleor";
import { getProductPrice, formatPrice } from "@/lib/saleor";
interface BundleSelectorProps {
baseProduct: Product;
bundleProducts: Product[];
selectedVariantId: string | null;
onSelectVariant: (variantId: string, quantity: number, price: number) => void;
locale: string;
}
interface BundleOption {
product: Product;
quantity: number;
price: number;
pricePerUnit: number;
savings: number;
isBase: boolean;
}
export default function BundleSelector({
baseProduct,
bundleProducts,
selectedVariantId,
onSelectVariant,
locale,
}: BundleSelectorProps) {
const t = useTranslations("Bundle");
if (bundleProducts.length === 0) {
return null;
}
const baseVariant = baseProduct.variants?.[0];
const basePrice = baseVariant?.pricing?.price?.gross?.amount || 0;
const options: BundleOption[] = [];
options.push({
product: baseProduct,
quantity: 1,
price: basePrice,
pricePerUnit: basePrice,
savings: 0,
isBase: true,
});
bundleProducts.forEach((bundle) => {
const variant = bundle.variants?.[0];
if (!variant?.pricing?.price?.gross?.amount) return;
const price = variant.pricing.price.gross.amount;
const quantityMatch = bundle.name.match(/(\d+)x/i);
const quantity = quantityMatch ? parseInt(quantityMatch[1], 10) : 1;
const pricePerUnit = price / quantity;
const savings = (basePrice * quantity) - price;
options.push({
product: bundle,
quantity,
price,
pricePerUnit,
savings,
isBase: false,
});
});
options.sort((a, b) => a.quantity - b.quantity);
const formatPriceWithLocale = (amount: number, currency: string = "RSD") => {
const localeMap: Record<string, string> = {
sr: "sr-RS",
en: "en-US",
de: "de-DE",
fr: "fr-FR",
};
const numLocale = localeMap[locale] || "sr-RS";
return new Intl.NumberFormat(numLocale, {
style: "currency",
currency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
};
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<span className="text-sm uppercase tracking-[0.1em] font-medium">
{t("selectBundle")}
</span>
</div>
<div className="space-y-3">
{options.map((option) => {
const variantId = option.isBase
? baseVariant?.id
: option.product.variants?.[0]?.id;
const isSelected = selectedVariantId === variantId;
return (
<motion.button
key={option.product.id}
onClick={() => variantId && onSelectVariant(variantId, option.quantity, option.price)}
className={`w-full p-4 border-2 transition-all text-left ${
isSelected
? "border-black bg-black text-white"
: "border-[#e5e5e5] hover:border-[#999999]"
}`}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
isSelected
? "border-white bg-white"
: "border-[#999999]"
}`}
>
{isSelected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="w-2.5 h-2.5 rounded-full bg-black"
/>
)}
</div>
<div>
<span className="font-medium">
{option.isBase ? t("singleUnit") : t("xSet", { count: option.quantity })}
</span>
{!option.isBase && option.savings > 0 && (
<span className="ml-2 text-xs text-green-500">
{t("save", { amount: formatPriceWithLocale(option.savings) })}
</span>
)}
</div>
</div>
<div className="text-right">
<div className={`font-bold ${isSelected ? "text-white" : "text-black"}`}>
{formatPriceWithLocale(option.price)}
</div>
{!option.isBase && (
<div className={`text-xs ${isSelected ? "text-white/70" : "text-[#666666]"}`}>
{formatPriceWithLocale(option.pricePerUnit)} {t("perUnit")}
</div>
)}
</div>
</div>
</motion.button>
);
})}
</div>
</div>
);
}
+1 -1
View File
@@ -7,7 +7,7 @@ interface ProductBenefitsProps {
locale?: string; locale?: string;
} }
export default function ProductBenefits({ locale = "SR" }: ProductBenefitsProps) { export default function ProductBenefits({ locale = "sr" }: ProductBenefitsProps) {
const t = useTranslations("ProductBenefits"); const t = useTranslations("ProductBenefits");
const benefits = [ const benefits = [
+5 -4
View File
@@ -6,6 +6,7 @@ import Link from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { Product } from "@/types/saleor"; import type { Product } from "@/types/saleor";
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor"; import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
import { isValidLocale, getSaleorLocale } from "@/lib/i18n/locales";
interface ProductCardProps { interface ProductCardProps {
product: Product; product: Product;
@@ -13,13 +14,13 @@ interface ProductCardProps {
locale?: string; locale?: string;
} }
export default function ProductCard({ product, index = 0, locale = "SR" }: ProductCardProps) { export default function ProductCard({ product, index = 0, locale = "sr" }: ProductCardProps) {
const t = useTranslations("ProductCard"); const t = useTranslations("ProductCard");
const image = getProductImage(product); const image = getProductImage(product);
const price = getProductPrice(product); const price = getProductPrice(product);
const localized = getLocalizedProduct(product, locale); const saleorLocale = isValidLocale(locale) ? getSaleorLocale(locale) : "SR";
const localized = getLocalizedProduct(product, saleorLocale);
const isAvailable = product.variants?.[0]?.quantityAvailable > 0; const isAvailable = product.variants?.[0]?.quantityAvailable > 0;
const urlLocale = locale === "SR" ? "sr" : "en";
return ( return (
<motion.div <motion.div
@@ -28,7 +29,7 @@ export default function ProductCard({ product, index = 0, locale = "SR" }: Produ
viewport={{ once: true }} viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }} transition={{ duration: 0.5, delay: index * 0.1 }}
> >
<Link href={`/${urlLocale}/products/${localized.slug}`} className="group block"> <Link href={`/${locale}/products/${localized.slug}`} className="group block">
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4"> <div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
{image ? ( {image ? (
<img <img
+58 -44
View File
@@ -9,6 +9,8 @@ import { useTranslations } from "next-intl";
import type { Product } from "@/types/saleor"; import type { Product } from "@/types/saleor";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore"; import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { getProductPrice, getProductPriceAmount, getLocalizedProduct, formatPrice } from "@/lib/saleor"; import { getProductPrice, getProductPriceAmount, getLocalizedProduct, formatPrice } from "@/lib/saleor";
import { getTranslatedShortDescription, getTranslatedBenefits } from "@/lib/i18n/productText";
import { isValidLocale } from "@/lib/i18n/locales";
import ProductCard from "@/components/product/ProductCard"; import ProductCard from "@/components/product/ProductCard";
import ProductBenefits from "@/components/product/ProductBenefits"; import ProductBenefits from "@/components/product/ProductBenefits";
import ProductReviews from "@/components/product/ProductReviews"; import ProductReviews from "@/components/product/ProductReviews";
@@ -17,10 +19,12 @@ import TrustBadges from "@/components/home/TrustBadges";
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery"; import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
import HowItWorks from "@/components/home/HowItWorks"; import HowItWorks from "@/components/home/HowItWorks";
import NewsletterSection from "@/components/home/NewsletterSection"; import NewsletterSection from "@/components/home/NewsletterSection";
import BundleSelector from "@/components/product/BundleSelector";
interface ProductDetailProps { interface ProductDetailProps {
product: Product; product: Product;
relatedProducts: Product[]; relatedProducts: Product[];
bundleProducts?: Product[];
locale?: string; locale?: string;
} }
@@ -86,14 +90,16 @@ function StarRating({ rating = 5, count = 0 }: { rating?: number; count?: number
); );
} }
export default function ProductDetail({ product, relatedProducts, locale = "SR" }: ProductDetailProps) { export default function ProductDetail({ product, relatedProducts, bundleProducts = [], locale = "sr" }: ProductDetailProps) {
const t = useTranslations("ProductDetail"); const t = useTranslations("ProductDetail");
const tProduct = useTranslations("Product"); const tProduct = useTranslations("Product");
const [selectedImage, setSelectedImage] = useState(0); const [selectedImage, setSelectedImage] = useState(0);
const [quantity, setQuantity] = useState(1); const [quantity, setQuantity] = useState(1);
const [isAdding, setIsAdding] = useState(false); const [isAdding, setIsAdding] = useState(false);
const [urgencyIndex, setUrgencyIndex] = useState(0); const [urgencyIndex, setUrgencyIndex] = useState(0);
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
const { addLine, openCart } = useSaleorCheckoutStore(); const { addLine, openCart } = useSaleorCheckoutStore();
const validLocale = isValidLocale(locale) ? locale : "sr";
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
@@ -109,38 +115,58 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
]; ];
const localized = getLocalizedProduct(product, locale); const localized = getLocalizedProduct(product, locale);
const variant = product.variants?.[0]; const baseVariant = product.variants?.[0];
const selectedVariantId = selectedBundleVariantId || baseVariant?.id;
const selectedVariant = selectedVariantId === baseVariant?.id
? baseVariant
: bundleProducts.find(p => p.variants?.[0]?.id === selectedVariantId)?.variants?.[0];
const images = product.media?.length > 0 const images = product.media?.length > 0
? product.media.filter(m => m.type === "IMAGE") ? product.media.filter(m => m.type === "IMAGE")
: [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" as const }]; : [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" as const }];
const handleAddToCart = async () => { const handleAddToCart = async () => {
if (!variant?.id) return; if (!selectedVariantId) return;
setIsAdding(true); setIsAdding(true);
try { try {
await addLine(variant.id, quantity); await addLine(selectedVariantId, 1);
openCart(); openCart();
} finally { } finally {
setIsAdding(false); setIsAdding(false);
} }
}; };
const isAvailable = variant?.quantityAvailable > 0; const handleSelectVariant = (variantId: string, qty: number, price: number) => {
const price = getProductPrice(product); setSelectedBundleVariantId(variantId);
const priceAmount = getProductPriceAmount(product); setQuantity(qty);
const originalPrice = priceAmount > 0 ? formatPrice(Math.round(priceAmount * 1.30)) : null; };
const shortDescription = localized.description const isAvailable = (selectedVariant?.quantityAvailable ?? 0) > 0;
? localized.description.split('.')[0] + '.'
: locale === "EN" ? "Premium natural oil for your beauty routine." : "Premium prirodno ulje za vašu rutinu lepote.";
const benefits = product.metadata?.find(m => m.key === "benefits")?.value?.split(',') || [ const selectedPrice = selectedVariant?.pricing?.price?.gross?.amount || 0;
locale === "EN" ? "Natural" : "Prirodno", const price = selectedPrice > 0
locale === "EN" ? "Organic" : "Organsko", ? new Intl.NumberFormat(validLocale === "en" ? "en-US" : validLocale === "de" ? "de-DE" : validLocale === "fr" ? "fr-FR" : "sr-RS", {
locale === "EN" ? "Cruelty-free" : "Bez okrutnosti", style: "currency",
]; currency: selectedVariant?.pricing?.price?.gross?.currency || "RSD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(selectedPrice)
: "";
const priceAmount = selectedPrice;
const originalPrice = priceAmount > 0 ? new Intl.NumberFormat(validLocale === "en" ? "en-US" : validLocale === "de" ? "de-DE" : validLocale === "fr" ? "fr-FR" : "sr-RS", {
style: "currency",
currency: "RSD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(Math.round(priceAmount * 1.30)) : null;
const shortDescription = getTranslatedShortDescription(localized.description, validLocale);
const metadataBenefits = product.metadata?.find(m => m.key === "benefits")?.value?.split(',');
const benefits = getTranslatedBenefits(metadataBenefits, validLocale);
return ( return (
<> <>
@@ -148,7 +174,7 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
<div className="border-b border-[#e5e5e5] pt-[72px] lg:pt-[72px]"> <div className="border-b border-[#e5e5e5] pt-[72px] lg:pt-[72px]">
<div className="container py-5"> <div className="container py-5">
<nav className="flex items-center gap-2 text-sm"> <nav className="flex items-center gap-2 text-sm">
<Link href={`/${locale.toLowerCase()}`} className="text-[#666666] hover:text-black transition-colors"> <Link href={`/${validLocale}`} className="text-[#666666] hover:text-black transition-colors">
{t("home")} {t("home")}
</Link> </Link>
<span className="text-[#999999]">/</span> <span className="text-[#999999]">/</span>
@@ -294,7 +320,16 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
<div className="border-t border-[#e5e5e5] mb-8" /> <div className="border-t border-[#e5e5e5] mb-8" />
{product.variants && product.variants.length > 1 && ( {bundleProducts.length > 0 ? (
<BundleSelector
baseProduct={product}
bundleProducts={bundleProducts}
selectedVariantId={selectedBundleVariantId || baseVariant?.id || null}
onSelectVariant={handleSelectVariant}
locale={validLocale}
/>
) : (
product.variants && product.variants.length > 1 && (
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span className="text-sm uppercase tracking-[0.1em] font-medium"> <span className="text-sm uppercase tracking-[0.1em] font-medium">
@@ -306,7 +341,7 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
<button <button
key={v.id} key={v.id}
className={`px-5 py-3 text-sm border-2 transition-colors ${ className={`px-5 py-3 text-sm border-2 transition-colors ${
v.id === variant?.id v.id === baseVariant?.id
? "border-black bg-black text-white" ? "border-black bg-black text-white"
: "border-[#e5e5e5] hover:border-[#999999]" : "border-[#e5e5e5] hover:border-[#999999]"
}`} }`}
@@ -316,30 +351,9 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
))} ))}
</div> </div>
</div> </div>
)
)} )}
<div className="flex items-center gap-4 mb-8">
<span className="text-sm uppercase tracking-[0.1em] font-medium w-16">
{t("qty")}
</span>
<div className="flex items-center border-2 border-[#1a1a1a]">
<button
onClick={() => setQuantity(Math.max(1, quantity - 1))}
className="w-12 h-12 flex items-center justify-center hover:bg-[#f8f9fa] transition-colors"
disabled={quantity <= 1}
>
<Minus className="w-4 h-4" />
</button>
<span className="w-14 text-center text-base font-medium">{quantity}</span>
<button
onClick={() => setQuantity(quantity + 1)}
className="w-12 h-12 flex items-center justify-center hover:bg-[#f8f9fa] transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
{isAvailable ? ( {isAvailable ? (
<button <button
onClick={handleAddToCart} onClick={handleAddToCart}
@@ -427,9 +441,9 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
</ExpandableSection> </ExpandableSection>
</div> </div>
{variant?.sku && ( {selectedVariant?.sku && (
<p className="text-xs text-[#999999] mt-8"> <p className="text-xs text-[#999999] mt-8">
SKU: {variant.sku} SKU: {selectedVariant.sku}
</p> </p>
)} )}
</motion.div> </motion.div>
@@ -469,7 +483,7 @@ export default function ProductDetail({ product, relatedProducts, locale = "SR"
</section> </section>
)} )}
<ProductBenefits locale={locale} /> <ProductBenefits key={locale} locale={locale} />
<TrustBadges /> <TrustBadges />
+2 -2
View File
@@ -3,6 +3,7 @@
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server"; import { getMessages } from "next-intl/server";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { SUPPORTED_LOCALES, isValidLocale } from "@/lib/i18n/locales";
export default async function LocaleProvider({ export default async function LocaleProvider({
children, children,
@@ -11,8 +12,7 @@ export default async function LocaleProvider({
children: React.ReactNode; children: React.ReactNode;
locale: string; locale: string;
}) { }) {
const locales = ["en", "sr"]; if (!isValidLocale(locale)) notFound();
if (!locales.includes(locale)) notFound();
const messages = await getMessages(); const messages = await getMessages();
+19 -2
View File
@@ -68,18 +68,27 @@
}, },
"Cart": { "Cart": {
"title": "Ihr Warenkorb", "title": "Ihr Warenkorb",
"yourCart": "Ihr Warenkorb",
"closeCart": "Warenkorb schließen",
"empty": "Ihr Warenkorb ist leer", "empty": "Ihr Warenkorb ist leer",
"emptyDesc": "Es sieht so aus, als hätten Sie noch nichts in Ihren Warenkorb gelegt.", "emptyDesc": "Es sieht so aus, als hätten Sie noch nichts in Ihren Warenkorb gelegt.",
"continueShopping": "Weiter einkaufen", "continueShopping": "Weiter einkaufen",
"startShopping": "Einkauf starten",
"checkout": "Zur Kasse", "checkout": "Zur Kasse",
"subtotal": "Zwischensumme", "subtotal": "Zwischensumme",
"shipping": "Versand", "shipping": "Versand",
"shippingCalc": "Wird an der Kasse berechnet", "shippingCalc": "Wird an der Kasse berechnet",
"calculatedAtCheckout": "Wird an der Kasse berechnet",
"dismiss": "Schließen",
"yourCartEmpty": "Ihr Warenkorb ist leer",
"looksLikeEmpty": "Es sieht so aus, als hätten Sie nichts hinzugefügt",
"total": "Gesamt", "total": "Gesamt",
"freeShipping": "Kostenloser Versand bei Bestellungen über {amount}", "freeShipping": "Kostenloser Versand bei Bestellungen über {amount}",
"remove": "Entfernen", "remove": "Entfernen",
"removeItem": "Artikel entfernen",
"processes": "Wird bearbeitet...", "processes": "Wird bearbeitet...",
"cartEmpty": "Ihr Warenkorb ist leer" "cartEmpty": "Ihr Warenkorb ist leer",
"qty": "Menge"
}, },
"About": { "About": {
"title": "Über uns", "title": "Über uns",
@@ -156,7 +165,8 @@
"help": "Hilfe", "help": "Hilfe",
"contactUs": "Kontaktieren Sie uns", "contactUs": "Kontaktieren Sie uns",
"brandDescription": "Premium natürliche Öle für Haar- und Hautpflege. Handgefertigt mit Liebe unter Verwendung traditioneller Methoden.", "brandDescription": "Premium natürliche Öle für Haar- und Hautpflege. Handgefertigt mit Liebe unter Verwendung traditioneller Methoden.",
"weAccept": "Wir akzeptieren:" "weAccept": "Wir akzeptieren:",
"madeWith": "Erstellt mit"
}, },
"Common": { "Common": {
"loading": "Laden...", "loading": "Laden...",
@@ -302,6 +312,13 @@
"urgency2": "In den Warenkörben von 2,5K Menschen - kaufen Sie, bevor es weg ist!", "urgency2": "In den Warenkörben von 2,5K Menschen - kaufen Sie, bevor es weg ist!",
"urgency3": "7.562 Personen haben sich dieses Produkt in den letzten 24 Stunden angesehen!" "urgency3": "7.562 Personen haben sich dieses Produkt in den letzten 24 Stunden angesehen!"
}, },
"Bundle": {
"selectBundle": "Paket wählen",
"singleUnit": "1 Stück",
"xSet": "{count}x Set",
"save": "Spare {amount}",
"perUnit": "pro Stück"
},
"Newsletter": { "Newsletter": {
"stayConnected": "Bleiben Sie verbunden", "stayConnected": "Bleiben Sie verbunden",
"joinCommunity": "Werden Sie Teil unserer Gemeinschaft", "joinCommunity": "Werden Sie Teil unserer Gemeinschaft",
+9 -14
View File
@@ -132,19 +132,6 @@
"faq4q": "Do you offer wholesale?", "faq4q": "Do you offer wholesale?",
"faq4a": "Yes, we offer wholesale pricing for bulk orders. Please contact us at [email protected] for more information." "faq4a": "Yes, we offer wholesale pricing for bulk orders. Please contact us at [email protected] for more information."
}, },
"Footer": {
"quickLinks": "Quick Links",
"customerService": "Customer Service",
"contact": "Contact",
"shipping": "Shipping",
"returns": "Returns",
"faq": "FAQ",
"followUs": "Follow Us",
"newsletter": "Newsletter",
"newsletterDesc": "Subscribe to our newsletter for exclusive offers and updates.",
"copyright": "All rights reserved.",
"allRights": "© {year} ManoonOils."
},
"Common": { "Common": {
"loading": "Loading...", "loading": "Loading...",
"error": "An error occurred", "error": "An error occurred",
@@ -321,7 +308,8 @@
"contactUs": "Contact Us", "contactUs": "Contact Us",
"brandDescription": "Premium natural oils for hair and skin care. Handcrafted with love using traditional methods.", "brandDescription": "Premium natural oils for hair and skin care. Handcrafted with love using traditional methods.",
"weAccept": "We accept:", "weAccept": "We accept:",
"allRights": "All rights reserved." "allRights": "All rights reserved.",
"madeWith": "Made with"
}, },
"ProductCard": { "ProductCard": {
"noImage": "No image", "noImage": "No image",
@@ -353,6 +341,13 @@
"urgency2": "In the carts of 2.5K people - buy before its gone!", "urgency2": "In the carts of 2.5K people - buy before its gone!",
"urgency3": "7,562 people viewed this product in the last 24 hours!" "urgency3": "7,562 people viewed this product in the last 24 hours!"
}, },
"Bundle": {
"selectBundle": "Select Package",
"singleUnit": "1 Unit",
"xSet": "{count}x Set",
"save": "Save {amount}",
"perUnit": "per unit"
},
"Newsletter": { "Newsletter": {
"stayConnected": "Stay Connected", "stayConnected": "Stay Connected",
"joinCommunity": "Join Our Community", "joinCommunity": "Join Our Community",
+19 -2
View File
@@ -68,18 +68,27 @@
}, },
"Cart": { "Cart": {
"title": "Votre Panier", "title": "Votre Panier",
"yourCart": "Votre Panier",
"closeCart": "Fermer le panier",
"empty": "Votre panier est vide", "empty": "Votre panier est vide",
"emptyDesc": "Il semble que vous n'ayez pas encore ajouté d'articles à votre panier.", "emptyDesc": "Il semble que vous n'ayez pas encore ajouté d'articles à votre panier.",
"continueShopping": "Continuer les Achats", "continueShopping": "Continuer les Achats",
"startShopping": "Commencer vos Achats",
"checkout": "Commander", "checkout": "Commander",
"subtotal": "Sous-total", "subtotal": "Sous-total",
"shipping": "Livraison", "shipping": "Livraison",
"shippingCalc": "Calculé à la caisse", "shippingCalc": "Calculé à la caisse",
"calculatedAtCheckout": "Calculé à la caisse",
"dismiss": "Fermer",
"yourCartEmpty": "Votre panier est vide",
"looksLikeEmpty": "On dirait que vous n'avez rien ajouté",
"total": "Total", "total": "Total",
"freeShipping": "Livraison gratuite sur les commandes de {amount}", "freeShipping": "Livraison gratuite sur les commandes de {amount}",
"remove": "Supprimer", "remove": "Supprimer",
"removeItem": "Supprimer l'article",
"processes": "En cours...", "processes": "En cours...",
"cartEmpty": "Votre panier est vide" "cartEmpty": "Votre panier est vide",
"qty": "Qté"
}, },
"About": { "About": {
"title": "À Propos", "title": "À Propos",
@@ -156,7 +165,8 @@
"help": "Aide", "help": "Aide",
"contactUs": "Contactez-nous", "contactUs": "Contactez-nous",
"brandDescription": "Huiles naturelles premium pour les soins capillaires et cutanés. Fait main avec amour en utilisant des méthodes traditionnelles.", "brandDescription": "Huiles naturelles premium pour les soins capillaires et cutanés. Fait main avec amour en utilisant des méthodes traditionnelles.",
"weAccept": "Nous acceptons:" "weAccept": "Nous acceptons:",
"madeWith": "Fait avec"
}, },
"Common": { "Common": {
"loading": "Chargement...", "loading": "Chargement...",
@@ -302,6 +312,13 @@
"urgency2": "Dans les paniers de 2,5K personnes - achetez avant qu'il ne disparaisse!", "urgency2": "Dans les paniers de 2,5K personnes - achetez avant qu'il ne disparaisse!",
"urgency3": "7 562 personnes ont vu ce produit ces dernières 24 heures!" "urgency3": "7 562 personnes ont vu ce produit ces dernières 24 heures!"
}, },
"Bundle": {
"selectBundle": "Choisir le Pack",
"singleUnit": "1 Unité",
"xSet": "{count}x Set",
"save": "Économisez {amount}",
"perUnit": "par unité"
},
"Newsletter": { "Newsletter": {
"stayConnected": "Restez Connectés", "stayConnected": "Restez Connectés",
"joinCommunity": "Rejoignez Notre Communauté", "joinCommunity": "Rejoignez Notre Communauté",
+9 -14
View File
@@ -132,19 +132,6 @@
"faq4q": "Da li nudite veleprodaju?", "faq4q": "Da li nudite veleprodaju?",
"faq4a": "Da, nudimo veleprodajne cene za narudžbine u velikim količinama. Molimo kontaktirajte nas na [email protected] za više informacija." "faq4a": "Da, nudimo veleprodajne cene za narudžbine u velikim količinama. Molimo kontaktirajte nas na [email protected] za više informacija."
}, },
"Footer": {
"quickLinks": "Brze veze",
"customerService": "Korisnička podrška",
"contact": "Kontakt",
"shipping": "Dostava",
"returns": "Povrat",
"faq": "Česta pitanja",
"followUs": "Pratite nas",
"newsletter": "Newsletter",
"newsletterDesc": "Pretplatite se na naš newsletter za ekskluzivne ponude i novosti.",
"copyright": "Sva prava zadržana.",
"allRights": "© {year} ManoonOils."
},
"Common": { "Common": {
"loading": "Učitavanje...", "loading": "Učitavanje...",
"error": "Došlo je do greške", "error": "Došlo je do greške",
@@ -321,7 +308,8 @@
"contactUs": "Kontaktirajte nas", "contactUs": "Kontaktirajte nas",
"brandDescription": "Premium prirodna ulja za negu kose i kože. Ručno pravljena sa ljubavlju, korišćenjem tradicionalnih metoda.", "brandDescription": "Premium prirodna ulja za negu kose i kože. Ručno pravljena sa ljubavlju, korišćenjem tradicionalnih metoda.",
"weAccept": "Prihvatamo:", "weAccept": "Prihvatamo:",
"allRights": "Sva prava zadržana." "allRights": "Sva prava zadržana.",
"madeWith": "Napravljeno sa"
}, },
"ProductCard": { "ProductCard": {
"noImage": "Nema slike", "noImage": "Nema slike",
@@ -353,6 +341,13 @@
"urgency2": "U korpama 2.5K ljudi - kupi pre nego što nestane!", "urgency2": "U korpama 2.5K ljudi - kupi pre nego što nestane!",
"urgency3": "7.562 osobe su pogledale ovaj proizvod u poslednja 24 sata!" "urgency3": "7.562 osobe su pogledale ovaj proizvod u poslednja 24 sata!"
}, },
"Bundle": {
"selectBundle": "Izaberi pakovanje",
"singleUnit": "1 komad",
"xSet": "{count}x Set",
"save": "Štedi {amount}",
"perUnit": "po komadu"
},
"Newsletter": { "Newsletter": {
"stayConnected": "Ostanite povezani", "stayConnected": "Ostanite povezani",
"joinCommunity": "Pridružite se našoj zajednici", "joinCommunity": "Pridružite se našoj zajednici",
+3 -2
View File
@@ -1,7 +1,8 @@
import { defineRouting } from "next-intl/routing"; import { defineRouting } from "next-intl/routing";
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from "@/lib/i18n/locales";
export const routing = defineRouting({ export const routing = defineRouting({
locales: ["sr", "en", "de", "fr"], locales: SUPPORTED_LOCALES,
defaultLocale: "sr", defaultLocale: DEFAULT_LOCALE,
localePrefix: "as-needed", localePrefix: "as-needed",
}); });
+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;
}
+12
View File
@@ -35,6 +35,18 @@ export const PRODUCT_FRAGMENT = gql`
key key
value value
} }
attributes {
attribute {
id
name
slug
}
values {
id
name
slug
}
}
} }
${PRODUCT_VARIANT_FRAGMENT} ${PRODUCT_VARIANT_FRAGMENT}
`; `;
+6 -1
View File
@@ -7,7 +7,7 @@ export { PRODUCT_VARIANT_FRAGMENT, CHECKOUT_LINE_FRAGMENT } from "./fragments/Va
export { CHECKOUT_FRAGMENT, ADDRESS_FRAGMENT } from "./fragments/Checkout"; export { CHECKOUT_FRAGMENT, ADDRESS_FRAGMENT } from "./fragments/Checkout";
// Queries // Queries
export { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_PRODUCTS_BY_CATEGORY } from "./queries/Products"; export { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_PRODUCTS_BY_CATEGORY, GET_BUNDLE_PRODUCTS } from "./queries/Products";
export { GET_CHECKOUT, GET_CHECKOUT_BY_ID } from "./queries/Checkout"; export { GET_CHECKOUT, GET_CHECKOUT_BY_ID } from "./queries/Checkout";
// Mutations // Mutations
@@ -34,4 +34,9 @@ export {
formatPrice, formatPrice,
getLocalizedProduct, getLocalizedProduct,
parseDescription, parseDescription,
getBundleProducts,
getBundleProductsForProduct,
getProductBundleComponents,
isBundleProduct,
filterOutBundles,
} from "./products"; } from "./products";
+67 -1
View File
@@ -1,5 +1,5 @@
import { saleorClient } from "./client"; import { saleorClient } from "./client";
import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG } from "./queries/Products"; import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_BUNDLE_PRODUCTS } from "./queries/Products";
import type { Product } from "@/types/saleor"; import type { Product } from "@/types/saleor";
const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel"; const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
@@ -155,3 +155,69 @@ export function getLocalizedProduct(
seoDescription: translation?.seoDescription || product.seoDescription, seoDescription: translation?.seoDescription || product.seoDescription,
}; };
} }
interface ProductsResponse {
products?: {
edges: Array<{ node: Product }>;
};
}
export async function getBundleProducts(
locale: string = "SR",
first: number = 50
): Promise<Product[]> {
try {
const { data } = await saleorClient.query<ProductsResponse>({
query: GET_BUNDLE_PRODUCTS,
variables: {
channel: CHANNEL,
locale: locale.toUpperCase(),
first,
},
});
return data?.products?.edges.map((edge) => edge.node) || [];
} catch (error) {
console.error("Error fetching bundle products from Saleor:", error);
return [];
}
}
export function getBundleProductsForProduct(
allProducts: Product[],
baseProductId: string
): Product[] {
return allProducts.filter((product) => {
const bundleItemsAttr = product.attributes?.find(
(attr) => attr.attribute.slug === "bundle-items"
);
if (!bundleItemsAttr) return false;
return bundleItemsAttr.values.some((val) => {
const referencedId = Buffer.from(val.slug.split(":")[1] || val.id).toString("base64");
const expectedId = `UHJvZHVjdDo${baseProductId.split("UHJvZHVjdDo")[1]}`;
return referencedId.includes(baseProductId.split("UHJvZHVjdDo")[1] || "") ||
val.slug.includes(baseProductId.split("UHJvZHVjdDo")[1] || "");
});
});
}
export function getProductBundleComponents(product: Product): number | null {
const bundleAttr = product.attributes?.find(
(attr) => attr.attribute.slug === "bundle-items"
);
if (!bundleAttr) return null;
const bundleAttrMatch = product.name.match(/(\d+)x/i);
if (bundleAttrMatch) {
return parseInt(bundleAttrMatch[1], 10);
}
return null;
}
export function isBundleProduct(product: Product): boolean {
return getProductBundleComponents(product) !== null;
}
export function filterOutBundles(products: Product[]): Product[] {
return products.filter((product) => !isBundleProduct(product));
}
+15 -2
View File
@@ -6,7 +6,7 @@ export const GET_PRODUCTS = gql`
products(channel: $channel, first: $first) { products(channel: $channel, first: $first) {
edges { edges {
node { node {
...ProductListItemFragment ...ProductFragment
} }
} }
pageInfo { pageInfo {
@@ -15,7 +15,7 @@ export const GET_PRODUCTS = gql`
} }
} }
} }
${PRODUCT_LIST_ITEM_FRAGMENT} ${PRODUCT_FRAGMENT}
`; `;
export const GET_PRODUCT_BY_SLUG = gql` export const GET_PRODUCT_BY_SLUG = gql`
@@ -49,3 +49,16 @@ export const GET_PRODUCTS_BY_CATEGORY = gql`
} }
${PRODUCT_LIST_ITEM_FRAGMENT} ${PRODUCT_LIST_ITEM_FRAGMENT}
`; `;
export const GET_BUNDLE_PRODUCTS = gql`
query GetBundleProducts($channel: String!, $locale: LanguageCodeEnum!, $first: Int!) {
products(channel: $channel, first: $first) {
edges {
node {
...ProductFragment
}
}
}
}
${PRODUCT_FRAGMENT}
`;
+3
View File
@@ -22,12 +22,14 @@ export interface ProductMedia {
} }
export interface ProductAttributeValue { export interface ProductAttributeValue {
id: string;
name: string; name: string;
slug: string; slug: string;
} }
export interface ProductAttribute { export interface ProductAttribute {
attribute: { attribute: {
id: string;
name: string; name: string;
slug: string; slug: string;
}; };
@@ -82,6 +84,7 @@ export interface Product {
key: string; key: string;
value: string; value: string;
}[]; }[];
attributes?: ProductAttribute[];
} }
export interface ProductEdge { export interface ProductEdge {