// Capsule, Settings, Landing, Onboarding — все интерактивные. // Changes from v1: // - CapsuleScreen: показывает реально избранные вопросы (favorite=true) // - SettingsScreen: время, категории, интимные, годовщина, пауза, выход — // всё редактируется через /api/couple/settings, /pair/leave // - LandingScreen: для пользователя без пары — кнопки создать/присоединиться // (POST /pair/create, /pair/join) прямо из веба // ═══════════════════════════════════════════════════════════════════ // CAPSULE // ═══════════════════════════════════════════════════════════════════ window.CapsuleScreen = function CapsuleScreen({ data, setScreen, reload }) { const D = data; const favs = D.history.filter(h => h.favorite); const progress = Math.min(100, Math.round(D.pair.days_together * 100 / 365)); return (

Наша капсула

{D.pair.days_together} дней вместе · ещё {D.pair.capsule_unlock_in} до года
💕
КАПСУЛА ПАРЫ

{D.me.name} & {D.partner.name}

Сборник ваших любимых вопросов и ответов. Откроется через {D.pair.capsule_unlock_in}, когда исполнится год вашей пары в tgplay.

{D.pair.days_together} / 365 дней {D.pair.capsule_unlock_days > 0 ? `${D.pair.capsule_unlock_in} до открытия` : 'открыта'}

{favs.length ? `Избранные вопросы (${favs.length})` : 'Пока нет избранного'}

{!favs.length && ( Добавьте из «Раскрытия» или «Архива» )}
{favs.length > 0 ? (
{favs.map(f => { const cat = f.cat_meta; return (
#{f.number} · {f.date} {cat.emoji} {cat.ru}
«{f.text}»
{D.me.name}
{f.you || }
{D.partner.name}
{f.partner || }
); })}
) : (
Отмечайте ❤️ избранные вопросы при раскрытии или в архиве — они появятся здесь.
)}
); }; // ═══════════════════════════════════════════════════════════════════ // SETTINGS // ═══════════════════════════════════════════════════════════════════ window.SettingsScreen = function SettingsScreen({ data, reload }) { const { useState } = React; const D = data; const [busy, setBusy] = useState(null); // which field is being saved const [error, setError] = useState(null); const [flash, setFlash] = useState(null); // Local drafts — we commit on blur or explicit save const [time, setTime] = useState(D.pair.question_time || '20:00'); const [anniv, setAnniv] = useState(D.pair.anniversary || ''); const [pauseDays, setPauseDays] = useState(7); const [categories, setCategories] = useState( (D.pair.enabled_categories || []).filter(c => c !== 'intimate') ); const [includeIntimate, setIncludeIntimate] = useState(!!D.pair.include_intimate); const [confirmModal, setConfirmModal] = useState(null); async function save(patch, fieldName) { setBusy(fieldName); setError(null); try { await window.API.updateSettings(patch); setFlash('Сохранено'); setTimeout(() => setFlash(null), 1500); if (reload) await reload(); } catch (e) { setError(e.message || 'Ошибка'); } finally { setBusy(null); } } function toggleCat(c) { const next = categories.includes(c) ? categories.filter(x => x !== c) : [...categories, c]; if (next.length === 0) { setError('Выберите хотя бы одну категорию'); return; } setCategories(next); save({ categories: next }, 'categories'); } async function doLeave() { setBusy('leave'); setError(null); try { await window.API.leavePair(); if (reload) await reload(); } catch (e) { setError(e.message || 'Ошибка'); } finally { setBusy(null); } } function onLeave() { setConfirmModal({ title: 'Выйти из пары?', body: 'История останется в архиве, но новые вопросы перестанут приходить. Партнёр тоже потеряет доступ.', confirmLabel: 'Выйти из пары', kind: 'danger', onConfirm: () => { setConfirmModal(null); doLeave(); } }); } function onPause() { var n = Number(pauseDays); var word = (n % 10 === 1 && n % 100 !== 11) ? 'день' : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) ? 'дня' : 'дней'); setConfirmModal({ title: 'Поставить на паузу?', body: 'На ' + n + ' ' + word + ' вопросы перестанут приходить. Возобновить можно в любой момент.', confirmLabel: 'Поставить на паузу', kind: 'primary', onConfirm: () => { setConfirmModal(null); save({ pause_days: n }, 'pause'); } }); } async function onResume() { await save({ resume: true }, 'resume'); } const isPaused = D.pair.state === 'paused'; const isWaiting = D.pair.is_waiting; return (

Настройки

Всё меняется сразу — изменения сохраняются на сервере.
{error && (
{error}
)} {flash && (
✓ {flash}
)} {isWaiting && D.pair.pair_id && (
Пара ждёт партнёра
Отправьте партнёру код приглашения — пока он не введёт его, вопросы не будут приходить.
{D.pair.pair_id}
)}
Время и уведомления
Время вопроса
Каждый день в это время сервер создаст новый вопрос
setTime(e.target.value)} onBlur={() => { if (time !== D.pair.question_time) save({ question_time: time }, 'time'); }} disabled={busy === 'time'} style={{padding: '8px 12px', borderRadius: 10, border: '1px solid var(--ink-3)', background: 'var(--ink-1)', color: 'var(--text-1)', fontFamily: 'var(--font-mono)', fontSize: 14}} />
Категории вопросов
{D.allCategories.map(c => { const meta = D.cats[c] || D.cats._default; const on = categories.includes(c); return ( ); })}
Интимные вопросы
18+ тема — только с обоюдного согласия
Пара
Партнёр
{D.partner.name} · с {D.pair.started_at}
{D.pair.state}
Годовщина
Дата начала отношений — для специальных вопросов
setAnniv(e.target.value)} onBlur={() => { if (anniv !== (D.pair.anniversary || '')) save({ anniversary: anniv || '' }, 'anniv'); }} disabled={busy === 'anniv'} style={{padding: '8px 12px', borderRadius: 10, border: '1px solid var(--ink-3)', background: 'var(--ink-1)', color: 'var(--text-1)', fontSize: 13}} /> {anniv && ( )}
Пауза
{isPaused ? `Пара на паузе до ${D.pair.paused_until ? new Date(D.pair.paused_until).toLocaleDateString('ru-RU') : '—'}` : 'Остановить вопросы на время (1–90 дней)'}
{isPaused ? ( ) : ( <> setPauseDays(e.target.value)} style={{width: 70, padding: '6px 10px', borderRadius: 10, border: '1px solid var(--ink-3)', background: 'var(--ink-1)', color: 'var(--text-1)', fontSize: 13}} /> )}
Выйти из пары
История останется в архиве, но новые вопросы перестанут приходить обоим
Также доступно в Telegram
Бот @{D.botUsername} присылает уведомления о новых вопросах и напоминания. Всё остальное — прямо здесь.
{confirmModal && (
setConfirmModal(null)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.62)', backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, animation: 'fadeIn 0.15s ease-out' }} >
e.stopPropagation()} style={{ background: 'var(--ink-1, #1a1420)', border: '1px solid var(--ink-3, #2a2030)', borderRadius: 16, padding: 26, maxWidth: 440, width: '100%', boxShadow: '0 24px 64px rgba(0,0,0,0.55)' }} >
{confirmModal.title}
{confirmModal.body}
)}
); }; // ═══════════════════════════════════════════════════════════════════ // LANDING — для пользователей без пары, с функциональным создать/присоединиться // ═══════════════════════════════════════════════════════════════════ window.LandingScreen = function LandingScreen({ data, reload }) { const { useState } = React; const D = data || {}; const authenticated = !!D.authenticated; const [mode, setMode] = useState(null); // 'create' | 'join' | null const [code, setCode] = useState(''); const [joining, setJoining] = useState(false); const [error, setError] = useState(null); const [createdCode, setCreatedCode] = useState(null); async function onCreate() { setError(null); setJoining(true); try { const res = await window.API.createPair(); setCreatedCode(res.pair_id); // не вызываем reload сразу: иначе LandingScreen размонтируется и код пропадёт. // Партнёр присоединится — пользователь сам нажмёт "Проверить снова" ниже. } catch (e) { setError(e.message || 'Не удалось создать пару'); } finally { setJoining(false); } } async function onJoin() { setError(null); if (!code.trim()) { setError('Введите код'); return; } setJoining(true); try { await window.API.joinPair(code.trim().toUpperCase()); if (reload) await reload(); } catch (e) { setError(e.message || 'Не удалось присоединиться'); } finally { setJoining(false); } } return (
tgplay · игра для пары

Один вопрос в день — и вы узнаёте друг друга заново.

Каждый вечер приходит один и тот же вопрос вам обоим. Отвечаете отдельно, ответы открываются, когда оба готовы.

{!authenticated && (
Войти через tgplay.ru
)} {authenticated && !mode && !createdCode && (
)} {authenticated && mode === 'create' && !createdCode && (
Создать новую пару
Мы сгенерируем короткий код — отправьте его партнёру, чтобы он присоединился.
{error &&
{error}
}
)} {authenticated && createdCode && (
Пара создана ✨
Отправьте этот код партнёру. Он вводит его в поле «У меня есть код».
{createdCode}
Как только партнёр введёт код, страница обновится и вы увидите свою пару.
партнёр присоединится сам, когда введёт код
)} {authenticated && mode === 'join' && (
Присоединиться к паре
Введите код, который дал(а) вам партнёр.
setCode(e.target.value.toUpperCase())} placeholder="ABC123" maxLength={16} style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--ink-3)', background: 'var(--ink-1)', color: 'var(--text-1)', fontFamily: 'var(--font-mono)', fontSize: 18, letterSpacing: 3, textAlign: 'center', textTransform: 'uppercase' }} />
{error &&
{error}
}
)}
Бесплатно Только вы двое Без рекламы
Сегодня · 21:00
ВОПРОС #128
«Когда ты в последний раз чувствовал, что я по-настоящему тебя слушаю?»
Ответить →
); }; // ═══════════════════════════════════════════════════════════════════ // ONBOARDING // ═══════════════════════════════════════════════════════════════════ window.OnboardingScreen = function OnboardingScreen({ data }) { const D = data || {}; const steps = [ { t: 'Создать пару или ввести код', d: 'На главной — «Создать пару», либо «У меня есть код», если партнёр уже создал.', icon: 'sparkle' }, { t: 'Выбрать время', d: 'В настройках задайте удобное время вопроса (по умолчанию 20:00).', icon: 'today' }, { t: 'Отвечать каждый день', d: 'Оба отвечаете на один вопрос. Ответы откроются, когда оба готовы.', icon: 'reveal' } ]; return (
{steps.map((_, i) =>
)}

Как это работает

Вся игра — прямо здесь. Бот Telegram присылает уведомления, но всё остальное — в вебе.

    {steps.map((s, i) =>
  1. {s.t}. {s.d}
  2. )}
); };