| 1 |
(function () { |
| 2 |
'use strict'; |
| 3 |
|
| 4 |
var _typoKeys = { |
| 5 |
family:'font-family', weight:'font-weight', style:'font-style', |
| 6 |
decoration:'text-decoration', transform:'text-transform', |
| 7 |
sizeDesktop:'font-size-d', sizeTablet:'font-size-t', sizeMobile:'font-size-m', |
| 8 |
lineHeightDesktop:'line-height-d', lineHeightTablet:'line-height-t', lineHeightMobile:'line-height-m', |
| 9 |
letterSpacingDesktop:'letter-spacing-d', letterSpacingTablet:'letter-spacing-t', letterSpacingMobile:'letter-spacing-m', |
| 10 |
wordSpacingDesktop:'word-spacing-d', wordSpacingTablet:'word-spacing-t', wordSpacingMobile:'word-spacing-m' |
| 11 |
}; |
| 12 |
function typoCssVarsForEl(el, obj, prefix) { |
| 13 |
if (!obj || typeof obj !== 'object') return; |
| 14 |
Object.keys(_typoKeys).forEach(function (k) { |
| 15 |
var v = obj[k]; |
| 16 |
if (v === undefined || v === '' || v === null) return; |
| 17 |
if (k === 'sizeDesktop' || k === 'sizeTablet' || k === 'sizeMobile') v = v + (obj.sizeUnit || 'px'); |
| 18 |
else if (k === 'lineHeightDesktop' || k === 'lineHeightTablet' || k === 'lineHeightMobile') v = v + (obj.lineHeightUnit || ''); |
| 19 |
else if (k === 'letterSpacingDesktop' || k === 'letterSpacingTablet' || k === 'letterSpacingMobile') v = v + (obj.letterSpacingUnit || 'px'); |
| 20 |
else if (k === 'wordSpacingDesktop' || k === 'wordSpacingTablet' || k === 'wordSpacingMobile') v = v + (obj.wordSpacingUnit || 'px'); |
| 21 |
el.style.setProperty(prefix + _typoKeys[k], String(v)); |
| 22 |
}); |
| 23 |
} |
| 24 |
|
| 25 |
var TERM_OPTIONS = [ |
| 26 |
{ value: 10, label: '10 years' }, { value: 15, label: '15 years' }, |
| 27 |
{ value: 20, label: '20 years' }, { value: 25, label: '25 years' }, |
| 28 |
{ value: 30, label: '30 years' }, |
| 29 |
]; |
| 30 |
|
| 31 |
function calcMortgage(price, downPct, rate, years) { |
| 32 |
var down = price * downPct / 100; |
| 33 |
var principal = price - down; |
| 34 |
var n = years * 12; |
| 35 |
var r = rate / 100 / 12; |
| 36 |
var monthly = r === 0 ? principal / n : principal * r * Math.pow(1+r,n) / (Math.pow(1+r,n)-1); |
| 37 |
var totalPaid = monthly * n; |
| 38 |
var totalInterest = totalPaid - principal; |
| 39 |
var rows = [], balance = principal; |
| 40 |
for (var y = 1; y <= years; y++) { |
| 41 |
var yp = 0, yi = 0; |
| 42 |
for (var m = 0; m < 12; m++) { |
| 43 |
var ip = balance * r, pp = monthly - ip; |
| 44 |
yi += ip; yp += pp; balance -= pp; |
| 45 |
} |
| 46 |
rows.push({ year: y, principal: yp, interest: yi, balance: Math.max(0, balance) }); |
| 47 |
} |
| 48 |
return { monthly: monthly, totalPaid: totalPaid, totalInterest: totalInterest, principal: principal, down: down, rows: rows }; |
| 49 |
} |
| 50 |
|
| 51 |
function fmt(val, cur, pos) { |
| 52 |
return pos === 'after' ? Math.round(val).toLocaleString('en-US') + ' ' + cur : cur + Math.round(val).toLocaleString('en-US'); |
| 53 |
} |
| 54 |
|
| 55 |
function buildApp(app) { |
| 56 |
var opts = {}; |
| 57 |
try { opts = JSON.parse(app.getAttribute('data-opts') || '{}'); } catch (e) {} |
| 58 |
|
| 59 |
var accent = opts.accentColor || '#6c3fb5'; |
| 60 |
var cardBg = opts.cardBg || '#ffffff'; |
| 61 |
var resultBg = opts.resultBg || '#f5f3ff'; |
| 62 |
var resultBdr = opts.resultBorder || '#ede9fe'; |
| 63 |
var totalClr = opts.totalColor || accent; |
| 64 |
var priClr = opts.principalColor || '#3b82f6'; |
| 65 |
var intClr = opts.interestColor || '#ef4444'; |
| 66 |
var pmiClr = opts.pmiColor || '#f59e0b'; |
| 67 |
var lclr = opts.labelColor || '#374151'; |
| 68 |
var tableBg = opts.tableBg || '#fafafa'; |
| 69 |
var cRadius = (opts.cardRadius || 16) + 'px'; |
| 70 |
var iRadius = (opts.inputRadius || 8) + 'px'; |
| 71 |
var maxW = (opts.maxWidth || 680) + 'px'; |
| 72 |
var ptop = (opts.paddingTop || 60) + 'px'; |
| 73 |
var pbot = (opts.paddingBottom || 60) + 'px'; |
| 74 |
var cur = opts.currency || '$'; |
| 75 |
var curPos = opts.currencyPos || 'before'; |
| 76 |
var fmtM = function (v) { return fmt(v, cur, curPos); }; |
| 77 |
|
| 78 |
var state = { |
| 79 |
price: opts.defaultPrice || 350000, |
| 80 |
down: opts.defaultDown || 20, |
| 81 |
rate: opts.defaultRate || 6.5, |
| 82 |
term: opts.defaultTerm || 30, |
| 83 |
}; |
| 84 |
|
| 85 |
/* Card */ |
| 86 |
var card = document.createElement('div'); |
| 87 |
card.className = 'bkbg-mtg-card'; |
| 88 |
card.style.cssText = 'background:' + cardBg + ';border-radius:' + cRadius + ';max-width:' + maxW + ';margin:0 auto;padding:' + ptop + ' 32px ' + pbot; |
| 89 |
app.innerHTML = ''; |
| 90 |
app.appendChild(card); |
| 91 |
|
| 92 |
/* Title */ |
| 93 |
if (opts.showTitle !== false && opts.title) { |
| 94 |
var h2 = document.createElement('h2'); |
| 95 |
h2.className = 'bkbg-mtg-title'; |
| 96 |
h2.style.cssText = 'color:' + (opts.titleColor||'#1e1b4b') + ';margin:0 0 8px;text-align:center'; |
| 97 |
h2.textContent = opts.title; |
| 98 |
card.appendChild(h2); |
| 99 |
} |
| 100 |
if (opts.showSubtitle !== false && opts.subtitle) { |
| 101 |
var sub = document.createElement('p'); |
| 102 |
sub.className = 'bkbg-mtg-subtitle'; |
| 103 |
sub.style.cssText = 'color:' + (opts.subtitleColor||'#6b7280') + ';text-align:center;margin:0 0 24px'; |
| 104 |
sub.textContent = opts.subtitle; |
| 105 |
card.appendChild(sub); |
| 106 |
} |
| 107 |
|
| 108 |
/* Helpers */ |
| 109 |
var inpCSS = 'width:100%;padding:10px 12px;border:1.5px solid #e5e7eb;border-radius:' + iRadius + ';font-size:15px;box-sizing:border-box;outline:none;background:#fff;color:' + lclr; |
| 110 |
|
| 111 |
function mkLabel(txt) { |
| 112 |
var l = document.createElement('label'); |
| 113 |
l.style.cssText = 'font-size:13px;font-weight:600;color:' + lclr + ';display:block;margin-bottom:4px'; |
| 114 |
l.textContent = txt; |
| 115 |
return l; |
| 116 |
} |
| 117 |
function mkField(label, inp) { |
| 118 |
var d = document.createElement('div'); |
| 119 |
d.style.marginBottom = '14px'; |
| 120 |
d.appendChild(mkLabel(label)); |
| 121 |
d.appendChild(inp); |
| 122 |
return d; |
| 123 |
} |
| 124 |
function mkNum(val, min, max, step, onChange) { |
| 125 |
var i = document.createElement('input'); |
| 126 |
i.type = 'number'; i.value = val; i.min = min; i.max = max; i.step = step; i.style.cssText = inpCSS; |
| 127 |
i.addEventListener('input', function () { onChange(parseFloat(i.value)||0); }); |
| 128 |
return i; |
| 129 |
} |
| 130 |
|
| 131 |
/* Inputs */ |
| 132 |
card.appendChild(mkField('Home Price (' + cur + ')', mkNum(state.price, 10000, 10000000, 1000, function(v){state.price=v; update();}))); |
| 133 |
card.appendChild(mkField('Down Payment (%)', mkNum(state.down, 0, 99, 0.5, function(v){state.down=Math.min(99,Math.max(0,v)); update();}))); |
| 134 |
card.appendChild(mkField('Annual Interest Rate (%)', mkNum(state.rate, 0.1, 30, 0.1, function(v){state.rate=v; update();}))); |
| 135 |
|
| 136 |
var termSel = document.createElement('select'); |
| 137 |
termSel.style.cssText = inpCSS + ';appearance:auto'; |
| 138 |
TERM_OPTIONS.forEach(function (o) { |
| 139 |
var opt = document.createElement('option'); |
| 140 |
opt.value = o.value; opt.textContent = o.label; |
| 141 |
if (o.value === state.term) opt.selected = true; |
| 142 |
termSel.appendChild(opt); |
| 143 |
}); |
| 144 |
termSel.addEventListener('change', function () { state.term = parseInt(termSel.value); update(); }); |
| 145 |
card.appendChild(mkField('Loan Term', termSel)); |
| 146 |
|
| 147 |
/* Result */ |
| 148 |
var resultBox = document.createElement('div'); |
| 149 |
resultBox.className = 'bkbg-mtg-result'; |
| 150 |
resultBox.style.cssText = 'background:' + resultBg + ';border:1.5px solid ' + resultBdr + ';border-radius:' + cRadius + ';padding:24px;text-align:center;margin-bottom:20px'; |
| 151 |
var rLbl = document.createElement('div'); |
| 152 |
rLbl.style.cssText = 'font-size:13px;color:#9ca3af;text-transform:uppercase;letter-spacing:0.06em;margin-bottom:4px'; |
| 153 |
rLbl.textContent = 'Monthly Payment'; |
| 154 |
var rNum = document.createElement('div'); |
| 155 |
rNum.className = 'bkbg-mtg-monthly'; |
| 156 |
rNum.style.cssText = 'color:' + totalClr + ';margin-bottom:4px'; |
| 157 |
var rSub = document.createElement('div'); |
| 158 |
rSub.style.cssText = 'font-size:13px;color:#9ca3af'; |
| 159 |
var rPMI = document.createElement('div'); |
| 160 |
rPMI.className = 'bkbg-mtg-pmi-note'; |
| 161 |
rPMI.style.cssText = 'margin-top:8px;font-size:13px;color:' + pmiClr; |
| 162 |
resultBox.appendChild(rLbl); resultBox.appendChild(rNum); resultBox.appendChild(rSub); resultBox.appendChild(rPMI); |
| 163 |
card.appendChild(resultBox); |
| 164 |
|
| 165 |
/* Breakdown */ |
| 166 |
var breakdown, stat1, stat2, stat3; |
| 167 |
if (opts.showBreakdown !== false) { |
| 168 |
breakdown = document.createElement('div'); |
| 169 |
breakdown.className = 'bkbg-mtg-breakdown'; |
| 170 |
breakdown.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:20px'; |
| 171 |
function mkStat(lbl, color) { |
| 172 |
var d = document.createElement('div'); |
| 173 |
d.style.cssText = 'text-align:center;background:#fff;border-radius:10px;padding:14px 12px;border:1.5px solid #e5e7eb'; |
| 174 |
d.innerHTML = '<div style="font-size:11px;text-transform:uppercase;letter-spacing:0.05em;color:#9ca3af;margin-bottom:4px">' + lbl + '</div>' + |
| 175 |
'<div style="font-size:18px;font-weight:800;color:' + color + '">—</div>'; |
| 176 |
return d; |
| 177 |
} |
| 178 |
stat1 = mkStat('Loan Amount', priClr); |
| 179 |
stat2 = mkStat('Total Interest', intClr); |
| 180 |
stat3 = mkStat('Total Cost', totalClr); |
| 181 |
breakdown.appendChild(stat1); breakdown.appendChild(stat2); breakdown.appendChild(stat3); |
| 182 |
card.appendChild(breakdown); |
| 183 |
} |
| 184 |
|
| 185 |
/* LTV */ |
| 186 |
var ltvDiv; |
| 187 |
if (opts.showLTV !== false) { |
| 188 |
ltvDiv = document.createElement('div'); |
| 189 |
ltvDiv.className = 'bkbg-mtg-ltv'; |
| 190 |
ltvDiv.style.cssText = 'text-align:center;margin-bottom:20px;font-size:14px;color:' + lclr; |
| 191 |
card.appendChild(ltvDiv); |
| 192 |
} |
| 193 |
|
| 194 |
/* Year table */ |
| 195 |
var tableWrap; |
| 196 |
if (opts.showYearTable !== false) { |
| 197 |
tableWrap = document.createElement('div'); |
| 198 |
tableWrap.className = 'bkbg-mtg-table-wrap'; |
| 199 |
tableWrap.style.overflowX = 'auto'; |
| 200 |
card.appendChild(tableWrap); |
| 201 |
} |
| 202 |
|
| 203 |
/* Update */ |
| 204 |
function update() { |
| 205 |
var calc = calcMortgage(state.price, state.down, state.rate, state.term); |
| 206 |
var ltv = Math.round(calc.principal / state.price * 100); |
| 207 |
var showPMI = opts.showPMI !== false && state.down < 20; |
| 208 |
var pmi = showPMI ? (calc.principal * (opts.pmiRate || 0.5) / 100 / 12) : 0; |
| 209 |
var total = calc.monthly + pmi; |
| 210 |
|
| 211 |
rNum.textContent = fmtM(total); |
| 212 |
rSub.textContent = 'per month' + (showPMI ? ' (incl. PMI)' : ''); |
| 213 |
rPMI.style.display = showPMI ? '' : 'none'; |
| 214 |
if (showPMI) rPMI.textContent = 'PMI: ' + fmtM(pmi) + '/mo (down payment < 20%)'; |
| 215 |
|
| 216 |
if (breakdown) { |
| 217 |
stat1.querySelector('div:last-child').textContent = fmtM(calc.principal); |
| 218 |
stat2.querySelector('div:last-child').textContent = fmtM(calc.totalInterest); |
| 219 |
stat3.querySelector('div:last-child').textContent = fmtM(calc.totalPaid); |
| 220 |
} |
| 221 |
|
| 222 |
if (ltvDiv) { |
| 223 |
var ltvColor = ltv >= 80 ? intClr : priClr; |
| 224 |
ltvDiv.innerHTML = 'Loan-to-Value (LTV): <strong style="color:' + ltvColor + '">' + ltv + '%</strong>' + |
| 225 |
(ltv >= 80 ? ' — PMI may apply' : ' — no PMI required'); |
| 226 |
} |
| 227 |
|
| 228 |
if (tableWrap) { |
| 229 |
tableWrap.innerHTML = ''; |
| 230 |
var maxRows = opts.tableRows || 10; |
| 231 |
var table = document.createElement('table'); |
| 232 |
table.className = 'bkbg-mtg-table'; |
| 233 |
table.style.cssText = 'width:100%;border-collapse:collapse;font-size:13px'; |
| 234 |
var thead = document.createElement('thead'); |
| 235 |
var hrow = document.createElement('tr'); |
| 236 |
hrow.style.background = tableBg; |
| 237 |
['Year','Balance','Principal Paid','Interest Paid'].forEach(function (h, i) { |
| 238 |
var th = document.createElement('th'); |
| 239 |
th.textContent = h; |
| 240 |
th.style.cssText = 'padding:10px 8px;font-weight:700;color:' + lclr + ';border-bottom:2px solid #e5e7eb;text-align:' + (i===0?'left':'right'); |
| 241 |
hrow.appendChild(th); |
| 242 |
}); |
| 243 |
thead.appendChild(hrow); |
| 244 |
table.appendChild(thead); |
| 245 |
var tbody = document.createElement('tbody'); |
| 246 |
calc.rows.slice(0, maxRows).forEach(function (row, idx) { |
| 247 |
var tr = document.createElement('tr'); |
| 248 |
var last = idx === Math.min(calc.rows.length, maxRows) - 1; |
| 249 |
tr.style.cssText = 'background:' + (idx%2===0?'#fff':tableBg) + ';border-bottom:1px solid #f0f0f0;' + (last?'font-weight:700':''); |
| 250 |
var td0 = document.createElement('td'); td0.style.cssText = 'padding:9px 8px;color:' + lclr; td0.textContent = 'Year ' + row.year; |
| 251 |
var td1 = document.createElement('td'); td1.style.cssText = 'padding:9px 8px;text-align:right;color:' + totalClr; td1.textContent = fmtM(row.balance); |
| 252 |
var td2 = document.createElement('td'); td2.style.cssText = 'padding:9px 8px;text-align:right;color:' + priClr; td2.textContent = fmtM(row.principal); |
| 253 |
var td3 = document.createElement('td'); td3.style.cssText = 'padding:9px 8px;text-align:right;color:' + intClr; td3.textContent = fmtM(row.interest); |
| 254 |
tr.appendChild(td0); tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); |
| 255 |
tbody.appendChild(tr); |
| 256 |
}); |
| 257 |
table.appendChild(tbody); |
| 258 |
tableWrap.appendChild(table); |
| 259 |
} |
| 260 |
} |
| 261 |
|
| 262 |
/* Typography CSS vars */ |
| 263 |
typoCssVarsForEl(app, opts.titleTypo, '--bkbg-mtg-tt-'); |
| 264 |
typoCssVarsForEl(app, opts.subtitleTypo, '--bkbg-mtg-st-'); |
| 265 |
typoCssVarsForEl(app, opts.resultTypo, '--bkbg-mtg-rs-'); |
| 266 |
|
| 267 |
update(); |
| 268 |
} |
| 269 |
|
| 270 |
document.querySelectorAll('.bkbg-mtg-app').forEach(buildApp); |
| 271 |
})(); |
| 272 |
|