import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { escapeHtml, escapeAttr, cssValue } from '../../utils/escape'; 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; anchorId?: string; animation?: string; animationDelay?: string; hideOnDesktop?: boolean; hideOnTablet?: boolean; hideOnMobile?: boolean; } 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 (
{[1, 2, 3, 4, 5].map((i) => ( ))}
); } function starsHtml(count: number, color: string): string { // `count` is `Testimonial.rating`, typed `number` but arrives unchecked via // AI update_props / deserialized state -- coerce to a real number before // it's interpolated into the aria-label attribute below (both the loop // comparison and the escapeAttr(String(...)) call are safe against any // non-numeric/garbage value). const safeCount = Number(count) || 0; const stars = [1, 2, 3, 4, 5].map((i) => `` ).join(''); return `
${stars}
`; } export const Testimonials: UserComponent = ({ testimonials = defaultTestimonials, layout = 'grid', columns = 3, style = {}, cardBg = '#f8fafc', starColor = '#f59e0b', anchorId, }) => { const { connectors: { connect, drag }, selected, } = useNode((node) => ({ selected: node.events.selected, })); const cardStyle: CSSProperties = { backgroundColor: cardBg, borderRadius: '12px', padding: '32px 24px', textAlign: 'center', border: '1px solid #e2e8f0', }; const renderCard = (t: Testimonial, i: number) => (
{renderStars(t.rating, starColor)}

“{t.quote}”

{t.name}
{t.title}
); const items = testimonials.length > 0 ? testimonials : defaultTestimonials; return (
{ if (ref) connect(drag(ref)); }} id={anchorId || undefined} style={{ padding: '80px 20px', backgroundColor: '#ffffff', outline: selected ? '2px solid #3b82f6' : 'none', ...style, }} >
{layout === 'grid' ? (
{items.map((t, i) => renderCard(t, i))}
) : ( // Static single testimonial (parity with the static toHtml export -- // no carousel controls, since the published site has no JS for this // component). Always shows the first testimonial.
{renderCard(items[0], 0)}
)}
); }; /* ---------- Craft config ---------- */ Testimonials.craft = { displayName: 'Testimonials', props: { testimonials: defaultTestimonials, layout: 'grid', columns: 3, style: { backgroundColor: '#ffffff' }, cardBg: '#f8fafc', starColor: '#f59e0b', anchorId: '', animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false, }, rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true, }, }; /* ---------- HTML export ---------- */ (Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => { const { testimonials = defaultTestimonials, layout = 'grid', style = {}, } = props; // Number() coercion: `columns` is a raw string-interpolation sink into the // grid style attribute below (repeat(${columns},1fr)); Number() of // anything non-numeric collapses safely to NaN instead of breaking out. const columns = Number(props.columns) || 3; // Sanitized -- raw string-interpolation sinks below (cardCss / starsHtml // style attributes). const cardBg = cssValue(props.cardBg) || '#f8fafc'; const starColor = cssValue(props.starColor) || '#f59e0b'; const items = testimonials.length > 0 ? testimonials : defaultTestimonials; const sectionStyle = cssPropsToString({ padding: '80px 20px', backgroundColor: '#ffffff', ...style, }); const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : ''; const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0`; const cardHtml = (t: Testimonial): string => `
${starsHtml(t.rating, starColor)}

“${escapeHtml(t.quote)}”

${escapeHtml(t.name)}
${escapeHtml(t.title)}
`; if (layout === 'single') { // Static parity with the editor's single-layout render: exactly ONE // testimonial card (the first), no carousel controls -- the published // export has no JS for this component. return { html: `
${cardHtml(items[0])}
`, }; } const cards = items.map(cardHtml).join('\n '); return { html: `
${cards}
`, }; };