| 1 |
(function () { |
| 2 |
'use strict'; |
| 3 |
|
| 4 |
function calcBEP(fixedCosts, pricePerUnit, varCostUnit, unitsToSell) { |
| 5 |
var contribution = pricePerUnit - varCostUnit; |
| 6 |
var bepUnits = contribution > 0 ? fixedCosts / contribution : Infinity; |
| 7 |
var bepRevenue = isFinite(bepUnits) ? bepUnits * pricePerUnit : Infinity; |
| 8 |
var profitAtUnits = unitsToSell * contribution - fixedCosts; |
| 9 |
var marginRatio = pricePerUnit > 0 ? (contribution / pricePerUnit) * 100 : 0; |
| 10 |
return { |
| 11 |
bepUnits: Math.ceil(bepUnits), |
| 12 |
bepRevenue: bepRevenue, |
| 13 |
contribution: contribution, |
| 14 |
profitAtUnits: profitAtUnits, |
| 15 |
marginRatio: marginRatio, |
| 16 |
revenue: unitsToSell * pricePerUnit, |
| 17 |
totalCosts: fixedCosts + unitsToSell * varCostUnit |
| 18 |
}; |
| 19 |
} |
| 20 |
|
| 21 |
function fmtCur(n, cur) { return cur + Math.abs(n).toLocaleString('en-US', {minimumFractionDigits:0,maximumFractionDigits:0}); } |
| 22 |
|
| 23 |
function typoCssVarsForEl(typo, prefix, el) { |
| 24 |
if (!typo) return; |
| 25 |
if (typo.family) el.style.setProperty(prefix + 'font-family', "'" + typo.family + "', sans-serif"); |
| 26 |
if (typo.weight) el.style.setProperty(prefix + 'font-weight', typo.weight); |
| 27 |
if (typo.transform) el.style.setProperty(prefix + 'text-transform', typo.transform); |
| 28 |
if (typo.style) el.style.setProperty(prefix + 'font-style', typo.style); |
| 29 |
if (typo.decoration) el.style.setProperty(prefix + 'text-decoration', typo.decoration); |
| 30 |
var su = typo.sizeUnit || 'px'; |
| 31 |
if (typo.sizeDesktop !== '' && typo.sizeDesktop != null) el.style.setProperty(prefix + 'font-size-d', typo.sizeDesktop + su); |
| 32 |
if (typo.sizeTablet !== '' && typo.sizeTablet != null) el.style.setProperty(prefix + 'font-size-t', typo.sizeTablet + su); |
| 33 |
if (typo.sizeMobile !== '' && typo.sizeMobile != null) el.style.setProperty(prefix + 'font-size-m', typo.sizeMobile + su); |
| 34 |
var lhu = typo.lineHeightUnit || ''; |
| 35 |
if (typo.lineHeightDesktop !== '' && typo.lineHeightDesktop != null) el.style.setProperty(prefix + 'line-height-d', typo.lineHeightDesktop + lhu); |
| 36 |
if (typo.lineHeightTablet !== '' && typo.lineHeightTablet != null) el.style.setProperty(prefix + 'line-height-t', typo.lineHeightTablet + lhu); |
| 37 |
if (typo.lineHeightMobile !== '' && typo.lineHeightMobile != null) el.style.setProperty(prefix + 'line-height-m', typo.lineHeightMobile + lhu); |
| 38 |
var lsu = typo.letterSpacingUnit || 'px'; |
| 39 |
if (typo.letterSpacingDesktop !== '' && typo.letterSpacingDesktop != null) el.style.setProperty(prefix + 'letter-spacing-d', typo.letterSpacingDesktop + lsu); |
| 40 |
if (typo.letterSpacingTablet !== '' && typo.letterSpacingTablet != null) el.style.setProperty(prefix + 'letter-spacing-t', typo.letterSpacingTablet + lsu); |
| 41 |
if (typo.letterSpacingMobile !== '' && typo.letterSpacingMobile != null) el.style.setProperty(prefix + 'letter-spacing-m', typo.letterSpacingMobile + lsu); |
| 42 |
var wsu = typo.wordSpacingUnit || 'px'; |
| 43 |
if (typo.wordSpacingDesktop !== '' && typo.wordSpacingDesktop != null) el.style.setProperty(prefix + 'word-spacing-d', typo.wordSpacingDesktop + wsu); |
| 44 |
if (typo.wordSpacingTablet !== '' && typo.wordSpacingTablet != null) el.style.setProperty(prefix + 'word-spacing-t', typo.wordSpacingTablet + wsu); |
| 45 |
if (typo.wordSpacingMobile !== '' && typo.wordSpacingMobile != null) el.style.setProperty(prefix + 'word-spacing-m', typo.wordSpacingMobile + wsu); |
| 46 |
} |
| 47 |
|
| 48 |
function initBEP(app) { |
| 49 |
var opts; |
| 50 |
try { opts = JSON.parse(app.getAttribute('data-opts') || '{}'); } catch(e) { opts = {}; } |
| 51 |
|
| 52 |
var accent = opts.accentColor || '#6c3fb5'; |
| 53 |
var cardBg = opts.cardBg || '#ffffff'; |
| 54 |
var resultBg = opts.resultBg || accent; |
| 55 |
var resultColor = opts.resultColor || '#ffffff'; |
| 56 |
var profitColor = opts.profitColor || '#10b981'; |
| 57 |
var lossColor = opts.lossColor || '#ef4444'; |
| 58 |
var barBg = opts.barBg || '#e5e7eb'; |
| 59 |
var statBg = opts.statBg || '#f3f4f6'; |
| 60 |
var statBorder = opts.statBorder || '#e5e7eb'; |
| 61 |
var inputBorder = opts.inputBorder || '#e5e7eb'; |
| 62 |
var labelColor = opts.labelColor || '#374151'; |
| 63 |
var cur = opts.currency || '$'; |
| 64 |
|
| 65 |
var cardRadius = (opts.cardRadius || 16) + 'px'; |
| 66 |
var inputRadius = (opts.inputRadius || 8) + 'px'; |
| 67 |
var maxWidth = (opts.maxWidth || 580) + 'px'; |
| 68 |
var resultSize = (opts.resultSize || 46) + 'px'; |
| 69 |
var titleSize = (opts.titleSize || 28) + 'px'; |
| 70 |
var paddingTop = (opts.paddingTop != null ? opts.paddingTop : 60) + 'px'; |
| 71 |
var paddingBottom = (opts.paddingBottom != null ? opts.paddingBottom : 60) + 'px'; |
| 72 |
|
| 73 |
var showProfitZone = opts.showProfitZone !== false; |
| 74 |
var showContribution = opts.showContribution !== false; |
| 75 |
var showMarginRatio = opts.showMarginRatio !== false; |
| 76 |
|
| 77 |
app.style.setProperty('--bkbg-bep-accent', accent); |
| 78 |
typoCssVarsForEl(opts.typoTitle, '--bkbg-bep-title-', app); |
| 79 |
typoCssVarsForEl(opts.typoSubtitle, '--bkbg-bep-sub-', app); |
| 80 |
app.style.paddingTop = paddingTop; |
| 81 |
app.style.paddingBottom = paddingBottom; |
| 82 |
if (opts.sectionBg) app.style.background = opts.sectionBg; |
| 83 |
|
| 84 |
var wrap = document.createElement('div'); wrap.className = 'bkbg-bep-wrap'; |
| 85 |
wrap.style.cssText = 'background:' + cardBg + ';border-radius:' + cardRadius + ';max-width:' + maxWidth; |
| 86 |
app.appendChild(wrap); |
| 87 |
|
| 88 |
// Header |
| 89 |
if ((opts.showTitle && opts.title) || (opts.showSubtitle && opts.subtitle)) { |
| 90 |
var hdr = document.createElement('div'); hdr.className = 'bkbg-bep-header'; |
| 91 |
if (opts.showTitle && opts.title) { |
| 92 |
var t = document.createElement('div'); t.className = 'bkbg-bep-title'; |
| 93 |
if (opts.titleColor) t.style.color = opts.titleColor; |
| 94 |
t.textContent = opts.title; hdr.appendChild(t); |
| 95 |
} |
| 96 |
if (opts.showSubtitle && opts.subtitle) { |
| 97 |
var s = document.createElement('div'); s.className = 'bkbg-bep-subtitle'; |
| 98 |
if (opts.subtitleColor) s.style.color = opts.subtitleColor; |
| 99 |
s.textContent = opts.subtitle; hdr.appendChild(s); |
| 100 |
} |
| 101 |
wrap.appendChild(hdr); |
| 102 |
} |
| 103 |
|
| 104 |
function makeLabel(text) { |
| 105 |
var l = document.createElement('label'); |
| 106 |
l.className = 'bkbg-bep-label'; l.style.color = labelColor; l.textContent = text; return l; |
| 107 |
} |
| 108 |
function makeInputField(labelText, defaultVal, hasCur) { |
| 109 |
var field = document.createElement('div'); |
| 110 |
field.appendChild(makeLabel(labelText)); |
| 111 |
var wrap2 = document.createElement('div'); wrap2.className = 'bkbg-bep-input-wrap'; |
| 112 |
if (hasCur) { |
| 113 |
var pfx = document.createElement('span'); pfx.className = 'bkbg-bep-prefix'; pfx.textContent = cur; |
| 114 |
wrap2.appendChild(pfx); |
| 115 |
} |
| 116 |
var inp = document.createElement('input'); |
| 117 |
inp.type='number'; inp.value=defaultVal; inp.min='0'; |
| 118 |
inp.className = 'bkbg-bep-input' + (hasCur ? ' has-prefix' : ''); |
| 119 |
inp.style.cssText = 'border:1.5px solid ' + inputBorder + ';border-radius:' + inputRadius; |
| 120 |
wrap2.appendChild(inp); field.appendChild(wrap2); |
| 121 |
return { field:field, inp:inp }; |
| 122 |
} |
| 123 |
|
| 124 |
var grid1 = document.createElement('div'); grid1.className = 'bkbg-bep-grid'; |
| 125 |
var fixedF = makeInputField('Fixed Costs', opts.defaultFixedCosts || 10000, true); |
| 126 |
var unitsF = makeInputField('Units to Sell', opts.defaultUnitsToSell || 500, false); |
| 127 |
grid1.appendChild(fixedF.field); grid1.appendChild(unitsF.field); wrap.appendChild(grid1); |
| 128 |
|
| 129 |
var grid2 = document.createElement('div'); grid2.className = 'bkbg-bep-grid'; grid2.style.marginBottom = '20px'; |
| 130 |
var priceF = makeInputField('Price per Unit', opts.defaultPricePerUnit || 50, true); |
| 131 |
var varF = makeInputField('Variable Cost / Unit', opts.defaultVarCostUnit || 20, true); |
| 132 |
grid2.appendChild(priceF.field); grid2.appendChild(varF.field); wrap.appendChild(grid2); |
| 133 |
|
| 134 |
// Result card |
| 135 |
var resultCard = document.createElement('div'); resultCard.className = 'bkbg-bep-result-card'; |
| 136 |
resultCard.style.cssText = 'background:' + resultBg + ';color:' + resultColor; |
| 137 |
var rLbl = document.createElement('div'); rLbl.className = 'bkbg-bep-result-label'; rLbl.textContent = 'Break-Even Point'; |
| 138 |
var rVal = document.createElement('div'); rVal.className = 'bkbg-bep-result-value'; rVal.style.fontSize = resultSize; |
| 139 |
var rSub = document.createElement('div'); rSub.className = 'bkbg-bep-result-sub'; |
| 140 |
resultCard.appendChild(rLbl); resultCard.appendChild(rVal); resultCard.appendChild(rSub); |
| 141 |
wrap.appendChild(resultCard); |
| 142 |
|
| 143 |
// Progress bar |
| 144 |
var progressSection, fillEl, midlineEl, progressNote, progressRight; |
| 145 |
if (showProfitZone) { |
| 146 |
progressSection = document.createElement('div'); progressSection.className = 'bkbg-bep-progress-section'; |
| 147 |
var progressHdr = document.createElement('div'); progressHdr.className = 'bkbg-bep-progress-header'; |
| 148 |
var progressLeft = document.createElement('span'); progressLeft.textContent = '0 units'; |
| 149 |
progressRight = document.createElement('span'); |
| 150 |
progressHdr.appendChild(progressLeft); progressHdr.appendChild(progressRight); |
| 151 |
var track = document.createElement('div'); track.className = 'bkbg-bep-track'; |
| 152 |
track.style.background = barBg; |
| 153 |
fillEl = document.createElement('div'); fillEl.className = 'bkbg-bep-fill'; |
| 154 |
midlineEl = document.createElement('div'); midlineEl.className = 'bkbg-bep-midline'; |
| 155 |
midlineEl.style.left = '50%'; |
| 156 |
track.appendChild(fillEl); track.appendChild(midlineEl); |
| 157 |
progressNote = document.createElement('div'); progressNote.className = 'bkbg-bep-progress-note'; |
| 158 |
progressSection.appendChild(progressHdr); |
| 159 |
progressSection.appendChild(track); |
| 160 |
progressSection.appendChild(progressNote); |
| 161 |
wrap.appendChild(progressSection); |
| 162 |
} |
| 163 |
|
| 164 |
// Stats row |
| 165 |
var statsRow = document.createElement('div'); statsRow.className = 'bkbg-bep-stats'; |
| 166 |
function makeStat(lbl, color) { |
| 167 |
var card = document.createElement('div'); card.className = 'bkbg-bep-stat'; |
| 168 |
card.style.cssText = 'background:' + statBg + ';border:1px solid ' + statBorder; |
| 169 |
var v = document.createElement('div'); v.className = 'bkbg-bep-stat-val'; if(color) v.style.color=color; |
| 170 |
var l = document.createElement('div'); l.className = 'bkbg-bep-stat-lbl'; l.textContent=lbl; |
| 171 |
card.appendChild(v); card.appendChild(l); return {card:card,val:v}; |
| 172 |
} |
| 173 |
var profitStat = makeStat('Profit / Loss'); |
| 174 |
var revStat = makeStat('Total Revenue'); |
| 175 |
var costStat = makeStat('Total Costs'); |
| 176 |
statsRow.appendChild(profitStat.card); statsRow.appendChild(revStat.card); statsRow.appendChild(costStat.card); |
| 177 |
wrap.appendChild(statsRow); |
| 178 |
|
| 179 |
var extrasRow; |
| 180 |
if (showContribution || showMarginRatio) { |
| 181 |
extrasRow = document.createElement('div'); extrasRow.className = 'bkbg-bep-extras'; |
| 182 |
if (showContribution) { var cm = makeStat('Contribution Margin / Unit'); cm.val.style.color=accent; extrasRow.appendChild(cm.card); wrap._cmStat=cm; } |
| 183 |
if (showMarginRatio) { var mr = makeStat('Contribution Margin Ratio'); mr.val.style.color=accent; extrasRow.appendChild(mr.card); wrap._mrStat=mr; } |
| 184 |
wrap.appendChild(extrasRow); |
| 185 |
} |
| 186 |
|
| 187 |
function refresh() { |
| 188 |
var fc = parseFloat(fixedF.inp.value) || 0; |
| 189 |
var pu = parseFloat(priceF.inp.value) || 0; |
| 190 |
var vc = parseFloat(varF.inp.value) || 0; |
| 191 |
var u = parseFloat(unitsF.inp.value) || 0; |
| 192 |
var r = calcBEP(fc, pu, vc, u); |
| 193 |
var pos = r.profitAtUnits >= 0; |
| 194 |
|
| 195 |
rVal.textContent = isFinite(r.bepUnits) ? r.bepUnits.toLocaleString() + ' units' : '∞'; |
| 196 |
rSub.textContent = isFinite(r.bepRevenue) |
| 197 |
? 'Revenue needed: ' + fmtCur(r.bepRevenue, cur) |
| 198 |
: 'Price must exceed variable cost'; |
| 199 |
|
| 200 |
profitStat.val.textContent = (r.profitAtUnits<0?'-':'') + fmtCur(r.profitAtUnits, cur); |
| 201 |
profitStat.val.style.color = pos ? profitColor : lossColor; |
| 202 |
revStat.val.textContent = fmtCur(r.revenue, cur); revStat.val.style.color = '#111827'; |
| 203 |
costStat.val.textContent = fmtCur(r.totalCosts, cur); costStat.val.style.color = '#111827'; |
| 204 |
|
| 205 |
if (showContribution && wrap._cmStat) wrap._cmStat.val.textContent = fmtCur(r.contribution, cur); |
| 206 |
if (showMarginRatio && wrap._mrStat) wrap._mrStat.val.textContent = r.marginRatio.toFixed(1) + '%'; |
| 207 |
|
| 208 |
if (showProfitZone && isFinite(r.bepUnits)) { |
| 209 |
var progress = r.bepUnits > 0 ? Math.min(100, (u / r.bepUnits) * 100) : 100; |
| 210 |
fillEl.style.width = progress + '%'; |
| 211 |
fillEl.style.background = pos ? profitColor : lossColor; |
| 212 |
progressRight.textContent = u.toLocaleString() + ' units → ' + (pos ? '✓ PROFIT' : '✗ LOSS'); |
| 213 |
progressRight.style.color = pos ? profitColor : lossColor; |
| 214 |
progressNote.textContent = '↑ Break-even at ' + r.bepUnits.toLocaleString() + ' units'; |
| 215 |
} |
| 216 |
} |
| 217 |
|
| 218 |
[fixedF.inp, priceF.inp, varF.inp, unitsF.inp].forEach(function(inp) { |
| 219 |
inp.addEventListener('input', refresh); |
| 220 |
}); |
| 221 |
refresh(); |
| 222 |
} |
| 223 |
|
| 224 |
document.querySelectorAll('.bkbg-bep-app').forEach(initBEP); |
| 225 |
})(); |
| 226 |
|