SectionTypePanel (Accordion/Tabs/Testimonials/Countdown/NumberCounter/ CTASection/CallToAction/FeaturesGrid), PricingStylePanel, and SocialStylePanel all gain a Spacing & Border section (margin/padding per-side, border, box-shadow, opacity), an Animation section, and a Visibility section, wired to the shared SpacingControl/BorderControl/ AnimationControl/VisibilityControl. PricingTable's per-card colors (cardBg/textColor/subColor/featColor/ checkColor/btnBg/btnColor) were previously hard-coded literals computed from featuredBg inside toHtml -- promoted to real optional props (each falling back to the exact prior literal when unset) and exposed via ColorPickerField in PricingStylePanel. SocialStylePanel now exposes SocialLinks' iconShape/gap (already-built props with no control), plus Icon's bgColor/bgShape/bgSize/link and StarRating's filledColor/emptyColor, which the panel's generic iconBgColor/starColor checks never matched since those aren't Icon's or StarRating's real prop names. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
325 lines
14 KiB
TypeScript
325 lines
14 KiB
TypeScript
import React from 'react';
|
|
import { useEditor } from '@craftjs/core';
|
|
import {
|
|
BG_COLORS,
|
|
SPACING_PRESETS,
|
|
SHADOW_PRESETS,
|
|
} from '../../../constants/presets';
|
|
import {
|
|
StylePanelProps,
|
|
SectionLabel,
|
|
ColorSwatchGrid,
|
|
PresetButtonGrid,
|
|
CollapsibleSection,
|
|
ColorPickerField,
|
|
labelStyle,
|
|
inputStyle,
|
|
smallInputStyle,
|
|
btnActiveStyle,
|
|
sectionGap,
|
|
useNodeProp,
|
|
SpacingControl,
|
|
BorderControl,
|
|
BorderValue,
|
|
buildBorderShorthand,
|
|
AnimationControl,
|
|
VisibilityControl,
|
|
} from './shared';
|
|
|
|
/** Parses a border shorthand string ("2px solid #hex") produced by
|
|
* buildBorderShorthand() back into its parts for round-tripping through
|
|
* BorderControl. See SectionTypePanel.tsx for the identical helper. */
|
|
function parseBorderShorthand(v: string | undefined): BorderValue {
|
|
if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' };
|
|
const m = String(v).match(/^([\d.]+[a-z%]*)\s+(\w+)\s+(.+)$/);
|
|
if (!m) return { width: '', style: 'none', color: '#000000' };
|
|
return { width: m[1], style: m[2], color: m[3] };
|
|
}
|
|
|
|
const capSide = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
|
|
|
|
const ICON_SHAPES = [
|
|
{ label: 'None', value: 'none' },
|
|
{ label: 'Circle', value: 'circle' },
|
|
{ label: 'Square', value: 'square' },
|
|
{ label: 'Rounded', value: 'rounded' },
|
|
];
|
|
|
|
/* ---------- SOCIAL / ICON / STAR RATING ---------- */
|
|
export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
|
const { actions } = useEditor();
|
|
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
|
|
|
const style = nodeProps.style || {};
|
|
|
|
return (
|
|
<>
|
|
{/* Social Links list */}
|
|
{nodeProps.links !== undefined && Array.isArray(nodeProps.links) && (
|
|
<CollapsibleSection title="Links">
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
{(nodeProps.links || []).map((link: any, i: number) => (
|
|
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
<span style={{ fontSize: 11, color: '#a1a1aa', width: 60, textTransform: 'capitalize' }}>{link.platform || 'link'}</span>
|
|
<input
|
|
type="text"
|
|
value={link.url || ''}
|
|
onChange={(e) => {
|
|
actions.setProp(selectedId, (props: any) => {
|
|
const updated = [...(props.links || [])];
|
|
updated[i] = { ...updated[i], url: e.target.value };
|
|
props.links = updated;
|
|
});
|
|
}}
|
|
placeholder="URL"
|
|
style={{ ...smallInputStyle, flex: 1 }}
|
|
/>
|
|
<button
|
|
onClick={() => {
|
|
actions.setProp(selectedId, (props: any) => {
|
|
const updated = [...(props.links || [])];
|
|
updated.splice(i, 1);
|
|
props.links = updated;
|
|
});
|
|
}}
|
|
style={{ padding: '2px 6px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
|
|
>
|
|
<i className="fa fa-times" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div style={{ marginTop: 6 }}>
|
|
<select
|
|
onChange={(e) => {
|
|
if (!e.target.value) return;
|
|
actions.setProp(selectedId, (props: any) => {
|
|
props.links = [...(props.links || []), { platform: e.target.value, url: '#' }];
|
|
});
|
|
e.target.value = '';
|
|
}}
|
|
style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
|
>
|
|
<option value="">+ Add Platform...</option>
|
|
{['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp', 'spotify', 'twitch'].map((p) => (
|
|
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</CollapsibleSection>
|
|
)}
|
|
|
|
{/* Icon name */}
|
|
{nodeProps.iconName !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Icon Name</label>
|
|
<input type="text" value={nodeProps.iconName || ''} onChange={(e) => setProp('iconName', e.target.value)} placeholder="fa-star" style={inputStyle} />
|
|
</div>
|
|
)}
|
|
{nodeProps.icon !== undefined && typeof nodeProps.icon === 'string' && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Icon</label>
|
|
<input type="text" value={nodeProps.icon || ''} onChange={(e) => setProp('icon', e.target.value)} placeholder="fa-star or emoji" style={inputStyle} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Star rating */}
|
|
{nodeProps.rating !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Rating: {nodeProps.rating || 0}</label>
|
|
<input type="range" min={0} max={5} step={0.5} value={nodeProps.rating || 0} onChange={(e) => setProp('rating', parseFloat(e.target.value))} style={{ width: '100%' }} />
|
|
</div>
|
|
)}
|
|
{nodeProps.maxStars !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Max Stars</label>
|
|
<input type="number" min={1} max={10} value={nodeProps.maxStars || 5} onChange={(e) => setProp('maxStars', parseInt(e.target.value) || 5)} style={inputStyle} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Icon shape (SocialLinks) -- exists on the component (iconShape prop
|
|
drives the circle/square/rounded background box) but previously had
|
|
no control. */}
|
|
{nodeProps.iconShape !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Icon Shape</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
{ICON_SHAPES.map((s) => (
|
|
<button key={s.value} onClick={() => setProp('iconShape', s.value)} style={{ ...btnActiveStyle(nodeProps.iconShape === s.value), flex: 1 }}>
|
|
{s.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Gap between links (SocialLinks) -- exists on the component but
|
|
previously had no control. */}
|
|
{nodeProps.gap !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Gap</label>
|
|
<input type="text" value={nodeProps.gap || '10px'} onChange={(e) => setProp('gap', e.target.value)} placeholder="10px" style={inputStyle} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Background shape / size / link (Icon component) -- bgColor, bgShape,
|
|
bgSize, link all already exist on Icon.craft.props but had no
|
|
control; the generic `color`/`iconColor` checks above never matched
|
|
Icon's actual prop names. */}
|
|
{nodeProps.bgShape !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Background Shape</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
{ICON_SHAPES.map((s) => (
|
|
<button key={s.value} onClick={() => setProp('bgShape', s.value)} style={{ ...btnActiveStyle(nodeProps.bgShape === s.value), flex: 1 }}>
|
|
{s.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{nodeProps.bgColor !== undefined && (
|
|
<ColorPickerField label="Background Color" value={nodeProps.bgColor === 'transparent' ? '' : nodeProps.bgColor} onChange={(v) => setProp('bgColor', v)} />
|
|
)}
|
|
{nodeProps.bgSize !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Background Size</label>
|
|
<input type="text" value={nodeProps.bgSize || '56px'} onChange={(e) => setProp('bgSize', e.target.value)} placeholder="56px" style={inputStyle} />
|
|
</div>
|
|
)}
|
|
{nodeProps.link !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Link URL</label>
|
|
<input type="text" value={nodeProps.link || ''} onChange={(e) => setProp('link', e.target.value)} placeholder="https://..." style={inputStyle} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Star colors (StarRating) -- exist on the component as filledColor /
|
|
emptyColor but had no control (the generic `starColor` check above
|
|
never matched StarRating's actual prop names). */}
|
|
{nodeProps.filledColor !== undefined && (
|
|
<ColorPickerField label="Filled Star Color" value={nodeProps.filledColor || '#f59e0b'} onChange={(v) => setProp('filledColor', v)} />
|
|
)}
|
|
{nodeProps.emptyColor !== undefined && (
|
|
<ColorPickerField label="Empty Star Color" value={nodeProps.emptyColor || '#d1d5db'} onChange={(v) => setProp('emptyColor', v)} />
|
|
)}
|
|
|
|
{/* Colors */}
|
|
{nodeProps.iconColor !== undefined && (
|
|
<ColorPickerField label="Icon Color" value={nodeProps.iconColor || '#3b82f6'} onChange={(v) => setProp('iconColor', v)} />
|
|
)}
|
|
{nodeProps.iconBgColor !== undefined && (
|
|
<ColorPickerField label="Icon Background" value={nodeProps.iconBgColor || 'transparent'} onChange={(v) => setProp('iconBgColor', v)} />
|
|
)}
|
|
{nodeProps.starColor !== undefined && (
|
|
<ColorPickerField label="Star Color" value={nodeProps.starColor || '#f59e0b'} onChange={(v) => setProp('starColor', v)} />
|
|
)}
|
|
{nodeProps.color !== undefined && typeof nodeProps.color === 'string' && (
|
|
<ColorPickerField label="Color" value={nodeProps.color || '#3b82f6'} onChange={(v) => setProp('color', v)} />
|
|
)}
|
|
|
|
{/* Size */}
|
|
{nodeProps.iconSize !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Icon Size</label>
|
|
<input type="text" value={nodeProps.iconSize || '24px'} onChange={(e) => setProp('iconSize', e.target.value)} style={inputStyle} />
|
|
</div>
|
|
)}
|
|
{nodeProps.size !== undefined && typeof nodeProps.size === 'string' && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Size</label>
|
|
<input type="text" value={nodeProps.size || '24px'} onChange={(e) => setProp('size', e.target.value)} style={inputStyle} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Alignment */}
|
|
{nodeProps.alignment !== undefined && (
|
|
<div style={sectionGap}>
|
|
<label style={labelStyle}>Alignment</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
{(['left', 'center', 'right'] as const).map((a) => (
|
|
<button key={a} onClick={() => setProp('alignment', a)} style={btnActiveStyle(nodeProps.alignment === a)}>
|
|
<i className={`fa fa-align-${a}`} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Background & padding */}
|
|
<CollapsibleSection title="Style" defaultOpen={false}>
|
|
<div className="guided-section">
|
|
<SectionLabel>Background</SectionLabel>
|
|
<ColorSwatchGrid colors={BG_COLORS} activeValue={style.backgroundColor} onSelect={(v: string) => setPropStyle('backgroundColor', v)} />
|
|
</div>
|
|
<div className="guided-section">
|
|
<SectionLabel>Padding</SectionLabel>
|
|
<PresetButtonGrid presets={SPACING_PRESETS} activeValue={style.padding as string} onSelect={(v) => setPropStyle('padding', v)} />
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
{/* Box model: margin, padding (per-side), border, shadow, opacity */}
|
|
<CollapsibleSection title="Spacing & Border" defaultOpen={false}>
|
|
<SpacingControl
|
|
label="Margin"
|
|
value={{
|
|
top: style.marginTop as string, right: style.marginRight as string,
|
|
bottom: style.marginBottom as string, left: style.marginLeft as string,
|
|
}}
|
|
onChange={(side, v) => setPropStyle(`margin${capSide(side)}`, v)}
|
|
/>
|
|
<SpacingControl
|
|
label="Padding"
|
|
value={{
|
|
top: style.paddingTop as string, right: style.paddingRight as string,
|
|
bottom: style.paddingBottom as string, left: style.paddingLeft as string,
|
|
}}
|
|
onChange={(side, v) => setPropStyle(`padding${capSide(side)}`, v)}
|
|
/>
|
|
<BorderControl
|
|
value={parseBorderShorthand(style.border as string)}
|
|
onChange={(v) => setPropStyle('border', buildBorderShorthand(v))}
|
|
/>
|
|
<div className="guided-section">
|
|
<SectionLabel>Shadow</SectionLabel>
|
|
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow as string} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
|
</div>
|
|
<div className="guided-section">
|
|
<SectionLabel>Opacity</SectionLabel>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={100}
|
|
value={style.opacity !== undefined ? Math.round(Number(style.opacity) * 100) : 100}
|
|
onChange={(e) => setPropStyle('opacity', String(Number(e.target.value) / 100))}
|
|
style={{ width: '100%' }}
|
|
/>
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
{/* Entrance animation */}
|
|
<CollapsibleSection title="Animation" defaultOpen={false}>
|
|
<AnimationControl
|
|
value={{ animation: nodeProps.animation || 'none', animationDelay: nodeProps.animationDelay || '0' }}
|
|
onChange={(v) => { setProp('animation', v.animation); setProp('animationDelay', v.animationDelay); }}
|
|
/>
|
|
</CollapsibleSection>
|
|
|
|
{/* Responsive visibility */}
|
|
<CollapsibleSection title="Visibility" defaultOpen={false}>
|
|
<VisibilityControl
|
|
value={{
|
|
hideOnDesktop: !!nodeProps.hideOnDesktop,
|
|
hideOnTablet: !!nodeProps.hideOnTablet,
|
|
hideOnMobile: !!nodeProps.hideOnMobile,
|
|
}}
|
|
onChange={(v) => {
|
|
setProp('hideOnDesktop', !!v.hideOnDesktop);
|
|
setProp('hideOnTablet', !!v.hideOnTablet);
|
|
setProp('hideOnMobile', !!v.hideOnMobile);
|
|
}}
|
|
/>
|
|
</CollapsibleSection>
|
|
</>
|
|
);
|
|
};
|