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>
308 lines
15 KiB
TypeScript
308 lines
15 KiB
TypeScript
import React, { useCallback, useState } from 'react';
|
||
import { useEditor } from '@craftjs/core';
|
||
import { SHADOW_PRESETS } from '../../../constants/presets';
|
||
import {
|
||
StylePanelProps,
|
||
CollapsibleSection,
|
||
ColorPickerField,
|
||
SectionLabel,
|
||
PresetButtonGrid,
|
||
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 bulletOptions = [
|
||
{ label: '✓', value: 'check' },
|
||
{ label: '●', value: 'dot' },
|
||
{ label: '→', value: 'arrow' },
|
||
{ label: '★', value: 'star' },
|
||
{ label: '—', value: 'dash' },
|
||
{ label: 'None', value: 'none' },
|
||
];
|
||
|
||
const bulletChar: Record<string, string> = {
|
||
check: '✓', dot: '●', arrow: '→', star: '★', dash: '—', none: '',
|
||
};
|
||
|
||
export const PricingStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||
const { actions } = useEditor();
|
||
const { setProp, setPropStyle } = useNodeProp(selectedId);
|
||
const [expandedPlan, setExpandedPlan] = useState<number>(0);
|
||
|
||
const plans: any[] = Array.isArray(nodeProps.plans) ? nodeProps.plans : [];
|
||
const currentBullet = nodeProps.bulletType || 'check';
|
||
const style = nodeProps.style || {};
|
||
|
||
const updatePlan = useCallback((planIndex: number, field: string, value: any) => {
|
||
actions.setProp(selectedId, (props: any) => {
|
||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||
updated[planIndex] = { ...updated[planIndex], [field]: value };
|
||
props.plans = updated;
|
||
});
|
||
}, [actions, selectedId]);
|
||
|
||
const addPlan = useCallback(() => {
|
||
actions.setProp(selectedId, (props: any) => {
|
||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||
updated.push({
|
||
name: 'New Plan',
|
||
price: '$0',
|
||
period: '/month',
|
||
features: ['Feature 1'],
|
||
buttonText: 'Choose Plan',
|
||
buttonHref: '#',
|
||
isFeatured: false,
|
||
});
|
||
props.plans = updated;
|
||
});
|
||
}, [actions, selectedId]);
|
||
|
||
const removePlan = useCallback((index: number) => {
|
||
actions.setProp(selectedId, (props: any) => {
|
||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||
updated.splice(index, 1);
|
||
props.plans = updated;
|
||
});
|
||
}, [actions, selectedId]);
|
||
|
||
const addFeature = useCallback((planIndex: number) => {
|
||
actions.setProp(selectedId, (props: any) => {
|
||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
|
||
features.push('New feature');
|
||
updated[planIndex] = { ...updated[planIndex], features };
|
||
props.plans = updated;
|
||
});
|
||
}, [actions, selectedId]);
|
||
|
||
const updateFeature = useCallback((planIndex: number, featureIndex: number, value: string) => {
|
||
actions.setProp(selectedId, (props: any) => {
|
||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
|
||
features[featureIndex] = value;
|
||
updated[planIndex] = { ...updated[planIndex], features };
|
||
props.plans = updated;
|
||
});
|
||
}, [actions, selectedId]);
|
||
|
||
const removeFeature = useCallback((planIndex: number, featureIndex: number) => {
|
||
actions.setProp(selectedId, (props: any) => {
|
||
const updated = [...(Array.isArray(props.plans) ? props.plans : [])];
|
||
const features = [...(Array.isArray(updated[planIndex]?.features) ? updated[planIndex].features : [])];
|
||
features.splice(featureIndex, 1);
|
||
updated[planIndex] = { ...updated[planIndex], features };
|
||
props.plans = updated;
|
||
});
|
||
}, [actions, selectedId]);
|
||
|
||
return (
|
||
<>
|
||
{/* Bullet type */}
|
||
<CollapsibleSection title="Bullet Style">
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
{bulletOptions.map((b) => (
|
||
<button key={b.value} onClick={() => actions.setProp(selectedId, (p: any) => { p.bulletType = b.value; })}
|
||
style={{ ...btnActiveStyle(currentBullet === b.value), flex: 1, fontSize: 14 }}>
|
||
{b.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</CollapsibleSection>
|
||
|
||
{/* Plans */}
|
||
<CollapsibleSection title={`Plans (${plans.length})`}>
|
||
{plans.map((plan, i) => {
|
||
const isExpanded = expandedPlan === i;
|
||
const features: string[] = Array.isArray(plan.features) ? plan.features : [];
|
||
|
||
return (
|
||
<div key={i} style={{
|
||
marginBottom: 8, background: '#18181b', borderRadius: 6,
|
||
border: plan.isFeatured ? '1px solid #3b82f6' : '1px solid #27272a',
|
||
}}>
|
||
{/* Plan header - click to expand */}
|
||
<div onClick={() => setExpandedPlan(isExpanded ? -1 : i)} style={{
|
||
padding: '8px 10px', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||
}}>
|
||
<span style={{ fontSize: 12, fontWeight: 600, color: '#e4e4e7' }}>
|
||
{plan.name || 'Plan'} {plan.isFeatured && <span style={{ fontSize: 9, background: '#3b82f6', color: '#fff', padding: '1px 5px', borderRadius: 3, marginLeft: 4 }}>Featured</span>}
|
||
</span>
|
||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||
<span style={{ fontSize: 11, color: '#71717a' }}>{plan.price}</span>
|
||
<i className={`fa fa-chevron-${isExpanded ? 'up' : 'down'}`} style={{ fontSize: 10, color: '#71717a' }} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Expanded plan settings */}
|
||
{isExpanded && (
|
||
<div style={{ padding: '0 10px 10px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ fontSize: 9, color: '#52525b' }}>Name</label>
|
||
<input type="text" value={plan.name || ''} onChange={(e) => updatePlan(i, 'name', e.target.value)} style={smallInputStyle} />
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ fontSize: 9, color: '#52525b' }}>Price</label>
|
||
<input type="text" value={plan.price || ''} onChange={(e) => updatePlan(i, 'price', e.target.value)} style={smallInputStyle} />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: 9, color: '#52525b' }}>Period</label>
|
||
<input type="text" value={plan.period || ''} onChange={(e) => updatePlan(i, 'period', e.target.value)} placeholder="/month" style={smallInputStyle} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ fontSize: 9, color: '#52525b' }}>Button Text</label>
|
||
<input type="text" value={plan.buttonText || ''} onChange={(e) => updatePlan(i, 'buttonText', e.target.value)} style={smallInputStyle} />
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<label style={{ fontSize: 9, color: '#52525b' }}>Button URL</label>
|
||
<input type="text" value={plan.buttonHref || ''} onChange={(e) => updatePlan(i, 'buttonHref', e.target.value)} style={smallInputStyle} />
|
||
</div>
|
||
</div>
|
||
<label style={{ fontSize: 10, color: '#71717a', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
||
<input type="checkbox" checked={!!plan.isFeatured} onChange={(e) => updatePlan(i, 'isFeatured', e.target.checked)} />
|
||
Featured (highlighted)
|
||
</label>
|
||
|
||
{/* Features list */}
|
||
<div>
|
||
<label style={{ fontSize: 9, color: '#52525b', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<span>Features ({features.length})</span>
|
||
<button onClick={() => addFeature(i)} style={{ fontSize: 9, background: '#3b82f6', color: '#fff', border: 'none', borderRadius: 3, padding: '2px 6px', cursor: 'pointer' }}>
|
||
+ Add
|
||
</button>
|
||
</label>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 4 }}>
|
||
{features.map((feat, fi) => (
|
||
<div key={fi} style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
|
||
<span style={{ fontSize: 11, color: '#10b981', width: 14, textAlign: 'center' }}>{bulletChar[currentBullet] || '✓'}</span>
|
||
<input type="text" value={feat} onChange={(e) => updateFeature(i, fi, e.target.value)} style={{ ...smallInputStyle, flex: 1 }} />
|
||
<button onClick={() => removeFeature(i, fi)} style={{ fontSize: 9, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 3, padding: '1px 4px', cursor: 'pointer', lineHeight: 1 }}>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Remove plan */}
|
||
{plans.length > 1 && (
|
||
<button onClick={() => removePlan(i)} style={{ fontSize: 10, background: 'none', color: '#ef4444', border: '1px solid #ef4444', borderRadius: 4, padding: '3px 8px', cursor: 'pointer', marginTop: 4 }}>
|
||
Remove Plan
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<button onClick={addPlan} style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', marginTop: 4 }}>
|
||
+ Add Plan
|
||
</button>
|
||
</CollapsibleSection>
|
||
|
||
{/* Colors */}
|
||
<CollapsibleSection title="Colors" defaultOpen={false}>
|
||
<ColorPickerField label="Featured Plan Color" value={nodeProps.featuredBg || '#3b82f6'} onChange={(v) => actions.setProp(selectedId, (p: any) => { p.featuredBg = v; })} />
|
||
{/* Regular (non-featured) card colors -- built into PricingTable's
|
||
render/toHtml but previously hard-coded literals with no control
|
||
surfacing them. Each falls back to the prior literal when blank. */}
|
||
<ColorPickerField label="Card Background" value={nodeProps.cardBg || '#ffffff'} onChange={(v) => setProp('cardBg', v)} />
|
||
<ColorPickerField label="Heading / Price Color" value={nodeProps.textColor || '#18181b'} onChange={(v) => setProp('textColor', v)} />
|
||
<ColorPickerField label="Period Text Color" value={nodeProps.subColor || '#64748b'} onChange={(v) => setProp('subColor', v)} />
|
||
<ColorPickerField label="Feature Text Color" value={nodeProps.featColor || '#4b5563'} onChange={(v) => setProp('featColor', v)} />
|
||
<ColorPickerField label="Checkmark Color" value={nodeProps.checkColor || '#10b981'} onChange={(v) => setProp('checkColor', v)} />
|
||
<ColorPickerField label="Button Background" value={nodeProps.btnBg || nodeProps.featuredBg || '#3b82f6'} onChange={(v) => setProp('btnBg', v)} />
|
||
<ColorPickerField label="Button Text Color" value={nodeProps.btnColor || '#ffffff'} onChange={(v) => setProp('btnColor', v)} />
|
||
</CollapsibleSection>
|
||
|
||
{/* Box model: margin, padding, 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>
|
||
</>
|
||
);
|
||
};
|