/* ============================================================
page-collections.jsx — Collections: bot-driven surveys, polls,
profile-collection prompts, and acknowledgements (#8)
============================================================ */
/* Re-bind parseDetail — set by primitives.jsx */
const parseDetail = window.parseDetail || ((body, status) => (body && (body.detail || body.message)) || `HTTP ${status}`);
const COLL_KIND_LABELS = {
text: 'Text',
number: 'Number',
rating: 'Rating',
choice: 'Choice (single)',
multi: 'Multi-select',
ack: 'Acknowledgement',
day_off: 'Day off',
whatsapp: 'WhatsApp number',
phone: 'Phone number',
email: 'Email address',
};
const COLL_AUDIENCE_LABELS = { all: 'Everyone', team: 'Team', role: 'Role', users: 'Specific users' };
const COLL_SCHEDULE_LABELS = { once: 'Once', daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly' };
const COLL_STATUS_LABELS = { draft: 'Draft', active: 'Active', paused: 'Paused', done: 'Done' };
const COLL_DOW_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
function collStatusBadgeKind(s) {
if (s === 'active') return 'ok';
if (s === 'paused') return 'warn';
if (s === 'done') return 'mute';
return 'info'; // draft
}
/* ── Prompt create/edit modal ── */
function CollPromptForm({ prompt, kinds, profileFields, onClose, onSaved }) {
const toast = useToast ? useToast() : { push: () => {} };
const isEdit = !!(prompt && prompt.id);
const [title, setTitle] = useState(prompt?.title || '');
const [question, setQuestion] = useState(prompt?.question || '');
const [kind, setKind] = useState(prompt?.kind || (kinds[0] || 'text'));
const [options, setOptions] = useState(Array.isArray(prompt?.options) ? prompt.options : []);
const [optDraft, setOptDraft] = useState('');
const [profileField, setProfileField] = useState(prompt?.profile_field || '');
const [active, setActive] = useState(prompt ? prompt.active !== false : true);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const needsOptions = kind === 'choice' || kind === 'multi';
const addOption = () => {
const v = optDraft.trim();
if (!v || options.includes(v)) { setOptDraft(''); return; }
setOptions([...options, v]);
setOptDraft('');
};
const removeOption = (i) => setOptions(options.filter((_, idx) => idx !== i));
const submit = async () => {
if (!title.trim()) { setErr((window.appT||(s=>s))('Title required')); return; }
if (!question.trim()) { setErr((window.appT||(s=>s))('Question required')); return; }
if (needsOptions && options.length < 2) { setErr((window.appT||(s=>s))('Add at least 2 options')); return; }
setErr(''); setBusy(true);
try {
const body = {
title: title.trim(),
question: question.trim(),
kind,
options: needsOptions ? options : null,
profile_field: profileField || null,
active,
};
const url = isEdit ? `/api/collections/prompts/${prompt.id}` : '/api/collections/prompts';
const r = await fetch(url, {
method: isEdit ? 'PUT' : 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const b = await r.json().catch(() => null);
throw new Error(parseDetail(b, r.status));
}
await r.json();
toast.push({ msg: (window.appT||(s=>s))(isEdit ? 'Prompt updated' : 'Prompt created'), kind: 'ok' });
onSaved();
onClose();
} catch (e) {
setErr(e.message);
} finally {
setBusy(false);
}
};
return (
e.stopPropagation()}>
{(window.appT||(s=>s))(isEdit ? 'Edit prompt' : 'New prompt')}
{(window.appT||(s=>s))('Title')}
setTitle(e.target.value)}
placeholder={(window.appT||(s=>s))('e.g. Monthly satisfaction check')} />
{(window.appT||(s=>s))('Question')}
{(window.appT||(s=>s))('Kind')}
setKind(e.target.value)}>
{kinds.map(k => {(window.appT||(s=>s))(COLL_KIND_LABELS[k] || k)} )}
{(window.appT||(s=>s))('Write answer to profile field')}
setProfileField(e.target.value)}>
{(window.appT||(s=>s))('(none)')}
{profileFields.map(f => {f} )}
{needsOptions && (
{(window.appT||(s=>s))('Options')}
{options.map((o, i) => (
{o}
removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-3)', padding: 0, fontSize: 13, lineHeight: 1 }}>×
))}
{options.length === 0 && (
{(window.appT||(s=>s))('No options yet')}
)}
setOptDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addOption(); } }}
placeholder={(window.appT||(s=>s))('Add option...')} style={{ flex: 1 }} />
{(window.appT||(s=>s))('+ Add')}
)}
setActive(e.target.checked)} />
{(window.appT||(s=>s))('Active')}
{err &&
{err}
}
{(window.appT||(s=>s))('Cancel')}
{busy ? (window.appT||(s=>s))('Saving…') : (window.appT||(s=>s))('Save')}
);
}
/* ── Campaign create/edit modal ── */
function CollCampaignForm({ campaign, prompts, onClose, onSaved }) {
const toast = useToast ? useToast() : { push: () => {} };
const isEdit = !!(campaign && campaign.id);
const [promptId, setPromptId] = useState(campaign?.prompt_id ?? (prompts[0]?.id ?? ''));
const [title, setTitle] = useState(campaign?.title || '');
const [audienceKind, setAudienceKind] = useState(campaign?.audience_kind || 'all');
const [audienceValue, setAudienceValue] = useState(campaign?.audience_value || '');
const [scheduleKind, setScheduleKind] = useState(campaign?.schedule_kind || 'once');
const [dayOfWeek, setDayOfWeek] = useState(campaign?.day_of_week ?? 0);
const [dayOfMonth, setDayOfMonth] = useState(campaign?.day_of_month ?? 1);
const [hourUtc, setHourUtc] = useState(campaign?.hour_utc ?? 9);
const [status, setStatus] = useState(campaign?.status || 'draft');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
// Audience pickers: real teams + employees (so no free-typing team names or
// raw user IDs). userSel holds selected employee ids for audience_kind=users.
const [teams, setTeams] = useState([]);
const [emps, setEmps] = useState([]);
const [userQuery, setUserQuery] = useState('');
const [userSel, setUserSel] = useState(() => (
(campaign?.audience_kind === 'users' && campaign?.audience_value)
? campaign.audience_value.split(',').map(s => Number(s.trim())).filter(Boolean)
: []
));
React.useEffect(() => {
fetch('/api/teams', { credentials: 'include' }).then(r => r.ok ? r.json() : [])
.then(d => {
const list = Array.isArray(d) ? d : (d.teams || []);
setTeams(list.map(t => (typeof t === 'string' ? t : (t.name || t.team_name))).filter(Boolean));
}).catch(() => {});
fetch('/api/employees', { credentials: 'include' }).then(r => r.ok ? r.json() : [])
.then(d => {
const list = Array.isArray(d) ? d : (d.employees || d.users || []);
setEmps(list.filter(e => e && e.id));
}).catch(() => {});
}, []);
const toggleUser = (id) => setUserSel(sel => sel.includes(id) ? sel.filter(x => x !== id) : [...sel, id]);
const submit = async () => {
if (!promptId) { setErr((window.appT||(s=>s))('Pick a prompt')); return; }
const audValue = audienceKind === 'all' ? null
: audienceKind === 'users' ? userSel.join(',')
: audienceValue.trim();
if (audienceKind !== 'all' && !audValue) { setErr((window.appT||(s=>s))('Pick at least one target for this audience')); return; }
setErr(''); setBusy(true);
try {
const body = {
prompt_id: Number(promptId),
title: title.trim() || null,
audience_kind: audienceKind,
audience_value: audValue,
schedule_kind: scheduleKind,
day_of_week: scheduleKind === 'weekly' ? Number(dayOfWeek) : null,
day_of_month: scheduleKind === 'monthly' ? Number(dayOfMonth) : null,
hour_utc: Number(hourUtc),
status,
};
const url = isEdit ? `/api/collections/campaigns/${campaign.id}` : '/api/collections/campaigns';
const r = await fetch(url, {
method: isEdit ? 'PUT' : 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const b = await r.json().catch(() => null);
throw new Error(parseDetail(b, r.status));
}
await r.json();
toast.push({ msg: (window.appT||(s=>s))(isEdit ? 'Campaign updated' : 'Campaign created'), kind: 'ok' });
onSaved();
onClose();
} catch (e) {
setErr(e.message);
} finally {
setBusy(false);
}
};
return (
e.stopPropagation()}>
{(window.appT||(s=>s))(isEdit ? 'Edit campaign' : 'New campaign')}
{(window.appT||(s=>s))('Cancel')}
{busy ? (window.appT||(s=>s))('Saving…') : (window.appT||(s=>s))('Save')}
);
}
/* ── Responses panel modal ── */
function CollResponsesModal({ campaign, onClose }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true); setError(null);
fetch(`/api/collections/campaigns/${campaign.id}/responses`, { credentials: 'include' })
.then(async r => {
if (!r.ok) {
const b = await r.json().catch(() => null);
throw new Error(parseDetail(b, r.status));
}
return r.json();
})
.then(d => { if (!cancelled) setData(d); })
.catch(e => { if (!cancelled) setError(e.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [campaign.id]);
const tallyEntries = data && data.tally ? Object.entries(data.tally).sort((a, b) => b[1] - a[1]) : [];
const maxTally = Math.max(...tallyEntries.map(([, c]) => c), 1);
return (
e.stopPropagation()} style={{ maxWidth: 640 }}>
{(window.appT||(s=>s))('Responses')} — {campaign.title}
{loading &&
s))('Loading responses…')} />}
{!loading && error && {error}
}
{!loading && !error && data && (
<>
{(window.appT||(s=>s))('Total:')} {data.total}
{(window.appT||(s=>s))('Answered:')} {data.answered}
{(window.appT||(s=>s))('Pending:')} {data.pending}
{data.declined > 0 && (
{(window.appT||(s=>s))('Declined:')} {data.declined}
)}
{data.declined > 0 && (data.declined_users || []).length > 0 && (
{(window.appT||(s=>s))('Declined by:')} {' '}
{(data.declined_users || []).map(u => u || '—').join(', ')}
)}
{tallyEntries.length > 0 && (
{(window.appT||(s=>s))('Tally')}
{tallyEntries.map(([val, count]) => (
))}
)}
{(window.appT||(s=>s))('Responses')}
{data.responses.length === 0 ? (
s))('No responses yet')} />
) : (
{(window.appT||(s=>s))('User')}
{(window.appT||(s=>s))('Status')}
{(window.appT||(s=>s))('Answer')}
{(window.appT||(s=>s))('Profile')}
{(window.appT||(s=>s))('Answered')}
{data.responses.map((r, i) => (
{r.user || '—'}
{(window.appT||(s=>s))(r.declined ? 'declined' : r.status)}
{r.answer != null ? String(r.answer) : (r.raw || '—')}
{r.applied_to_profile ? {(window.appT||(s=>s))('applied')} : '—'}
{r.answered_at || '—'}
))}
)}
>
)}
{(window.appT||(s=>s))('Close')}
);
}
/* ── Main page ── */
function CollectionsPage() {
const toast = useToast ? useToast() : { push: () => {} };
const [tab, setTab] = useState('prompts'); // 'prompts' | 'campaigns'
const [prompts, setPrompts] = useState([]);
const [kinds, setKinds] = useState([]);
const [profileFields, setProfileFields] = useState([]);
const [campaigns, setCampaigns] = useState([]);
const [loadingPrompts, setLoadingPrompts] = useState(true);
const [loadingCampaigns, setLoadingCampaigns] = useState(true);
const [editPrompt, setEditPrompt] = useState(null);
const [editCampaign, setEditCampaign] = useState(null);
const [responsesFor, setResponsesFor] = useState(null);
const loadPrompts = () => {
setLoadingPrompts(true);
fetch('/api/collections/prompts', { credentials: 'include' })
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
.then(d => {
setPrompts(d.prompts || []);
setKinds(d.kinds || []);
setProfileFields(d.profile_fields || []);
})
.catch(e => toast.push({ msg: `${(window.appT||(s=>s))('Prompts fetch failed:')} ${e.message}`, kind: 'err' }))
.finally(() => setLoadingPrompts(false));
};
const loadCampaigns = () => {
setLoadingCampaigns(true);
fetch('/api/collections/campaigns', { credentials: 'include' })
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
.then(d => setCampaigns(d.campaigns || []))
.catch(e => toast.push({ msg: `${(window.appT||(s=>s))('Campaigns fetch failed:')} ${e.message}`, kind: 'err' }))
.finally(() => setLoadingCampaigns(false));
};
useEffect(() => { loadPrompts(); loadCampaigns(); }, []);
const deletePrompt = async (p) => {
if (!window.confirm(`${(window.appT||(s=>s))('Delete prompt')} "${p.title}"?`)) return;
try {
const r = await fetch(`/api/collections/prompts/${p.id}`, { method: 'DELETE', credentials: 'include' });
if (!r.ok) {
const b = await r.json().catch(() => null);
throw new Error(parseDetail(b, r.status));
}
const d = await r.json();
toast.push({ msg: (window.appT||(s=>s))(d.disabled ? 'Built-in prompt disabled' : 'Prompt deleted'), kind: 'ok' });
loadPrompts();
} catch (e) {
toast.push({ msg: `${(window.appT||(s=>s))('Delete failed:')} ${e.message}`, kind: 'err' });
}
};
const deleteCampaign = async (c) => {
if (!window.confirm(`${(window.appT||(s=>s))('Delete campaign')} "${c.title}"?`)) return;
try {
const r = await fetch(`/api/collections/campaigns/${c.id}`, { method: 'DELETE', credentials: 'include' });
if (!r.ok) {
const b = await r.json().catch(() => null);
throw new Error(parseDetail(b, r.status));
}
toast.push({ msg: (window.appT||(s=>s))('Campaign deleted'), kind: 'ok' });
loadCampaigns();
} catch (e) {
toast.push({ msg: `${(window.appT||(s=>s))('Delete failed:')} ${e.message}`, kind: 'err' });
}
};
const sendCampaign = async (c) => {
try {
const r = await fetch(`/api/collections/campaigns/${c.id}/send`, { method: 'POST', credentials: 'include' });
if (!r.ok) {
const b = await r.json().catch(() => null);
throw new Error(parseDetail(b, r.status));
}
const d = await r.json();
const n = d.queued || 0;
const blocked = d.blocked || 0;
const isAr = window.appLang && window.appLang() === 'ar';
let msg = isAr ? `تم إرسال ${n} رسالة` : `Queued ${n} DM${n === 1 ? '' : 's'}`;
if (blocked > 0) {
msg += isAr ? ` · ${blocked} محظور (قائمة عدم المراسلة)` : ` · ${blocked} blocked (never-DM list)`;
}
toast.push({ msg, kind: blocked > 0 ? 'warn' : 'ok' });
loadCampaigns();
} catch (e) {
toast.push({ msg: `${(window.appT||(s=>s))('Send failed:')} ${e.message}`, kind: 'err' });
}
};
const promptTitleFor = (id) => {
const p = prompts.find(x => x.id === id);
return p ? p.title : null;
};
return (
s))('Collections')}
meta={(window.appT||(s=>s))('Bot-driven surveys, polls, profile-fill prompts, and acknowledgements')}
/>
{/* Section tabs */}
{[['prompts', 'Prompts'], ['campaigns', 'Campaigns']].map(([id, label]) => (
setTab(id)}
style={{
background: 'none', border: 'none',
borderBottom: tab === id ? '2px solid var(--brand)' : '2px solid transparent',
padding: '8px 14px', cursor: 'pointer', fontSize: 13,
fontWeight: tab === id ? 600 : 400,
color: tab === id ? 'var(--text-1)' : 'var(--text-3)',
marginBottom: -1,
}}
>
{(window.appT||(s=>s))(label)}
))}
{tab === 'prompts' && (
s))('Prompt templates')}
sub={(window.appT||(s=>s))('Reusable questions the bot can send via a campaign')}
tip={(window.appT||(s=>s))('Built-in prompts ship with the bot and cannot be deleted — disabling them turns them off instead.')}
right={
setEditPrompt({})}>
{(window.appT||(s=>s))('New prompt')}
}
/>
{loadingPrompts ? (
s))('Loading prompts…')} />
) : prompts.length === 0 ? (
s))('No prompts yet')} subtitle={(window.appT||(s=>s))('Create one to start collecting answers.')} />
) : (
{(window.appT||(s=>s))('Title')}
{(window.appT||(s=>s))('Kind')}
{(window.appT||(s=>s))('Profile field')}
{(window.appT||(s=>s))('Active')}
{prompts.map(p => (
{p.title}
{p.is_builtin && {(window.appT||(s=>s))('built-in')} }
{(window.appT||(s=>s))(COLL_KIND_LABELS[p.kind] || p.kind)}
{p.profile_field || '—'}
{p.active ? (window.appT||(s=>s))('active') : (window.appT||(s=>s))('inactive')}
setEditPrompt(p)}>
{(window.appT||(s=>s))('Edit')}
deletePrompt(p)}>
{p.is_builtin ? (window.appT||(s=>s))('Disable') : (window.appT||(s=>s))('Delete')}
))}
)}
)}
{tab === 'campaigns' && (
s))('Campaigns')}
sub={(window.appT||(s=>s))('Schedule or send a prompt to an audience')}
tip={(window.appT||(s=>s))('A campaign dispatches its prompt to the chosen audience on its schedule, or immediately via Send now.')}
right={
setEditCampaign({})}>
{(window.appT||(s=>s))('New campaign')}
}
/>
{loadingCampaigns ? (
s))('Loading campaigns…')} />
) : campaigns.length === 0 ? (
s))('No campaigns yet')} subtitle={(window.appT||(s=>s))('Create one to start sending a prompt.')} />
) : (
{(window.appT||(s=>s))('Title')}
{(window.appT||(s=>s))('Prompt')}
{(window.appT||(s=>s))('Audience')}
{(window.appT||(s=>s))('Schedule')}
{(window.appT||(s=>s))('Status')}
{(window.appT||(s=>s))('Last run')}
{campaigns.map(c => (
{c.title}
{c.prompt_title || promptTitleFor(c.prompt_id) || '—'}
{(window.appT||(s=>s))(COLL_AUDIENCE_LABELS[c.audience_kind] || c.audience_kind)}
{c.audience_value ? ` · ${c.audience_value}` : ''}
{(window.appT||(s=>s))(COLL_SCHEDULE_LABELS[c.schedule_kind] || c.schedule_kind)}
{c.schedule_kind === 'weekly' && c.day_of_week != null ? ` (${COLL_DOW_LABELS[c.day_of_week]})` : ''}
{c.schedule_kind === 'monthly' && c.day_of_month != null ? ` (${(window.appT||(s=>s))('day')} ${c.day_of_month})` : ''}
{c.schedule_kind !== 'once' ? ` @ ${String(c.hour_utc).padStart(2, '0')}:00 UTC` : ''}
{(window.appT||(s=>s))(COLL_STATUS_LABELS[c.status] || c.status)}
{c.last_run_at || '—'}
sendCampaign(c)}>
{(window.appT||(s=>s))('Send now')}
setResponsesFor(c)}>
{(window.appT||(s=>s))('Responses')}
setEditCampaign(c)}>
{(window.appT||(s=>s))('Edit')}
deleteCampaign(c)}>
{(window.appT||(s=>s))('Delete')}
))}
)}
)}
{editPrompt && (
setEditPrompt(null)}
onSaved={loadPrompts}
/>
)}
{editCampaign && (
setEditCampaign(null)}
onSaved={loadCampaigns}
/>
)}
{responsesFor && (
setResponsesFor(null)} />
)}
);
}
window.CollectionsPage = CollectionsPage;