Skip to main content

max / makenotwork

10.3 KB · 236 lines History Blame Raw
1 <div class="tab-docs"><a href="/docs/pricing">Docs: Pricing &rarr;</a></div>
2
3 <div class="content-section">
4 <div class="section-header">
5 <h2 class="subsection-title">Promo Codes</h2>
6 </div>
7
8 <p class="form-hint mb-5">Create codes that give buyers discounts, free access, or free trials on items in this project.</p>
9
10 <details class="form-section">
11 <summary><h2 class="subsection-title">New Promo Code</h2></summary>
12 <form hx-post="/api/promo-codes"
13 hx-target="#promo-codes-list"
14 hx-swap="outerHTML"
15 class="proj-promo-form">
16 <input type="hidden" name="project_id" value="{{ project_id }}">
17
18 <div class="proj-promo-grid">
19 <div class="form-group">
20 <label for="pc-purpose">Type</label>
21 <select id="pc-purpose" name="code_purpose"
22 onchange="togglePromoFields(this.value)">
23 <option value="discount">Discount</option>
24 <option value="free_access">Free Access</option>
25 <option value="free_trial">Free Trial</option>
26 </select>
27 </div>
28 <div class="form-group">
29 <label for="pc-code">Code</label>
30 <input type="text" id="pc-code" name="code" placeholder="e.g. LAUNCH50"
31 class="proj-promo-code-input input--upper">
32 </div>
33 </div>
34
35 <div id="discount-fields" class="proj-promo-grid proj-promo-grid--spaced">
36 <div class="form-group">
37 <label for="pc-type">Discount type</label>
38 <select id="pc-type" name="discount_type">
39 <option value="percentage">Percentage</option>
40 <option value="fixed">Fixed ($)</option>
41 </select>
42 </div>
43 <div class="form-group">
44 <label for="pc-value-input">Amount</label>
45 <input type="number" id="pc-value-input" min="1" step="1" placeholder="e.g. 50">
46 <input type="hidden" id="pc-value-hidden" name="discount_value">
47 </div>
48 </div>
49
50 <div id="trial-fields" class="proj-promo-trial-fields">
51 <div class="form-group proj-promo-trial-group">
52 <label for="pc-trial-preset">Trial length</label>
53 <select id="pc-trial-preset" onchange="onTrialPresetChange()">
54 <option value="7">7 days</option>
55 <option value="14" selected>14 days</option>
56 <option value="30">30 days</option>
57 <option value="60">60 days</option>
58 <option value="90">90 days</option>
59 <option value="custom">Custom...</option>
60 </select>
61 <input type="number" id="pc-trial-days" name="trial_days" min="1" value="14" hidden>
62 <input type="number" id="pc-trial-days-custom" min="1" placeholder="e.g. 21" hidden
63 oninput="document.getElementById('pc-trial-days').value = this.value">
64 </div>
65 </div>
66
67 <div class="proj-promo-grid proj-promo-grid--bordered">
68 <div class="form-group">
69 <label for="pc-max">Max uses</label>
70 <input type="number" id="pc-max" name="max_uses" min="1" placeholder="Unlimited">
71 </div>
72 <div class="form-group">
73 <label for="pc-starts">Starts</label>
74 <input type="date" id="pc-starts" name="starts_at">
75 </div>
76 <div class="form-group">
77 <label for="pc-expires">Expires</label>
78 <input type="date" id="pc-expires" name="expires_at">
79 </div>
80 {% if !items.is_empty() %}
81 <div class="form-group proj-promo-scope">
82 <label for="pc-item">Scope</label>
83 <select id="pc-item" name="item_id">
84 <option value="">All items in project</option>
85 {% for item in items %}
86 <option value="{{ item.id }}">{{ item.title }}</option>
87 {% endfor %}
88 </select>
89 </div>
90 {% endif %}
91 </div>
92
93 <div class="proj-promo-actions">
94 <button class="btn-secondary" type="submit">Create Code</button>
95 </div>
96 <p class="form-hint proj-promo-hint">Percentage: 1-100. Fixed: dollar amount (e.g. 5 = $5.00 off). Free access codes auto-generate if code left blank.</p>
97 <script>
98 (function() {
99 var typeSelect = document.getElementById('pc-type');
100 var input = document.getElementById('pc-value-input');
101 var hidden = document.getElementById('pc-value-hidden');
102 function updatePromoInput() {
103 if (typeSelect.value === 'fixed') {
104 input.step = '0.01';
105 input.placeholder = 'e.g. 5.00';
106 } else {
107 input.step = '1';
108 input.placeholder = 'e.g. 50';
109 }
110 syncValue();
111 }
112 function syncValue() {
113 var val = parseFloat(input.value || 0);
114 hidden.value = typeSelect.value === 'fixed' ? Math.round(val * 100) : Math.round(val);
115 }
116 typeSelect.addEventListener('change', updatePromoInput);
117 input.addEventListener('input', syncValue);
118 updatePromoInput();
119 })();
120 </script>
121 </form>
122 </details>
123
124 {% include "partials/promo_codes_list.html" %}
125 </div>
126
127 <script>
128 function togglePromoFields(purpose) {
129 var discountFields = document.getElementById('discount-fields');
130 var trialFields = document.getElementById('trial-fields');
131 var codeInput = document.getElementById('pc-code');
132 if (purpose === 'discount') {
133 discountFields.classList.remove('is-hidden');
134 trialFields.classList.remove('is-visible');
135 codeInput.required = true;
136 codeInput.placeholder = 'e.g. LAUNCH50';
137 } else if (purpose === 'free_trial') {
138 discountFields.classList.add('is-hidden');
139 trialFields.classList.add('is-visible');
140 codeInput.required = true;
141 codeInput.placeholder = 'e.g. TRIAL14';
142 } else {
143 discountFields.classList.add('is-hidden');
144 trialFields.classList.remove('is-visible');
145 codeInput.required = false;
146 codeInput.placeholder = 'Auto-generated';
147 }
148 }
149
150 /// Show a modal listing redemptions of a single promo code. Fetched on
151 /// demand so the page-render path doesn't pay for it. Guest checkouts
152 /// surface as the guest's email; missing item titles render as "—".
153 function showPromoRedemptions(codeId, codeLabel) {
154 var existing = document.getElementById('promo-redemptions-modal');
155 if (existing) existing.remove();
156
157 var overlay = document.createElement('div');
158 overlay.id = 'promo-redemptions-modal';
159 overlay.className = 'modal-overlay';
160 overlay.style.display = 'flex';
161 overlay.onclick = function (e) { if (e.target === overlay) overlay.remove(); };
162
163 var content = document.createElement('div');
164 content.className = 'modal-content';
165 content.style.maxWidth = '640px';
166 content.style.padding = '2rem';
167 content.innerHTML =
168 '<div class="modal-header" style="margin-bottom: 1rem;">'
169 + '<h2>Redemptions: <code>' + codeLabel + '</code></h2>'
170 + '<button type="button" class="modal-close" aria-label="Dismiss"'
171 + ' onclick="document.getElementById(\'promo-redemptions-modal\').remove()">&times;</button>'
172 + '</div>'
173 + '<div id="promo-redemptions-body" class="muted">Loading…</div>';
174 overlay.appendChild(content);
175 document.body.appendChild(overlay);
176
177 fetch('/api/promo-codes/' + codeId + '/redemptions', { credentials: 'same-origin' })
178 .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
179 .then(function (data) {
180 var body = document.getElementById('promo-redemptions-body');
181 if (!body) return;
182 var rows = (data && data.redemptions) || [];
183 if (rows.length === 0) {
184 body.textContent = 'No redemptions yet.';
185 return;
186 }
187 var html = '<table class="data-table" style="width:100%; font-size: 0.9rem;">'
188 + '<thead><tr><th>When</th><th>Who</th><th>Item</th><th>Paid</th></tr></thead><tbody>';
189 for (var i = 0; i < rows.length; i++) {
190 var r = rows[i];
191 var when = new Date(r.redeemed_at).toLocaleDateString();
192 var who = r.username
193 ? '@' + escapeHtml(r.username)
194 : (r.guest_email ? escapeHtml(r.guest_email) + ' (guest)' : '');
195 var item = r.item_title ? escapeHtml(r.item_title) : '';
196 var paid = r.amount_cents === 0 ? 'Free' : '$' + (r.amount_cents / 100).toFixed(2);
197 html += '<tr><td>' + when + '</td><td>' + who + '</td><td>' + item + '</td><td>' + paid + '</td></tr>';
198 }
199 html += '</tbody></table>';
200 if (rows.length === 500) {
201 html += '<p class="muted" style="margin-top: 0.75rem;">Showing the 500 most recent redemptions.</p>';
202 }
203 body.innerHTML = html;
204 })
205 .catch(function () {
206 var body = document.getElementById('promo-redemptions-body');
207 if (body) body.textContent = 'Could not load redemptions.';
208 });
209 }
210
211 function escapeHtml(s) {
212 return String(s)
213 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
214 .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
215 }
216
217 /// Switch between preset trial lengths and a custom number input. The
218 /// hidden #pc-trial-days carries the actual submitted value so we can keep
219 /// the server-side contract (a single `trial_days` field) untouched.
220 function onTrialPresetChange() {
221 var preset = document.getElementById('pc-trial-preset');
222 var hidden = document.getElementById('pc-trial-days');
223 var custom = document.getElementById('pc-trial-days-custom');
224 if (preset.value === 'custom') {
225 custom.hidden = false;
226 custom.required = true;
227 custom.focus();
228 hidden.value = custom.value || '';
229 } else {
230 custom.hidden = true;
231 custom.required = false;
232 hidden.value = preset.value;
233 }
234 }
235 </script>
236