202 lines
8.1 KiB
TypeScript
202 lines
8.1 KiB
TypeScript
|
|
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',
|
||
|
|
};
|
||
|
|
}
|