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:
2026-05-25 12:43:28 -07:00
parent 7b747f775f
commit d0925d9e2d
24 changed files with 723 additions and 237 deletions
+43 -118
View File
@@ -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>`,
};