Skip to main content

max / makenotwork

14.3 KB · 335 lines History Blame Raw
1 {% extends "base.html" %}
2
3 {% block title %}Import Data - Makenot.work{% endblock %}
4 {% block body_attrs %} class="padded-page import-page"{% endblock %}
5
6 {% block content %}
7 {% include "partials/site_header.html" %}
8
9 <div class="container">
10 <a href="/dashboard" class="back-link">&larr; Back to Dashboard</a>
11
12 <header>
13 <h1 class="page-title">Import Data</h1>
14 <p class="subtitle">Import subscribers, items, and transaction history from other platforms.</p>
15 </header>
16
17 <div class="import-form">
18 <div class="form-group">
19 <label for="import-project">Project</label>
20 <select id="import-project">
21 {% for p in projects %}
22 <option value="{{ p.id }}">{{ p.title }}</option>
23 {% endfor %}
24 </select>
25 </div>
26
27 <div class="form-group">
28 <label for="import-file">CSV File</label>
29 <input type="file" id="import-file" accept=".csv,text/csv">
30 </div>
31
32 <div class="csv-preview hidden" id="csv-preview">
33 <p class="import-preview-label">Preview (first 3 rows):</p>
34 <table id="preview-table"></table>
35 </div>
36
37 <div class="column-mapping hidden" id="column-mapping">
38 <p class="import-mapping-label">Map CSV columns to fields:</p>
39 <div class="mapping-row">
40 <label>Email</label>
41 <select id="map-email"><option value="">-- skip --</option></select>
42 </div>
43 <div class="mapping-row">
44 <label>Name</label>
45 <select id="map-name"><option value="">-- skip --</option></select>
46 </div>
47 <div class="mapping-row">
48 <label>Amount</label>
49 <select id="map-amount"><option value="">-- skip --</option></select>
50 </div>
51 <div class="mapping-row">
52 <label>Date</label>
53 <select id="map-date"><option value="">-- skip --</option></select>
54 </div>
55 <div class="mapping-row">
56 <label>Item Title</label>
57 <select id="map-item-title"><option value="">-- skip --</option></select>
58 </div>
59 <div class="mapping-row">
60 <label>Tier</label>
61 <select id="map-tier"><option value="">-- skip --</option></select>
62 </div>
63 <div class="mapping-row">
64 <label>Status</label>
65 <select id="map-status"><option value="">-- skip --</option></select>
66 </div>
67
68 <button id="start-import-btn" class="import-start-btn">Start Import</button>
69 </div>
70 </div>
71
72 <div class="import-progress hidden" id="import-progress">
73 <p class="import-progress-label">Importing...</p>
74 <div class="progress-bar-container my-4">
75 <div class="progress-bar progress-bar--highlight" id="progress-bar"></div>
76 </div>
77 <div class="progress-stats">
78 <span>Processed: <span class="stat-value" id="stat-processed">0</span></span>
79 <span>Created: <span class="stat-value" id="stat-created">0</span></span>
80 <span>Skipped: <span class="stat-value" id="stat-skipped">0</span></span>
81 </div>
82 </div>
83
84 <div class="import-result hidden" id="import-result"></div>
85
86 {% if !jobs.is_empty() %}
87 <div class="import-history">
88 <h2 class="import-history-heading">Import History</h2>
89 <table class="history-table">
90 <thead>
91 <tr>
92 <th>Source</th>
93 <th>Status</th>
94 <th>Rows</th>
95 <th>Created</th>
96 <th>Date</th>
97 </tr>
98 </thead>
99 <tbody>
100 {% for j in jobs %}
101 <tr>
102 <td>{{ j.source }}</td>
103 <td><span class="status-badge {{ j.status }}">{{ j.status }}</span></td>
104 <td>{{ j.total_rows }}</td>
105 <td>{{ j.created_rows }}</td>
106 <td>{{ j.created_at.format("%Y-%m-%d %H:%M") }}</td>
107 </tr>
108 {% endfor %}
109 </tbody>
110 </table>
111 </div>
112 {% endif %}
113
114 <div class="import-note">
115 <h3>Supported Formats</h3>
116 <p>Currently supports generic CSV files. Upload a CSV with subscriber emails, transaction amounts, or item data, then map columns to the right fields. Coming soon: direct Substack, Ghost, Gumroad, Bandcamp, and Patreon imports.</p>
117 </div>
118 </div>
119
120 <script>
121 (function() {
122 const fileInput = document.getElementById('import-file');
123 const previewEl = document.getElementById('csv-preview');
124 const previewTable = document.getElementById('preview-table');
125 const mappingEl = document.getElementById('column-mapping');
126 const startBtn = document.getElementById('start-import-btn');
127 const progressEl = document.getElementById('import-progress');
128 const resultEl = document.getElementById('import-result');
129
130 let csvBase64 = '';
131 let csvHeaders = [];
132
133 fileInput.addEventListener('change', function(e) {
134 const file = e.target.files[0];
135 if (!file) return;
136
137 const reader = new FileReader();
138 reader.onload = function(ev) {
139 const text = ev.target.result;
140 csvBase64 = btoa(unescape(encodeURIComponent(text)));
141
142 const lines = text.split('\n').filter(l => l.trim());
143 if (lines.length < 2) {
144 alert('CSV must have a header row and at least one data row.');
145 return;
146 }
147
148 csvHeaders = parseCSVLine(lines[0]);
149
150 // Build preview table
151 let html = '<thead><tr>';
152 csvHeaders.forEach(h => { html += '<th>' + escapeHtml(h) + '</th>'; });
153 html += '</tr></thead><tbody>';
154 for (let i = 1; i < Math.min(lines.length, 4); i++) {
155 const cols = parseCSVLine(lines[i]);
156 html += '<tr>';
157 csvHeaders.forEach((_, ci) => {
158 html += '<td>' + escapeHtml(cols[ci] || '') + '</td>';
159 });
160 html += '</tr>';
161 }
162 html += '</tbody>';
163 previewTable.innerHTML = html;
164 previewEl.classList.remove('hidden');
165
166 // Populate mapping selects
167 const selects = mappingEl.querySelectorAll('select');
168 selects.forEach(sel => {
169 const current = sel.value;
170 sel.innerHTML = '<option value="">-- skip --</option>';
171 csvHeaders.forEach((h, i) => {
172 const opt = document.createElement('option');
173 opt.value = i;
174 opt.textContent = h;
175 sel.appendChild(opt);
176 });
177 // Auto-detect common column names
178 autoDetectColumn(sel, csvHeaders);
179 });
180 mappingEl.classList.remove('hidden');
181 };
182 reader.readAsText(file);
183 });
184
185 function autoDetectColumn(select, headers) {
186 const id = select.id;
187 const lower = headers.map(h => h.toLowerCase().trim());
188 const patterns = {
189 'map-email': ['email', 'e-mail', 'email_address', 'subscriber_email'],
190 'map-name': ['name', 'full_name', 'display_name', 'subscriber_name'],
191 'map-amount': ['amount', 'total', 'price', 'payment', 'lifetime_amount'],
192 'map-date': ['date', 'created_at', 'joined', 'start_date', 'signup_date'],
193 'map-item-title': ['item', 'product', 'title', 'item_title', 'product_name'],
194 'map-tier': ['tier', 'plan', 'level', 'membership'],
195 'map-status': ['status', 'state', 'active']
196 };
197 const p = patterns[id] || [];
198 for (let i = 0; i < lower.length; i++) {
199 if (p.includes(lower[i])) {
200 select.value = i;
201 return;
202 }
203 }
204 }
205
206 startBtn.addEventListener('click', function() {
207 const projectId = document.getElementById('import-project').value;
208 if (!csvBase64) {
209 alert('Please select a CSV file first.');
210 return;
211 }
212
213 const mapping = {};
214 const mapEmail = document.getElementById('map-email').value;
215 const mapName = document.getElementById('map-name').value;
216 const mapAmount = document.getElementById('map-amount').value;
217 const mapDate = document.getElementById('map-date').value;
218 const mapItem = document.getElementById('map-item-title').value;
219 const mapTier = document.getElementById('map-tier').value;
220 const mapStatus = document.getElementById('map-status').value;
221
222 if (mapEmail) mapping.email = parseInt(mapEmail);
223 if (mapName) mapping.name = parseInt(mapName);
224 if (mapAmount) mapping.amount = parseInt(mapAmount);
225 if (mapDate) mapping.date = parseInt(mapDate);
226 if (mapItem) mapping.item_title = parseInt(mapItem);
227 if (mapTier) mapping.tier = parseInt(mapTier);
228 if (mapStatus) mapping.status = parseInt(mapStatus);
229
230 if (!mapping.email && !mapping.amount) {
231 alert('Please map at least an email or amount column.');
232 return;
233 }
234
235 startBtn.disabled = true;
236 startBtn.textContent = 'Starting...';
237
238 fetch('/api/users/me/import', {
239 method: 'POST',
240 headers: {
241 'Content-Type': 'application/json',
242 'X-CSRF-Token': '{{ csrf_token.as_deref().unwrap_or_default() }}'
243 },
244 body: JSON.stringify({
245 project_id: projectId,
246 source: 'generic_csv',
247 csv_data: csvBase64,
248 column_mapping: mapping
249 })
250 })
251 .then(r => r.json())
252 .then(data => {
253 if (data.error) {
254 resultEl.className = 'import-result error';
255 resultEl.textContent = data.error;
256 startBtn.disabled = false;
257 startBtn.textContent = 'Start Import';
258 return;
259 }
260 progressEl.classList.remove('hidden');
261 pollProgress(data.job_id);
262 })
263 .catch(err => {
264 resultEl.className = 'import-result error';
265 resultEl.textContent = 'Failed to start import: ' + err.message;
266 startBtn.disabled = false;
267 startBtn.textContent = 'Start Import';
268 });
269 });
270
271 function pollProgress(jobId) {
272 const interval = setInterval(() => {
273 fetch('/api/users/me/import/' + jobId)
274 .then(r => r.json())
275 .then(data => {
276 const pct = data.total_rows > 0
277 ? Math.round((data.processed_rows / data.total_rows) * 100)
278 : 0;
279 document.getElementById('progress-bar').style.width = pct + '%';
280 document.getElementById('stat-processed').textContent = data.processed_rows;
281 document.getElementById('stat-created').textContent = data.created_rows;
282 document.getElementById('stat-skipped').textContent = data.skipped_rows;
283
284 if (data.status === 'completed' || data.status === 'failed') {
285 clearInterval(interval);
286 progressEl.classList.add('hidden');
287
288 resultEl.classList.remove('hidden');
289 if (data.status === 'completed') {
290 resultEl.className = 'import-result success';
291 resultEl.innerHTML = '<strong>Import complete.</strong> ' +
292 data.created_rows + ' created, ' +
293 data.skipped_rows + ' skipped.';
294 } else {
295 resultEl.className = 'import-result error';
296 resultEl.innerHTML = '<strong>Import failed.</strong>';
297 }
298 if (data.error_log) {
299 resultEl.innerHTML += '<div class="error-log">' +
300 escapeHtml(data.error_log) + '</div>';
301 }
302 startBtn.disabled = false;
303 startBtn.textContent = 'Start Import';
304 }
305 });
306 }, 2000);
307 }
308
309 function parseCSVLine(line) {
310 const result = [];
311 let current = '';
312 let inQuotes = false;
313 for (let i = 0; i < line.length; i++) {
314 const ch = line[i];
315 if (inQuotes) {
316 if (ch === '"' && line[i+1] === '"') { current += '"'; i++; }
317 else if (ch === '"') { inQuotes = false; }
318 else { current += ch; }
319 } else {
320 if (ch === '"') { inQuotes = true; }
321 else if (ch === ',') { result.push(current.trim()); current = ''; }
322 else { current += ch; }
323 }
324 }
325 result.push(current.trim());
326 return result;
327 }
328
329 function escapeHtml(s) {
330 return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
331 }
332 })();
333 </script>
334 {% endblock %}
335