2026-04-05 18:31:16 -07:00
|
|
|
import React, { CSSProperties } from 'react';
|
|
|
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
|
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
2026-07-12 15:56:25 -07:00
|
|
|
import { escapeHtml, escapeAttr, safeUrl, cssValue } from '../../utils/escape';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
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;
|
2026-05-25 12:43:28 -07:00
|
|
|
anchorId?: string;
|
2026-07-14 06:47:58 -07:00
|
|
|
/* ---- Regular (non-featured) card colors ----
|
|
|
|
|
All optional; each falls back to the exact literal the card was
|
|
|
|
|
previously hard-coded to (or, for the button, to featuredBg -- the
|
|
|
|
|
button's original derivation) when left unset, so existing saved
|
|
|
|
|
projects render pixel-identical until a color is explicitly picked. */
|
|
|
|
|
cardBg?: string;
|
|
|
|
|
textColor?: string;
|
|
|
|
|
subColor?: string;
|
|
|
|
|
featColor?: string;
|
|
|
|
|
checkColor?: string;
|
|
|
|
|
btnBg?: string;
|
|
|
|
|
btnColor?: string;
|
|
|
|
|
animation?: string;
|
|
|
|
|
animationDelay?: string;
|
|
|
|
|
hideOnDesktop?: boolean;
|
|
|
|
|
hideOnTablet?: boolean;
|
|
|
|
|
hideOnMobile?: boolean;
|
2026-04-05 18:31:16 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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',
|
2026-05-25 12:43:28 -07:00
|
|
|
anchorId,
|
2026-07-14 06:47:58 -07:00
|
|
|
cardBg,
|
|
|
|
|
textColor,
|
|
|
|
|
subColor,
|
|
|
|
|
featColor,
|
|
|
|
|
checkColor,
|
|
|
|
|
btnBg,
|
|
|
|
|
btnColor,
|
2026-04-05 18:31:16 -07:00
|
|
|
}) => {
|
|
|
|
|
const {
|
|
|
|
|
connectors: { connect, drag },
|
|
|
|
|
selected,
|
|
|
|
|
} = useNode((node) => ({
|
|
|
|
|
selected: node.events.selected,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-07-14 06:47:58 -07:00
|
|
|
const regCardBg = cardBg || '#ffffff';
|
|
|
|
|
const regTextColor = textColor || '#18181b';
|
|
|
|
|
const regSubColor = subColor || '#64748b';
|
|
|
|
|
const regFeatColor = featColor || '#4b5563';
|
|
|
|
|
const regCheckColor = checkColor || '#10b981';
|
|
|
|
|
const regBtnBg = btnBg || featuredBg;
|
|
|
|
|
const regBtnColor = btnColor || '#ffffff';
|
|
|
|
|
|
2026-04-05 18:31:16 -07:00
|
|
|
return (
|
|
|
|
|
<section
|
|
|
|
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
2026-05-25 12:43:28 -07:00
|
|
|
id={anchorId || undefined}
|
2026-04-05 18:31:16 -07:00
|
|
|
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',
|
2026-07-14 06:47:58 -07:00
|
|
|
backgroundColor: plan.isFeatured ? featuredBg : regCardBg,
|
2026-04-05 18:31:16 -07:00
|
|
|
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',
|
2026-07-14 06:47:58 -07:00
|
|
|
color: plan.isFeatured ? '#ffffff' : regTextColor,
|
2026-04-05 18:31:16 -07:00
|
|
|
marginBottom: '8px',
|
|
|
|
|
marginTop: plan.isFeatured ? '8px' : '0',
|
|
|
|
|
}}>
|
|
|
|
|
{plan.name}
|
|
|
|
|
</h3>
|
|
|
|
|
<div style={{ marginBottom: '24px' }}>
|
|
|
|
|
<span style={{
|
|
|
|
|
fontSize: '48px',
|
|
|
|
|
fontWeight: '700',
|
2026-07-14 06:47:58 -07:00
|
|
|
color: plan.isFeatured ? '#ffffff' : regTextColor,
|
2026-04-05 18:31:16 -07:00
|
|
|
lineHeight: '1',
|
|
|
|
|
}}>
|
|
|
|
|
{plan.price}
|
|
|
|
|
</span>
|
|
|
|
|
<span style={{
|
|
|
|
|
fontSize: '16px',
|
2026-07-14 06:47:58 -07:00
|
|
|
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor,
|
2026-04-05 18:31:16 -07:00
|
|
|
}}>
|
|
|
|
|
{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',
|
2026-07-14 06:47:58 -07:00
|
|
|
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor,
|
2026-04-05 18:31:16 -07:00
|
|
|
display: 'flex',
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
gap: '8px',
|
|
|
|
|
}}>
|
2026-07-14 06:47:58 -07:00
|
|
|
<span style={{ color: plan.isFeatured ? '#bbf7d0' : regCheckColor, fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
|
2026-04-05 18:31:16 -07:00
|
|
|
{feature}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
<a
|
|
|
|
|
href={plan.buttonHref}
|
|
|
|
|
onClick={(e) => e.preventDefault()}
|
|
|
|
|
style={{
|
|
|
|
|
marginTop: 'auto',
|
|
|
|
|
display: 'inline-block',
|
|
|
|
|
padding: '14px 32px',
|
2026-07-14 06:47:58 -07:00
|
|
|
backgroundColor: plan.isFeatured ? '#ffffff' : regBtnBg,
|
|
|
|
|
color: plan.isFeatured ? featuredBg : regBtnColor,
|
2026-04-05 18:31:16 -07:00
|
|
|
textDecoration: 'none',
|
|
|
|
|
borderRadius: '8px',
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
fontSize: '14px',
|
|
|
|
|
width: '100%',
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{plan.buttonText}
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- Craft config ---------- */
|
|
|
|
|
|
|
|
|
|
PricingTable.craft = {
|
|
|
|
|
displayName: 'Pricing Table',
|
|
|
|
|
props: {
|
|
|
|
|
plans: defaultPlans,
|
|
|
|
|
style: { backgroundColor: '#ffffff' },
|
|
|
|
|
featuredBg: '#3b82f6',
|
|
|
|
|
bulletType: 'check',
|
2026-05-25 12:43:28 -07:00
|
|
|
anchorId: '',
|
2026-07-14 06:47:58 -07:00
|
|
|
cardBg: '',
|
|
|
|
|
textColor: '',
|
|
|
|
|
subColor: '',
|
|
|
|
|
featColor: '',
|
|
|
|
|
checkColor: '',
|
|
|
|
|
btnBg: '',
|
|
|
|
|
btnColor: '',
|
|
|
|
|
animation: '',
|
|
|
|
|
animationDelay: '',
|
|
|
|
|
hideOnDesktop: false,
|
|
|
|
|
hideOnTablet: false,
|
|
|
|
|
hideOnMobile: false,
|
2026-04-05 18:31:16 -07:00
|
|
|
},
|
|
|
|
|
rules: {
|
|
|
|
|
canDrag: () => true,
|
|
|
|
|
canMoveIn: () => false,
|
|
|
|
|
canMoveOut: () => true,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- HTML export ---------- */
|
|
|
|
|
|
|
|
|
|
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
|
|
|
|
|
const bulletType = props.bulletType || 'check';
|
|
|
|
|
const sectionStyle = cssPropsToString({
|
|
|
|
|
padding: '80px 20px',
|
|
|
|
|
...props.style,
|
|
|
|
|
});
|
2026-07-12 11:49:10 -07:00
|
|
|
const idAttr = props.anchorId ? ` id="${escapeAttr(props.anchorId)}"` : '';
|
2026-04-05 18:31:16 -07:00
|
|
|
const plans = props.plans || defaultPlans;
|
2026-07-12 15:56:25 -07:00
|
|
|
// Sanitized -- featuredBg is a raw string-interpolation sink below (drives
|
|
|
|
|
// cardBg/btnBg/btnColor, all raw-interpolated into style="...").
|
|
|
|
|
const featuredBg = cssValue(props.featuredBg) || '#3b82f6';
|
2026-07-14 06:47:58 -07:00
|
|
|
// Sanitized -- regular (non-featured) card color overrides, all raw
|
|
|
|
|
// string-interpolation sinks into style="..." below. Each falls back to
|
|
|
|
|
// the exact literal the card was previously hard-coded to (or, for the
|
|
|
|
|
// button, to featuredBg) when unset, so unmodified pricing tables render
|
|
|
|
|
// identically to before these props existed.
|
|
|
|
|
const regCardBg = cssValue(props.cardBg) || '#ffffff';
|
|
|
|
|
const regTextColor = cssValue(props.textColor) || '#18181b';
|
|
|
|
|
const regSubColor = cssValue(props.subColor) || '#64748b';
|
|
|
|
|
const regFeatColor = cssValue(props.featColor) || '#4b5563';
|
|
|
|
|
const regCheckColor = cssValue(props.checkColor) || '#10b981';
|
|
|
|
|
const regBtnBg = cssValue(props.btnBg) || featuredBg;
|
|
|
|
|
const regBtnColor = cssValue(props.btnColor) || '#ffffff';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
const cards = plans.map((plan) => {
|
2026-07-14 06:47:58 -07:00
|
|
|
const cardBg = plan.isFeatured ? featuredBg : regCardBg;
|
2026-04-05 18:31:16 -07:00
|
|
|
const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;';
|
2026-07-14 06:47:58 -07:00
|
|
|
const textColor = plan.isFeatured ? '#ffffff' : regTextColor;
|
|
|
|
|
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : regSubColor;
|
|
|
|
|
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : regFeatColor;
|
|
|
|
|
const checkColor = plan.isFeatured ? '#bbf7d0' : regCheckColor;
|
|
|
|
|
const btnBg = plan.isFeatured ? '#ffffff' : regBtnBg;
|
|
|
|
|
const btnColor = plan.isFeatured ? featuredBg : regBtnColor;
|
2026-04-05 18:31:16 -07:00
|
|
|
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) =>
|
2026-07-12 11:49:10 -07:00
|
|
|
`<li style="font-size:14px;color:${featColor};display:flex;align-items:center;gap:8px"><span style="color:${checkColor};font-weight:700">${bulletChars[bulletType] || '✓'}</span>${escapeHtml(f)}</li>`
|
2026-04-05 18:31:16 -07:00
|
|
|
).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}
|
2026-07-12 11:49:10 -07:00
|
|
|
<h3 style="font-size:20px;font-weight:600;color:${textColor};margin-bottom:8px;${plan.isFeatured ? 'margin-top:8px;' : ''}">${escapeHtml(plan.name)}</h3>
|
2026-04-05 18:31:16 -07:00
|
|
|
<div style="margin-bottom:24px">
|
2026-07-12 11:49:10 -07:00
|
|
|
<span style="font-size:48px;font-weight:700;color:${textColor};line-height:1">${escapeHtml(plan.price)}</span>
|
|
|
|
|
<span style="font-size:16px;color:${subColor}">${escapeHtml(plan.period)}</span>
|
2026-04-05 18:31:16 -07:00
|
|
|
</div>
|
|
|
|
|
<ul style="list-style:none;padding:0;margin:0 0 32px 0;width:100%;display:flex;flex-direction:column;gap:12px">
|
|
|
|
|
${featuresHtml}
|
|
|
|
|
</ul>
|
2026-07-12 12:05:57 -07:00
|
|
|
<a href="${escapeAttr(safeUrl(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">${escapeHtml(plan.buttonText)}</a>
|
2026-04-05 18:31:16 -07:00
|
|
|
</div>`;
|
|
|
|
|
}).join('\n ');
|
|
|
|
|
|
|
|
|
|
return {
|
2026-05-25 12:43:28 -07:00
|
|
|
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
2026-04-05 18:31:16 -07:00
|
|
|
<div style="max-width:1100px;margin:0 auto;display:flex;gap:24px;justify-content:center;align-items:stretch;flex-wrap:wrap">
|
|
|
|
|
${cards}
|
|
|
|
|
</div>
|
|
|
|
|
</section>`,
|
|
|
|
|
};
|
|
|
|
|
};
|