import { t } from './i18n.js?v={{APP_QVER}}'; import { showToast } from './domUtils.js?v={{APP_QVER}}'; // ───────────────────────────── // Portal intake presets // ───────────────────────────── const PORTAL_INTAKE_PRESETS = { legal: { label: 'Legal intake', title: 'Secure legal document upload', introText: 'Upload engagement letters, signed agreements, IDs, and supporting documents here. ' + 'Please avoid emailing sensitive files.', footerText: 'If you uploaded something in error, contact our office. Please do not share this link.', brandColor: '#2563eb', requireForm: true, formVisible: { name: true, email: true, reference: true, notes: true, }, formLabels: { name: 'Full legal name', email: 'Email address', reference: 'Matter / case #', notes: 'Notes for our team', }, formDefaults: { name: '', email: '', reference: '', notes: '', }, formRequired: { name: true, email: true, reference: true, notes: false, }, }, tax: { label: 'Tax client', title: 'Tax documents upload', introText: 'Upload your tax documents (W-2s, 1099s, statements, prior returns, etc.). ' + 'Please avoid emailing sensitive files.', footerText: 'If you are unsure what to upload, contact our office before sending files.', brandColor: '#16a34a', requireForm: true, formVisible: { name: true, email: true, reference: true, notes: true, }, formLabels: { name: 'Name (as on tax return)', email: 'Contact email', reference: 'Tax year(s)', notes: 'Notes / special situations', }, formDefaults: { name: '', email: '', reference: '', notes: '', }, formRequired: { name: true, email: true, reference: true, notes: false, }, }, order: { label: 'Order / RMA', title: 'Order / RMA upload', introText: 'Upload photos of the item, receipts, and any supporting documents for your order or return.', footerText: 'Include your order or RMA number so we can locate your purchase quickly.', brandColor: '#eab308', requireForm: true, formVisible: { name: true, email: true, reference: true, notes: true, }, formLabels: { name: 'Contact name', email: 'Email for updates', reference: 'Order # / RMA #', notes: 'Describe the issue / reason for return', }, formDefaults: { name: '', email: '', reference: '', notes: '', }, formRequired: { name: false, email: true, reference: true, notes: true, }, }, }; // Tiny JSON helper (same behavior as in adminPanel.js) async function safeJson(res) { const text = await res.text(); let body = null; try { body = text ? JSON.parse(text) : null; } catch { /* ignore */ } if (!res.ok) { const msg = (body && (body.error || body.message)) || (text && text.trim()) || `HTTP ${res.status}`; const err = new Error(msg); err.status = res.status; throw err; } return body ?? {}; } // Reusable custom confirm using #customConfirmModal from index.html function portalConfirm(message) { const modal = document.getElementById('customConfirmModal'); const msgEl = document.getElementById('confirmMessage'); const yesBtn = document.getElementById('confirmYesBtn'); const noBtn = document.getElementById('confirmNoBtn'); // Fallback to window.confirm if modal isn't present if (!modal || !msgEl || !yesBtn || !noBtn) { return Promise.resolve(window.confirm(message)); } msgEl.textContent = message; modal.style.display = 'block'; return new Promise(resolve => { const cleanup = () => { modal.style.display = 'none'; yesBtn.removeEventListener('click', onYes); noBtn.removeEventListener('click', onNo); // optional: close on backdrop click modal.removeEventListener('click', onBackdrop); document.removeEventListener('keydown', onEsc); }; const onYes = (e) => { e?.preventDefault?.(); cleanup(); resolve(true); }; const onNo = (e) => { e?.preventDefault?.(); cleanup(); resolve(false); }; const onBackdrop = (e) => { if (e.target === modal) { cleanup(); resolve(false); } }; const onEsc = (e) => { if (e.key === 'Escape') { cleanup(); resolve(false); } }; yesBtn.addEventListener('click', onYes); noBtn.addEventListener('click', onNo); modal.addEventListener('click', onBackdrop); document.addEventListener('keydown', onEsc); }); } async function fetchAllPortals() { const res = await fetch('/api/pro/portals/list.php', { credentials: 'include', headers: { 'X-CSRF-Token': window.csrfToken || '' } }); const data = await safeJson(res); return data && typeof data === 'object' && data.portals && typeof data.portals === 'object' ? data.portals : {}; } async function saveAllPortals(portals) { const res = await fetch('/api/pro/portals/save.php', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.csrfToken || '' }, body: JSON.stringify({ portals }) }); return await safeJson(res); } let __portalsCache = {}; // Shared folder list for portal folder picker (reuses getFolderList.php like folderManager.js) let __portalFolderListLoaded = false; let __portalFolderOptions = []; // Remember a newly-created portal to focus its folder field let __portalSlugToFocus = null; // Cache portal submissions per slug for CSV export const __portalSubmissionsCache = {}; async function loadPortalFolderList() { if (__portalFolderListLoaded) return __portalFolderOptions; try { const res = await fetch('/api/folder/getFolderList.php', { credentials: 'include' }); const data = await res.json(); let list = data; // Support both shapes: ["A/B", "C/D"] or [{ folder: "A/B" }, ...] if (Array.isArray(list) && list.length && typeof list[0] === 'object' && list[0].folder) { list = list.map(it => it.folder); } __portalFolderOptions = (list || []) .filter(Boolean) .filter(f => f !== 'trash' && f !== 'profile_pics'); __portalFolderListLoaded = true; } catch (e) { console.error('Error loading portal folder list', e); __portalFolderOptions = []; __portalFolderListLoaded = true; } return __portalFolderOptions; } // ───────────────────────────────────────── // Public entry point from adminPanel.js // ───────────────────────────────────────── export async function openClientPortalsModal() { const isDark = document.body.classList.contains('dark-mode'); const overlayBg = isDark ? 'rgba(0,0,0,0.7)' : 'rgba(0,0,0,0.3)'; const contentBg = isDark ? '#2c2c2c' : '#fff'; const contentFg = isDark ? '#e0e0e0' : '#000'; const borderCol = isDark ? '#555' : '#ccc'; let modal = document.getElementById('clientPortalsModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'clientPortalsModal'; modal.style.cssText = ` position:fixed; inset:0; background:${overlayBg}; display:flex; align-items:center; justify-content:center; z-index:3650; `; modal.innerHTML = `
`; document.body.appendChild(modal); document.getElementById('closeClientPortalsModal').onclick = () => (modal.style.display = 'none'); document.getElementById('cancelClientPortals').onclick = () => (modal.style.display = 'none'); document.getElementById('saveClientPortals').onclick = saveClientPortalsFromUI; document.getElementById('addPortalBtn').onclick = addEmptyPortalRow; const quickAddUserBtn = document.getElementById('clientPortalsQuickAddUser'); if (quickAddUserBtn) { quickAddUserBtn.onclick = () => { // Reuse existing admin add-user button / modal const globalBtn = document.getElementById('adminOpenAddUser'); if (globalBtn) { globalBtn.click(); } else { showToast('Use the Users tab to add a new user.'); } }; } const openPermsBtn = document.getElementById('clientPortalsOpenUserPerms'); if (openPermsBtn) { openPermsBtn.onclick = () => { const btn = document.getElementById('adminOpenUserPermissions'); if (btn) { btn.click(); } else { showToast('Use the Users tab to edit folder access.'); } }; } const openGroupsBtn = document.getElementById('clientPortalsOpenUserGroups'); if (openGroupsBtn) { openGroupsBtn.onclick = () => { const btn = document.getElementById('adminOpenUserGroups'); if (btn) { btn.click(); } else { showToast('Use the Users tab to manage user groups.'); } }; } } else { modal.style.background = overlayBg; const content = modal.querySelector('.modal-content'); if (content) { content.style.background = contentBg; content.style.color = contentFg; content.style.border = `1px solid ${borderCol}`; } } modal.style.display = 'flex'; await loadClientPortalsList(); } // ───────────────────────────────────────── // Internal helpers – same behavior as now // ───────────────────────────────────────── async function loadClientPortalsList(useCacheOnly) { const body = document.getElementById('clientPortalsBody'); const status = document.getElementById('clientPortalsStatus'); if (!body) return; body.textContent = `${t('loading')}…`; if (status) { status.textContent = ''; status.className = 'small text-muted'; } try { let portals; if (useCacheOnly && __portalsCache && Object.keys(__portalsCache).length) { portals = __portalsCache; } else { portals = await fetchAllPortals(); __portalsCache = portals || {}; } const slugs = Object.keys(__portalsCache).sort((a, b) => a.localeCompare(b)); if (!slugs.length) { body.innerHTML = `No client portals defined yet. Click “Add portal” to create one.
`; return; } let html = ''; slugs.forEach(slug => { const origin = window.location.origin || ''; const portalPath = '/portal/' + encodeURIComponent(slug); const portalUrl = origin ? origin + portalPath : portalPath; const p = __portalsCache[slug] || {}; const label = p.label || slug; const folder = p.folder || ''; const clientEmail = p.clientEmail || ''; const uploadOnly = !!p.uploadOnly; // Backwards compat: // - Old portals only had "uploadOnly": // uploadOnly = true => upload yes, download no // uploadOnly = false => upload yes, download yes // - New portals have explicit allowDownload. let allowDownload; if (Object.prototype.hasOwnProperty.call(p, 'allowDownload')) { allowDownload = p.allowDownload !== false; } else { // Legacy: "upload only" meant no download allowDownload = !uploadOnly; } const expiresAt = p.expiresAt ? String(p.expiresAt).slice(0, 10) : ''; const brandColor = p.brandColor || ''; const footerText = p.footerText || ''; const formDefaults = p.formDefaults || {}; const formRequired = p.formRequired || {}; const formLabels = p.formLabels || {}; const formVisible = p.formVisible || {}; const uploadMaxSizeMb = typeof p.uploadMaxSizeMb === 'number' ? p.uploadMaxSizeMb : (p.uploadMaxSizeMb ? parseInt(p.uploadMaxSizeMb, 10) || 0 : 0); const uploadExtWhitelist = p.uploadExtWhitelist || ''; const uploadMaxPerDay = typeof p.uploadMaxPerDay === 'number' ? p.uploadMaxPerDay : (p.uploadMaxPerDay ? parseInt(p.uploadMaxPerDay, 10) || 0 : 0); const showThankYou = !!p.showThankYou; const thankYouText = p.thankYouText || ''; const defName = formDefaults.name || ''; const defEmail = formDefaults.email || ''; const defRef = formDefaults.reference || ''; const defNotes = formDefaults.notes || ''; const lblName = formLabels.name || 'Name'; const lblEmail = formLabels.email || 'Email'; const lblRef = formLabels.reference || 'Reference / Case / Order #'; const lblNotes = formLabels.notes || 'Notes'; const visibleName = formVisible.name !== false; const visibleEmail = formVisible.email !== false; const visibleRef = formVisible.reference !== false; const visibleNotes = formVisible.notes !== false; const title = p.title || ''; const introText = p.introText || ''; const requireForm = !!p.requireForm; html += `Error loading client portals.
`; if (status) { status.textContent = 'Error loading client portals.'; status.className = 'small text-danger'; } } } function addEmptyPortalRow() { if (!__portalsCache || typeof __portalsCache !== 'object') { __portalsCache = {}; } let base = 'portal-' + Math.random().toString(36).slice(2, 8); let slug = base; let i = 1; while (__portalsCache[slug]) { slug = `${base}-${i++}`; } __portalsCache[slug] = { label: 'New client portal', folder: '', clientEmail: '', uploadOnly: true, allowDownload: false, expiresAt: '' }; // After re-render, auto-focus this portal's folder field __portalSlugToFocus = slug; loadClientPortalsList(true); } // ───────────────────── // Folder picker helpers // ───────────────────── function attachPortalFolderPickers() { const body = document.getElementById('clientPortalsBody'); if (!body) return; body.querySelectorAll('.portal-card').forEach(card => { const input = card.querySelector('[data-portal-field="folder"]'); const browseBtn = card.querySelector('.portal-folder-browse-btn'); if (!input) return; if (input.dataset._portalFolderPickerBound === '1') return; input.dataset._portalFolderPickerBound = '1'; // Preferred path: if you ever add a central folder picker, use it: const useNativePicker = typeof window.FileRiseFolderPicker === 'function'; const openPicker = async () => { if (useNativePicker) { try { const folder = await window.FileRiseFolderPicker({ current: input.value || '', mode: 'select-folder', source: 'client-portals' }); if (folder) input.value = folder; return; } catch (e) { console.error('Folder picker error', e); showToast('Could not open folder picker.'); return; } } // Fallback: datalist built from /api/folder/getFolderList.php try { let datalist = document.getElementById('portalFolderList'); if (!datalist) { datalist = document.createElement('datalist'); datalist.id = 'portalFolderList'; document.body.appendChild(datalist); const folders = await loadPortalFolderList(); datalist.innerHTML = ''; folders.forEach(f => { const opt = document.createElement('option'); opt.value = f; datalist.appendChild(opt); }); } input.setAttribute('list', 'portalFolderList'); input.focus(); input.select(); } catch (e) { console.error('Error preparing folder list', e); input.focus(); input.select(); } }; // Clicking or focusing the input prepares the list input.addEventListener('focus', openPicker); input.addEventListener('click', openPicker); // Browse button does the same thing if (browseBtn && !browseBtn.__frFolderPickerBound) { browseBtn.__frFolderPickerBound = true; browseBtn.addEventListener('click', (e) => { e.preventDefault(); openPicker(); }); } }); } function attachPortalLogoUploaders() { const body = document.getElementById('clientPortalsBody'); if (!body) return; body.querySelectorAll('.portal-card').forEach(card => { const uploadBtn = card.querySelector('.portal-logo-upload-btn'); if (!uploadBtn) return; if (uploadBtn.__frLogoBound) return; uploadBtn.__frLogoBound = true; const slug = (card.getAttribute('data-portal-slug') || '').trim(); const logoField = card.querySelector('[data-portal-field="logoFile"]'); // Hidden file input per card const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'image/*'; fileInput.style.display = 'none'; card.appendChild(fileInput); uploadBtn.addEventListener('click', (e) => { e.preventDefault(); if (!slug) { showToast('Please set a portal slug before uploading a logo.'); return; } fileInput.click(); }); fileInput.addEventListener('change', async () => { if (!fileInput.files || !fileInput.files.length) return; const file = fileInput.files[0]; const formData = new FormData(); formData.append('portal_logo', file); formData.append('slug', slug); try { const res = await fetch('/api/pro/portals/uploadLogo.php', { method: 'POST', credentials: 'include', headers: { 'X-CSRF-Token': window.csrfToken || '' }, body: formData }); const data = await safeJson(res); if (!data || data.success !== true) { throw new Error(data && data.error ? data.error : 'Upload failed'); } const fileName = data.fileName || data.filename || ''; if (logoField && fileName) { logoField.value = fileName; } showToast('Portal logo uploaded.'); } catch (err) { console.error(err); showToast('Error uploading portal logo: ' + (err && err.message ? err.message : err)); } finally { fileInput.value = ''; } }); }); } // ───────────────────── // Intake presets helpers // ───────────────────── function applyPresetToPortalCard(card, presetKey) { const preset = PORTAL_INTAKE_PRESETS[presetKey]; if (!preset) return; const setVal = (selector, value) => { const el = card.querySelector(selector); if (el) el.value = value != null ? String(value) : ''; }; const setChecked = (selector, value) => { const el = card.querySelector(selector); if (el) el.checked = !!value; }; // Display name (admin label) if (preset.label) { setVal('[data-portal-field="label"]', preset.label); const headerLabelEl = card.querySelector('.portal-card-header-main strong'); if (headerLabelEl) { headerLabelEl.textContent = preset.label; } } // Title / intro / footer / accent / require-form setVal('[data-portal-field="title"]', preset.title || ''); setVal('[data-portal-field="introText"]', preset.introText || ''); setVal('[data-portal-field="footerText"]', preset.footerText || ''); if (preset.brandColor) { setVal('[data-portal-field="brandColor"]', preset.brandColor); } setChecked('[data-portal-field="requireForm"]', !!preset.requireForm); // Visibility toggles if (preset.formVisible) { setChecked('[data-portal-field="visName"]', !!preset.formVisible.name); setChecked('[data-portal-field="visEmail"]', !!preset.formVisible.email); setChecked('[data-portal-field="visRef"]', !!preset.formVisible.reference); setChecked('[data-portal-field="visNotes"]', !!preset.formVisible.notes); } // Labels if (preset.formLabels) { setVal('[data-portal-field="lblName"]', preset.formLabels.name || ''); setVal('[data-portal-field="lblEmail"]', preset.formLabels.email || ''); setVal('[data-portal-field="lblRef"]', preset.formLabels.reference || ''); setVal('[data-portal-field="lblNotes"]', preset.formLabels.notes || ''); } // Defaults if (preset.formDefaults) { setVal('[data-portal-field="defName"]', preset.formDefaults.name || ''); setVal('[data-portal-field="defEmail"]', preset.formDefaults.email || ''); setVal('[data-portal-field="defRef"]', preset.formDefaults.reference || ''); setVal('[data-portal-field="defNotes"]', preset.formDefaults.notes || ''); } // Required flags if (preset.formRequired) { setChecked('[data-portal-field="reqName"]', !!preset.formRequired.name); setChecked('[data-portal-field="reqEmail"]', !!preset.formRequired.email); setChecked('[data-portal-field="reqRef"]', !!preset.formRequired.reference); setChecked('[data-portal-field="reqNotes"]', !!preset.formRequired.notes); } showToast(`Applied "${preset.label}" preset.`); } function attachPortalPresetSelectors() { const body = document.getElementById('clientPortalsBody'); if (!body) return; body.querySelectorAll('.portal-card').forEach(card => { const select = card.querySelector('.portal-intake-preset'); if (!select || select._frPresetBound) return; select._frPresetBound = true; select.addEventListener('change', () => { const key = select.value; if (!key) return; applyPresetToPortalCard(card, key); }); }); } // ───────────────────── // Submissions helpers // ───────────────────── async function fetchPortalSubmissions(slug) { const res = await fetch('/api/pro/portals/submissions.php?slug=' + encodeURIComponent(slug), { credentials: 'include', headers: { 'X-CSRF-Token': window.csrfToken || '' } }); const data = await safeJson(res); if (!data || data.success === false) { throw new Error((data && data.error) || 'Failed to load submissions'); } const submissions = Array.isArray(data.submissions) ? data.submissions : []; // Cache for CSV export __portalSubmissionsCache[slug] = submissions; return submissions; } function renderPortalSubmissionsList(listEl, countEl, submissions) { listEl.textContent = ''; if (!Array.isArray(submissions) || submissions.length === 0) { countEl.textContent = 'No submissions'; const empty = document.createElement('div'); empty.className = 'portal-submissions-item portal-submissions-empty'; empty.textContent = 'No submissions yet.'; listEl.appendChild(empty); return; } countEl.textContent = submissions.length === 1 ? '1 submission' : submissions.length + ' submissions'; submissions.forEach(sub => { const item = document.createElement('div'); item.className = 'portal-submissions-item'; const header = document.createElement('div'); header.className = 'portal-submissions-header'; const headerParts = []; const created = sub.createdAt || sub.created_at || sub.timestamp || sub.time; if (created) { try { const d = typeof created === 'number' ? new Date(created * 1000) : new Date(created); if (!isNaN(d.getTime())) { headerParts.push(d.toLocaleString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })); } } catch { headerParts.push(String(created)); } } const raw = sub.raw || sub; const folder = sub.folder || (raw && raw.folder) || ''; const submittedBy = sub.submittedBy || (raw && raw.submittedBy) || ''; const ip = sub.ip || (raw && raw.ip) || ''; if (folder) headerParts.push('Folder: ' + folder); if (submittedBy) headerParts.push('Submitted by: ' + submittedBy); if (ip) headerParts.push('IP: ' + ip); header.textContent = headerParts.join(' • '); const summary = document.createElement('div'); summary.className = 'portal-submissions-summary'; const form = raw.form || sub.form || raw; const summaryParts = []; const name = form.name || sub.name || ''; const email = form.email || sub.email || ''; const ref = form.reference || form.ref || sub.reference || sub.ref || ''; const notes = form.notes || form.message || sub.notes || sub.message || ''; if (name) summaryParts.push('Name: ' + name); if (email) summaryParts.push('Email: ' + email); if (ref) summaryParts.push('Ref: ' + ref); if (notes) summaryParts.push('Notes: ' + notes); summary.textContent = summaryParts.join(' • '); item.appendChild(header); if (summaryParts.length) { item.appendChild(summary); } listEl.appendChild(item); }); } function normalizeSubmissionForCsv(sub) { const created = sub.createdAt || sub.created_at || sub.timestamp || sub.time || ''; const raw = sub.raw || sub; const folder = sub.folder || (raw && raw.folder) || ''; const submittedBy = sub.submittedBy || (raw && raw.submittedBy) || ''; const ip = sub.ip || (raw && raw.ip) || ''; const form = raw.form || sub.form || raw || {}; const name = form.name || sub.name || ''; const email = form.email || sub.email || ''; const reference = form.reference || form.ref || sub.reference || sub.ref || ''; const notes = form.notes || form.message || sub.notes || sub.message || ''; return { created, folder, submittedBy, ip, name, email, reference, notes }; } function csvEscape(val) { if (val == null) return ''; const str = String(val); if (/[",\n\r]/.test(str)) { return '"' + str.replace(/"/g, '""') + '"'; } return str; } function exportSubmissionsToCsv(slug, submissions) { if (!Array.isArray(submissions) || !submissions.length) { showToast('No submissions to export.'); return; } const header = [ 'Created', 'Folder', 'SubmittedBy', 'IP', 'Name', 'Email', 'Reference', 'Notes' ]; const lines = []; lines.push(header.map(csvEscape).join(',')); submissions.forEach(sub => { const row = normalizeSubmissionForCsv(sub); const cols = [ row.created, row.folder, row.submittedBy, row.ip, row.name, row.email, row.reference, row.notes ]; lines.push(cols.map(csvEscape).join(',')); }); const csv = lines.join('\r\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (slug || 'portal') + '-submissions.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => { URL.revokeObjectURL(url); }, 0); } function attachPortalSubmissionsUI() { const body = document.getElementById('clientPortalsBody'); if (!body) return; body.querySelectorAll('.portal-card').forEach(card => { if (card.querySelector('.portal-submissions-block')) { return; } const slug = card.getAttribute('data-portal-slug') || ''; if (!slug) return; const container = document.createElement('div'); container.className = 'portal-submissions-block'; const headerRow = document.createElement('div'); headerRow.className = 'd-flex align-items-center justify-content-between mb-1'; const title = document.createElement('strong'); title.textContent = 'Submissions'; const buttonsWrap = document.createElement('div'); buttonsWrap.className = 'd-flex align-items-center'; buttonsWrap.style.gap = '6px'; const loadBtn = document.createElement('button'); loadBtn.type = 'button'; loadBtn.className = 'btn btn-sm btn-outline-secondary portal-submissions-load-btn'; loadBtn.textContent = 'Load submissions'; loadBtn.setAttribute('data-portal-action', 'load-submissions'); const exportBtn = document.createElement('button'); exportBtn.type = 'button'; exportBtn.className = 'btn btn-sm btn-outline-secondary portal-submissions-export-btn'; exportBtn.textContent = 'Export CSV'; buttonsWrap.appendChild(loadBtn); buttonsWrap.appendChild(exportBtn); headerRow.appendChild(title); headerRow.appendChild(buttonsWrap); container.appendChild(headerRow); const countEl = document.createElement('small'); countEl.className = 'text-muted portal-submissions-count'; countEl.textContent = 'No submissions'; container.appendChild(countEl); const listEl = document.createElement('div'); listEl.className = 'portal-submissions-list'; container.appendChild(listEl); const bodyEl = card.querySelector('.portal-card-body') || card; bodyEl.appendChild(container); const loadSubmissions = async () => { countEl.textContent = 'Loading...'; listEl.textContent = ''; try { const submissions = await fetchPortalSubmissions(slug); renderPortalSubmissionsList(listEl, countEl, submissions); return submissions; } catch (err) { console.error(err); countEl.textContent = 'Error loading submissions'; showToast('Error loading submissions: ' + (err && err.message ? err.message : err)); return []; } }; loadBtn.addEventListener('click', () => { loadSubmissions(); }); exportBtn.addEventListener('click', async () => { let submissions = __portalSubmissionsCache[slug]; // If we don't have anything cached yet, load them first if (!submissions || !submissions.length) { submissions = await loadSubmissions(); } if (!submissions || !submissions.length) { showToast('No submissions to export yet.'); return; } exportSubmissionsToCsv(slug, submissions); }); // Initial auto-load so the admin sees something right away loadSubmissions(); }); } // ───────────────────── // Save portals // ───────────────────── async function saveClientPortalsFromUI() { const body = document.getElementById('clientPortalsBody'); const status = document.getElementById('clientPortalsStatus'); if (!body) return; const cards = body.querySelectorAll('.card[data-portal-slug]'); const portals = {}; const invalid = []; let firstInvalidField = null; // Clear previous visual errors cards.forEach(card => { card.style.boxShadow = ''; card.style.borderColor = ''; card.classList.remove('portal-card-has-error'); const hint = card.querySelector('.portal-card-error-hint'); if (hint) hint.remove(); }); const markCardMissingRequired = (card, message) => { // Mark visually card.classList.add('portal-card-has-error'); card.style.borderColor = '#dc3545'; card.style.boxShadow = '0 0 0 2px rgba(220,53,69,0.6)'; // Expand the card so the error is visible even if it was collapsed const header = card.querySelector('.portal-card-header'); const bodyEl = card.querySelector('.portal-card-body') || card; const caret = card.querySelector('.portal-card-caret'); if (header && bodyEl) { header.setAttribute('aria-expanded', 'true'); bodyEl.style.display = 'block'; if (caret) caret.textContent = '▾'; } // Small inline hint at top of the card body let hint = bodyEl.querySelector('.portal-card-error-hint'); if (!hint) { hint = document.createElement('div'); hint.className = 'portal-card-error-hint text-danger small'; hint.style.marginBottom = '6px'; hint.textContent = message || 'Slug and folder are required. This portal will not be saved until both are filled.'; bodyEl.insertBefore(hint, bodyEl.firstChild); } else { hint.textContent = message || hint.textContent; } }; cards.forEach(card => { const origSlug = card.getAttribute('data-portal-slug') || ''; let slug = origSlug.trim(); const getVal = (selector) => { const el = card.querySelector(selector); return el ? el.value || '' : ''; }; const label = getVal('[data-portal-field="label"]').trim(); const folder = getVal('[data-portal-field="folder"]').trim(); const clientEmail = getVal('[data-portal-field="clientEmail"]').trim(); const expiresAt = getVal('[data-portal-field="expiresAt"]').trim(); const title = getVal('[data-portal-field="title"]').trim(); const introText = getVal('[data-portal-field="introText"]').trim(); const brandColor = getVal('[data-portal-field="brandColor"]').trim(); const footerText = getVal('[data-portal-field="footerText"]').trim(); const logoFile = getVal('[data-portal-field="logoFile"]').trim(); const logoUrl = getVal('[data-portal-field="logoUrl"]').trim(); // (optional, not exposed in UI yet) const defName = getVal('[data-portal-field="defName"]').trim(); const defEmail = getVal('[data-portal-field="defEmail"]').trim(); const defRef = getVal('[data-portal-field="defRef"]').trim(); const defNotes = getVal('[data-portal-field="defNotes"]').trim(); const lblName = getVal('[data-portal-field="lblName"]').trim(); const lblEmail = getVal('[data-portal-field="lblEmail"]').trim(); const lblRef = getVal('[data-portal-field="lblRef"]').trim(); const lblNotes = getVal('[data-portal-field="lblNotes"]').trim(); const uploadOnlyEl = card.querySelector('[data-portal-field="uploadOnly"]'); const allowDownloadEl = card.querySelector('[data-portal-field="allowDownload"]'); const requireFormEl = card.querySelector('[data-portal-field="requireForm"]'); const uploadOnly = uploadOnlyEl ? !!uploadOnlyEl.checked : true; const allowDownload = allowDownloadEl ? !!allowDownloadEl.checked : false; const requireForm = requireFormEl ? !!requireFormEl.checked : false; const reqNameEl = card.querySelector('[data-portal-field="reqName"]'); const reqEmailEl = card.querySelector('[data-portal-field="reqEmail"]'); const reqRefEl = card.querySelector('[data-portal-field="reqRef"]'); const reqNotesEl = card.querySelector('[data-portal-field="reqNotes"]'); const reqName = reqNameEl ? !!reqNameEl.checked : false; const reqEmail = reqEmailEl ? !!reqEmailEl.checked : false; const reqRef = reqRefEl ? !!reqRefEl.checked : false; const reqNotes = reqNotesEl ? !!reqNotesEl.checked : false; const visNameEl = card.querySelector('[data-portal-field="visName"]'); const visEmailEl = card.querySelector('[data-portal-field="visEmail"]'); const visRefEl = card.querySelector('[data-portal-field="visRef"]'); const visNotesEl = card.querySelector('[data-portal-field="visNotes"]'); const visName = visNameEl ? !!visNameEl.checked : true; const visEmail = visEmailEl ? !!visEmailEl.checked : true; const visRef = visRefEl ? !!visRefEl.checked : true; const visNotes = visNotesEl ? !!visNotesEl.checked : true; const uploadMaxSizeMb = getVal('[data-portal-field="uploadMaxSizeMb"]').trim(); const uploadExtWhitelist = getVal('[data-portal-field="uploadExtWhitelist"]').trim(); const uploadMaxPerDay = getVal('[data-portal-field="uploadMaxPerDay"]').trim(); const thankYouText = getVal('[data-portal-field="thankYouText"]').trim(); const showThankYouEl = card.querySelector('[data-portal-field="showThankYou"]'); const showThankYou = showThankYouEl ? !!showThankYouEl.checked : false; const folderInput = card.querySelector('[data-portal-field="folder"]'); const slugInput = card.querySelector('[data-portal-field="slug"]'); if (slugInput) { const rawSlug = slugInput.value.trim(); if (rawSlug) slug = rawSlug; } const labelForError = label || slug || origSlug || '(unnamed portal)'; // Validation: slug + folder required if (!slug || !folder) { invalid.push(labelForError); // Remember the first problematic field so we can scroll exactly to it if (!firstInvalidField) { if (!folder && folderInput) { firstInvalidField = folderInput; } else if (!slug && slugInput) { firstInvalidField = slugInput; } else { firstInvalidField = card; } } markCardMissingRequired( card, 'Slug and folder are required. This portal will not be saved until both are filled.' ); return; } portals[slug] = { label, folder, clientEmail, uploadOnly, allowDownload, expiresAt, title, introText, requireForm, brandColor, footerText, logoFile, logoUrl, formDefaults: { name: defName, email: defEmail, reference: defRef, notes: defNotes }, formRequired: { name: reqName, email: reqEmail, reference: reqRef, notes: reqNotes }, formLabels: { name: lblName, email: lblEmail, reference: lblRef, notes: lblNotes }, formVisible: { name: visName, email: visEmail, reference: visRef, notes: visNotes }, uploadMaxSizeMb: uploadMaxSizeMb ? parseInt(uploadMaxSizeMb, 10) || 0 : 0, uploadExtWhitelist, uploadMaxPerDay: uploadMaxPerDay ? parseInt(uploadMaxPerDay, 10) || 0 : 0, showThankYou, thankYouText, }; }); if (invalid.length) { if (status) { status.textContent = 'Please fill slug and folder for highlighted portals.'; status.className = 'small text-danger'; } // Scroll the *first missing field* into view so the admin sees exactly where to fix const targetEl = firstInvalidField || body.querySelector('.portal-card-has-error'); if (targetEl) { targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); // If it's an input, focus + select to make typing instant if (typeof targetEl.focus === 'function') { targetEl.focus(); if (typeof targetEl.select === 'function') { targetEl.select(); } } } showToast('Please set slug and folder for: ' + invalid.join(', ')); return; // Don’t hit the API if local validation failed } if (status) { status.textContent = 'Saving…'; status.className = 'small text-muted'; } try { const res = await saveAllPortals(portals); if (!res || res.success !== true) { throw new Error(res && res.error ? res.error : 'Unknown error saving client portals'); } __portalsCache = portals; if (status) { status.textContent = 'Saved.'; status.className = 'small text-success'; } showToast('Client portals saved.'); // Re-render from cache so headers / slugs / etc. all reflect the saved state await loadClientPortalsList(true); } catch (e) { console.error(e); if (status) { status.textContent = 'Error saving.'; status.className = 'small text-danger'; } showToast('Error saving client portals: ' + (e.message || e)); } }