2026-04-05 18:31:16 -07:00
|
|
|
import React, { CSSProperties } from 'react';
|
|
|
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
|
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
|
|
|
|
|
|
|
|
|
interface FeatureItem {
|
|
|
|
|
title: string;
|
|
|
|
|
description: string;
|
|
|
|
|
icon: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FeaturesGridProps {
|
|
|
|
|
features?: FeatureItem[];
|
|
|
|
|
style?: CSSProperties;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const defaultFeatures: FeatureItem[] = [
|
|
|
|
|
{ title: 'Fast & Reliable', description: 'Built for performance with optimized loading and rock-solid uptime.', icon: '⚡' },
|
|
|
|
|
{ title: 'Easy to Use', description: 'Intuitive drag-and-drop interface that anyone can master in minutes.', icon: '✨' },
|
|
|
|
|
{ title: 'Fully Responsive', description: 'Looks great on every device, from phones to ultrawide monitors.', icon: '📱' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
|
|
|
|
features = defaultFeatures,
|
|
|
|
|
style = {},
|
|
|
|
|
}) => {
|
|
|
|
|
const {
|
|
|
|
|
connectors: { connect, drag },
|
|
|
|
|
selected,
|
|
|
|
|
} = useNode((node) => ({
|
|
|
|
|
selected: node.events.selected,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
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', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '32px' }}>
|
|
|
|
|
{(Array.isArray(features) ? features : []).map((feat, i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={i}
|
|
|
|
|
style={{
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
padding: '32px 24px',
|
|
|
|
|
borderRadius: '12px',
|
|
|
|
|
backgroundColor: '#f8fafc',
|
|
|
|
|
border: '1px solid #e2e8f0',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div style={{ fontSize: '36px', marginBottom: '16px' }}>{feat.icon}</div>
|
|
|
|
|
<h3 style={{ fontSize: '20px', fontWeight: '600', color: '#18181b', marginBottom: '8px' }}>{feat.title}</h3>
|
|
|
|
|
<p style={{ fontSize: '14px', color: '#64748b', lineHeight: '1.6' }}>{feat.description}</p>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- Settings panel ---------- */
|
|
|
|
|
|
|
|
|
|
const FeaturesGridSettings: React.FC = () => {
|
|
|
|
|
const { actions: { setProp }, props } = useNode((node) => ({
|
|
|
|
|
props: node.data.props as FeaturesGridProps,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const features = props.features || defaultFeatures;
|
|
|
|
|
|
|
|
|
|
const inputStyle: CSSProperties = {
|
|
|
|
|
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
|
|
|
|
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateFeature = (index: number, field: keyof FeatureItem, value: string) => {
|
|
|
|
|
setProp((p: FeaturesGridProps) => {
|
|
|
|
|
const updated = [...(p.features || defaultFeatures)];
|
|
|
|
|
updated[index] = { ...updated[index], [field]: value };
|
|
|
|
|
p.features = updated;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const addFeature = () => {
|
|
|
|
|
setProp((p: FeaturesGridProps) => {
|
|
|
|
|
p.features = [...(p.features || defaultFeatures), { title: 'New Feature', description: 'Describe this feature.', icon: '🔧' }];
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const removeFeature = (index: number) => {
|
|
|
|
|
setProp((p: FeaturesGridProps) => {
|
|
|
|
|
const updated = [...(p.features || defaultFeatures)];
|
|
|
|
|
updated.splice(index, 1);
|
|
|
|
|
p.features = updated;
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
|
|
|
|
<div>
|
|
|
|
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
|
|
|
{bgPresets.map((c) => (
|
|
|
|
|
<button
|
|
|
|
|
key={c}
|
|
|
|
|
onClick={() => setProp((p: FeaturesGridProps) => { 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>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Features</label>
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
|
|
|
{(Array.isArray(features) ? features : []).map((feat, i) => (
|
|
|
|
|
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
|
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
|
|
|
<input type="text" value={feat.icon} onChange={(e) => updateFeature(i, 'icon', e.target.value)} placeholder="Icon" style={{ ...inputStyle, width: 40, flex: 'none', textAlign: 'center' }} />
|
|
|
|
|
<input type="text" value={feat.title} onChange={(e) => updateFeature(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => removeFeature(i)}
|
|
|
|
|
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
|
|
|
|
>
|
|
|
|
|
X
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<textarea
|
|
|
|
|
value={feat.description}
|
|
|
|
|
onChange={(e) => updateFeature(i, 'description', e.target.value)}
|
|
|
|
|
placeholder="Description"
|
|
|
|
|
rows={2}
|
|
|
|
|
style={{ ...inputStyle, resize: 'vertical' }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={addFeature}
|
|
|
|
|
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
|
|
|
|
>
|
|
|
|
|
+ Add Feature
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- Craft config ---------- */
|
|
|
|
|
|
|
|
|
|
FeaturesGrid.craft = {
|
|
|
|
|
displayName: 'Features Grid',
|
|
|
|
|
props: {
|
|
|
|
|
features: defaultFeatures,
|
|
|
|
|
style: { backgroundColor: '#ffffff' },
|
|
|
|
|
},
|
|
|
|
|
rules: {
|
|
|
|
|
canDrag: () => true,
|
|
|
|
|
canMoveIn: () => false,
|
|
|
|
|
canMoveOut: () => true,
|
|
|
|
|
},
|
|
|
|
|
related: {
|
|
|
|
|
settings: FeaturesGridSettings,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- HTML export ---------- */
|
|
|
|
|
|
|
|
|
|
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
|
2026-05-24 15:54:48 -07:00
|
|
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
2026-04-05 18:31:16 -07:00
|
|
|
const sectionStyle = cssPropsToString({
|
|
|
|
|
padding: '80px 20px',
|
|
|
|
|
...props.style,
|
|
|
|
|
});
|
|
|
|
|
const cards = (props.features || defaultFeatures).map((feat) => {
|
|
|
|
|
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0">
|
|
|
|
|
<div style="font-size:36px;margin-bottom:16px">${esc(feat.icon)}</div>
|
|
|
|
|
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${esc(feat.title)}</h3>
|
|
|
|
|
<p style="font-size:14px;color:#64748b;line-height:1.6">${esc(feat.description)}</p>
|
|
|
|
|
</div>`;
|
|
|
|
|
}).join('\n ');
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
|
|
|
|
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px">
|
|
|
|
|
${cards}
|
|
|
|
|
</div>
|
|
|
|
|
</section>`,
|
|
|
|
|
};
|
|
|
|
|
};
|