feat: add SEO outreach templates, testimonial components, and supporting data schemas

This commit is contained in:
Timo Knuth
2026-04-12 23:23:39 +02:00
parent 434b5954c1
commit 6ccab068d8
7 changed files with 1672 additions and 110 deletions

View File

@@ -14,14 +14,13 @@ import { Button } from '@/components/ui/Button';
import { ReprintCalculatorTeaser } from '@/components/marketing/ReprintCalculatorTeaser';
import { ScrollToTop } from '@/components/ui/ScrollToTop';
import { FreeToolsGrid } from '@/components/marketing/FreeToolsGrid';
import { Testimonials } from '@/components/marketing/Testimonials';
import { getFeaturedTestimonials } from '@/lib/testimonial-data';
import { TestimonialsCarousel } from '@/components/marketing/TestimonialsCarousel';
import { testimonials } from '@/lib/testimonial-data';
import en from '@/i18n/en.json';
export default function HomePageClient() {
// Always use English for marketing pages
const t = en;
const featuredTestimonials = getFeaturedTestimonials();
const industries = [
'Restaurant Chain',
@@ -44,8 +43,8 @@ export default function HomePageClient() {
{/* Free Tools Grid */}
<FreeToolsGrid />
{/* Testimonials Section */}
<Testimonials testimonials={featuredTestimonials} />
{/* Testimonials Carousel */}
<TestimonialsCarousel testimonials={testimonials} />
<React.Fragment>
<StaticVsDynamic t={t} />

View File

@@ -93,18 +93,23 @@ export const Testimonials: React.FC<TestimonialsProps> = ({
<span className="font-semibold text-gray-900">
{testimonial.author.name}
</span>
<div className="text-sm text-gray-600">
{testimonial.author.company && (
<span>{testimonial.author.company}</span>
)}
{testimonial.author.company && testimonial.author.location && (
<span> </span>
)}
{testimonial.author.location && (
<span>{testimonial.author.location}</span>
)}
</div>
<span className="text-xs text-gray-500 mt-1">
{(testimonial.author.role || testimonial.author.company) && (
<div className="text-sm text-gray-700">
{testimonial.author.role && (
<span>{testimonial.author.role}</span>
)}
{testimonial.author.role && testimonial.author.company && (
<span>, </span>
)}
{testimonial.author.company && (
<span>{testimonial.author.company}</span>
)}
</div>
)}
{testimonial.author.location && (
<span className="text-sm text-gray-500">{testimonial.author.location}</span>
)}
<span className="text-xs text-gray-400 mt-1">
{testimonial.date}
</span>
</div>

View File

@@ -0,0 +1,109 @@
'use client';
import React from 'react';
import { Star, CheckCircle, ChevronRight } from 'lucide-react';
import Link from 'next/link';
import type { Testimonial } from '@/lib/types';
interface Props {
testimonials: Testimonial[];
title?: string;
subtitle?: string;
}
function StarRating({ rating }: { rating: number }) {
return (
<div className="flex gap-0.5" aria-label={`${rating} out of 5 stars`}>
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-4 h-4 ${i < rating ? 'fill-yellow-400 text-yellow-400' : 'fill-gray-200 text-gray-200'}`}
/>
))}
</div>
);
}
function TestimonialCard({ testimonial }: { testimonial: Testimonial }) {
return (
<div className="w-[340px] sm:w-[380px] bg-white rounded-2xl shadow-sm border border-gray-100 p-6 flex flex-col h-full">
<div className="flex items-center justify-between mb-3">
<StarRating rating={testimonial.rating} />
{testimonial.verified && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-green-50 text-green-700 text-xs font-medium rounded-full border border-green-200">
<CheckCircle className="w-3 h-3" />
Verified
</span>
)}
</div>
<h3 className="text-sm font-semibold text-gray-900 mb-2">"{testimonial.title}"</h3>
<p className="text-gray-600 text-sm leading-relaxed flex-grow">{testimonial.content}</p>
<div className="border-t border-gray-100 pt-4 mt-4">
<span className="font-semibold text-gray-900 text-sm block">{testimonial.author.name}</span>
{(testimonial.author.role || testimonial.author.company) && (
<span className="text-xs text-gray-600 block">
{[testimonial.author.role, testimonial.author.company].filter(Boolean).join(', ')}
</span>
)}
{testimonial.author.location && (
<span className="text-xs text-gray-400 block">{testimonial.author.location}</span>
)}
</div>
</div>
);
}
export const TestimonialsCarousel: React.FC<Props> = ({
testimonials,
title = 'What Our Customers Say',
subtitle = 'Real experiences from businesses using QR Master',
}) => {
// Duplicate for seamless loop: when first set exits left, second set is identical → no visible reset
const doubled = [...testimonials, ...testimonials];
return (
<section className="py-16 bg-gray-50 overflow-hidden">
<style>{`
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.marquee-track {
animation: marquee 90s linear infinite;
}
.marquee-track:hover {
animation-play-state: paused;
}
`}</style>
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl mb-10">
<div className="text-center">
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 mb-4">{title}</h2>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">{subtitle}</p>
</div>
</div>
{/* Full-width overflow mask */}
<div className="w-full overflow-hidden">
<div
className="marquee-track flex gap-6"
style={{ width: 'max-content' }}
>
{doubled.map((t, idx) => (
<TestimonialCard key={`${t.id}-${idx}`} testimonial={t} />
))}
</div>
</div>
<div className="mt-8 text-center">
<Link
href="/testimonials"
className="inline-flex items-center text-blue-600 font-semibold hover:text-blue-700 transition-colors text-sm"
>
See all {testimonials.length} reviews
<ChevronRight className="w-4 h-4 ml-1" />
</Link>
</div>
</section>
);
};