1import { Controller } from "@hotwired/stimulus"; 2import "number-flow"; 3import { continuous } from "number-flow"; 4 5export default class extends Controller { 6 static targets = [ 7 "periodTab", 8 "planTab", 9 "priceDisplay", 10 "billingText", 11 "selectedPlan", 12 "selectedPeriod", 13 "savingsInfo", 14 ]; 15 16 static values = { 17 initialPrice: { type: Number, default: 29 }, // Initial price 18 activeTabClasses: { type: Array, default: ["bg-white", "text-slate-900", "shadow-md"] }, // Classes for the active tab 19 inactiveTabClasses: { type: Array, default: ["text-slate-600", "hover:bg-slate-100", "hover:text-slate-800"] }, // Classes for the inactive tab 20 tickAnimationDuration: { type: Number, default: 700 }, // Duration of the tick animation 21 }; 22 23 // SaaS pricing structure 24 pricing = { 25 basic: { 26 monthly: 29, 27 quarterly: 25, // ~15% discount 28 annual: 22, // ~25% discount 29 }, 30 pro: { 31 monthly: 79, 32 quarterly: 67, // ~15% discount 33 annual: 59, // ~25% discount 34 }, 35 enterprise: { 36 monthly: 199, 37 quarterly: 169, // ~15% discount 38 annual: 149, // ~25% discount 39 }, 40 }; 41 42 connect() { 43 this.priceDisplayTarget.innerHTML = "<number-flow></number-flow>"; 44 this.priceFlow = this.priceDisplayTarget.querySelector("number-flow"); 45 46 this.priceFlow.format = { style: "currency", currency: "USD", minimumFractionDigits: 0 }; 47 this.priceFlow.plugins = [continuous]; 48 this.priceFlow.spinTiming = { duration: this.tickAnimationDurationValue, easing: "ease-in-out" }; 49 this.priceFlow.transformTiming = { duration: this.tickAnimationDurationValue, easing: "ease-in-out" }; 50 51 // Initialize with default selections 52 this.currentPeriod = "monthly"; 53 this.currentPlan = "basic"; 54 this.currentPeriodIndex = 0; 55 this.currentPlanIndex = 0; 56 57 // Set initial active states 58 this.activatePeriodTab(this.periodTabTargets[0]); 59 this.activatePlanTab(this.planTabTargets[0]); 60 61 // Update display 62 this.updatePriceDisplay(); 63 } 64 65 switchPeriod(event) { 66 event.preventDefault(); 67 const clickedTab = event.currentTarget; 68 const newPeriod = clickedTab.dataset.period; 69 70 this.currentPeriod = newPeriod; 71 this.currentPeriodIndex = this.periodTabTargets.indexOf(clickedTab); 72 this.activatePeriodTab(clickedTab); 73 this.updatePriceDisplay(); 74 } 75 76 switchPlan(event) { 77 event.preventDefault(); 78 const clickedTab = event.currentTarget; 79 const newPlan = clickedTab.dataset.plan; 80 81 this.currentPlan = newPlan; 82 this.currentPlanIndex = this.planTabTargets.indexOf(clickedTab); 83 this.activatePlanTab(clickedTab); 84 this.updatePriceDisplay(); 85 } 86 87 // Period navigation methods 88 previousPeriod(event) { 89 event.preventDefault(); 90 const newIndex = this.currentPeriodIndex > 0 ? this.currentPeriodIndex - 1 : this.periodTabTargets.length - 1; 91 this.switchToPeriodByIndex(newIndex); 92 } 93 94 nextPeriod(event) { 95 event.preventDefault(); 96 const newIndex = this.currentPeriodIndex < this.periodTabTargets.length - 1 ? this.currentPeriodIndex + 1 : 0; 97 this.switchToPeriodByIndex(newIndex); 98 } 99 100 // Plan navigation methods 101 previousPlan(event) { 102 event.preventDefault(); 103 const newIndex = this.currentPlanIndex > 0 ? this.currentPlanIndex - 1 : this.planTabTargets.length - 1; 104 this.switchToPlanByIndex(newIndex); 105 } 106 107 nextPlan(event) { 108 event.preventDefault(); 109 const newIndex = this.currentPlanIndex < this.planTabTargets.length - 1 ? this.currentPlanIndex + 1 : 0; 110 this.switchToPlanByIndex(newIndex); 111 } 112 113 switchToPeriodByIndex(index) { 114 if (index >= 0 && index < this.periodTabTargets.length) { 115 const targetTab = this.periodTabTargets[index]; 116 const newPeriod = targetTab.dataset.period; 117 118 this.currentPeriod = newPeriod; 119 this.currentPeriodIndex = index; 120 this.activatePeriodTab(targetTab); 121 this.updatePriceDisplay(); 122 targetTab.focus(); 123 } 124 } 125 126 switchToPlanByIndex(index) { 127 if (index >= 0 && index < this.planTabTargets.length) { 128 const targetTab = this.planTabTargets[index]; 129 const newPlan = targetTab.dataset.plan; 130 131 this.currentPlan = newPlan; 132 this.currentPlanIndex = index; 133 this.activatePlanTab(targetTab); 134 this.updatePriceDisplay(); 135 targetTab.focus(); 136 } 137 } 138
139 activatePeriodTab(activeTab) { 140 this.periodTabTargets.forEach((tab) => { 141 tab.classList.remove(...this.activeTabClassesValue); 142 tab.classList.add(...this.inactiveTabClassesValue); 143 tab.setAttribute("aria-selected", "false"); 144 tab.setAttribute("tabindex", "-1"); 145 }); 146 activeTab.classList.add(...this.activeTabClassesValue); 147 activeTab.classList.remove(...this.inactiveTabClassesValue); 148 activeTab.setAttribute("aria-selected", "true"); 149 activeTab.setAttribute("tabindex", "0"); 150 } 151 152 activatePlanTab(activeTab) { 153 this.planTabTargets.forEach((tab) => { 154 tab.classList.remove(...this.activeTabClassesValue); 155 tab.classList.add(...this.inactiveTabClassesValue); 156 tab.setAttribute("aria-selected", "false"); 157 tab.setAttribute("tabindex", "-1"); 158 }); 159 activeTab.classList.add(...this.activeTabClassesValue); 160 activeTab.classList.remove(...this.inactiveTabClassesValue); 161 activeTab.setAttribute("aria-selected", "true"); 162 activeTab.setAttribute("tabindex", "0"); 163 } 164 165 updatePriceDisplay() { 166 const price = this.pricing[this.currentPlan][this.currentPeriod]; 167 this.priceFlow.update(price); 168 169 // Update billing text 170 const billingTexts = { 171 monthly: "per month", 172 quarterly: "per month, billed quarterly", 173 annual: "per month, billed annually", 174 }; 175 this.billingTextTarget.textContent = billingTexts[this.currentPeriod]; 176 177 // Update selected plan and period display 178 const planNames = { 179 basic: "Basic Plan", 180 pro: "Pro Plan", 181 enterprise: "Enterprise Plan", 182 }; 183 const periodNames = { 184 monthly: "Monthly", 185 quarterly: "Quarterly", 186 annual: "Annual", 187 }; 188 189 this.selectedPlanTarget.textContent = planNames[this.currentPlan]; 190 this.selectedPeriodTarget.textContent = periodNames[this.currentPeriod]; 191 192 // Show savings information 193 this.updateSavingsInfo(); 194 } 195 196 updateSavingsInfo() { 197 const monthlyPrice = this.pricing[this.currentPlan].monthly; 198 const currentPrice = this.pricing[this.currentPlan][this.currentPeriod]; 199 200 if (this.currentPeriod === "monthly") { 201 this.savingsInfoTarget.classList.add("hidden"); 202 } else { 203 const savings = monthlyPrice - currentPrice; 204 const savingsPercent = Math.round((savings / monthlyPrice) * 100); 205 const totalSavings = this.currentPeriod === "quarterly" ? savings * 3 : savings * 12; 206 207 this.savingsInfoTarget.innerHTML = ` 208 ð° You save $${savings}/month (${savingsPercent}% off) ⢠$${totalSavings} total savings per ${ 209 this.currentPeriod === "quarterly" ? "quarter" : "year" 210 } 211 `; 212 this.savingsInfoTarget.classList.remove("hidden"); 213 } 214 } 215};
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.