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:
2026-04-05 18:31:16 -07:00
parent b511a6684d
commit 91a6b6f34b
103 changed files with 26296 additions and 0 deletions
@@ -0,0 +1,486 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface CallToActionProps {
heading?: string;
description?: string;
buttonText?: string;
buttonHref?: string;
secondaryButtonText?: string;
secondaryButtonHref?: string;
bgType?: 'color' | 'gradient' | 'image';
bgValue?: string;
overlayColor?: string;
overlayOpacity?: number;
textColor?: string;
buttonColor?: string;
style?: CSSProperties;
}
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
export const CallToAction: UserComponent<CallToActionProps> = ({
heading = 'Ready to Get Started?',
description = 'Join thousands of satisfied users and start building your dream website today.',
buttonText = 'Get Started',
buttonHref = '#',
secondaryButtonText = '',
secondaryButtonHref = '#',
bgType = 'gradient',
bgValue = defaultGradient,
overlayColor = '#000000',
overlayOpacity = 0,
textColor = '#ffffff',
buttonColor = '#ffffff',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const bgStyle: CSSProperties = {};
if (bgType === 'color') {
bgStyle.backgroundColor = bgValue;
} else if (bgType === 'gradient') {
bgStyle.background = bgValue;
} else if (bgType === 'image') {
bgStyle.backgroundImage = `url(${bgValue})`;
bgStyle.backgroundSize = 'cover';
bgStyle.backgroundPosition = 'center';
}
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
position: 'relative',
padding: '80px 20px',
textAlign: 'center',
outline: selected ? '2px solid #3b82f6' : 'none',
...bgStyle,
...style,
}}
>
{/* Overlay */}
{bgType === 'image' && overlayOpacity > 0 && (
<div
style={{
position: 'absolute',
inset: 0,
backgroundColor: overlayColor,
opacity: overlayOpacity / 100,
pointerEvents: 'none',
}}
/>
)}
<div style={{ maxWidth: '700px', margin: '0 auto', position: 'relative', zIndex: 1 }}>
<h2 style={{ fontSize: '36px', fontWeight: '700', color: textColor, marginBottom: '12px' }}>
{heading}
</h2>
<p style={{ fontSize: '18px', color: textColor, opacity: 0.85, marginBottom: '28px', lineHeight: '1.6' }}>
{description}
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
<a
href={buttonHref}
onClick={(e) => e.preventDefault()}
style={{
display: 'inline-block',
padding: '14px 36px',
backgroundColor: buttonColor,
color: buttonTextColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
}}
>
{buttonText}
</a>
{secondaryButtonText && (
<a
href={secondaryButtonHref}
onClick={(e) => e.preventDefault()}
style={{
display: 'inline-block',
padding: '14px 36px',
backgroundColor: 'transparent',
color: textColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
border: `2px solid ${textColor}`,
}}
>
{secondaryButtonText}
</a>
)}
</div>
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const CallToActionSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as CallToActionProps,
}));
const gradientPresets = [
{ label: 'Blue-Purple', value: 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)' },
{ label: 'Purple', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ label: 'Teal', value: 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)' },
{ label: 'Sunset', value: 'linear-gradient(135deg, #f97316 0%, #ec4899 100%)' },
{ label: 'Dark', value: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)' },
{ label: 'Ocean', value: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)' },
];
const colorPresets = ['#2563eb', '#7c3aed', '#0d9488', '#18181b', '#0f172a', '#1e293b', '#dc2626', '#f97316'];
const buttonColorPresets = ['#ffffff', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#ec4899'];
const textColorPresets = ['#ffffff', '#f8fafc', '#e2e8f0', '#18181b', '#1e293b', '#fef3c7'];
const inputStyle: CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
<input
type="text"
value={props.heading || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.heading = e.target.value; })}
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Description</label>
<textarea
value={props.description || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.description = e.target.value; })}
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
{/* Primary Button */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Primary Button Text</label>
<input
type="text"
value={props.buttonText || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.buttonText = e.target.value; })}
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Primary Button URL</label>
<input
type="text"
value={props.buttonHref || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.buttonHref = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
{/* Secondary Button */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Secondary Button Text <span style={{ opacity: 0.5 }}>(leave empty to hide)</span></label>
<input
type="text"
value={props.secondaryButtonText || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.secondaryButtonText = e.target.value; })}
placeholder="e.g. Learn More"
style={inputStyle}
/>
</div>
{props.secondaryButtonText && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Secondary Button URL</label>
<input
type="text"
value={props.secondaryButtonHref || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.secondaryButtonHref = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
)}
{/* Background Type */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Type</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['color', 'gradient', 'image'] as const).map((t) => (
<button
key={t}
onClick={() => {
setProp((p: CallToActionProps) => {
p.bgType = t;
if (t === 'color') p.bgValue = '#2563eb';
if (t === 'gradient') p.bgValue = defaultGradient;
if (t === 'image') p.bgValue = '';
});
}}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.bgType === t ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
textTransform: 'capitalize',
}}
>
{t}
</button>
))}
</div>
</div>
{/* Background sub-controls */}
{props.bgType === 'color' && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CallToActionProps) => { p.bgValue = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.bgValue === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
)}
{props.bgType === 'gradient' && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gradient</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gradientPresets.map((g) => (
<button
key={g.label}
onClick={() => setProp((p: CallToActionProps) => { p.bgValue = g.value; })}
title={g.label}
style={{
width: 32, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
background: g.value, cursor: 'pointer',
outline: props.bgValue === g.value ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
)}
{props.bgType === 'image' && (
<>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Image URL</label>
<input
type="text"
value={props.bgValue || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.bgValue = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
Overlay Opacity: {props.overlayOpacity ?? 0}%
</label>
<input
type="range"
min={0}
max={100}
value={props.overlayOpacity ?? 0}
onChange={(e) => setProp((p: CallToActionProps) => { p.overlayOpacity = parseInt(e.target.value); })}
style={{ width: '100%', accentColor: '#3b82f6' }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Overlay Color</label>
<input
type="color"
value={props.overlayColor || '#000000'}
onChange={(e) => setProp((p: CallToActionProps) => { p.overlayColor = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
</>
)}
{/* Text Color */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{textColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CallToActionProps) => { p.textColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.textColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Button Color */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{buttonColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CallToActionProps) => { p.buttonColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.buttonColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
CallToAction.craft = {
displayName: 'Call to Action',
props: {
heading: 'Ready to Get Started?',
description: 'Join thousands of satisfied users and start building your dream website today.',
buttonText: 'Get Started',
buttonHref: '#',
secondaryButtonText: 'Learn More',
secondaryButtonHref: '#',
bgType: 'gradient',
bgValue: defaultGradient,
overlayColor: '#000000',
overlayOpacity: 0,
textColor: '#ffffff',
buttonColor: '#ffffff',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: CallToActionSettings,
},
};
/* ---------- HTML export ---------- */
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const bgType = props.bgType || 'gradient';
const bgValue = props.bgValue || defaultGradient;
const textColor = props.textColor || '#ffffff';
const buttonColor = props.buttonColor || '#ffffff';
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
const sectionCss: CSSProperties = {
position: 'relative',
padding: '80px 20px',
textAlign: 'center',
...props.style,
};
if (bgType === 'color') {
sectionCss.backgroundColor = bgValue;
} else if (bgType === 'gradient') {
sectionCss.background = bgValue;
} else if (bgType === 'image') {
sectionCss.backgroundImage = `url(${bgValue})`;
sectionCss.backgroundSize = 'cover';
sectionCss.backgroundPosition = 'center';
}
const sectionStyle = cssPropsToString(sectionCss);
let overlayHtml = '';
if (bgType === 'image' && (props.overlayOpacity || 0) > 0) {
const overlayStyle = cssPropsToString({
position: 'absolute',
inset: '0',
backgroundColor: props.overlayColor || '#000000',
opacity: String((props.overlayOpacity || 0) / 100) as any,
pointerEvents: 'none',
});
overlayHtml = `<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div>`;
}
let secondaryBtnHtml = '';
if (props.secondaryButtonText) {
const secStyle = cssPropsToString({
display: 'inline-block',
padding: '14px 36px',
backgroundColor: 'transparent',
color: textColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
border: `2px solid ${textColor}`,
});
secondaryBtnHtml = `\n <a href="${props.secondaryButtonHref || '#'}"${secStyle ? ` style="${secStyle}"` : ''}>${esc(props.secondaryButtonText)}</a>`;
}
const btnStyle = cssPropsToString({
display: 'inline-block',
padding: '14px 36px',
backgroundColor: buttonColor,
color: buttonTextColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
});
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
${overlayHtml}<div style="max-width:700px;margin:0 auto;position:relative;z-index:1">
<h2 style="font-size:36px;font-weight:700;color:${textColor};margin-bottom:12px">${esc(props.heading || '')}</h2>
<p style="font-size:18px;color:${textColor};opacity:0.85;margin-bottom:28px;line-height:1.6">${esc(props.description || '')}</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
<a href="${props.buttonHref || '#'}"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.buttonText || '')}</a>${secondaryBtnHtml}
</div>
</div>
</section>`,
};
};