Skip to main content

max / makenotwork

12.2 KB · 309 lines History Blame Raw
1 /* SyncKit billing panel — mode toggle + live price preview +
2 setup / activate / patch / cancel / portal flows.
3
4 Loaded globally from base.html. The user/project SyncKit dashboard tab
5 partials call window.initSyncKitBilling() after HTMX swaps them in. The
6 function is idempotent: a panel that's already wired up gets skipped via
7 the `data-wired` marker.
8
9 Pricing formula constants come from data-* attrs on each .synckit-billing
10 root. The formula mirrors src/synckit_billing.rs::monthly_price_cents.
11 */
12 (function () {
13 'use strict';
14
15 function moneyFromCents(cents) {
16 var dollars = Math.floor(cents / 100);
17 var rem = cents % 100;
18 return '$' + dollars + '.' + (rem < 10 ? '0' : '') + rem;
19 }
20
21 function priceCents(panel, mode, knobs) {
22 var rate = parseFloat(panel.dataset.storageRate);
23 var floor = parseInt(panel.dataset.baseFloorCents, 10);
24 var gb = 0;
25 if (mode === 'bulk') {
26 gb = knobs.storage_gb_cap || 0;
27 } else if (mode === 'per_key') {
28 gb = (knobs.key_cap || 0) * (knobs.gb_per_key || 0);
29 }
30 var raw = Math.ceil(gb * rate);
31 return Math.max(raw, floor);
32 }
33
34 function selectedMode(panel) {
35 var checked = panel.querySelector('input[name^="synckit-mode-"]:checked');
36 return checked ? checked.value : 'bulk';
37 }
38
39 function readKnobs(panel) {
40 return {
41 storage_gb_cap: parseInt(panel.querySelector('.synckit-storage-input').value, 10) || 0,
42 key_cap: parseInt(panel.querySelector('.synckit-key-cap-input').value, 10) || 0,
43 gb_per_key: parseInt(panel.querySelector('.synckit-gb-per-key-input').value, 10) || 0,
44 };
45 }
46
47 function buildRequestBody(panel) {
48 var mode = selectedMode(panel);
49 var knobs = readKnobs(panel);
50 var body = { enforcement_mode: mode };
51 if (mode === 'bulk') {
52 body.storage_gb_cap = knobs.storage_gb_cap;
53 } else if (mode === 'per_key') {
54 body.key_cap = knobs.key_cap;
55 body.gb_per_key = knobs.gb_per_key;
56 }
57 return body;
58 }
59
60 function validateBody(body) {
61 if (body.enforcement_mode === 'bulk') {
62 if (!(body.storage_gb_cap > 0)) return 'Storage must be greater than 0 GB.';
63 } else if (body.enforcement_mode === 'per_key') {
64 if (!(body.key_cap > 0)) return 'Key cap must be greater than 0.';
65 if (!(body.gb_per_key > 0)) return 'GB per key must be greater than 0.';
66 }
67 return null;
68 }
69
70 function updatePricePreview(panel) {
71 var mode = selectedMode(panel);
72 var knobs = readKnobs(panel);
73 var cents = priceCents(panel, mode, knobs);
74 var preview = panel.querySelector('.synckit-price-preview');
75 if (preview) preview.textContent = moneyFromCents(cents) + ' / month';
76 }
77
78 function setRowsForMode(panel, mode) {
79 var bulkRows = panel.querySelectorAll('.synckit-bulk-row');
80 var perKeyRows = panel.querySelectorAll('.synckit-per-key-row');
81 Array.prototype.forEach.call(bulkRows, function (r) { r.hidden = (mode !== 'bulk'); });
82 Array.prototype.forEach.call(perKeyRows, function (r) { r.hidden = (mode !== 'per_key'); });
83 }
84
85 function syncSliderTextbox(slider, textbox) {
86 slider.addEventListener('input', function () { textbox.value = slider.value; });
87 textbox.addEventListener('input', function () {
88 var v = parseFloat(textbox.value);
89 if (!isNaN(v)) {
90 var min = parseFloat(slider.min);
91 var max = parseFloat(slider.max);
92 slider.value = String(Math.min(Math.max(v, min), max));
93 }
94 });
95 }
96
97 function markDirty(panel) {
98 var saveBtn = panel.querySelector('.synckit-billing-save-btn');
99 if (saveBtn) saveBtn.disabled = false;
100 }
101
102 function setStatusMsg(panel, msg, kind) {
103 var el = panel.querySelector('.synckit-billing-status-msg');
104 if (!el) return;
105 el.textContent = msg || '';
106 el.className = 'synckit-billing-status-msg' + (kind ? ' synckit-billing-status-msg--' + kind : '');
107 }
108
109 function postJson(url, body) {
110 return fetch(url, {
111 method: 'POST',
112 credentials: 'same-origin',
113 headers: Object.assign({ 'Content-Type': 'application/json' }, window.csrfHeaders ? window.csrfHeaders() : {}),
114 body: body ? JSON.stringify(body) : null
115 });
116 }
117
118 function patchJson(url, body) {
119 return fetch(url, {
120 method: 'PATCH',
121 credentials: 'same-origin',
122 headers: Object.assign({ 'Content-Type': 'application/json' }, window.csrfHeaders ? window.csrfHeaders() : {}),
123 body: JSON.stringify(body)
124 });
125 }
126
127 function deleteJson(url) {
128 return fetch(url, {
129 method: 'DELETE',
130 credentials: 'same-origin',
131 headers: window.csrfHeaders ? window.csrfHeaders() : {}
132 });
133 }
134
135 function getJson(url) {
136 return fetch(url, { method: 'GET', credentials: 'same-origin' });
137 }
138
139 function refreshTab() {
140 var tab = document.getElementById('tab-synckit');
141 if (tab) tab.click();
142 }
143
144 function wirePanel(panel) {
145 if (panel.dataset.wired === '1') return;
146 panel.dataset.wired = '1';
147
148 var appId = panel.dataset.appId;
149 var storageSlider = panel.querySelector('.synckit-storage-slider');
150 var storageInput = panel.querySelector('.synckit-storage-input');
151 if (storageSlider && storageInput) syncSliderTextbox(storageSlider, storageInput);
152
153 function onKnobChange() {
154 updatePricePreview(panel);
155 markDirty(panel);
156 }
157 ['.synckit-storage-input', '.synckit-storage-slider',
158 '.synckit-key-cap-input', '.synckit-gb-per-key-input'].forEach(function (sel) {
159 var el = panel.querySelector(sel);
160 if (el) el.addEventListener('input', onKnobChange);
161 });
162
163 var modeRadios = panel.querySelectorAll('input[name^="synckit-mode-"]');
164 Array.prototype.forEach.call(modeRadios, function (radio) {
165 radio.addEventListener('change', function () {
166 if (radio.checked) {
167 setRowsForMode(panel, radio.value);
168 onKnobChange();
169 }
170 });
171 });
172
173 updatePricePreview(panel);
174
175 // ── Setup ──
176 var setupBtn = panel.querySelector('.synckit-billing-setup-btn');
177 if (setupBtn) {
178 setupBtn.addEventListener('click', function () {
179 setupBtn.disabled = true;
180 setStatusMsg(panel, 'Opening Stripe…', 'info');
181 postJson('/api/sync/apps/' + appId + '/billing/setup').then(function (res) {
182 if (!res.ok) {
183 return res.text().then(function (t) {
184 setupBtn.disabled = false;
185 setStatusMsg(panel, t || 'Failed to open billing portal.', 'error');
186 });
187 }
188 return res.json().then(function (data) {
189 if (data.billing_portal_url) {
190 window.location.href = data.billing_portal_url;
191 } else {
192 setupBtn.disabled = false;
193 setStatusMsg(panel, 'No portal URL returned.', 'error');
194 }
195 });
196 }).catch(function () {
197 setupBtn.disabled = false;
198 setStatusMsg(panel, 'Network error.', 'error');
199 });
200 });
201 }
202
203 // ── Activate (draft → active) ──
204 var activateBtn = panel.querySelector('.synckit-billing-activate-btn');
205 if (activateBtn) {
206 activateBtn.addEventListener('click', function () {
207 var body = buildRequestBody(panel);
208 var err = validateBody(body);
209 if (err) { setStatusMsg(panel, err, 'error'); return; }
210 activateBtn.disabled = true;
211 setStatusMsg(panel, 'Activating…', 'info');
212 postJson('/api/sync/apps/' + appId + '/billing/activate', body).then(function (res) {
213 if (!res.ok) {
214 return res.text().then(function (t) {
215 activateBtn.disabled = false;
216 setStatusMsg(panel, t || 'Failed to activate billing.', 'error');
217 });
218 }
219 refreshTab();
220 }).catch(function () {
221 activateBtn.disabled = false;
222 setStatusMsg(panel, 'Network error.', 'error');
223 });
224 });
225 }
226
227 // ── Save (PATCH knobs on active app) ──
228 var saveBtn = panel.querySelector('.synckit-billing-save-btn');
229 if (saveBtn) {
230 saveBtn.addEventListener('click', function () {
231 var body = buildRequestBody(panel);
232 var err = validateBody(body);
233 if (err) { setStatusMsg(panel, err, 'error'); return; }
234 saveBtn.disabled = true;
235 setStatusMsg(panel, 'Saving…', 'info');
236 patchJson('/api/sync/apps/' + appId + '/billing', body).then(function (res) {
237 if (!res.ok) {
238 return res.text().then(function (t) {
239 saveBtn.disabled = false;
240 setStatusMsg(panel, t || 'Failed to save changes.', 'error');
241 });
242 }
243 refreshTab();
244 }).catch(function () {
245 saveBtn.disabled = false;
246 setStatusMsg(panel, 'Network error.', 'error');
247 });
248 });
249 }
250
251 // ── Portal ──
252 var portalBtn = panel.querySelector('.synckit-billing-portal-btn');
253 if (portalBtn) {
254 portalBtn.addEventListener('click', function () {
255 portalBtn.disabled = true;
256 getJson('/api/sync/apps/' + appId + '/billing/portal').then(function (res) {
257 if (!res.ok) {
258 portalBtn.disabled = false;
259 setStatusMsg(panel, 'Could not open Stripe portal.', 'error');
260 return;
261 }
262 return res.json().then(function (data) {
263 if (data.billing_portal_url) {
264 window.location.href = data.billing_portal_url;
265 } else {
266 portalBtn.disabled = false;
267 }
268 });
269 }).catch(function () {
270 portalBtn.disabled = false;
271 setStatusMsg(panel, 'Network error.', 'error');
272 });
273 });
274 }
275
276 // ── Cancel ──
277 var cancelBtn = panel.querySelector('.synckit-billing-cancel-btn');
278 if (cancelBtn) {
279 cancelBtn.addEventListener('click', function () {
280 if (!confirm('Cancel billing for this app? End-user clients will stop syncing immediately.')) return;
281 cancelBtn.disabled = true;
282 setStatusMsg(panel, 'Canceling…', 'info');
283 deleteJson('/api/sync/apps/' + appId + '/billing').then(function (res) {
284 if (!res.ok) {
285 cancelBtn.disabled = false;
286 setStatusMsg(panel, 'Failed to cancel billing.', 'error');
287 return;
288 }
289 refreshTab();
290 }).catch(function () {
291 cancelBtn.disabled = false;
292 setStatusMsg(panel, 'Network error.', 'error');
293 });
294 });
295 }
296 }
297
298 window.initSyncKitBilling = function () {
299 var panels = document.querySelectorAll('.synckit-billing');
300 Array.prototype.forEach.call(panels, wirePanel);
301 };
302
303 if (document.readyState === 'loading') {
304 document.addEventListener('DOMContentLoaded', window.initSyncKitBilling);
305 } else {
306 window.initSyncKitBilling();
307 }
308 })();
309