Files
site-builder/craft/src/panels/right/styles/PricingStylePanel.tsx
T
shadowdaoandClaude Opus 4.8 8bf525c600 feat(builder): surface hidden section/pricing/social props + box-model rollout
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>
2026-07-14 06:47:58 -07:00

308 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
</>
);
};