PageSourceSearch

https://smrtbld.ai/js/pages/settings/my-info.js

js smrtbld.ai collected 2026-10-05 15:09:10 UTC 12,599 bytes, 316 lines download raw bytes

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.