Add Craft.js site builder (v2) - complete rebuild from GrapesJS
Rebuilt the visual site builder from scratch using Craft.js, React 18, and TypeScript. The new editor renders directly in the DOM (no iframe), supports 40+ components, multi-page with shared header/footer, 16 templates, full-spectrum color/gradient controls, custom head code injection, save/publish workflow, and auto-save. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
421
craft/src/components/sections/Testimonials.tsx
Normal file
421
craft/src/components/sections/Testimonials.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
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' }}>
|
||||
“{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)); }}
|
||||
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) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
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>`,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user