1/** 2 * Settings - My Info Tab 3 * Full profile (name, title, phone, cell, address, secondary email) + avatar 4 * + electronic signature + password change. 5 */ 6 7import * as settingsApi from '../../api/settings.js'; 8import { getValue, saveWithButton } from './utils.js'; 9import { isValidFormat } from '../../utils/form-validation.js'; 10import { afterRender, runWithBusyButton } from '../../utils/dom.js'; 11import { showModal } from '../../utils/modal.js'; 12import { auth } from '../../auth.js'; 13 14const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB 15let passwordModalClose = null; 16 17export async function init() { 18 const content = document.getElementById('settings-content'); 19 20 try { 21 const [profile, signature] = await Promise.all([ 22 settingsApi.getUserSettings(), 23 settingsApi.getSignature().catch(() => null), 24 ]); 25 26 const tmpl = $.templates('#tpl-tab-my-info'); 27 content.innerHTML = tmpl.render({ 28 firstName: profile?.firstName || '', 29 lastName: profile?.lastName || '', 30 email: profile?.email || '', 31 title: profile?.title || '', 32 phone: profile?.phone || '', 33 cell: profile?.cell || '', 34 address: profile?.address || '', 35 altEmail: profile?.altEmail || '', 36 avatarUrl: auth.getUser()?.avatarUrl || '', 37 signatureBase64: signature?.signatureBase64 || '', 38 hasSignature: !!signature?.hasSignature, 39 }); 40 afterRender(content); 41 42 initHandlers(); 43 } catch (error) { 44 if (error.name === 'AbortError') return; 45 console.error('[Settings/MyInfo] Init failed:', error); 46 window.showToast?.('Failed to load profile', 'error', { description: error.message }); 47 } 48} 49 50function initHandlers() { 51 const saveBtn = document.getElementById('cmdSaveMyInfo'); 52 53 // Clear field errors on input 54 ['txtFirstName', 'txtLastName', 'txtEmail', 'txtAltEmail'].forEach(id => { 55 document.getElementById(id)?.addEventListener('input', () => clearFieldError(id)); 56 }); 57 58 // Save profile 59 saveBtn?.addEventListener('click', async () => { 60 clearAllFieldErrors('tab-my-info'); 61 62 const payload = { 63 firstName: getValue('txtFirstName'), 64 lastName: getValue('txtLastName'), 65 email: getValue('txtEmail'), 66 altEmail: getValue('txtAltEmail'), 67 title: getValue('txtTitle'), 68 phone: getValue('txtPhone'), 69 cell: getValue('txtCell'), 70 address: getValue('txtAddress'), 71 }; 72 73 const errors = []; 74 if (!payload.firstName) errors.push({ id: 'txtFirstName', msg: 'First name is required' }); 75 if (!payload.email) { 76 errors.push({ id: 'txtEmail', msg: 'Email address is required' }); 77 } else if (!isValidFormat(payload.email, 'email')) { 78 errors.push({ id: 'txtEmail', msg: 'Email address is not valid' }); 79 } 80 if (payload.altEmail && !isValidFormat(payload.altEmail, 'email')) { 81 errors.push({ id: 'txtAltEmail', msg: 'Secondary email is not valid' }); 82 } 83 84 if (errors.length) { 85 errors.forEach(e => showFieldError(e.id, e.msg)); 86 document.getElementById(errors[0].id)?.focus(); 87 return; 88 } 89 90 await saveWithButton(saveBtn, () => settingsApi.updateUserSettings(payload)); 91 }); 92 93 // Password change modal 94 document.getElementById('cmdChangePassword')?.addEventListener('click', showPasswordModal); 95 96 initAvatarHandlers(); 97 initSignatureHandlers(); 98 initFieldModeToggle(); 99} 100 101// --- Avatar --- 102 103function initAvatarHandlers() { 104 const fileInput = document.getElementById('avatarFileInput'); 105 const uploadBtn = document.getElementById('cmdUploadAvatar'); 106 const removeBtn = document.getElementById('cmdRemoveAvatar'); 107 108 uploadBtn?.addEventListener('click', () => fileInput?.click()); 109 110 fileInput?.addEventListener('change', async () => { 111 const file = fileInput.files?.[0]; 112 fileInput.value = ''; // allow re-selecting the same file 113 if (!file) return; 114 if (file.size > MAX_IMAGE_BYTES) { 115 window.showToast?.('Image too large', 'error', { description: 'Please choose an image under 5 MB.' }); 116 return; 117 } 118 119 await runWithBusyButton(uploadBtn, 'Uploading...', async () => { 120 const dataUrl = await readFileAsDataUrl(file); 121 const res = await settingsApi.updateMyAvatar(dataUrl); 122 applyAvatar(res?.avatarUrl || null); 123 window.showToast?.('Photo updated', 'success', { description: 'Your profile photo has been changed' }); 124 }, { errorTitle: 'Upload failed', errorLogPrefix: '[Settings/MyInfo] Avatar upload failed:', resetOnSuccess: true }); 125 }); 126 127 removeBtn?.addEventListener('click', async () => { 128 await runWithBusyButton(removeBtn, 'Removing...', async () => { 129 await settingsApi.updateMyAvatar('_DELETE_'); 130 applyAvatar(null); 131 window.showToast?.('Photo removed', 'success', { description: 'Your profile photo has been removed' }); 132 }, { errorTitle: 'Remove failed', errorLogPrefix: '[Settings/MyInfo] Avatar remove failed:', resetOnSuccess: true }); 133 }); 134} 135 136function applyAvatar(url) { 137 const img = document.getElementById('avatarPreview'); 138 const placeholder = document.getElementById('avatarPlaceholder'); 139 const removeBtn = document.getElementById('cmdRemoveAvatar'); 140 141 if (url) { 142 if (img) { img.src = url; img.classList.remove('hidden'); } 143 placeholder?.classList.add('hidden'); 144 removeBtn?.classList.remove('hidden'); 145 } else { 146 if (img) { img.src = ''; img.classList.add('hidden'); } 147 placeholder?.classList.remove('hidden'); 148 removeBtn?.classList.add('hidden'); 149 } 150 151 // Keep the auth user (and therefore the sidebar on next render) in sync. 152 const user = auth.getUser(); 153 if (user) auth.setUser?.({ ...user, avatarUrl: url }); 154} 155 156// --- Electronic Signature --- 157 158function initSignatureHandlers() { 159 const fileInput = document.getElementById('signatureFileInput'); 160 const uploadBtn = document.getElementById('cmdUploadSignature'); 161 const clearBtn = document.getElementById('cmdClearSignature'); 162 163 uploadBtn?.addEventListener('click', () => fileInput?.click()); 164 165 fileInput?.addEventListener('change', async () => { 166 const file = fileInput.files?.[0]; 167 fileInput.value = ''; 168 if (!file) return; 169 if (file.size > MAX_IMAGE_BYTES) { 170 window.showToast?.('Image too large', 'error', { description: 'Please choose an image under 5 MB.' }); 171 return; 172 } 173 174 await runWithBusyButton(uploadBtn, 'Uploading...', async () => { 175 const dataUrl = await readFileAsDataUrl(file); 176 await settingsApi.uploadSignature(dataUrl); 177 applySignature(dataUrl); 178 window.showToast?.('Signature saved', 'success', { description: 'Your electronic signature has been updated' }); 179 }, { errorTitle: 'Upload failed', errorLogPrefix: '[Settings/MyInfo] Signature upload failed:', resetOnSuccess: true }); 180 }); 181 182 clearBtn?.addEventListener('click', async () => { 183 await runWithBusyButton(clearBtn, 'Clearing...', async () => { 184 await settingsApi.deleteSignature(); 185 applySignature(null); 186 window.showToast?.('Signature cleared', 'success', { description: 'Your electronic signature has been removed' }); 187 }, { errorTitle: 'Clear failed', errorLogPrefix: '[Settings/MyInfo] Signature clear failed:', resetOnSuccess: true }); 188 }); 189} 190 191function applySignature(dataUrl) { 192 const img = document.getElementById('signaturePreview'); 193 const empty = document.getElementById('signatureEmpty'); 194 const clearBtn = document.getElementById('cmdClearSignature'); 195 196 if (dataUrl) { 197 if (img) { img.src = dataUrl; img.classList.remove('hidden'); } 198 empty?.classList.add('hidden'); 199 clearBtn?.classList.remove('hidden'); 200 } else { 201 if (img) { img.src = ''; img.classList.add('hidden'); } 202 empty?.classList.remove('hidden'); 203 clearBtn?.classList.add('hidden'); 204 } 205} 206
207// --- Field Mode (mobile only â card is lg:hidden) --- 208 209function initFieldModeToggle() { 210 const fieldToggle = document.getElementById('toggleFieldMode'); 211 if (!fieldToggle) return; 212 213 const current = localStorage.getItem('sb_mobile_mode') || 'office'; 214 fieldToggle.checked = current === 'field'; 215 216 fieldToggle.addEventListener('change', () => { 217 const newMode = fieldToggle.checked ? 'field' : 'office'; 218 document.documentElement.setAttribute('data-mobile-mode', newMode); 219 try { localStorage.setItem('sb_mobile_mode', newMode); } catch { /* ignore */ } 220 }); 221} 222 223function readFileAsDataUrl(file) { 224 return new Promise((resolve, reject) => { 225 const reader = new FileReader(); 226 reader.onload = () => resolve(reader.result); 227 reader.onerror = () => reject(new Error('Failed to read the selected file')); 228 reader.readAsDataURL(file); 229 }); 230} 231 232// --- Password modal --- 233 234async function showPasswordModal() { 235 const { element, close } = await showModal('#tpl-password-modal', {}, { 236 onClose: () => { passwordModalClose = null; }, 237 }); 238 passwordModalClose = close; 239 240 element.querySelector('#txtCurrentPassword')?.focus(); 241 242 ['txtCurrentPassword', 'txtNewPassword', 'txtConfirmPassword'].forEach(id => { 243 element.querySelector(`#${id}`)?.addEventListener('input', () => clearFieldError(id)); 244 }); 245 246 const saveBtn = element.querySelector('#cmdSavePassword'); 247 saveBtn?.addEventListener('click', async () => { 248 clearAllFieldErrors('passwordModal'); 249 250 const currentPw = getValue('txtCurrentPassword'); 251 const newPw = getValue('txtNewPassword'); 252 const confirmPw = getValue('txtConfirmPassword'); 253 254 const errors = []; 255 if (!currentPw) errors.push({ id: 'txtCurrentPassword', msg: 'Current password is required' }); 256 if (!newPw) errors.push({ id: 'txtNewPassword', msg: 'New password is required' }); 257 else if (newPw.length < 8 || newPw.length > 20) errors.push({ id: 'txtNewPassword', msg: 'Must be between 8 and 20 characters' }); 258 if (!confirmPw) errors.push({ id: 'txtConfirmPassword', msg: 'Please confirm your new password' }); 259 else if (newPw && confirmPw !== newPw) errors.push({ id: 'txtConfirmPassword', msg: 'Passwords do not match' }); 260 261 if (errors.length) { 262 errors.forEach(e => showFieldError(e.id, e.msg)); 263 document.getElementById(errors[0].id)?.focus(); 264 return; 265 } 266 267 await saveWithButton(saveBtn, async () => { 268 await settingsApi.changePassword({ currentPassword: currentPw, newPassword: newPw }); 269 window.showToast?.('Password updated', 'success', { description: 'Your password has been changed' }); 270 close(); 271 }); 272 }); 273} 274 275// --- Inline Validation Helpers --- 276 277function showFieldError(inputId, message) { 278 const input = document.getElementById(inputId); 279 if (!input) return; 280 281 input.classList.add('form-input-error', 'field-attention'); 282 const label = input.closest('.space-y-2')?.querySelector('label'); 283 label?.classList.add('form-label-error'); 284 285 const existing = input.parentElement.querySelector('.form-error-message'); 286 if (!existing) { 287 const msg = document.createElement('p'); 288 msg.className = 'form-error-message'; 289 msg.textContent = message; 290 input.after(msg); 291 } 292} 293 294function clearFieldError(inputId) { 295 const input = document.getElementById(inputId); 296 if (!input) return; 297 298 input.classList.remove('form-input-error', 'field-attention'); 299 const label = input.closest('.space-y-2')?.querySelector('label'); 300 label?.classList.remove('form-label-error'); 301 input.parentElement.querySelector('.form-error-message')?.remove(); 302} 303 304function clearAllFieldErrors(containerId) { 305 const container = document.getElementById(containerId); 306 if (!container) return; 307 308 container.querySelectorAll('.form-input-error').forEach(el => el.classList.remove('form-input-error', 'field-attention')); 309 container.querySelectorAll('.form-label-error').forEach(el => el.classList.remove('form-label-error')); 310 container.querySelectorAll('.form-error-message').forEach(el => el.remove()); 311} 312 313export function destroy() { 314 passwordModalClose?.(); 315 passwordModalClose = null; 316}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.