// nyashelf-subscreens.jsx // Full-screen sub-pages reached from Settings: // ProfileScreen, NotifSettingsScreen, MembersScreen, AboutScreen. // Generic stacked sub-screen header — back arrow + title. function SubHeader({ goBack, title, action }) { return (
{I.back(20)}

{title}

{action}
); } // ───────────────────────────────────────────────────────────────────────────── // 1. Profile — view/edit user info pulled from Telegram // ───────────────────────────────────────────────────────────────────────────── function ProfileScreen({ goBack, tweaks }) { const tg = React.useMemo(() => getTgUser(), []); const toast = useToast(); const figures = useFigures(); const [name, setName] = React.useState(tg.name); const [username, setUsername] = React.useState(tg.username || ''); const [bio, setBio] = React.useState('коллекционирую с 2020. больше всего люблю фигурки Alter.'); const stats = React.useMemo(() => { const total = figures.length; const paid = figures.filter(f => f.status === 'paid' || f.status === 'shipped' || f.status === 'available' || f.status === 'delivered').length; const preorders = figures.filter(f => f.status === 'preorder' || f.status === 'soon').length; return { total, paid, preorders }; }, [figures]); const save = () => toast.show('профиль сохранён', { kind: 'success' }); return (
{/* avatar + name */}

{tg.name}

{tg.username && (
@{tg.username}
)}
подгружено из telegram
{/* stats */} {[ { label: 'всего', v: stats.total }, { label: 'оплачено', v: stats.paid }, { label: 'предзаказы', v: stats.preorders }, ].map((s, i, all) => (
{s.v}
{s.label}
{i < all.length - 1 && (
)} ))} {/* editable fields */} о себе
сохранить
привязано } title="telegram" sub={`id ${tg.username || 'guest'}`} status="связано" />
); } function SubLinkRow({ icon, iconBg, title, sub, status, last }) { return (
{icon}
{title}
{sub}
{status}
); } // ───────────────────────────────────────────────────────────────────────────── // 2. Notification settings — per-type toggles + reminder rails // ───────────────────────────────────────────────────────────────────────────── function NotifSettingsScreen({ goBack, tweaks }) { const toast = useToast(); const [types, setTypes] = React.useState({ pay: true, price: true, release: true, ship: true, wishlist: false, }); const [channels, setChannels] = React.useState({ push: true, sound: false, badge: true }); const [quiet, setQuiet] = React.useState({ enabled: true, from: '23:00', to: '08:00' }); const typesList = [ { id: 'pay', label: 'оплата', sub: 'напомнить когда подходит срок', color: 'linear-gradient(135deg, #ffb3c9, #c89bf0)' }, { id: 'price', label: 'цена', sub: 'если цена изменится больше 10%', color: 'linear-gradient(135deg, #9ecdff, #c89bf0)' }, { id: 'release', label: 'релиз', sub: 'за неделю / в день релиза', color: 'linear-gradient(135deg, #f5e6a8, #ffb3c9)' }, { id: 'ship', label: 'отправка', sub: 'когда заказ ушёл со склада', color: 'linear-gradient(135deg, #b3f0c9, #9ecdff)' }, { id: 'wishlist', label: 'вишлист', sub: 'если позиция вернулась в наличие', color: 'linear-gradient(135deg, #ff9ecd, #f5a3c7)' }, ]; return (
о чём писать {typesList.map((t, i) => (
{(NOTIF_ICONS[t.id] || I.bell)(18)}
{t.label}
{t.sub}
setTypes(s => ({ ...s, [t.id]: v }))}/>
))}
каналы setChannels(s => ({ ...s, push: v }))} /> setChannels(s => ({ ...s, sound: v }))} /> setChannels(s => ({ ...s, badge: v }))} last /> тихие часы setQuiet(q => ({ ...q, enabled: v }))} last={!quiet.enabled} /> {quiet.enabled && (
с
setQuiet(q => ({ ...q, from: v }))} />
до
setQuiet(q => ({ ...q, to: v }))} />
)}
toast.show('настройки сохранены', { kind: 'success' })}> сохранить
); } // ─── Shared row with switch ───────────────────────────────────────────────── function NyaSwitch({ value, onChange }) { return ( ); } function SwitchRow({ label, sub, value, onChange, last }) { return (
{label}
{sub &&
{sub}
}
); } // ───────────────────────────────────────────────────────────────────────────── // 3. Members — list with roles + invite by username // ───────────────────────────────────────────────────────────────────────────── const ROLE_OPTS = [ { id: 'owner', label: 'владелец', sub: 'все права' }, { id: 'editor', label: 'редактор', sub: 'добавляет, оплачивает' }, { id: 'viewer', label: 'наблюдатель',sub: 'только просмотр' }, ]; function MembersScreen({ goBack, tweaks }) { const toast = useToast(); const tgEnv = React.useMemo(() => { const tg = window.Telegram && window.Telegram.WebApp; return tg && tg.initData ? { inTelegram: true, initData: tg.initData, tg } : { inTelegram: false }; }, []); const [members, setMembers] = React.useState([]); const [myRole, setMyRole] = React.useState('viewer'); const [loading, setLoading] = React.useState(true); const [inviteOpen, setInviteOpen] = React.useState(false); const [inviteRole, setInviteRole] = React.useState('viewer'); const [inviteResult, setInviteResult] = React.useState(null); // {code, link, role, expires_at} const [confirmRemove, setConfirmRemove] = React.useState(null); const fetchMembers = React.useCallback(async () => { if (!tgEnv.inTelegram) { setLoading(false); return; } try { const r = await fetch('/api/workspace/members', { headers: { Authorization: 'tma ' + tgEnv.initData }, }); if (!r.ok) throw new Error('HTTP ' + r.status); const data = await r.json(); // Backend caches each member's photo_url every time they hit the // API; current user's photo also comes through initData as a freshness // backstop in case they haven't opened the WebApp since the photo // changed. const myTg = window.Telegram?.WebApp?.initDataUnsafe?.user || {}; setMembers((data.members || []).map(m => ({ id: String(m.user_id), name: m.name || ('id ' + m.user_id), username: m.name || ('id' + m.user_id), role: m.role, is_me: m.is_me, photo: (m.is_me ? (myTg.photo_url || m.photo_url) : m.photo_url) || null, }))); setMyRole(data.my_role || 'viewer'); } catch (e) { console.error('members fetch failed', e); toast.show('не удалось загрузить участников'); } finally { setLoading(false); } }, [tgEnv]); React.useEffect(() => { fetchMembers(); }, [fetchMembers]); const createInvite = async () => { if (!tgEnv.inTelegram) { setInviteResult({ code: 'DEMO0000', link: null, role: inviteRole, expires_at: null }); return; } try { const r = await fetch('/api/workspace/invites', { method: 'POST', headers: { Authorization: 'tma ' + tgEnv.initData, 'Content-Type': 'application/json' }, body: JSON.stringify({ role: inviteRole, expires_in_days: 7 }), }); if (!r.ok) { const txt = await r.text(); throw new Error(txt || ('HTTP ' + r.status)); } const data = await r.json(); setInviteResult(data); } catch (e) { console.error('invite create failed', e); toast.show('не удалось создать приглашение'); } }; const remove = async (id) => { setConfirmRemove(null); if (!tgEnv.inTelegram) { setMembers(prev => prev.filter(m => m.id !== id)); toast.show('участник удалён'); return; } try { const r = await fetch('/api/workspace/members/' + id, { method: 'DELETE', headers: { Authorization: 'tma ' + tgEnv.initData }, }); if (!r.ok) throw new Error('HTTP ' + r.status); toast.show('участник удалён'); await fetchMembers(); } catch (e) { console.error('remove failed', e); toast.show('не получилось удалить'); } }; const changeRole = async (id, role) => { if (!tgEnv.inTelegram) { setMembers(prev => prev.map(m => m.id === id ? { ...m, role } : m)); toast.show('роль обновлена', { kind: 'success' }); return; } try { const r = await fetch('/api/workspace/members/' + id, { method: 'PUT', headers: { Authorization: 'tma ' + tgEnv.initData, 'Content-Type': 'application/json' }, body: JSON.stringify({ role }), }); if (!r.ok) throw new Error('HTTP ' + r.status); toast.show('роль обновлена', { kind: 'success' }); await fetchMembers(); } catch (e) { console.error('role change failed', e); toast.show('не получилось'); } }; const closeInviteModal = () => { setInviteOpen(false); setInviteResult(null); setInviteRole('viewer'); }; const copyLink = () => { if (!inviteResult) return; const text = inviteResult.link || inviteResult.code; try { navigator.clipboard.writeText(text); toast.show('ссылка скопирована', { kind: 'success' }); } catch (_) { toast.show(text); } }; const isOwner = myRole === 'owner'; return (
setInviteOpen(true)}>пригласить : null} />
поделись полочкой с близкими. они увидят твои предзаказы и смогут помогать с оплатой.
{loading ? (
загружаем…
) : members.length === 0 ? (
пока никого нет
) : (
{members.map(m => ( changeRole(m.id, role)} onRemove={() => setConfirmRemove(m)} /> ))}
)}
{/* Invite modal */} закрыть скопировать
) : (
отмена создать код
) } > {inviteResult ? (
отправь эту ссылку — приглашённый откроет бот и автоматически попадёт в воркспейс.
{inviteResult.link || inviteResult.code}
роль: {inviteResult.role} {inviteResult.expires_at && (<> · истекает {new Date(inviteResult.expires_at).toLocaleDateString('ru-RU')})}
) : (
{ROLE_OPTS.filter(r => r.id !== 'owner').map(r => { const on = inviteRole === r.id; return ( ); })}
)} remove(confirmRemove.id)} onCancel={() => setConfirmRemove(null)} />
); } function MemberCard({ member, onRoleChange, onRemove, canManage = true }) { const [rolePickerOpen, setRolePickerOpen] = React.useState(false); const role = ROLE_OPTS.find(r => r.id === member.role); let h = 0; for (const c of member.name) h = (h + c.charCodeAt(0) * 7) % 360; return (
{member.name}{member.is_me ? ' · ты' : ''}
{role?.label}
{canManage && ( )} {canManage && member.role !== 'owner' && ( )} setRolePickerOpen(false)} title="роль">
{ROLE_OPTS.map(r => { const on = member.role === r.id; return ( ); })}
); } // ───────────────────────────────────────────────────────────────────────────── // 4. About — version, credits, links // ───────────────────────────────────────────────────────────────────────────── function AboutScreen({ goBack, tweaks }) { const toast = useToast(); return (
{/* hero */}

NyaShelf

трекер предзаказов аниме-фигурок
версия 1.0.0
{/* description */}

личная полочка для предзаказов с nin-nin-game и других магазинов. следи за датами оплаты, ценами и релизами — без таблиц и заметок в телефоне.

ресурсы toast.show('открываю telegram…')} /> toast.show('открываю политику…')} /> toast.show('открываю условия…')} /> toast.show('открываю чат поддержки…')} last /> сделано с
{[ { name: 'telegram mini app', desc: 'платформа' }, { name: 'react', desc: 'интерфейс' }, { name: 'nin-nin-game', desc: 'источник данных' }, { name: 'comfortaa+quicksand', desc: 'шрифты' }, ].map(c => (
{c.name}
{c.desc}
))}
сделано с для коллекционеров
© 2026 NyaShelf · all rights reserved
); } function AboutLink({ label, sub, onClick, last }) { return (
{label}
{sub &&
{sub}
}
); } Object.assign(window, { ProfileScreen, NotifSettingsScreen, MembersScreen, AboutScreen });