PageSourceSearch

https://railsblocks.com/assets/controllers/pricing_tabs_controller…1efe413e3a83912d7e7fffe3b3609f8ac67bffd4.js

js railsblocks.com collected 2026-09-25 20:23:57 UTC 7,062 bytes, 215 lines download raw bytes

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.