/* NEXT OS Communications: authenticated form management and client responses. */ /* ─── tiny helpers ─── */ const cfUid = () => Math.random().toString(36).slice(2, 10) + Date.now().toString(36); /* Supabase helpers */ const getSb = () => new Promise((resolve, reject) => { const startedAt = Date.now(); const check = () => { const sb = window.OS_DATA?.getSupabaseClient ? window.OS_DATA.getSupabaseClient() : null; if (sb) return resolve(sb); if (Date.now() - startedAt >= 15000) { return reject(new Error('Supabase did not initialize. Check the network connection and reload NEXT OS.')); } setTimeout(check, 100); }; check(); }); async function cfLoadForms() { const sb = await getSb(); const [formsResult, responsesResult] = await Promise.all([ sb.from('os_forms').select('*').order('created_at', { ascending: false }), sb.from('os_form_responses').select('*').order('submitted_at', { ascending: false }) ]); if (formsResult.error) throw new Error(`Could not load client forms: ${formsResult.error.message}`); if (responsesResult.error) throw new Error(`Could not load client responses. Check the signed-in account's form access: ${responsesResult.error.message}`); const forms = formsResult.data || []; const responses = responsesResult.data || []; return forms.map(f => ({ ...f, created: f.created_at, responses: (responses || []).filter(r => r.form_id === f.id).map(r => ({ id: r.id, answers: r.answers, submittedAt: r.submitted_at, seen: r.seen })) })); } async function cfLoadDiscoveryInbox() { const sb = await getSb(); const { data, error, count } = await sb.from('discovery_submissions') .select('id,created_at,client_company,contact_name,contact_email,contact_phone,status', { count: 'exact' }) .order('created_at', { ascending: false }) .limit(100); if (error) throw new Error(`Could not load the Sembule discovery inbox: ${error.message}`); return { submissions: data || [], count: count ?? (data || []).length }; } async function cfLoadDiscoverySubmission(id) { const sb = await getSb(); const { data, error } = await sb.from('discovery_submissions') .select('id,created_at,client_company,contact_name,contact_email,contact_phone,status,answers') .eq('id', id) .single(); if (error) throw new Error(`Could not open the discovery submission: ${error.message}`); return data; } async function cfDownloadDiscoveryFile(submissionId, file) { const path = file?.path; if (typeof path !== 'string' || !path.startsWith(`${submissionId}/`) || !/^[-a-f0-9]{36}\/[-a-f0-9]{36}\.(png|jpe?g|webp|gif|svg|pdf|docx?|xlsx?|csv)$/i.test(path)) { throw new Error('This attachment path is not valid.'); } const sb = await getSb(); const { data, error } = await sb.storage.from('discovery-uploads').download(path); if (error) throw new Error(`Could not download ${file.name || 'the attachment'}: ${error.message}`); const url = URL.createObjectURL(data); const link = document.createElement('a'); link.href = url; link.download = String(file.name || 'client-attachment').replace(/[\\/:*?"<>|]/g, '_'); document.body.appendChild(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function cfPrintEscape(value) { return String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', })[char]); } function cfPrintAnswerValue(answer) { const value = answer?.value; if (answer?.type === 'file') { const files = Array.isArray(value) ? value : []; return files.length ? files.map(file => `${file.name || 'Attachment'}${file.size ? ` (${Math.max(1, Math.round(Number(file.size) / 1024))} KB)` : ''}`).join('\n') : 'No file attached'; } if (value === null || value === undefined || value === '') return 'Not provided'; if (typeof value === 'boolean') return value ? 'Yes' : 'No'; if (Array.isArray(value)) { return value.map(item => item && typeof item === 'object' ? Object.entries(item).map(([key, cell]) => `${key}: ${String(cell ?? '')}`).join('\n') : String(item ?? '')).join('\n'); } if (typeof value === 'object') return JSON.stringify(value, null, 2); return String(value); } function cfOpenResponsePdf({ title, company, contact, submittedAt, status, reference, sections, clientLogo }, printWindow = null) { printWindow = printWindow || window.open('', '_blank'); if (!printWindow) throw new Error('Allow pop-ups for NEXT OS to export this response as a PDF.'); const brandLogo = new URL('/uploads/NEXT Favicon Transperent Logo@3x.png', window.location.origin).href; const sectionMarkup = (sections || []).map(section => `

${cfPrintEscape(section.title || 'Answers')}

${(section.answers || []).map(answer => `
${cfPrintEscape(answer.label || 'Answer')}
${cfPrintEscape(cfPrintAnswerValue(answer))}
`).join('')}
`).join(''); const logoMarkup = clientLogo ? `` : ''; const safeTitle = String(title || 'Client response').replace(/[\\/:*?"<>|]+/g, '-').slice(0, 100); const submitted = submittedAt ? new Date(submittedAt).toLocaleString() : 'Date not available'; printWindow.document.open(); printWindow.document.write(`NEXT - ${cfPrintEscape(safeTitle)} - ${cfPrintEscape(company || 'Client response')}
NEXT logo
NEXT
Digital Operating System
${logoMarkup}

${cfPrintEscape(title || 'Client response')}

${cfPrintEscape(company || 'Client submission')}

Contact${cfPrintEscape(contact || 'Not provided')}
Submitted${cfPrintEscape(submitted)}
Status${cfPrintEscape(status || 'Received')}
Reference${cfPrintEscape(reference || 'Not available')}
${sectionMarkup || '
No answers were included in this response.
'}
`); printWindow.document.close(); } function cfExportFormResponse(form, response) { const sections = [{ title: 'Client answers', answers: (form.fields || []).map(field => ({ label: field.label, type: field.type, value: response.answers?.[field.id] })), }]; cfOpenResponsePdf({ title: form.title || 'Client form response', company: cfClientName(form, response) || 'Client submission', submittedAt: response.submittedAt, status: response.seen ? 'Reviewed' : 'New response', reference: response.id, sections, }); } async function cfDiscoveryClientLogo(submission) { const answers = (submission.answers?.sections || []).flatMap(section => section.answers || []); const logoAnswer = answers.find(answer => answer.type === 'file' && /logo/i.test(answer.label || '')); const file = (Array.isArray(logoAnswer?.value) ? logoAnswer.value : []).find(item => typeof item?.path === 'string' && item.path.startsWith(`${submission.id}/`) && /^[-a-f0-9]{36}\/[-a-f0-9]{36}\.(png|jpe?g|webp|gif)$/i.test(item.path)); if (!file) return ''; try { const sb = await getSb(); const { data, error } = await sb.storage.from('discovery-uploads').download(file.path); if (error || !data) return ''; return await new Promise(resolve => { const reader = new FileReader(); reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); reader.onerror = () => resolve(''); reader.readAsDataURL(data); }); } catch (error) { return ''; } } async function cfExportDiscoverySubmission(submission) { // Open the print tab before awaiting storage so browsers do not block the popup. const printWindow = window.open('', '_blank'); if (!printWindow) throw new Error('Allow pop-ups for NEXT OS to export this response as a PDF.'); printWindow.document.write('Preparing client response…

Preparing the branded response PDF…

'); try { const clientLogo = await cfDiscoveryClientLogo(submission); const sections = (Array.isArray(submission.answers?.sections) ? submission.answers.sections : []).map(section => ({ title: section.title, answers: Array.isArray(section.answers) ? section.answers : [], })); cfOpenResponsePdf({ title: 'Project discovery response', company: submission.client_company, contact: [submission.contact_name, submission.contact_email, submission.contact_phone].filter(Boolean).join(' · '), submittedAt: submission.created_at, status: submission.status, reference: submission.id, sections, clientLogo, }, printWindow); } catch (error) { printWindow.close(); throw error; } } async function cfSaveForm(form) { const sb = await getSb(); const { id, title, description, fields, status } = form; let result; if (id.startsWith('draft-')) { result = await sb.from('os_forms').insert({ title, description, fields, status }); } else { result = await sb.from('os_forms').update({ title, description, fields, status }).eq('id', id); } if (result.error) throw new Error(`Could not save the form: ${result.error.message}`); } async function cfDeleteForm(id) { const sb = await getSb(); const { error } = await sb.from('os_forms').delete().eq('id', id); if (error) throw new Error(`Could not delete the form: ${error.message}`); } async function cfMarkSeen(responseId) { const sb = await getSb(); const { error } = await sb.from('os_form_responses').update({ seen: true }).eq('id', responseId); if (error) throw new Error(`Could not mark the response as read: ${error.message}`); } const CF_RUNTIME = window.__NEXT_OS_FORMS_RUNTIME || (window.__NEXT_OS_FORMS_RUNTIME = { channel: null, pollTimer: null, starting: false, baselineReady: false, baselineIds: new Set(), notifiedIds: new Set(), discoveryBaselineIds: new Set(), discoveryNotifiedIds: new Set(), discovery: { submissions: [], count: 0, error: '' }, status: 'CLOSED', authSubscription: null, }); function cfIsAdmin(session) { const email = String(session?.user?.email || '').trim().toLowerCase(); return session?.user?.app_metadata?.role === 'nextos_admin' || email === 'hudson.tim.uk@gmail.com' || email === 'patrickemma143@gmail.com'; } function cfClientName(form, response) { const fields = form?.fields || []; const nameField = fields.find(field => /\bname\b/i.test(field.label || '')); const value = nameField && response?.answers?.[nameField.id]; return typeof value === 'string' ? value.trim().slice(0, 80) : ''; } function cfDispatchChange(detail) { window.dispatchEvent(new CustomEvent('nextos:forms-changed', { detail: detail || {} })); } function cfAnnounceResponse(response, form) { if (!response?.id || CF_RUNTIME.notifiedIds.has(response.id)) return; CF_RUNTIME.notifiedIds.add(response.id); const name = cfClientName(form, response); const formTitle = form?.title || 'Client form'; const message = `${name || 'A client'} submitted “${formTitle}”.`; cfDispatchChange({ type: 'response', responseId: response.id, formTitle, clientName: name }); if (window.NEXT_OS?.notify) { window.NEXT_OS.notify({ severity: 'success', title: 'New client form response', body: message, source: 'Communications', actionUrl: 'os://comms', actionLabel: 'View response', dedupeKey: `client-form-response:${response.id}`, }); } } function cfAnnounceDiscoverySubmission(submission) { if (!submission?.id || CF_RUNTIME.discoveryNotifiedIds.has(submission.id)) return; CF_RUNTIME.discoveryNotifiedIds.add(submission.id); const client = [submission.contact_name, submission.client_company].filter(Boolean).join(' · '); const message = `${client || 'A client'} submitted the Sembule Media discovery form.`; cfDispatchChange({ type: 'discovery-response', submissionId: submission.id, clientName: submission.contact_name, clientCompany: submission.client_company }); if (window.NEXT_OS?.notify) { window.NEXT_OS.notify({ severity: 'success', title: 'New discovery submission', body: message, source: 'Communications', actionUrl: 'os://comms', actionLabel: 'View submission', dedupeKey: `discovery-submission:${submission.id}`, }); } } async function cfRefreshRuntime() { const forms = await cfLoadForms(); let discovery = { submissions: [], count: 0, error: '' }; try { discovery = await cfLoadDiscoveryInbox(); } catch (error) { discovery.error = error.message || 'Could not load the discovery inbox.'; } if (CF_RUNTIME.baselineReady) { forms.forEach(form => (form.responses || []).forEach(response => { if (!CF_RUNTIME.baselineIds.has(response.id)) cfAnnounceResponse(response, form); })); discovery.submissions.forEach(submission => { if (!CF_RUNTIME.discoveryBaselineIds.has(submission.id)) cfAnnounceDiscoverySubmission(submission); }); } forms.forEach(form => (form.responses || []).forEach(response => CF_RUNTIME.baselineIds.add(response.id))); discovery.submissions.forEach(submission => CF_RUNTIME.discoveryBaselineIds.add(submission.id)); CF_RUNTIME.discovery = discovery; CF_RUNTIME.baselineReady = true; cfDispatchChange({ type: 'refresh', discovery }); return forms; } function cfStopRealtime(sb) { if (CF_RUNTIME.pollTimer) clearInterval(CF_RUNTIME.pollTimer); CF_RUNTIME.pollTimer = null; if (CF_RUNTIME.channel && sb) sb.removeChannel(CF_RUNTIME.channel); CF_RUNTIME.channel = null; CF_RUNTIME.starting = false; CF_RUNTIME.baselineReady = false; CF_RUNTIME.baselineIds.clear(); CF_RUNTIME.notifiedIds.clear(); CF_RUNTIME.discoveryBaselineIds.clear(); CF_RUNTIME.discoveryNotifiedIds.clear(); CF_RUNTIME.discovery = { submissions: [], count: 0, error: '' }; CF_RUNTIME.status = 'CLOSED'; } async function cfStartRealtime(sb) { if (CF_RUNTIME.channel || CF_RUNTIME.starting) return; CF_RUNTIME.starting = true; try { const { data: authData, error: authError } = await sb.auth.getSession(); if (authError) throw authError; if (!cfIsAdmin(authData.session)) { CF_RUNTIME.starting = false; return; } await cfRefreshRuntime(); CF_RUNTIME.pollTimer = setInterval(() => { cfRefreshRuntime().catch(error => console.warn('[NEXT OS] Communications refresh failed:', error)); }, 15000); CF_RUNTIME.channel = sb.channel('os_forms_channel') .on('postgres_changes', { event: '*', schema: 'public', table: 'os_forms' }, () => { cfRefreshRuntime().catch(error => console.warn('[NEXT OS] Form refresh failed:', error)); }) .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'os_form_responses' }, async payload => { try { const forms = await cfRefreshRuntime(); const form = forms.find(item => item.id === payload.new?.form_id); const response = (form?.responses || []).find(item => item.id === payload.new?.id) || payload.new; cfAnnounceResponse(response, form); } catch (error) { console.warn('[NEXT OS] Could not refresh after a client response:', error); } }) .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'discovery_submissions' }, async payload => { try { await cfRefreshRuntime(); const submission = CF_RUNTIME.discovery.submissions.find(item => item.id === payload.new?.id) || payload.new; cfAnnounceDiscoverySubmission(submission); } catch (error) { console.warn('[NEXT OS] Could not refresh after a discovery submission:', error); } }) .subscribe(status => { CF_RUNTIME.status = status; cfDispatchChange({ type: 'connection', status }); }); } catch (error) { CF_RUNTIME.starting = false; throw error; } CF_RUNTIME.starting = false; } // Keep response notifications active while the app is open, even off the // Communications route. Client submissions remain public; admin reads do not. getSb().then(async sb => { const { data, error } = await sb.auth.getSession(); if (error) throw error; if (cfIsAdmin(data.session)) cfStartRealtime(sb); if (!CF_RUNTIME.authSubscription) { const { data: authState } = sb.auth.onAuthStateChange((_event, session) => { setTimeout(() => { if (cfIsAdmin(session)) cfStartRealtime(sb).catch(error => console.warn('[NEXT OS] Communications realtime unavailable:', error)); else cfStopRealtime(sb); }, 0); }); CF_RUNTIME.authSubscription = authState.subscription; } }).catch(error => console.warn('[NEXT OS] Communications background listener unavailable:', error)); /* ─── field type configs ─── */ const FIELD_TYPES = [ { type: 'text', label: 'Short Text', icon: '✏️' }, { type: 'textarea', label: 'Long Text', icon: '📝' }, { type: 'email', label: 'Email', icon: '📧' }, { type: 'phone', label: 'Phone', icon: '📱' }, { type: 'number', label: 'Number', icon: '🔢' }, { type: 'select', label: 'Dropdown', icon: '▾' }, { type: 'radio', label: 'Choice', icon: '⊙' }, { type: 'date', label: 'Date', icon: '📅' }, ]; /* ─── colours ─── */ const C = { mint: 'var(--mint)', mintGlow: 'var(--mint-glow)', elevated: 'var(--bg-elevated)', surface: 'var(--bg-surface)', border: 'var(--border-subtle)', borderDef: 'var(--border-default)', textPrim: 'var(--text-primary)', textSec: 'var(--text-secondary)', textTer: 'var(--text-tertiary)', danger: 'var(--danger)', gold: 'var(--gold)', info: 'var(--info)', }; /* ─── status badge ─── */ const StatusBadge = ({ status }) => { const map = { draft: { color: C.textTer, bg: 'rgba(255,255,255,0.06)', label: 'Draft' }, active: { color: C.mint, bg: 'rgba(0,252,143,0.12)', label: 'Active' }, closed: { color: C.gold, bg: 'rgba(255,180,0,0.1)', label: 'Closed' }, }; const s = map[status] || map.draft; return ( {s.label} ); }; /* ─── field editor row ─── */ const FieldEditor = ({ field, index, total, onChange, onRemove, onMove }) => { const [open, setOpen] = React.useState(false); const s = { wrap: { background: C.surface, border: `1px solid ${C.border}`, borderRadius: 10, marginBottom: 8, overflow: 'hidden', }, header: { display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', cursor: 'pointer', }, drag: { color: C.textTer, fontSize: 16, cursor: 'grab', userSelect: 'none', }, label: { flex: 1, fontSize: 13, fontWeight: 500, color: C.textPrim, }, typeTag: { fontSize: 10, fontFamily: 'var(--font-mono)', color: C.textTer, padding: '2px 6px', borderRadius: 4, background: 'rgba(255,255,255,0.06)', }, body: { padding: '0 14px 14px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, }, inp: { width: '100%', background: C.elevated, border: `1px solid ${C.border}`, borderRadius: 7, color: C.textPrim, fontFamily: 'var(--font-body)', fontSize: 13, padding: '7px 10px', outline: 'none', }, fullRow: { gridColumn: '1 / -1' }, lbl: { fontSize: 11, color: C.textTer, marginBottom: 4, display: 'block' }, iconBtn: { background: 'none', border: 'none', cursor: 'pointer', color: C.textTer, fontSize: 14, padding: '4px 6px', borderRadius: 5, }, }; return (
setOpen(o => !o)}> ⠿ {field.label || Untitled field} {field.type} {field.required && required} {open ? '▾' : '▸'}
{open && (
Field label * onChange(index, 'label', e.target.value)} placeholder="e.g. Company Name" />
Field type
Placeholder text onChange(index, 'placeholder', e.target.value)} placeholder="Hint shown to client" />
onChange(index, 'required', e.target.checked)} style={{ accentColor: C.mint }} />
{(field.type === 'select' || field.type === 'radio') && (
Options (one per line)