| 1 |
( function () { |
| 2 |
var el = wp.element.createElement; |
| 3 |
var useState = wp.element.useState; |
| 4 |
var Fragment = wp.element.Fragment; |
| 5 |
var registerBlockType = wp.blocks.registerBlockType; |
| 6 |
var __ = wp.i18n.__; |
| 7 |
var InspectorControls = wp.blockEditor.InspectorControls; |
| 8 |
var PanelColorSettings = wp.blockEditor.PanelColorSettings; |
| 9 |
var useBlockProps = wp.blockEditor.useBlockProps; |
| 10 |
var PanelBody = wp.components.PanelBody; |
| 11 |
var RangeControl = wp.components.RangeControl; |
| 12 |
var SelectControl = wp.components.SelectControl; |
| 13 |
var TextControl = wp.components.TextControl; |
| 14 |
var ToggleControl = wp.components.ToggleControl; |
| 15 |
|
| 16 |
var _dpcTC, _dpcTV; |
| 17 |
function _tc() { return _dpcTC || (_dpcTC = window.bkbgTypographyControl); } |
| 18 |
function _tv(t, p) { return (_dpcTV || (_dpcTV = window.bkbgTypoCssVars)) ? _dpcTV(t, p) : {}; } |
| 19 |
|
| 20 |
var DEFAULT_DEBTS = [ |
| 21 |
{id:1, name:'Credit Card A', balance:5000, rate:22.99, minPay:100}, |
| 22 |
{id:2, name:'Credit Card B', balance:2800, rate:17.49, minPay:60}, |
| 23 |
{id:3, name:'Personal Loan', balance:8500, rate:11.5, minPay:180} |
| 24 |
]; |
| 25 |
var nextId = 10; |
| 26 |
|
| 27 |
function calcPayoff(debts, extraMonthly, strategy) { |
| 28 |
if (!debts.length) return {months:0, totalInterest:0, schedule:[]}; |
| 29 |
var list = debts.map(function(d){ return {name:d.name, balance:parseFloat(d.balance)||0, rate:(parseFloat(d.rate)||0)/100/12, min:parseFloat(d.minPay)||0}; }); |
| 30 |
list = list.filter(function(d){ return d.balance > 0; }); |
| 31 |
if (!list.length) return {months:0, totalInterest:0, schedule:[]}; |
| 32 |
|
| 33 |
var minTotal = list.reduce(function(s,d){ return s+d.min; }, 0); |
| 34 |
var budget = minTotal + (extraMonthly||0); |
| 35 |
|
| 36 |
// sort by strategy |
| 37 |
var sorted = list.slice().sort(function(a,b){ |
| 38 |
return strategy === 'avalanche' ? b.rate - a.rate : a.balance - b.balance; |
| 39 |
}); |
| 40 |
|
| 41 |
var schedule = []; |
| 42 |
var totalInterest = 0; |
| 43 |
var months = 0; |
| 44 |
var MAX = 600; |
| 45 |
|
| 46 |
while (sorted.some(function(d){ return d.balance > 0; }) && months < MAX) { |
| 47 |
months++; |
| 48 |
var available = budget; |
| 49 |
var row = {month: months, debts:[], totalBalance:0}; |
| 50 |
|
| 51 |
// pay minimums first |
| 52 |
sorted.forEach(function(d){ |
| 53 |
if (d.balance <= 0) return; |
| 54 |
var interest = d.balance * d.rate; |
| 55 |
totalInterest += interest; |
| 56 |
d.balance += interest; |
| 57 |
var pay = Math.min(d.min, d.balance); |
| 58 |
d.balance -= pay; |
| 59 |
available -= pay; |
| 60 |
if (d.balance < 0.01) d.balance = 0; |
| 61 |
}); |
| 62 |
|
| 63 |
// apply extra to priority debt |
| 64 |
for (var i=0; i<sorted.length; i++) { |
| 65 |
if (sorted[i].balance > 0 && available > 0) { |
| 66 |
var extra = Math.min(available, sorted[i].balance); |
| 67 |
sorted[i].balance -= extra; |
| 68 |
available -= extra; |
| 69 |
if (sorted[i].balance < 0.01) sorted[i].balance = 0; |
| 70 |
} |
| 71 |
} |
| 72 |
|
| 73 |
sorted.forEach(function(d){ row.totalBalance += d.balance; }); |
| 74 |
row.totalInterest = Math.round(totalInterest*100)/100; |
| 75 |
if (schedule.length < 120) schedule.push(row); |
| 76 |
} |
| 77 |
|
| 78 |
return {months: months, totalInterest: Math.round(totalInterest*100)/100, schedule: schedule}; |
| 79 |
} |
| 80 |
|
| 81 |
function fmt(n, cur) { return (cur||'$') + Math.abs(n).toLocaleString('en', {minimumFractionDigits:2,maximumFractionDigits:2}); } |
| 82 |
|
| 83 |
function DPCPreview(props) { |
| 84 |
var a = props.attributes; |
| 85 |
var accent = a.accentColor || '#6c3fb5'; |
| 86 |
var cur = a.currency || '$'; |
| 87 |
|
| 88 |
var _debts = useState(DEFAULT_DEBTS.map(function(d){ return Object.assign({},d); })); |
| 89 |
var debts = _debts[0]; var setDebts = _debts[1]; |
| 90 |
|
| 91 |
var _extra = useState(200); |
| 92 |
var extra = _extra[0]; var setExtra = _extra[1]; |
| 93 |
|
| 94 |
var _strat = useState(a.defaultStrategy || 'avalanche'); |
| 95 |
var strat = _strat[0]; var setStrat = _strat[1]; |
| 96 |
|
| 97 |
var avResult = calcPayoff(debts, extra, 'avalanche'); |
| 98 |
var sbResult = calcPayoff(debts, extra, 'snowball'); |
| 99 |
var curResult = strat === 'avalanche' ? avResult : sbResult; |
| 100 |
|
| 101 |
var inpStyle = {padding:'6px 8px',border:'1.5px solid '+(a.inputBorder||'#e5e7eb'),borderRadius:(a.inputRadius||8)+'px',fontSize:'14px',fontFamily:'inherit',outline:'none',width:'100%',boxSizing:'border-box'}; |
| 102 |
var lblStyle = {fontSize:'12px',fontWeight:600,color:a.labelColor||'#374151',display:'block',marginBottom:'3px'}; |
| 103 |
|
| 104 |
function updateDebt(id, field, val) { |
| 105 |
setDebts(debts.map(function(d){ return d.id===id ? Object.assign({},d,{[field]:val}) : d; })); |
| 106 |
} |
| 107 |
|
| 108 |
return el('div',{style:{paddingTop:(a.paddingTop||60)+'px',paddingBottom:(a.paddingBottom||60)+'px',background:a.sectionBg||undefined}}, |
| 109 |
el('div',{style:{background:a.cardBg||'#fff',borderRadius:(a.cardRadius||16)+'px',padding:'28px',maxWidth:(a.maxWidth||700)+'px',margin:'0 auto',boxShadow:'0 4px 24px rgba(0,0,0,.09)'}}, |
| 110 |
|
| 111 |
(a.showTitle||a.showSubtitle) && el('div',{style:{marginBottom:'20px'}}, |
| 112 |
a.showTitle && el('div',{className:'bkbg-dpc-title',style:{color:a.titleColor,marginBottom:'4px'}},(a.title)), |
| 113 |
a.showSubtitle && el('div',{className:'bkbg-dpc-subtitle',style:{color:a.subtitleColor}},(a.subtitle)) |
| 114 |
), |
| 115 |
|
| 116 |
// Strategy tabs |
| 117 |
el('div',{style:{display:'flex',gap:'8px',marginBottom:'20px'}}, |
| 118 |
[{id:'avalanche',label:'⬇ Avalanche (highest rate first)',color:a.avalancheColor||'#8b5cf6'}, |
| 119 |
{id:'snowball', label:'⛄ Snowball (lowest balance first)',color:a.snowballColor||'#3b82f6'}].map(function(s){ |
| 120 |
var active = strat===s.id; |
| 121 |
return el('button',{key:s.id,onClick:function(){setStrat(s.id);},style:{flex:1,padding:'8px 12px',border:'2px solid '+(active?s.color:'#e5e7eb'),borderRadius:'8px',background:active?s.color+'18':'#fff',color:active?s.color:'#6b7280',fontWeight:active?700:500,cursor:'pointer',fontSize:'13px',fontFamily:'inherit',transition:'all .2s'}},s.label); |
| 122 |
}) |
| 123 |
), |
| 124 |
|
| 125 |
// Debt rows header |
| 126 |
el('div',{style:{display:'grid',gridTemplateColumns:'2fr 1fr 1fr 1fr 36px',gap:'6px',padding:'4px 8px',marginBottom:'4px'}}, |
| 127 |
['Name','Balance','APR %','Min Pay',''].map(function(h){ |
| 128 |
return el('div',{key:h,style:lblStyle},h); |
| 129 |
}) |
| 130 |
), |
| 131 |
|
| 132 |
debts.map(function(d){ |
| 133 |
return el('div',{key:d.id,style:{display:'grid',gridTemplateColumns:'2fr 1fr 1fr 1fr 36px',gap:'6px',alignItems:'center',background:a.rowBg||'#f9fafb',border:'1px solid '+(a.rowBorder||'#e5e7eb'),borderRadius:'8px',padding:'7px 8px',marginBottom:'6px'}}, |
| 134 |
el('input',{type:'text',value:d.name,placeholder:'Debt name',style:inpStyle,onChange:function(e){updateDebt(d.id,'name',e.target.value);}}), |
| 135 |
el('input',{type:'number',value:d.balance,min:0,style:inpStyle,onChange:function(e){updateDebt(d.id,'balance',e.target.value);}}), |
| 136 |
el('input',{type:'number',value:d.rate,min:0,max:100,step:0.01,style:inpStyle,onChange:function(e){updateDebt(d.id,'rate',e.target.value);}}), |
| 137 |
el('input',{type:'number',value:d.minPay,min:0,style:inpStyle,onChange:function(e){updateDebt(d.id,'minPay',e.target.value);}}), |
| 138 |
el('button',{onClick:function(){setDebts(debts.filter(function(x){return x.id!==d.id;}));},style:{border:'none',background:'#fee2e2',color:'#ef4444',borderRadius:'6px',cursor:'pointer',padding:'4px 8px',fontWeight:700,fontSize:'14px',fontFamily:'inherit',width:'36px',height:'32px'}},'×') |
| 139 |
); |
| 140 |
}), |
| 141 |
|
| 142 |
el('div',{style:{display:'flex',gap:'12px',marginTop:'8px',marginBottom:'20px',flexWrap:'wrap',alignItems:'flex-end'}}, |
| 143 |
el('button',{onClick:function(){nextId++;setDebts(debts.concat({id:nextId,name:'New Debt',balance:1000,rate:15,minPay:25}));},style:{padding:'8px 16px',background:accent,color:'#fff',border:'none',borderRadius:'8px',fontWeight:700,cursor:'pointer',fontSize:'13px',fontFamily:'inherit'}},'+ Add Debt'), |
| 144 |
el('div',{style:{flex:1,minWidth:'160px'}}, |
| 145 |
el('label',{style:lblStyle},'Extra Monthly Payment ('+cur+')'), |
| 146 |
el('input',{type:'number',value:extra,min:0,style:{...inpStyle,maxWidth:'180px'},onChange:function(e){setExtra(parseFloat(e.target.value)||0);}}) |
| 147 |
) |
| 148 |
), |
| 149 |
|
| 150 |
// Results |
| 151 |
el('div',{style:{background:a.resultBg||'#f5f3ff',border:'1px solid '+(a.resultBorder||'#ede9fe'),borderRadius:'12px',padding:'18px',marginBottom:'16px'}}, |
| 152 |
el('div',{style:{fontSize:'13px',fontWeight:700,color:strat==='avalanche'?(a.avalancheColor||'#8b5cf6'):(a.snowballColor||'#3b82f6'),textTransform:'uppercase',letterSpacing:'.05em',marginBottom:'12px'}}, |
| 153 |
(strat==='avalanche'?'Avalanche':'Snowball')+' Strategy Results' |
| 154 |
), |
| 155 |
el('div',{style:{display:'grid',gridTemplateColumns:'repeat(3,1fr)',gap:'12px'}}, |
| 156 |
[{label:'Months to Payoff',val:curResult.months+' mo'}, |
| 157 |
{label:'Years to Payoff', val:(curResult.months/12).toFixed(1)+' yr'}, |
| 158 |
{label:'Total Interest', val:fmt(curResult.totalInterest,cur)} |
| 159 |
].map(function(item){ |
| 160 |
return el('div',{key:item.label,style:{textAlign:'center',background:'#fff',borderRadius:'8px',padding:'12px 8px'}}, |
| 161 |
el('div',{style:{fontSize:(a.resultNumFontSize||22)+'px',fontWeight:800,color:accent}},(item.val)), |
| 162 |
el('div',{style:{fontSize:'12px',color:'#6b7280',marginTop:'2px'}},(item.label)) |
| 163 |
); |
| 164 |
}) |
| 165 |
) |
| 166 |
), |
| 167 |
|
| 168 |
// Comparison |
| 169 |
a.showComparison && el('div',{style:{background:'#fff',border:'1px solid #e5e7eb',borderRadius:'12px',padding:'16px',marginBottom:'16px'}}, |
| 170 |
el('div',{style:{fontSize:'13px',fontWeight:700,color:'#374151',textTransform:'uppercase',letterSpacing:'.05em',marginBottom:'12px'}},'Strategy Comparison'), |
| 171 |
el('div',{style:{display:'grid',gridTemplateColumns:'1fr 1fr',gap:'12px'}}, |
| 172 |
[{label:'Avalanche',res:avResult,color:a.avalancheColor||'#8b5cf6'}, |
| 173 |
{label:'Snowball', res:sbResult, color:a.snowballColor||'#3b82f6'}].map(function(s){ |
| 174 |
return el('div',{key:s.label,style:{background:s.color+'0d',border:'2px solid '+s.color+'44',borderRadius:'10px',padding:'12px',textAlign:'center'}}, |
| 175 |
el('div',{style:{color:s.color,fontWeight:800,fontSize:(a.stratLabelFontSize||15)+'px',marginBottom:'4px'}},s.label), |
| 176 |
el('div',{style:{fontSize:'20px',fontWeight:800,color:'#1f2937'}},s.res.months+' mo'), |
| 177 |
el('div',{style:{fontSize:'13px',color:'#6b7280'}},'Interest: '+fmt(s.res.totalInterest,cur)) |
| 178 |
); |
| 179 |
}) |
| 180 |
), |
| 181 |
avResult.months > 0 && sbResult.months > 0 && el('div',{style:{marginTop:'12px',textAlign:'center',fontSize:'14px',color:'#374151',background:'#f9fafb',borderRadius:'8px',padding:'10px'}}, |
| 182 |
avResult.totalInterest <= sbResult.totalInterest |
| 183 |
? '💡 Avalanche saves '+fmt(sbResult.totalInterest-avResult.totalInterest,cur)+' in interest vs. Snowball' |
| 184 |
: '💡 Snowball saves '+fmt(avResult.totalInterest-sbResult.totalInterest,cur)+' in interest vs. Avalanche' |
| 185 |
) |
| 186 |
), |
| 187 |
|
| 188 |
// Schedule preview |
| 189 |
a.showSchedule && curResult.schedule.length > 0 && el('div',null, |
| 190 |
el('div',{style:{fontSize:'13px',fontWeight:700,color:'#374151',textTransform:'uppercase',letterSpacing:'.05em',marginBottom:'8px'}},'Payoff Schedule (first '+(a.scheduleRows||12)+' months)'), |
| 191 |
el('div',{style:{overflowX:'auto'}}, |
| 192 |
el('table',{style:{width:'100%',borderCollapse:'collapse',fontSize:(a.tableFontSize||13)+'px'}}, |
| 193 |
el('thead',null, |
| 194 |
el('tr',null, |
| 195 |
['Month','Remaining Balance','Total Interest Paid'].map(function(h){ |
| 196 |
return el('th',{key:h,style:{textAlign:'left',padding:'6px 10px',background:'#f3f4f6',color:'#374151',fontWeight:600,borderBottom:'2px solid #e5e7eb',whiteSpace:'nowrap'}},h); |
| 197 |
}) |
| 198 |
) |
| 199 |
), |
| 200 |
el('tbody',null, |
| 201 |
curResult.schedule.slice(0,a.scheduleRows||12).map(function(row,i){ |
| 202 |
return el('tr',{key:row.month,style:{background:i%2===0?'#fff':'#f9fafb'}}, |
| 203 |
el('td',{style:{padding:'6px 10px',color:'#374151',borderBottom:'1px solid #f3f4f6'}},row.month), |
| 204 |
el('td',{style:{padding:'6px 10px',color:'#374151',fontWeight:600,borderBottom:'1px solid #f3f4f6'}},fmt(row.totalBalance,cur)), |
| 205 |
el('td',{style:{padding:'6px 10px',color:'#ef4444',borderBottom:'1px solid #f3f4f6'}},fmt(row.totalInterest,cur)) |
| 206 |
); |
| 207 |
}) |
| 208 |
) |
| 209 |
) |
| 210 |
) |
| 211 |
) |
| 212 |
) |
| 213 |
); |
| 214 |
} |
| 215 |
|
| 216 |
registerBlockType('blockenberg/debt-payoff-calculator', { |
| 217 |
edit: function(props) { |
| 218 |
var a = props.attributes; var set = props.setAttributes; |
| 219 |
var blockProps = useBlockProps({ style: Object.assign( |
| 220 |
{ '--bkbg-dpc-ttl-fs': (a.titleSize || 28) + 'px', |
| 221 |
'--bkbg-dpc-sub-fs': (a.subtitleFontSize || 15) + 'px' }, |
| 222 |
_tv(a.typoTitle || {}, '--bkbg-dpc-ttl-'), |
| 223 |
_tv(a.typoSubtitle || {}, '--bkbg-dpc-sub-') |
| 224 |
) }); |
| 225 |
var colorSettings = [ |
| 226 |
{value:a.accentColor, onChange:function(v){set({accentColor:v});}, label:'Accent Color'}, |
| 227 |
{value:a.cardBg, onChange:function(v){set({cardBg:v});}, label:'Card Background'}, |
| 228 |
{value:a.avalancheColor, onChange:function(v){set({avalancheColor:v});}, label:'Avalanche Strategy Color'}, |
| 229 |
{value:a.snowballColor, onChange:function(v){set({snowballColor:v});}, label:'Snowball Strategy Color'}, |
| 230 |
{value:a.resultBg, onChange:function(v){set({resultBg:v});}, label:'Results Background'}, |
| 231 |
{value:a.resultBorder, onChange:function(v){set({resultBorder:v});}, label:'Results Border'}, |
| 232 |
{value:a.rowBg, onChange:function(v){set({rowBg:v});}, label:'Debt Row Background'}, |
| 233 |
{value:a.rowBorder, onChange:function(v){set({rowBorder:v});}, label:'Debt Row Border'}, |
| 234 |
{value:a.inputBorder, onChange:function(v){set({inputBorder:v});}, label:'Input Border'}, |
| 235 |
{value:a.labelColor, onChange:function(v){set({labelColor:v});}, label:'Label Color'}, |
| 236 |
{value:a.titleColor, onChange:function(v){set({titleColor:v});}, label:'Title Color'}, |
| 237 |
{value:a.subtitleColor, onChange:function(v){set({subtitleColor:v});}, label:'Subtitle Color'}, |
| 238 |
{value:a.sectionBg, onChange:function(v){set({sectionBg:v});}, label:'Section Background'} |
| 239 |
]; |
| 240 |
return el(Fragment,null, |
| 241 |
el(InspectorControls,null, |
| 242 |
el(PanelBody,{title:'Header',initialOpen:false}, |
| 243 |
el(ToggleControl,{__nextHasNoMarginBottom:true,label:'Show Title', checked:a.showTitle, onChange:function(v){set({showTitle:v});}}), |
| 244 |
el(ToggleControl,{__nextHasNoMarginBottom:true,label:'Show Subtitle',checked:a.showSubtitle,onChange:function(v){set({showSubtitle:v});}}), |
| 245 |
el(TextControl,{label:'Title', value:a.title, onChange:function(v){set({title:v});}}), |
| 246 |
el(TextControl,{label:'Subtitle',value:a.subtitle,onChange:function(v){set({subtitle:v});}}) |
| 247 |
), |
| 248 |
el(PanelBody,{title:'Calculator Settings',initialOpen:true}, |
| 249 |
el(TextControl,{label:'Currency Symbol',value:a.currency,onChange:function(v){set({currency:v});}}), |
| 250 |
el(SelectControl,{label:'Default Strategy',value:a.defaultStrategy,options:[{label:'Avalanche (highest rate first)',value:'avalanche'},{label:'Snowball (lowest balance first)',value:'snowball'}],onChange:function(v){set({defaultStrategy:v});}}), |
| 251 |
el(ToggleControl,{__nextHasNoMarginBottom:true,label:'Show Strategy Comparison',checked:a.showComparison,onChange:function(v){set({showComparison:v});}}), |
| 252 |
el(ToggleControl,{__nextHasNoMarginBottom:true,label:'Show Payment Schedule', checked:a.showSchedule, onChange:function(v){set({showSchedule:v});}}), |
| 253 |
el(RangeControl,{label:'Schedule Rows to Show',value:a.scheduleRows,min:6,max:60,step:6,onChange:function(v){set({scheduleRows:v});}}) |
| 254 |
), |
| 255 |
|
| 256 |
el( PanelBody, { title: __( 'Typography', 'blockenberg' ), initialOpen: false }, |
| 257 |
_tc() && _tc()({ label: __('Title typography', 'blockenberg'), value: a.typoTitle || {}, onChange: function (v) { set({ typoTitle: v }); } }), |
| 258 |
_tc() && _tc()({ label: __('Subtitle typography', 'blockenberg'), value: a.typoSubtitle || {}, onChange: function (v) { set({ typoSubtitle: v }); } }), |
| 259 |
el(RangeControl,{label:'Result number size (px)',value:a.resultNumFontSize,min:14,max:36,step:1,onChange:function(v){set({resultNumFontSize:v});}, __nextHasNoMarginBottom:true}), |
| 260 |
el(RangeControl,{label:'Strategy label size (px)',value:a.stratLabelFontSize,min:11,max:22,step:1,onChange:function(v){set({stratLabelFontSize:v});},__nextHasNoMarginBottom:true}), |
| 261 |
el(RangeControl,{label:'Table font size (px)',value:a.tableFontSize, min:10,max:18,step:1, onChange:function(v){set({tableFontSize:v});}, __nextHasNoMarginBottom:true}) |
| 262 |
), |
| 263 |
el(PanelColorSettings,{title:'Colors',initialOpen:false,colorSettings:colorSettings}), |
| 264 |
el(PanelBody,{title:'Sizing & Layout',initialOpen:false}, |
| 265 |
el(RangeControl,{label:'Card Border Radius',value:a.cardRadius, min:0, max:40,step:1, onChange:function(v){set({cardRadius:v});}}), |
| 266 |
el(RangeControl,{label:'Input Radius', value:a.inputRadius, min:0, max:20,step:1, onChange:function(v){set({inputRadius:v});}}), |
| 267 |
el(RangeControl,{label:'Max Width (px)', value:a.maxWidth, min:360,max:1100,step:10,onChange:function(v){set({maxWidth:v});}}), |
| 268 |
el(RangeControl,{label:'Padding Top', value:a.paddingTop, min:0, max:160,step:4, onChange:function(v){set({paddingTop:v});}}), |
| 269 |
el(RangeControl,{label:'Padding Bottom', value:a.paddingBottom,min:0,max:160,step:4, onChange:function(v){set({paddingBottom:v});}}) |
| 270 |
) |
| 271 |
), |
| 272 |
el('div',blockProps, el(DPCPreview,{attributes:a})) |
| 273 |
); |
| 274 |
}, |
| 275 |
save: function(props){ |
| 276 |
var a = props.attributes; |
| 277 |
return el('div',wp.blockEditor.useBlockProps.save(), |
| 278 |
el('div',{className:'bkbg-dpc-app','data-opts':JSON.stringify(a)})); |
| 279 |
} |
| 280 |
}); |
| 281 |
}() ); |
| 282 |
|