profileShare

rasmusjy / splitapp-backend-modular-monolith

Read-only snapshot

No repository description.

main default branch 418 files Expires Sep 13, 2026, 9:06 AM
splitapp.js 11,092 bytes
1 /* ============================================================
2 SplitApp Interactive Utilities
3 Vanilla JS enhancements for premium UX
4 ============================================================ */
5
6 const SplitApp = {
7 /* ---------- Toast Notifications ---------- */
8 toast(message, type = 'success', duration = 4000) {
9 const container = document.getElementById('sa-toast-container');
10 if (!container) return;
11
12 const toast = document.createElement('div');
13 toast.className = `sa-toast sa-toast-${type}`;
14
15 const icons = {
16 success: 'bi-check-circle-fill',
17 error: 'bi-exclamation-circle-fill',
18 warning: 'bi-exclamation-triangle-fill',
19 info: 'bi-info-circle-fill'
20 };
21
22 toast.innerHTML = `
23 <i class="bi ${icons[type] || icons.success}" style="font-size:1.1rem"></i>
24 <span>${message}</span>
25 `;
26
27 container.appendChild(toast);
28
29 setTimeout(() => {
30 toast.classList.add('sa-toast-dismiss');
31 setTimeout(() => toast.remove(), 300);
32 }, duration);
33 },
34
35 /* ---------- Copy to Clipboard ---------- */
36 async copyToClipboard(text, button) {
37 try {
38 await navigator.clipboard.writeText(text);
39 if (button) {
40 const original = button.innerHTML;
41 button.innerHTML = '<i class="bi bi-check2"></i> Copied!';
42 button.classList.add('sa-btn-success');
43 setTimeout(() => {
44 button.innerHTML = original;
45 button.classList.remove('sa-btn-success');
46 }, 2000);
47 }
48 SplitApp.toast('Copied to clipboard!', 'success', 2000);
49 } catch {
50 // Fallback
51 const ta = document.createElement('textarea');
52 ta.value = text;
53 ta.style.position = 'fixed';
54 ta.style.opacity = '0';
55 document.body.appendChild(ta);
56 ta.select();
57 document.execCommand('copy');
58 document.body.removeChild(ta);
59 if (button) {
60 const original = button.innerHTML;
61 button.innerHTML = '<i class="bi bi-check2"></i> Copied!';
62 setTimeout(() => { button.innerHTML = original; }, 2000);
63 }
64 }
65 },
66
67 /* ---------- Form Loading States ---------- */
68 initFormLoading() {
69 document.querySelectorAll('form[data-sa-loading]').forEach(form => {
70 form.addEventListener('submit', (e) => {
71 // Check if jQuery validation exists and if form is invalid
72 if (typeof $ !== 'undefined' && $(form).valid && !$(form).valid()) {
73 return;
74 }
75 const btn = form.querySelector('button[type="submit"], input[type="submit"]');
76 if (btn && !btn.disabled) {
77 btn.disabled = true;
78 btn.classList.add('sa-btn-loading');
79 const text = btn.textContent;
80 btn.dataset.originalText = text;
81 }
82 });
83 });
84 },
85
86 /* ---------- Scroll Animations ---------- */
87 initScrollAnimations() {
88 const observer = new IntersectionObserver((entries) => {
89 entries.forEach(entry => {
90 if (entry.isIntersecting) {
91 entry.target.classList.add('sa-visible');
92 observer.unobserve(entry.target);
93 }
94 });
95 }, { threshold: 0.1, rootMargin: '0px 0px -40px 0px' });
96
97 document.querySelectorAll('.sa-animate-on-scroll').forEach(el => {
98 observer.observe(el);
99 });
100 },
101
102 /* ---------- Animated Progress Bars ---------- */
103 initProgressBars() {
104 const observer = new IntersectionObserver((entries) => {
105 entries.forEach(entry => {
106 if (entry.isIntersecting) {
107 const bar = entry.target;
108 const target = bar.dataset.width;
109 if (target) {
110 requestAnimationFrame(() => {
111 bar.style.width = target;
112 });
113 }
114 observer.unobserve(bar);
115 }
116 });
117 }, { threshold: 0.2 });
118
119 document.querySelectorAll('.sa-progress-bar[data-width]').forEach(bar => {
120 bar.style.width = '0%';
121 observer.observe(bar);
122 });
123 },
124
125 /* ---------- Expense Split Calculator ---------- */
126 initSplitCalculator() {
127 const methodInputs = document.querySelectorAll('input[name="SplitMethod"]');
128 if (!methodInputs.length) return;
129
130 const amountInput = document.getElementById('expenseAmount');
131 const section = document.getElementById('participantSection');
132 const amountCols = document.querySelectorAll('.split-amount-col');
133 const pctCols = document.querySelectorAll('.split-pct-col');
134 const checks = document.querySelectorAll('.participant-check');
135 const previews = document.querySelectorAll('.split-preview');
136
137 function getMethod() {
138 const checked = document.querySelector('input[name="SplitMethod"]:checked');
139 return checked ? checked.value : '0';
140 }
141
142 function updateUI() {
143 const method = getMethod();
144
145 // Animate section visibility
146 if (method === '0') {
147 if (section) section.style.display = 'none';
148 } else {
149 if (section) {
150 section.style.display = 'block';
151 section.classList.add('sa-animate-fade-in');
152 }
153 }
154
155 amountCols.forEach(c => c.style.display = method === '2' ? 'block' : 'none');
156 pctCols.forEach(c => c.style.display = method === '3' ? 'block' : 'none');
157
158 if (method === '2' || method === '3') {
159 checks.forEach(c => { c.checked = true; });
160 }
161
162 updatePreview();
163 }
164
165 function updatePreview() {
166 const method = getMethod();
167 const amount = parseFloat(amountInput?.value) || 0;
168 const checkedBoxes = document.querySelectorAll('.participant-check:checked');
169
170 previews.forEach(p => { p.textContent = ''; p.className = 'sa-badge sa-badge-neutral split-preview'; });
171
172 if (method === '0' || method === '1') {
173 const count = method === '0' ? checks.length : checkedBoxes.length;
174 if (count > 0 && amount > 0) {
175 const each = (amount / count).toFixed(2);
176 const targets = method === '0' ? checks : checkedBoxes;
177 targets.forEach(c => {
178 const idx = c.dataset.index;
179 if (previews[idx]) {
180 previews[idx].textContent = `€${each}`;
181 previews[idx].className = 'sa-badge sa-badge-secondary split-preview';
182 }
183 });
184 }
185 }
186
187 if (method === '2') {
188 const amountInputs = document.querySelectorAll('.split-amount');
189 let total = 0;
190 amountInputs.forEach(input => { total += parseFloat(input.value) || 0; });
191 const remaining = amount - total;
192
193 // Update remaining indicator
194 const indicator = document.getElementById('splitRemaining');
195 if (indicator) {
196 indicator.textContent = remaining.toFixed(2);
197 indicator.className = Math.abs(remaining) < 0.01 ? 'sa-amount sa-amount-positive' : 'sa-amount sa-amount-negative';
198 }
199 }
200
201 if (method === '3') {
202 const pctInputs = document.querySelectorAll('.split-pct');
203 let totalPct = 0;
204 pctInputs.forEach(input => { totalPct += parseFloat(input.value) || 0; });
205
206 const indicator = document.getElementById('splitPctTotal');
207 if (indicator) {
208 indicator.textContent = totalPct.toFixed(1) + '%';
209 indicator.className = Math.abs(totalPct - 100) < 0.1 ? 'sa-amount sa-amount-positive' : 'sa-amount sa-amount-negative';
210 }
211 }
212 }
213
214 methodInputs.forEach(input => input.addEventListener('change', updateUI));
215 if (amountInput) amountInput.addEventListener('input', updatePreview);
216 checks.forEach(c => c.addEventListener('change', updatePreview));
217 document.querySelectorAll('.split-amount, .split-pct').forEach(input => {
218 input.addEventListener('input', updatePreview);
219 });
220 },
221
222 /* ---------- Dynamic Poll Options ---------- */
223 initDynamicPollOptions() {
224 const container = document.getElementById('pollOptionsContainer');
225 const addBtn = document.getElementById('addPollOption');
226 if (!container || !addBtn) return;
227
228 let count = container.querySelectorAll('.poll-option-row').length;
229
230 addBtn.addEventListener('click', () => {
231 if (count >= 10) {
232 SplitApp.toast('Maximum 10 options allowed', 'warning');
233 return;
234 }
235 count++;
236 const row = document.createElement('div');
237 row.className = 'poll-option-row d-flex gap-2 mb-2 sa-animate-slide-up';
238 row.innerHTML = `
239 <input type="text" name="Options" class="form-control" placeholder="Option ${count}" required />
240 <button type="button" class="sa-btn sa-btn-ghost sa-btn-icon sa-btn-sm remove-option" title="Remove">
241 <i class="bi bi-x-lg"></i>
242 </button>
243 `;
244 container.appendChild(row);
245
246 row.querySelector('.remove-option').addEventListener('click', () => {
247 if (container.querySelectorAll('.poll-option-row').length > 2) {
248 row.style.opacity = '0';
249 row.style.transform = 'translateX(-20px)';
250 row.style.transition = 'all 0.2s ease';
251 setTimeout(() => row.remove(), 200);
252 count--;
253 }
254 });
255 });
256
257 // Wire up existing remove buttons
258 container.querySelectorAll('.remove-option').forEach(btn => {
259 btn.addEventListener('click', () => {
260 const row = btn.closest('.poll-option-row');
261 if (container.querySelectorAll('.poll-option-row').length > 2) {
262 row.remove();
263 count--;
264 }
265 });
266 });
267 },
268
269 /* ---------- Icon Picker ---------- */
270 initIconPicker() {
271 const picker = document.querySelector('.sa-icon-picker');
272 const hiddenInput = document.getElementById('iconNameInput');
273 if (!picker || !hiddenInput) return;
274
275 picker.querySelectorAll('.sa-icon-option').forEach(opt => {
276 opt.addEventListener('click', () => {
277 picker.querySelectorAll('.sa-icon-option').forEach(o => o.classList.remove('active'));
278 opt.classList.add('active');
279 hiddenInput.value = opt.dataset.icon;
280 });
281 });
282 },
283
284 /* ---------- Confirm Delete ---------- */
285 initDeleteConfirm() {
286 document.querySelectorAll('[data-sa-confirm]').forEach(btn => {
287 btn.addEventListener('click', (e) => {
288 if (!confirm(btn.dataset.saConfirm || 'Are you sure?')) {
289 e.preventDefault();
290 }
291 });
292 });
293 },
294
295 /* ---------- TempData Toast Reader ---------- */
296 readTempDataToasts() {
297 const el = document.getElementById('sa-tempdata-messages');
298 if (!el) return;
299
300 const success = el.dataset.success;
301 const error = el.dataset.error;
302 const warning = el.dataset.warning;
303
304 if (success) SplitApp.toast(success, 'success');
305 if (error) SplitApp.toast(error, 'error');
306 if (warning) SplitApp.toast(warning, 'warning');
307 },
308
309 /* ---------- Initialize Everything ---------- */
310 init() {
311 this.initScrollAnimations();
312 this.initProgressBars();
313 this.initFormLoading();
314 this.initSplitCalculator();
315 this.initDynamicPollOptions();
316 this.initIconPicker();
317 this.initDeleteConfirm();
318 this.readTempDataToasts();
319 }
320 };
321
322 // Auto-init on DOM ready
323 document.addEventListener('DOMContentLoaded', () => SplitApp.init());
324