feat: implement centralized taxonomy for programmatic SEO
- Create taxonomy system with oils.json (5 oils) and concerns.json (9 concerns) - Migrate 10 content files to new data/content/oil-for-concern/ structure - Add scripts: generate-urls.js, validate-taxonomy.js, migrate-content.js - Update dataLoader.ts to use centralized taxonomy - Generate 40 URLs (10 pairs × 4 languages) - Create sitemap-programmatic.xml for SEO - Update by-oil and by-concern directory pages
This commit is contained in:
@@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { ChevronRight, Search, ArrowRight } from "lucide-react";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import { getAllOilForConcernPages, getLocalizedString } from "@/lib/programmatic-seo/dataLoader";
|
||||
import { getAllOilForConcernPages, getLocalizedString, getAllConcerns } from "@/lib/programmatic-seo/dataLoader";
|
||||
import { isValidLocale, DEFAULT_LOCALE } from "@/lib/i18n/locales";
|
||||
|
||||
type Params = Promise<{ locale: string }>;
|
||||
@@ -43,13 +43,18 @@ export default async function ByConcernPage({
|
||||
const t = await getTranslations({ locale, namespace: "Solutions" });
|
||||
const pageT = await getTranslations({ locale, namespace: "Solutions.ByConcern" });
|
||||
|
||||
const pages = await getAllOilForConcernPages();
|
||||
const [concerns, allPages] = await Promise.all([
|
||||
getAllConcerns(),
|
||||
getAllOilForConcernPages(),
|
||||
]);
|
||||
|
||||
const sortedPages = pages.sort((a, b) => {
|
||||
const aName = getLocalizedString(a.concernName, locale);
|
||||
const bName = getLocalizedString(b.concernName, locale);
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
const concernsWithPages = concerns
|
||||
.map(concern => ({
|
||||
...concern,
|
||||
pages: allPages.filter(page => page.concernId === concern.id),
|
||||
}))
|
||||
.filter(concern => concern.pages.length > 0)
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -82,44 +87,63 @@ export default async function ByConcernPage({
|
||||
<div className="flex items-center gap-3 text-[#666666]">
|
||||
<Search className="w-5 h-5" />
|
||||
<span className="text-sm">
|
||||
{pageT("stats.solutionsAvailable", { count: sortedPages.length })}
|
||||
{pageT("stats.solutionsAvailable", { count: allPages.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{sortedPages.map((page) => {
|
||||
const pageTitle = getLocalizedString(page.pageTitle, locale);
|
||||
const oilName = getLocalizedString(page.oilName, locale);
|
||||
const concernName = getLocalizedString(page.concernName, locale);
|
||||
<div className="space-y-16">
|
||||
{concernsWithPages.map((concern) => {
|
||||
const concernName = getLocalizedString(concern.name, locale);
|
||||
const concernDescription = getLocalizedString(concern.description, locale);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={page.slug}
|
||||
href={`/${locale}/solutions/${page.localizedSlugs[locale as "sr" | "en" | "de" | "fr"]}`}
|
||||
className="border border-[#e5e5e5] rounded-lg p-6 hover:border-black transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs px-2 py-1 bg-amber-100 text-amber-700 rounded-full">
|
||||
<div key={concern.id} className="border-b border-[#e5e5e5] pb-16 last:border-0">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl lg:text-3xl font-medium text-[#1a1a1a] mb-3">
|
||||
{concernName}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[#666666] max-w-2xl">
|
||||
{concernDescription}
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-[#1a1a1a] mb-2 group-hover:text-black transition-colors">
|
||||
{pageTitle}
|
||||
</h3>
|
||||
<p className="text-sm text-[#666666] mb-4">
|
||||
{oilName}
|
||||
</p>
|
||||
<span className="inline-flex items-center text-sm font-medium text-[#1a1a1a] group-hover:text-black transition-colors">
|
||||
{pageT("viewSolution")}
|
||||
<ArrowRight className="ml-1 w-4 h-4 transform group-hover:translate-x-1 transition-transform" />
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{concern.pages.map((page) => {
|
||||
const pageTitle = getLocalizedString(page.pageTitle, locale);
|
||||
const oilName = getLocalizedString(page.oilName, locale);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={page.slug}
|
||||
href={`/${locale}/solutions/${page.localizedSlugs[locale as "sr" | "en" | "de" | "fr"]}`}
|
||||
className="border border-[#e5e5e5] rounded-lg p-6 hover:border-black transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-xs px-2 py-1 bg-amber-100 text-amber-700 rounded-full">
|
||||
{concernName}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-[#1a1a1a] mb-2 group-hover:text-black transition-colors">
|
||||
{pageTitle}
|
||||
</h3>
|
||||
<p className="text-sm text-[#666666] mb-4">
|
||||
{oilName}
|
||||
</p>
|
||||
<span className="inline-flex items-center text-sm font-medium text-[#1a1a1a] group-hover:text-black transition-colors">
|
||||
{pageT("viewSolution")}
|
||||
<ArrowRight className="ml-1 w-4 h-4 transform group-hover:translate-x-1 transition-transform" />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{sortedPages.length === 0 && (
|
||||
{concernsWithPages.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-[#666666]">{pageT("noResults")}</p>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { ChevronRight, Droplets } from "lucide-react";
|
||||
import Header from "@/components/layout/Header";
|
||||
import Footer from "@/components/layout/Footer";
|
||||
import { getAllOilForConcernPages, getLocalizedString } from "@/lib/programmatic-seo/dataLoader";
|
||||
import { getAllOilForConcernPages, getLocalizedString, getAllOils } from "@/lib/programmatic-seo/dataLoader";
|
||||
import { isValidLocale, DEFAULT_LOCALE } from "@/lib/i18n/locales";
|
||||
|
||||
type Params = Promise<{ locale: string }>;
|
||||
@@ -34,47 +34,33 @@ export async function generateMetadata({
|
||||
};
|
||||
}
|
||||
|
||||
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[];
|
||||
concerns: string[];
|
||||
locale: string;
|
||||
firstSolutionSlug: string;
|
||||
bestFor: string;
|
||||
exploreSolutions: string;
|
||||
solutionLabel: string;
|
||||
solutionsLabel: string;
|
||||
shortDescription: string;
|
||||
}
|
||||
|
||||
function OilCard({
|
||||
oilSlug,
|
||||
oilName,
|
||||
concernCount,
|
||||
topConcerns,
|
||||
concerns,
|
||||
locale,
|
||||
firstSolutionSlug,
|
||||
bestFor,
|
||||
exploreSolutions,
|
||||
solutionLabel,
|
||||
solutionsLabel
|
||||
solutionsLabel,
|
||||
shortDescription,
|
||||
}: OilCardProps) {
|
||||
return (
|
||||
<div className="border border-[#e5e5e5] rounded-lg p-6 hover:border-black transition-colors group">
|
||||
<div className="border border-[#e5e5e5] rounded-lg p-6 hover:border-black transition-colors group flex flex-col">
|
||||
<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" />
|
||||
@@ -83,23 +69,35 @@ function OilCard({
|
||||
{oilName}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[#666666] mb-4 flex-grow">
|
||||
{shortDescription}
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-[#666666] mb-4">
|
||||
{concernCount} {concernCount === 1 ? solutionLabel : solutionsLabel}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
<p className="text-xs uppercase tracking-wider text-[#999999] font-medium">
|
||||
{bestFor}
|
||||
</p>
|
||||
{topConcerns.slice(0, 3).map((concernName) => (
|
||||
{concerns.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>
|
||||
))}
|
||||
{concerns.length > 3 && (
|
||||
<p className="text-xs text-[#999999]">
|
||||
+{concerns.length - 3} more
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/${locale}/solutions/${firstSolutionSlug}`}
|
||||
className="inline-flex items-center text-sm font-medium text-[#1a1a1a] group-hover:text-black transition-colors"
|
||||
className="inline-flex items-center text-sm font-medium text-[#1a1a1a] group-hover:text-black transition-colors mt-auto"
|
||||
>
|
||||
{exploreSolutions}
|
||||
<ChevronRight className="ml-1 w-4 h-4 transform group-hover:translate-x-1 transition-transform" />
|
||||
@@ -117,18 +115,22 @@ export default async function ByOilPage({
|
||||
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)),
|
||||
firstSolutionSlug: getLocalizedString(pages[0].localizedSlugs, locale),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const [oils, allPages] = await Promise.all([
|
||||
getAllOils(),
|
||||
getAllOilForConcernPages(),
|
||||
]);
|
||||
|
||||
const oilsWithPages = oils
|
||||
.map(oil => {
|
||||
const pages = allPages.filter(page => page.oilId === oil.id);
|
||||
return {
|
||||
...oil,
|
||||
pages,
|
||||
concernCount: pages.length,
|
||||
};
|
||||
})
|
||||
.filter(oil => oil.pages.length > 0)
|
||||
.sort((a, b) => a.name[locale as "sr" | "en" | "de" | "fr"].localeCompare(b.name[locale as "sr" | "en" | "de" | "fr"]));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -164,34 +166,40 @@ export default async function ByOilPage({
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#666666]">
|
||||
{pageT("stats.availableOils", { count: oilsList.length })}
|
||||
{pageT("stats.availableOils", { count: oilsWithPages.length })}
|
||||
</p>
|
||||
<p className="text-sm text-[#666666]">
|
||||
{pageT("stats.totalSolutions", { count: pages.length })}
|
||||
{pageT("stats.totalSolutions", { count: allPages.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}
|
||||
firstSolutionSlug={oil.firstSolutionSlug}
|
||||
bestFor={pageT("oilCard.bestFor")}
|
||||
exploreSolutions={pageT("oilCard.exploreSolutions")}
|
||||
solutionLabel={pageT("oilCard.solutionLabel")}
|
||||
solutionsLabel={pageT("oilCard.solutionsLabel")}
|
||||
/>
|
||||
))}
|
||||
{oilsWithPages.map((oil) => {
|
||||
const oilName = getLocalizedString(oil.name, locale);
|
||||
const shortDescription = getLocalizedString(oil.shortDescription, locale);
|
||||
const concerns = oil.pages.map(p => getLocalizedString(p.concernName, locale));
|
||||
|
||||
return (
|
||||
<OilCard
|
||||
key={oil.id}
|
||||
oilName={oilName}
|
||||
concernCount={oil.concernCount}
|
||||
concerns={concerns}
|
||||
locale={locale}
|
||||
firstSolutionSlug={oil.pages[0].localizedSlugs[locale as "sr" | "en" | "de" | "fr"]}
|
||||
bestFor={pageT("oilCard.bestFor")}
|
||||
exploreSolutions={pageT("oilCard.exploreSolutions")}
|
||||
solutionLabel={pageT("oilCard.solutionLabel")}
|
||||
solutionsLabel={pageT("oilCard.solutionsLabel")}
|
||||
shortDescription={shortDescription}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{oilsList.length === 0 && (
|
||||
{oilsWithPages.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-[#666666]">{pageT("noResults")}</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user