1import { Controller } from "@hotwired/stimulus" 2import { localeCurrency } from "currency" 3 4// Connects to data-controller="roi-calculator" 5// 6// Live monthly-recurring-revenue calculator for the integrators page. Three 7// inputs (cameras per site, number of sites, cloud recording cost per camera) 8// plus a set of additional-service checkboxes feed a single annual-revenue 9// figure, displayed as monthly / annual / 7-year headlines. 10// 11// Pricing model per checkbox (exactly one attribute each): 12// data-percent-uplift -> adds N% of base annual revenue 13// data-monthly-per-camera-gbp -> flat GBP/camera/month 14// data-monthly-per-site-gbp -> flat GBP/site/month 15// 16// Currency is centralised in currency.js (localeCurrency); an AU visitor sees 17// A$ figures using the shared rate table, never a hardcoded symbol. 18// 19// connect()/disconnect() replace the old turbo:load inline script - Stimulus 20// re-binds on every Turbo navigation, so no accumulating listeners and the 21// calculator recomputes on every page entry. 22export default class extends Controller { 23 static targets = [ 24 "camerasPerSite", 25 "numberOfSites", 26 "cloudRecordingCost", 27 "addon", 28 "monthlyResult", 29 "annualResult", 30 "sevenYearResult" 31 ] 32 33 connect() { 34 this.currency = localeCurrency() 35 this.convertCloudCostOptions() 36 this.updateCalculation() 37 } 38 39 // GBP -> locale currency, rounded up to the next whole unit (matches 40 // convertGbp in currency.js and the prior inline script). 41 toLocale(gbp) { 42 return Math.ceil(gbp * this.currency.rate) 43 } 44 45 // Compact figure formatting: 1.2b / 3.4m / 56k / 1,234.
46 formatAmount(value) { 47 if (value >= 1e9) return `${(value / 1e9).toFixed(1)}b` 48 if (value >= 1e6) return `${(value / 1e6).toFixed(1)}m` 49 if (value >= 1e5) return `${(value / 1e3).toFixed(0)}k` 50 return value.toLocaleString() 51 } 52 53 // Rewrite the cloud-recording-cost option labels into locale currency, 54 // preserving any "(Platform Only)" suffix, and push the new label into the 55 // TomSelect clone if one is mounted. 56 convertCloudCostOptions() { 57 const select = this.cloudRecordingCostTarget 58 Array.from(select.options).forEach((opt) => { 59 const gbpMonthly = parseInt(opt.value) 60 const suffix = opt.textContent.includes("(") 61 ? ` ${opt.textContent.slice(opt.textContent.indexOf("("))}` 62 : "" 63 const label = `${this.currency.symbol}${this.toLocale(gbpMonthly).toLocaleString()}/month${suffix}` 64 opt.textContent = label 65 if (select.tomselect) { 66 select.tomselect.updateOption(opt.value, { value: opt.value, text: label }) 67 } 68 }) 69 } 70 71 updateCalculation() { 72 const c = this.currency.symbol 73 const camerasPerSite = parseInt(this.camerasPerSiteTarget.value) 74 const numberOfSites = parseInt(this.numberOfSitesTarget.value) 75 const monthlyCostPerCamera = parseInt(this.cloudRecordingCostTarget.value) 76 const annualCostPerCamera = monthlyCostPerCamera * 12 77 const totalCameras = camerasPerSite * numberOfSites 78 79 let percentUpliftTotal = 0 80 let perCameraMonthlyGbp = 0 81 let perSiteMonthlyGbp = 0 82 this.addonTargets.forEach((cb) => { 83 if (!cb.checked) return 84 const pct = parseFloat(cb.dataset.percentUplift) 85 const cam = parseFloat(cb.dataset.monthlyPerCameraGbp) 86 const site = parseFloat(cb.dataset.monthlyPerSiteGbp) 87 if (!Number.isNaN(pct) && pct > 0) percentUpliftTotal += pct 88 if (!Number.isNaN(cam) && cam > 0) perCameraMonthlyGbp += cam 89 if (!Number.isNaN(site) && site > 0) perSiteMonthlyGbp += site 90 }) 91 const serviceMultiplier = 1 + percentUpliftTotal 92 93 const baseAnnualRevenue = totalCameras * annualCostPerCamera 94 const perCameraAnnualGbp = totalCameras * perCameraMonthlyGbp * 12 95 const perSiteAnnualGbp = numberOfSites * perSiteMonthlyGbp * 12 96 const annualRevenue = Math.round( 97 baseAnnualRevenue * serviceMultiplier + perCameraAnnualGbp + perSiteAnnualGbp 98 ) 99 100 this.annualResultTarget.textContent = `${c}
100${this.formatAmount(this.toLocale(annualRevenue))}` 101 this.monthlyResultTarget.textContent = `${c}${this.formatAmount(this.toLocale(Math.round(annualRevenue / 12)))}` 102 this.sevenYearResultTarget.textContent = `${c}${this.formatAmount(this.toLocale(annualRevenue * 7))}` 103 104 const panel = this.annualResultTarget.closest(".results-panel") 105 if (panel && panel.style.visibility === "hidden") { 106 panel.style.visibility = "visible" 107 } 108 } 109}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.