48d0441be3
Every user-controlled URL emitted by a component's static toHtml (href, src, action, and CSS url()) now runs through escapeAttr(safeUrl(...)) before hitting the exported HTML string, closing the XSS gaps flagged in the A2 review (PricingTable buttonHref was fully unescaped, Gallery/ HeroSimple/ImageBlock/VideoBlock/etc. lacked scheme filtering) plus a few more found via a grep sweep of href=/src=/action=/url( inside toHtml template strings: MapEmbed's iframe src and the shared form-relay-wiring fallback form action (a javascript: form action executes on submit). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
201 lines
8.2 KiB
TypeScript
201 lines
8.2 KiB
TypeScript
import React, { CSSProperties } from 'react';
|
|
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
|
|
|
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`;
|
|
}
|
|
}
|
|
|
|
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="${escapeAttr(safeUrl(c.href || '#'))}"${target} style="${ctaCssString(c, defaults)}">${escapeHtml(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',
|
|
};
|
|
}
|