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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user