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: ``,
};
}
const cards = items.map(cardHtml).join('\n ');
return {
html: ``,
};
};