// churn.jsx — экран «Почему уходишь?» (?view=churn, CJM Насти, п. 3.8). // Открывается из «Мой тариф» («Не буду продлевать») и сам — на главной, если подписка закончилась // без оплаты и идут 3 дня grace (churn_pending в /api/auth/me; «Не сейчас» — до конца сессии). // Вопрос «Что повлияло на решение?» → вариант + «Дополнить ответ» → карточка-предложение // (кнопка ведёт в бота ?start=) или «Всё равно не продлевать» → благодарность и дата, // до которой можно вернуться по прежней цене. Ответ, клик и отказ сохраняет сервер (/api/churn), // их видит команда. Тексты — data/churn.json + правки в «Команда» → «Тексты». function ChurnOffer({ o, answerId, onClicked }) { const click = () => { postJSON(`/api/churn/${encodeURIComponent(answerId)}/click`, { offer: o.id }); onClicked && onClicked(o.id); }; return (
{o.title}

{o.text}

{o.button} ↗
); } function Churn({ nav }) { const [data, reload] = useApi('/api/churn'); const [reason, setReason] = React.useState(''); const [comment, setComment] = React.useState(''); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(''); const [answer, setAnswer] = React.useState(null); const [edit, setEdit] = React.useState(false); React.useEffect(() => { if (data && data.answer) setAnswer(data.answer); }, [data]); const skip = () => { try { sessionStorage.setItem('cm_churn_skip', '1'); } catch (e) {} nav('home'); }; const { member } = ME(); const frame = (body) => (
{body}
); if (data === undefined) return frame(
Загружаем…
); if (!data) return frame(
Экран не загрузился. Обнови страницу через минуту.
); const send = async (e) => { e.preventDefault(); if (!reason) return setErr('Выбери вариант, который ближе всего.'); setBusy(true); setErr(''); const r = await postJSON('/api/churn', { reason, comment }); setBusy(false); if (!r.ok) return setErr(r.j && r.j.error === 'comment_too_long' ? 'Комментарий длинноват: до 1 000 знаков.' : 'Не получилось отправить, попробуй ещё раз.'); setAnswer(r.j.answer); setEdit(false); try { sessionStorage.setItem('cm_churn_skip', '1'); } catch (x) {} if (window.__ME) window.__ME.churn_pending = false; }; const decline = async () => { setBusy(true); await postJSON(`/api/churn/${encodeURIComponent(answer.id)}/decline`); setBusy(false); setAnswer(a => ({ ...a, declined: true })); }; const label = (id) => (data.reasons.find(r => r.id === id) || {}).label || id; const hint = (data.reasons.find(r => r.id === reason) || {}).comment_hint || ''; const head = (
Продление

Почему уходишь?

{data.in_grace ? <>Подписка закончилась {fmtDateShort(data.paid_until)}. Доступ держится ещё несколько дней, до {fmtDateShort(member.grace_until)}. : data.paid_until ? <>Подписка действует до {fmtDateShort(data.paid_until)}. : null} {answer && !edit ? (answer.declined ? 'Жаль расставаться.' : 'Спасибо за ответ. Вот что может подойти.') : 'Ответ займёт минуту и поможет нам стать лучше. А мы предложим вариант, который может подойти.'}

); // благодарность после «Всё равно не продлевать» if (answer && answer.declined && !edit) return frame(<> {head}
{Icons.check()}
{data.decline.title}

{data.decline.text}

); // ответ есть — предложения по причине if (answer && !edit) return frame(<> {head}
Твой ответ: {label(answer.reason)}{answer.comment ? ` · «${answer.comment}»` : ''}
{answer.offers.length > 0 && (

Может подойти

{answer.offers.map(o => toast('Открываем Службу заботы')}/>)}
)} {data.return_until &&

Если всё же уйдёшь: вернуться по прежней цене можно до {fmtDateShort(data.return_until)}.

}
); return frame(<> {head}
{data.question}
{data.reasons.map(r => ( ))}