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:
Unchained
2026-04-09 08:04:35 +02:00
parent 9d07a60d3f
commit 9ab07ab01d
25 changed files with 4618 additions and 117 deletions

View File

@@ -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>