max / makenotwork
100 files changed,
+3140 insertions,
-3092 deletions
| @@ -0,0 +1,21 @@ | |||
| 1 | + | function exportAll() { | |
| 2 | + | var btn = document.getElementById('export-all-btn'); | |
| 3 | + | var buttons = document.querySelectorAll('.export-card button.secondary'); | |
| 4 | + | if (buttons.length === 0) return; | |
| 5 | + | ||
| 6 | + | btn.disabled = true; | |
| 7 | + | btn.textContent = 'Exporting...'; | |
| 8 | + | var i = 0; | |
| 9 | + | ||
| 10 | + | function next() { | |
| 11 | + | if (i >= buttons.length) { | |
| 12 | + | btn.textContent = 'Done'; | |
| 13 | + | setTimeout(function() { btn.textContent = 'Export All'; btn.disabled = false; }, 3000); | |
| 14 | + | return; | |
| 15 | + | } | |
| 16 | + | buttons[i].click(); | |
| 17 | + | i++; | |
| 18 | + | setTimeout(next, 1500); | |
| 19 | + | } | |
| 20 | + | next(); | |
| 21 | + | } |
| @@ -0,0 +1,214 @@ | |||
| 1 | + | (function() { | |
| 2 | + | const fileInput = document.getElementById('import-file'); | |
| 3 | + | const previewEl = document.getElementById('csv-preview'); | |
| 4 | + | const previewTable = document.getElementById('preview-table'); | |
| 5 | + | const mappingEl = document.getElementById('column-mapping'); | |
| 6 | + | const startBtn = document.getElementById('start-import-btn'); | |
| 7 | + | const progressEl = document.getElementById('import-progress'); | |
| 8 | + | const resultEl = document.getElementById('import-result'); | |
| 9 | + | ||
| 10 | + | const csrfToken = document.getElementById('dashboard-import-inline-cfg').dataset.csrfToken; | |
| 11 | + | ||
| 12 | + | let csvBase64 = ''; | |
| 13 | + | let csvHeaders = []; | |
| 14 | + | ||
| 15 | + | fileInput.addEventListener('change', function(e) { | |
| 16 | + | const file = e.target.files[0]; | |
| 17 | + | if (!file) return; | |
| 18 | + | ||
| 19 | + | const reader = new FileReader(); | |
| 20 | + | reader.onload = function(ev) { | |
| 21 | + | const text = ev.target.result; | |
| 22 | + | csvBase64 = btoa(unescape(encodeURIComponent(text))); | |
| 23 | + | ||
| 24 | + | const lines = text.split('\n').filter(l => l.trim()); | |
| 25 | + | if (lines.length < 2) { | |
| 26 | + | alert('CSV must have a header row and at least one data row.'); | |
| 27 | + | return; | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | csvHeaders = parseCSVLine(lines[0]); | |
| 31 | + | ||
| 32 | + | // Build preview table | |
| 33 | + | let html = '<thead><tr>'; | |
| 34 | + | csvHeaders.forEach(h => { html += '<th>' + escapeHtml(h) + '</th>'; }); | |
| 35 | + | html += '</tr></thead><tbody>'; | |
| 36 | + | for (let i = 1; i < Math.min(lines.length, 4); i++) { | |
| 37 | + | const cols = parseCSVLine(lines[i]); | |
| 38 | + | html += '<tr>'; | |
| 39 | + | csvHeaders.forEach((_, ci) => { | |
| 40 | + | html += '<td>' + escapeHtml(cols[ci] || '') + '</td>'; | |
| 41 | + | }); | |
| 42 | + | html += '</tr>'; | |
| 43 | + | } | |
| 44 | + | html += '</tbody>'; | |
| 45 | + | previewTable.innerHTML = html; | |
| 46 | + | previewEl.classList.remove('hidden'); | |
| 47 | + | ||
| 48 | + | // Populate mapping selects | |
| 49 | + | const selects = mappingEl.querySelectorAll('select'); | |
| 50 | + | selects.forEach(sel => { | |
| 51 | + | const current = sel.value; | |
| 52 | + | sel.innerHTML = '<option value="">-- skip --</option>'; | |
| 53 | + | csvHeaders.forEach((h, i) => { | |
| 54 | + | const opt = document.createElement('option'); | |
| 55 | + | opt.value = i; | |
| 56 | + | opt.textContent = h; | |
| 57 | + | sel.appendChild(opt); | |
| 58 | + | }); | |
| 59 | + | // Auto-detect common column names | |
| 60 | + | autoDetectColumn(sel, csvHeaders); | |
| 61 | + | }); | |
| 62 | + | mappingEl.classList.remove('hidden'); | |
| 63 | + | }; | |
| 64 | + | reader.readAsText(file); | |
| 65 | + | }); | |
| 66 | + | ||
| 67 | + | function autoDetectColumn(select, headers) { | |
| 68 | + | const id = select.id; | |
| 69 | + | const lower = headers.map(h => h.toLowerCase().trim()); | |
| 70 | + | const patterns = { | |
| 71 | + | 'map-email': ['email', 'e-mail', 'email_address', 'subscriber_email'], | |
| 72 | + | 'map-name': ['name', 'full_name', 'display_name', 'subscriber_name'], | |
| 73 | + | 'map-amount': ['amount', 'total', 'price', 'payment', 'lifetime_amount'], | |
| 74 | + | 'map-date': ['date', 'created_at', 'joined', 'start_date', 'signup_date'], | |
| 75 | + | 'map-item-title': ['item', 'product', 'title', 'item_title', 'product_name'], | |
| 76 | + | 'map-tier': ['tier', 'plan', 'level', 'membership'], | |
| 77 | + | 'map-status': ['status', 'state', 'active'] | |
| 78 | + | }; | |
| 79 | + | const p = patterns[id] || []; | |
| 80 | + | for (let i = 0; i < lower.length; i++) { | |
| 81 | + | if (p.includes(lower[i])) { | |
| 82 | + | select.value = i; | |
| 83 | + | return; | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | startBtn.addEventListener('click', function() { | |
| 89 | + | const projectId = document.getElementById('import-project').value; | |
| 90 | + | if (!csvBase64) { | |
| 91 | + | alert('Please select a CSV file first.'); | |
| 92 | + | return; | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | const mapping = {}; | |
| 96 | + | const mapEmail = document.getElementById('map-email').value; | |
| 97 | + | const mapName = document.getElementById('map-name').value; | |
| 98 | + | const mapAmount = document.getElementById('map-amount').value; | |
| 99 | + | const mapDate = document.getElementById('map-date').value; | |
| 100 | + | const mapItem = document.getElementById('map-item-title').value; | |
| 101 | + | const mapTier = document.getElementById('map-tier').value; | |
| 102 | + | const mapStatus = document.getElementById('map-status').value; | |
| 103 | + | ||
| 104 | + | if (mapEmail) mapping.email = parseInt(mapEmail); | |
| 105 | + | if (mapName) mapping.name = parseInt(mapName); | |
| 106 | + | if (mapAmount) mapping.amount = parseInt(mapAmount); | |
| 107 | + | if (mapDate) mapping.date = parseInt(mapDate); | |
| 108 | + | if (mapItem) mapping.item_title = parseInt(mapItem); | |
| 109 | + | if (mapTier) mapping.tier = parseInt(mapTier); | |
| 110 | + | if (mapStatus) mapping.status = parseInt(mapStatus); | |
| 111 | + | ||
| 112 | + | if (!mapping.email && !mapping.amount) { | |
| 113 | + | alert('Please map at least an email or amount column.'); | |
| 114 | + | return; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | startBtn.disabled = true; | |
| 118 | + | startBtn.textContent = 'Starting...'; | |
| 119 | + | ||
| 120 | + | fetch('/api/users/me/import', { | |
| 121 | + | method: 'POST', | |
| 122 | + | headers: { | |
| 123 | + | 'Content-Type': 'application/json', | |
| 124 | + | 'X-CSRF-Token': csrfToken | |
| 125 | + | }, | |
| 126 | + | body: JSON.stringify({ | |
| 127 | + | project_id: projectId, | |
| 128 | + | source: 'generic_csv', | |
| 129 | + | csv_data: csvBase64, | |
| 130 | + | column_mapping: mapping | |
| 131 | + | }) | |
| 132 | + | }) | |
| 133 | + | .then(r => r.json()) | |
| 134 | + | .then(data => { | |
| 135 | + | if (data.error) { | |
| 136 | + | resultEl.className = 'import-result error'; | |
| 137 | + | resultEl.textContent = data.error; | |
| 138 | + | startBtn.disabled = false; | |
| 139 | + | startBtn.textContent = 'Start Import'; | |
| 140 | + | return; | |
| 141 | + | } | |
| 142 | + | progressEl.classList.remove('hidden'); | |
| 143 | + | pollProgress(data.job_id); | |
| 144 | + | }) | |
| 145 | + | .catch(err => { | |
| 146 | + | resultEl.className = 'import-result error'; | |
| 147 | + | resultEl.textContent = 'Failed to start import: ' + err.message; | |
| 148 | + | startBtn.disabled = false; | |
| 149 | + | startBtn.textContent = 'Start Import'; | |
| 150 | + | }); | |
| 151 | + | }); | |
| 152 | + | ||
| 153 | + | function pollProgress(jobId) { | |
| 154 | + | const interval = setInterval(() => { | |
| 155 | + | fetch('/api/users/me/import/' + jobId) | |
| 156 | + | .then(r => r.json()) | |
| 157 | + | .then(data => { | |
| 158 | + | const pct = data.total_rows > 0 | |
| 159 | + | ? Math.round((data.processed_rows / data.total_rows) * 100) | |
| 160 | + | : 0; | |
| 161 | + | document.getElementById('progress-bar').style.width = pct + '%'; | |
| 162 | + | document.getElementById('stat-processed').textContent = data.processed_rows; | |
| 163 | + | document.getElementById('stat-created').textContent = data.created_rows; | |
| 164 | + | document.getElementById('stat-skipped').textContent = data.skipped_rows; | |
| 165 | + | ||
| 166 | + | if (data.status === 'completed' || data.status === 'failed') { | |
| 167 | + | clearInterval(interval); | |
| 168 | + | progressEl.classList.add('hidden'); | |
| 169 | + | ||
| 170 | + | resultEl.classList.remove('hidden'); | |
| 171 | + | if (data.status === 'completed') { | |
| 172 | + | resultEl.className = 'import-result success'; | |
| 173 | + | resultEl.innerHTML = '<strong>Import complete.</strong> ' + | |
| 174 | + | data.created_rows + ' created, ' + | |
| 175 | + | data.skipped_rows + ' skipped.'; | |
| 176 | + | } else { | |
| 177 | + | resultEl.className = 'import-result error'; | |
| 178 | + | resultEl.innerHTML = '<strong>Import failed.</strong>'; | |
| 179 | + | } | |
| 180 | + | if (data.error_log) { | |
| 181 | + | resultEl.innerHTML += '<div class="error-log">' + | |
| 182 | + | escapeHtml(data.error_log) + '</div>'; | |
| 183 | + | } | |
| 184 | + | startBtn.disabled = false; | |
| 185 | + | startBtn.textContent = 'Start Import'; | |
| 186 | + | } | |
| 187 | + | }); | |
| 188 | + | }, 2000); | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | function parseCSVLine(line) { | |
| 192 | + | const result = []; | |
| 193 | + | let current = ''; | |
| 194 | + | let inQuotes = false; | |
| 195 | + | for (let i = 0; i < line.length; i++) { | |
| 196 | + | const ch = line[i]; | |
| 197 | + | if (inQuotes) { | |
| 198 | + | if (ch === '"' && line[i+1] === '"') { current += '"'; i++; } | |
| 199 | + | else if (ch === '"') { inQuotes = false; } | |
| 200 | + | else { current += ch; } | |
| 201 | + | } else { | |
| 202 | + | if (ch === '"') { inQuotes = true; } | |
| 203 | + | else if (ch === ',') { result.push(current.trim()); current = ''; } | |
| 204 | + | else { current += ch; } | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | result.push(current.trim()); | |
| 208 | + | return result; | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | function escapeHtml(s) { | |
| 212 | + | return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); | |
| 213 | + | } | |
| 214 | + | })(); |
| @@ -0,0 +1,26 @@ | |||
| 1 | + | const audio = new Audio(); | |
| 2 | + | let loaded = false; | |
| 3 | + | // Read the preview URL from the data- attribute rather than interpolating it | |
| 4 | + | // into this JS string. Askama autoescapes for HTML, which is the correct | |
| 5 | + | // escaper for an attribute value but NOT for a JS string literal — keeping the | |
| 6 | + | // URL in the attribute keeps escaping correct even once preview URLs derive | |
| 7 | + | // from user-influenced filenames. | |
| 8 | + | const previewUrl = document.querySelector('.player').dataset.previewUrl; | |
| 9 | + | function togglePlay() { | |
| 10 | + | if (!loaded) { audio.src = previewUrl; loaded = true; } | |
| 11 | + | if (audio.paused) { audio.play(); document.getElementById('play').innerHTML = '▮▮'; } | |
| 12 | + | else { audio.pause(); document.getElementById('play').innerHTML = '▶'; } | |
| 13 | + | } | |
| 14 | + | audio.ontimeupdate = () => { | |
| 15 | + | const pct = (audio.currentTime / audio.duration) * 100; | |
| 16 | + | document.getElementById('progress').style.width = pct + '%'; | |
| 17 | + | const m = Math.floor(audio.currentTime / 60); | |
| 18 | + | const s = Math.floor(audio.currentTime % 60); | |
| 19 | + | document.getElementById('time').textContent = m + ':' + (s < 10 ? '0' : '') + s; | |
| 20 | + | }; | |
| 21 | + | audio.onended = () => { document.getElementById('play').innerHTML = '▶'; }; | |
| 22 | + | function seek(e) { | |
| 23 | + | if (!audio.duration) return; | |
| 24 | + | const rect = e.currentTarget.getBoundingClientRect(); | |
| 25 | + | audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration; | |
| 26 | + | } |
| @@ -0,0 +1,40 @@ | |||
| 1 | + | (function() { | |
| 2 | + | var cfg = document.getElementById('page-buy-cfg'); | |
| 3 | + | if (!cfg) return; | |
| 4 | + | var hostUrl = cfg.dataset.hostUrl; | |
| 5 | + | var itemId = cfg.dataset.itemId; | |
| 6 | + | var pwywEnabled = cfg.dataset.pwywEnabled === 'true'; | |
| 7 | + | ||
| 8 | + | window.buy = function() { | |
| 9 | + | const btn = document.getElementById('buy-btn'); | |
| 10 | + | btn.disabled = true; | |
| 11 | + | btn.textContent = 'Redirecting...'; | |
| 12 | + | ||
| 13 | + | const body = {}; | |
| 14 | + | if (pwywEnabled) { | |
| 15 | + | const amount = parseFloat(document.getElementById('pwyw_amount').value); | |
| 16 | + | body.amount_cents = Math.round(amount * 100); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | fetch(hostUrl + '/api/checkout/guest/' + itemId, { | |
| 20 | + | method: 'POST', | |
| 21 | + | headers: { 'Content-Type': 'application/json' }, | |
| 22 | + | body: JSON.stringify(body), | |
| 23 | + | }) | |
| 24 | + | .then(r => r.json()) | |
| 25 | + | .then(data => { | |
| 26 | + | if (data.checkout_url) { | |
| 27 | + | window.location.href = data.checkout_url; | |
| 28 | + | } else { | |
| 29 | + | btn.disabled = false; | |
| 30 | + | btn.textContent = 'Buy Now'; | |
| 31 | + | alert(data.error || 'Something went wrong'); | |
| 32 | + | } | |
| 33 | + | }) | |
| 34 | + | .catch(() => { | |
| 35 | + | btn.disabled = false; | |
| 36 | + | btn.textContent = 'Buy Now'; | |
| 37 | + | alert('Something went wrong. Please try again.'); | |
| 38 | + | }); | |
| 39 | + | }; | |
| 40 | + | })(); |
| @@ -0,0 +1,49 @@ | |||
| 1 | + | (function() { | |
| 2 | + | document.querySelectorAll('.pwyw-cart-input').forEach(function(input) { | |
| 3 | + | var debounce; | |
| 4 | + | input.addEventListener('change', function() { | |
| 5 | + | clearTimeout(debounce); | |
| 6 | + | var itemId = this.dataset.itemId; | |
| 7 | + | var dollars = parseFloat(this.value) || 0; | |
| 8 | + | var cents = Math.round(dollars * 100); | |
| 9 | + | debounce = setTimeout(function() { | |
| 10 | + | fetch('/api/cart/' + itemId, { | |
| 11 | + | method: 'PUT', | |
| 12 | + | headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), | |
| 13 | + | body: JSON.stringify({ amount_cents: cents }) | |
| 14 | + | }).then(function(r) { | |
| 15 | + | if (!r.ok) return r.json().then(function(d) { showToast(d.error || 'Invalid amount'); }); | |
| 16 | + | }).catch(function() { showToast('Failed to update amount'); }); | |
| 17 | + | }, 300); | |
| 18 | + | }); | |
| 19 | + | }); | |
| 20 | + | })(); | |
| 21 | + | ||
| 22 | + | window.removeCartGroup = function(btn) { | |
| 23 | + | var name = btn.dataset.sellerName || 'this creator'; | |
| 24 | + | if (!confirm('Remove all items from ' + name + '?')) return; | |
| 25 | + | var group = btn.closest('.cart-group'); | |
| 26 | + | if (!group) return; | |
| 27 | + | var rows = group.querySelectorAll('tr[id^="cart-row-"]'); | |
| 28 | + | if (rows.length === 0) return; | |
| 29 | + | btn.disabled = true; | |
| 30 | + | var headers = csrfHeaders(); | |
| 31 | + | var pending = rows.length; | |
| 32 | + | var failed = false; | |
| 33 | + | function finish() { | |
| 34 | + | if (--pending !== 0) return; | |
| 35 | + | if (failed) { | |
| 36 | + | showToast('Some items could not be removed. Refreshing.'); | |
| 37 | + | window.location.reload(); | |
| 38 | + | } else { | |
| 39 | + | window.location.reload(); | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | rows.forEach(function(row) { | |
| 43 | + | var id = row.id.replace('cart-row-', ''); | |
| 44 | + | fetch('/api/cart/' + id, { method: 'DELETE', headers: headers }) | |
| 45 | + | .then(function(r) { if (!r.ok) failed = true; }) | |
| 46 | + | .catch(function() { failed = true; }) | |
| 47 | + | .finally(finish); | |
| 48 | + | }); | |
| 49 | + | }; |
| @@ -0,0 +1,114 @@ | |||
| 1 | + | // Sync filter UI state on filter click: update active highlight and write | |
| 2 | + | // the selected filter value into the hidden form input so subsequent | |
| 3 | + | // search/sort submissions include it. | |
| 4 | + | document.body.addEventListener('htmx:beforeRequest', function(evt) { | |
| 5 | + | if (evt.detail.elt.classList.contains('filter-item')) { | |
| 6 | + | // Only deactivate siblings in the same filter section | |
| 7 | + | var section = evt.detail.elt.closest('.filter-list'); | |
| 8 | + | if (section) { | |
| 9 | + | section.querySelectorAll('.filter-item').forEach(function(i) { i.classList.remove('is-selected'); }); | |
| 10 | + | } | |
| 11 | + | evt.detail.elt.classList.add('is-selected'); | |
| 12 | + | ||
| 13 | + | var hxVals = JSON.parse(evt.detail.elt.getAttribute('hx-vals') || '{}'); | |
| 14 | + | if ('item_type' in hxVals) { | |
| 15 | + | document.getElementById('type-input').value = hxVals.item_type || ''; | |
| 16 | + | } | |
| 17 | + | if ('tag' in hxVals) { | |
| 18 | + | document.getElementById('tag-input').value = hxVals.tag || ''; | |
| 19 | + | } | |
| 20 | + | if ('category' in hxVals) { | |
| 21 | + | document.getElementById('category-input').value = hxVals.category || ''; | |
| 22 | + | } | |
| 23 | + | if ('label' in hxVals) { | |
| 24 | + | document.getElementById('label-input').value = hxVals.label || ''; | |
| 25 | + | } | |
| 26 | + | if ('ai_tier' in hxVals) { | |
| 27 | + | document.getElementById('ai-tier-input').value = hxVals.ai_tier || ''; | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | }); | |
| 31 | + | ||
| 32 | + | // View toggle (list vs grid) with localStorage persistence. | |
| 33 | + | // Re-applied after HTMX swaps because new content replaces the container. | |
| 34 | + | (function() { | |
| 35 | + | function applyView(view) { | |
| 36 | + | var container = document.getElementById('results-container-inner'); | |
| 37 | + | if (container) { | |
| 38 | + | container.className = 'results-container results-' + view; | |
| 39 | + | } | |
| 40 | + | document.querySelectorAll('.view-btn').forEach(function(btn) { | |
| 41 | + | btn.classList.toggle('is-selected', btn.dataset.view === view); | |
| 42 | + | }); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Load saved preference on page load | |
| 46 | + | document.addEventListener('DOMContentLoaded', function() { | |
| 47 | + | var saved = safeStorageGet('discoverViewPref') || 'grid'; | |
| 48 | + | applyView(saved); | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | // Handle view button clicks | |
| 52 | + | document.querySelectorAll('.view-btn').forEach(function(btn) { | |
| 53 | + | btn.addEventListener('click', function() { | |
| 54 | + | var view = btn.dataset.view; | |
| 55 | + | applyView(view); | |
| 56 | + | safeStorageSet('discoverViewPref', view); | |
| 57 | + | }); | |
| 58 | + | }); | |
| 59 | + | ||
| 60 | + | // Re-apply view preference after HTMX swaps new content | |
| 61 | + | document.body.addEventListener('htmx:afterSwap', function(evt) { | |
| 62 | + | if (evt.detail.target.id === 'results-container') { | |
| 63 | + | var saved = safeStorageGet('discoverViewPref') || 'grid'; | |
| 64 | + | applyView(saved); | |
| 65 | + | } | |
| 66 | + | }); | |
| 67 | + | })(); | |
| 68 | + | ||
| 69 | + | // Search suggestions autocomplete | |
| 70 | + | (function() { | |
| 71 | + | var input = document.getElementById('search-input'); | |
| 72 | + | var box = document.getElementById('search-suggestions'); | |
| 73 | + | var timer = null; | |
| 74 | + | var selectedIdx = -1; | |
| 75 | + | ||
| 76 | + | input.addEventListener('input', function() { | |
| 77 | + | clearTimeout(timer); | |
| 78 | + | var q = input.value.trim(); | |
| 79 | + | if (q.length < 2) { box.innerHTML = ''; box.style.display = 'none'; return; } | |
| 80 | + | timer = setTimeout(function() { | |
| 81 | + | fetch('/discover/suggestions?q=' + encodeURIComponent(q)) | |
| 82 | + | .then(function(r) { return r.json(); }) | |
| 83 | + | .then(function(items) { | |
| 84 | + | if (items.length === 0) { box.innerHTML = ''; box.style.display = 'none'; return; } | |
| 85 | + | selectedIdx = -1; | |
| 86 | + | box.innerHTML = items.map(function(s, i) { | |
| 87 | + | return '<a href="' + escapeHtml(s.url) + '" class="suggestion-item" data-idx="' + i + '">' | |
| 88 | + | + '<span class="suggestion-label">' + escapeHtml(s.label) + '</span>' | |
| 89 | + | + '<span class="suggestion-category">' + escapeHtml(s.category) + '</span></a>'; | |
| 90 | + | }).join(''); | |
| 91 | + | box.style.display = 'block'; | |
| 92 | + | }); | |
| 93 | + | }, 200); | |
| 94 | + | }); | |
| 95 | + | ||
| 96 | + | input.addEventListener('keydown', function(e) { | |
| 97 | + | var items = box.querySelectorAll('.suggestion-item'); | |
| 98 | + | if (!items.length) return; | |
| 99 | + | if (e.key === 'ArrowDown') { e.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, items.length - 1); updateHighlight(items); } | |
| 100 | + | else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, -1); updateHighlight(items); } | |
| 101 | + | else if (e.key === 'Enter' && selectedIdx >= 0) { e.preventDefault(); items[selectedIdx].click(); } | |
| 102 | + | else if (e.key === 'Escape') { box.style.display = 'none'; } | |
| 103 | + | }); | |
| 104 | + | ||
| 105 | + | function updateHighlight(items) { | |
| 106 | + | items.forEach(function(el, i) { el.classList.toggle('highlighted', i === selectedIdx); }); | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | function escapeHtml(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; } | |
| 110 | + | ||
| 111 | + | document.addEventListener('click', function(e) { | |
| 112 | + | if (!box.contains(e.target) && e.target !== input) { box.style.display = 'none'; } | |
| 113 | + | }); | |
| 114 | + | })(); |
| @@ -0,0 +1,7 @@ | |||
| 1 | + | (function(){ | |
| 2 | + | function hl(){var h=location.hash;if(h&&h.match(/^#L\d+$/)){ | |
| 3 | + | document.querySelectorAll('.git-file-content tr.highlighted').forEach(function(r){r.classList.remove('highlighted')}); | |
| 4 | + | var el=document.getElementById(h.slice(1));if(el)el.classList.add('highlighted'); | |
| 5 | + | }} | |
| 6 | + | window.addEventListener('hashchange',hl);hl(); | |
| 7 | + | })(); |
| @@ -0,0 +1,30 @@ | |||
| 1 | + | function submitNotify(e) { | |
| 2 | + | e.preventDefault(); | |
| 3 | + | var form = document.getElementById('notify-form'); | |
| 4 | + | var status = document.getElementById('notify-status'); | |
| 5 | + | var email = form.querySelector('input[name="email"]').value; | |
| 6 | + | var btn = form.querySelector('button'); | |
| 7 | + | btn.disabled = true; | |
| 8 | + | btn.textContent = 'Sending...'; | |
| 9 | + | status.textContent = ''; | |
| 10 | + | fetch('/api/email-signup', { | |
| 11 | + | method: 'POST', | |
| 12 | + | headers: {'Content-Type': 'application/json'}, | |
| 13 | + | body: JSON.stringify({email: email}) | |
| 14 | + | }).then(function(r) { | |
| 15 | + | if (r.ok) { | |
| 16 | + | status.textContent = 'You\'re on the list.'; | |
| 17 | + | status.className = 'notify-status success'; | |
| 18 | + | form.querySelector('input[name="email"]').value = ''; | |
| 19 | + | } else { | |
| 20 | + | return r.json().then(function(d) { throw new Error(d.error || 'Something went wrong'); }); | |
| 21 | + | } | |
| 22 | + | }).catch(function(err) { | |
| 23 | + | status.textContent = err.message; | |
| 24 | + | status.className = 'notify-status error'; | |
| 25 | + | }).finally(function() { | |
| 26 | + | btn.disabled = false; | |
| 27 | + | btn.textContent = 'Notify Me'; | |
| 28 | + | }); | |
| 29 | + | return false; | |
| 30 | + | } |
| @@ -0,0 +1,18 @@ | |||
| 1 | + | (function() { | |
| 2 | + | var cfg = document.getElementById('page-item-1-cfg'); | |
| 3 | + | var itemId = cfg.dataset.itemId; | |
| 4 | + | var content = document.getElementById('license-text-content'); | |
| 5 | + | if (!content) return; | |
| 6 | + | var details = content.closest('details'); | |
| 7 | + | if (!details) return; | |
| 8 | + | var loaded = false; | |
| 9 | + | details.addEventListener('toggle', function() { | |
| 10 | + | if (details.open && !loaded) { | |
| 11 | + | loaded = true; | |
| 12 | + | fetch('/api/items/' + itemId + '/license.txt') | |
| 13 | + | .then(function(r) { return r.text(); }) | |
| 14 | + | .then(function(t) { content.textContent = t; }) | |
| 15 | + | .catch(function() { content.textContent = 'Failed to load license text.'; }); | |
| 16 | + | } | |
| 17 | + | }); | |
| 18 | + | })(); |
| @@ -0,0 +1,44 @@ | |||
| 1 | + | function switchSectionTab(btn, panelId) { | |
| 2 | + | document.querySelectorAll('.section-tab').forEach(function(t) { t.classList.remove('is-selected'); }); | |
| 3 | + | document.querySelectorAll('.section-panel').forEach(function(p) { p.classList.remove('active'); }); | |
| 4 | + | btn.classList.add('is-selected'); | |
| 5 | + | var panel = document.getElementById(panelId); | |
| 6 | + | if (panel) panel.classList.add('active'); | |
| 7 | + | history.replaceState(null, '', '#' + panelId); | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | (function() { | |
| 11 | + | var hash = window.location.hash.replace('#', ''); | |
| 12 | + | if (hash) { | |
| 13 | + | var panel = document.getElementById(hash); | |
| 14 | + | var tab = document.querySelector('[data-tab="' + hash + '"]'); | |
| 15 | + | if (panel && tab) switchSectionTab(tab, hash); | |
| 16 | + | } | |
| 17 | + | })(); | |
| 18 | + | ||
| 19 | + | (function() { | |
| 20 | + | var cfg = document.getElementById('page-item-2-cfg'); | |
| 21 | + | var itemId = cfg.dataset.itemId; | |
| 22 | + | var player = document.getElementById('item-player'); | |
| 23 | + | if (player) { | |
| 24 | + | var loaded = false; | |
| 25 | + | player.addEventListener('play', function loadSrc() { | |
| 26 | + | if (!loaded) { | |
| 27 | + | loaded = true; | |
| 28 | + | player.pause(); | |
| 29 | + | fetch('/api/stream/' + itemId) | |
| 30 | + | .then(function(r) { | |
| 31 | + | if (!r.ok) throw new Error('Stream unavailable'); | |
| 32 | + | return r.json(); | |
| 33 | + | }) | |
| 34 | + | .then(function(data) { | |
| 35 | + | player.src = data.stream_url; | |
| 36 | + | player.play(); | |
| 37 | + | }) | |
| 38 | + | .catch(function(err) { | |
| 39 | + | showToast(err.message || 'Could not load video'); | |
| 40 | + | }); | |
| 41 | + | } | |
| 42 | + | }, { once: true }); | |
| 43 | + | } | |
| 44 | + | })(); |
| @@ -0,0 +1,31 @@ | |||
| 1 | + | (function() { | |
| 2 | + | window.toggleWishlist = function(itemId) { | |
| 3 | + | var btn = document.getElementById('wishlist-btn'); | |
| 4 | + | fetch('/api/wishlists/' + itemId, { method: 'POST', headers: csrfHeaders() }) | |
| 5 | + | .then(function(r) { return r.json(); }) | |
| 6 | + | .then(function(data) { | |
| 7 | + | if (data.wishlisted) { | |
| 8 | + | btn.textContent = 'Wishlisted'; | |
| 9 | + | } else { | |
| 10 | + | btn.textContent = 'Add to Wishlist'; | |
| 11 | + | } | |
| 12 | + | }); | |
| 13 | + | }; | |
| 14 | + | ||
| 15 | + | window.toggleCart = function(itemId) { | |
| 16 | + | var btn = document.getElementById('cart-btn'); | |
| 17 | + | fetch('/api/cart/' + itemId, { method: 'POST', headers: csrfHeaders() }) | |
| 18 | + | .then(function(r) { return r.json(); }) | |
| 19 | + | .then(function(data) { | |
| 20 | + | if (data.in_cart) { | |
| 21 | + | btn.textContent = 'In Cart'; | |
| 22 | + | showToast('Added to cart. Buying multiple items together saves the creator on processing fees.', 'info'); | |
| 23 | + | } else { | |
| 24 | + | btn.textContent = 'Add to Cart'; | |
| 25 | + | } | |
| 26 | + | }) | |
| 27 | + | .catch(function(err) { | |
| 28 | + | showToast(err.message || 'Failed to update cart'); | |
| 29 | + | }); | |
| 30 | + | }; | |
| 31 | + | })(); |
| @@ -0,0 +1,13 @@ | |||
| 1 | + | function downloadVersion(versionId) { | |
| 2 | + | fetch('/api/versions/' + versionId + '/download') | |
| 3 | + | .then(function(res) { | |
| 4 | + | if (!res.ok) throw new Error('Failed to get download URL'); | |
| 5 | + | return res.json(); | |
| 6 | + | }) | |
| 7 | + | .then(function(data) { | |
| 8 | + | window.location.href = data.download_url; | |
| 9 | + | }) | |
| 10 | + | .catch(function(err) { | |
| 11 | + | showToast(err.message || 'Download failed'); | |
| 12 | + | }); | |
| 13 | + | } |
| @@ -0,0 +1,31 @@ | |||
| 1 | + | function switchSectionTab(btn, panelId) { | |
| 2 | + | document.querySelectorAll('.section-tab').forEach(function(t) { t.classList.remove('is-selected'); }); | |
| 3 | + | document.querySelectorAll('.section-panel').forEach(function(p) { p.classList.remove('active'); }); | |
| 4 | + | btn.classList.add('is-selected'); | |
| 5 | + | var panel = document.getElementById(panelId); | |
| 6 | + | if (panel) panel.classList.add('active'); | |
| 7 | + | history.replaceState(null, '', '#' + panelId); | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | (function() { | |
| 11 | + | var hash = window.location.hash.replace('#', ''); | |
| 12 | + | if (hash) { | |
| 13 | + | var panel = document.getElementById(hash); | |
| 14 | + | var tab = document.querySelector('[data-tab="' + hash + '"]'); | |
| 15 | + | if (panel && tab) switchSectionTab(tab, hash); | |
| 16 | + | } | |
| 17 | + | })(); | |
| 18 | + | ||
| 19 | + | function downloadVersion(versionId) { | |
| 20 | + | fetch('/api/versions/' + versionId + '/download') | |
| 21 | + | .then(function(res) { | |
| 22 | + | if (!res.ok) throw new Error('Failed to get download URL'); | |
| 23 | + | return res.json(); | |
| 24 | + | }) | |
| 25 | + | .then(function(data) { | |
| 26 | + | window.location.href = data.download_url; | |
| 27 | + | }) | |
| 28 | + | .catch(function(err) { | |
| 29 | + | showToast(err.message || 'Download failed'); | |
| 30 | + | }); | |
| 31 | + | } |
| @@ -0,0 +1,13 @@ | |||
| 1 | + | function downloadVersion(versionId) { | |
| 2 | + | fetch('/api/versions/' + versionId + '/download') | |
| 3 | + | .then(function(res) { | |
| 4 | + | if (!res.ok) throw new Error('Failed to get download URL'); | |
| 5 | + | return res.json(); | |
| 6 | + | }) | |
| 7 | + | .then(function(data) { | |
| 8 | + | window.location.href = data.download_url; | |
| 9 | + | }) | |
| 10 | + | .catch(function(err) { | |
| 11 | + | showToast(err.message || 'Download failed'); | |
| 12 | + | }); | |
| 13 | + | } |
| @@ -0,0 +1,5 @@ | |||
| 1 | + | // Show passkey button if browser supports WebAuthn (absent in SSO-only mode) | |
| 2 | + | var passkeyLogin = document.getElementById('passkey-login'); | |
| 3 | + | if (window.PublicKeyCredential && passkeyLogin) { | |
| 4 | + | passkeyLogin.classList.remove('hidden'); | |
| 5 | + | } |
| @@ -0,0 +1,6 @@ | |||
| 1 | + | // Handle successful login redirect via HX-Redirect header | |
| 2 | + | document.body.addEventListener('htmx:beforeSwap', function(evt) { | |
| 3 | + | if (evt.detail.xhr.status === 200 && evt.detail.xhr.getResponseHeader('HX-Redirect')) { | |
| 4 | + | // Let HTMX handle the redirect | |
| 5 | + | } | |
| 6 | + | }); |
| @@ -0,0 +1,256 @@ | |||
| 1 | + | (function() { | |
| 2 | + | 'use strict'; | |
| 3 | + | ||
| 4 | + | var cfg = document.getElementById('page-pricing-cfg'); | |
| 5 | + | var basicStd = cfg ? parseInt(cfg.dataset.basicStd, 10) : 0; | |
| 6 | + | ||
| 7 | + | var STRIPE_PERCENT = 0.029; | |
| 8 | + | var STRIPE_FIXED = 0.30; | |
| 9 | + | ||
| 10 | + | // Average transactions per month at a given revenue level. | |
| 11 | + | // Assumes ~$25 average transaction for simplicity. | |
| 12 | + | function estimateTransactions(revenue) { | |
| 13 | + | if (revenue <= 0) return 0; | |
| 14 | + | return Math.max(1, Math.round(revenue / 25)); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | function stripeNet(revenue) { | |
| 18 | + | if (revenue <= 0) return 0; | |
| 19 | + | var txns = estimateTransactions(revenue); | |
| 20 | + | return revenue - (revenue * STRIPE_PERCENT) - (txns * STRIPE_FIXED); | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | var competitors = [ | |
| 24 | + | { | |
| 25 | + | name: 'Patreon', | |
| 26 | + | url: 'https://www.patreon.com/pricing', | |
| 27 | + | fee: '10% + ~3% + $0.30', | |
| 28 | + | calc: function(rev) { | |
| 29 | + | if (rev <= 0) return 0; | |
| 30 | + | var txns = estimateTransactions(rev); | |
| 31 | + | var afterPlatform = rev * 0.90; | |
| 32 | + | return afterPlatform - (afterPlatform * STRIPE_PERCENT) - (txns * STRIPE_FIXED); | |
| 33 | + | } | |
| 34 | + | }, | |
| 35 | + | { | |
| 36 | + | name: 'Gumroad', | |
| 37 | + | url: 'https://gumroad.com/pricing', | |
| 38 | + | fee: '10% + $0.50/tx + ~3%', | |
| 39 | + | calc: function(rev) { | |
| 40 | + | if (rev <= 0) return 0; | |
| 41 | + | var txns = estimateTransactions(rev); | |
| 42 | + | var afterPlatform = rev * 0.90 - (txns * 0.50); | |
| 43 | + | return afterPlatform - (afterPlatform * STRIPE_PERCENT); | |
| 44 | + | } | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | name: 'Bandcamp', | |
| 48 | + | url: 'https://bandcamp.com/pricing', | |
| 49 | + | fee: '15% (10% after $5k) + ~3%', | |
| 50 | + | calc: function(rev) { | |
| 51 | + | if (rev <= 0) return 0; | |
| 52 | + | var rate = rev > 5000 ? 0.10 : 0.15; | |
| 53 | + | var afterPlatform = rev * (1 - rate); | |
| 54 | + | return afterPlatform - (afterPlatform * STRIPE_PERCENT); | |
| 55 | + | } | |
| 56 | + | }, | |
| 57 | + | { | |
| 58 | + | name: 'Ko-fi Free', | |
| 59 | + | url: 'https://ko-fi.com/pricing', | |
| 60 | + | fee: '0% tips, 5% shop/memberships + ~3%', | |
| 61 | + | calc: function(rev) { | |
| 62 | + | if (rev <= 0) return 0; | |
| 63 | + | var afterPlatform = rev * 0.95; | |
| 64 | + | return afterPlatform - (afterPlatform * STRIPE_PERCENT); | |
| 65 | + | } | |
| 66 | + | }, | |
| 67 | + | { | |
| 68 | + | name: 'Ko-fi Gold', | |
| 69 | + | url: 'https://ko-fi.com/pricing', | |
| 70 | + | fee: '$12/mo + 0% + ~3%', | |
| 71 | + | calc: function(rev) { | |
| 72 | + | if (rev <= 0) return -12; | |
| 73 | + | return stripeNet(rev) - 12; | |
| 74 | + | } | |
| 75 | + | }, | |
| 76 | + | { | |
| 77 | + | name: 'Itch.io', | |
| 78 | + | url: 'https://itch.io/docs/creators/faq', | |
| 79 | + | fee: '10% default + ~3%', | |
| 80 | + | calc: function(rev) { | |
| 81 | + | if (rev <= 0) return 0; | |
| 82 | + | var afterPlatform = rev * 0.90; | |
| 83 | + | return afterPlatform - (afterPlatform * STRIPE_PERCENT); | |
| 84 | + | } | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | name: 'Lemon Squeezy', | |
| 88 | + | url: 'https://www.lemonsqueezy.com/pricing', | |
| 89 | + | fee: '5% + $0.50/tx (incl. processing)', | |
| 90 | + | calc: function(rev) { | |
| 91 | + | if (rev <= 0) return 0; | |
| 92 | + | var txns = estimateTransactions(rev); | |
| 93 | + | return rev * 0.95 - (txns * 0.50); | |
| 94 | + | } | |
| 95 | + | }, | |
| 96 | + | { | |
| 97 | + | name: 'Buy Me a Coffee', | |
| 98 | + | url: 'https://www.buymeacoffee.com/about', | |
| 99 | + | fee: '5% + ~3%', | |
| 100 | + | calc: function(rev) { | |
| 101 | + | if (rev <= 0) return 0; | |
| 102 | + | var afterPlatform = rev * 0.95; | |
| 103 | + | return afterPlatform - (afterPlatform * STRIPE_PERCENT); | |
| 104 | + | } | |
| 105 | + | }, | |
| 106 | + | { | |
| 107 | + | name: 'Substack', | |
| 108 | + | url: 'https://substack.com/going-paid', | |
| 109 | + | fee: '10% on paid subs', | |
| 110 | + | calc: function(rev) { | |
| 111 | + | if (rev <= 0) return 0; | |
| 112 | + | var afterPlatform = rev * 0.90; | |
| 113 | + | return afterPlatform - (afterPlatform * STRIPE_PERCENT); | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ]; | |
| 117 | + | ||
| 118 | + | function fmt(n) { | |
| 119 | + | return '$' + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ','); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | function fmtWhole(n) { | |
| 123 | + | return '$' + Math.round(n).toLocaleString(); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | function update() { | |
| 127 | + | var revenueInput = document.getElementById('revenue'); | |
| 128 | + | var revenue = parseFloat(revenueInput.value) || 0; | |
| 129 | + | ||
| 130 | + | var tierRadios = document.querySelectorAll('input[name="tier"]'); | |
| 131 | + | // Default falls back to the Basic-tier radio value; templates render the canonical price. | |
| 132 | + | var tierCost = basicStd; | |
| 133 | + | for (var i = 0; i < tierRadios.length; i++) { | |
| 134 | + | if (tierRadios[i].checked) { | |
| 135 | + | tierCost = parseInt(tierRadios[i].value, 10); | |
| 136 | + | break; | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // MNW net: revenue minus processing fees minus monthly membership | |
| 141 | + | var mnwNet = stripeNet(revenue) - tierCost; | |
| 142 | + | if (revenue <= 0) mnwNet = -tierCost; | |
| 143 | + | ||
| 144 | + | document.getElementById('mnw-keep').textContent = fmt(Math.max(0, mnwNet)); | |
| 145 | + | document.getElementById('mnw-detail').textContent = | |
| 146 | + | 'of every ' + fmtWhole(revenue) + ' after processing fees (~3%) and ' + fmt(tierCost) + '/mo membership'; | |
| 147 | + | ||
| 148 | + | // Build comparison rows sorted by net (descending) | |
| 149 | + | var rows = []; | |
| 150 | + | rows.push({ | |
| 151 | + | name: 'Makenot.work', | |
| 152 | + | fee: fmt(tierCost) + '/mo flat + ~3% processing', | |
| 153 | + | net: mnwNet, | |
| 154 | + | isMnw: true | |
| 155 | + | }); | |
| 156 | + | ||
| 157 | + | for (var j = 0; j < competitors.length; j++) { | |
| 158 | + | var c = competitors[j]; | |
| 159 | + | rows.push({ | |
| 160 | + | name: c.name, | |
| 161 | + | url: c.url, | |
| 162 | + | fee: c.fee, | |
| 163 | + | net: c.calc(revenue), | |
| 164 | + | isMnw: false | |
| 165 | + | }); | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | rows.sort(function(a, b) { return b.net - a.net; }); | |
| 169 | + | ||
| 170 | + | var tbody = document.getElementById('comparison-body'); | |
| 171 | + | tbody.innerHTML = ''; | |
| 172 | + | ||
| 173 | + | var breakevenMessages = []; | |
| 174 | + | ||
| 175 | + | for (var k = 0; k < rows.length; k++) { | |
| 176 | + | var row = rows[k]; | |
| 177 | + | var tr = document.createElement('tr'); | |
| 178 | + | if (row.isMnw) tr.className = 'highlight'; | |
| 179 | + | ||
| 180 | + | var tdName = document.createElement('td'); | |
| 181 | + | if (row.isMnw) { | |
| 182 | + | tdName.textContent = row.name; | |
| 183 | + | tdName.style.fontWeight = '600'; | |
| 184 | + | } else { | |
| 185 | + | var link = document.createElement('a'); | |
| 186 | + | link.href = row.url; | |
| 187 | + | link.textContent = row.name; | |
| 188 | + | link.target = '_blank'; | |
| 189 | + | link.rel = 'noopener'; | |
| 190 | + | tdName.appendChild(link); | |
| 191 | + | } | |
| 192 | + | tr.appendChild(tdName); | |
| 193 | + | ||
| 194 | + | var tdFee = document.createElement('td'); | |
| 195 | + | tdFee.textContent = row.fee; | |
| 196 | + | tr.appendChild(tdFee); | |
| 197 | + | ||
| 198 | + | var tdKeep = document.createElement('td'); | |
| 199 | + | tdKeep.textContent = revenue > 0 ? fmt(Math.max(0, row.net)) : '--'; | |
| 200 | + | tr.appendChild(tdKeep); | |
| 201 | + | ||
| 202 | + | var tdDiff = document.createElement('td'); | |
| 203 | + | if (row.isMnw) { | |
| 204 | + | tdDiff.textContent = '--'; | |
| 205 | + | } else if (revenue > 0) { | |
| 206 | + | var diff = mnwNet - row.net; | |
| 207 | + | if (diff > 0.005) { | |
| 208 | + | tdDiff.textContent = '+' + fmt(diff); | |
| 209 | + | tdDiff.className = 'savings-negative'; | |
| 210 | + | } else if (diff < -0.005) { | |
| 211 | + | tdDiff.textContent = fmt(diff); | |
| 212 | + | tdDiff.className = 'savings-positive'; | |
| 213 | + | breakevenMessages.push(row.name); | |
| 214 | + | } else { | |
| 215 | + | tdDiff.textContent = '$0.00'; | |
| 216 | + | } | |
| 217 | + | } else { | |
| 218 | + | tdDiff.textContent = '--'; | |
| 219 | + | } | |
| 220 | + | tr.appendChild(tdDiff); | |
| 221 | + | ||
| 222 | + | tbody.appendChild(tr); | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // Breakeven note | |
| 226 | + | var breakevenSection = document.getElementById('breakeven-section'); | |
| 227 | + | var breakevenNote = document.getElementById('breakeven-note'); | |
| 228 | + | if (breakevenMessages.length > 0 && revenue > 0) { | |
| 229 | + | breakevenSection.classList.remove('hidden'); | |
| 230 | + | breakevenNote.textContent = | |
| 231 | + | 'At ' + fmtWhole(revenue) + '/mo, some platforms with percentage-only fees cost less than MNW\'s flat rate. ' | |
| 232 | + | + 'MNW becomes cheaper as your revenue grows.'; | |
| 233 | + | } else { | |
| 234 | + | breakevenSection.classList.add('hidden'); | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // Tier selector visual state. Listen on the radio inputs so keyboard | |
| 239 | + | // navigation (arrow keys within the radiogroup) updates the styling. | |
| 240 | + | var tierInputs = document.querySelectorAll('.tier-option input[type="radio"]'); | |
| 241 | + | var tierLabels = document.querySelectorAll('.tier-option'); | |
| 242 | + | for (var i = 0; i < tierInputs.length; i++) { | |
| 243 | + | tierInputs[i].addEventListener('change', function() { | |
| 244 | + | for (var j = 0; j < tierLabels.length; j++) { | |
| 245 | + | tierLabels[j].classList.remove('is-selected'); | |
| 246 | + | } | |
| 247 | + | this.closest('.tier-option').classList.add('is-selected'); | |
| 248 | + | update(); | |
| 249 | + | }); | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | document.getElementById('revenue').addEventListener('input', update); | |
| 253 | + | ||
| 254 | + | // Initial calculation | |
| 255 | + | update(); | |
| 256 | + | })(); |
| @@ -0,0 +1,27 @@ | |||
| 1 | + | // View toggle with localStorage persistence | |
| 2 | + | (function() { | |
| 3 | + | function applyView(view) { | |
| 4 | + | var container = document.getElementById('items-container'); | |
| 5 | + | if (container) { | |
| 6 | + | container.className = 'items-container items-' + view + '-view'; | |
| 7 | + | } | |
| 8 | + | document.querySelectorAll('.view-btn').forEach(function(btn) { | |
| 9 | + | btn.classList.toggle('is-selected', btn.dataset.view === view); | |
| 10 | + | }); | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | // Load saved preference on page load | |
| 14 | + | document.addEventListener('DOMContentLoaded', function() { | |
| 15 | + | var saved = safeStorageGet('projectViewPref') || 'grid'; | |
| 16 | + | applyView(saved); | |
| 17 | + | }); | |
| 18 | + | ||
| 19 | + | // Handle view button clicks | |
| 20 | + | document.querySelectorAll('.view-btn').forEach(function(btn) { | |
| 21 | + | btn.addEventListener('click', function() { | |
| 22 | + | var view = btn.dataset.view; | |
| 23 | + | applyView(view); | |
| 24 | + | safeStorageSet('projectViewPref', view); | |
| 25 | + | }); | |
| 26 | + | }); | |
| 27 | + | })(); |
| @@ -0,0 +1,18 @@ | |||
| 1 | + | function switchSectionTab(btn, panelId) { | |
| 2 | + | // Tab selection uses .is-selected (charter); panel visibility | |
| 3 | + | // still uses .active (display: none/block toggle). | |
| 4 | + | document.querySelectorAll('.project-sections .section-tab').forEach(function(t) { t.classList.remove('is-selected'); }); | |
| 5 | + | document.querySelectorAll('.project-sections .section-panel').forEach(function(p) { p.classList.remove('active'); }); | |
| 6 | + | btn.classList.add('is-selected'); | |
| 7 | + | var panel = document.getElementById(panelId); | |
| 8 | + | if (panel) panel.classList.add('active'); | |
| 9 | + | history.replaceState(null, '', '#' + panelId); | |
| 10 | + | } | |
| 11 | + | (function() { | |
| 12 | + | var hash = window.location.hash.replace('#', ''); | |
| 13 | + | if (hash) { | |
| 14 | + | var panel = document.getElementById(hash); | |
| 15 | + | var tab = document.querySelector('.project-sections [data-tab="' + hash + '"]'); | |
| 16 | + | if (panel && tab) switchSectionTab(tab, hash); | |
| 17 | + | } | |
| 18 | + | })(); |
| @@ -0,0 +1,10 @@ | |||
| 1 | + | function updatePwywButton(val) { | |
| 2 | + | var dollars = parseFloat(val) || 0; | |
| 3 | + | document.getElementById('amount_cents').value = Math.round(dollars * 100); | |
| 4 | + | document.getElementById('checkout-btn').textContent = 'Continue to Payment - $' + dollars.toFixed(2); | |
| 5 | + | } | |
| 6 | + | // Initialize on load | |
| 7 | + | (function() { | |
| 8 | + | var input = document.getElementById('pwyw_amount'); | |
| 9 | + | if (input) updatePwywButton(input.value); | |
| 10 | + | })(); |