site-builder: dynamic CTAs, section anchors, edit-with-Sitesmith
Three related features: 1. Dynamic CTA buttons on HeroSimple, CTASection, CallToAction. New shared ctas[] array (text + href + variant + target) replaces the primary/secondary pair. Settings panel gets add/remove/reorder controls. Legacy fields stay readable for backwards compat — first user edit migrates the section onto the new array. 2. Anchor IDs on all layout/section components (Container, Section, BackgroundSection, ColumnLayout, plus 6 section blocks done by parallel subagent, plus Hero/CTA/CallToAction). Anchor input lives in the settings panel with an "auto from heading" button that walks the subtree for the first Heading.text. Renders as id="..." on the outermost element so #anchor URLs resolve. 3. Edit-with-Sitesmith targeted invocation. Right-click → "Ask Sitesmith" and a button at the top of the right-side settings panel both open the modal pre-targeted at the selected node. The node's serialized subtree is sent to the server; system prompt is augmented to require a patch with replace_node. Editor lifts modal state into a new SitesmithContext. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
interface BackgroundSectionProps {
|
||||
bgImage?: string;
|
||||
@@ -11,6 +12,7 @@ interface BackgroundSectionProps {
|
||||
innerMaxWidth?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
|
||||
@@ -20,12 +22,14 @@ export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
|
||||
overlayOpacity = 0.4,
|
||||
innerMaxWidth = '1200px',
|
||||
style = {},
|
||||
anchorId,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
@@ -77,6 +81,7 @@ const BackgroundSectionSettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Image URL</label>
|
||||
<input
|
||||
@@ -162,6 +167,7 @@ BackgroundSection.craft = {
|
||||
overlayOpacity: 0.4,
|
||||
innerMaxWidth: '1200px',
|
||||
style: { padding: '0' },
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -176,6 +182,7 @@ BackgroundSection.craft = {
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(BackgroundSection as any).toHtml = (props: BackgroundSectionProps, childrenHtml: string) => {
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const outerStyle = cssPropsToString({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
@@ -200,7 +207,8 @@ BackgroundSection.craft = {
|
||||
margin: '0 auto',
|
||||
padding: '60px 20px',
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
return {
|
||||
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
|
||||
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { Container } from './Container';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
type SplitOption =
|
||||
| '100'
|
||||
@@ -18,6 +19,7 @@ interface ColumnLayoutProps {
|
||||
gap?: string;
|
||||
style?: CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
const splitToWidths: Record<string, string[]> = {
|
||||
@@ -59,6 +61,7 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
|
||||
split = '50-50',
|
||||
gap = '16px',
|
||||
style = {},
|
||||
anchorId,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
const widths = getWidths(split, columns);
|
||||
@@ -66,6 +69,7 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
|
||||
return (
|
||||
<div
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
@@ -124,6 +128,7 @@ const ColumnLayoutSettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
{/* Preset layouts */}
|
||||
<div>
|
||||
<label style={labelStyle}>Column Layout</label>
|
||||
@@ -270,6 +275,7 @@ ColumnLayout.craft = {
|
||||
split: '50-50',
|
||||
gap: '16px',
|
||||
style: {},
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -284,6 +290,7 @@ ColumnLayout.craft = {
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string) => {
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const gap = props.gap || '16px';
|
||||
const outerStyle = cssPropsToString({
|
||||
display: 'flex',
|
||||
@@ -292,7 +299,8 @@ ColumnLayout.craft = {
|
||||
width: '100%',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
return {
|
||||
html: `<div${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
|
||||
html: `<div${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { SettingsTabs } from '../../ui/SettingsTabs';
|
||||
import { BorderControl } from '../../ui/BorderControl';
|
||||
import { AdvancedTab } from '../../ui/AdvancedTab';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
interface ContainerProps {
|
||||
style?: CSSProperties;
|
||||
@@ -11,6 +12,7 @@ interface ContainerProps {
|
||||
children?: React.ReactNode;
|
||||
cssId?: string;
|
||||
cssClass?: string;
|
||||
anchorId?: string;
|
||||
hideOnDesktop?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
hideOnMobile?: boolean;
|
||||
@@ -37,6 +39,7 @@ export const Container: UserComponent<ContainerProps> = ({
|
||||
children,
|
||||
fullWidth = false,
|
||||
contentWidth = 'full',
|
||||
anchorId,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
@@ -56,6 +59,7 @@ export const Container: UserComponent<ContainerProps> = ({
|
||||
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
|
||||
style: outerStyle,
|
||||
'data-craft-container': 'true',
|
||||
id: anchorId || undefined,
|
||||
},
|
||||
needsBoxedWrapper
|
||||
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children)
|
||||
@@ -120,6 +124,7 @@ const ContainerSettings: React.FC = () => {
|
||||
<SettingsTabs
|
||||
general={
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<AnchorIdField />
|
||||
{/* Tag */}
|
||||
<div>
|
||||
<label style={cLabelStyle}>HTML Element</label>
|
||||
@@ -304,6 +309,7 @@ Container.craft = {
|
||||
tag: 'div',
|
||||
fullWidth: false,
|
||||
contentWidth: 'full',
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -318,6 +324,7 @@ Container.craft = {
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const tag = props.tag || 'div';
|
||||
const isBoxed = props.contentWidth === 'boxed';
|
||||
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
|
||||
@@ -333,11 +340,12 @@ Container.craft = {
|
||||
}
|
||||
|
||||
const styleStr = cssPropsToString(outerCss);
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
|
||||
if (isBoxed) {
|
||||
const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles });
|
||||
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
|
||||
return { html: `<${tag}${idAttr}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
|
||||
}
|
||||
|
||||
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
|
||||
return { html: `<${tag}${idAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
|
||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { Container } from './Container';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
/* ---------- Shape Divider SVG Paths ---------- */
|
||||
|
||||
@@ -27,6 +28,7 @@ interface SectionProps {
|
||||
bottomDivider?: DividerShape;
|
||||
bottomDividerColor?: string;
|
||||
bottomDividerHeight?: string;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
/* ---------- Divider renderer ---------- */
|
||||
@@ -85,6 +87,7 @@ export const Section: UserComponent<SectionProps> = ({
|
||||
bottomDivider = 'none',
|
||||
bottomDividerColor = '#ffffff',
|
||||
bottomDividerHeight = '50px',
|
||||
anchorId,
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
|
||||
@@ -94,6 +97,7 @@ export const Section: UserComponent<SectionProps> = ({
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null) => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
width: '100%',
|
||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||
@@ -229,6 +233,7 @@ const SectionSettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
@@ -333,6 +338,7 @@ Section.craft = {
|
||||
bottomDivider: 'none',
|
||||
bottomDividerColor: '#ffffff',
|
||||
bottomDividerHeight: '50px',
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -377,6 +383,7 @@ function buildDividerHtml(
|
||||
}
|
||||
|
||||
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
|
||||
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
|
||||
|
||||
@@ -394,8 +401,9 @@ function buildDividerHtml(
|
||||
|
||||
const topHtml = buildDividerHtml(props.topDivider, props.topDividerColor, props.topDividerHeight, 'top');
|
||||
const bottomHtml = buildDividerHtml(props.bottomDivider, props.bottomDividerColor, props.bottomDividerHeight, 'bottom');
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
|
||||
return {
|
||||
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
|
||||
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
interface AccordionItem {
|
||||
title: string;
|
||||
@@ -15,6 +16,7 @@ interface AccordionProps {
|
||||
headerColor?: string;
|
||||
contentBg?: string;
|
||||
borderColor?: string;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
const defaultItems: AccordionItem[] = [
|
||||
@@ -30,6 +32,7 @@ export const Accordion: UserComponent<AccordionProps> = ({
|
||||
headerColor = '#18181b',
|
||||
contentBg = '#ffffff',
|
||||
borderColor = '#e2e8f0',
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -56,6 +59,7 @@ export const Accordion: UserComponent<AccordionProps> = ({
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
@@ -161,6 +165,7 @@ const AccordionSettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
<div>
|
||||
<label style={labelStyle}>Header Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
@@ -279,6 +284,7 @@ Accordion.craft = {
|
||||
headerColor: '#18181b',
|
||||
contentBg: '#ffffff',
|
||||
borderColor: '#e2e8f0',
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -293,11 +299,12 @@ Accordion.craft = {
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
const headerBg = props.headerBg || '#f8fafc';
|
||||
const headerColor = props.headerColor || '#18181b';
|
||||
const contentBg = props.contentBg || '#ffffff';
|
||||
@@ -320,7 +327,7 @@ Accordion.craft = {
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:800px;margin:0 auto;display:flex;flex-direction:column">
|
||||
${panels}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
interface CTASectionProps {
|
||||
heading?: string;
|
||||
description?: string;
|
||||
ctas?: CtaButton[];
|
||||
/** Legacy props kept for backward compat with saved projects. */
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
gradient?: string;
|
||||
anchorId?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
@@ -16,9 +21,11 @@ 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 = '#',
|
||||
ctas,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
gradient = defaultGradient,
|
||||
anchorId,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
@@ -28,9 +35,13 @@ export const CTASection: UserComponent<CTASectionProps> = ({
|
||||
selected: node.events.selected,
|
||||
}));
|
||||
|
||||
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref });
|
||||
const ctaDefaults = { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' };
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
background: gradient,
|
||||
padding: '80px 20px',
|
||||
@@ -46,22 +57,14 @@ export const CTASection: UserComponent<CTASectionProps> = ({
|
||||
<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 style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
{effectiveCtas.map((cta, i) => (
|
||||
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
|
||||
style={ctaInlineStyle(cta, ctaDefaults)}>
|
||||
{cta.text}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -83,8 +86,11 @@ const CTASectionSettings: React.FC = () => {
|
||||
{ label: 'Ocean', value: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)' },
|
||||
];
|
||||
|
||||
const effectiveCtas = normalizeCtas(props);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
|
||||
<input
|
||||
@@ -105,26 +111,14 @@ const CTASectionSettings: React.FC = () => {
|
||||
/>
|
||||
</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>
|
||||
<CtasEditor
|
||||
ctas={effectiveCtas}
|
||||
onChange={(next) => setProp((p: CTASectionProps) => {
|
||||
p.ctas = next;
|
||||
p.buttonText = undefined;
|
||||
p.buttonHref = undefined;
|
||||
})}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gradient</label>
|
||||
@@ -155,9 +149,11 @@ CTASection.craft = {
|
||||
props: {
|
||||
heading: 'Ready to Get Started?',
|
||||
description: 'Join thousands of satisfied users and start building your dream website today.',
|
||||
buttonText: 'Start Free Trial',
|
||||
buttonHref: '#',
|
||||
ctas: [
|
||||
{ text: 'Start Free Trial', href: '#', variant: 'primary' },
|
||||
] as CtaButton[],
|
||||
gradient: defaultGradient,
|
||||
anchorId: '',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
@@ -180,12 +176,15 @@ CTASection.craft = {
|
||||
textAlign: 'center',
|
||||
...props.style,
|
||||
});
|
||||
const ctas = normalizeCtas(props);
|
||||
const buttonsHtml = ctasToHtml(ctas, { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' });
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${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 style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">${buttonsHtml}</div>
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
interface CallToActionProps {
|
||||
heading?: string;
|
||||
description?: string;
|
||||
ctas?: CtaButton[];
|
||||
/** Legacy props kept for backward compat with saved projects. */
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
secondaryButtonText?: string;
|
||||
@@ -15,6 +19,7 @@ interface CallToActionProps {
|
||||
overlayOpacity?: number;
|
||||
textColor?: string;
|
||||
buttonColor?: string;
|
||||
anchorId?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
@@ -23,16 +28,18 @@ 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 = '#',
|
||||
ctas,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
secondaryButtonText,
|
||||
secondaryButtonHref,
|
||||
bgType = 'gradient',
|
||||
bgValue = defaultGradient,
|
||||
overlayColor = '#000000',
|
||||
overlayOpacity = 0,
|
||||
textColor = '#ffffff',
|
||||
buttonColor = '#ffffff',
|
||||
anchorId,
|
||||
style = {},
|
||||
}) => {
|
||||
const {
|
||||
@@ -56,9 +63,13 @@ export const CallToAction: UserComponent<CallToActionProps> = ({
|
||||
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
|
||||
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
|
||||
|
||||
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref, secondaryButtonText, secondaryButtonHref });
|
||||
const ctaDefaults = { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor };
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
position: 'relative',
|
||||
padding: '80px 20px',
|
||||
@@ -89,41 +100,12 @@ export const CallToAction: UserComponent<CallToActionProps> = ({
|
||||
{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}
|
||||
{effectiveCtas.map((cta, i) => (
|
||||
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
|
||||
style={ctaInlineStyle(cta, ctaDefaults)}>
|
||||
{cta.text}
|
||||
</a>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -155,8 +137,11 @@ const CallToActionSettings: React.FC = () => {
|
||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
|
||||
const effectiveCtas = normalizeCtas(props);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
|
||||
<input
|
||||
@@ -177,52 +162,16 @@ const CallToActionSettings: React.FC = () => {
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
<CtasEditor
|
||||
ctas={effectiveCtas}
|
||||
onChange={(next) => setProp((p: CallToActionProps) => {
|
||||
p.ctas = next;
|
||||
p.buttonText = undefined;
|
||||
p.buttonHref = undefined;
|
||||
p.secondaryButtonText = undefined;
|
||||
p.secondaryButtonHref = undefined;
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Background Type */}
|
||||
<div>
|
||||
@@ -380,10 +329,11 @@ CallToAction.craft = {
|
||||
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: '#',
|
||||
ctas: [
|
||||
{ text: 'Get Started', href: '#', variant: 'primary' },
|
||||
{ text: 'Learn More', href: '#', variant: 'outline' },
|
||||
] as CtaButton[],
|
||||
anchorId: '',
|
||||
bgType: 'gradient',
|
||||
bgValue: defaultGradient,
|
||||
overlayColor: '#000000',
|
||||
@@ -445,41 +395,16 @@ CallToAction.craft = {
|
||||
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',
|
||||
});
|
||||
const ctas = normalizeCtas(props);
|
||||
const buttonsHtml = ctasToHtml(ctas, { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor });
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${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 style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">${buttonsHtml}</div>
|
||||
</div>
|
||||
</section>`,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties, useEffect, useState, useCallback } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
interface CountdownProps {
|
||||
targetDate?: string;
|
||||
@@ -9,6 +10,7 @@ interface CountdownProps {
|
||||
digitColor?: string;
|
||||
labelColor?: string;
|
||||
bgColor?: string;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
interface TimeLeft {
|
||||
@@ -44,6 +46,7 @@ export const Countdown: UserComponent<CountdownProps> = ({
|
||||
digitColor = '#ffffff',
|
||||
labelColor = 'rgba(255,255,255,0.7)',
|
||||
bgColor = '#18181b',
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -98,6 +101,7 @@ export const Countdown: UserComponent<CountdownProps> = ({
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
textAlign: 'center',
|
||||
@@ -141,6 +145,7 @@ const CountdownSettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
{/* Target date */}
|
||||
<div>
|
||||
<label style={labelStyle}>Target Date</label>
|
||||
@@ -235,6 +240,7 @@ Countdown.craft = {
|
||||
digitColor: '#ffffff',
|
||||
labelColor: 'rgba(255,255,255,0.7)',
|
||||
bgColor: '#18181b',
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -265,6 +271,7 @@ Countdown.craft = {
|
||||
backgroundColor: bgColor,
|
||||
...style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
|
||||
const headingHtml = heading
|
||||
? `<h2 style="font-size:32px;font-weight:700;color:${digitColor};margin-bottom:32px;font-family:Inter,sans-serif">${esc(heading)}</h2>`
|
||||
@@ -278,7 +285,7 @@ Countdown.craft = {
|
||||
const uid = 'cd_' + Math.random().toString(36).slice(2, 8);
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
interface FeatureItem {
|
||||
title: string;
|
||||
@@ -11,6 +12,7 @@ interface FeatureItem {
|
||||
interface FeaturesGridProps {
|
||||
features?: FeatureItem[];
|
||||
style?: CSSProperties;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
const defaultFeatures: FeatureItem[] = [
|
||||
@@ -22,6 +24,7 @@ const defaultFeatures: FeatureItem[] = [
|
||||
export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
||||
features = defaultFeatures,
|
||||
style = {},
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -33,6 +36,7 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '80px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
@@ -102,6 +106,7 @@ const FeaturesGridSettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
@@ -163,6 +168,7 @@ FeaturesGrid.craft = {
|
||||
props: {
|
||||
features: defaultFeatures,
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -177,11 +183,12 @@ FeaturesGrid.craft = {
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '80px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
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>
|
||||
@@ -191,7 +198,7 @@ FeaturesGrid.craft = {
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px">
|
||||
${cards}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
interface HeroProps {
|
||||
heading?: string;
|
||||
subtitle?: string;
|
||||
/** New dynamic CTAs. When set (length > 0), legacy primary/secondary fields are ignored. */
|
||||
ctas?: CtaButton[];
|
||||
/** Legacy — kept for backwards compatibility with saved projects. */
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
secondaryButtonText?: string;
|
||||
@@ -24,6 +29,7 @@ interface HeroProps {
|
||||
minHeight?: string;
|
||||
verticalAlign?: 'top' | 'center' | 'bottom';
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
anchorId?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
@@ -43,10 +49,11 @@ function buildBackground(props: HeroProps): string {
|
||||
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 = '#',
|
||||
ctas,
|
||||
buttonText,
|
||||
buttonHref,
|
||||
secondaryButtonText,
|
||||
secondaryButtonHref,
|
||||
bgType = 'color',
|
||||
bgColor = '#1e293b',
|
||||
bgGradientFrom = '#667eea',
|
||||
@@ -62,6 +69,7 @@ export const HeroSimple: UserComponent<HeroProps> = ({
|
||||
minHeight = '500px',
|
||||
verticalAlign = 'center',
|
||||
textAlign = 'center',
|
||||
anchorId,
|
||||
style = {},
|
||||
}) => {
|
||||
const { connectors: { connect, drag } } = useNode();
|
||||
@@ -72,9 +80,17 @@ export const HeroSimple: UserComponent<HeroProps> = ({
|
||||
|
||||
const justifyMap = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
||||
|
||||
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref, secondaryButtonText, secondaryButtonHref });
|
||||
const ctaDefaults = {
|
||||
primaryBg: buttonBgColor,
|
||||
primaryText: buttonTextColor,
|
||||
outlineText: textColor,
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
...style,
|
||||
background: bgType !== 'image' ? bg : undefined,
|
||||
@@ -134,25 +150,12 @@ export const HeroSimple: UserComponent<HeroProps> = ({
|
||||
{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}
|
||||
{effectiveCtas.map((cta, i) => (
|
||||
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
|
||||
style={ctaInlineStyle(cta, ctaDefaults)}>
|
||||
{cta.text}
|
||||
</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>
|
||||
@@ -181,8 +184,11 @@ const HeroSettings: React.FC = () => {
|
||||
props: node.data.props as HeroProps,
|
||||
}));
|
||||
|
||||
const effectiveCtas = normalizeCtas(props);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<AnchorIdField />
|
||||
{/* Content */}
|
||||
<div>
|
||||
<label style={labelStyle}>Heading</label>
|
||||
@@ -192,18 +198,20 @@ const HeroSettings: React.FC = () => {
|
||||
<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>
|
||||
|
||||
{/* Dynamic CTAs */}
|
||||
<CtasEditor
|
||||
ctas={effectiveCtas}
|
||||
onChange={(next) => setProp((p: HeroProps) => {
|
||||
p.ctas = next;
|
||||
// Once the user touches CTAs, the legacy fields are no longer
|
||||
// authoritative — clear them so the array is the only source.
|
||||
p.buttonText = undefined;
|
||||
p.buttonHref = undefined;
|
||||
p.secondaryButtonText = undefined;
|
||||
p.secondaryButtonHref = undefined;
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Background Type */}
|
||||
<div>
|
||||
@@ -370,10 +378,9 @@ HeroSimple.craft = {
|
||||
props: {
|
||||
heading: 'Build Something Amazing',
|
||||
subtitle: 'Create beautiful websites without writing a single line of code.',
|
||||
buttonText: 'Get Started',
|
||||
buttonHref: '#',
|
||||
secondaryButtonText: '',
|
||||
secondaryButtonHref: '#',
|
||||
ctas: [
|
||||
{ text: 'Get Started', href: '#', variant: 'primary' },
|
||||
] as CtaButton[],
|
||||
bgType: 'color',
|
||||
bgColor: '#1e293b',
|
||||
bgGradientFrom: '#667eea',
|
||||
@@ -389,6 +396,7 @@ HeroSimple.craft = {
|
||||
minHeight: '500px',
|
||||
verticalAlign: 'center',
|
||||
textAlign: 'center',
|
||||
anchorId: '',
|
||||
style: {},
|
||||
},
|
||||
rules: {
|
||||
@@ -436,16 +444,16 @@ HeroSimple.craft = {
|
||||
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>`;
|
||||
}
|
||||
const ctas = normalizeCtas(props);
|
||||
const buttonsHtml = ctasToHtml(ctas, {
|
||||
primaryBg: props.buttonBgColor || '#3b82f6',
|
||||
primaryText: props.buttonTextColor || '#fff',
|
||||
outlineText: props.textColor || '#fff',
|
||||
});
|
||||
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
return {
|
||||
html: `<section style="${sectionStyle}">
|
||||
html: `<section${idAttr} 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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
interface PricingPlan {
|
||||
name: string;
|
||||
@@ -17,6 +18,7 @@ interface PricingTableProps {
|
||||
style?: CSSProperties;
|
||||
featuredBg?: string;
|
||||
bulletType?: string;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
const bulletChars: Record<string, string> = {
|
||||
@@ -58,6 +60,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
style = {},
|
||||
featuredBg = '#3b82f6',
|
||||
bulletType = 'check',
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -69,6 +72,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '80px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
@@ -271,6 +275,7 @@ const PricingTableSettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
<div>
|
||||
<label style={labelStyle}>Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
@@ -384,6 +389,7 @@ PricingTable.craft = {
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
featuredBg: '#3b82f6',
|
||||
bulletType: 'check',
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -398,12 +404,13 @@ PricingTable.craft = {
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const bulletType = props.bulletType || 'check';
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '80px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
const plans = props.plans || defaultPlans;
|
||||
const featuredBg = props.featuredBg || '#3b82f6';
|
||||
|
||||
@@ -442,7 +449,7 @@ PricingTable.craft = {
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
interface TabItem {
|
||||
label: string;
|
||||
@@ -15,6 +16,7 @@ interface TabsProps {
|
||||
inactiveTabBg?: string;
|
||||
inactiveTabColor?: string;
|
||||
contentBg?: string;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
const defaultTabs: TabItem[] = [
|
||||
@@ -31,6 +33,7 @@ export const Tabs: UserComponent<TabsProps> = ({
|
||||
inactiveTabBg = '#f1f5f9',
|
||||
inactiveTabColor = '#64748b',
|
||||
contentBg = '#ffffff',
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -44,6 +47,7 @@ export const Tabs: UserComponent<TabsProps> = ({
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '60px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
@@ -139,6 +143,7 @@ const TabsSettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
<div>
|
||||
<label style={labelStyle}>Active Tab Background</label>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
@@ -276,6 +281,7 @@ Tabs.craft = {
|
||||
inactiveTabBg: '#f1f5f9',
|
||||
inactiveTabColor: '#64748b',
|
||||
contentBg: '#ffffff',
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -290,11 +296,12 @@ Tabs.craft = {
|
||||
/* ---------- HTML export ---------- */
|
||||
|
||||
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => {
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const sectionStyle = cssPropsToString({
|
||||
padding: '60px 20px',
|
||||
...props.style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
const tabs = props.tabs || defaultTabs;
|
||||
const activeTabBg = props.activeTabBg || '#3b82f6';
|
||||
const activeTabColor = props.activeTabColor || '#ffffff';
|
||||
@@ -326,7 +333,7 @@ function ${tabId}_switch(idx){
|
||||
</script>`;
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:800px;margin:0 auto">
|
||||
<div style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
|
||||
${tabButtons}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||
|
||||
interface Testimonial {
|
||||
quote: string;
|
||||
@@ -16,6 +17,7 @@ interface TestimonialsProps {
|
||||
style?: CSSProperties;
|
||||
cardBg?: string;
|
||||
starColor?: string;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
const defaultTestimonials: Testimonial[] = [
|
||||
@@ -52,6 +54,7 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
|
||||
style = {},
|
||||
cardBg = '#f8fafc',
|
||||
starColor = '#f59e0b',
|
||||
anchorId,
|
||||
}) => {
|
||||
const {
|
||||
connectors: { connect, drag },
|
||||
@@ -86,6 +89,7 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
|
||||
return (
|
||||
<section
|
||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||
id={anchorId || undefined}
|
||||
style={{
|
||||
padding: '80px 20px',
|
||||
backgroundColor: '#ffffff',
|
||||
@@ -187,6 +191,7 @@ const TestimonialsSettings: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||
<AnchorIdField />
|
||||
{/* Layout */}
|
||||
<div>
|
||||
<label style={labelStyle}>Layout</label>
|
||||
@@ -357,6 +362,7 @@ Testimonials.craft = {
|
||||
style: { backgroundColor: '#ffffff' },
|
||||
cardBg: '#f8fafc',
|
||||
starColor: '#f59e0b',
|
||||
anchorId: '',
|
||||
},
|
||||
rules: {
|
||||
canDrag: () => true,
|
||||
@@ -388,6 +394,7 @@ Testimonials.craft = {
|
||||
backgroundColor: '#ffffff',
|
||||
...style,
|
||||
});
|
||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||
|
||||
const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0`;
|
||||
|
||||
@@ -403,7 +410,7 @@ Testimonials.craft = {
|
||||
if (layout === 'single') {
|
||||
// For single layout, export as grid with 1 column (simpler static export)
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:600px;margin:0 auto;display:grid;grid-template-columns:1fr;gap:24px">
|
||||
${cards}
|
||||
</div>
|
||||
@@ -412,7 +419,7 @@ Testimonials.craft = {
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
|
||||
${cards}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
|
||||
export type CtaVariant = 'primary' | 'outline' | 'ghost';
|
||||
|
||||
export interface CtaButton {
|
||||
text: string;
|
||||
href: string;
|
||||
variant?: CtaVariant;
|
||||
target?: '_blank';
|
||||
}
|
||||
|
||||
export interface CtaStyleDefaults {
|
||||
primaryBg: string;
|
||||
primaryText: string;
|
||||
outlineText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the effective list of CTAs for a section, falling back to legacy
|
||||
* primary/secondary props when ctas[] is absent. New sections write ctas[]
|
||||
* directly; old sections keep rendering until the user touches the settings.
|
||||
*/
|
||||
export function normalizeCtas(props: {
|
||||
ctas?: CtaButton[];
|
||||
buttonText?: string;
|
||||
buttonHref?: string;
|
||||
secondaryButtonText?: string;
|
||||
secondaryButtonHref?: string;
|
||||
}): CtaButton[] {
|
||||
if (Array.isArray(props.ctas) && props.ctas.length > 0) {
|
||||
return props.ctas.filter((c) => c && (c.text || c.href));
|
||||
}
|
||||
const legacy: CtaButton[] = [];
|
||||
if (props.buttonText) legacy.push({ text: props.buttonText, href: props.buttonHref || '#', variant: 'primary' });
|
||||
if (props.secondaryButtonText) legacy.push({ text: props.secondaryButtonText, href: props.secondaryButtonHref || '#', variant: 'outline' });
|
||||
return legacy;
|
||||
}
|
||||
|
||||
export function ctaInlineStyle(cta: CtaButton, defaults: CtaStyleDefaults): CSSProperties {
|
||||
const variant = cta.variant || 'primary';
|
||||
switch (variant) {
|
||||
case 'outline':
|
||||
return {
|
||||
display: 'inline-block', padding: '14px 36px',
|
||||
backgroundColor: 'transparent', color: defaults.outlineText,
|
||||
textDecoration: 'none', borderRadius: '8px',
|
||||
fontWeight: 600, fontSize: '16px',
|
||||
border: `2px solid ${defaults.outlineText}`,
|
||||
};
|
||||
case 'ghost':
|
||||
return {
|
||||
display: 'inline-block', padding: '14px 24px',
|
||||
backgroundColor: 'transparent', color: defaults.outlineText,
|
||||
textDecoration: 'underline', borderRadius: '8px',
|
||||
fontWeight: 600, fontSize: '16px',
|
||||
};
|
||||
case 'primary':
|
||||
default:
|
||||
return {
|
||||
display: 'inline-block', padding: '14px 36px',
|
||||
backgroundColor: defaults.primaryBg, color: defaults.primaryText,
|
||||
textDecoration: 'none', borderRadius: '8px',
|
||||
fontWeight: 600, fontSize: '16px',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function ctaCssString(cta: CtaButton, defaults: CtaStyleDefaults): string {
|
||||
const variant = cta.variant || 'primary';
|
||||
switch (variant) {
|
||||
case 'outline':
|
||||
return `display:inline-block;padding:14px 36px;background-color:transparent;color:${defaults.outlineText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${defaults.outlineText}`;
|
||||
case 'ghost':
|
||||
return `display:inline-block;padding:14px 24px;background-color:transparent;color:${defaults.outlineText};text-decoration:underline;border-radius:8px;font-weight:600;font-size:16px`;
|
||||
case 'primary':
|
||||
default:
|
||||
return `display:inline-block;padding:14px 36px;background-color:${defaults.primaryBg};color:${defaults.primaryText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px`;
|
||||
}
|
||||
}
|
||||
|
||||
const esc = (s: any) => String(s ?? '').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
export function ctasToHtml(ctas: CtaButton[], defaults: CtaStyleDefaults): string {
|
||||
return ctas.map((c) => {
|
||||
const target = c.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
return `<a href="${esc(c.href || '#')}"${target} style="${ctaCssString(c, defaults)}">${esc(c.text || '')}</a>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* ---------- CTAs editor (settings UI) ---------- */
|
||||
|
||||
interface CtasEditorProps {
|
||||
ctas: CtaButton[];
|
||||
/** Called whenever the user mutates the array. Sections wire this via setProp. */
|
||||
onChange: (next: CtaButton[]) => void;
|
||||
/** Max items the user can add. Default 4. */
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%', padding: '6px 8px', background: '#27272a',
|
||||
color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||
};
|
||||
const labelStyle: CSSProperties = {
|
||||
fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4,
|
||||
};
|
||||
|
||||
export const CtasEditor: React.FC<CtasEditorProps> = ({ ctas, onChange, max = 4 }) => {
|
||||
const update = (i: number, patch: Partial<CtaButton>) => {
|
||||
const next = ctas.slice();
|
||||
next[i] = { ...next[i], ...patch };
|
||||
onChange(next);
|
||||
};
|
||||
const remove = (i: number) => onChange(ctas.filter((_, j) => j !== i));
|
||||
const add = () => {
|
||||
if (ctas.length >= max) return;
|
||||
onChange([...ctas, { text: 'New button', href: '#', variant: ctas.length === 0 ? 'primary' : 'outline' }]);
|
||||
};
|
||||
const move = (i: number, dir: -1 | 1) => {
|
||||
const j = i + dir;
|
||||
if (j < 0 || j >= ctas.length) return;
|
||||
const next = ctas.slice();
|
||||
[next[i], next[j]] = [next[j], next[i]];
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ fontSize: 11, color: '#a1a1aa', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Buttons ({ctas.length})
|
||||
</div>
|
||||
{ctas.length === 0 && (
|
||||
<div style={{ fontSize: 11, color: '#71717a', fontStyle: 'italic', padding: '8px 0' }}>
|
||||
No buttons. Click "Add button" to insert one.
|
||||
</div>
|
||||
)}
|
||||
{ctas.map((cta, i) => (
|
||||
<div key={i} style={{
|
||||
background: '#18181b', border: '1px solid #3f3f46', borderRadius: 6,
|
||||
padding: 10, display: 'flex', flexDirection: 'column', gap: 6,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 10, color: '#a1a1aa', fontWeight: 600, flex: 1 }}>Button {i + 1}</span>
|
||||
<button onClick={() => move(i, -1)} disabled={i === 0} title="Move up"
|
||||
style={iconBtn(i === 0)}>↑</button>
|
||||
<button onClick={() => move(i, 1)} disabled={i === ctas.length - 1} title="Move down"
|
||||
style={iconBtn(i === ctas.length - 1)}>↓</button>
|
||||
<button onClick={() => remove(i)} title="Remove"
|
||||
style={{ ...iconBtn(false), color: '#fca5a5' }}>✕</button>
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>Text</label>
|
||||
<input type="text" value={cta.text} onChange={(e) => update(i, { text: e.target.value })} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>URL</label>
|
||||
<input type="text" value={cta.href} onChange={(e) => update(i, { href: e.target.value })}
|
||||
placeholder="https://… or #anchor" style={inputStyle} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={labelStyle}>Style</label>
|
||||
<select value={cta.variant || 'primary'}
|
||||
onChange={(e) => update(i, { variant: e.target.value as CtaVariant })}
|
||||
style={{ ...inputStyle, padding: '5px 6px' }}>
|
||||
<option value="primary">Primary (filled)</option>
|
||||
<option value="outline">Outline</option>
|
||||
<option value="ghost">Ghost (text)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ flex: '0 0 auto', display: 'flex', alignItems: 'flex-end' }}>
|
||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer', whiteSpace: 'nowrap' }}>
|
||||
<input type="checkbox" checked={cta.target === '_blank'}
|
||||
onChange={(e) => update(i, { target: e.target.checked ? '_blank' : undefined })} />
|
||||
New tab
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{ctas.length < max && (
|
||||
<button onClick={add} style={{
|
||||
padding: '8px 12px', fontSize: 12, fontWeight: 600,
|
||||
color: '#3b82f6', background: 'rgba(59,130,246,0.1)',
|
||||
border: '1px dashed #3b82f6', borderRadius: 4, cursor: 'pointer',
|
||||
}}>
|
||||
+ Add button{ctas.length === 0 ? '' : ` (${max - ctas.length} more)`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function iconBtn(disabled: boolean): CSSProperties {
|
||||
return {
|
||||
width: 22, height: 22, fontSize: 11,
|
||||
background: '#27272a', color: disabled ? '#52525b' : '#a1a1aa',
|
||||
border: '1px solid #3f3f46', borderRadius: 4,
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user