Skip to main content

max / makenotwork

ux: extract all executable inline JS from templates into static files Move every executable inline <script> block out of the Askama templates (44 templates) into external /static/*.js files, honoring the CLAUDE.md "JS goes in static/, not inline" rule and making the surface strict-CSP ready. JSON-LD and application/json data blocks and existing <script src> tags are left inline; CSP is unchanged (still allows unsafe-inline), so behavior is identical. Server-rendered values that the scripts used via Askama interpolation are passed through hidden data-* config elements (auto-escaped attribute context) and read via dataset, rather than baked into the JS. HTMX- swapped partials keep working because htmx executes external <script src> tags on swap (allowScriptTags). Functions invoked from inline onclick handlers are exposed on window so they still resolve. 56 new static/*.js files; no JS logic changed. Verified: all templates compile (cargo check), every new file passes node --check, and the full integration suite (971) renders every page green. ultra-fuzz Run 5 M-UX3 (deep). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-24 03:18 UTC
Commit: b9dd22a60b591d28a4cc80c822e86cf7b3edd8eb
Parent: 3de6ec0
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
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 = '&#9646;&#9646;'; }
12 + else { audio.pause(); document.getElementById('play').innerHTML = '&#9654;'; }
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 = '&#9654;'; };
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 + })();