224 lines
6.0 KiB
TypeScript
224 lines
6.0 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import { cn } from '../../lib/utils';
|
|
import { Star, Quote } from 'lucide-react';
|
|
|
|
export interface TestimonialCardProps {
|
|
quote: string;
|
|
author?: string;
|
|
role?: string;
|
|
company?: string;
|
|
rating?: number;
|
|
avatar?: string;
|
|
variant?: 'default' | 'highlight' | 'compact';
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* TestimonialCard Component
|
|
* Displays customer testimonials with optional ratings and author info
|
|
* Maps to WordPress testimonial patterns and quote blocks
|
|
*/
|
|
export const TestimonialCard: React.FC<TestimonialCardProps> = ({
|
|
quote,
|
|
author,
|
|
role,
|
|
company,
|
|
rating = 0,
|
|
avatar,
|
|
variant = 'default',
|
|
className = ''
|
|
}) => {
|
|
// Generate star rating
|
|
const renderStars = () => {
|
|
if (!rating || rating === 0) return null;
|
|
|
|
const stars = [];
|
|
const fullStars = Math.floor(rating);
|
|
const hasHalfStar = rating % 1 >= 0.5;
|
|
|
|
for (let i = 0; i < fullStars; i++) {
|
|
stars.push(
|
|
<Star key={`full-${i}`} className="w-4 h-4 fill-yellow-400 text-yellow-400" />
|
|
);
|
|
}
|
|
|
|
if (hasHalfStar) {
|
|
stars.push(
|
|
<Star key="half" className="w-4 h-4 fill-yellow-400 text-yellow-400 opacity-50" />
|
|
);
|
|
}
|
|
|
|
const emptyStars = 5 - stars.length;
|
|
for (let i = 0; i < emptyStars; i++) {
|
|
stars.push(
|
|
<Star key={`empty-${i}`} className="w-4 h-4 text-gray-300" />
|
|
);
|
|
}
|
|
|
|
return <div className="flex gap-1">{stars}</div>;
|
|
};
|
|
|
|
// Variant-specific styles
|
|
const variantStyles = {
|
|
default: 'bg-white border border-gray-200 shadow-sm',
|
|
highlight: 'bg-gradient-to-br from-primary/5 to-secondary/5 border-primary/20 shadow-lg',
|
|
compact: 'bg-gray-50 border border-gray-100 shadow-sm'
|
|
};
|
|
|
|
const paddingStyles = {
|
|
default: 'p-6 md:p-8',
|
|
highlight: 'p-6 md:p-8',
|
|
compact: 'p-4 md:p-6'
|
|
};
|
|
|
|
const quoteIconStyles = {
|
|
default: 'w-8 h-8 text-primary/30',
|
|
highlight: 'w-10 h-10 text-primary/50',
|
|
compact: 'w-6 h-6 text-primary/30'
|
|
};
|
|
|
|
return (
|
|
<div className={cn(
|
|
'relative rounded-xl',
|
|
variantStyles[variant],
|
|
paddingStyles[variant],
|
|
'transition-all duration-200',
|
|
'hover:shadow-md hover:-translate-y-1',
|
|
className
|
|
)}>
|
|
{/* Quote Icon */}
|
|
<div className={cn(
|
|
'absolute top-4 left-4 md:top-6 md:left-6',
|
|
'opacity-90',
|
|
quoteIconStyles[variant]
|
|
)}>
|
|
<Quote />
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className={cn(
|
|
'space-y-4 md:space-y-6',
|
|
'pl-6 md:pl-8' // Space for quote icon
|
|
)}>
|
|
{/* Quote Text */}
|
|
<blockquote className={cn(
|
|
'text-gray-700 leading-relaxed',
|
|
variant === 'highlight' && 'text-gray-800 font-medium',
|
|
variant === 'compact' && 'text-sm md:text-base'
|
|
)}>
|
|
"{quote}"
|
|
</blockquote>
|
|
|
|
{/* Rating */}
|
|
{rating > 0 && (
|
|
<div className="flex items-center gap-2">
|
|
{renderStars()}
|
|
<span className={cn(
|
|
'text-sm font-medium',
|
|
variant === 'highlight' ? 'text-primary' : 'text-gray-600'
|
|
)}>
|
|
{rating.toFixed(1)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Author Info */}
|
|
{(author || role || company || avatar) && (
|
|
<div className={cn(
|
|
'flex items-start gap-3 md:gap-4',
|
|
variant === 'compact' && 'gap-2'
|
|
)}>
|
|
{/* Avatar */}
|
|
{avatar && (
|
|
<div className={cn(
|
|
'flex-shrink-0 rounded-full overflow-hidden',
|
|
'w-10 h-10 md:w-12 md:h-12',
|
|
variant === 'compact' && 'w-8 h-8'
|
|
)}>
|
|
<img
|
|
src={avatar}
|
|
alt={author || 'Avatar'}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Author Details */}
|
|
<div className="flex-1 min-w-0">
|
|
{author && (
|
|
<div className={cn(
|
|
'font-semibold text-gray-900',
|
|
variant === 'highlight' && 'text-lg',
|
|
variant === 'compact' && 'text-base'
|
|
)}>
|
|
{author}
|
|
</div>
|
|
)}
|
|
|
|
{(role || company) && (
|
|
<div className={cn(
|
|
'text-sm',
|
|
'text-gray-600',
|
|
variant === 'compact' && 'text-xs'
|
|
)}>
|
|
{[role, company].filter(Boolean).join(' • ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Decorative corner accent for highlight variant */}
|
|
{variant === 'highlight' && (
|
|
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-bl from-primary/20 to-transparent rounded-tr-xl" />
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Helper function to parse WordPress testimonial content
|
|
export function parseWpTestimonial(content: string): Partial<TestimonialCardProps> {
|
|
// This would parse WordPress testimonial patterns
|
|
// For now, returns basic structure
|
|
return {
|
|
quote: content.replace(/<[^>]*>/g, '').trim().substring(0, 300) // Strip HTML, limit length
|
|
};
|
|
}
|
|
|
|
// Grid wrapper for multiple testimonials
|
|
export const TestimonialGrid: React.FC<{
|
|
testimonials: TestimonialCardProps[];
|
|
columns?: 1 | 2 | 3;
|
|
gap?: 'sm' | 'md' | 'lg';
|
|
className?: string;
|
|
}> = ({ testimonials, columns = 2, gap = 'md', className = '' }) => {
|
|
const gapStyles = {
|
|
sm: 'gap-4',
|
|
md: 'gap-6',
|
|
lg: 'gap-8'
|
|
};
|
|
|
|
const columnStyles = {
|
|
1: 'grid-cols-1',
|
|
2: 'grid-cols-1 md:grid-cols-2',
|
|
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
|
|
};
|
|
|
|
return (
|
|
<div className={cn(
|
|
'grid',
|
|
columnStyles[columns],
|
|
gapStyles[gap],
|
|
className
|
|
)}>
|
|
{testimonials.map((testimonial, index) => (
|
|
<TestimonialCard key={index} {...testimonial} />
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TestimonialCard; |