/* 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
Digital Operating System
${logoMarkup}
${cfPrintEscape(title || 'Client response')} ${cfPrintEscape(company || 'Client submission')}
${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 }
{ e.stopPropagation(); onMove(index, -1); }} disabled={index === 0} title="Move up">▲
{ e.stopPropagation(); onMove(index, 1); }} disabled={index === total - 1} title="Move down">▼
{ e.stopPropagation(); onRemove(index); }} title="Remove">✕
{open ? '▾' : '▸'}
{open && (
)}
);
};
/* ─── Form Builder modal ─── */
const FormBuilder = ({ form, onSave, onCancel }) => {
const [draft, setDraft] = React.useState(() => form || {
id: 'draft-' + cfUid(), title: '', description: '', fields: [], status: 'draft',
created: new Date().toISOString(), responses: [],
});
const updateDraft = (k, v) => setDraft(d => ({ ...d, [k]: v }));
const addField = (type) => {
const f = { id: cfUid(), type, label: '', placeholder: '', required: false };
setDraft(d => ({ ...d, fields: [...d.fields, f] }));
};
const updateField = (idx, k, v) => {
setDraft(d => {
const fields = [...d.fields];
fields[idx] = { ...fields[idx], [k]: v };
return { ...d, fields };
});
};
const removeField = (idx) => setDraft(d => ({ ...d, fields: d.fields.filter((_, i) => i !== idx) }));
const moveField = (idx, dir) => {
setDraft(d => {
const fields = [...d.fields];
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= fields.length) return d;
[fields[idx], fields[newIdx]] = [fields[newIdx], fields[idx]];
return { ...d, fields };
});
};
const s = {
overlay: {
position: 'fixed', inset: 0, zIndex: 1000,
background: 'rgba(6,0,18,0.85)', backdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
padding: '40px 16px', overflowY: 'auto',
},
modal: {
background: C.elevated, border: `1px solid ${C.borderDef}`,
borderRadius: 16, width: '100%', maxWidth: 680,
boxShadow: '0 24px 80px rgba(0,0,0,0.6)',
},
header: {
padding: '24px 28px 16px',
borderBottom: `1px solid ${C.border}`,
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
},
title: { fontSize: 18, fontWeight: 700, fontFamily: 'var(--font-display)' },
body: { padding: 28 },
inp: {
width: '100%', background: C.surface,
border: `1px solid ${C.border}`, borderRadius: 8,
color: C.textPrim, fontFamily: 'var(--font-body)',
fontSize: 14, padding: '9px 12px', outline: 'none',
marginBottom: 14,
},
lbl: { fontSize: 12, color: C.textTer, display: 'block', marginBottom: 5, fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.06em' },
section: { fontSize: 13, fontWeight: 600, color: C.mint, letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 12, marginTop: 24 },
fieldTypes: { display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 20 },
typeBtn: {
background: C.surface, border: `1px solid ${C.border}`,
borderRadius: 8, padding: '6px 12px', cursor: 'pointer',
fontSize: 12, color: C.textSec,
display: 'flex', alignItems: 'center', gap: 5,
transition: 'all 0.15s',
},
footer: {
padding: '16px 28px', borderTop: `1px solid ${C.border}`,
display: 'flex', gap: 10, justifyContent: 'flex-end',
},
btnPrimary: {
background: C.mint, color: '#060012', fontWeight: 700,
border: 'none', borderRadius: 8, padding: '10px 24px',
cursor: 'pointer', fontSize: 14,
},
btnSecondary: {
background: 'transparent', color: C.textSec,
border: `1px solid ${C.border}`, borderRadius: 8,
padding: '10px 24px', cursor: 'pointer', fontSize: 14,
},
};
return (
e.target === e.currentTarget && onCancel()}>
{form ? 'Edit Form' : 'New Client Form'}
✕
Cancel
{
if (!draft.title.trim()) { alert('Please give the form a title.'); return; }
if (draft.fields.length === 0) { alert('Add at least one field.'); return; }
onSave(draft);
}}>
{form ? 'Save Changes' : 'Create Form'}
);
};
/* ─── Share link panel ─── */
const ShareModal = ({ form, onClose }) => {
const [copied, setCopied] = React.useState(false);
const baseUrl = window.location.origin + window.location.pathname.replace(/[^/]*$/, '');
const shareUrl = form.isDiscovery
? `${baseUrl}discovery/discovery.html`
: `${baseUrl}client-form.html?id=${form.id}`;
const copy = () => {
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
const s = {
overlay: {
position: 'fixed', inset: 0, zIndex: 1001,
background: 'rgba(6,0,18,0.85)', backdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
},
modal: {
background: C.elevated, border: `1px solid ${C.borderDef}`,
borderRadius: 16, maxWidth: 560, width: '100%',
boxShadow: '0 24px 80px rgba(0,0,0,0.6)', padding: 32,
},
urlBox: {
background: C.surface, border: `1px solid ${C.border}`,
borderRadius: 10, padding: '12px 16px',
fontFamily: 'var(--font-mono)', fontSize: 11,
color: C.textSec, wordBreak: 'break-all',
userSelect: 'all', marginTop: 12, marginBottom: 20,
},
copyBtn: {
width: '100%', background: copied ? 'rgba(0,252,143,0.15)' : C.mint,
color: copied ? C.mint : '#060012',
border: copied ? `1px solid ${C.mint}` : 'none',
borderRadius: 10, padding: '12px', cursor: 'pointer',
fontWeight: 700, fontSize: 14,
},
note: { fontSize: 12, color: C.textTer, textAlign: 'center', marginTop: 12 },
};
return (
e.target === e.currentTarget && onClose()}>
Share this link with your client. When they fill in the form, their response appears directly in this panel.
{shareUrl}
{copied ? '✓ Copied to clipboard!' : '⎘ Copy shareable link'}
The client can open this link without signing in. Their response is saved to the shared database and appears in Communications.
);
};
/* ─── Response viewer modal ─── */
const ResponsesModal = ({ form, onClose }) => {
const [exportError, setExportError] = React.useState('');
const s = {
overlay: {
position: 'fixed', inset: 0, zIndex: 1001,
background: 'rgba(6,0,18,0.85)', backdropFilter: 'blur(6px)',
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
padding: '40px 16px', overflowY: 'auto',
},
modal: {
background: C.elevated, border: `1px solid ${C.borderDef}`,
borderRadius: 16, maxWidth: 680, width: '100%',
boxShadow: '0 24px 80px rgba(0,0,0,0.6)',
},
header: {
padding: '24px 28px', borderBottom: `1px solid ${C.border}`,
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
},
body: { padding: 28 },
responseCard: {
background: C.surface, border: `1px solid ${C.border}`,
borderRadius: 12, padding: '18px 20px', marginBottom: 14,
},
answerRow: {
marginBottom: 14,
},
qLabel: { fontSize: 11, fontWeight: 600, color: C.textTer, textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4 },
aValue: { fontSize: 14, color: C.textPrim, lineHeight: 1.5 },
timestamp: { fontSize: 11, fontFamily: 'var(--font-mono)', color: C.textTer, marginBottom: 14 },
exportButton: { background: 'rgba(0,252,143,0.1)', border: '1px solid rgba(0,252,143,0.3)', color: C.mint, borderRadius: 7, padding: '6px 10px', cursor: 'pointer', fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' },
};
const responses = form.responses || [];
return (
e.target === e.currentTarget && onClose()}>
Responses — {form.title}
{responses.length} response{responses.length !== 1 ? 's' : ''} received
✕
{exportError &&
{exportError}
}
{responses.length === 0 ? (
📭
No responses yet. Share the form link with your client.
) : [...responses].reverse().map((resp, i) => (
Submitted {new Date(resp.submittedAt).toLocaleString()}
{
setExportError('');
try { cfExportFormResponse(form, resp); } catch (error) { setExportError(error.message || 'Could not export this response.'); }
}}>Export PDF
{form.fields.map(field => (
{field.label}
{resp.answers?.[field.id] === undefined || resp.answers?.[field.id] === null || resp.answers?.[field.id] === ''
? —
: cfPrintAnswerValue({ type: field.type, value: resp.answers[field.id] })}
))}
))}
);
};
/* ─── Sembule discovery inbox ─── */
const DiscoveryFormCard = ({ count, ready, error, onShare, onView }) => {
const s = {
card: { background: C.elevated, border: `1px solid ${C.border}`, borderRadius: 12, padding: '20px 22px', display: 'flex', alignItems: 'flex-start', gap: 16 },
icon: { width: 44, height: 44, borderRadius: 10, background: 'rgba(0,252,143,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, flexShrink: 0 },
title: { fontWeight: 600, fontSize: 15, color: C.textPrim, marginBottom: 4 },
desc: { fontSize: 12, color: C.textTer, lineHeight: 1.5, marginBottom: 10 },
meta: { display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' },
metaItem: { fontSize: 11, fontFamily: 'var(--font-mono)', color: C.textTer },
actionRow: { display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' },
btn: accent => ({ background: accent ? 'rgba(0,252,143,0.1)' : 'rgba(255,255,255,0.05)', border: `1px solid ${accent ? 'rgba(0,252,143,0.3)' : C.border}`, color: accent ? C.mint : C.textSec, borderRadius: 7, padding: '6px 12px', cursor: ready ? 'pointer' : 'not-allowed', fontSize: 12, fontWeight: 500, opacity: ready ? 1 : 0.55 }),
};
return (
🧭
Sembule Media — Project Discovery
{ready ?
:
Setup required }
Tony’s guided discovery: company, brand, website, operations, systems, and supporting files. Progress saves on the client’s device.
Multi-section · file uploads · save and resume
{count} submission{count !== 1 ? 's' : ''}
{error &&
Submission inbox setup required: {error}
}
🔗 Share discovery form
📥 View submissions
);
};
const DiscoveryInboxModal = ({ submissions, count, selected, error, onSelect, onDownload, onClose }) => {
const [downloadError, setDownloadError] = React.useState('');
const [exportError, setExportError] = React.useState('');
const [exporting, setExporting] = React.useState(false);
const s = {
overlay: { position: 'fixed', inset: 0, zIndex: 1002, background: 'rgba(6,0,18,0.85)', backdropFilter: 'blur(6px)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: '24px 16px', overflowY: 'auto' },
modal: { background: C.elevated, border: `1px solid ${C.borderDef}`, borderRadius: 16, maxWidth: 1120, width: '100%', minHeight: 360, boxShadow: '0 24px 80px rgba(0,0,0,0.6)', overflow: 'hidden' },
header: { padding: '20px 24px', borderBottom: `1px solid ${C.border}`, display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
layout: { display: 'grid', gridTemplateColumns: 'minmax(220px, 300px) 1fr', minHeight: 320 },
list: { borderRight: `1px solid ${C.border}`, maxHeight: '70vh', overflowY: 'auto', padding: 12 },
item: active => ({ width: '100%', textAlign: 'left', padding: 12, marginBottom: 8, borderRadius: 8, border: `1px solid ${active ? 'rgba(0,252,143,0.35)' : C.border}`, background: active ? 'rgba(0,252,143,0.08)' : 'transparent', color: C.textPrim, cursor: 'pointer' }),
detail: { padding: 24, maxHeight: '70vh', overflowY: 'auto' },
section: { borderTop: `1px solid ${C.border}`, paddingTop: 14, marginTop: 18 },
answer: { padding: '10px 0', borderBottom: `1px solid ${C.border}` },
label: { fontSize: 11, textTransform: 'uppercase', letterSpacing: '0.05em', color: C.textTer, marginBottom: 4 },
value: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', fontSize: 13, color: C.textPrim, lineHeight: 1.55 },
exportButton: { background: 'rgba(0,252,143,0.1)', border: '1px solid rgba(0,252,143,0.3)', color: C.mint, borderRadius: 7, padding: '7px 11px', cursor: exporting ? 'wait' : 'pointer', fontSize: 12, fontWeight: 600, opacity: exporting ? 0.65 : 1 },
};
const renderValue = (answer, submissionId) => {
const value = answer?.value;
if (answer?.type === 'file') {
const files = Array.isArray(value) ? value : [];
return files.length ? files.map((file, index) => (
{
setDownloadError('');
try { await onDownload(submissionId, file); } catch (err) { setDownloadError(err.message || 'Download failed.'); }
}}>⬇ {file.name || 'Attachment'} {Math.max(1, Math.round(Number(file.size || 0) / 1024))} KB
)) : No file attached ;
}
if (value === null || value === undefined || value === '') return Not provided ;
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (Array.isArray(value)) {
if (value.every(item => item && typeof item === 'object')) return value.map((item, index) => (
{Object.entries(item).map(([key, cell]) =>
{key}: {String(cell ?? '')}
)}
));
return value.join(' · ');
}
if (typeof value === 'object') return {JSON.stringify(value, null, 2)} ;
return String(value);
};
return (
event.target === event.currentTarget && onClose()}>
Sembule Media discovery submissions
{count} total · newest first
✕
{error ?
{error}
: (
{!submissions.length &&
No submissions yet.
}
{submissions.map(item =>
onSelect(item.id)}>
{item.client_company || 'Unnamed company'}
{item.contact_name || 'Unknown contact'}
{new Date(item.created_at).toLocaleString()} · {item.status}
)}
{!selected ?
Choose a submission to review the answers.
: <>
{selected.client_company}
{selected.contact_name} · {selected.contact_email} · {selected.contact_phone}
Submitted {new Date(selected.created_at).toLocaleString()} · {selected.status}
{
setExportError('');
setExporting(true);
try { await cfExportDiscoverySubmission(selected); } catch (err) { setExportError(err.message || 'Could not export this submission.'); }
finally { setExporting(false); }
}}>{exporting ? 'Preparing PDF…' : 'Export PDF'}
{exportError &&
{exportError}
}
{downloadError &&
{downloadError}
}
{(Array.isArray(selected.answers?.sections) ? selected.answers.sections : []).map(section =>
{section.title}
{(Array.isArray(section.answers) ? section.answers : []).map(answer =>
{answer.label}
{renderValue(answer, selected.id)}
)}
)}
>}
)}
);
};
/* ─── Form card ─── */
const FormCard = ({ form, onEdit, onShare, onViewResponses, onDelete, onToggleStatus }) => {
const respCount = (form.responses || []).length;
const hasNew = (form.responses || []).some(r => !r.seen);
const s = {
card: {
background: C.elevated,
border: `1px solid ${C.border}`,
borderRadius: 12, padding: '20px 22px',
display: 'flex', alignItems: 'flex-start', gap: 16,
transition: 'border-color 0.2s',
cursor: 'default',
},
icon: {
width: 44, height: 44, borderRadius: 10,
background: 'rgba(0,252,143,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, flexShrink: 0,
},
title: { fontWeight: 600, fontSize: 15, color: C.textPrim, marginBottom: 4 },
desc: { fontSize: 12, color: C.textTer, lineHeight: 1.5, marginBottom: 10 },
meta: { display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap' },
metaItem: { fontSize: 11, fontFamily: 'var(--font-mono)', color: C.textTer },
actionRow: {
display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap',
},
btn: (accent) => ({
background: accent ? 'rgba(0,252,143,0.1)' : 'rgba(255,255,255,0.05)',
border: `1px solid ${accent ? 'rgba(0,252,143,0.3)' : C.border}`,
color: accent ? C.mint : C.textSec,
borderRadius: 7, padding: '6px 12px', cursor: 'pointer',
fontSize: 12, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 5,
}),
responsesPill: {
display: 'flex', alignItems: 'center', gap: 6,
background: hasNew ? 'rgba(0,252,143,0.12)' : 'rgba(255,255,255,0.05)',
border: `1px solid ${hasNew ? 'rgba(0,252,143,0.3)' : C.border}`,
borderRadius: 7, padding: '6px 12px', cursor: 'pointer',
fontSize: 12, fontWeight: 500,
color: hasNew ? C.mint : C.textSec,
},
};
return (
📋
{form.description &&
{form.description}
}
{form.fields.length} field{form.fields.length !== 1 ? 's' : ''}
Created {new Date(form.created).toLocaleDateString()}
onViewResponses(form)}>
{hasNew && }
📥 {respCount} response{respCount !== 1 ? 's' : ''}
{form.status !== 'closed' && (
onShare(form)}>🔗 Share Link
)}
onEdit(form)}>✏️ Edit
onToggleStatus(form)}>
{form.status === 'active' ? '🔒 Close' : '▶ Activate'}
onDelete(form.id)}>🗑
);
};
/* ─── Activity feed entry ─── */
const ActivityEntry = ({ item }) => (
{item.icon}
{item.text}
{new Date(item.ts).toLocaleString()}
);
/* Public client-facing form links; submissions remain private to authorized admins. */
const PublicClientFormsPanel = () => {
const [forms, setForms] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState('');
const [sharing, setSharing] = React.useState(null);
React.useEffect(() => {
let active = true;
getSb().then(async sb => {
const { data, error } = await sb.from('os_forms')
.select('id,title,description,fields,status,created_at')
.eq('status', 'active')
.order('created_at', { ascending: false });
if (!active) return;
if (error) setLoadError(error.message || 'Could not load active client forms.');
else setForms(data || []);
setLoading(false);
}).catch(error => {
if (!active) return;
setLoadError(error.message || 'Could not connect to the forms service.');
setLoading(false);
});
return () => { active = false; };
}, []);
const page = { fontFamily: 'var(--font-body)' };
const pageHeader = { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20, flexWrap: 'wrap', gap: 12 };
const heading = { fontFamily: 'var(--font-display)', fontSize: 25, fontWeight: 800, color: C.textPrim };
const subheading = { fontSize: 13, color: C.textSec, marginTop: 4 };
const card = { background: C.elevated, border: `1px solid ${C.border}`, borderRadius: 12, padding: '20px 22px' };
const secondaryButton = { background: 'transparent', border: `1px solid ${C.border}`, color: C.textSec, borderRadius: 8, padding: '8px 14px', cursor: 'pointer', fontSize: 12 };
const button = { background: 'rgba(0,252,143,0.1)', border: '1px solid rgba(0,252,143,0.3)', color: C.mint, borderRadius: 7, padding: '8px 13px', cursor: 'pointer', fontSize: 12, fontWeight: 600 };
return (
Forms for clients
Open a form and share its link. Clients can fill it out without signing in.
View submitted client responses in Communications.
🧭
Sembule Media — Project Discovery
Tony’s guided discovery form for company, brand, website, operations, systems, and supporting files.
setSharing({ id: 'sembule-discovery', title: 'Sembule Media — Project Discovery', isDiscovery: true })}>🔗 Share discovery form
{loading &&
Loading active client forms…
}
{loadError &&
Could not load other client forms: {loadError}
}
{!loading && !loadError && forms.length === 0 &&
No other active forms yet. The discovery form above is ready to share.
}
{forms.map(form => (
{form.title}
{form.description &&
{form.description}
}
setSharing(form)}>🔗 Share form
))}
Client responses and uploaded files are available to authorized NEXT OS accounts.
{sharing &&
setSharing(null)} />}
);
};
/* ─── Main Forms Page ─── */
const CommsPage = ({ onNavigate, embedded = false }) => {
const [forms, setForms] = React.useState([]);
const [discoverySubmissions, setDiscoverySubmissions] = React.useState(CF_RUNTIME.discovery.submissions || []);
const [discoveryCount, setDiscoveryCount] = React.useState(CF_RUNTIME.discovery.count || 0);
const [discoveryError, setDiscoveryError] = React.useState(CF_RUNTIME.discovery.error || '');
const [discoveryLoading, setDiscoveryLoading] = React.useState(true);
const [loading, setLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState('');
const [session, setSession] = React.useState(null);
const [authLoading, setAuthLoading] = React.useState(true);
const [liveStatus, setLiveStatus] = React.useState(CF_RUNTIME.status);
const [building, setBuilding] = React.useState(null); // null | 'new' | form object (edit)
const [sharing, setSharing] = React.useState(null); // form to share
const [viewing, setViewing] = React.useState(null); // form whose responses to view
const [discoveryInboxOpen, setDiscoveryInboxOpen] = React.useState(false);
const [discoverySelected, setDiscoverySelected] = React.useState(null);
const [activity, setActivity] = React.useState([]);
const [search, setSearch] = React.useState('');
const [filter, setFilter] = React.useState('all');
const loadData = async () => {
try {
const data = await cfLoadForms();
setForms(data);
setLoadError('');
} catch (error) {
setLoadError(error.message || 'Could not load client forms and responses.');
} finally {
setLoading(false);
}
try {
const inbox = await cfLoadDiscoveryInbox();
setDiscoverySubmissions(inbox.submissions);
setDiscoveryCount(inbox.count);
setDiscoveryError('');
CF_RUNTIME.discovery = inbox;
} catch (error) {
const message = error.message || 'Could not load the discovery inbox.';
setDiscoverySubmissions([]);
setDiscoveryCount(0);
setDiscoveryError(message);
CF_RUNTIME.discovery = { submissions: [], count: 0, error: message };
} finally {
setDiscoveryLoading(false);
}
};
React.useEffect(() => {
let active = true;
let authSubscription;
(async () => {
const sb = await getSb();
const { data, error } = await sb.auth.getSession();
if (error) throw error;
if (!active) return;
setSession(data.session);
setAuthLoading(false);
if (cfIsAdmin(data.session)) cfStartRealtime(sb).catch(error => setLoadError(error.message));
const { data: authState } = sb.auth.onAuthStateChange((_event, nextSession) => {
if (!active) return;
setSession(nextSession);
setTimeout(() => {
if (cfIsAdmin(nextSession)) cfStartRealtime(sb).catch(error => setLoadError(error.message));
else cfStopRealtime(sb);
}, 0);
});
authSubscription = authState.subscription;
})().catch(error => {
if (!active) return;
setLoadError(error.message || 'Could not connect to Supabase authentication.');
setAuthLoading(false);
setLoading(false);
});
const onFormsChanged = event => {
const detail = event.detail || {};
if (detail.type === 'connection') setLiveStatus(detail.status);
if (detail.type === 'response') {
const who = detail.clientName ? `${detail.clientName} submitted` : 'A client submitted';
setActivity(items => [{
id: detail.responseId,
icon: '📥', ts: new Date().toISOString(),
text: `${who} “${detail.formTitle || 'a form'}”.`,
}, ...items.filter(item => item.id !== detail.responseId)].slice(0, 20));
}
if (detail.type === 'discovery-response') {
const who = [detail.clientName, detail.clientCompany].filter(Boolean).join(' from ');
setActivity(items => [{
id: detail.submissionId,
icon: '🧭', ts: new Date().toISOString(),
text: `${who || 'A client'} submitted the Sembule Media discovery form.`,
}, ...items.filter(item => item.id !== detail.submissionId)].slice(0, 20));
}
if (detail.type === 'refresh') {
if (Array.isArray(detail.forms)) setForms(detail.forms);
if (detail.discovery) {
setDiscoverySubmissions(detail.discovery.submissions || []);
setDiscoveryCount(detail.discovery.count || 0);
setDiscoveryError(detail.discovery.error || '');
}
setLoadError('');
}
if (detail.type === 'response') loadData();
};
window.addEventListener('nextos:forms-changed', onFormsChanged);
return () => {
active = false;
window.removeEventListener('nextos:forms-changed', onFormsChanged);
if (authSubscription) authSubscription.unsubscribe();
};
}, []);
React.useEffect(() => {
if (session) loadData();
else setLoading(false);
}, [session]);
const handleSave = async (draft) => {
try {
await cfSaveForm(draft);
if (draft.id.startsWith('draft-')) {
setActivity(a => [{ icon: '🆕', ts: new Date().toISOString(), text: `Form created: "${draft.title}"` }, ...a].slice(0, 20));
}
await loadData();
setBuilding(null);
} catch (error) {
setLoadError(error.message || 'Could not save the form.');
}
};
const handleDelete = async (id) => {
if (!confirm('Delete this form and all its responses?')) return;
try { await cfDeleteForm(id); await loadData(); }
catch (error) { setLoadError(error.message || 'Could not delete the form.'); }
};
const handleToggleStatus = async (form) => {
const next = form.status === 'active' ? 'closed' : 'active';
try {
await cfSaveForm({ ...form, status: next });
setActivity(a => [{ icon: next === 'active' ? '▶' : '🔒', ts: new Date().toISOString(), text: `"${form.title}" ${next === 'active' ? 'activated' : 'closed'}` }, ...a].slice(0, 20));
await loadData();
} catch (error) { setLoadError(error.message || 'Could not update the form status.'); }
};
const handleViewResponses = async (form) => {
// Mark all responses as seen in the DB
const unread = (form.responses || []).filter(r => !r.seen);
if (unread.length > 0) {
try {
await Promise.all(unread.map(r => cfMarkSeen(r.id)));
await loadData();
} catch (error) { setLoadError(error.message || 'Could not update response status.'); }
}
setViewing(form);
};
const handleViewDiscovery = async () => {
setDiscoveryInboxOpen(true);
setDiscoverySelected(null);
const first = discoverySubmissions[0];
if (!first) return;
try { setDiscoverySelected(await cfLoadDiscoverySubmission(first.id)); }
catch (error) { setDiscoveryError(error.message || 'Could not open the discovery submission.'); }
};
const handleSelectDiscovery = async (id) => {
try { setDiscoverySelected(await cfLoadDiscoverySubmission(id)); }
catch (error) { setDiscoveryError(error.message || 'Could not open the discovery submission.'); }
};
/* When sharing, first set form to active if it's a draft */
const handleShare = async (form) => {
if (form.status === 'draft') {
try {
await cfSaveForm({ ...form, status: 'active' });
const updated = { ...form, status: 'active' };
await loadData();
setSharing(updated);
} catch (error) { setLoadError(error.message || 'Could not activate the form.'); }
} else {
setSharing(form);
}
};
const filtered = forms
.filter(f => filter === 'all' || f.status === filter)
.filter(f => !search || f.title.toLowerCase().includes(search.toLowerCase()));
const discoveryMatches = (filter === 'all' || filter === 'active') &&
(!search || 'Sembule Media Project Discovery'.toLowerCase().includes(search.toLowerCase()));
const totalResponses = forms.reduce((s, f) => s + (f.responses || []).length, 0) + discoveryCount;
const activeForms = forms.filter(f => f.status === 'active').length + (discoveryError ? 0 : 1);
const newResponses = forms.reduce((s, f) => s + (f.responses || []).filter(r => !r.seen).length, 0);
const s = {
page: { fontFamily: 'var(--font-body)' },
pageHeader: {
display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start',
marginBottom: 28, flexWrap: 'wrap', gap: 12,
},
heading: { fontFamily: 'var(--font-display)', fontSize: 28, fontWeight: 800, color: C.textPrim },
subheading: { fontSize: 14, color: C.textSec, marginTop: 4 },
newBtn: {
background: C.mint, color: '#060012', fontWeight: 700,
border: 'none', borderRadius: 10, padding: '11px 22px',
cursor: 'pointer', fontSize: 14, display: 'flex', alignItems: 'center', gap: 7,
},
kpiRow: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 14, marginBottom: 28 },
kpiCard: {
background: C.elevated, border: `1px solid ${C.border}`,
borderRadius: 10, padding: '16px 20px',
},
kpiVal: { fontFamily: 'var(--font-display)', fontSize: 28, fontWeight: 700 },
kpiLbl: { fontSize: 11, color: C.textTer, textTransform: 'uppercase', letterSpacing: '0.08em', marginTop: 4 },
toolbar: { display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap' },
searchBox: {
flex: 1, minWidth: 200,
background: C.elevated, border: `1px solid ${C.border}`,
borderRadius: 8, padding: '9px 14px', color: C.textPrim,
fontFamily: 'var(--font-body)', fontSize: 13, outline: 'none',
},
filterBtn: (active) => ({
background: active ? 'rgba(0,252,143,0.1)' : 'transparent',
border: `1px solid ${active ? 'rgba(0,252,143,0.3)' : C.border}`,
color: active ? C.mint : C.textSec,
borderRadius: 8, padding: '8px 14px', cursor: 'pointer', fontSize: 12,
}),
grid: { display: 'flex', flexDirection: 'column', gap: 14 },
empty: {
textAlign: 'center', padding: '60px 20px',
color: C.textTer, fontSize: 14,
},
layout: { display: 'grid', gridTemplateColumns: '1fr 300px', gap: 24 },
sidebar: {},
error: { padding: '12px 14px', marginBottom: 16, color: '#ff9b9b', background: 'rgba(255,71,87,0.1)', border: '1px solid rgba(255,71,87,0.25)', borderRadius: 9, fontSize: 13 },
loginCard: { width: '100%', maxWidth: 440, margin: '8vh auto', padding: 28, background: C.elevated, border: `1px solid ${C.border}`, borderRadius: 14 },
loginInput: { width: '100%', padding: '12px 14px', marginTop: 7, marginBottom: 16, color: C.textPrim, background: C.surface, border: `1px solid ${C.border}`, borderRadius: 8, fontSize: 14 },
sidebarCard: {
background: C.elevated, border: `1px solid ${C.border}`,
borderRadius: 12, padding: '20px',
marginBottom: 16,
},
sidebarTitle: { fontSize: 12, fontWeight: 600, color: C.mint, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 14 },
};
if (authLoading) return Connecting to secure Communications…
;
if (!session) return (
NEXT OS session required
Communications uses your existing NEXT OS sign-in. Reload NEXT OS to restore your session.
);
if (embedded && !cfIsAdmin(session)) return ;
if (!cfIsAdmin(session)) return (
Admin access required
This signed-in account is not authorized to review client responses.
);
return (
{embedded ? 'Forms for clients' : 'Communications'}
{embedded ? 'Share forms and review client submissions. New responses trigger an in-app notification.' : 'Client Forms: submissions appear here with an in-app notification.'}
setBuilding('new')}>+ New Form
{loadError &&
{loadError} Retry
}
{/* KPI row */}
{forms.length + 1}
Total Forms
0 ? 'rgba(0,252,143,0.2)' : C.border }}>
{activeForms}
Active
0 ? 'rgba(0,252,143,0.25)' : C.border }}>
0 ? C.mint : C.textPrim }}>
{totalResponses}
{newResponses > 0 && +{newResponses} new }
Responses
{/* Main forms list */}
{/* Toolbar */}
setSearch(e.target.value)}
/>
{['all', 'active', 'draft', 'closed'].map(f => (
setFilter(f)}>
{f.charAt(0).toUpperCase() + f.slice(1)}
))}
{/* Forms */}
{loading ? (
Loading forms and responses…
) : filtered.length === 0 && !discoveryMatches ? (
📋
{forms.length === 0 && !discoveryMatches
? <>No forms yet. Click
+ New Form to create your first client form.>
: 'No forms match your filter.'}
) : <>
{discoveryMatches &&
setSharing({ id: 'sembule-discovery', title: 'Sembule Media — Project Discovery', isDiscovery: true })}
onView={handleViewDiscovery}
/>}
{filtered.map(form => (
setBuilding(f)}
onShare={handleShare}
onViewResponses={handleViewResponses}
onDelete={handleDelete}
onToggleStatus={handleToggleStatus}
/>
))}
>}
{/* Right sidebar: activity */}
📡 Live Activity
{liveStatus === 'SUBSCRIBED' ? 'Live updates connected' : 'Live connection unavailable; checking every 15 seconds'}
{activity.length === 0 ? (
No activity yet. Responses appear here in real-time.
) : activity.map((item, i) =>
)}
💡 How it works
1. Create a form with custom fields
2. Share the link with your client
3. Client fills it out in their browser
4. Response lands right here
{/* Modals */}
{building && (
setBuilding(null)}
/>
)}
{sharing && setSharing(null)} />}
{viewing && setViewing(null)} />}
{discoveryInboxOpen && setDiscoveryInboxOpen(false)}
/>}
);
};
window.CommsPage = CommsPage;