/* SyncKit billing panel — mode toggle + live price preview + setup / activate / patch / cancel / portal flows. Loaded globally from base.html. The user/project SyncKit dashboard tab partials call window.initSyncKitBilling() after HTMX swaps them in. The function is idempotent: a panel that's already wired up gets skipped via the `data-wired` marker. Pricing formula constants come from data-* attrs on each .synckit-billing root. The formula mirrors src/synckit_billing.rs::monthly_price_cents. */ (function () { 'use strict'; function moneyFromCents(cents) { var dollars = Math.floor(cents / 100); var rem = cents % 100; return '$' + dollars + '.' + (rem < 10 ? '0' : '') + rem; } function priceCents(panel, mode, knobs) { var rate = parseFloat(panel.dataset.storageRate); var floor = parseInt(panel.dataset.baseFloorCents, 10); var gb = 0; if (mode === 'bulk') { gb = knobs.storage_gb_cap || 0; } else if (mode === 'per_key') { gb = (knobs.key_cap || 0) * (knobs.gb_per_key || 0); } var raw = Math.ceil(gb * rate); return Math.max(raw, floor); } function selectedMode(panel) { var checked = panel.querySelector('input[name^="synckit-mode-"]:checked'); return checked ? checked.value : 'bulk'; } function readKnobs(panel) { return { storage_gb_cap: parseInt(panel.querySelector('.synckit-storage-input').value, 10) || 0, key_cap: parseInt(panel.querySelector('.synckit-key-cap-input').value, 10) || 0, gb_per_key: parseInt(panel.querySelector('.synckit-gb-per-key-input').value, 10) || 0, }; } function buildRequestBody(panel) { var mode = selectedMode(panel); var knobs = readKnobs(panel); var body = { enforcement_mode: mode }; if (mode === 'bulk') { body.storage_gb_cap = knobs.storage_gb_cap; } else if (mode === 'per_key') { body.key_cap = knobs.key_cap; body.gb_per_key = knobs.gb_per_key; } return body; } function validateBody(body) { if (body.enforcement_mode === 'bulk') { if (!(body.storage_gb_cap > 0)) return 'Storage must be greater than 0 GB.'; } else if (body.enforcement_mode === 'per_key') { if (!(body.key_cap > 0)) return 'Key cap must be greater than 0.'; if (!(body.gb_per_key > 0)) return 'GB per key must be greater than 0.'; } return null; } function updatePricePreview(panel) { var mode = selectedMode(panel); var knobs = readKnobs(panel); var cents = priceCents(panel, mode, knobs); var preview = panel.querySelector('.synckit-price-preview'); if (preview) preview.textContent = moneyFromCents(cents) + ' / month'; } function setRowsForMode(panel, mode) { var bulkRows = panel.querySelectorAll('.synckit-bulk-row'); var perKeyRows = panel.querySelectorAll('.synckit-per-key-row'); Array.prototype.forEach.call(bulkRows, function (r) { r.hidden = (mode !== 'bulk'); }); Array.prototype.forEach.call(perKeyRows, function (r) { r.hidden = (mode !== 'per_key'); }); } function syncSliderTextbox(slider, textbox) { slider.addEventListener('input', function () { textbox.value = slider.value; }); textbox.addEventListener('input', function () { var v = parseFloat(textbox.value); if (!isNaN(v)) { var min = parseFloat(slider.min); var max = parseFloat(slider.max); slider.value = String(Math.min(Math.max(v, min), max)); } }); } function markDirty(panel) { var saveBtn = panel.querySelector('.synckit-billing-save-btn'); if (saveBtn) saveBtn.disabled = false; } function setStatusMsg(panel, msg, kind) { var el = panel.querySelector('.synckit-billing-status-msg'); if (!el) return; el.textContent = msg || ''; el.className = 'synckit-billing-status-msg' + (kind ? ' synckit-billing-status-msg--' + kind : ''); } function postJson(url, body) { return fetch(url, { method: 'POST', credentials: 'same-origin', headers: Object.assign({ 'Content-Type': 'application/json' }, window.csrfHeaders ? window.csrfHeaders() : {}), body: body ? JSON.stringify(body) : null }); } function patchJson(url, body) { return fetch(url, { method: 'PATCH', credentials: 'same-origin', headers: Object.assign({ 'Content-Type': 'application/json' }, window.csrfHeaders ? window.csrfHeaders() : {}), body: JSON.stringify(body) }); } function deleteJson(url) { return fetch(url, { method: 'DELETE', credentials: 'same-origin', headers: window.csrfHeaders ? window.csrfHeaders() : {} }); } function getJson(url) { return fetch(url, { method: 'GET', credentials: 'same-origin' }); } function refreshTab() { var tab = document.getElementById('tab-synckit'); if (tab) tab.click(); } function wirePanel(panel) { if (panel.dataset.wired === '1') return; panel.dataset.wired = '1'; var appId = panel.dataset.appId; var storageSlider = panel.querySelector('.synckit-storage-slider'); var storageInput = panel.querySelector('.synckit-storage-input'); if (storageSlider && storageInput) syncSliderTextbox(storageSlider, storageInput); function onKnobChange() { updatePricePreview(panel); markDirty(panel); } ['.synckit-storage-input', '.synckit-storage-slider', '.synckit-key-cap-input', '.synckit-gb-per-key-input'].forEach(function (sel) { var el = panel.querySelector(sel); if (el) el.addEventListener('input', onKnobChange); }); var modeRadios = panel.querySelectorAll('input[name^="synckit-mode-"]'); Array.prototype.forEach.call(modeRadios, function (radio) { radio.addEventListener('change', function () { if (radio.checked) { setRowsForMode(panel, radio.value); onKnobChange(); } }); }); updatePricePreview(panel); // ── Setup ── var setupBtn = panel.querySelector('.synckit-billing-setup-btn'); if (setupBtn) { setupBtn.addEventListener('click', function () { setupBtn.disabled = true; setStatusMsg(panel, 'Opening Stripe…', 'info'); postJson('/api/sync/apps/' + appId + '/billing/setup').then(function (res) { if (!res.ok) { return res.text().then(function (t) { setupBtn.disabled = false; setStatusMsg(panel, t || 'Failed to open billing portal.', 'error'); }); } return res.json().then(function (data) { if (data.billing_portal_url) { window.location.href = data.billing_portal_url; } else { setupBtn.disabled = false; setStatusMsg(panel, 'No portal URL returned.', 'error'); } }); }).catch(function () { setupBtn.disabled = false; setStatusMsg(panel, 'Network error.', 'error'); }); }); } // ── Activate (draft → active) ── var activateBtn = panel.querySelector('.synckit-billing-activate-btn'); if (activateBtn) { activateBtn.addEventListener('click', function () { var body = buildRequestBody(panel); var err = validateBody(body); if (err) { setStatusMsg(panel, err, 'error'); return; } activateBtn.disabled = true; setStatusMsg(panel, 'Activating…', 'info'); postJson('/api/sync/apps/' + appId + '/billing/activate', body).then(function (res) { if (!res.ok) { return res.text().then(function (t) { activateBtn.disabled = false; setStatusMsg(panel, t || 'Failed to activate billing.', 'error'); }); } refreshTab(); }).catch(function () { activateBtn.disabled = false; setStatusMsg(panel, 'Network error.', 'error'); }); }); } // ── Save (PATCH knobs on active app) ── var saveBtn = panel.querySelector('.synckit-billing-save-btn'); if (saveBtn) { saveBtn.addEventListener('click', function () { var body = buildRequestBody(panel); var err = validateBody(body); if (err) { setStatusMsg(panel, err, 'error'); return; } saveBtn.disabled = true; setStatusMsg(panel, 'Saving…', 'info'); patchJson('/api/sync/apps/' + appId + '/billing', body).then(function (res) { if (!res.ok) { return res.text().then(function (t) { saveBtn.disabled = false; setStatusMsg(panel, t || 'Failed to save changes.', 'error'); }); } refreshTab(); }).catch(function () { saveBtn.disabled = false; setStatusMsg(panel, 'Network error.', 'error'); }); }); } // ── Portal ── var portalBtn = panel.querySelector('.synckit-billing-portal-btn'); if (portalBtn) { portalBtn.addEventListener('click', function () { portalBtn.disabled = true; getJson('/api/sync/apps/' + appId + '/billing/portal').then(function (res) { if (!res.ok) { portalBtn.disabled = false; setStatusMsg(panel, 'Could not open Stripe portal.', 'error'); return; } return res.json().then(function (data) { if (data.billing_portal_url) { window.location.href = data.billing_portal_url; } else { portalBtn.disabled = false; } }); }).catch(function () { portalBtn.disabled = false; setStatusMsg(panel, 'Network error.', 'error'); }); }); } // ── Cancel ── var cancelBtn = panel.querySelector('.synckit-billing-cancel-btn'); if (cancelBtn) { cancelBtn.addEventListener('click', function () { if (!confirm('Cancel billing for this app? End-user clients will stop syncing immediately.')) return; cancelBtn.disabled = true; setStatusMsg(panel, 'Canceling…', 'info'); deleteJson('/api/sync/apps/' + appId + '/billing').then(function (res) { if (!res.ok) { cancelBtn.disabled = false; setStatusMsg(panel, 'Failed to cancel billing.', 'error'); return; } refreshTab(); }).catch(function () { cancelBtn.disabled = false; setStatusMsg(panel, 'Network error.', 'error'); }); }); } } window.initSyncKitBilling = function () { var panels = document.querySelectorAll('.synckit-billing'); Array.prototype.forEach.call(panels, wirePanel); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', window.initSyncKitBilling); } else { window.initSyncKitBilling(); } })();