// 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) => (
{i < all.length - 1 && (
)}
))}
{/* editable fields */}
о себе
сохранить
привязано
}
title="telegram"
sub={`id ${tg.username || 'guest'}`}
status="связано"
/>
);
}
function SubLinkRow({ icon, iconBg, title, sub, status, last }) {
return (
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 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)}
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 (
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 => (
))}
сделано с для коллекционеров
© 2026 NyaShelf · all rights reserved
);
}
function AboutLink({ label, sub, onClick, last }) {
return (
);
}
Object.assign(window, { ProfileScreen, NotifSettingsScreen, MembersScreen, AboutScreen });