2026-04-05 18:31:16 -07:00
import React , { CSSProperties , useState } from 'react' ;
import { useNode , UserComponent } from '@craftjs/core' ;
import { cssPropsToString } from '../../utils/style-helpers' ;
2026-07-12 11:49:10 -07:00
import { escapeHtml , escapeAttr } from '../../utils/escape' ;
2026-04-05 18:31:16 -07:00
interface Testimonial {
quote : string ;
name : string ;
title : string ;
rating : number ;
}
interface TestimonialsProps {
testimonials? : Testimonial [];
layout ?: 'grid' | 'single' ;
columns? : number ;
style? : CSSProperties ;
cardBg? : string ;
starColor? : string ;
2026-05-25 12:43:28 -07:00
anchorId? : string ;
2026-04-05 18:31:16 -07:00
}
const defaultTestimonials : Testimonial [] = [
{ quote : 'This product has completely transformed our workflow. Highly recommended for any team.' , name : 'Sarah Johnson' , title : 'Marketing Director' , rating : 5 },
{ quote : 'Outstanding support and an incredibly intuitive interface. We saw results from day one.' , name : 'Michael Chen' , title : 'CTO, TechStart' , rating : 5 },
{ quote : 'The best investment we have made this year. Simple, powerful, and reliable.' , name : 'Emily Rodriguez' , title : 'Founder, DesignLab' , rating : 4 },
];
function renderStars ( count : number , color : string ) : React . ReactNode {
return (
< div style = {{ display : 'flex' , gap : '2px' , justifyContent : 'center' , marginBottom : '12px' }}>
{[ 1 , 2 , 3 , 4 , 5 ]. map (( i ) => (
< i
key = { i }
className = { `fa ${ i <= count ? 'fa-star' : 'fa-star-o' } ` }
style = {{ color , fontSize : '14px' }}
/>
))}
</ div >
);
}
function starsHtml ( count : number , color : string ) : string {
const stars = [ 1 , 2 , 3 , 4 , 5 ]. map (( i ) =>
`<i class="fa ${ i <= count ? 'fa-star' : 'fa-star-o' } " style="color: ${ color } ;font-size:14px"></i>`
). join ( '' );
return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px"> ${ stars } </div>` ;
}
export const Testimonials : UserComponent < TestimonialsProps > = ({
testimonials = defaultTestimonials ,
layout = 'grid' ,
columns = 3 ,
style = {},
cardBg = '#f8fafc' ,
starColor = '#f59e0b' ,
2026-05-25 12:43:28 -07:00
anchorId ,
2026-04-05 18:31:16 -07:00
}) => {
const {
connectors : { connect , drag },
selected ,
} = useNode (( node ) => ({
selected : node.events.selected ,
}));
const [ currentIndex , setCurrentIndex ] = useState ( 0 );
const cardStyle : CSSProperties = {
backgroundColor : cardBg ,
borderRadius : '12px' ,
padding : '32px 24px' ,
textAlign : 'center' ,
border : '1px solid #e2e8f0' ,
};
const renderCard = ( t : Testimonial , i : number ) => (
< div key = { i } style = { cardStyle }>
{ renderStars ( t . rating , starColor )}
< p style = {{ fontSize : '15px' , color : '#374151' , lineHeight : '1.7' , marginBottom : '16px' , fontStyle : 'italic' , fontFamily : 'Inter, sans-serif' }}>
& ldquo ;{ t . quote } & rdquo ;
</ p >
< div style = {{ fontWeight : '600' , fontSize : '14px' , color : '#18181b' , fontFamily : 'Inter, sans-serif' }}>{ t . name }</ div >
< div style = {{ fontSize : '13px' , color : '#64748b' , fontFamily : 'Inter, sans-serif' }}>{ t . title }</ div >
</ div >
);
const items = testimonials . length > 0 ? testimonials : defaultTestimonials ;
return (
< section
ref = {( ref : HTMLElement | null ) : void => { if ( ref ) connect ( drag ( ref )); }}
2026-05-25 12:43:28 -07:00
id = { anchorId || undefined }
2026-04-05 18:31:16 -07:00
style = {{
padding : '80px 20px' ,
backgroundColor : '#ffffff' ,
outline : selected ? '2px solid #3b82f6' : 'none' ,
... style ,
}}
>
< div style = {{ maxWidth : '1100px' , margin : '0 auto' }}>
{ layout === 'grid' ? (
< div style = {{ display : 'grid' , gridTemplateColumns : `repeat( ${ columns } , 1fr)` , gap : '24px' }}>
{ items . map (( t , i ) => renderCard ( t , i ))}
</ div >
) : (
< div style = {{ maxWidth : '600px' , margin : '0 auto' , position : 'relative' }}>
{ renderCard ( items [ currentIndex ] || items [ 0 ], currentIndex )}
{ items . length > 1 && (
< div style = {{ display : 'flex' , justifyContent : 'center' , gap : '12px' , marginTop : '20px' }}>
< button
onClick = {() => setCurrentIndex (( prev ) => ( prev - 1 + items . length ) % items . length )}
style = {{
width : 36 , height : 36 , borderRadius : '50%' , border : '1px solid #d1d5db' ,
background : '#ffffff' , cursor : 'pointer' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' ,
fontSize : 14 , color : '#374151' ,
}}
>
< i className = "fa fa-chevron-left" />
</ button >
< div style = {{ display : 'flex' , alignItems : 'center' , gap : '6px' }}>
{ items . map (( _ , i ) => (
< div
key = { i }
onClick = {() => setCurrentIndex ( i )}
style = {{
width : 8 , height : 8 , borderRadius : '50%' , cursor : 'pointer' ,
backgroundColor : i === currentIndex ? '#3b82f6' : '#d1d5db' ,
}}
/>
))}
</ div >
< button
onClick = {() => setCurrentIndex (( prev ) => ( prev + 1 ) % items . length )}
style = {{
width : 36 , height : 36 , borderRadius : '50%' , border : '1px solid #d1d5db' ,
background : '#ffffff' , cursor : 'pointer' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' ,
fontSize : 14 , color : '#374151' ,
}}
>
< i className = "fa fa-chevron-right" />
</ button >
</ div >
)}
</ div >
)}
</ div >
</ section >
);
};
/* ---------- Craft config ---------- */
Testimonials . craft = {
displayName : 'Testimonials' ,
props : {
testimonials : defaultTestimonials ,
layout : 'grid' ,
columns : 3 ,
style : { backgroundColor : '#ffffff' },
cardBg : '#f8fafc' ,
starColor : '#f59e0b' ,
2026-05-25 12:43:28 -07:00
anchorId : '' ,
2026-04-05 18:31:16 -07:00
},
rules : {
canDrag : () => true ,
canMoveIn : () => false ,
canMoveOut : () => true ,
},
};
/* ---------- HTML export ---------- */
( Testimonials as any ). toHtml = ( props : TestimonialsProps , _childrenHtml : string ) => {
const {
testimonials = defaultTestimonials ,
layout = 'grid' ,
columns = 3 ,
style = {},
cardBg = '#f8fafc' ,
starColor = '#f59e0b' ,
} = props ;
const items = testimonials . length > 0 ? testimonials : defaultTestimonials ;
const sectionStyle = cssPropsToString ({
padding : '80px 20px' ,
backgroundColor : '#ffffff' ,
... style ,
});
2026-07-12 11:49:10 -07:00
const idAttr = props . anchorId ? ` id=" ${ escapeAttr ( props . anchorId ) } "` : '' ;
2026-04-05 18:31:16 -07:00
const cardCss = `background-color: ${ cardBg } ;border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0` ;
const cards = items . map (( t ) => {
return `<div style=" ${ cardCss } ">
${ starsHtml ( t . rating , starColor ) }
2026-07-12 11:49:10 -07:00
<p style="font-size:15px;color:#374151;line-height:1.7;margin-bottom:16px;font-style:italic;font-family:Inter,sans-serif">“ ${ escapeHtml ( t . quote ) } ”</p>
<div style="font-weight:600;font-size:14px;color:#18181b;font-family:Inter,sans-serif"> ${ escapeHtml ( t . name ) } </div>
<div style="font-size:13px;color:#64748b;font-family:Inter,sans-serif"> ${ escapeHtml ( t . title ) } </div>
2026-04-05 18:31:16 -07:00
</div>` ;
}). join ( '\n ' );
if ( layout === 'single' ) {
// For single layout, export as grid with 1 column (simpler static export)
return {
2026-05-25 12:43:28 -07:00
html : `<section ${ idAttr }${ sectionStyle ? ` style=" ${ sectionStyle } "` : '' } >
2026-04-05 18:31:16 -07:00
<div style="max-width:600px;margin:0 auto;display:grid;grid-template-columns:1fr;gap:24px">
${ cards }
</div>
</section>` ,
};
}
return {
2026-05-25 12:43:28 -07:00
html : `<section ${ idAttr }${ sectionStyle ? ` style=" ${ sectionStyle } "` : '' } >
2026-04-05 18:31:16 -07:00
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat( ${ columns } ,1fr);gap:24px">
${ cards }
</div>
</section>` ,
};
};