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 | |