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:
@@ -0,0 +1,330 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface AccordionItem {
|
||||
title: string;
|
||||
content: string;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
interface AccordionProps {
|
||||
items?: AccordionItem[];
|
||||
style?: CSSProperties;
|
||||
headerBg?: string;
|
||||
headerColor?: string;
|
||||
contentBg?: string;
|
||||
borderColor?: string;
|
||||
}
|
||||
|
||||
const defaultItems: AccordionItem[] = [
|
||||
{ title: 'What is this product?', content: 'Our product is a powerful yet easy-to-use tool designed to help you build beautiful websites without writing a single line of code.', isOpen: true },
|
||||
{ title: 'How do I get started?', content: 'Simply sign up for a free account, choose a template, and start customizing. Our drag-and-drop editor makes it easy to create professional pages in minutes.', isOpen: false },
|
||||
{ title: 'Is there a free plan?', content: 'Yes! We offer a generous free tier that includes all core features. Upgrade anytime to unlock advanced capabilities like custom domains and analytics.', isOpen: false },
|
||||
];
|
||||
|
||||
export const Accordion: UserComponent<AccordionProps> = ({
|
||||
items = defaultItems,
|
||||
style = {},
|
||||
headerBg = '#f8fafc',
|
||||
headerColor = '#18181b',
|
||||
contentBg = '#ffffff',
|
||||
borderColor = '#e2e8f0',
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [openIndexes, setOpenIndexes] = useState<Set<number>>(() => {
|
||||
const initial = new Set<number>();
|
||||
items.forEach((item, i) => { if (item.isOpen) initial.add(i); });
|
||||
return initial;
|
||||
});
|
||||
|
||||
const toggle = (index: number) => {
|
||||
setOpenIndexes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto', display: 'flex', flexDirection: 'column', gap: '0px' }}>
|
||||
{items.map((item, i) => {
|
||||
const isOpen = openIndexes.has(i);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
border: `1px solid ${borderColor}`,
|
||||
borderBottom: i === items.length - 1 ? `1px solid ${borderColor}` : 'none',
|
||||
...(i === 0 ? { borderTopLeftRadius: '8px', borderTopRightRadius: '8px' } : {}),
|
||||
...(i === items.length - 1 ? { borderBottomLeftRadius: '8px', borderBottomRightRadius: '8px', borderBottom: `1px solid ${borderColor}` } : {}),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={() => toggle(i)}
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: headerBg,
|
||||
color: headerColor,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
userSelect: 'none',
|
||||
...(i === 0 ? { borderTopLeftRadius: '7px', borderTopRightRadius: '7px' } : {}),
|
||||
...(i === items.length - 1 && !isOpen ? { borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' } : {}),
|
||||
}}
|
||||
>
|
||||
<span>{item.title}</span>
|
||||
<span style={{ fontSize: '12px', transition: 'transform 0.2s', transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}>▼</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
backgroundColor: contentBg,
|
||||
color: '#4b5563',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
borderTop: `1px solid ${borderColor}`,
|
||||
...(i === items.length - 1 ? { borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' } : {}),
|
||||
}}
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const AccordionSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as AccordionProps,
|
||||
}));
|
||||
|
||||
const items = props.items || defaultItems;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
|
||||
const updateItem = (index: number, field: keyof AccordionItem, value: string | boolean) => {
|
||||
setProp((p: AccordionProps) => {
|
||||
const updated = [...(p.items || defaultItems)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.items = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
setProp((p: AccordionProps) => {
|
||||
p.items = [...(p.items || defaultItems), { title: 'New Question', content: 'Answer goes here.', isOpen: false }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
setProp((p: AccordionProps) => {
|
||||
const updated = [...(p.items || defaultItems)];
|
||||
updated.splice(index, 1);
|
||||
p.items = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const colorSwatches = ['#f8fafc', '#f1f5f9', '#e2e8f0', '#ffffff', '#18181b', '#1e293b', '#3b82f6', '#8b5cf6'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Header Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorSwatches.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: AccordionProps) => { p.headerBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.headerBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Header Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#18181b', '#1f2937', '#374151', '#ffffff', '#e2e8f0', '#3b82f6'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: AccordionProps) => { p.headerColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.headerColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Content Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#1e293b'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: AccordionProps) => { p.contentBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.contentBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Border Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#e2e8f0', '#cbd5e1', '#d1d5db', '#3f3f46', '#52525b'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: AccordionProps) => { p.borderColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.borderColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Items</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item, 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={item.title} onChange={(e) => updateItem(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={item.content}
|
||||
onChange={(e) => updateItem(i, 'content', e.target.value)}
|
||||
placeholder="Content"
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addItem}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Accordion.craft = {
|
||||
displayName: 'Accordion',
|
||||
props: {
|
||||
items: defaultItems,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
headerBg: '#f8fafc',
|
||||
headerColor: '#18181b',
|
||||
contentBg: '#ffffff',
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: AccordionSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const headerBg = props.headerBg || '#f8fafc';
|
||||
const headerColor = props.headerColor || '#18181b';
|
||||
const contentBg = props.contentBg || '#ffffff';
|
||||
const borderColor = props.borderColor || '#e2e8f0';
|
||||
const items = props.items || defaultItems;
|
||||
|
||||
const panels = items.map((item, i) => {
|
||||
const openAttr = item.isOpen ? ' open' : '';
|
||||
const topRadius = i === 0 ? 'border-top-left-radius:8px;border-top-right-radius:8px;' : '';
|
||||
const bottomRadius = i === items.length - 1 ? 'border-bottom-left-radius:8px;border-bottom-right-radius:8px;' : '';
|
||||
const borderBottom = i === items.length - 1 ? `border:1px solid ${borderColor};` : `border:1px solid ${borderColor};border-bottom:none;`;
|
||||
return `<details${openAttr} style="${borderBottom}${topRadius}${bottomRadius}">
|
||||
<summary style="padding:16px 20px;background-color:${headerBg};color:${headerColor};cursor:pointer;font-weight:600;font-size:16px;list-style:none;display:flex;justify-content:space-between;align-items:center">
|
||||
${esc(item.title)}
|
||||
</summary>
|
||||
<div style="padding:16px 20px;background-color:${contentBg};color:#4b5563;font-size:14px;line-height:1.6;border-top:1px solid ${borderColor}">
|
||||
${esc(item.content)}
|
||||
</div>
|
||||
</details>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:800px;margin:0 auto;display:flex;flex-direction:column">
|
||||
${panels}
|
||||
</div>
|
||||
<style>details summary::-webkit-details-marker{display:none}details summary::marker{display:none}</style>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface CTASectionProps {
|
||||
heading?: string;
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
gradient?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
||||
|
||||
export const CTASection: UserComponent<CTASectionProps> = ({
|
||||
heading = 'Ready to Get Started?',
|
||||
description = 'Join thousands of satisfied users and start building your dream website today.',
|
||||
buttonText = 'Start Free Trial',
|
||||
buttonHref = '#',
|
||||
gradient = defaultGradient,
|
||||
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={{
|
||||
background: gradient,
|
||||
padding: '80px 20px',
|
||||
textAlign: 'center',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '700px', margin: '0 auto' }}>
|
||||
<h2 style={{ fontSize: '36px', fontWeight: '700', color: '#ffffff', marginBottom: '12px' }}>
|
||||
{heading}
|
||||
</h2>
|
||||
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.85)', marginBottom: '28px', lineHeight: '1.6' }}>
|
||||
{description}
|
||||
</p>
|
||||
<a
|
||||
href={buttonHref}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '14px 36px',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#18181b',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
{buttonText}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const CTASectionSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as CTASectionProps,
|
||||
}));
|
||||
|
||||
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%)' },
|
||||
];
|
||||
|
||||
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: CTASectionProps) => { p.heading = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Description</label>
|
||||
<textarea
|
||||
value={props.description || ''}
|
||||
onChange={(e) => setProp((p: CTASectionProps) => { p.description = e.target.value; })}
|
||||
rows={2}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonText || ''}
|
||||
onChange={(e) => setProp((p: CTASectionProps) => { p.buttonText = e.target.value; })}
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.buttonHref || ''}
|
||||
onChange={(e) => setProp((p: CTASectionProps) => { p.buttonHref = e.target.value; })}
|
||||
placeholder="https://..."
|
||||
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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: CTASectionProps) => { p.gradient = g.value; })}
|
||||
title={g.label}
|
||||
style={{
|
||||
width: 32, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
background: g.value, cursor: 'pointer',
|
||||
outline: props.gradient === g.value ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
CTASection.craft = {
|
||||
displayName: 'CTA Section',
|
||||
props: {
|
||||
heading: 'Ready to Get Started?',
|
||||
description: 'Join thousands of satisfied users and start building your dream website today.',
|
||||
buttonText: 'Start Free Trial',
|
||||
buttonHref: '#',
|
||||
gradient: defaultGradient,
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: CTASectionSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const sectionStyle = cssPropsToString({
|
||||
background: props.gradient || defaultGradient,
|
||||
padding: '80px 20px',
|
||||
textAlign: 'center',
|
||||
...props.style,
|
||||
});
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:700px;margin:0 auto">
|
||||
<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px">${esc(props.heading || '')}</h2>
|
||||
<p style="font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6">${esc(props.description || '')}</p>
|
||||
<a href="${props.buttonHref || '#'}" style="display:inline-block;padding:14px 36px;background-color:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">${esc(props.buttonText || '')}</a>
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -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, '<').replace(/>/g, '>');
|
||||
|
||||
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>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,530 @@
|
||||
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface Slide {
|
||||
type: 'image' | 'content';
|
||||
imageSrc?: string;
|
||||
heading?: string;
|
||||
text?: string;
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
bgColor?: string;
|
||||
}
|
||||
|
||||
interface ContentSliderProps {
|
||||
slides?: Slide[];
|
||||
autoplay?: boolean;
|
||||
interval?: number;
|
||||
showDots?: boolean;
|
||||
showArrows?: boolean;
|
||||
height?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const defaultSlides: Slide[] = [
|
||||
{
|
||||
type: 'image',
|
||||
imageSrc: '',
|
||||
heading: 'First Slide',
|
||||
text: 'Welcome to our showcase',
|
||||
buttonText: 'Learn More',
|
||||
buttonHref: '#',
|
||||
bgColor: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
imageSrc: '',
|
||||
heading: 'Second Slide',
|
||||
text: 'Discover something amazing',
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
bgColor: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
imageSrc: '',
|
||||
heading: 'Third Slide',
|
||||
text: 'Build your future today',
|
||||
buttonText: 'Contact Us',
|
||||
buttonHref: '#',
|
||||
bgColor: 'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)',
|
||||
},
|
||||
];
|
||||
|
||||
export const ContentSlider: UserComponent<ContentSliderProps> = ({
|
||||
slides = defaultSlides,
|
||||
autoplay = true,
|
||||
interval = 5000,
|
||||
showDots = true,
|
||||
showArrows = true,
|
||||
height = '400px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const items = slides.length > 0 ? slides : defaultSlides;
|
||||
|
||||
const goTo = useCallback((index: number) => {
|
||||
setActiveIndex(((index % items.length) + items.length) % items.length);
|
||||
}, [items.length]);
|
||||
|
||||
const goNext = useCallback(() => goTo(activeIndex + 1), [activeIndex, goTo]);
|
||||
const goPrev = useCallback(() => goTo(activeIndex - 1), [activeIndex, goTo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoplay && items.length > 1) {
|
||||
timerRef.current = setInterval(goNext, interval);
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
||||
}
|
||||
}, [autoplay, interval, goNext, items.length]);
|
||||
|
||||
const arrowStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
color: '#18181b',
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||
};
|
||||
|
||||
const renderSlide = (slide: Slide, i: number) => {
|
||||
const bg = slide.imageSrc
|
||||
? { backgroundImage: `url(${slide.imageSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: slide.bgColor?.startsWith('linear-gradient')
|
||||
? { backgroundImage: slide.bgColor }
|
||||
: { backgroundColor: slide.bgColor || '#3b82f6' };
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
opacity: i === activeIndex ? 1 : 0,
|
||||
transition: 'opacity 0.5s ease-in-out',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...bg,
|
||||
}}
|
||||
>
|
||||
{(slide.heading || slide.text || slide.buttonText) && (
|
||||
<div style={{ textAlign: 'center', padding: '20px', zIndex: 1 }}>
|
||||
{slide.heading && (
|
||||
<h2 style={{ fontSize: '36px', fontWeight: '700', color: '#ffffff', marginBottom: '12px', fontFamily: 'Inter, sans-serif', textShadow: '0 2px 8px rgba(0,0,0,0.3)' }}>
|
||||
{slide.heading}
|
||||
</h2>
|
||||
)}
|
||||
{slide.text && (
|
||||
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.9)', marginBottom: '20px', fontFamily: 'Inter, sans-serif', textShadow: '0 1px 4px rgba(0,0,0,0.3)' }}>
|
||||
{slide.text}
|
||||
</p>
|
||||
)}
|
||||
{slide.buttonText && (
|
||||
<a
|
||||
href={slide.buttonHref || '#'}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '12px 28px',
|
||||
background: '#ffffff',
|
||||
color: '#18181b',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '15px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}
|
||||
>
|
||||
{slide.buttonText}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height,
|
||||
overflow: 'hidden',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{items.map((slide, i) => renderSlide(slide, i))}
|
||||
|
||||
{showArrows && items.length > 1 && (
|
||||
<>
|
||||
<button onClick={goPrev} style={{ ...arrowStyle, left: '16px' }}>
|
||||
<i className="fa fa-chevron-left" />
|
||||
</button>
|
||||
<button onClick={goNext} style={{ ...arrowStyle, right: '16px' }}>
|
||||
<i className="fa fa-chevron-right" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showDots && items.length > 1 && (
|
||||
<div style={{ position: 'absolute', bottom: '16px', left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: '8px', zIndex: 2 }}>
|
||||
{items.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => goTo(i)}
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: i === activeIndex ? '#ffffff' : 'rgba(255,255,255,0.5)',
|
||||
transition: 'background-color 0.3s',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const ContentSliderSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as ContentSliderProps,
|
||||
}));
|
||||
|
||||
const items = props.slides || defaultSlides;
|
||||
|
||||
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 heightPresets = ['300px', '400px', '500px', '600px', '80vh'];
|
||||
const intervalPresets = [3000, 4000, 5000, 7000, 10000];
|
||||
const bgPresets = [
|
||||
'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
|
||||
'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)',
|
||||
'linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%)',
|
||||
'#18181b',
|
||||
'#0f172a',
|
||||
'#1e293b',
|
||||
'#3b82f6',
|
||||
];
|
||||
|
||||
const updateSlide = (index: number, field: keyof Slide, value: string) => {
|
||||
setProp((p: ContentSliderProps) => {
|
||||
const updated = [...(p.slides || defaultSlides)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.slides = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addSlide = () => {
|
||||
setProp((p: ContentSliderProps) => {
|
||||
const current = p.slides || defaultSlides;
|
||||
p.slides = [...current, {
|
||||
type: 'image',
|
||||
imageSrc: '',
|
||||
heading: 'New Slide',
|
||||
text: 'Add your content here',
|
||||
buttonText: '',
|
||||
buttonHref: '#',
|
||||
bgColor: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const removeSlide = (index: number) => {
|
||||
setProp((p: ContentSliderProps) => {
|
||||
const updated = [...(p.slides || defaultSlides)];
|
||||
updated.splice(index, 1);
|
||||
p.slides = updated;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Height */}
|
||||
<div>
|
||||
<label style={labelStyle}>Height</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{heightPresets.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setProp((p: ContentSliderProps) => { p.height = h; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.height === h ? '#3b82f6' : '#27272a',
|
||||
color: props.height === h ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Autoplay */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.autoplay !== false}
|
||||
onChange={(e) => setProp((p: ContentSliderProps) => { p.autoplay = e.target.checked; })}
|
||||
/>
|
||||
Autoplay
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Interval */}
|
||||
{props.autoplay !== false && (
|
||||
<div>
|
||||
<label style={labelStyle}>Interval (ms)</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{intervalPresets.map((ms) => (
|
||||
<button
|
||||
key={ms}
|
||||
onClick={() => setProp((p: ContentSliderProps) => { p.interval = ms; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: (props.interval || 5000) === ms ? '#3b82f6' : '#27272a',
|
||||
color: (props.interval || 5000) === ms ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{ms / 1000}s
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show Arrows */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.showArrows !== false}
|
||||
onChange={(e) => setProp((p: ContentSliderProps) => { p.showArrows = e.target.checked; })}
|
||||
/>
|
||||
Show Arrows
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Show Dots */}
|
||||
<div>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.showDots !== false}
|
||||
onChange={(e) => setProp((p: ContentSliderProps) => { p.showDots = e.target.checked; })}
|
||||
/>
|
||||
Show Dots
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Slides */}
|
||||
<div>
|
||||
<label style={labelStyle}>Slides</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((slide, 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' }}>
|
||||
<span style={{ fontSize: 11, color: '#a1a1aa', flex: 'none', width: 18 }}>{i + 1}.</span>
|
||||
<select
|
||||
value={slide.type}
|
||||
onChange={(e) => updateSlide(i, 'type', e.target.value)}
|
||||
style={{ ...inputStyle, width: 70, flex: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
<option value="image">Image</option>
|
||||
<option value="content">Content</option>
|
||||
</select>
|
||||
<input type="text" value={slide.heading || ''} onChange={(e) => updateSlide(i, 'heading', e.target.value)} placeholder="Heading" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeSlide(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" value={slide.imageSrc || ''} onChange={(e) => updateSlide(i, 'imageSrc', e.target.value)} placeholder="Image URL (optional)" style={inputStyle} />
|
||||
<input type="text" value={slide.text || ''} onChange={(e) => updateSlide(i, 'text', e.target.value)} placeholder="Text" style={inputStyle} />
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={slide.buttonText || ''} onChange={(e) => updateSlide(i, 'buttonText', e.target.value)} placeholder="Button text" style={{ ...inputStyle, flex: 1 }} />
|
||||
<input type="text" value={slide.buttonHref || ''} onChange={(e) => updateSlide(i, 'buttonHref', e.target.value)} placeholder="Button URL" style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ fontSize: 10, color: '#a1a1aa', display: 'block', marginBottom: 2 }}>Background</span>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((bg) => (
|
||||
<button
|
||||
key={bg}
|
||||
onClick={() => updateSlide(i, 'bgColor', bg)}
|
||||
style={{
|
||||
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
background: bg, cursor: 'pointer',
|
||||
outline: slide.bgColor === bg ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addSlide}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Slide
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
ContentSlider.craft = {
|
||||
displayName: 'Content Slider',
|
||||
props: {
|
||||
slides: defaultSlides,
|
||||
autoplay: true,
|
||||
interval: 5000,
|
||||
showDots: true,
|
||||
showArrows: true,
|
||||
height: '400px',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: ContentSliderSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const {
|
||||
slides = defaultSlides,
|
||||
autoplay = true,
|
||||
interval = 5000,
|
||||
showDots = true,
|
||||
showArrows = true,
|
||||
height = '400px',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const items = slides.length > 0 ? slides : defaultSlides;
|
||||
const uid = 'cs_' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height,
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
});
|
||||
|
||||
const slidesHtml = items.map((slide, i) => {
|
||||
const hasBgImage = slide.imageSrc;
|
||||
const bgStyle = hasBgImage
|
||||
? `background-image:url(${esc(slide.imageSrc!)});background-size:cover;background-position:center`
|
||||
: slide.bgColor?.startsWith('linear-gradient')
|
||||
? `background-image:${slide.bgColor}`
|
||||
: `background-color:${slide.bgColor || '#3b82f6'}`;
|
||||
|
||||
const contentParts: string[] = [];
|
||||
if (slide.heading) {
|
||||
contentParts.push(`<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px;font-family:Inter,sans-serif;text-shadow:0 2px 8px rgba(0,0,0,0.3)">${esc(slide.heading)}</h2>`);
|
||||
}
|
||||
if (slide.text) {
|
||||
contentParts.push(`<p style="font-size:18px;color:rgba(255,255,255,0.9);margin-bottom:20px;font-family:Inter,sans-serif;text-shadow:0 1px 4px rgba(0,0,0,0.3)">${esc(slide.text)}</p>`);
|
||||
}
|
||||
if (slide.buttonText) {
|
||||
contentParts.push(`<a href="${esc(slide.buttonHref || '#')}" style="display:inline-block;padding:12px 28px;background:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;font-family:Inter,sans-serif">${esc(slide.buttonText)}</a>`);
|
||||
}
|
||||
|
||||
const innerHtml = contentParts.length > 0
|
||||
? `<div style="text-align:center;padding:20px;z-index:1">${contentParts.join('\n ')}</div>`
|
||||
: '';
|
||||
|
||||
return `<div id="${uid}_s${i}" style="position:absolute;top:0;left:0;width:100%;height:100%;opacity:${i === 0 ? 1 : 0};transition:opacity 0.5s ease-in-out;display:flex;flex-direction:column;align-items:center;justify-content:center;${bgStyle}">
|
||||
${innerHtml}
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
const arrowsHtml = showArrows && items.length > 1
|
||||
? `<button onclick="${uid}_prev()" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-left"></i></button>
|
||||
<button onclick="${uid}_next()" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-right"></i></button>`
|
||||
: '';
|
||||
|
||||
const dotsHtml = showDots && items.length > 1
|
||||
? `<div style="position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:8px;z-index:2">
|
||||
${items.map((_, i) => `<button onclick="${uid}_go(${i})" id="${uid}_d${i}" style="width:10px;height:10px;border-radius:50%;border:none;cursor:pointer;background-color:${i === 0 ? '#ffffff' : 'rgba(255,255,255,0.5)'};transition:background-color 0.3s"></button>`).join('\n ')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
${slidesHtml}
|
||||
${arrowsHtml}
|
||||
${dotsHtml}
|
||||
<script>
|
||||
(function(){
|
||||
var current=0, total=${items.length}, uid="${uid}";
|
||||
function show(idx){
|
||||
document.getElementById(uid+"_s"+current).style.opacity="0";
|
||||
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="rgba(255,255,255,0.5)";` : ''}
|
||||
current=((idx%total)+total)%total;
|
||||
document.getElementById(uid+"_s"+current).style.opacity="1";
|
||||
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="#ffffff";` : ''}
|
||||
}
|
||||
window["${uid}_go"]=show;
|
||||
window["${uid}_next"]=function(){show(current+1);};
|
||||
window["${uid}_prev"]=function(){show(current-1);};
|
||||
${autoplay && items.length > 1 ? `setInterval(function(){show(current+1);},${interval});` : ''}
|
||||
})();
|
||||
</script>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,311 @@
|
||||
import React, { CSSProperties, useEffect, useState, useCallback } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface CountdownProps {
|
||||
targetDate?: string;
|
||||
heading?: string;
|
||||
style?: CSSProperties;
|
||||
digitColor?: string;
|
||||
labelColor?: string;
|
||||
bgColor?: string;
|
||||
}
|
||||
|
||||
interface TimeLeft {
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
function getDefaultTargetDate(): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 30);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function calcTimeLeft(target: string): TimeLeft {
|
||||
const diff = new Date(target).getTime() - Date.now();
|
||||
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||
return {
|
||||
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
|
||||
hours: Math.floor((diff / (1000 * 60 * 60)) % 24),
|
||||
minutes: Math.floor((diff / (1000 * 60)) % 60),
|
||||
seconds: Math.floor((diff / 1000) % 60),
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_TARGET = getDefaultTargetDate();
|
||||
|
||||
export const Countdown: UserComponent<CountdownProps> = ({
|
||||
targetDate = DEFAULT_TARGET,
|
||||
heading = 'Coming Soon',
|
||||
style = {},
|
||||
digitColor = '#ffffff',
|
||||
labelColor = 'rgba(255,255,255,0.7)',
|
||||
bgColor = '#18181b',
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [timeLeft, setTimeLeft] = useState<TimeLeft>(() => calcTimeLeft(targetDate));
|
||||
|
||||
useEffect(() => {
|
||||
setTimeLeft(calcTimeLeft(targetDate));
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft(calcTimeLeft(targetDate));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [targetDate]);
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
const boxStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
minWidth: '80px',
|
||||
};
|
||||
|
||||
const digitStyle: CSSProperties = {
|
||||
fontSize: '48px',
|
||||
fontWeight: '700',
|
||||
color: digitColor,
|
||||
lineHeight: '1',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
};
|
||||
|
||||
const unitLabelStyle: CSSProperties = {
|
||||
fontSize: '12px',
|
||||
color: labelColor,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.1em',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
};
|
||||
|
||||
const units: Array<{ label: string; value: number }> = [
|
||||
{ label: 'Days', value: timeLeft.days },
|
||||
{ label: 'Hours', value: timeLeft.hours },
|
||||
{ label: 'Minutes', value: timeLeft.minutes },
|
||||
{ label: 'Seconds', value: timeLeft.seconds },
|
||||
];
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: bgColor,
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{heading && (
|
||||
<h2 style={{ fontSize: '32px', fontWeight: '700', color: digitColor, marginBottom: '32px', fontFamily: 'Inter, sans-serif' }}>
|
||||
{heading}
|
||||
</h2>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: '24px', flexWrap: 'wrap' }}>
|
||||
{units.map((u) => (
|
||||
<div key={u.label} style={boxStyle}>
|
||||
<span style={digitStyle}>{pad(u.value)}</span>
|
||||
<span style={unitLabelStyle}>{u.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const CountdownSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as CountdownProps,
|
||||
}));
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
|
||||
const colorPresets = ['#ffffff', '#f8fafc', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
|
||||
const bgPresets = ['#18181b', '#0f172a', '#1e293b', '#1e1b4b', '#042f2e', '#27272a', '#ffffff', '#f8fafc'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Target date */}
|
||||
<div>
|
||||
<label style={labelStyle}>Target Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={props.targetDate || DEFAULT_TARGET}
|
||||
onChange={(e) => setProp((p: CountdownProps) => { p.targetDate = e.target.value; })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<div>
|
||||
<label style={labelStyle}>Heading</label>
|
||||
<input
|
||||
type="text"
|
||||
value={props.heading || ''}
|
||||
onChange={(e) => setProp((p: CountdownProps) => { p.heading = e.target.value; })}
|
||||
placeholder="Coming Soon"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Digit color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Digit Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CountdownProps) => { p.digitColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.digitColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Label Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CountdownProps) => { p.labelColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.labelColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: CountdownProps) => { p.bgColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Countdown.craft = {
|
||||
displayName: 'Countdown',
|
||||
props: {
|
||||
targetDate: DEFAULT_TARGET,
|
||||
heading: 'Coming Soon',
|
||||
style: {},
|
||||
digitColor: '#ffffff',
|
||||
labelColor: 'rgba(255,255,255,0.7)',
|
||||
bgColor: '#18181b',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: CountdownSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const {
|
||||
targetDate = DEFAULT_TARGET,
|
||||
heading = 'Coming Soon',
|
||||
style = {},
|
||||
digitColor = '#ffffff',
|
||||
labelColor = 'rgba(255,255,255,0.7)',
|
||||
bgColor = '#18181b',
|
||||
} = props;
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: bgColor,
|
||||
...style,
|
||||
});
|
||||
|
||||
const headingHtml = heading
|
||||
? `<h2 style="font-size:32px;font-weight:700;color:${digitColor};margin-bottom:32px;font-family:Inter,sans-serif">${esc(heading)}</h2>`
|
||||
: '';
|
||||
|
||||
const boxStyle = 'display:flex;flex-direction:column;align-items:center;gap:4px;min-width:80px';
|
||||
const dStyle = `font-size:48px;font-weight:700;color:${digitColor};line-height:1;font-family:Inter,sans-serif`;
|
||||
const lStyle = `font-size:12px;color:${labelColor};text-transform:uppercase;letter-spacing:0.1em;font-family:Inter,sans-serif`;
|
||||
|
||||
// Generate a unique ID for this countdown instance
|
||||
const uid = 'cd_' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
${headingHtml}
|
||||
<div style="display:flex;justify-content:center;gap:24px;flex-wrap:wrap">
|
||||
<div style="${boxStyle}"><span id="${uid}_d" style="${dStyle}">00</span><span style="${lStyle}">Days</span></div>
|
||||
<div style="${boxStyle}"><span id="${uid}_h" style="${dStyle}">00</span><span style="${lStyle}">Hours</span></div>
|
||||
<div style="${boxStyle}"><span id="${uid}_m" style="${dStyle}">00</span><span style="${lStyle}">Minutes</span></div>
|
||||
<div style="${boxStyle}"><span id="${uid}_s" style="${dStyle}">00</span><span style="${lStyle}">Seconds</span></div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var target = new Date("${targetDate}").getTime();
|
||||
function pad(n){ return String(n).padStart(2,'0'); }
|
||||
function update(){
|
||||
var diff = target - Date.now();
|
||||
if(diff<=0){ diff=0; }
|
||||
var d = Math.floor(diff/(1000*60*60*24));
|
||||
var h = Math.floor((diff/(1000*60*60))%24);
|
||||
var m = Math.floor((diff/(1000*60))%60);
|
||||
var s = Math.floor((diff/1000)%60);
|
||||
document.getElementById("${uid}_d").textContent = pad(d);
|
||||
document.getElementById("${uid}_h").textContent = pad(h);
|
||||
document.getElementById("${uid}_m").textContent = pad(m);
|
||||
document.getElementById("${uid}_s").textContent = pad(s);
|
||||
}
|
||||
update();
|
||||
setInterval(update,1000);
|
||||
})();
|
||||
</script>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
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) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
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>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,322 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface GalleryImage {
|
||||
src: string;
|
||||
alt: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
interface GalleryProps {
|
||||
images?: GalleryImage[];
|
||||
columns?: number;
|
||||
gap?: string;
|
||||
style?: CSSProperties;
|
||||
lightbox?: boolean;
|
||||
}
|
||||
|
||||
const placeholderSvg = (index: number) => {
|
||||
const colors = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#ec4899'];
|
||||
const color = colors[index % colors.length];
|
||||
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300"><rect fill="${color}" width="400" height="300" opacity="0.15"/><rect fill="${color}" x="150" y="100" width="100" height="100" rx="12" opacity="0.3"/><text x="200" y="160" text-anchor="middle" font-family="sans-serif" font-size="24" fill="${color}" opacity="0.6">${index + 1}</text></svg>`)}`;
|
||||
};
|
||||
|
||||
const defaultImages: GalleryImage[] = [
|
||||
{ src: placeholderSvg(0), alt: 'Gallery image 1', caption: 'First image' },
|
||||
{ src: placeholderSvg(1), alt: 'Gallery image 2', caption: 'Second image' },
|
||||
{ src: placeholderSvg(2), alt: 'Gallery image 3', caption: 'Third image' },
|
||||
{ src: placeholderSvg(3), alt: 'Gallery image 4', caption: 'Fourth image' },
|
||||
{ src: placeholderSvg(4), alt: 'Gallery image 5', caption: 'Fifth image' },
|
||||
{ src: placeholderSvg(5), alt: 'Gallery image 6', caption: 'Sixth image' },
|
||||
];
|
||||
|
||||
export const Gallery: UserComponent<GalleryProps> = ({
|
||||
images = defaultImages,
|
||||
columns = 3,
|
||||
gap = '16px',
|
||||
style = {},
|
||||
lightbox = false,
|
||||
}) => {
|
||||
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: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '1100px',
|
||||
margin: '0 auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gap: gap,
|
||||
}}
|
||||
>
|
||||
{images.map((img, i) => (
|
||||
<div key={i} style={{ position: 'relative', overflow: 'hidden', borderRadius: '8px' }}>
|
||||
<img
|
||||
src={img.src}
|
||||
alt={img.alt}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '200px',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: '#f1f5f9',
|
||||
}}
|
||||
/>
|
||||
{img.caption && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
left: '0',
|
||||
right: '0',
|
||||
padding: '8px 12px',
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
|
||||
color: '#ffffff',
|
||||
fontSize: '12px',
|
||||
borderBottomLeftRadius: '8px',
|
||||
borderBottomRightRadius: '8px',
|
||||
}}
|
||||
>
|
||||
{img.caption}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const GallerySettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as GalleryProps,
|
||||
}));
|
||||
|
||||
const images = props.images || defaultImages;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
|
||||
const updateImage = (index: number, field: keyof GalleryImage, value: string) => {
|
||||
setProp((p: GalleryProps) => {
|
||||
const updated = [...(p.images || defaultImages)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.images = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addImage = () => {
|
||||
setProp((p: GalleryProps) => {
|
||||
const current = p.images || defaultImages;
|
||||
p.images = [...current, { src: placeholderSvg(current.length), alt: `Gallery image ${current.length + 1}`, caption: '' }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
setProp((p: GalleryProps) => {
|
||||
const updated = [...(p.images || defaultImages)];
|
||||
updated.splice(index, 1);
|
||||
p.images = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const columnOptions = [2, 3, 4, 5, 6];
|
||||
const gapOptions = ['8px', '12px', '16px', '24px', '32px'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: GalleryProps) => { 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={labelStyle}>Columns</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{columnOptions.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setProp((p: GalleryProps) => { p.columns = n; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.columns === n ? '#3b82f6' : '#27272a',
|
||||
color: props.columns === n ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Gap</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{gapOptions.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => setProp((p: GalleryProps) => { p.gap = g; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: props.gap === g ? '#3b82f6' : '#27272a',
|
||||
color: props.gap === g ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.lightbox || false}
|
||||
onChange={(e) => setProp((p: GalleryProps) => { p.lightbox = e.target.checked; })}
|
||||
/>
|
||||
Lightbox (click to enlarge in exported HTML)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Images</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{images.map((img, 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' }}>
|
||||
<img
|
||||
src={img.src}
|
||||
alt=""
|
||||
style={{ width: 32, height: 32, objectFit: 'cover', borderRadius: 4, flexShrink: 0, backgroundColor: '#27272a' }}
|
||||
/>
|
||||
<input type="text" value={img.src} onChange={(e) => updateImage(i, 'src', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeImage(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={img.alt} onChange={(e) => updateImage(i, 'alt', e.target.value)} placeholder="Alt text" style={{ ...inputStyle, flex: 1 }} />
|
||||
<input type="text" value={img.caption || ''} onChange={(e) => updateImage(i, 'caption', e.target.value)} placeholder="Caption" style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addImage}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Gallery.craft = {
|
||||
displayName: 'Gallery',
|
||||
props: {
|
||||
images: defaultImages,
|
||||
columns: 3,
|
||||
gap: '16px',
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
lightbox: false,
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: GallerySettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const images = props.images || defaultImages;
|
||||
const columns = props.columns || 3;
|
||||
const gap = props.gap || '16px';
|
||||
const lightbox = props.lightbox || false;
|
||||
|
||||
const galleryId = 'gallery_' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
const items = images.map((img) => {
|
||||
const caption = img.caption
|
||||
? `<div style="position:absolute;bottom:0;left:0;right:0;padding:8px 12px;background:linear-gradient(transparent,rgba(0,0,0,0.7));color:#ffffff;font-size:12px;border-bottom-left-radius:8px;border-bottom-right-radius:8px">${esc(img.caption)}</div>`
|
||||
: '';
|
||||
const clickAttr = lightbox ? ` onclick="${galleryId}_open('${esc(img.src)}')" style="cursor:pointer;position:relative;overflow:hidden;border-radius:8px"` : ' style="position:relative;overflow:hidden;border-radius:8px"';
|
||||
return `<div${clickAttr}>
|
||||
<img src="${esc(img.src)}" alt="${esc(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
|
||||
${caption}
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
let lightboxHtml = '';
|
||||
if (lightbox) {
|
||||
lightboxHtml = `
|
||||
<div id="${galleryId}_overlay" onclick="${galleryId}_close()" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;justify-content:center;align-items:center;cursor:pointer">
|
||||
<img id="${galleryId}_img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" />
|
||||
</div>
|
||||
<script>
|
||||
function ${galleryId}_open(src){var o=document.getElementById('${galleryId}_overlay');document.getElementById('${galleryId}_img').src=src;o.style.display='flex';}
|
||||
function ${galleryId}_close(){document.getElementById('${galleryId}_overlay').style.display='none';}
|
||||
</script>`;
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:${gap}">
|
||||
${items}
|
||||
</div>${lightboxHtml}
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,457 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface HeroProps {
|
||||
heading?: string;
|
||||
subtitle?: string;
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
secondaryButtonText?: string;
|
||||
secondaryButtonHref?: string;
|
||||
bgType?: 'color' | 'gradient' | 'image' | 'video';
|
||||
bgColor?: string;
|
||||
bgGradientFrom?: string;
|
||||
bgGradientTo?: string;
|
||||
bgGradientAngle?: number;
|
||||
bgImage?: string;
|
||||
bgVideo?: string;
|
||||
overlayColor?: string;
|
||||
overlayOpacity?: number;
|
||||
textColor?: string;
|
||||
buttonBgColor?: string;
|
||||
buttonTextColor?: string;
|
||||
minHeight?: string;
|
||||
verticalAlign?: 'top' | 'center' | 'bottom';
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
// Helper: build the background CSS value
|
||||
function buildBackground(props: HeroProps): string {
|
||||
switch (props.bgType) {
|
||||
case 'gradient':
|
||||
return `linear-gradient(${props.bgGradientAngle || 135}deg, ${props.bgGradientFrom || '#667eea'}, ${props.bgGradientTo || '#764ba2'})`;
|
||||
case 'image':
|
||||
return props.bgImage ? `url('${props.bgImage}') center/cover no-repeat` : '#1e293b';
|
||||
case 'color':
|
||||
default:
|
||||
return props.bgColor || '#1e293b';
|
||||
}
|
||||
}
|
||||
|
||||
export const HeroSimple: UserComponent<HeroProps> = ({
|
||||
heading = 'Build Something Amazing',
|
||||
subtitle = 'Create beautiful websites without writing a single line of code.',
|
||||
buttonText = 'Get Started',
|
||||
buttonHref = '#',
|
||||
secondaryButtonText = '',
|
||||
secondaryButtonHref = '#',
|
||||
bgType = 'color',
|
||||
bgColor = '#1e293b',
|
||||
bgGradientFrom = '#667eea',
|
||||
bgGradientTo = '#764ba2',
|
||||
bgGradientAngle = 135,
|
||||
bgImage = '',
|
||||
bgVideo = '',
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 0,
|
||||
textColor = '#ffffff',
|
||||
buttonBgColor = '#3b82f6',
|
||||
buttonTextColor = '#ffffff',
|
||||
minHeight = '500px',
|
||||
verticalAlign = 'center',
|
||||
textAlign = 'center',
|
||||
style = {},
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
const bg = buildBackground({
|
||||
bgType, bgColor, bgGradientFrom, bgGradientTo, bgGradientAngle, bgImage,
|
||||
} as HeroProps);
|
||||
|
||||
const justifyMap = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
...style,
|
||||
background: bgType !== 'image' ? bg : undefined,
|
||||
backgroundImage: bgType === 'image' && bgImage ? `url('${bgImage}')` : undefined,
|
||||
backgroundSize: bgType === 'image' ? 'cover' : undefined,
|
||||
backgroundPosition: bgType === 'image' ? 'center' : undefined,
|
||||
minHeight: minHeight === '100vh' ? '100vh' : minHeight,
|
||||
display: 'flex',
|
||||
alignItems: justifyMap[verticalAlign] || 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
padding: '60px 20px',
|
||||
}}
|
||||
>
|
||||
{/* Video background */}
|
||||
{bgType === 'video' && bgVideo && (
|
||||
<video
|
||||
src={bgVideo}
|
||||
autoPlay muted loop playsInline
|
||||
style={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%',
|
||||
objectFit: 'cover', zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Overlay (renders AFTER video so it sits on top) */}
|
||||
{overlayOpacity > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
||||
backgroundColor: overlayColor,
|
||||
opacity: overlayOpacity / 100,
|
||||
zIndex: 1,
|
||||
}} />
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div style={{
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
textAlign: textAlign as any,
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontSize: '48px', fontWeight: '700', color: textColor,
|
||||
marginBottom: '16px', lineHeight: '1.2',
|
||||
}}>
|
||||
{heading}
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: '20px', color: textColor,
|
||||
opacity: 0.85, marginBottom: '32px', lineHeight: '1.6',
|
||||
whiteSpace: 'pre-line',
|
||||
}}>
|
||||
{subtitle}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start', flexWrap: 'wrap' }}>
|
||||
{buttonText && (
|
||||
<a href={buttonHref} onClick={(e) => e.preventDefault()} style={{
|
||||
display: 'inline-block', padding: '14px 36px', backgroundColor: buttonBgColor,
|
||||
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 inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '6px 8px', background: '#27272a',
|
||||
color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4,
|
||||
};
|
||||
const btnStyle = (active: boolean): React.CSSProperties => ({
|
||||
flex: 1, padding: '6px 4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: active ? '#3b82f6' : '#27272a',
|
||||
color: active ? '#fff' : '#a1a1aa',
|
||||
fontWeight: active ? 600 : 400,
|
||||
});
|
||||
|
||||
const HeroSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as HeroProps,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label style={labelStyle}>Heading</label>
|
||||
<input type="text" value={props.heading || ''} onChange={(e) => setProp((p: HeroProps) => { p.heading = e.target.value; })} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Subtitle</label>
|
||||
<textarea value={props.subtitle || ''} onChange={(e) => setProp((p: HeroProps) => { p.subtitle = e.target.value; })} rows={3} style={{ ...inputStyle, resize: 'vertical' as const }} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="text" value={props.buttonText || ''} onChange={(e) => setProp((p: HeroProps) => { p.buttonText = e.target.value; })} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Button URL</label>
|
||||
<input type="text" value={props.buttonHref || ''} onChange={(e) => setProp((p: HeroProps) => { p.buttonHref = e.target.value; })} placeholder="#" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Secondary Button Text</label>
|
||||
<input type="text" value={props.secondaryButtonText || ''} onChange={(e) => setProp((p: HeroProps) => { p.secondaryButtonText = e.target.value; })} placeholder="Leave blank to hide" style={inputStyle} />
|
||||
</div>
|
||||
|
||||
{/* Background Type */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background Type</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['color', 'gradient', 'image', 'video'] as const).map((t) => (
|
||||
<button key={t} onClick={() => setProp((p: HeroProps) => { p.bgType = t; })}
|
||||
style={btnStyle(props.bgType === t)}>
|
||||
{t === 'color' ? 'Color' : t === 'gradient' ? 'Gradient' : t === 'image' ? 'Image' : 'Video'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background controls based on type */}
|
||||
{props.bgType === 'color' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Background Color</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="color" value={props.bgColor || '#1e293b'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgColor = e.target.value; })}
|
||||
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="text" value={props.bgColor || '#1e293b'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgColor = e.target.value; })}
|
||||
style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.bgType === 'gradient' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>From</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="color" value={props.bgGradientFrom || '#667eea'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientFrom = e.target.value; })}
|
||||
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="text" value={props.bgGradientFrom || '#667eea'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientFrom = e.target.value; })}
|
||||
style={{ ...inputStyle, fontSize: 10 }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>To</label>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="color" value={props.bgGradientTo || '#764ba2'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientTo = e.target.value; })}
|
||||
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="text" value={props.bgGradientTo || '#764ba2'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientTo = e.target.value; })}
|
||||
style={{ ...inputStyle, fontSize: 10 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Angle: {props.bgGradientAngle || 135}°</label>
|
||||
<input type="range" min={0} max={360} value={props.bgGradientAngle || 135}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientAngle = parseInt(e.target.value); })}
|
||||
style={{ width: '100%' }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{props.bgType === 'image' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Background Image URL</label>
|
||||
<input type="text" value={props.bgImage || ''} placeholder="https://..."
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgImage = e.target.value; })} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.bgType === 'video' && (
|
||||
<div>
|
||||
<label style={labelStyle}>Background Video URL</label>
|
||||
<input type="text" value={props.bgVideo || ''} placeholder="https://...mp4"
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.bgVideo = e.target.value; })} style={inputStyle} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay */}
|
||||
{(props.bgType === 'image' || props.bgType === 'video') && (
|
||||
<div>
|
||||
<label style={labelStyle}>Overlay ({props.overlayOpacity || 0}%)</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="color" value={props.overlayColor || '#000000'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.overlayColor = e.target.value; })}
|
||||
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="range" min={0} max={100} value={props.overlayOpacity || 0}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.overlayOpacity = parseInt(e.target.value); })}
|
||||
style={{ flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text & Button Colors */}
|
||||
<div>
|
||||
<label style={labelStyle}>Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input type="color" value={props.textColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.textColor = e.target.value; })}
|
||||
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none' }} />
|
||||
<input type="text" value={props.textColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.textColor = e.target.value; })}
|
||||
style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Button BG</label>
|
||||
<input type="color" value={props.buttonBgColor || '#3b82f6'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.buttonBgColor = e.target.value; })}
|
||||
style={{ width: '100%', height: 30, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Button Text</label>
|
||||
<input type="color" value={props.buttonTextColor || '#ffffff'}
|
||||
onChange={(e) => setProp((p: HeroProps) => { p.buttonTextColor = e.target.value; })}
|
||||
style={{ width: '100%', height: 30, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layout */}
|
||||
<div>
|
||||
<label style={labelStyle}>Min Height</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{['300px', '400px', '500px', '600px', '100vh'].map((h) => (
|
||||
<button key={h} onClick={() => setProp((p: HeroProps) => { p.minHeight = h; })}
|
||||
style={btnStyle(props.minHeight === h)}>{h === '100vh' ? 'Full' : h}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Vertical Align</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['top', 'center', 'bottom'] as const).map((v) => (
|
||||
<button key={v} onClick={() => setProp((p: HeroProps) => { p.verticalAlign = v; })}
|
||||
style={btnStyle(props.verticalAlign === v)}>{v}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Text Align</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{(['left', 'center', 'right'] as const).map((a) => (
|
||||
<button key={a} onClick={() => setProp((p: HeroProps) => { p.textAlign = a; })}
|
||||
style={btnStyle(props.textAlign === a)}>
|
||||
<i className={`fa fa-align-${a}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
HeroSimple.craft = {
|
||||
displayName: 'Hero',
|
||||
props: {
|
||||
heading: 'Build Something Amazing',
|
||||
subtitle: 'Create beautiful websites without writing a single line of code.',
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
secondaryButtonText: '',
|
||||
secondaryButtonHref: '#',
|
||||
bgType: 'color',
|
||||
bgColor: '#1e293b',
|
||||
bgGradientFrom: '#667eea',
|
||||
bgGradientTo: '#764ba2',
|
||||
bgGradientAngle: 135,
|
||||
bgImage: '',
|
||||
bgVideo: '',
|
||||
overlayColor: '#000000',
|
||||
overlayOpacity: 0,
|
||||
textColor: '#ffffff',
|
||||
buttonBgColor: '#3b82f6',
|
||||
buttonTextColor: '#ffffff',
|
||||
minHeight: '500px',
|
||||
verticalAlign: 'center',
|
||||
textAlign: 'center',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: HeroSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const bg = buildBackground(props);
|
||||
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
background: props.bgType !== 'image' ? bg : undefined,
|
||||
backgroundImage: props.bgType === 'image' && props.bgImage ? `url('${props.bgImage}')` : undefined,
|
||||
backgroundSize: props.bgType === 'image' ? 'cover' : undefined,
|
||||
backgroundPosition: props.bgType === 'image' ? 'center' : undefined,
|
||||
minHeight: props.minHeight || '500px',
|
||||
display: 'flex',
|
||||
alignItems: justifyMap[props.verticalAlign || 'center'],
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
|
||||
let overlayHtml = '';
|
||||
if ((props.overlayOpacity || 0) > 0) {
|
||||
overlayHtml = `<div style="position:absolute;top:0;left:0;right:0;bottom:0;background-color:${props.overlayColor || '#000'};opacity:${(props.overlayOpacity || 0) / 100};z-index:1"></div>`;
|
||||
}
|
||||
|
||||
let videoHtml = '';
|
||||
if (props.bgType === 'video' && props.bgVideo) {
|
||||
videoHtml = `<video src="${props.bgVideo}" autoplay muted loop playsinline style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0"></video>`;
|
||||
}
|
||||
|
||||
const textAlign = props.textAlign || 'center';
|
||||
const justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start';
|
||||
|
||||
let buttonsHtml = '';
|
||||
if (props.buttonText) {
|
||||
buttonsHtml += `<a href="${props.buttonHref || '#'}" style="display:inline-block;padding:14px 36px;background-color:${props.buttonBgColor || '#3b82f6'};color:${props.buttonTextColor || '#fff'};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">${esc(props.buttonText)}</a>`;
|
||||
}
|
||||
if (props.secondaryButtonText) {
|
||||
buttonsHtml += `<a href="${props.secondaryButtonHref || '#'}" style="display:inline-block;padding:14px 36px;background:transparent;color:${props.textColor || '#fff'};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${props.textColor || '#fff'}">${esc(props.secondaryButtonText)}</a>`;
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<section style="${sectionStyle}">
|
||||
${videoHtml}${overlayHtml}
|
||||
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:${textAlign}">
|
||||
<h1 style="font-size:48px;font-weight:700;color:${props.textColor || '#fff'};margin-bottom:16px;line-height:1.2">${esc(props.heading || '')}</h1>
|
||||
<p style="font-size:20px;color:${props.textColor || '#fff'};opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">${esc(props.subtitle || '')}</p>
|
||||
<div style="display:flex;gap:12px;justify-content:${justifyBtn};flex-wrap:wrap">${buttonsHtml}</div>
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,368 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface Counter {
|
||||
number: number;
|
||||
suffix: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface NumberCounterProps {
|
||||
counters?: Counter[];
|
||||
columns?: number;
|
||||
numberColor?: string;
|
||||
labelColor?: string;
|
||||
numberSize?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const defaultCounters: Counter[] = [
|
||||
{ number: 150, suffix: '+', label: 'Projects' },
|
||||
{ number: 50, suffix: '+', label: 'Clients' },
|
||||
{ number: 10, suffix: '', label: 'Years' },
|
||||
{ number: 99, suffix: '%', label: 'Satisfaction' },
|
||||
];
|
||||
|
||||
export const NumberCounter: UserComponent<NumberCounterProps> = ({
|
||||
counters = defaultCounters,
|
||||
columns = 4,
|
||||
numberColor = '#3b82f6',
|
||||
labelColor = '#6b7280',
|
||||
numberSize = '48px',
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const items = counters.length > 0 ? counters : defaultCounters;
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '1100px',
|
||||
margin: '0 auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gap: '32px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{items.map((counter, i) => (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{
|
||||
fontSize: numberSize,
|
||||
fontWeight: '700',
|
||||
color: numberColor,
|
||||
lineHeight: '1.1',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
}}>
|
||||
{counter.number}{counter.suffix}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '15px',
|
||||
color: labelColor,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
{counter.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const NumberCounterSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as NumberCounterProps,
|
||||
}));
|
||||
|
||||
const items = props.counters || defaultCounters;
|
||||
|
||||
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 columnOptions = [2, 3, 4, 5, 6];
|
||||
const sizePresets = ['32px', '40px', '48px', '56px', '64px'];
|
||||
const numberColorPresets = ['#3b82f6', '#10b981', '#8b5cf6', '#ef4444', '#f59e0b', '#18181b', '#ec4899', '#0ea5e9'];
|
||||
const labelColorPresets = ['#6b7280', '#374151', '#9ca3af', '#a1a1aa', '#64748b', '#18181b'];
|
||||
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
|
||||
|
||||
const updateCounter = (index: number, field: keyof Counter, value: string | number) => {
|
||||
setProp((p: NumberCounterProps) => {
|
||||
const updated = [...(p.counters || defaultCounters)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.counters = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addCounter = () => {
|
||||
setProp((p: NumberCounterProps) => {
|
||||
p.counters = [...(p.counters || defaultCounters), { number: 100, suffix: '+', label: 'New Stat' }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeCounter = (index: number) => {
|
||||
setProp((p: NumberCounterProps) => {
|
||||
const updated = [...(p.counters || defaultCounters)];
|
||||
updated.splice(index, 1);
|
||||
p.counters = updated;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
{/* Columns */}
|
||||
<div>
|
||||
<label style={labelStyle}>Columns</label>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{columnOptions.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { p.columns = n; })}
|
||||
style={{
|
||||
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: (props.columns || 4) === n ? '#3b82f6' : '#27272a',
|
||||
color: '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Number Size */}
|
||||
<div>
|
||||
<label style={labelStyle}>Number Size</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{sizePresets.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { p.numberSize = s; })}
|
||||
style={{
|
||||
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
||||
border: '1px solid #3f3f46',
|
||||
background: (props.numberSize || '48px') === s ? '#3b82f6' : '#27272a',
|
||||
color: (props.numberSize || '48px') === s ? '#fff' : '#e4e4e7',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Number Color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Number Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{numberColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { p.numberColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: (props.numberColor || '#3b82f6') === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Label Color */}
|
||||
<div>
|
||||
<label style={labelStyle}>Label Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{labelColorPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { p.labelColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: (props.labelColor || '#6b7280') === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background */}
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: NumberCounterProps) => { 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>
|
||||
|
||||
{/* Counters */}
|
||||
<div>
|
||||
<label style={labelStyle}>Counters</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((counter, 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="number"
|
||||
value={counter.number}
|
||||
onChange={(e) => updateCounter(i, 'number', parseInt(e.target.value) || 0)}
|
||||
placeholder="Number"
|
||||
style={{ ...inputStyle, width: 70, flex: 'none' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={counter.suffix}
|
||||
onChange={(e) => updateCounter(i, 'suffix', e.target.value)}
|
||||
placeholder="Suffix"
|
||||
style={{ ...inputStyle, width: 40, flex: 'none' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={counter.label}
|
||||
onChange={(e) => updateCounter(i, 'label', e.target.value)}
|
||||
placeholder="Label"
|
||||
style={{ ...inputStyle, flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeCounter(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addCounter}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Counter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
NumberCounter.craft = {
|
||||
displayName: 'Number Counter',
|
||||
props: {
|
||||
counters: defaultCounters,
|
||||
columns: 4,
|
||||
numberColor: '#3b82f6',
|
||||
labelColor: '#6b7280',
|
||||
numberSize: '48px',
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: NumberCounterSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const {
|
||||
counters = defaultCounters,
|
||||
columns = 4,
|
||||
numberColor = '#3b82f6',
|
||||
labelColor = '#6b7280',
|
||||
numberSize = '48px',
|
||||
style = {},
|
||||
} = props;
|
||||
|
||||
const items = counters.length > 0 ? counters : defaultCounters;
|
||||
const uid = 'nc_' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
...style,
|
||||
});
|
||||
|
||||
const countersHtml = items.map((counter, i) => {
|
||||
return `<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
|
||||
<span id="${uid}_n${i}" data-target="${counter.number}" data-suffix="${esc(counter.suffix)}" style="font-size:${numberSize};font-weight:700;color:${numberColor};line-height:1.1;font-family:Inter,sans-serif">0${esc(counter.suffix)}</span>
|
||||
<span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${esc(counter.label)}</span>
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div id="${uid}" style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:32px;text-align:center">
|
||||
${countersHtml}
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var uid="${uid}",started=false;
|
||||
function animate(){
|
||||
if(started)return;started=true;
|
||||
for(var i=0;i<${items.length};i++){
|
||||
(function(el){
|
||||
var target=parseInt(el.getAttribute("data-target")),
|
||||
suffix=el.getAttribute("data-suffix")||"",
|
||||
current=0,
|
||||
step=Math.max(1,Math.floor(target/60)),
|
||||
timer=setInterval(function(){
|
||||
current+=step;
|
||||
if(current>=target){current=target;clearInterval(timer);}
|
||||
el.textContent=current+suffix;
|
||||
},16);
|
||||
})(document.getElementById(uid+"_n"+i));
|
||||
}
|
||||
}
|
||||
if("IntersectionObserver"in window){
|
||||
var obs=new IntersectionObserver(function(entries){
|
||||
entries.forEach(function(e){if(e.isIntersecting){animate();obs.disconnect();}});
|
||||
},{threshold:0.2});
|
||||
obs.observe(document.getElementById(uid));
|
||||
}else{animate();}
|
||||
})();
|
||||
</script>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,451 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface PricingPlan {
|
||||
name: string;
|
||||
price: string;
|
||||
period: string;
|
||||
features: string[];
|
||||
buttonText: string;
|
||||
buttonHref: string;
|
||||
isFeatured: boolean;
|
||||
}
|
||||
|
||||
interface PricingTableProps {
|
||||
plans?: PricingPlan[];
|
||||
style?: CSSProperties;
|
||||
featuredBg?: string;
|
||||
bulletType?: string;
|
||||
}
|
||||
|
||||
const bulletChars: Record<string, string> = {
|
||||
check: '✓', dot: '●', arrow: '→', star: '★', dash: '—', none: '',
|
||||
};
|
||||
|
||||
const defaultPlans: PricingPlan[] = [
|
||||
{
|
||||
name: 'Basic',
|
||||
price: '$9',
|
||||
period: '/month',
|
||||
features: ['1 Website', '10 GB Storage', 'Free SSL Certificate', 'Email Support'],
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
isFeatured: false,
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: '$29',
|
||||
period: '/month',
|
||||
features: ['10 Websites', '100 GB Storage', 'Free SSL Certificate', 'Priority Support', 'Custom Domain', 'Analytics Dashboard'],
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
isFeatured: true,
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
price: '$99',
|
||||
period: '/month',
|
||||
features: ['Unlimited Websites', '1 TB Storage', 'Free SSL Certificate', '24/7 Phone Support', 'Custom Domain', 'Advanced Analytics', 'API Access', 'Team Collaboration'],
|
||||
buttonText: 'Contact Sales',
|
||||
buttonHref: '#',
|
||||
isFeatured: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
plans = defaultPlans,
|
||||
style = {},
|
||||
featuredBg = '#3b82f6',
|
||||
bulletType = 'check',
|
||||
}) => {
|
||||
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: 'flex',
|
||||
gap: '24px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'stretch',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{plans.map((plan, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: '1 1 280px',
|
||||
maxWidth: '360px',
|
||||
backgroundColor: plan.isFeatured ? featuredBg : '#ffffff',
|
||||
border: plan.isFeatured ? 'none' : '1px solid #e2e8f0',
|
||||
borderRadius: '16px',
|
||||
padding: '40px 32px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
position: 'relative',
|
||||
transform: plan.isFeatured ? 'scale(1.05)' : 'none',
|
||||
boxShadow: plan.isFeatured ? '0 20px 60px rgba(59,130,246,0.3)' : '0 1px 3px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
{plan.isFeatured && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '-12px',
|
||||
backgroundColor: '#facc15',
|
||||
color: '#18181b',
|
||||
padding: '4px 16px',
|
||||
borderRadius: '9999px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '700',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
}}>
|
||||
Most Popular
|
||||
</div>
|
||||
)}
|
||||
<h3 style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: '600',
|
||||
color: plan.isFeatured ? '#ffffff' : '#18181b',
|
||||
marginBottom: '8px',
|
||||
marginTop: plan.isFeatured ? '8px' : '0',
|
||||
}}>
|
||||
{plan.name}
|
||||
</h3>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<span style={{
|
||||
fontSize: '48px',
|
||||
fontWeight: '700',
|
||||
color: plan.isFeatured ? '#ffffff' : '#18181b',
|
||||
lineHeight: '1',
|
||||
}}>
|
||||
{plan.price}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '16px',
|
||||
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b',
|
||||
}}>
|
||||
{plan.period}
|
||||
</span>
|
||||
</div>
|
||||
<ul style={{
|
||||
listStyle: 'none',
|
||||
padding: '0',
|
||||
margin: '0 0 32px 0',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
}}>
|
||||
{(Array.isArray(plan.features) ? plan.features : []).map((feature, fi) => (
|
||||
<li key={fi} style={{
|
||||
fontSize: '14px',
|
||||
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<span style={{ color: plan.isFeatured ? '#bbf7d0' : '#10b981', fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a
|
||||
href={plan.buttonHref}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
display: 'inline-block',
|
||||
padding: '14px 32px',
|
||||
backgroundColor: plan.isFeatured ? '#ffffff' : featuredBg,
|
||||
color: plan.isFeatured ? featuredBg : '#ffffff',
|
||||
textDecoration: 'none',
|
||||
borderRadius: '8px',
|
||||
fontWeight: '600',
|
||||
fontSize: '14px',
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{plan.buttonText}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const PricingTableSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as PricingTableProps,
|
||||
}));
|
||||
|
||||
const plans = props.plans || defaultPlans;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
|
||||
const updatePlan = (index: number, field: keyof PricingPlan, value: any) => {
|
||||
setProp((p: PricingTableProps) => {
|
||||
const updated = [...(p.plans || defaultPlans)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.plans = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const updateFeature = (planIndex: number, featureIndex: number, value: string) => {
|
||||
setProp((p: PricingTableProps) => {
|
||||
const updated = [...(p.plans || defaultPlans)];
|
||||
const features = [...(Array.isArray(updated[planIndex].features) ? updated[planIndex].features : [])];
|
||||
features[featureIndex] = value;
|
||||
updated[planIndex] = { ...updated[planIndex], features };
|
||||
p.plans = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addFeature = (planIndex: number) => {
|
||||
setProp((p: PricingTableProps) => {
|
||||
const updated = [...(p.plans || defaultPlans)];
|
||||
updated[planIndex] = { ...updated[planIndex], features: [...updated[planIndex].features, 'New Feature'] };
|
||||
p.plans = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const removeFeature = (planIndex: number, featureIndex: number) => {
|
||||
setProp((p: PricingTableProps) => {
|
||||
const updated = [...(p.plans || defaultPlans)];
|
||||
const features = [...(Array.isArray(updated[planIndex].features) ? updated[planIndex].features : [])];
|
||||
features.splice(featureIndex, 1);
|
||||
updated[planIndex] = { ...updated[planIndex], features };
|
||||
p.plans = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addPlan = () => {
|
||||
setProp((p: PricingTableProps) => {
|
||||
p.plans = [...(p.plans || defaultPlans), {
|
||||
name: 'New Plan',
|
||||
price: '$19',
|
||||
period: '/month',
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
isFeatured: false,
|
||||
}];
|
||||
});
|
||||
};
|
||||
|
||||
const removePlan = (index: number) => {
|
||||
setProp((p: PricingTableProps) => {
|
||||
const updated = [...(p.plans || defaultPlans)];
|
||||
updated.splice(index, 1);
|
||||
p.plans = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const bgPresets = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#18181b', '#0f172a', '#7c3aed'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: PricingTableProps) => { 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={labelStyle}>Featured Plan Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{bgPresets.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: PricingTableProps) => { p.featuredBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.featuredBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Plans</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{plans.map((plan, 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={plan.name} onChange={(e) => updatePlan(i, 'name', e.target.value)} placeholder="Plan Name" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removePlan(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={plan.price} onChange={(e) => updatePlan(i, 'price', e.target.value)} placeholder="$29" style={{ ...inputStyle, width: '60px', flex: 'none' }} />
|
||||
<input type="text" value={plan.period} onChange={(e) => updatePlan(i, 'period', e.target.value)} placeholder="/month" style={{ ...inputStyle, width: '70px', flex: 'none' }} />
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#a1a1aa', marginLeft: 'auto' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plan.isFeatured}
|
||||
onChange={(e) => updatePlan(i, 'isFeatured', e.target.checked)}
|
||||
/>
|
||||
Featured
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={plan.buttonText} onChange={(e) => updatePlan(i, 'buttonText', e.target.value)} placeholder="Button Text" style={{ ...inputStyle, flex: 1 }} />
|
||||
<input type="text" value={plan.buttonHref} onChange={(e) => updatePlan(i, 'buttonHref', e.target.value)} placeholder="URL" style={{ ...inputStyle, flex: 1 }} />
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<span style={{ fontSize: 10, color: '#71717a' }}>Features:</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 2 }}>
|
||||
{(Array.isArray(plan.features) ? plan.features : []).map((feat, fi) => (
|
||||
<div key={fi} style={{ display: 'flex', gap: 2 }}>
|
||||
<input type="text" value={feat} onChange={(e) => updateFeature(i, fi, e.target.value)} style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeFeature(i, fi)}
|
||||
style={{ padding: '1px 4px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 3, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addFeature(i)}
|
||||
style={{ marginTop: 2, width: '100%', padding: '3px', fontSize: 10, background: '#27272a', color: '#a1a1aa', border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer' }}
|
||||
>
|
||||
+ Feature
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addPlan}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
PricingTable.craft = {
|
||||
displayName: 'Pricing Table',
|
||||
props: {
|
||||
plans: defaultPlans,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
featuredBg: '#3b82f6',
|
||||
bulletType: 'check',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: PricingTableSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const bulletType = props.bulletType || 'check';
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '80px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const plans = props.plans || defaultPlans;
|
||||
const featuredBg = props.featuredBg || '#3b82f6';
|
||||
|
||||
const cards = plans.map((plan) => {
|
||||
const cardBg = plan.isFeatured ? featuredBg : '#ffffff';
|
||||
const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;';
|
||||
const textColor = plan.isFeatured ? '#ffffff' : '#18181b';
|
||||
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b';
|
||||
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563';
|
||||
const checkColor = plan.isFeatured ? '#bbf7d0' : '#10b981';
|
||||
const btnBg = plan.isFeatured ? '#ffffff' : featuredBg;
|
||||
const btnColor = plan.isFeatured ? featuredBg : '#ffffff';
|
||||
const scale = plan.isFeatured ? 'transform:scale(1.05);' : '';
|
||||
const shadow = plan.isFeatured ? 'box-shadow:0 20px 60px rgba(59,130,246,0.3);' : 'box-shadow:0 1px 3px rgba(0,0,0,0.06);';
|
||||
|
||||
const featuresHtml = (Array.isArray(plan.features) ? plan.features : []).map((f) =>
|
||||
`<li style="font-size:14px;color:${featColor};display:flex;align-items:center;gap:8px"><span style="color:${checkColor};font-weight:700">${bulletChars[bulletType] || '✓'}</span>${esc(f)}</li>`
|
||||
).join('\n ');
|
||||
|
||||
const badge = plan.isFeatured
|
||||
? `<div style="position:absolute;top:-12px;background-color:#facc15;color:#18181b;padding:4px 16px;border-radius:9999px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px">Most Popular</div>`
|
||||
: '';
|
||||
|
||||
return `<div style="flex:1 1 280px;max-width:360px;background-color:${cardBg};${cardBorder}border-radius:16px;padding:40px 32px;display:flex;flex-direction:column;align-items:center;text-align:center;position:relative;${scale}${shadow}">
|
||||
${badge}
|
||||
<h3 style="font-size:20px;font-weight:600;color:${textColor};margin-bottom:8px;${plan.isFeatured ? 'margin-top:8px;' : ''}">${esc(plan.name)}</h3>
|
||||
<div style="margin-bottom:24px">
|
||||
<span style="font-size:48px;font-weight:700;color:${textColor};line-height:1">${esc(plan.price)}</span>
|
||||
<span style="font-size:16px;color:${subColor}">${esc(plan.period)}</span>
|
||||
</div>
|
||||
<ul style="list-style:none;padding:0;margin:0 0 32px 0;width:100%;display:flex;flex-direction:column;gap:12px">
|
||||
${featuresHtml}
|
||||
</ul>
|
||||
<a href="${plan.buttonHref || '#'}" style="margin-top:auto;display:inline-block;padding:14px 32px;background-color:${btnBg};color:${btnColor};text-decoration:none;border-radius:8px;font-weight:600;font-size:14px;width:100%;text-align:center">${esc(plan.buttonText)}</a>
|
||||
</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:flex;gap:24px;justify-content:center;align-items:stretch;flex-wrap:wrap">
|
||||
${cards}
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,339 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
|
||||
interface TabItem {
|
||||
label: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
tabs?: TabItem[];
|
||||
style?: CSSProperties;
|
||||
activeTabBg?: string;
|
||||
activeTabColor?: string;
|
||||
inactiveTabBg?: string;
|
||||
inactiveTabColor?: string;
|
||||
contentBg?: string;
|
||||
}
|
||||
|
||||
const defaultTabs: TabItem[] = [
|
||||
{ label: 'Overview', content: 'Welcome to our platform. We provide the tools you need to build, launch, and grow your online presence. Our intuitive interface makes it simple to get started in minutes.' },
|
||||
{ label: 'Features', content: 'Drag-and-drop editor, responsive templates, custom domains, analytics dashboard, SEO tools, and integrations with your favorite services. Everything you need in one place.' },
|
||||
{ label: 'Support', content: 'Our dedicated support team is available 24/7 to help you with any questions. Access our knowledge base, community forums, or reach out directly via live chat or email.' },
|
||||
];
|
||||
|
||||
export const Tabs: UserComponent<TabsProps> = ({
|
||||
tabs = defaultTabs,
|
||||
style = {},
|
||||
activeTabBg = '#3b82f6',
|
||||
activeTabColor = '#ffffff',
|
||||
inactiveTabBg = '#f1f5f9',
|
||||
inactiveTabColor = '#64748b',
|
||||
contentBg = '#ffffff',
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
selected,
|
||||
} = useNode((node) => ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||
{/* Tab buttons */}
|
||||
<div style={{ display: 'flex', gap: '2px', borderBottom: '2px solid #e2e8f0' }}>
|
||||
{tabs.map((tab, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveIndex(i)}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
border: 'none',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: i === activeIndex ? activeTabBg : inactiveTabBg,
|
||||
color: i === activeIndex ? activeTabColor : inactiveTabColor,
|
||||
transition: 'background-color 0.2s, color 0.2s',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Content panel */}
|
||||
<div
|
||||
style={{
|
||||
padding: '24px',
|
||||
backgroundColor: contentBg,
|
||||
border: '1px solid #e2e8f0',
|
||||
borderTop: 'none',
|
||||
borderBottomLeftRadius: '8px',
|
||||
borderBottomRightRadius: '8px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.7',
|
||||
color: '#4b5563',
|
||||
minHeight: '100px',
|
||||
}}
|
||||
>
|
||||
{tabs[activeIndex]?.content || ''}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Settings panel ---------- */
|
||||
|
||||
const TabsSettings: React.FC = () => {
|
||||
const { actions: { setProp }, props } = useNode((node) => ({
|
||||
props: node.data.props as TabsProps,
|
||||
}));
|
||||
|
||||
const tabs = props.tabs || defaultTabs;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||||
|
||||
const updateTab = (index: number, field: keyof TabItem, value: string) => {
|
||||
setProp((p: TabsProps) => {
|
||||
const updated = [...(p.tabs || defaultTabs)];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
p.tabs = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const addTab = () => {
|
||||
setProp((p: TabsProps) => {
|
||||
p.tabs = [...(p.tabs || defaultTabs), { label: 'New Tab', content: 'Tab content goes here.' }];
|
||||
});
|
||||
};
|
||||
|
||||
const removeTab = (index: number) => {
|
||||
setProp((p: TabsProps) => {
|
||||
const updated = [...(p.tabs || defaultTabs)];
|
||||
updated.splice(index, 1);
|
||||
p.tabs = updated;
|
||||
});
|
||||
};
|
||||
|
||||
const colorSwatches = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#18181b', '#ffffff', '#f1f5f9'];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<div>
|
||||
<label style={labelStyle}>Active Tab Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{colorSwatches.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.activeTabBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.activeTabBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Active Tab Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#18181b', '#1f2937', '#e2e8f0'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.activeTabColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.activeTabColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Inactive Tab Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#f1f5f9', '#e2e8f0', '#f8fafc', '#ffffff', '#27272a', '#18181b'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.inactiveTabBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.inactiveTabBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Inactive Tab Text Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#64748b', '#94a3b8', '#18181b', '#ffffff'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.inactiveTabColor = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.inactiveTabColor === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Content Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#1e293b'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setProp((p: TabsProps) => { p.contentBg = c; })}
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||||
backgroundColor: c, cursor: 'pointer',
|
||||
outline: props.contentBg === c ? '2px solid #3b82f6' : 'none',
|
||||
outlineOffset: 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={labelStyle}>Tabs</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{tabs.map((tab, 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={tab.label} onChange={(e) => updateTab(i, 'label', e.target.value)} placeholder="Label" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button
|
||||
onClick={() => removeTab(i)}
|
||||
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={tab.content}
|
||||
onChange={(e) => updateTab(i, 'content', e.target.value)}
|
||||
placeholder="Content"
|
||||
rows={2}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={addTab}
|
||||
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||
>
|
||||
+ Add Tab
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Tabs.craft = {
|
||||
displayName: 'Tabs',
|
||||
props: {
|
||||
tabs: defaultTabs,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
activeTabBg: '#3b82f6',
|
||||
activeTabColor: '#ffffff',
|
||||
inactiveTabBg: '#f1f5f9',
|
||||
inactiveTabColor: '#64748b',
|
||||
contentBg: '#ffffff',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
canMoveIn: () => false,
|
||||
canMoveOut: () => true,
|
||||
},
|
||||
related: {
|
||||
settings: TabsSettings,
|
||||
},
|
||||
};
|
||||
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => {
|
||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const tabs = props.tabs || defaultTabs;
|
||||
const activeTabBg = props.activeTabBg || '#3b82f6';
|
||||
const activeTabColor = props.activeTabColor || '#ffffff';
|
||||
const inactiveTabBg = props.inactiveTabBg || '#f1f5f9';
|
||||
const inactiveTabColor = props.inactiveTabColor || '#64748b';
|
||||
const contentBg = props.contentBg || '#ffffff';
|
||||
|
||||
const tabId = 'tabs_' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
const tabButtons = tabs.map((tab, i) => {
|
||||
const isActive = i === 0;
|
||||
return `<button onclick="${tabId}_switch(${i})" id="${tabId}_btn_${i}" style="padding:12px 24px;font-size:14px;font-weight:600;border:none;border-top-left-radius:8px;border-top-right-radius:8px;cursor:pointer;background-color:${isActive ? activeTabBg : inactiveTabBg};color:${isActive ? activeTabColor : inactiveTabColor}">${esc(tab.label)}</button>`;
|
||||
}).join('\n ');
|
||||
|
||||
const tabPanels = tabs.map((tab, i) => {
|
||||
return `<div id="${tabId}_panel_${i}" style="padding:24px;background-color:${contentBg};border:1px solid #e2e8f0;border-top:none;border-bottom-left-radius:8px;border-bottom-right-radius:8px;font-size:14px;line-height:1.7;color:#4b5563;min-height:100px;${i !== 0 ? 'display:none' : ''}">${esc(tab.content)}</div>`;
|
||||
}).join('\n ');
|
||||
|
||||
const switchScript = `<script>
|
||||
function ${tabId}_switch(idx){
|
||||
var total=${tabs.length};
|
||||
for(var i=0;i<total;i++){
|
||||
document.getElementById('${tabId}_panel_'+i).style.display=i===idx?'':'none';
|
||||
var btn=document.getElementById('${tabId}_btn_'+i);
|
||||
btn.style.backgroundColor=i===idx?'${activeTabBg}':'${inactiveTabBg}';
|
||||
btn.style.color=i===idx?'${activeTabColor}':'${inactiveTabColor}';
|
||||
}
|
||||
}
|
||||
</script>`;
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:800px;margin:0 auto">
|
||||
<div style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
|
||||
${tabButtons}
|
||||
</div>
|
||||
${tabPanels}
|
||||
</div>
|
||||
${switchScript}
|
||||
</section>`,
|
||||
};
|
||||
};
|
||||
@@ -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