Files
site-builder/craft/src/components/sections/PricingTable.tsx

452 lines
17 KiB
TypeScript
Raw Normal View History

import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface PricingPlan {
name: string;
price: string;
period: string;
features: string[];
buttonText: string;
buttonHref: string;
isFeatured: boolean;
}
interface PricingTableProps {
plans?: PricingPlan[];
style?: CSSProperties;
featuredBg?: string;
bulletType?: string;
}
const bulletChars: Record<string, string> = {
check: '✓', dot: '●', arrow: '→', star: '★', dash: '—', none: '',
};
const defaultPlans: PricingPlan[] = [
{
name: 'Basic',
price: '$9',
period: '/month',
features: ['1 Website', '10 GB Storage', 'Free SSL Certificate', 'Email Support'],
buttonText: 'Get Started',
buttonHref: '#',
isFeatured: false,
},
{
name: 'Pro',
price: '$29',
period: '/month',
features: ['10 Websites', '100 GB Storage', 'Free SSL Certificate', 'Priority Support', 'Custom Domain', 'Analytics Dashboard'],
buttonText: 'Get Started',
buttonHref: '#',
isFeatured: true,
},
{
name: 'Enterprise',
price: '$99',
period: '/month',
features: ['Unlimited Websites', '1 TB Storage', 'Free SSL Certificate', '24/7 Phone Support', 'Custom Domain', 'Advanced Analytics', 'API Access', 'Team Collaboration'],
buttonText: 'Contact Sales',
buttonHref: '#',
isFeatured: false,
},
];
export const PricingTable: UserComponent<PricingTableProps> = ({
plans = defaultPlans,
style = {},
featuredBg = '#3b82f6',
bulletType = 'check',
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{
maxWidth: '1100px',
margin: '0 auto',
display: 'flex',
gap: '24px',
justifyContent: 'center',
alignItems: 'stretch',
flexWrap: 'wrap',
}}>
{plans.map((plan, i) => (
<div
key={i}
style={{
flex: '1 1 280px',
maxWidth: '360px',
backgroundColor: plan.isFeatured ? featuredBg : '#ffffff',
border: plan.isFeatured ? 'none' : '1px solid #e2e8f0',
borderRadius: '16px',
padding: '40px 32px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
position: 'relative',
transform: plan.isFeatured ? 'scale(1.05)' : 'none',
boxShadow: plan.isFeatured ? '0 20px 60px rgba(59,130,246,0.3)' : '0 1px 3px rgba(0,0,0,0.06)',
}}
>
{plan.isFeatured && (
<div style={{
position: 'absolute',
top: '-12px',
backgroundColor: '#facc15',
color: '#18181b',
padding: '4px 16px',
borderRadius: '9999px',
fontSize: '12px',
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}>
Most Popular
</div>
)}
<h3 style={{
fontSize: '20px',
fontWeight: '600',
color: plan.isFeatured ? '#ffffff' : '#18181b',
marginBottom: '8px',
marginTop: plan.isFeatured ? '8px' : '0',
}}>
{plan.name}
</h3>
<div style={{ marginBottom: '24px' }}>
<span style={{
fontSize: '48px',
fontWeight: '700',
color: plan.isFeatured ? '#ffffff' : '#18181b',
lineHeight: '1',
}}>
{plan.price}
</span>
<span style={{
fontSize: '16px',
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b',
}}>
{plan.period}
</span>
</div>
<ul style={{
listStyle: 'none',
padding: '0',
margin: '0 0 32px 0',
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}>
{(Array.isArray(plan.features) ? plan.features : []).map((feature, fi) => (
<li key={fi} style={{
fontSize: '14px',
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<span style={{ color: plan.isFeatured ? '#bbf7d0' : '#10b981', fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
{feature}
</li>
))}
</ul>
<a
href={plan.buttonHref}
onClick={(e) => e.preventDefault()}
style={{
marginTop: 'auto',
display: 'inline-block',
padding: '14px 32px',
backgroundColor: plan.isFeatured ? '#ffffff' : featuredBg,
color: plan.isFeatured ? featuredBg : '#ffffff',
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '14px',
width: '100%',
textAlign: 'center',
}}
>
{plan.buttonText}
</a>
</div>
))}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const PricingTableSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as PricingTableProps,
}));
const plans = props.plans || defaultPlans;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const updatePlan = (index: number, field: keyof PricingPlan, value: any) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
updated[index] = { ...updated[index], [field]: value };
p.plans = updated;
});
};
const updateFeature = (planIndex: number, featureIndex: number, value: string) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
const features = [...(Array.isArray(updated[planIndex].features) ? updated[planIndex].features : [])];
features[featureIndex] = value;
updated[planIndex] = { ...updated[planIndex], features };
p.plans = updated;
});
};
const addFeature = (planIndex: number) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
updated[planIndex] = { ...updated[planIndex], features: [...updated[planIndex].features, 'New Feature'] };
p.plans = updated;
});
};
const removeFeature = (planIndex: number, featureIndex: number) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
const features = [...(Array.isArray(updated[planIndex].features) ? updated[planIndex].features : [])];
features.splice(featureIndex, 1);
updated[planIndex] = { ...updated[planIndex], features };
p.plans = updated;
});
};
const addPlan = () => {
setProp((p: PricingTableProps) => {
p.plans = [...(p.plans || defaultPlans), {
name: 'New Plan',
price: '$19',
period: '/month',
features: ['Feature 1', 'Feature 2'],
buttonText: 'Get Started',
buttonHref: '#',
isFeatured: false,
}];
});
};
const removePlan = (index: number) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
updated.splice(index, 1);
p.plans = updated;
});
};
const bgPresets = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#18181b', '#0f172a', '#7c3aed'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'].map((c) => (
<button
key={c}
onClick={() => setProp((p: PricingTableProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Featured Plan Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: PricingTableProps) => { p.featuredBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.featuredBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Plans</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{plans.map((plan, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={plan.name} onChange={(e) => updatePlan(i, 'name', e.target.value)} placeholder="Plan Name" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removePlan(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={plan.price} onChange={(e) => updatePlan(i, 'price', e.target.value)} placeholder="$29" style={{ ...inputStyle, width: '60px', flex: 'none' }} />
<input type="text" value={plan.period} onChange={(e) => updatePlan(i, 'period', e.target.value)} placeholder="/month" style={{ ...inputStyle, width: '70px', flex: 'none' }} />
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#a1a1aa', marginLeft: 'auto' }}>
<input
type="checkbox"
checked={plan.isFeatured}
onChange={(e) => updatePlan(i, 'isFeatured', e.target.checked)}
/>
Featured
</label>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={plan.buttonText} onChange={(e) => updatePlan(i, 'buttonText', e.target.value)} placeholder="Button Text" style={{ ...inputStyle, flex: 1 }} />
<input type="text" value={plan.buttonHref} onChange={(e) => updatePlan(i, 'buttonHref', e.target.value)} placeholder="URL" style={{ ...inputStyle, flex: 1 }} />
</div>
{/* Features */}
<div style={{ marginTop: 4 }}>
<span style={{ fontSize: 10, color: '#71717a' }}>Features:</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 2 }}>
{(Array.isArray(plan.features) ? plan.features : []).map((feat, fi) => (
<div key={fi} style={{ display: 'flex', gap: 2 }}>
<input type="text" value={feat} onChange={(e) => updateFeature(i, fi, e.target.value)} style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeFeature(i, fi)}
style={{ padding: '1px 4px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 3, cursor: 'pointer' }}
>
X
</button>
</div>
))}
</div>
<button
onClick={() => addFeature(i)}
style={{ marginTop: 2, width: '100%', padding: '3px', fontSize: 10, background: '#27272a', color: '#a1a1aa', border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer' }}
>
+ Feature
</button>
</div>
</div>
))}
</div>
<button
onClick={addPlan}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Plan
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
PricingTable.craft = {
displayName: 'Pricing Table',
props: {
plans: defaultPlans,
style: { backgroundColor: '#ffffff' },
featuredBg: '#3b82f6',
bulletType: 'check',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: PricingTableSettings,
},
};
/* ---------- HTML export ---------- */
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const bulletType = props.bulletType || 'check';
const sectionStyle = cssPropsToString({
padding: '80px 20px',
...props.style,
});
const plans = props.plans || defaultPlans;
const featuredBg = props.featuredBg || '#3b82f6';
const cards = plans.map((plan) => {
const cardBg = plan.isFeatured ? featuredBg : '#ffffff';
const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;';
const textColor = plan.isFeatured ? '#ffffff' : '#18181b';
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b';
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563';
const checkColor = plan.isFeatured ? '#bbf7d0' : '#10b981';
const btnBg = plan.isFeatured ? '#ffffff' : featuredBg;
const btnColor = plan.isFeatured ? featuredBg : '#ffffff';
const scale = plan.isFeatured ? 'transform:scale(1.05);' : '';
const shadow = plan.isFeatured ? 'box-shadow:0 20px 60px rgba(59,130,246,0.3);' : 'box-shadow:0 1px 3px rgba(0,0,0,0.06);';
const featuresHtml = (Array.isArray(plan.features) ? plan.features : []).map((f) =>
`<li style="font-size:14px;color:${featColor};display:flex;align-items:center;gap:8px"><span style="color:${checkColor};font-weight:700">${bulletChars[bulletType] || '✓'}</span>${esc(f)}</li>`
).join('\n ');
const badge = plan.isFeatured
? `<div style="position:absolute;top:-12px;background-color:#facc15;color:#18181b;padding:4px 16px;border-radius:9999px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px">Most Popular</div>`
: '';
return `<div style="flex:1 1 280px;max-width:360px;background-color:${cardBg};${cardBorder}border-radius:16px;padding:40px 32px;display:flex;flex-direction:column;align-items:center;text-align:center;position:relative;${scale}${shadow}">
${badge}
<h3 style="font-size:20px;font-weight:600;color:${textColor};margin-bottom:8px;${plan.isFeatured ? 'margin-top:8px;' : ''}">${esc(plan.name)}</h3>
<div style="margin-bottom:24px">
<span style="font-size:48px;font-weight:700;color:${textColor};line-height:1">${esc(plan.price)}</span>
<span style="font-size:16px;color:${subColor}">${esc(plan.period)}</span>
</div>
<ul style="list-style:none;padding:0;margin:0 0 32px 0;width:100%;display:flex;flex-direction:column;gap:12px">
${featuresHtml}
</ul>
<a href="${plan.buttonHref || '#'}" style="margin-top:auto;display:inline-block;padding:14px 32px;background-color:${btnBg};color:${btnColor};text-decoration:none;border-radius:8px;font-weight:600;font-size:14px;width:100%;text-align:center">${esc(plan.buttonText)}</a>
</div>`;
}).join('\n ');
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:flex;gap:24px;justify-content:center;align-items:stretch;flex-wrap:wrap">
${cards}
</div>
</section>`,
};
};