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' ;
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 ;
}
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' ,
}) => {
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 )); }}
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 >
);
};
/* ---------- Settings panel ---------- */
const TestimonialsSettings : React.FC = () => {
const { actions : { setProp }, props } = useNode (( node ) => ({
props : node.data.props as TestimonialsProps ,
}));
const items = props . testimonials || defaultTestimonials ;
const labelStyle : CSSProperties = { fontSize : 11 , color : '#a1a1aa' , display : 'block' , marginBottom : 6 };
const inputStyle : CSSProperties = {
width : '100%' , padding : '3px 6px' , background : '#27272a' , color : '#e4e4e7' ,
border : '1px solid #3f3f46' , borderRadius : 4 , fontSize : 11 ,
};
const updateTestimonial = ( index : number , field : keyof Testimonial , value : string | number ) => {
setProp (( p : TestimonialsProps ) => {
const updated = [...( p . testimonials || defaultTestimonials )];
updated [ index ] = { ... updated [ index ], [ field ] : value };
p . testimonials = updated ;
});
};
const addTestimonial = () => {
setProp (( p : TestimonialsProps ) => {
p . testimonials = [...( p . testimonials || defaultTestimonials ), { quote : 'Great experience!' , name : 'New Person' , title : 'Role' , rating : 5 }];
});
};
const removeTestimonial = ( index : number ) => {
setProp (( p : TestimonialsProps ) => {
const updated = [...( p . testimonials || defaultTestimonials )];
updated . splice ( index , 1 );
p . testimonials = updated ;
});
};
const bgPresets = [ '#ffffff' , '#f8fafc' , '#f1f5f9' , '#18181b' , '#0f172a' ];
const cardBgPresets = [ '#f8fafc' , '#ffffff' , '#f1f5f9' , '#e2e8f0' , '#27272a' , '#1e293b' ];
const starColorPresets = [ '#f59e0b' , '#eab308' , '#ef4444' , '#3b82f6' , '#10b981' , '#8b5cf6' ];
return (
< div style = {{ padding : '12px' , display : 'flex' , flexDirection : 'column' , gap : '14px' }}>
{ /* Layout */ }
< div >
< label style = { labelStyle }> Layout </ label >
< div style = {{ display : 'flex' , gap : 4 }}>
< button
onClick = {() => setProp (( p : TestimonialsProps ) => { p . layout = 'grid' ; })}
style = {{
flex : 1 , padding : '6px 10px' , fontSize : 11 , borderRadius : 4 , cursor : 'pointer' ,
border : '1px solid #3f3f46' ,
background : props.layout === 'grid' ? '#3b82f6' : '#27272a' ,
color : props.layout === 'grid' ? '#fff' : '#a1a1aa' ,
fontWeight : 500 ,
}}
>
Grid
</ button >
< button
onClick = {() => setProp (( p : TestimonialsProps ) => { p . layout = 'single' ; })}
style = {{
flex : 1 , padding : '6px 10px' , fontSize : 11 , borderRadius : 4 , cursor : 'pointer' ,
border : '1px solid #3f3f46' ,
background : props.layout === 'single' ? '#3b82f6' : '#27272a' ,
color : props.layout === 'single' ? '#fff' : '#a1a1aa' ,
fontWeight : 500 ,
}}
>
Single
</ button >
</ div >
</ div >
{ /* Columns (only for grid) */ }
{ props . layout === 'grid' && (
< div >
< label style = { labelStyle }> Columns </ label >
< div style = {{ display : 'flex' , gap : 4 }}>
{[ 1 , 2 , 3 , 4 ]. map (( n ) => (
< button
key = { n }
onClick = {() => setProp (( p : TestimonialsProps ) => { p . columns = n ; })}
style = {{
padding : '4px 10px' , fontSize : 11 , borderRadius : 4 , cursor : 'pointer' ,
border : '1px solid #3f3f46' ,
background : props.columns === n ? '#3b82f6' : '#27272a' ,
color : '#e4e4e7' ,
}}
>
{ n }
</ button >
))}
</ div >
</ div >
)}
{ /* Section background */ }
< div >
< label style = { labelStyle }> Background </ label >
< div style = {{ display : 'flex' , gap : 4 , flexWrap : 'wrap' }}>
{ bgPresets . map (( c ) => (
< button
key = { c }
onClick = {() => setProp (( p : TestimonialsProps ) => { p . style = { ... p . style , backgroundColor : c }; })}
style = {{
width : 24 , height : 24 , borderRadius : 4 , border : '1px solid #3f3f46' ,
backgroundColor : c , cursor : 'pointer' ,
outline : props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none' ,
outlineOffset : 1 ,
}}
/>
))}
</ div >
</ div >
{ /* Card background */ }
< div >
< label style = { labelStyle }> Card Background </ label >
< div style = {{ display : 'flex' , gap : 4 , flexWrap : 'wrap' }}>
{ cardBgPresets . map (( c ) => (
< button
key = { c }
onClick = {() => setProp (( p : TestimonialsProps ) => { p . cardBg = c ; })}
style = {{
width : 24 , height : 24 , borderRadius : 4 , border : '1px solid #3f3f46' ,
backgroundColor : c , cursor : 'pointer' ,
outline : props.cardBg === c ? '2px solid #3b82f6' : 'none' ,
outlineOffset : 1 ,
}}
/>
))}
</ div >
</ div >
{ /* Star color */ }
< div >
< label style = { labelStyle }> Star Color </ label >
< div style = {{ display : 'flex' , gap : 4 , flexWrap : 'wrap' }}>
{ starColorPresets . map (( c ) => (
< button
key = { c }
onClick = {() => setProp (( p : TestimonialsProps ) => { p . starColor = c ; })}
style = {{
width : 24 , height : 24 , borderRadius : 4 , border : '1px solid #3f3f46' ,
backgroundColor : c , cursor : 'pointer' ,
outline : props.starColor === c ? '2px solid #3b82f6' : 'none' ,
outlineOffset : 1 ,
}}
/>
))}
</ div >
</ div >
{ /* Testimonials list */ }
< div >
< label style = { labelStyle }> Testimonials </ label >
< div style = {{ display : 'flex' , flexDirection : 'column' , gap : 8 }}>
{ items . map (( t , i ) => (
< div key = { i } style = {{ background : '#1e1e22' , borderRadius : 6 , padding : 8 , display : 'flex' , flexDirection : 'column' , gap : 4 }}>
< div style = {{ display : 'flex' , gap : 4 , alignItems : 'center' }}>
< input type = "text" value = { t . name } onChange = {( e ) => updateTestimonial ( i , 'name' , e . target . value )} placeholder = "Name" style = {{ ... inputStyle , flex : 1 }} />
< button
onClick = {() => removeTestimonial ( i )}
style = {{ padding : '2px 6px' , fontSize : 11 , background : '#ef4444' , color : '#fff' , border : 'none' , borderRadius : 4 , cursor : 'pointer' }}
>
X
</ button >
</ div >
< input type = "text" value = { t . title } onChange = {( e ) => updateTestimonial ( i , 'title' , e . target . value )} placeholder = "Title/Role" style = { inputStyle } />
< textarea
value = { t . quote }
onChange = {( e ) => updateTestimonial ( i , 'quote' , e . target . value )}
placeholder = "Quote..."
rows = { 2 }
style = {{ ... inputStyle , resize : 'vertical' }}
/>
< div style = {{ display : 'flex' , alignItems : 'center' , gap : 4 }}>
< span style = {{ fontSize : 11 , color : '#a1a1aa' }}> Rating : </ span >
{[ 1 , 2 , 3 , 4 , 5 ]. map (( n ) => (
< i
key = { n }
className = { `fa ${ n <= t . rating ? 'fa-star' : 'fa-star-o' } ` }
onClick = {() => updateTestimonial ( i , 'rating' , n )}
style = {{ color : '#f59e0b' , cursor : 'pointer' , fontSize : 14 }}
/>
))}
</ div >
</ div >
))}
</ div >
< button
onClick = { addTestimonial }
style = {{ marginTop : 6 , width : '100%' , padding : '6px' , fontSize : 11 , background : '#27272a' , color : '#e4e4e7' , border : '1px solid #3f3f46' , borderRadius : 4 , cursor : 'pointer' }}
>
+ Add Testimonial
</ button >
</ div >
</ div >
);
};
/* ---------- Craft config ---------- */
Testimonials . craft = {
displayName : 'Testimonials' ,
props : {
testimonials : defaultTestimonials ,
layout : 'grid' ,
columns : 3 ,
style : { backgroundColor : '#ffffff' },
cardBg : '#f8fafc' ,
starColor : '#f59e0b' ,
},
rules : {
canDrag : () => true ,
canMoveIn : () => false ,
canMoveOut : () => true ,
},
related : {
settings : TestimonialsSettings ,
},
};
/* ---------- HTML export ---------- */
( Testimonials as any ). toHtml = ( props : TestimonialsProps , _childrenHtml : string ) => {
2026-05-24 15:54:48 -07:00
const esc = ( s : any ) => String ( s ?? "" ). replace ( /</g , '<' ). replace ( />/g , '>' ). replace ( /"/g , '"' );
2026-04-05 18:31:16 -07:00
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 ,
});
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 ) }
<p style="font-size:15px;color:#374151;line-height:1.7;margin-bottom:16px;font-style:italic;font-family:Inter,sans-serif">“ ${ esc ( t . quote ) } ”</p>
<div style="font-weight:600;font-size:14px;color:#18181b;font-family:Inter,sans-serif"> ${ esc ( t . name ) } </div>
<div style="font-size:13px;color:#64748b;font-family:Inter,sans-serif"> ${ esc ( t . title ) } </div>
</div>` ;
}). join ( '\n ' );
if ( layout === 'single' ) {
// For single layout, export as grid with 1 column (simpler static export)
return {
html : `<section ${ sectionStyle ? ` style=" ${ sectionStyle } "` : '' } >
<div style="max-width:600px;margin:0 auto;display:grid;grid-template-columns:1fr;gap:24px">
${ cards }
</div>
</section>` ,
};
}
return {
html : `<section ${ sectionStyle ? ` style=" ${ sectionStyle } "` : '' } >
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat( ${ columns } ,1fr);gap:24px">
${ cards }
</div>
</section>` ,
};
};