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:
@@ -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