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
+54 -46
View File
@@ -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>