feat: implement programmatic SEO solutions hub
- Add /solutions hub page with 10 category cards - Add /solutions/by-concern directory page - Add /solutions/by-oil directory page - Add Solutions section to Footer with navigation links - Add Breadcrumb component for solution pages - Add translations for all solution pages (sr, en, de, fr) - Fix ExitIntentDetector JSON parsing error - Update sitemap with solution pages - Create 3 sample solution pages with data files
This commit is contained in:
106
src/app/[locale]/solutions/[slug]/page.tsx
Normal file
106
src/app/[locale]/solutions/[slug]/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
getOilForConcernPage,
|
||||
getAllSolutionSlugs,
|
||||
getLocalizedString,
|
||||
getLocalizedKeywords
|
||||
} from "@/lib/programmatic-seo/dataLoader";
|
||||
import { getProducts } from "@/lib/saleor";
|
||||
import { OilForConcernPageTemplate } from "@/components/programmatic-seo/OilForConcernPage";
|
||||
import { FAQSchema } from "@/components/programmatic-seo/FAQSchema";
|
||||
import { isValidLocale, DEFAULT_LOCALE, type Locale } from "@/lib/i18n/locales";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ locale: string; slug: string }>;
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return await getAllSolutionSlugs();
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale, slug } = await params;
|
||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
const page = await getOilForConcernPage(slug);
|
||||
|
||||
if (!page) {
|
||||
return {
|
||||
title: "Page Not Found",
|
||||
};
|
||||
}
|
||||
|
||||
const metaTitle = getLocalizedString(page.metaTitle, validLocale);
|
||||
const metaDescription = getLocalizedString(page.metaDescription, validLocale);
|
||||
const keywords = getLocalizedKeywords(page.seoKeywords, validLocale);
|
||||
const localePrefix = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`;
|
||||
const canonicalUrl = `${baseUrl}${localePrefix}/solutions/${page.slug}`;
|
||||
|
||||
return {
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
keywords: keywords.join(", "),
|
||||
alternates: {
|
||||
canonical: canonicalUrl,
|
||||
languages: {
|
||||
"sr": `${baseUrl}/solutions/${page.slug}`,
|
||||
"en": `${baseUrl}/en/solutions/${page.slug}`,
|
||||
"de": `${baseUrl}/de/solutions/${page.slug}`,
|
||||
"fr": `${baseUrl}/fr/solutions/${page.slug}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
type: "article",
|
||||
url: canonicalUrl,
|
||||
images: [{
|
||||
url: `${baseUrl}/og-image.jpg`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: metaTitle,
|
||||
}],
|
||||
locale: validLocale,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: metaTitle,
|
||||
description: metaDescription,
|
||||
images: [`${baseUrl}/og-image.jpg`],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function SolutionPage({ params }: PageProps) {
|
||||
const { locale, slug } = await params;
|
||||
const validLocale = isValidLocale(locale) ? locale : DEFAULT_LOCALE;
|
||||
const [page, products] = await Promise.all([
|
||||
getOilForConcernPage(slug),
|
||||
getProducts(validLocale === "sr" ? "SR" : "EN", 4)
|
||||
]);
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const basePath = validLocale === DEFAULT_LOCALE ? "" : `/${validLocale}`;
|
||||
|
||||
const faqQuestions = page.faqs.map((faq) => ({
|
||||
question: getLocalizedString(faq.question, validLocale),
|
||||
answer: getLocalizedString(faq.answer, validLocale),
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<FAQSchema questions={faqQuestions} />
|
||||
<OilForConcernPageTemplate
|
||||
page={page}
|
||||
locale={validLocale as Locale}
|
||||
basePath={basePath}
|
||||
products={products}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
154
src/app/[locale]/solutions/by-concern/page.tsx
Normal file
154
src/app/[locale]/solutions/by-concern/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ChevronRight, Search } from "lucide-react";
|
||||
import { getAllOilForConcernPages, getLocalizedString } from "@/lib/programmatic-seo/dataLoader";
|
||||
|
||||
type Params = Promise<{ locale: string }>;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "Solutions.ByConcern" });
|
||||
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
};
|
||||
}
|
||||
|
||||
function groupByConcern(pages: Awaited<ReturnType<typeof getAllOilForConcernPages>>) {
|
||||
const concerns = new Map<string, typeof pages>();
|
||||
|
||||
pages.forEach((page) => {
|
||||
const concernSlug = page.concernSlug;
|
||||
if (!concerns.has(concernSlug)) {
|
||||
concerns.set(concernSlug, []);
|
||||
}
|
||||
concerns.get(concernSlug)?.push(page);
|
||||
});
|
||||
|
||||
return concerns;
|
||||
}
|
||||
|
||||
interface ConcernCardProps {
|
||||
concernSlug: string;
|
||||
concernName: string;
|
||||
oilCount: number;
|
||||
topOils: string[];
|
||||
locale: string;
|
||||
}
|
||||
|
||||
function ConcernCard({ concernSlug, concernName, oilCount, topOils, locale }: ConcernCardProps) {
|
||||
return (
|
||||
<div className="border border-[#e5e5e5] rounded-lg p-6 hover:border-black transition-colors group">
|
||||
<h3 className="text-lg font-medium text-[#1a1a1a] mb-2">
|
||||
{concernName}
|
||||
</h3>
|
||||
<p className="text-sm text-[#666666] mb-4">
|
||||
{oilCount} {oilCount === 1 ? "oil solution" : "oil solutions"} available
|
||||
</p>
|
||||
<div className="space-y-2 mb-4">
|
||||
{topOils.slice(0, 3).map((oilName) => (
|
||||
<div key={oilName} className="flex items-center gap-2 text-sm text-[#666666]">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400" />
|
||||
{oilName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href={`/${locale}/solutions/by-concern/${concernSlug}`}
|
||||
className="inline-flex items-center text-sm font-medium text-[#1a1a1a] group-hover:text-black transition-colors"
|
||||
>
|
||||
View All Solutions
|
||||
<ChevronRight className="ml-1 w-4 h-4 transform group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function ByConcernPage({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "Solutions" });
|
||||
const pageT = await getTranslations({ locale, namespace: "Solutions.ByConcern" });
|
||||
|
||||
const pages = await getAllOilForConcernPages();
|
||||
const concernsMap = groupByConcern(pages);
|
||||
|
||||
const concernsList = Array.from(concernsMap.entries())
|
||||
.map(([slug, pages]) => ({
|
||||
slug,
|
||||
name: getLocalizedString(pages[0].concernName, locale),
|
||||
oilCount: pages.length,
|
||||
topOils: pages.slice(0, 3).map((p) => getLocalizedString(p.oilName, locale)),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<section className="pt-32 pb-16 lg:pt-40 lg:pb-24">
|
||||
<div className="container">
|
||||
<nav className="flex items-center gap-2 text-sm text-[#666666] mb-8">
|
||||
<Link href={`/${locale}`} className="hover:text-black transition-colors">
|
||||
{t("breadcrumb.home")}
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<Link href={`/${locale}/solutions`} className="hover:text-black transition-colors">
|
||||
{t("breadcrumb.solutions")}
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<span className="text-[#1a1a1a]">{t("breadcrumb.byConcern")}</span>
|
||||
</nav>
|
||||
|
||||
<div className="max-w-3xl mb-12">
|
||||
<h1 className="text-4xl lg:text-5xl font-medium tracking-tight text-[#1a1a1a] mb-6">
|
||||
{pageT("title")}
|
||||
</h1>
|
||||
<p className="text-lg text-[#666666] leading-relaxed">
|
||||
{pageT("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#fafafa] border border-[#e5e5e5] rounded-lg p-6 mb-12">
|
||||
<div className="flex items-center gap-3 text-[#666666]">
|
||||
<Search className="w-5 h-5" />
|
||||
<span className="text-sm">
|
||||
{pageT("stats.availableConcerns", { count: concernsList.length })}
|
||||
</span>
|
||||
<span className="text-[#e5e5e5]">|</span>
|
||||
<span className="text-sm">
|
||||
{pageT("stats.totalSolutions", { count: pages.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{concernsList.map((concern) => (
|
||||
<ConcernCard
|
||||
key={concern.slug}
|
||||
concernSlug={concern.slug}
|
||||
concernName={concern.name}
|
||||
oilCount={concern.oilCount}
|
||||
topOils={concern.topOils}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{concernsList.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-[#666666]">{pageT("noResults")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/app/[locale]/solutions/by-oil/page.tsx
Normal file
165
src/app/[locale]/solutions/by-oil/page.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ChevronRight, Droplets } from "lucide-react";
|
||||
import { getAllOilForConcernPages, getLocalizedString } from "@/lib/programmatic-seo/dataLoader";
|
||||
|
||||
type Params = Promise<{ locale: string }>;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "Solutions.ByOil" });
|
||||
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
};
|
||||
}
|
||||
|
||||
function groupByOil(pages: Awaited<ReturnType<typeof getAllOilForConcernPages>>) {
|
||||
const oils = new Map<string, typeof pages>();
|
||||
|
||||
pages.forEach((page) => {
|
||||
const oilSlug = page.oilSlug;
|
||||
if (!oils.has(oilSlug)) {
|
||||
oils.set(oilSlug, []);
|
||||
}
|
||||
oils.get(oilSlug)?.push(page);
|
||||
});
|
||||
|
||||
return oils;
|
||||
}
|
||||
|
||||
interface OilCardProps {
|
||||
oilSlug: string;
|
||||
oilName: string;
|
||||
concernCount: number;
|
||||
topConcerns: string[];
|
||||
locale: string;
|
||||
}
|
||||
|
||||
function OilCard({ oilSlug, oilName, concernCount, topConcerns, locale }: OilCardProps) {
|
||||
return (
|
||||
<div className="border border-[#e5e5e5] rounded-lg p-6 hover:border-black transition-colors group">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-amber-100 flex items-center justify-center">
|
||||
<Droplets className="w-5 h-5 text-amber-700" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-[#1a1a1a]">
|
||||
{oilName}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[#666666] mb-4">
|
||||
{concernCount} {concernCount === 1 ? "concern solution" : "concern solutions"} available
|
||||
</p>
|
||||
<div className="space-y-2 mb-4">
|
||||
<p className="text-xs uppercase tracking-wider text-[#999999] font-medium">
|
||||
Best for:
|
||||
</p>
|
||||
{topConcerns.slice(0, 3).map((concernName) => (
|
||||
<div key={concernName} className="flex items-center gap-2 text-sm text-[#666666]">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
||||
{concernName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href={`/${locale}/solutions/by-oil/${oilSlug}`}
|
||||
className="inline-flex items-center text-sm font-medium text-[#1a1a1a] group-hover:text-black transition-colors"
|
||||
>
|
||||
Explore Oil Solutions
|
||||
<ChevronRight className="ml-1 w-4 h-4 transform group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function ByOilPage({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "Solutions" });
|
||||
const pageT = await getTranslations({ locale, namespace: "Solutions.ByOil" });
|
||||
|
||||
const pages = await getAllOilForConcernPages();
|
||||
const oilsMap = groupByOil(pages);
|
||||
|
||||
const oilsList = Array.from(oilsMap.entries())
|
||||
.map(([slug, pages]) => ({
|
||||
slug,
|
||||
name: getLocalizedString(pages[0].oilName, locale),
|
||||
concernCount: pages.length,
|
||||
topConcerns: pages.slice(0, 3).map((p) => getLocalizedString(p.concernName, locale)),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<section className="pt-32 pb-16 lg:pt-40 lg:pb-24">
|
||||
<div className="container">
|
||||
<nav className="flex items-center gap-2 text-sm text-[#666666] mb-8">
|
||||
<Link href={`/${locale}`} className="hover:text-black transition-colors">
|
||||
{t("breadcrumb.home")}
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<Link href={`/${locale}/solutions`} className="hover:text-black transition-colors">
|
||||
{t("breadcrumb.solutions")}
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<span className="text-[#1a1a1a]">{t("breadcrumb.byOil")}</span>
|
||||
</nav>
|
||||
|
||||
<div className="max-w-3xl mb-12">
|
||||
<h1 className="text-4xl lg:text-5xl font-medium tracking-tight text-[#1a1a1a] mb-6">
|
||||
{pageT("title")}
|
||||
</h1>
|
||||
<p className="text-lg text-[#666666] leading-relaxed">
|
||||
{pageT("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-r from-amber-50 to-emerald-50 border border-[#e5e5e5] rounded-lg p-6 mb-12">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-white flex items-center justify-center shadow-sm">
|
||||
<Droplets className="w-6 h-6 text-amber-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#666666]">
|
||||
{pageT("stats.availableOils", { count: oilsList.length })}
|
||||
</p>
|
||||
<p className="text-sm text-[#666666]">
|
||||
{pageT("stats.totalSolutions", { count: pages.length })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{oilsList.map((oil) => (
|
||||
<OilCard
|
||||
key={oil.slug}
|
||||
oilSlug={oil.slug}
|
||||
oilName={oil.name}
|
||||
concernCount={oil.concernCount}
|
||||
topConcerns={oil.topConcerns}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{oilsList.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-[#666666]">{pageT("noResults")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
291
src/app/[locale]/solutions/page.tsx
Normal file
291
src/app/[locale]/solutions/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ChevronRight, Sparkles, Heart, Leaf, Sun, Moon, Clock, Globe, Users, Droplets, ArrowRight } from "lucide-react";
|
||||
|
||||
type Params = Promise<{ locale: string }>;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "Solutions.Hub" });
|
||||
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
};
|
||||
}
|
||||
|
||||
interface CategoryCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
priority?: boolean;
|
||||
}
|
||||
|
||||
function CategoryCard({ title, description, href, icon, priority }: CategoryCardProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`group block p-6 lg:p-8 border border-[#e5e5e5] rounded-lg hover:border-black transition-all duration-300 hover:shadow-lg ${
|
||||
priority ? "bg-gradient-to-br from-amber-50/50 to-white" : "bg-white"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center ${
|
||||
priority ? "bg-amber-100 text-amber-700" : "bg-[#f5f5f5] text-[#666666] group-hover:bg-black group-hover:text-white"
|
||||
} transition-colors`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-lg font-medium text-[#1a1a1a] group-hover:text-black transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
{priority && (
|
||||
<span className="px-2 py-0.5 text-[10px] uppercase tracking-wider font-medium bg-amber-100 text-amber-700 rounded-full">
|
||||
Popular
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[#666666] leading-relaxed mb-4">
|
||||
{description}
|
||||
</p>
|
||||
<span className="inline-flex items-center text-sm font-medium text-[#1a1a1a] group-hover:text-black transition-colors">
|
||||
{priority ? "Explore Solutions" : "Learn More"}
|
||||
<ArrowRight className="ml-1 w-4 h-4 transform group-hover:translate-x-1 transition-transform" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuickLinkProps {
|
||||
title: string;
|
||||
href: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
function QuickLink({ title, href, count }: QuickLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="flex items-center justify-between p-4 border-b border-[#e5e5e5] hover:bg-[#fafafa] transition-colors group"
|
||||
>
|
||||
<span className="text-[#1a1a1a] group-hover:text-black transition-colors">
|
||||
{title}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{count !== undefined && (
|
||||
<span className="text-xs text-[#999999]">{count} solutions</span>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-[#999999] group-hover:text-black transition-colors" />
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function SolutionsHubPage({
|
||||
params,
|
||||
}: {
|
||||
params: Params;
|
||||
}) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "Solutions" });
|
||||
const hubT = await getTranslations({ locale, namespace: "Solutions.Hub" });
|
||||
|
||||
const categories = [
|
||||
{
|
||||
title: hubT("categories.oilForConcern.title"),
|
||||
description: hubT("categories.oilForConcern.description"),
|
||||
href: `/${locale}/solutions/by-concern`,
|
||||
icon: <Droplets className="w-5 h-5" />,
|
||||
priority: true,
|
||||
},
|
||||
{
|
||||
title: hubT("categories.ageSkinRoutine.title"),
|
||||
description: hubT("categories.ageSkinRoutine.description"),
|
||||
href: `/${locale}/solutions/age-skin-routine`,
|
||||
icon: <Clock className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
title: hubT("categories.ingredientPairings.title"),
|
||||
description: hubT("categories.ingredientPairings.description"),
|
||||
href: `/${locale}/solutions/ingredient-pairings`,
|
||||
icon: <Sparkles className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
title: hubT("categories.bodyPartConcerns.title"),
|
||||
description: hubT("categories.bodyPartConcerns.description"),
|
||||
href: `/${locale}/solutions/body-part-concerns`,
|
||||
icon: <Heart className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
title: hubT("categories.oilComparisons.title"),
|
||||
description: hubT("categories.oilComparisons.description"),
|
||||
href: `/${locale}/solutions/oil-comparisons`,
|
||||
icon: <Users className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
title: hubT("categories.routineStepSkinType.title"),
|
||||
description: hubT("categories.routineStepSkinType.description"),
|
||||
href: `/${locale}/solutions/routine-step-skin-type`,
|
||||
icon: <Leaf className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
title: hubT("categories.seasonalSkincare.title"),
|
||||
description: hubT("categories.seasonalSkincare.description"),
|
||||
href: `/${locale}/solutions/seasonal-skincare`,
|
||||
icon: <Sun className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
title: hubT("categories.timeOfDayConcerns.title"),
|
||||
description: hubT("categories.timeOfDayConcerns.description"),
|
||||
href: `/${locale}/solutions/time-of-day-concerns`,
|
||||
icon: <Moon className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
title: hubT("categories.naturalAlternatives.title"),
|
||||
description: hubT("categories.naturalAlternatives.description"),
|
||||
href: `/${locale}/solutions/natural-alternatives`,
|
||||
icon: <Leaf className="w-5 h-5" />,
|
||||
},
|
||||
{
|
||||
title: hubT("categories.culturalBeautySecrets.title"),
|
||||
description: hubT("categories.culturalBeautySecrets.description"),
|
||||
href: `/${locale}/solutions/cultural-beauty-secrets`,
|
||||
icon: <Globe className="w-5 h-5" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<section className="pt-32 pb-16 lg:pt-40 lg:pb-24">
|
||||
<div className="container">
|
||||
<nav className="flex items-center gap-2 text-sm text-[#666666] mb-8">
|
||||
<Link href={`/${locale}`} className="hover:text-black transition-colors">
|
||||
{t("breadcrumb.home")}
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<span className="text-[#1a1a1a]">{t("breadcrumb.solutions")}</span>
|
||||
</nav>
|
||||
|
||||
<div className="max-w-3xl">
|
||||
<h1 className="text-4xl lg:text-5xl font-medium tracking-tight text-[#1a1a1a] mb-6">
|
||||
{hubT("title")}
|
||||
</h1>
|
||||
<p className="text-lg text-[#666666] leading-relaxed">
|
||||
{hubT("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pb-16 lg:pb-24">
|
||||
<div className="container">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 lg:gap-6">
|
||||
{categories.map((category) => (
|
||||
<CategoryCard key={category.href} {...category} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pb-16 lg:pb-24">
|
||||
<div className="container">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
|
||||
<div className="border border-[#e5e5e5] rounded-lg overflow-hidden">
|
||||
<div className="p-6 bg-[#fafafa] border-b border-[#e5e5e5]">
|
||||
<h2 className="text-lg font-medium text-[#1a1a1a]">
|
||||
{hubT("quickAccess.byConcern")}
|
||||
</h2>
|
||||
<p className="text-sm text-[#666666] mt-1">
|
||||
{hubT("quickAccess.byConcernDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-[#e5e5e5]">
|
||||
<QuickLink
|
||||
title={hubT("quickAccess.links.wrinkles")}
|
||||
href={`/${locale}/solutions/by-concern/wrinkles`}
|
||||
/>
|
||||
<QuickLink
|
||||
title={hubT("quickAccess.links.acne")}
|
||||
href={`/${locale}/solutions/by-concern/acne`}
|
||||
/>
|
||||
<QuickLink
|
||||
title={hubT("quickAccess.links.drySkin")}
|
||||
href={`/${locale}/solutions/by-concern/dry-skin`}
|
||||
/>
|
||||
<QuickLink
|
||||
title={hubT("quickAccess.links.darkSpots")}
|
||||
href={`/${locale}/solutions/by-concern/dark-spots`}
|
||||
/>
|
||||
<QuickLink
|
||||
title={hubT("quickAccess.links.viewAll")}
|
||||
href={`/${locale}/solutions/by-concern`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-[#e5e5e5] rounded-lg overflow-hidden">
|
||||
<div className="p-6 bg-[#fafafa] border-b border-[#e5e5e5]">
|
||||
<h2 className="text-lg font-medium text-[#1a1a1a]">
|
||||
{hubT("quickAccess.byOil")}
|
||||
</h2>
|
||||
<p className="text-sm text-[#666666] mt-1">
|
||||
{hubT("quickAccess.byOilDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-[#e5e5e5]">
|
||||
<QuickLink
|
||||
title={hubT("quickAccess.links.rosehipOil")}
|
||||
href={`/${locale}/solutions/by-oil/rosehip-oil`}
|
||||
/>
|
||||
<QuickLink
|
||||
title={hubT("quickAccess.links.arganOil")}
|
||||
href={`/${locale}/solutions/by-oil/argan-oil`}
|
||||
/>
|
||||
<QuickLink
|
||||
title={hubT("quickAccess.links.jojobaOil")}
|
||||
href={`/${locale}/solutions/by-oil/jojoba-oil`}
|
||||
/>
|
||||
<QuickLink
|
||||
title={hubT("quickAccess.links.seaBuckthornOil")}
|
||||
href={`/${locale}/solutions/by-oil/sea-buckthorn-oil`}
|
||||
/>
|
||||
<QuickLink
|
||||
title={hubT("quickAccess.links.viewAll")}
|
||||
href={`/${locale}/solutions/by-oil`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pb-16 lg:pb-24">
|
||||
<div className="container">
|
||||
<div className="bg-[#1a1a1a] rounded-2xl p-8 lg:p-12 text-center">
|
||||
<h2 className="text-2xl lg:text-3xl font-medium text-white mb-4">
|
||||
{hubT("cta.title")}
|
||||
</h2>
|
||||
<p className="text-[#999999] max-w-xl mx-auto mb-8">
|
||||
{hubT("cta.description")}
|
||||
</p>
|
||||
<Link
|
||||
href={`/${locale}/products`}
|
||||
className="inline-flex items-center justify-center px-8 py-3 bg-white text-[#1a1a1a] font-medium rounded-full hover:bg-[#f5f5f5] transition-colors"
|
||||
>
|
||||
{hubT("cta.button")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||
import { getAllOilForConcernPages } from "@/lib/programmatic-seo/dataLoader";
|
||||
import { SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/locales";
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://manoonoils.com";
|
||||
@@ -105,5 +106,35 @@ export default async function sitemap(): Promise<SitemapEntry[]> {
|
||||
}
|
||||
}
|
||||
|
||||
return [...staticPages, ...productUrls];
|
||||
let solutionPages: any[] = [];
|
||||
try {
|
||||
solutionPages = await getAllOilForConcernPages();
|
||||
} catch (e) {
|
||||
console.log("Failed to fetch solution pages for sitemap during build");
|
||||
}
|
||||
|
||||
const solutionUrls: SitemapEntry[] = [];
|
||||
|
||||
for (const page of solutionPages) {
|
||||
const hreflangs: Record<string, string> = {};
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const path = locale === "sr" ? `/solutions/${page.slug}` : `/${locale}/solutions/${page.slug}`;
|
||||
hreflangs[locale] = `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
for (const locale of SUPPORTED_LOCALES) {
|
||||
const localePrefix = locale === "sr" ? "" : `/${locale}`;
|
||||
solutionUrls.push({
|
||||
url: `${baseUrl}${localePrefix}/solutions/${page.slug}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.7,
|
||||
alternates: {
|
||||
languages: hreflangs,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...staticPages, ...productUrls, ...solutionUrls];
|
||||
}
|
||||
|
||||
@@ -32,11 +32,17 @@ export default function ExitIntentDetector() {
|
||||
try {
|
||||
const response = await fetch("/api/geoip");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCountry(data.country);
|
||||
setCountryCode(data.countryCode);
|
||||
setCity(data.city || "");
|
||||
setRegion(data.region || "");
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
const text = await response.text();
|
||||
if (text && text.trim()) {
|
||||
const data = JSON.parse(text);
|
||||
setCountry(data.country);
|
||||
setCountryCode(data.countryCode);
|
||||
setCity(data.city || "");
|
||||
setRegion(data.region || "");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get country:", error);
|
||||
|
||||
@@ -21,6 +21,12 @@ export default function Footer({ locale = "sr" }: FooterProps) {
|
||||
{ label: t("skinCare"), href: `${localePath}/products` },
|
||||
{ label: t("giftSets"), href: `${localePath}/products` },
|
||||
],
|
||||
solutions: [
|
||||
{ label: t("allSolutions"), href: `${localePath}/solutions` },
|
||||
{ label: t("byConcern"), href: `${localePath}/solutions/by-concern` },
|
||||
{ label: t("byOil"), href: `${localePath}/solutions/by-oil` },
|
||||
{ label: t("skincareGuide"), href: `${localePath}/solutions` },
|
||||
],
|
||||
about: [
|
||||
{ label: t("ourStory"), href: `${localePath}/about` },
|
||||
{ label: t("process"), href: `${localePath}/about` },
|
||||
@@ -74,7 +80,7 @@ export default function Footer({ locale = "sr" }: FooterProps) {
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
|
||||
<div className="flex flex-col">
|
||||
<h4 className="text-xs uppercase tracking-[0.15em] font-medium mb-5 text-[#1a1a1a]">
|
||||
{t("shop")}
|
||||
@@ -93,6 +99,24 @@ export default function Footer({ locale = "sr" }: FooterProps) {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<h4 className="text-xs uppercase tracking-[0.15em] font-medium mb-5 text-[#1a1a1a]">
|
||||
{t("solutions")}
|
||||
</h4>
|
||||
<ul className="space-y-3">
|
||||
{footerLinks.solutions.map((link) => (
|
||||
<li key={link.label}>
|
||||
<Link
|
||||
href={link.href}
|
||||
className="text-sm text-[#666666] hover:text-black transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<h4 className="text-xs uppercase tracking-[0.15em] font-medium mb-5 text-[#1a1a1a]">
|
||||
{t("about")}
|
||||
|
||||
16
src/components/programmatic-seo/FAQSchema.tsx
Normal file
16
src/components/programmatic-seo/FAQSchema.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { generateFAQPageSchema } from "@/lib/programmatic-seo/schema";
|
||||
|
||||
interface FAQSchemaProps {
|
||||
questions: Array<{ question: string; answer: string }>;
|
||||
}
|
||||
|
||||
export function FAQSchema({ questions }: FAQSchemaProps) {
|
||||
const schema = generateFAQPageSchema(questions);
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
157
src/components/programmatic-seo/OilForConcernPage.tsx
Normal file
157
src/components/programmatic-seo/OilForConcernPage.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { Locale } from "@/lib/i18n/locales";
|
||||
import type { OilForConcernPage } from "@/lib/programmatic-seo/types";
|
||||
import type { Product } from "@/types/saleor";
|
||||
import { getLocalizedString, getLocalizedArray } from "@/lib/programmatic-seo/dataLoader";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import ProductReviews from "@/components/product/ProductReviews";
|
||||
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
||||
import ProductsGrid from "./ProductsGrid";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Check, Clock, Droplets } from "lucide-react";
|
||||
|
||||
interface OilForConcernPageProps {
|
||||
page: OilForConcernPage;
|
||||
locale: Locale;
|
||||
basePath: string;
|
||||
products: Product[];
|
||||
}
|
||||
|
||||
export function OilForConcernPageTemplate({ page, locale, basePath, products }: OilForConcernPageProps) {
|
||||
const pageTitle = getLocalizedString(page.pageTitle, locale);
|
||||
const oilName = getLocalizedString(page.oilName, locale);
|
||||
const concernName = getLocalizedString(page.concernName, locale);
|
||||
const whyThisWorks = getLocalizedString(page.whyThisWorks, locale);
|
||||
const keyBenefits = getLocalizedArray(page.keyBenefits, locale);
|
||||
const howToApply = getLocalizedArray(page.howToApply, locale);
|
||||
const expectedResults = getLocalizedString(page.expectedResults, locale);
|
||||
const timeframe = getLocalizedString(page.timeframe, locale);
|
||||
const productsHref = locale === "sr" ? "/products" : `/${locale}/products`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header locale={locale} />
|
||||
<main className="min-h-screen bg-white">
|
||||
<section className="bg-[#FAF9F7] pt-[180px] lg:pt-[200px] pb-16">
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-[#E8DFD0] rounded-full mb-6">
|
||||
<Droplets className="w-4 h-4 text-[#8B7355]" />
|
||||
<span className="text-sm text-[#5C4D3C] font-medium">
|
||||
{locale === "sr" ? "Prirodno rešenje" :
|
||||
locale === "de" ? "Natürliche Lösung" :
|
||||
locale === "fr" ? "Solution naturelle" : "Natural Solution"}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-light text-[#1A1A1A] mb-6 leading-tight">
|
||||
{pageTitle}
|
||||
</h1>
|
||||
<p className="text-lg text-[#666666] max-w-2xl mx-auto mb-8">
|
||||
{whyThisWorks.substring(0, 150)}...
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href={productsHref}
|
||||
className="inline-flex items-center justify-center gap-2 px-8 py-4 bg-[#1A1A1A] text-white text-sm uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors"
|
||||
>
|
||||
{locale === "sr" ? "Kupi proizvode sa " :
|
||||
locale === "de" ? "Produkte mit " :
|
||||
locale === "fr" ? "Acheter des produits avec " : "Shop products with "}
|
||||
{oilName}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ProductReviews locale={locale} productName={oilName} />
|
||||
|
||||
<section className="py-16 lg:py-24">
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-light text-[#1A1A1A] mb-6">
|
||||
{locale === "sr" ? "Zašto " :
|
||||
locale === "de" ? "Warum " :
|
||||
locale === "fr" ? "Pourquoi " : "Why "}
|
||||
{oilName}
|
||||
{locale === "sr" ? " deluje protiv " :
|
||||
locale === "de" ? " gegen " :
|
||||
locale === "fr" ? " contre " : " works for "}
|
||||
{concernName.toLowerCase()}
|
||||
</h2>
|
||||
<p className="text-[#666666] text-lg leading-relaxed mb-8">
|
||||
{whyThisWorks}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 p-4 bg-[#F8F7F5] rounded-lg">
|
||||
<Clock className="w-5 h-5 text-[#8B7355]" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-[#1A1A1A]">
|
||||
{locale === "sr" ? "Vreme rezultata: " :
|
||||
locale === "de" ? "Zeit bis zum Ergebnis: " :
|
||||
locale === "fr" ? "Délai des résultats: " : "Results timeframe: "}
|
||||
</span>
|
||||
<span className="text-sm text-[#666666]">{timeframe}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{keyBenefits.slice(0, 4).map((benefit, index) => (
|
||||
<div key={index} className="p-6 bg-[#FAF9F7] rounded-lg">
|
||||
<Check className="w-6 h-6 text-[#8B7355] mb-3" />
|
||||
<p className="text-[#1A1A1A] font-medium">{benefit}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BeforeAfterGallery />
|
||||
|
||||
<section className="py-16 lg:py-24">
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-3xl md:text-4xl font-light text-[#1A1A1A] text-center mb-12">
|
||||
{locale === "sr" ? "Kako koristiti" :
|
||||
locale === "de" ? "Anwendung" :
|
||||
locale === "fr" ? "Comment utiliser" : "How to use"}
|
||||
</h2>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="space-y-6">
|
||||
{howToApply.map((step, index) => (
|
||||
<div key={index} className="flex gap-4 items-start">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-[#1A1A1A] text-white flex items-center justify-center font-medium">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
<p className="text-[#1A1A1A] text-lg">{step}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16 lg:py-24 bg-[#FAF9F7]">
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<h2 className="text-3xl md:text-4xl font-light text-[#1A1A1A] mb-6">
|
||||
{locale === "sr" ? "Šta možete očekivati" :
|
||||
locale === "de" ? "Was Sie erwarten können" :
|
||||
locale === "fr" ? "Ce que vous pouvez attendre" : "What to expect"}
|
||||
</h2>
|
||||
<p className="text-lg text-[#666666] leading-relaxed">
|
||||
{expectedResults}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ProductsGrid products={products} locale={locale} />
|
||||
</main>
|
||||
<Footer locale={locale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
159
src/components/programmatic-seo/ProductsGrid.tsx
Normal file
159
src/components/programmatic-seo/ProductsGrid.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import type { Product } from "@/types/saleor";
|
||||
import { getProductPrice, getProductImage, getLocalizedProduct } from "@/lib/saleor";
|
||||
import { isValidLocale, getSaleorLocale } from "@/lib/i18n/locales";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
interface ProductsGridProps {
|
||||
products: Product[];
|
||||
locale: string;
|
||||
}
|
||||
|
||||
function ProductCardWithAddToCart({ product, index, locale }: { product: Product; index: number; locale: string }) {
|
||||
const t = useTranslations("ProductCard");
|
||||
const tProduct = useTranslations("Product");
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const { addLine, openCart, setLanguageCode } = useSaleorCheckoutStore();
|
||||
const { trackAddToCart } = useAnalytics();
|
||||
|
||||
const image = getProductImage(product);
|
||||
const price = getProductPrice(product);
|
||||
const saleorLocale = isValidLocale(locale) ? getSaleorLocale(locale) : "SR";
|
||||
const localized = getLocalizedProduct(product, saleorLocale);
|
||||
const variant = product.variants?.[0];
|
||||
const isAvailable = (variant?.quantityAvailable || 0) > 0;
|
||||
const productHref = locale === "sr" ? `/products/${localized.slug}` : `/${locale}/products/${localized.slug}`;
|
||||
|
||||
const handleAddToCart = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!variant?.id) return;
|
||||
|
||||
if (isValidLocale(locale)) {
|
||||
setLanguageCode(locale);
|
||||
}
|
||||
|
||||
setIsAdding(true);
|
||||
try {
|
||||
await addLine(variant.id, 1);
|
||||
|
||||
const priceAmount = variant?.pricing?.price?.gross?.amount || 0;
|
||||
const currency = variant?.pricing?.price?.gross?.currency || "RSD";
|
||||
|
||||
trackAddToCart({
|
||||
id: product.id,
|
||||
name: localized.name,
|
||||
price: priceAmount,
|
||||
currency,
|
||||
quantity: 1,
|
||||
variant: variant.name,
|
||||
});
|
||||
|
||||
openCart();
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="group"
|
||||
>
|
||||
<Link href={productHref} className="block">
|
||||
<div className="relative w-full aspect-square bg-[#f8f9fa] overflow-hidden mb-4">
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt={localized.name}
|
||||
fill
|
||||
className="object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||
loading={index < 4 ? "eager" : "lazy"}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[#999999]">
|
||||
<span className="text-sm">{t("noImage")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isAvailable && (
|
||||
<div className="absolute inset-0 bg-white/80 flex items-center justify-center">
|
||||
<span className="text-sm uppercase tracking-[0.1em] text-[#666666]">
|
||||
{t("outOfStock")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href={productHref}>
|
||||
<h3 className="text-[15px] font-medium text-[#1a1a1a] mb-1 group-hover:text-[#666666] transition-colors line-clamp-1">
|
||||
{localized.name}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
<p className="text-[14px] text-[#666666] mb-3">
|
||||
{price || t("contactForPrice")}
|
||||
</p>
|
||||
|
||||
{isAvailable ? (
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={isAdding}
|
||||
className="w-full py-3 bg-black text-white text-xs uppercase tracking-[0.1em] hover:bg-[#333333] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isAdding ? tProduct("adding") : tProduct("addToCart")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-full py-3 bg-[#f8f9fa] text-[#666666] text-xs uppercase tracking-[0.1em]">
|
||||
{t("outOfStock")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductsGrid({ products, locale }: ProductsGridProps) {
|
||||
const t = useTranslations("Solutions");
|
||||
const validProducts = products.filter(p => p && p.id);
|
||||
|
||||
return (
|
||||
<section className="py-16 lg:py-24 bg-[#1A1A1A]">
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-light text-white mb-4">
|
||||
{t("completeYourRoutine")}
|
||||
</h2>
|
||||
<p className="text-[#999999] max-w-2xl mx-auto">
|
||||
{t("discoverProducts")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||
{validProducts.map((product, index) => (
|
||||
<ProductCardWithAddToCart
|
||||
key={product.id}
|
||||
product={product}
|
||||
index={index}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
57
src/components/solutions/Breadcrumb.tsx
Normal file
57
src/components/solutions/Breadcrumb.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Home } from "lucide-react";
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbProps {
|
||||
items: BreadcrumbItem[];
|
||||
locale: string;
|
||||
showHome?: boolean;
|
||||
}
|
||||
|
||||
export default function Breadcrumb({ items, locale, showHome = true }: BreadcrumbProps) {
|
||||
const allItems = showHome
|
||||
? [{ label: "Home", href: `/${locale}` }, ...items]
|
||||
: items;
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-2 text-sm text-[#666666]" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center gap-2 flex-wrap">
|
||||
{allItems.map((item, index) => {
|
||||
const isLast = index === allItems.length - 1;
|
||||
|
||||
return (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
{index > 0 && <ChevronRight className="w-4 h-4 flex-shrink-0" />}
|
||||
{isLast || !item.href ? (
|
||||
<span className={isLast ? "text-[#1a1a1a]" : ""} aria-current={isLast ? "page" : undefined}>
|
||||
{index === 0 && showHome ? (
|
||||
<Home className="w-4 h-4" />
|
||||
) : (
|
||||
item.label
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="hover:text-black transition-colors"
|
||||
>
|
||||
{index === 0 && showHome ? (
|
||||
<Home className="w-4 h-4" />
|
||||
) : (
|
||||
item.label
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -180,6 +180,11 @@
|
||||
"hairCare": "Haarpflege",
|
||||
"skinCare": "Hautpflege",
|
||||
"giftSets": "Geschenksets",
|
||||
"solutions": "Lösungen",
|
||||
"allSolutions": "Alle Lösungen",
|
||||
"byConcern": "Nach Problem",
|
||||
"byOil": "Nach Öl",
|
||||
"skincareGuide": "Hautpflege-Guide",
|
||||
"about": "Über uns",
|
||||
"ourStory": "Unsere Geschichte",
|
||||
"process": "Prozess",
|
||||
@@ -310,6 +315,10 @@
|
||||
"quickAdd": "Schnell hinzufügen",
|
||||
"contactForPrice": "Preis anfragen"
|
||||
},
|
||||
"Product": {
|
||||
"adding": "Wird hinzugefügt...",
|
||||
"addToCart": "In den Warenkorb"
|
||||
},
|
||||
"ProductDetail": {
|
||||
"home": "Startseite",
|
||||
"outOfStock": "Nicht auf Lager",
|
||||
@@ -439,5 +448,107 @@
|
||||
"description": "Bezahlen Sie per Banküberweisung",
|
||||
"comingSoon": "Demnächst verfügbar"
|
||||
}
|
||||
},
|
||||
"Solutions": {
|
||||
"breadcrumb": {
|
||||
"home": "Startseite",
|
||||
"solutions": "Lösungen",
|
||||
"byConcern": "Nach Problem",
|
||||
"byOil": "Nach Öl"
|
||||
},
|
||||
"Hub": {
|
||||
"metaTitle": "Natürliche Hautpflege-Lösungen | ManoonOils",
|
||||
"metaDescription": "Entdecken Sie natürliche Öl-Lösungen für jedes Hautproblem. Durchsuchen Sie nach Problem, Öltyp oder erkunden Sie unsere umfassenden Hautpflege-Guides.",
|
||||
"title": "Natürliche Hautpflege-Lösungen",
|
||||
"subtitle": "Entdecken Sie die perfekten natürlichen Öl-Lösungen für Ihre Hautprobleme. Unsere fachkundig erstellten Guides helfen Ihnen, die richtigen Öle für Falten, Akne, Trockenheit und mehr zu finden.",
|
||||
"categories": {
|
||||
"oilForConcern": {
|
||||
"title": "Öl für Problem",
|
||||
"description": "Finden Sie die besten natürlichen Öle für spezifische Hautprobleme wie Falten, Akne, dunkle Flecken und mehr."
|
||||
},
|
||||
"ageSkinRoutine": {
|
||||
"title": "Alter-Haut Routine",
|
||||
"description": "Personalisierte Hautpflege-Routinen basierend auf Ihrem Alter und Hauttyp für optimale Ergebnisse."
|
||||
},
|
||||
"ingredientPairings": {
|
||||
"title": "Inhaltsstoff-Kombinationen",
|
||||
"description": "Lernen Sie, welche natürlichen Inhaltsstoffe am besten zusammenwirken für verbesserte Hautpflege-Vorteile."
|
||||
},
|
||||
"bodyPartConcerns": {
|
||||
"title": "Körperteil-Probleme",
|
||||
"description": "Gezielte Lösungen für spezifische Körperbereiche wie Gesicht, Hals, Hände und mehr."
|
||||
},
|
||||
"oilComparisons": {
|
||||
"title": "Öl-Vergleiche",
|
||||
"description": "Vergleiche nebeneinander, um Ihnen bei der Wahl zwischen verschiedenen natürlichen Ölen zu helfen."
|
||||
},
|
||||
"routineStepSkinType": {
|
||||
"title": "Routine nach Hauttyp",
|
||||
"description": "Schritt-für-Schritt-Anleitung, zugeschnitten auf Ihren spezifischen Hauttyp und Ihre Probleme."
|
||||
},
|
||||
"seasonalSkincare": {
|
||||
"title": "Saisonale Hautpflege",
|
||||
"description": "Passen Sie Ihre Routine den Jahreszeiten an für gesunde Haut das ganze Jahr über."
|
||||
},
|
||||
"timeOfDayConcerns": {
|
||||
"title": "Tageszeit",
|
||||
"description": "Morgen- und Abend-Hautpflege-Routinen für maximale Wirksamkeit."
|
||||
},
|
||||
"naturalAlternatives": {
|
||||
"title": "Natürliche Alternativen",
|
||||
"description": "Entdecken Sie natürliche Alternativen zu synthetischen Hautpflege-Inhaltsstoffen."
|
||||
},
|
||||
"culturalBeautySecrets": {
|
||||
"title": "Kulturelle Schönheitsgeheimnisse",
|
||||
"description": "Uralte Schönheitsweisheit aus der ganzen Welt mit natürlichen Ölen."
|
||||
}
|
||||
},
|
||||
"quickAccess": {
|
||||
"byConcern": "Nach Problem durchsuchen",
|
||||
"byConcernDesc": "Finden Sie Lösungen für Ihre spezifischen Hautprobleme",
|
||||
"byOil": "Nach Öl durchsuchen",
|
||||
"byOilDesc": "Erkunden Sie die Vorteile verschiedener natürlicher Öle",
|
||||
"links": {
|
||||
"wrinkles": "Falten & Aging",
|
||||
"acne": "Akne & Unreinheiten",
|
||||
"drySkin": "Trockene Haut",
|
||||
"darkSpots": "Dunkle Flecken",
|
||||
"viewAll": "Alle ansehen →",
|
||||
"rosehipOil": "Hagebuttenöl",
|
||||
"arganOil": "Arganöl",
|
||||
"jojobaOil": "Jojobaöl",
|
||||
"seaBuckthornOil": "Sanddornöl"
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Bereit, Ihre Haut zu verwandeln?",
|
||||
"description": "Durchstöbern Sie unsere Kollektion von Premium-Naturölen und beginnen Sie noch heute Ihre Reise zu gesünderer, strahlender Haut.",
|
||||
"button": "Naturöle kaufen"
|
||||
}
|
||||
},
|
||||
"ByConcern": {
|
||||
"metaTitle": "Hautpflege-Lösungen nach Problem | ManoonOils",
|
||||
"metaDescription": "Durchsuchen Sie natürliche Öl-Lösungen nach Hautproblem organisiert. Finden Sie das perfekte Heilmittel für Falten, Akne, Trockenheit und mehr.",
|
||||
"title": "Lösungen nach Problem",
|
||||
"subtitle": "Erkunden Sie unsere umfassende Kollektion natürlicher Öl-Lösungen, organisiert nach Hautproblem, um Ihnen zu helfen, genau das zu finden, was Sie brauchen.",
|
||||
"stats": {
|
||||
"availableConcerns": "{count} Hautprobleme abgedeckt",
|
||||
"totalSolutions": "{count} fachkundig kuratierte Lösungen"
|
||||
},
|
||||
"noResults": "Keine Probleme gefunden. Bitte schauen Sie später für neue Lösungen vorbei."
|
||||
},
|
||||
"ByOil": {
|
||||
"metaTitle": "Hautpflege-Lösungen nach Öl | ManoonOils",
|
||||
"metaDescription": "Entdecken Sie die Vorteile verschiedener natürlicher Öle für verschiedene Hautprobleme. Finden Sie heraus, welches Öl das richtige für Sie ist.",
|
||||
"title": "Lösungen nach Öl",
|
||||
"subtitle": "Lernen Sie die einzigartigen Eigenschaften jedes natürlichen Öls kennen und entdecken Sie, welche am besten für Ihre Hautprobleme geeignet sind.",
|
||||
"stats": {
|
||||
"availableOils": "{count} natürliche Öle",
|
||||
"totalSolutions": "{count} fachkundig kuratierte Lösungen"
|
||||
},
|
||||
"noResults": "Keine Öle gefunden. Bitte schauen Sie später für neue Lösungen vorbei."
|
||||
},
|
||||
"completeYourRoutine": "Vervollständigen Sie Ihre Routine",
|
||||
"discoverProducts": "Entdecken Sie unsere Premium-Produkte mit natürlichen Inhaltsstoffen"
|
||||
}
|
||||
}
|
||||
@@ -319,6 +319,11 @@
|
||||
"hairCare": "Hair Care",
|
||||
"skinCare": "Skin Care",
|
||||
"giftSets": "Gift Sets",
|
||||
"solutions": "Solutions",
|
||||
"allSolutions": "All Solutions",
|
||||
"byConcern": "By Concern",
|
||||
"byOil": "By Oil",
|
||||
"skincareGuide": "Skincare Guide",
|
||||
"about": "About",
|
||||
"ourStory": "Our Story",
|
||||
"process": "Process",
|
||||
@@ -339,6 +344,10 @@
|
||||
"quickAdd": "Quick Add",
|
||||
"contactForPrice": "Contact for price"
|
||||
},
|
||||
"Product": {
|
||||
"adding": "Adding...",
|
||||
"addToCart": "Add to Cart"
|
||||
},
|
||||
"ProductDetail": {
|
||||
"home": "Home",
|
||||
"outOfStock": "Out of Stock",
|
||||
@@ -494,5 +503,107 @@
|
||||
"goHome": "Go Home",
|
||||
"lookingFor": "Can't find what you're looking for?",
|
||||
"searchSuggestion": "Try browsing our product collection or contact us for assistance."
|
||||
},
|
||||
"Solutions": {
|
||||
"breadcrumb": {
|
||||
"home": "Home",
|
||||
"solutions": "Solutions",
|
||||
"byConcern": "By Concern",
|
||||
"byOil": "By Oil"
|
||||
},
|
||||
"Hub": {
|
||||
"metaTitle": "Natural Skincare Solutions | ManoonOils",
|
||||
"metaDescription": "Discover natural oil solutions for every skin concern. Browse by concern, oil type, or explore our comprehensive skincare guides.",
|
||||
"title": "Natural Skincare Solutions",
|
||||
"subtitle": "Discover the perfect natural oil solutions for your skin concerns. Our expertly crafted guides help you find the right oils for wrinkles, acne, dryness, and more.",
|
||||
"categories": {
|
||||
"oilForConcern": {
|
||||
"title": "Oil for Concern",
|
||||
"description": "Find the best natural oils for specific skin concerns like wrinkles, acne, dark spots, and more."
|
||||
},
|
||||
"ageSkinRoutine": {
|
||||
"title": "Age-Skin Routine",
|
||||
"description": "Personalized skincare routines based on your age and skin type for optimal results."
|
||||
},
|
||||
"ingredientPairings": {
|
||||
"title": "Ingredient Pairings",
|
||||
"description": "Learn which natural ingredients work best together for enhanced skincare benefits."
|
||||
},
|
||||
"bodyPartConcerns": {
|
||||
"title": "Body Part Concerns",
|
||||
"description": "Targeted solutions for specific body areas like face, neck, hands, and more."
|
||||
},
|
||||
"oilComparisons": {
|
||||
"title": "Oil Comparisons",
|
||||
"description": "Side-by-side comparisons to help you choose between different natural oils."
|
||||
},
|
||||
"routineStepSkinType": {
|
||||
"title": "Routine Step by Skin Type",
|
||||
"description": "Step-by-step guidance tailored to your specific skin type and concerns."
|
||||
},
|
||||
"seasonalSkincare": {
|
||||
"title": "Seasonal Skincare",
|
||||
"description": "Adjust your routine with the seasons for year-round healthy skin."
|
||||
},
|
||||
"timeOfDayConcerns": {
|
||||
"title": "Time of Day Concerns",
|
||||
"description": "Morning and evening skincare routines for maximum effectiveness."
|
||||
},
|
||||
"naturalAlternatives": {
|
||||
"title": "Natural Alternatives",
|
||||
"description": "Discover natural alternatives to synthetic skincare ingredients."
|
||||
},
|
||||
"culturalBeautySecrets": {
|
||||
"title": "Cultural Beauty Secrets",
|
||||
"description": "Ancient beauty wisdom from around the world using natural oils."
|
||||
}
|
||||
},
|
||||
"quickAccess": {
|
||||
"byConcern": "Browse by Concern",
|
||||
"byConcernDesc": "Find solutions for your specific skin concerns",
|
||||
"byOil": "Browse by Oil",
|
||||
"byOilDesc": "Explore benefits of different natural oils",
|
||||
"links": {
|
||||
"wrinkles": "Wrinkles & Aging",
|
||||
"acne": "Acne & Blemishes",
|
||||
"drySkin": "Dry Skin",
|
||||
"darkSpots": "Dark Spots",
|
||||
"viewAll": "View All →",
|
||||
"rosehipOil": "Rosehip Oil",
|
||||
"arganOil": "Argan Oil",
|
||||
"jojobaOil": "Jojoba Oil",
|
||||
"seaBuckthornOil": "Sea Buckthorn Oil"
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Ready to Transform Your Skin?",
|
||||
"description": "Browse our collection of premium natural oils and start your journey to healthier, more radiant skin today.",
|
||||
"button": "Shop Natural Oils"
|
||||
}
|
||||
},
|
||||
"ByConcern": {
|
||||
"metaTitle": "Skincare Solutions by Concern | ManoonOils",
|
||||
"metaDescription": "Browse natural oil solutions organized by skin concern. Find the perfect remedy for wrinkles, acne, dryness, and more.",
|
||||
"title": "Solutions by Concern",
|
||||
"subtitle": "Explore our comprehensive collection of natural oil solutions, organized by skin concern to help you find exactly what you need.",
|
||||
"stats": {
|
||||
"availableConcerns": "{count} skin concerns covered",
|
||||
"totalSolutions": "{count} expert-curated solutions"
|
||||
},
|
||||
"noResults": "No concerns found. Please check back later for new solutions."
|
||||
},
|
||||
"ByOil": {
|
||||
"metaTitle": "Skincare Solutions by Oil | ManoonOils",
|
||||
"metaDescription": "Discover the benefits of different natural oils for various skin concerns. Find which oil is right for you.",
|
||||
"title": "Solutions by Oil",
|
||||
"subtitle": "Learn about the unique properties of each natural oil and discover which ones are best suited for your skin concerns.",
|
||||
"stats": {
|
||||
"availableOils": "{count} natural oils",
|
||||
"totalSolutions": "{count} expert-curated solutions"
|
||||
},
|
||||
"noResults": "No oils found. Please check back later for new solutions."
|
||||
},
|
||||
"completeYourRoutine": "Complete Your Routine",
|
||||
"discoverProducts": "Discover our premium products with natural ingredients"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,11 @@
|
||||
"hairCare": "Soins Capillaires",
|
||||
"skinCare": "Soins Cutanés",
|
||||
"giftSets": "Coffrets Cadeaux",
|
||||
"solutions": "Solutions",
|
||||
"allSolutions": "Toutes les Solutions",
|
||||
"byConcern": "Par Problème",
|
||||
"byOil": "Par Huile",
|
||||
"skincareGuide": "Guide Soins",
|
||||
"about": "À Propos",
|
||||
"ourStory": "Notre Histoire",
|
||||
"process": "Processus",
|
||||
@@ -310,6 +315,10 @@
|
||||
"quickAdd": "Ajout Rapide",
|
||||
"contactForPrice": "Contacter pour le prix"
|
||||
},
|
||||
"Product": {
|
||||
"adding": "Ajout en cours...",
|
||||
"addToCart": "Ajouter au Panier"
|
||||
},
|
||||
"ProductDetail": {
|
||||
"home": "Accueil",
|
||||
"outOfStock": "Rupture de Stock",
|
||||
@@ -439,5 +448,107 @@
|
||||
"description": "Payez par virement bancaire",
|
||||
"comingSoon": "Bientôt disponible"
|
||||
}
|
||||
},
|
||||
"Solutions": {
|
||||
"breadcrumb": {
|
||||
"home": "Accueil",
|
||||
"solutions": "Solutions",
|
||||
"byConcern": "Par Problème",
|
||||
"byOil": "Par Huile"
|
||||
},
|
||||
"Hub": {
|
||||
"metaTitle": "Solutions Naturelles pour la Peau | ManoonOils",
|
||||
"metaDescription": "Découvrez les solutions à base d'huiles naturelles pour chaque problème de peau. Parcourez par problème, type d'huile ou explorez nos guides complets de soins.",
|
||||
"title": "Solutions Naturelles pour la Peau",
|
||||
"subtitle": "Découvrez les solutions d'huiles naturelles parfaites pour vos problèmes de peau. Nos guides créés par des experts vous aident à trouver les bonnes huiles pour les rides, l'acné, la sécheresse et plus encore.",
|
||||
"categories": {
|
||||
"oilForConcern": {
|
||||
"title": "Huile pour Problème",
|
||||
"description": "Trouvez les meilleures huiles naturelles pour des problèmes de peau spécifiques comme les rides, l'acné, les taches sombres et plus encore."
|
||||
},
|
||||
"ageSkinRoutine": {
|
||||
"title": "Routine Âge-Peau",
|
||||
"description": "Routines de soins personnalisées basées sur votre âge et type de peau pour des résultats optimaux."
|
||||
},
|
||||
"ingredientPairings": {
|
||||
"title": "Associations d'Ingrédients",
|
||||
"description": "Apprenez quels ingrédients naturels fonctionnent le mieux ensemble pour des bienfaits améliorés."
|
||||
},
|
||||
"bodyPartConcerns": {
|
||||
"title": "Problèmes par Partie du Corps",
|
||||
"description": "Solutions ciblées pour des zones spécifiques comme le visage, le cou, les mains et plus encore."
|
||||
},
|
||||
"oilComparisons": {
|
||||
"title": "Comparaisons d'Huiles",
|
||||
"description": "Comparaisons côte à côte pour vous aider à choisir entre différentes huiles naturelles."
|
||||
},
|
||||
"routineStepSkinType": {
|
||||
"title": "Routine par Type de Peau",
|
||||
"description": "Guide étape par étape adapté à votre type de peau spécifique et à vos problèmes."
|
||||
},
|
||||
"seasonalSkincare": {
|
||||
"title": "Soins Saisonniers",
|
||||
"description": "Adaptez votre routine aux saisons pour une peau saine toute l'année."
|
||||
},
|
||||
"timeOfDayConcerns": {
|
||||
"title": "Moment de la Journée",
|
||||
"description": "Routines de soins matinales et du soir pour une efficacité maximale."
|
||||
},
|
||||
"naturalAlternatives": {
|
||||
"title": "Alternatives Naturelles",
|
||||
"description": "Découvrez des alternatives naturelles aux ingrédients synthétiques de soins."
|
||||
},
|
||||
"culturalBeautySecrets": {
|
||||
"title": "Secrets de Beauté Culturels",
|
||||
"description": "Sagesse beauté ancestrale du monde entier utilisant des huiles naturelles."
|
||||
}
|
||||
},
|
||||
"quickAccess": {
|
||||
"byConcern": "Parcourir par Problème",
|
||||
"byConcernDesc": "Trouvez des solutions pour vos problèmes de peau spécifiques",
|
||||
"byOil": "Parcourir par Huile",
|
||||
"byOilDesc": "Explorez les bienfaits des différentes huiles naturelles",
|
||||
"links": {
|
||||
"wrinkles": "Rides & Vieillissement",
|
||||
"acne": "Acné & Imperfections",
|
||||
"drySkin": "Peau Sèche",
|
||||
"darkSpots": "Taches Sombres",
|
||||
"viewAll": "Voir Tout →",
|
||||
"rosehipOil": "Huile de Rose Musquée",
|
||||
"arganOil": "Huile d'Argan",
|
||||
"jojobaOil": "Huile de Jojoba",
|
||||
"seaBuckthornOil": "Huile d'Argousier"
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Prêt à Transformer Votre Peau?",
|
||||
"description": "Parcourez notre collection d'huiles naturelles premium et commencez votre voyage vers une peau plus saine et éclatante dès aujourd'hui.",
|
||||
"button": "Acheter les Huiles Naturelles"
|
||||
}
|
||||
},
|
||||
"ByConcern": {
|
||||
"metaTitle": "Solutions Soins par Problème | ManoonOils",
|
||||
"metaDescription": "Parcourez les solutions d'huiles naturelles organisées par problème de peau. Trouvez le remède parfait pour les rides, l'acné, la sécheresse et plus encore.",
|
||||
"title": "Solutions par Problème",
|
||||
"subtitle": "Explorez notre collection complète de solutions d'huiles naturelles, organisées par problème de peau pour vous aider à trouver exactement ce dont vous avez besoin.",
|
||||
"stats": {
|
||||
"availableConcerns": "{count} problèmes de peau couverts",
|
||||
"totalSolutions": "{count} solutions sélectionnées par des experts"
|
||||
},
|
||||
"noResults": "Aucun problème trouvé. Veuillez vérifier plus tard pour de nouvelles solutions."
|
||||
},
|
||||
"ByOil": {
|
||||
"metaTitle": "Solutions Soins par Huile | ManoonOils",
|
||||
"metaDescription": "Découvrez les bienfaits des différentes huiles naturelles pour divers problèmes de peau. Trouvez quelle huile est la bonne pour vous.",
|
||||
"title": "Solutions par Huile",
|
||||
"subtitle": "Apprenez les propriétés uniques de chaque huile naturelle et découvrez lesquelles conviennent le mieux à vos problèmes de peau.",
|
||||
"stats": {
|
||||
"availableOils": "{count} huiles naturelles",
|
||||
"totalSolutions": "{count} solutions sélectionnées par des experts"
|
||||
},
|
||||
"noResults": "Aucune huile trouvée. Veuillez vérifier plus tard pour de nouvelles solutions."
|
||||
},
|
||||
"completeYourRoutine": "Complétez votre routine",
|
||||
"discoverProducts": "Découvrez nos produits premium aux ingrédients naturels"
|
||||
}
|
||||
}
|
||||
@@ -319,6 +319,11 @@
|
||||
"hairCare": "Nega kose",
|
||||
"skinCare": "Nega kože",
|
||||
"giftSets": "Poklon setovi",
|
||||
"solutions": "Rešenja",
|
||||
"allSolutions": "Sva rešenja",
|
||||
"byConcern": "Po problemu",
|
||||
"byOil": "Po ulju",
|
||||
"skincareGuide": "Vodič za negu",
|
||||
"about": "O nama",
|
||||
"ourStory": "Naša priča",
|
||||
"process": "Proces",
|
||||
@@ -335,10 +340,14 @@
|
||||
},
|
||||
"ProductCard": {
|
||||
"noImage": "Nema slike",
|
||||
"outOfStock": "Nema na stanju",
|
||||
"outOfStock": "Nema na zalihama",
|
||||
"quickAdd": "Brzo dodavanje",
|
||||
"contactForPrice": "Kontaktirajte za cenu"
|
||||
},
|
||||
"Product": {
|
||||
"adding": "Dodavanje...",
|
||||
"addToCart": "Dodaj u korpu"
|
||||
},
|
||||
"ProductDetail": {
|
||||
"home": "Početna",
|
||||
"outOfStock": "Nema na stanju",
|
||||
@@ -493,5 +502,107 @@
|
||||
"goHome": "Početna Strana",
|
||||
"lookingFor": "Ne možete da pronađete ono što tražite?",
|
||||
"searchSuggestion": "Pokušajte da pregledate našu kolekciju proizvoda ili nas kontaktirajte za pomoć."
|
||||
},
|
||||
"Solutions": {
|
||||
"breadcrumb": {
|
||||
"home": "Početna",
|
||||
"solutions": "Rešenja",
|
||||
"byConcern": "Po problemu",
|
||||
"byOil": "Po ulju"
|
||||
},
|
||||
"Hub": {
|
||||
"metaTitle": "Prirodna rešenja za negu kože | ManoonOils",
|
||||
"metaDescription": "Otkrijte prirodna uljana rešenja za svaki problem kože. Pretražujte po problemu, vrsti ulja ili istražite naše sveobuhvatne vodiče za negu kože.",
|
||||
"title": "Prirodna rešenja za negu kože",
|
||||
"subtitle": "Otkrijte savršena prirodna uljana rešenja za vaše probleme sa kožom. Naši stručno izrađeni vodiči pomažu vam da pronađete prava ulja za bore, akne, suvu kožu i još mnogo toga.",
|
||||
"categories": {
|
||||
"oilForConcern": {
|
||||
"title": "Ulje za problem",
|
||||
"description": "Pronađite najbolja prirodna ulja za specifične probleme kože poput bora, akni, tamnih fleka i još mnogo toga."
|
||||
},
|
||||
"ageSkinRoutine": {
|
||||
"title": "Rutina prema uzrastu",
|
||||
"description": "Personalizovane rutine nege kože na osnovu vašeg uzrasta i tipa kože za optimalne rezultate."
|
||||
},
|
||||
"ingredientPairings": {
|
||||
"title": "Kombinacije sastojaka",
|
||||
"description": "Saznajte koji prirodni sastojci najbolje rade zajedno za poboljšane koristi za kožu."
|
||||
},
|
||||
"bodyPartConcerns": {
|
||||
"title": "Problemi po delovima tela",
|
||||
"description": "Ciljana rešenja za specifične delove tela poput lica, vrata, ruku i još mnogo toga."
|
||||
},
|
||||
"oilComparisons": {
|
||||
"title": "Poređenje ulja",
|
||||
"description": "Poređenja jedno pored drugog da vam pomognu da izaberete između različitih prirodnih ulja."
|
||||
},
|
||||
"routineStepSkinType": {
|
||||
"title": "Rutina prema tipu kože",
|
||||
"description": "Vodič korak po korak prilagođen vašem specifičnom tipu kože i problemima."
|
||||
},
|
||||
"seasonalSkincare": {
|
||||
"title": "Sezonska nega kože",
|
||||
"description": "Prilagodite svoju rutinu godišnjim dobima za zdravu kožu tokom cele godine."
|
||||
},
|
||||
"timeOfDayConcerns": {
|
||||
"title": "Vreme dana",
|
||||
"description": "Jutarnje i večernje rutine nege kože za maksimalnu efikasnost."
|
||||
},
|
||||
"naturalAlternatives": {
|
||||
"title": "Prirodne alternative",
|
||||
"description": "Otkrijte prirodne alternative sintetičkim sastojcima za negu kože."
|
||||
},
|
||||
"culturalBeautySecrets": {
|
||||
"title": "Kulturne tajne lepote",
|
||||
"description": "Drevna mudrost lepote iz celog sveta korišćenjem prirodnih ulja."
|
||||
}
|
||||
},
|
||||
"quickAccess": {
|
||||
"byConcern": "Pretraži po problemu",
|
||||
"byConcernDesc": "Pronađi rešenja za svoje probleme sa kožom",
|
||||
"byOil": "Pretraži po ulju",
|
||||
"byOilDesc": "Istraži prednosti različitih prirodnih ulja",
|
||||
"links": {
|
||||
"wrinkles": "Bore i starenje",
|
||||
"acne": "Akne i nesavršenstva",
|
||||
"drySkin": "Suva koža",
|
||||
"darkSpots": "Tamne fleke",
|
||||
"viewAll": "Pogledaj sve →",
|
||||
"rosehipOil": "Ulje divlje ruže",
|
||||
"arganOil": "Arganovo ulje",
|
||||
"jojobaOil": "Jojoba ulje",
|
||||
"seaBuckthornOil": "Ulje pasjeg trna"
|
||||
}
|
||||
},
|
||||
"cta": {
|
||||
"title": "Spremni za transformaciju kože?",
|
||||
"description": "Pregledajte našu kolekciju premium prirodnih ulja i započnite svoje putovanje ka zdravijoj, sjajnijoj koži već danas.",
|
||||
"button": "Kupi prirodna ulja"
|
||||
}
|
||||
},
|
||||
"ByConcern": {
|
||||
"metaTitle": "Rešenja za negu kože po problemu | ManoonOils",
|
||||
"metaDescription": "Pregledajte prirodna uljana rešenja organizovana po problemima kože. Pronađite savršen lek za bore, akne, suvu kožu i još mnogo toga.",
|
||||
"title": "Rešenja po problemu",
|
||||
"subtitle": "Istražite našu sveobuhvatnu kolekciju prirodnih uljanih rešenja, organizovanih po problemima kože da vam pomognemo da pronađete tačno ono što vam treba.",
|
||||
"stats": {
|
||||
"availableConcerns": "{count} problema kože pokriveno",
|
||||
"totalSolutions": "{count} stručno odabranih rešenja"
|
||||
},
|
||||
"noResults": "Nema pronađenih problema. Proverite ponovo kasnije za nova rešenja."
|
||||
},
|
||||
"ByOil": {
|
||||
"metaTitle": "Rešenja za negu kože po ulju | ManoonOils",
|
||||
"metaDescription": "Otkrijte prednosti različitih prirodnih ulja za različite probleme kože. Pronađite koje ulje je pravo za vas.",
|
||||
"title": "Rešenja po ulju",
|
||||
"subtitle": "Saznajte o jedinstvenim svojstvima svakog prirodnog ulja i otkrijte koja su najpogodnija za vaše probleme sa kožom.",
|
||||
"stats": {
|
||||
"availableOils": "{count} prirodnih ulja",
|
||||
"totalSolutions": "{count} stručno odabranih rešenja"
|
||||
},
|
||||
"noResults": "Nema pronađenih ulja. Proverite ponovo kasnije za nova rešenja."
|
||||
},
|
||||
"completeYourRoutine": "Dovršite svoju rutinu",
|
||||
"discoverProducts": "Otkrijte naše premium proizvode sa prirodnim sastojcima"
|
||||
}
|
||||
}
|
||||
|
||||
79
src/lib/programmatic-seo/dataLoader.ts
Normal file
79
src/lib/programmatic-seo/dataLoader.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import type {
|
||||
OilForConcernPage,
|
||||
LocalizedSEOKeywords
|
||||
} from "./types";
|
||||
|
||||
const DATA_DIR = join(process.cwd(), "data");
|
||||
|
||||
export function getLocalizedString(
|
||||
localized: { sr: string; en: string; de: string; fr: string },
|
||||
locale: string
|
||||
): string {
|
||||
return localized[locale as keyof typeof localized] || localized.en;
|
||||
}
|
||||
|
||||
export function getLocalizedArray(
|
||||
localized: { sr: string[]; en: string[]; de: string[]; fr: string[] },
|
||||
locale: string
|
||||
): string[] {
|
||||
return localized[locale as keyof typeof localized] || localized.en;
|
||||
}
|
||||
|
||||
export function getLocalizedKeywords(
|
||||
seoKeywords: LocalizedSEOKeywords,
|
||||
locale: string
|
||||
): string[] {
|
||||
const keywords = seoKeywords[locale as keyof LocalizedSEOKeywords] || seoKeywords.en;
|
||||
return [...keywords.primary, ...keywords.secondary, ...keywords.longTail];
|
||||
}
|
||||
|
||||
export async function getOilForConcernPage(slug: string): Promise<OilForConcernPage | null> {
|
||||
try {
|
||||
const filePath = join(DATA_DIR, "oil-for-concern", `${slug}.json`);
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
return JSON.parse(content) as OilForConcernPage;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load oil-for-concern page: ${slug}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllOilForConcernSlugs(): Promise<string[]> {
|
||||
try {
|
||||
const { readdir } = await import("fs/promises");
|
||||
const dirPath = join(DATA_DIR, "oil-for-concern");
|
||||
const files = await readdir(dirPath);
|
||||
return files
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) => file.replace(".json", ""));
|
||||
} catch (error) {
|
||||
console.error("Failed to load oil-for-concern slugs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllOilForConcernPages(): Promise<OilForConcernPage[]> {
|
||||
const slugs = await getAllOilForConcernSlugs();
|
||||
const pages = await Promise.all(
|
||||
slugs.map((slug) => getOilForConcernPage(slug))
|
||||
);
|
||||
return pages.filter((page): page is OilForConcernPage => page !== null);
|
||||
}
|
||||
|
||||
export async function getAllSolutionSlugs(): Promise<Array<{ locale: string; slug: string }>> {
|
||||
const slugs = await getAllOilForConcernSlugs();
|
||||
const result: Array<{ locale: string; slug: string }> = [];
|
||||
|
||||
for (const slug of slugs) {
|
||||
for (const locale of ["sr", "en", "de", "fr"] as const) {
|
||||
result.push({
|
||||
locale,
|
||||
slug
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
30
src/lib/programmatic-seo/schema.ts
Normal file
30
src/lib/programmatic-seo/schema.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface FAQPageSchema {
|
||||
"@context": "https://schema.org";
|
||||
"@type": "FAQPage";
|
||||
mainEntity: Array<{
|
||||
"@type": "Question";
|
||||
name: string;
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer";
|
||||
text: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export function generateFAQPageSchema(
|
||||
questions: Array<{ question: string; answer: string }>
|
||||
): FAQPageSchema {
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
mainEntity: questions.map((q) => ({
|
||||
"@type": "Question",
|
||||
name: q.question,
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: q.answer,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
388
src/lib/programmatic-seo/types.ts
Normal file
388
src/lib/programmatic-seo/types.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
export interface LocalizedString {
|
||||
sr: string;
|
||||
en: string;
|
||||
de: string;
|
||||
fr: string;
|
||||
}
|
||||
|
||||
export interface LocalizedArray {
|
||||
sr: string[];
|
||||
en: string[];
|
||||
de: string[];
|
||||
fr: string[];
|
||||
}
|
||||
|
||||
export interface SEOKeywords {
|
||||
primary: string[];
|
||||
secondary: string[];
|
||||
longTail: string[];
|
||||
}
|
||||
|
||||
export interface LocalizedSEOKeywords {
|
||||
sr: SEOKeywords;
|
||||
en: SEOKeywords;
|
||||
de: SEOKeywords;
|
||||
fr: SEOKeywords;
|
||||
}
|
||||
|
||||
export interface FAQ {
|
||||
question: LocalizedString;
|
||||
answer: LocalizedString;
|
||||
}
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RoutineStep {
|
||||
step: number;
|
||||
name: LocalizedString;
|
||||
description: LocalizedString;
|
||||
products: string[];
|
||||
technique?: LocalizedString;
|
||||
}
|
||||
|
||||
export interface ComparisonPoint {
|
||||
category: LocalizedString;
|
||||
oilAWins: boolean;
|
||||
oilBWins: boolean;
|
||||
explanation: LocalizedString;
|
||||
}
|
||||
|
||||
export interface Testimonial {
|
||||
quote: LocalizedString;
|
||||
name: string;
|
||||
age?: number;
|
||||
skinType?: string;
|
||||
timeframe?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export interface OilForConcernPage {
|
||||
slug: string;
|
||||
oilSlug: string;
|
||||
concernSlug: string;
|
||||
pageTitle: LocalizedString;
|
||||
metaTitle: LocalizedString;
|
||||
metaDescription: LocalizedString;
|
||||
oilName: LocalizedString;
|
||||
concernName: LocalizedString;
|
||||
whyThisWorks: LocalizedString;
|
||||
keyBenefits: LocalizedArray;
|
||||
howToApply: LocalizedArray;
|
||||
expectedResults: LocalizedString;
|
||||
timeframe: LocalizedString;
|
||||
complementaryIngredients: string[];
|
||||
productsToShow: string[];
|
||||
customerResults: Testimonial[];
|
||||
faqs: FAQ[];
|
||||
seoKeywords: LocalizedSEOKeywords;
|
||||
relatedPages: {
|
||||
otherOilsForSameConcern: string[];
|
||||
sameOilForOtherConcerns: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgeSkinTypeRoutinePage {
|
||||
slug: string;
|
||||
ageRange: string;
|
||||
skinType: string;
|
||||
season?: string;
|
||||
pageTitle: LocalizedString;
|
||||
metaTitle: LocalizedString;
|
||||
metaDescription: LocalizedString;
|
||||
ageRangeLabel: LocalizedString;
|
||||
skinTypeLabel: LocalizedString;
|
||||
seasonLabel?: LocalizedString;
|
||||
skinChangesAtThisAge: LocalizedArray;
|
||||
whyThisRoutineWorks: LocalizedString;
|
||||
morningRoutine: RoutineStep[];
|
||||
eveningRoutine: RoutineStep[];
|
||||
weeklyTreatments: RoutineStep[];
|
||||
keyIngredients: string[];
|
||||
productsToAvoid: LocalizedArray;
|
||||
expectedResults: LocalizedString;
|
||||
timeframe: LocalizedString;
|
||||
productBundle: string[];
|
||||
faqs: FAQ[];
|
||||
seoKeywords: LocalizedSEOKeywords;
|
||||
}
|
||||
|
||||
export interface IngredientPairingPage {
|
||||
slug: string;
|
||||
ingredientA: string;
|
||||
ingredientB: string;
|
||||
benefitSlug: string;
|
||||
pageTitle: LocalizedString;
|
||||
metaTitle: LocalizedString;
|
||||
metaDescription: LocalizedString;
|
||||
ingredientAName: LocalizedString;
|
||||
ingredientBName: LocalizedString;
|
||||
benefitName: LocalizedString;
|
||||
whyTheyWorkTogether: LocalizedString;
|
||||
synergyExplanation: LocalizedString;
|
||||
ingredientAContribution: LocalizedString;
|
||||
ingredientBContribution: LocalizedString;
|
||||
howToMix: LocalizedArray;
|
||||
applicationSteps: LocalizedArray;
|
||||
bestTimeToUse: LocalizedString;
|
||||
skinTypesBestFor: string[];
|
||||
whoShouldAvoid: LocalizedArray;
|
||||
productsWithBoth: string[];
|
||||
diyRecipe?: LocalizedString;
|
||||
expectedResults: LocalizedString;
|
||||
timeframe: LocalizedString;
|
||||
customerTestimonials: Testimonial[];
|
||||
faqs: FAQ[];
|
||||
seoKeywords: LocalizedSEOKeywords;
|
||||
alternativePairings: string[];
|
||||
}
|
||||
|
||||
export interface BodyPartConcernPage {
|
||||
slug: string;
|
||||
bodyPart: string;
|
||||
concernSlug: string;
|
||||
pageTitle: LocalizedString;
|
||||
metaTitle: LocalizedString;
|
||||
metaDescription: LocalizedString;
|
||||
bodyPartName: LocalizedString;
|
||||
concernName: LocalizedString;
|
||||
whyThisAreaIsDifferent: LocalizedString;
|
||||
commonCauses: LocalizedArray;
|
||||
topOilsForThisArea: {
|
||||
oilSlug: string;
|
||||
reason: LocalizedString;
|
||||
rank: number;
|
||||
}[];
|
||||
applicationTechnique: LocalizedString;
|
||||
massageSteps: LocalizedArray;
|
||||
frequency: LocalizedString;
|
||||
complementaryTreatments: LocalizedArray;
|
||||
lifestyleTips: LocalizedArray;
|
||||
productsSpecificallyForArea: string[];
|
||||
beforeAfterGallery: boolean;
|
||||
faqs: FAQ[];
|
||||
seoKeywords: LocalizedSEOKeywords;
|
||||
}
|
||||
|
||||
export interface OilComparisonPage {
|
||||
slug: string;
|
||||
oilA: string;
|
||||
oilB: string;
|
||||
concernSlug: string;
|
||||
pageTitle: LocalizedString;
|
||||
metaTitle: LocalizedString;
|
||||
metaDescription: LocalizedString;
|
||||
oilAName: LocalizedString;
|
||||
oilBName: LocalizedString;
|
||||
concernName: LocalizedString;
|
||||
quickVerdict: LocalizedString;
|
||||
winner: string | "tie" | "depends";
|
||||
comparisonTable: ComparisonPoint[];
|
||||
oilAPros: LocalizedArray;
|
||||
oilACons: LocalizedArray;
|
||||
oilBPros: LocalizedArray;
|
||||
oilBCons: LocalizedArray;
|
||||
chooseOilAIf: LocalizedArray;
|
||||
chooseOilBIf: LocalizedArray;
|
||||
detailedComparison: LocalizedString;
|
||||
priceComparison: LocalizedString;
|
||||
effectivenessComparison: LocalizedString;
|
||||
gentlenessComparison: LocalizedString;
|
||||
bestProducts: {
|
||||
oilA: string[];
|
||||
oilB: string[];
|
||||
};
|
||||
canYouUseBoth: LocalizedString;
|
||||
howToCombine: LocalizedArray;
|
||||
faqs: FAQ[];
|
||||
seoKeywords: LocalizedSEOKeywords;
|
||||
relatedComparisons: string[];
|
||||
}
|
||||
|
||||
export interface RoutineStepSkinTypePage {
|
||||
slug: string;
|
||||
routineStep: string;
|
||||
skinType: string;
|
||||
concernSlug: string;
|
||||
pageTitle: LocalizedString;
|
||||
metaTitle: LocalizedString;
|
||||
metaDescription: LocalizedString;
|
||||
routineStepName: LocalizedString;
|
||||
skinTypeName: LocalizedString;
|
||||
concernName: LocalizedString;
|
||||
whyThisStepMatters: LocalizedString;
|
||||
whatToLookFor: LocalizedArray;
|
||||
ingredientsToAvoid: LocalizedArray;
|
||||
topRecommendations: {
|
||||
productName: string;
|
||||
productSlug: string;
|
||||
whyItWorks: LocalizedString;
|
||||
keyIngredients: string[];
|
||||
bestFor: LocalizedString;
|
||||
rank: number;
|
||||
}[];
|
||||
applicationTips: LocalizedArray;
|
||||
commonMistakes: LocalizedArray;
|
||||
fullRoutineContext: {
|
||||
previousStep?: LocalizedString;
|
||||
nextStep?: LocalizedString;
|
||||
};
|
||||
expectedResults: LocalizedString;
|
||||
faqs: FAQ[];
|
||||
seoKeywords: LocalizedSEOKeywords;
|
||||
}
|
||||
|
||||
export interface SeasonalSkincarePage {
|
||||
slug: string;
|
||||
season: string;
|
||||
target: string;
|
||||
targetType: "skinType" | "ageRange" | "concern";
|
||||
pageTitle: LocalizedString;
|
||||
metaTitle: LocalizedString;
|
||||
metaDescription: LocalizedString;
|
||||
seasonName: LocalizedString;
|
||||
targetName: LocalizedString;
|
||||
seasonalChallenges: LocalizedArray;
|
||||
howSkinChanges: LocalizedString;
|
||||
routineAdjustments: {
|
||||
add: LocalizedArray;
|
||||
remove: LocalizedArray;
|
||||
modify: LocalizedArray;
|
||||
};
|
||||
morningRoutine: RoutineStep[];
|
||||
eveningRoutine: RoutineStep[];
|
||||
keyIngredients: string[];
|
||||
ingredientsToAvoidThisSeason: LocalizedArray;
|
||||
lifestyleTips: LocalizedArray;
|
||||
nutritionTips: LocalizedArray;
|
||||
seasonalProductBundle: string[];
|
||||
faqs: FAQ[];
|
||||
seoKeywords: LocalizedSEOKeywords;
|
||||
}
|
||||
|
||||
export interface TimeOfDayConcernPage {
|
||||
slug: string;
|
||||
timeOfDay: string;
|
||||
productType: string;
|
||||
concernSlug: string;
|
||||
pageTitle: LocalizedString;
|
||||
metaTitle: LocalizedString;
|
||||
metaDescription: LocalizedString;
|
||||
timeOfDayName: LocalizedString;
|
||||
productTypeName: LocalizedString;
|
||||
concernName: LocalizedString;
|
||||
whyTimingMatters: LocalizedString;
|
||||
skinBehaviorAtThisTime: LocalizedString;
|
||||
whatHappensOvernight?: LocalizedString;
|
||||
whyMorningRoutineMatters?: LocalizedString;
|
||||
keyIngredientsToLookFor: LocalizedArray;
|
||||
ingredientsToAvoid: LocalizedArray;
|
||||
topRecommendations: {
|
||||
productName: string;
|
||||
productSlug: string;
|
||||
whyItWorks: LocalizedString;
|
||||
keyIngredients: string[];
|
||||
rank: number;
|
||||
}[];
|
||||
applicationTips: LocalizedArray;
|
||||
layeringOrder: LocalizedArray;
|
||||
complementaryProducts: string[];
|
||||
expectedResults: LocalizedString;
|
||||
timeframe: LocalizedString;
|
||||
faqs: FAQ[];
|
||||
seoKeywords: LocalizedSEOKeywords;
|
||||
relatedTimeSpecificPages: string[];
|
||||
}
|
||||
|
||||
export interface NaturalAlternativePage {
|
||||
slug: string;
|
||||
syntheticIngredient: string;
|
||||
concernSlug: string;
|
||||
pageTitle: LocalizedString;
|
||||
metaTitle: LocalizedString;
|
||||
metaDescription: LocalizedString;
|
||||
syntheticName: LocalizedString;
|
||||
concernName: LocalizedString;
|
||||
whyPeopleWantAlternatives: LocalizedString;
|
||||
syntheticHowItWorks: LocalizedString;
|
||||
syntheticSideEffects: LocalizedArray;
|
||||
naturalAlternativeName: LocalizedString;
|
||||
naturalAlternativeSlug: string;
|
||||
howNaturalAlternativeWorks: LocalizedString;
|
||||
effectivenessComparison: LocalizedString;
|
||||
timelineComparison: LocalizedString;
|
||||
gentlenessComparison: LocalizedString;
|
||||
costComparison: LocalizedString;
|
||||
whoShouldSwitch: LocalizedArray;
|
||||
transitionGuide: LocalizedArray;
|
||||
whatToExpect: LocalizedString;
|
||||
bestProductsWithNaturalAlternative: string[];
|
||||
diyOption?: LocalizedString;
|
||||
customerStories: Testimonial[];
|
||||
faqs: FAQ[];
|
||||
seoKeywords: LocalizedSEOKeywords;
|
||||
otherNaturalAlternatives: string[];
|
||||
}
|
||||
|
||||
export interface CulturalBeautySecretPage {
|
||||
slug: string;
|
||||
region: string;
|
||||
focus: string;
|
||||
focusType: "ingredient" | "technique" | "ritual";
|
||||
pageTitle: LocalizedString;
|
||||
metaTitle: LocalizedString;
|
||||
metaDescription: LocalizedString;
|
||||
regionName: LocalizedString;
|
||||
focusName: LocalizedString;
|
||||
culturalBackground: LocalizedString;
|
||||
historicalContext: LocalizedString;
|
||||
traditionalUse: LocalizedString;
|
||||
modernScienceValidation: LocalizedString;
|
||||
keyIngredients: string[];
|
||||
traditionalPreparation: LocalizedString;
|
||||
modernAdaptation: LocalizedString;
|
||||
applicationRitual: LocalizedArray;
|
||||
bestTimeToUse: LocalizedString;
|
||||
skinTypesBestFor: string[];
|
||||
expectedResults: LocalizedString;
|
||||
timeframe: LocalizedString;
|
||||
productsInspiredByTradition: string[];
|
||||
whereSourceFrom: LocalizedString;
|
||||
sustainabilityNotes: LocalizedString;
|
||||
customerExperiences: Testimonial[];
|
||||
faqs: FAQ[];
|
||||
seoKeywords: LocalizedSEOKeywords;
|
||||
relatedCulturalPages: string[];
|
||||
}
|
||||
|
||||
export type ProgrammaticPageType =
|
||||
| "oil-for-concern"
|
||||
| "age-skin-routine"
|
||||
| "ingredient-pairing"
|
||||
| "body-part-concern"
|
||||
| "oil-comparison"
|
||||
| "routine-step-skin"
|
||||
| "seasonal-skincare"
|
||||
| "time-of-day-concern"
|
||||
| "natural-alternative"
|
||||
| "cultural-beauty-secret";
|
||||
|
||||
export type ProgrammaticPage =
|
||||
| OilForConcernPage
|
||||
| AgeSkinTypeRoutinePage
|
||||
| IngredientPairingPage
|
||||
| BodyPartConcernPage
|
||||
| OilComparisonPage
|
||||
| RoutineStepSkinTypePage
|
||||
| SeasonalSkincarePage
|
||||
| TimeOfDayConcernPage
|
||||
| NaturalAlternativePage
|
||||
| CulturalBeautySecretPage;
|
||||
|
||||
export interface DataLoader<T> {
|
||||
getAll(): Promise<T[]>;
|
||||
getBySlug(slug: string): Promise<T | null>;
|
||||
getBySlugs(slugs: string[]): Promise<T[]>;
|
||||
}
|
||||
Reference in New Issue
Block a user