2887cc9bdf
The editor rendered layout="single" as an interactive carousel (prev/next buttons, dot indicators, currentIndex state) but toHtml exported a static stacked list of ALL testimonials -- a structural mismatch. This codebase's static export has no published-JS interactivity for this component, so static-parity is the lower-risk fix: the editor's single view now renders just the first testimonial (no carousel chrome), and toHtml exports exactly that one card. Grid layout is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
189 lines
6.2 KiB
TypeScript
189 lines
6.2 KiB
TypeScript
import React, { CSSProperties } from 'react';
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
|
import { escapeHtml, escapeAttr } 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;
|
|
}
|
|
|
|
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',
|
|
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) => (
|
|
<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' }}>
|
|
“{t.quote}”
|
|
</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)); }}
|
|
id={anchorId || undefined}
|
|
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>
|
|
) : (
|
|
// 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.
|
|
<div style={{ maxWidth: '600px', margin: '0 auto', position: 'relative' }}>
|
|
{renderCard(items[0], 0)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
/* ---------- Craft config ---------- */
|
|
|
|
Testimonials.craft = {
|
|
displayName: 'Testimonials',
|
|
props: {
|
|
testimonials: defaultTestimonials,
|
|
layout: 'grid',
|
|
columns: 3,
|
|
style: { backgroundColor: '#ffffff' },
|
|
cardBg: '#f8fafc',
|
|
starColor: '#f59e0b',
|
|
anchorId: '',
|
|
},
|
|
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,
|
|
});
|
|
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 => `<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">“${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>
|
|
</div>`;
|
|
|
|
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: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
|
<div style="max-width:600px;margin:0 auto">
|
|
${cardHtml(items[0])}
|
|
</div>
|
|
</section>`,
|
|
};
|
|
}
|
|
|
|
const cards = items.map(cardHtml).join('\n ');
|
|
|
|
return {
|
|
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
|
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
|
|
${cards}
|
|
</div>
|
|
</section>`,
|
|
};
|
|
};
|