// nyashelf-screens.jsx
// All screen components: Dashboard, FigureDetail, Calendar, Search, Wishlist,
// Settings, plus Loading / Empty / Error states.
// ─────────────────────────────────────────────────────────────────────────────
// Reusable: FigureCard (compact 2-column)
// ─────────────────────────────────────────────────────────────────────────────
function FigureCard({ figure, onClick, density = 'comfy' }) {
const fmt = useFormat();
const f = figure;
const compact = density === 'tight';
const imgH = compact ? 130 : 150;
const cardH = compact ? 240 : 268; // fixed so every card is identical regardless of text length
return (
{/* meta row — fixed height so status pill always aligns */}
{f.maker} · {f.scale}
{/* name — 1 line, fixed height */}
{f.name}
{/* line — 1 line, fixed height */}
{f.line}
{/* spacer pushes price row to the bottom */}
{/* price row — pinned to bottom */}
{fmt(f.price)}
{f.payDays > 0 ? (
до оплаты {f.payDays} дн
) : f.release ? (
{nyaFormatDate(f.release)}
) : null}
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 1. Dashboard
// ─────────────────────────────────────────────────────────────────────────────
function DashboardScreen({ go, tweaks }) {
const fmt = useFormat();
const toast = useToast();
const [refreshing, setRefreshing] = React.useState(false);
const figures = useFigures();
const stats = React.useMemo(() => {
const active = figures.filter(f => f.status !== 'delivered');
const monthPay = figures
.filter(f => f.status === 'preorder' && f.payDays <= 30 && f.payDays > 0)
.reduce((s, f) => s + (f.price - f.paid), 0);
const totalCommit = figures
.filter(f => f.status === 'preorder' || f.status === 'soon')
.reduce((s, f) => s + (f.price - f.paid), 0);
return { count: active.length, monthPay, totalCommit };
}, [figures]);
const sorted = [...figures]
.filter(f => f.status !== 'delivered')
.sort((a, b) => {
const aP = a.payDays || 9999, bP = b.payDays || 9999;
return aP - bP;
});
const handleRefresh = async () => {
setRefreshing(true);
await new Promise(r => setTimeout(r, 1200));
setRefreshing(false);
toast.show('обновлено', { kind: 'success' });
};
return (
{/* Greeting */}
добрый вечер
твоя полочка
go('notifications')}>
{I.bell(18)}
{/* Hero stat card */}
в этом месяце к оплате
{fmt(stats.monthPay)}
предзаказов
{stats.count}
всего к доплате
{fmt(stats.totalCommit)}
{/* Quick actions row */}
go('search')} style={{ flex: 1 }}>
добавить
go('calendar')} style={{ flex: 1 }}>
календарь
{/* Upcoming payments callout */}
go('calendar')} style={{
background: 'transparent', border: 0, color: 'var(--accent-a)',
fontSize: 12, fontWeight: 600, cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 2,
}}>смотреть {I.chevRight(12)}
}>скоро оплата
{(() => {
const urgent = sorted.filter(f => f.payDays > 0 && f.payDays <= 14).slice(0, 2);
return (
{urgent.map(f => (
go('detail', f)} style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
{f.name}
{fmt(f.price - f.paid)} · {nyaFormatDate(f.release)}
{f.payDays} {ruPlural(f.payDays, ['день', 'дня', 'дней'])}
))}
);
})()}
все заказы
{refreshing ? (
{Array.from({ length: 4 }).map((_, i) => )}
) : (
{sorted.map(f => (
go('detail', f)} />
))}
)}
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 2. Figure detail
// ─────────────────────────────────────────────────────────────────────────────
// Floating chevron button on the hero image (used in detail screen).
const arrowStyle = (side) => ({
position: 'absolute', top: '50%',
[side]: 10,
transform: 'translateY(-50%)',
width: 36, height: 36, borderRadius: '50%', border: 0,
background: 'rgba(20,12,40,.55)',
backdropFilter: 'blur(10px)', WebkitBackdropFilter: 'blur(10px)',
color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0, zIndex: 5,
boxShadow: '0 4px 12px rgba(0,0,0,.3)',
});
const zoomBtnStyle = {
width: 32, height: 32, borderRadius: '50%', border: 0,
background: 'rgba(20,12,40,.6)',
backdropFilter: 'blur(10px)', WebkitBackdropFilter: 'blur(10px)',
color: '#fff', cursor: 'pointer', padding: 0,
fontSize: 16, fontWeight: 500,
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,.3)',
};
const _EMPTY_FIGURE = {
id: '_', name: '', scale: '', tone: '', maker: '', line: '',
image_urls: [], image_url: null, price: 0, currency: 'EUR',
status: '', release: null, payDays: null, paid: 0,
wishlist: false, tags: [], hue: 0, char: '', category: '',
product_url: null, order_number: null,
};
function FigureDetailScreen({ go, goBack, figureId, figureData, tweaks, wishlist, toggleWishlist, openCatalogWithTag }) {
const fmt = useFormat();
const toast = useToast();
const figures = useFigures();
// Cache the figure object so it survives a later refetch that may drop
// the item (e.g. user removes it from wishlist while on this screen).
const figureRef = React.useRef(figureData);
if (figureData && figureData !== figureRef.current) {
figureRef.current = figureData;
}
const fActual = figureRef.current || figures.find(x => x.id === figureId);
// _EMPTY_FIGURE keeps every hook below safe when fActual is null;
// we still render the "not found" view at the bottom in that case.
const f = fActual || _EMPTY_FIGURE;
// Use the wishlist Set as the single source of truth. The figure
// object may carry a stale `wishlist: true` from when it was first
// fetched — after a DELETE the Set is updated but the cached figure
// isn't, so reading the Set keeps the heart in sync.
const isWishProduct = (figure) => figure && figure.product_url && wishlist.has(figure.product_url);
const isOrderItem = !!(f && f.order_number); // ordered → wishlist heart makes no sense
const handleShare = React.useCallback(() => {
if (!f) return;
const url = f.product_url;
const tg = window.Telegram && window.Telegram.WebApp;
if (tg && tg.openTelegramLink && url) {
const share = 'https://t.me/share/url?url=' + encodeURIComponent(url)
+ '&text=' + encodeURIComponent(f.name || '');
try { tg.openTelegramLink(share); return; } catch (_) {}
}
if (url && navigator.share) {
navigator.share({ title: f.name, url }).catch(() => {});
return;
}
if (url && navigator.clipboard) {
navigator.clipboard.writeText(url).then(() => toast.show('ссылка скопирована', { kind: 'success' }));
}
}, [f, toast]);
const [notesOpen, setNotesOpen] = React.useState(false);
const isWish = isWishProduct(f);
const progress = f.paid / f.price;
// When the figure carries a real gallery, treat each photo as a view;
// otherwise fall back to the mock's 4 stylized angles for the SVG art.
const views = (f.image_urls && f.image_urls.length)
? Array.from({ length: Math.min(f.image_urls.length, 10) }, (_, i) => i)
: ['front', 'close', 'back', 'box'];
const viewLabels = { front: 'спереди', close: 'крупно', back: 'сбоку', box: 'коробка' };
const [viewIdx, setViewIdx] = React.useState(0);
const [zoom, setZoom] = React.useState(false);
// lightbox zoom/pan state — wheel & double-click change scale, drag pans when scaled
const [scale, setScale] = React.useState(1);
const [pan, setPan] = React.useState({ x: 0, y: 0 });
const view = views[viewIdx];
const setView = (v) => setViewIdx(views.indexOf(v));
const prevView = React.useCallback(() => { setScale(1); setPan({x:0,y:0}); setViewIdx(i => (i - 1 + views.length) % views.length); }, []);
const nextView = React.useCallback(() => { setScale(1); setPan({x:0,y:0}); setViewIdx(i => (i + 1) % views.length); }, []);
// Reset zoom/pan whenever the lightbox closes or the view changes
React.useEffect(() => { setScale(1); setPan({ x: 0, y: 0 }); }, [zoom, viewIdx]);
const onWheel = (e) => {
e.preventDefault();
setScale(s => Math.min(4, Math.max(1, s + (e.deltaY > 0 ? -0.2 : 0.2))));
};
const onDblClick = () => setScale(s => s > 1 ? 1 : 2);
// pinch + drag handlers
const pinchRef = React.useRef({ d0: 0, s0: 1 });
const dragRef = React.useRef({ x: 0, y: 0, px: 0, py: 0, down: false });
const onTouchStart = (e) => {
if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
pinchRef.current = { d0: Math.hypot(dx, dy), s0: scale };
} else if (e.touches.length === 1 && scale > 1) {
dragRef.current = { x: e.touches[0].clientX, y: e.touches[0].clientY, px: pan.x, py: pan.y, down: true };
} else {
window.__nyaSwipe = e.touches[0].clientX;
}
};
const onTouchMove = (e) => {
if (e.touches.length === 2 && pinchRef.current.d0) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const d = Math.hypot(dx, dy);
setScale(Math.min(4, Math.max(1, pinchRef.current.s0 * (d / pinchRef.current.d0))));
e.preventDefault();
} else if (dragRef.current.down && scale > 1) {
setPan({
x: dragRef.current.px + (e.touches[0].clientX - dragRef.current.x),
y: dragRef.current.py + (e.touches[0].clientY - dragRef.current.y),
});
e.preventDefault();
}
};
const onTouchEnd = (e) => {
if (dragRef.current.down) { dragRef.current.down = false; return; }
if (pinchRef.current.d0) { pinchRef.current.d0 = 0; return; }
// single-touch swipe → switch view, but only if not zoomed in
if (scale === 1) {
const x0 = window.__nyaSwipe; if (x0 == null) return;
const dx = e.changedTouches[0].clientX - x0;
if (Math.abs(dx) > 40) (dx > 0 ? prevView : nextView)();
window.__nyaSwipe = null;
}
};
// mouse-drag pan when zoomed
const onMouseDown = (e) => {
if (scale <= 1) return;
dragRef.current = { x: e.clientX, y: e.clientY, px: pan.x, py: pan.y, down: true };
const move = (ev) => {
if (!dragRef.current.down) return;
setPan({
x: dragRef.current.px + (ev.clientX - dragRef.current.x),
y: dragRef.current.py + (ev.clientY - dragRef.current.y),
});
};
const up = () => {
dragRef.current.down = false;
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
// Keyboard nav inside the lightbox: ← → to swap, Esc to close.
React.useEffect(() => {
if (!zoom) return;
const onKey = (e) => {
if (e.key === 'ArrowLeft') prevView();
else if (e.key === 'ArrowRight') nextView();
else if (e.key === 'Escape') setZoom(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [zoom, prevView, nextView]);
// Auto-generated description — same template, figure-specific bits filled in.
const description = React.useMemo(() => {
const heightStr = f.scale === '1/6' ? '~28 см' : f.scale === '1/7' ? '~24 см' : '~21 см';
const toneText = {
lilac: 'нежно-сиреневая палитра и струящаяся ткань платья',
pink: 'розовые тона и воздушный силуэт',
blue: 'прохладная сине-голубая гамма и аккуратные складки',
rose: 'румяные оттенки и романтичный образ',
gold: 'золотистая палитра и сияющие детали',
}[f.tone] || 'мягкие нежные тона и приятный силуэт';
const lineStr = f.line ? `из линейки «${f.line}» ` : '';
const scaleStr = f.scale ? `в масштабе ${f.scale} ` : '';
const makerStr = f.maker ? `от ${f.maker}` : '';
const headerLine = [f.name, ' ', lineStr.trim() ? '— ' + lineStr : '', scaleStr, makerStr].join('').trim().replace(/\s+/g, ' ');
return `${headerLine}. ` +
`${toneText[0].toUpperCase() + toneText.slice(1)}. Высота фигурки ${heightStr}, ` +
`материал — ПВХ и АБС-пластик. Основание входит в комплект.`;
}, [f]);
// mock price history — 8 points. Robust to non-numeric ids (catalog
// items use 'cat-238722' which parseInt would NaN out on).
const priceHistory = React.useMemo(() => {
const idStr = String(f.id || '');
let s = 0;
for (let i = 0; i < idStr.length; i++) s = (s * 31 + idStr.charCodeAt(i)) & 0x7fffffff;
if (!s) s = 1;
const rng = () => { s = (s*1103515245+12345) & 0x7fffffff; return s / 0x7fffffff; };
const base = f.price || 100;
return Array.from({ length: 8 }, (_, i) => ({
m: ['авг','сен','окт','ноя','дек','янв','фев','мар'][i],
p: Math.round(base * (0.85 + rng() * 0.25)),
}));
}, [f]);
const pMin = Math.min(...priceHistory.map(p => p.p));
const pMax = Math.max(...priceHistory.map(p => p.p));
const w = 320, hCh = 90, pad = 6;
const pts = priceHistory.map((p, i) => {
const x = pad + (i / (priceHistory.length - 1)) * (w - pad * 2);
const y = pad + (1 - (p.p - pMin) / (pMax - pMin || 1)) * (hCh - pad * 2);
return { x, y, ...p };
});
const linePath = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
const areaPath = `${linePath} L ${pts[pts.length-1].x} ${hCh} L ${pts[0].x} ${hCh} Z`;
// Figure went away while we're on this screen (e.g. user removed from
// wishlist via the heart and refetch dropped it). Render a soft "back"
// state instead of crashing.
if (!fActual) {
return (
фигурки больше нет в списке
назад
);
}
return (
{/* Header bar */}
{I.back(20)}
{!isOrderItem && (
toggleWishlist(f)} active={isWish}>
{I.heart(18, isWish ? 'currentColor' : 'none')}
)}
{I.share(18)}
{/* Hero — main image + thumbnails */}
{ window.__nyaSwipe = e.touches[0].clientX; }}
onTouchEnd={(e) => {
const x0 = window.__nyaSwipe; if (x0 == null) return;
const dx = e.changedTouches[0].clientX - x0;
if (Math.abs(dx) > 40) (dx > 0 ? prevView : nextView)();
window.__nyaSwipe = null;
}}
>
setZoom(true)}>
{/* prev / next arrows */}
{ e.stopPropagation(); prevView(); }} style={arrowStyle('left')}>
{I.back(20)}
{ e.stopPropagation(); nextView(); }} style={arrowStyle('right')}>
{I.back(20)}
{/* dots indicator */}
{views.map((v, i) => (
))}
{/* thumbnail strip — scrolls horizontally when there are many photos */}
{views.map((v, i) => (
setViewIdx(i)} style={{
padding: 0, border: 0, cursor: 'pointer',
borderRadius: 'var(--radius-sm)', overflow: 'hidden',
boxShadow: v === view
? `0 0 0 2px var(--accent-a), 0 0 14px var(--glow-soft)`
: `0 0 0 1px var(--card-border)`,
transition: 'box-shadow .15s',
background: 'transparent',
flex: '0 0 auto',
width: 'calc((100% - 24px) / 4)', minWidth: 64,
}}>
))}
{/* Title block */}
{f.maker} · {f.line}
{f.name}
{/* Price + relase row */}
цена
{fmt(f.price)}
релиз
{nyaFormatDate(f.release)}
{/* Payment progress */}
{f.status === 'preorder' && (
оплачено
{fmt(f.paid)} / {fmt(f.price)}
{f.payDays > 0 && (
следующий платёж
через {f.payDays} дн
)}
)}
{/* Description */}
описание
{description}
{/* Tags — click any tag to open catalog filtered by it */}
{f.tags && f.tags.length > 0 && (
<>
теги
{f.tags.map(t => (
openCatalogWithTag?.(t)} style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '6px 12px', borderRadius: 9999,
background: 'rgba(255,255,255,.06)',
border: '1px solid var(--card-border)',
color: 'var(--text)',
fontFamily: 'Quicksand, sans-serif',
fontSize: 12, fontWeight: 500,
cursor: 'pointer',
transition: 'all .15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, var(--accent-a), var(--accent-b))';
e.currentTarget.style.color = '#3a1e4d';
e.currentTarget.style.borderColor = 'transparent';
e.currentTarget.style.boxShadow = '0 0 10px var(--glow-soft)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,.06)';
e.currentTarget.style.color = 'var(--text)';
e.currentTarget.style.borderColor = 'var(--card-border)';
e.currentTarget.style.boxShadow = 'none';
}}>
# {t}
))}
>
)}
{/* Price history chart */}
история цены
мин · {fmt(pMin)}
макс · {fmt(pMax)}
{pts.map((p, i) => (
))}
{pts.map((p, i) => {p.m} )}
{/* Details list */}
детали
{[
['производитель', f.maker],
['масштаб', f.scale],
['линейка', f.line],
['материал', 'ПВХ, АБС-пластик'],
['высота', f.scale === '1/6' ? '~28 см' : f.scale === '1/7' ? '~24 см' : '~21 см'],
['магазин', 'AmiAmi'],
].map((row, i, all) => (
{row[0]}
{row[1]}
))}
{/* Actions */}
действия
toast.show('открываю сайт магазина…')}
icon={
}>
открыть на сайте{f.maker ? ' ' + f.maker.toLowerCase() : ''}
setNotesOpen(true)} style={{ marginTop: 8 }}>
мои заметки
{/* Notes modal — the only user-mutable thing on this screen.
Order data itself is parsed automatically from nin-nin-game. */}
setNotesOpen(false)} />
{/* Full-screen lightbox */}
{zoom && (
setZoom(false)}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
style={{
position: 'fixed', inset: 0, zIndex: 200,
background: 'rgba(10,6,24,.92)',
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
display: 'flex', flexDirection: 'column',
borderRadius: 56, overflow: 'hidden',
animation: 'nya-fade-down .2s ease forwards',
}}
>
{/* top bar */}
{f.name}
{viewIdx + 1} / {views.length}
{ e.stopPropagation(); setZoom(false); }} style={{
width: 38, height: 38, borderRadius: '50%', border: 0,
background: 'rgba(255,255,255,.08)', color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}>{I.close(18)}
{/* big image with arrows */}
e.stopPropagation()}
onWheel={onWheel}
onDoubleClick={onDblClick}
onMouseDown={onMouseDown}>
1 ? 'grab' : 'zoom-in',
userSelect: 'none',
}}>
{ e.stopPropagation(); prevView(); }} style={arrowStyle('left')}>
{I.back(22)}
{ e.stopPropagation(); nextView(); }} style={arrowStyle('right')}>
{I.back(22)}
{/* zoom controls */}
{ e.stopPropagation(); setScale(s => Math.min(4, s + 0.5)); }} style={zoomBtnStyle}>+
{ e.stopPropagation(); setScale(1); setPan({x:0,y:0}); }} style={{ ...zoomBtnStyle, fontSize: 11, fontWeight: 600 }}>{Math.round(scale * 100)}%
{ e.stopPropagation(); setScale(s => Math.max(1, s - 0.5)); }} style={zoomBtnStyle}>−
{/* bottom thumbnail strip — horizontally scrollable for galleries > 4 */}
e.stopPropagation()}>
{views.map((v, i) => (
setViewIdx(i)} style={{
padding: 0, border: 0, cursor: 'pointer',
borderRadius: 'var(--radius-sm)', overflow: 'hidden',
boxShadow: i === viewIdx
? `0 0 0 2px var(--accent-a), 0 0 14px var(--glow-soft)`
: `0 0 0 1px rgba(255,255,255,.1)`,
background: 'transparent',
flex: '0 0 auto',
width: 'calc((100% - 24px) / 4)', minWidth: 64,
}}>
))}
)}
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 3. Calendar
// ─────────────────────────────────────────────────────────────────────────────
function CalendarScreen({ go, tweaks }) {
const figures = useFigures();
const fmt = useFormat();
const [openMonth, setOpenMonth] = React.useState('2026-06');
const [filterOpen, setFilterOpen] = React.useState(false);
const [filter, setFilter] = React.useState({
statuses: new Set(),
minAmount: 0,
showPaid: false,
});
const filterCount = filter.statuses.size + (filter.minAmount > 0 ? 1 : 0) + (filter.showPaid ? 1 : 0);
const byMonth = React.useMemo(() => {
const grouped = {};
figures.forEach(f => {
if (f.status === 'delivered') return;
if (!filter.showPaid && (f.status === 'paid' || f.status === 'shipped')) return;
if (filter.statuses.size > 0 && !filter.statuses.has(f.status)) return;
const remaining = f.price - f.paid;
if (remaining < filter.minAmount) return;
const d = new Date(f.release);
const key = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;
if (!grouped[key]) grouped[key] = { key, date: d, items: [], total: 0 };
grouped[key].items.push(f);
grouped[key].total += remaining;
});
return Object.values(grouped).sort((a,b) => a.date - b.date);
}, [filter]);
return (
setFilterOpen(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
height: 36, padding: '0 14px', borderRadius: 9999, border: 0,
background: filterCount > 0
? 'linear-gradient(135deg, var(--accent-a), var(--accent-b))'
: 'rgba(255,255,255,.06)',
color: filterCount > 0 ? '#3a1e4d' : 'var(--text)',
fontSize: 12.5, fontWeight: 600, cursor: 'pointer',
border: '1px solid ' + (filterCount > 0 ? 'transparent' : 'var(--card-border)'),
boxShadow: filterCount > 0 ? '0 0 10px var(--glow-soft)' : 'none',
}}>
{I.filter(14)} фильтр
{filterCount > 0 && (
{filterCount}
)}
}
>календарь
{/* Summary card */}
всего к оплате
{fmt(byMonth.reduce((s, m) => s + m.total, 0))}
{/* Months */}
{byMonth.map(m => {
const isOpen = openMonth === m.key;
return (
setOpenMonth(isOpen ? null : m.key)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '14px 14px', cursor: 'pointer',
}}
>
{RU_MONTHS[m.date.getMonth()]}
{String(m.date.getFullYear()).slice(2)}
{RU_MONTHS_FULL[m.date.getMonth()]}
{m.items.length} {ruPlural(m.items.length, ['позиция', 'позиции', 'позиций'])}
{I.chevDown(16)}
{isOpen && (
{m.items.map(f => (
go('detail', f)} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px', borderRadius: 'var(--radius-sm)',
background: 'rgba(255,255,255,.04)',
cursor: 'pointer',
}}>
{f.name}
{nyaFormatDate(f.release)} · {f.line}
))}
)}
);
})}
setFilterOpen(false)}
value={filter}
onApply={setFilter}
/>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Helper hook — turns a container into a drag-to-scroll horizontal carousel.
// Wheel events also scroll horizontally (no Shift required) — much better UX
// for desktop users than relying on browser-default horizontal scroll.
function useDragScroll() {
const ref = React.useRef(null);
React.useEffect(() => {
const el = ref.current; if (!el) return;
let down = false, sx = 0, sl = 0, moved = false;
const onDown = (e) => {
down = true; moved = false;
sx = e.clientX; sl = el.scrollLeft;
el.style.cursor = 'grabbing'; el.style.userSelect = 'none';
};
const onMove = (e) => {
if (!down) return;
const dx = e.clientX - sx;
if (Math.abs(dx) > 3) moved = true;
el.scrollLeft = sl - dx;
};
const onUp = () => {
down = false; el.style.cursor = 'grab'; el.style.userSelect = '';
};
const onClick = (e) => { if (moved) { e.stopPropagation(); e.preventDefault(); } };
const onWheel = (e) => {
// translate vertical wheel to horizontal scroll (common pattern for h-rails)
if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
el.scrollLeft += e.deltaY;
e.preventDefault();
}
};
el.style.cursor = 'grab';
el.addEventListener('mousedown', onDown);
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
el.addEventListener('click', onClick, true);
el.addEventListener('wheel', onWheel, { passive: false });
return () => {
el.removeEventListener('mousedown', onDown);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
el.removeEventListener('click', onClick, true);
el.removeEventListener('wheel', onWheel);
};
}, []);
return ref;
}
// Visual hint: nothing — just a smooth scrollable container with drag + wheel.
function HScrollRail({ children, padding = '14px 16px' }) {
const ref = useDragScroll();
return (
{children}
);
}
function SearchScreen({ go, tweaks, wishlist, toggleWishlist, tags, setTags }) {
const figures = useFigures();
const [q, setQ] = React.useState('');
const [category, setCategory] = React.useState('all');
const [quick, setQuick] = React.useState(null); // 'just-added' | 'hot-pre' | 'hot' | null
const [sortDesc, setSortDesc] = React.useState(false);
const [filterOpen, setFilterOpen] = React.useState(false);
const [advFilter, setAdvFilter] = React.useState({
priceMin: 0, priceMax: 350,
statuses: new Set(), makers: new Set(), year: 'all',
});
const toggleTag = (t) => setTags(prev => {
const n = new Set(prev);
if (n.has(t)) n.delete(t); else n.add(t);
return n;
});
// Aggregate all tags from the catalog with usage counts → popular tag cloud.
const allTags = React.useMemo(() => {
const counts = {};
figures.forEach(f => f.tags?.forEach(t => { counts[t] = (counts[t] || 0) + 1; }));
return Object.entries(counts).sort((a, b) => b[1] - a[1]);
}, []);
// Tag suggestions while typing — match the input against tag names
const tagSuggestions = React.useMemo(() => {
if (!q || q.length < 1) return [];
const s = q.toLowerCase();
return allTags.filter(([t]) => t.includes(s) && !tags.has(t)).slice(0, 5);
}, [q, allTags, tags]);
// ── Catalog fetching ──
// When the user types a real query (>= 2 chars) or selects one of the
// catalog-style chips, hit /api/catalog/* instead of filtering the
// local figures array. Local filtering stays the default when there's
// nothing to query.
const [debouncedQ, setDebouncedQ] = React.useState('');
const [catalogItems, setCatalogItems] = React.useState([]);
const [catalogLoading, setCatalogLoading] = React.useState(false);
const [catalogPage, setCatalogPage] = React.useState(1);
const [catalogTotalPages, setCatalogTotalPages] = React.useState(1);
const [catalogTotalResults, setCatalogTotalResults] = React.useState(0);
const initData = React.useMemo(() => window.Telegram?.WebApp?.initData || null, []);
React.useEffect(() => {
const id = setTimeout(() => setDebouncedQ(q.trim()), 350);
return () => clearTimeout(id);
}, [q]);
// Catalog tab is ALWAYS catalog mode — user comes here to browse Nin-Nin
// Game, not to filter their own collection. Defaults to "new arrivals"
// when no query/chip is set so the tab is never empty.
const isCatalogMode = !!initData;
// Whenever the filter (query/chip/sort) changes, reset to page 1 so
// the fetch runs a fresh batch instead of appending to a stale set.
React.useEffect(() => { setCatalogPage(1); }, [debouncedQ, quick, sortDesc]);
React.useEffect(() => {
if (!isCatalogMode) {
setCatalogItems([]);
setCatalogLoading(false);
return;
}
let url;
if (debouncedQ.length >= 2) {
url = '/api/catalog/search?q=' + encodeURIComponent(debouncedQ);
} else if (quick === 'hot-pre' || quick === 'hot') {
url = '/api/catalog/hot';
} else {
url = '/api/catalog/new'; // default + 'just-added' chip
}
url += (url.includes('?') ? '&' : '?') + 'page=' + catalogPage;
url += '&sort=price&order=' + (sortDesc ? 'desc' : 'asc');
let cancelled = false;
setCatalogLoading(true);
fetch(url, { headers: { Authorization: 'tma ' + initData } })
.then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
.then(data => {
if (cancelled) return;
const items = Array.isArray(data.items) ? data.items : [];
if (catalogPage === 1) setCatalogItems(items);
else setCatalogItems(prev => [...prev, ...items]);
setCatalogTotalPages(data.total_pages || 1);
setCatalogTotalResults(data.total_results || items.length);
})
.catch(e => {
console.error('catalog fetch failed', e);
if (!cancelled && catalogPage === 1) setCatalogItems([]);
})
.finally(() => { if (!cancelled) setCatalogLoading(false); });
return () => { cancelled = true; };
}, [isCatalogMode, debouncedQ, quick, catalogPage, sortDesc, initData]);
const loadMoreCatalog = React.useCallback(() => {
setCatalogPage(p => p + 1);
}, []);
const hasMoreCatalog = isCatalogMode && catalogPage < catalogTotalPages;
const results = React.useMemo(() => {
const source = isCatalogMode ? (catalogItems || []) : (figures || []);
let r = source.filter(f => {
if (category !== 'all' && f.category !== category) return false;
// local-only filters (tag/text/quick) skipped in catalog mode —
// server already did the search and chip routing
if (!isCatalogMode) {
if (quick) {
const qf = NYA_QUICK_FILTERS.find(x => x.id === quick);
if (qf && !qf.filter(f)) return false;
}
if (tags.size > 0) {
for (const t of tags) if (!(f.tags || []).includes(t)) return false;
}
if (q) {
const s = q.toLowerCase();
const hay = [f.name, f.line, f.maker, ...(f.tags || [])].join(' ').toLowerCase();
if (!hay.includes(s)) return false;
}
}
if (advFilter.priceMin > 0 || advFilter.priceMax < 350) {
if (f.price != null && (f.price < advFilter.priceMin || f.price > advFilter.priceMax)) return false;
}
if (advFilter.statuses.size > 0 && !advFilter.statuses.has(f.status)) return false;
if (advFilter.makers.size > 0 && f.maker && !advFilter.makers.has(f.maker)) return false;
if (advFilter.year !== 'all' && (!f.release || !f.release.startsWith(advFilter.year))) return false;
return true;
});
r.sort((a, b) => sortDesc ? (b.price || 0) - (a.price || 0) : (a.price || 0) - (b.price || 0));
return r;
}, [isCatalogMode, catalogItems, figures, q, category, quick, tags, sortDesc, advFilter]);
const advCount = (advFilter.priceMin > 0 ? 1 : 0)
+ (advFilter.priceMax < 350 ? 1 : 0)
+ advFilter.statuses.size + advFilter.makers.size
+ (advFilter.year !== 'all' ? 1 : 0);
const hasAnyFilter = q || category !== 'all' || quick || tags.size > 0 || advCount > 0;
const clearAll = () => {
setQ(''); setCategory('all'); setQuick(null); setTags(new Set());
setAdvFilter({ priceMin: 0, priceMax: 350, statuses: new Set(), makers: new Set(), year: 'all' });
};
return (
каталог
{/* Tag suggestions dropdown while typing */}
{tagSuggestions.length > 0 && (
{tagSuggestions.map(([t, c]) => (
{ toggleTag(t); setQ(''); }} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', padding: '8px 10px',
background: 'transparent', border: 0, borderRadius: 'var(--radius-sm)',
color: 'var(--text)', fontFamily: 'Quicksand, sans-serif',
fontSize: 13, fontWeight: 500, cursor: 'pointer', textAlign: 'left',
}}>
# {t}
{c}
))}
)}
{/* Quick filter rail — Just added / Hot pre-orders / Hot products */}
подборки
{NYA_QUICK_FILTERS.map(qf => {
const on = quick === qf.id;
return (
setQuick(on ? null : qf.id)} style={{
flexShrink: 0,
padding: '10px 16px', borderRadius: 'var(--radius-sm)',
background: on
? 'linear-gradient(135deg, var(--accent-a), var(--accent-b))'
: 'rgba(255,255,255,.06)',
border: '1px solid ' + (on ? 'transparent' : 'var(--card-border)'),
color: on ? '#3a1e4d' : 'var(--text)',
fontFamily: 'Comfortaa, sans-serif', fontWeight: 600, fontSize: 12.5,
cursor: 'pointer', whiteSpace: 'nowrap',
boxShadow: on ? '0 0 14px var(--glow-soft)' : 'none',
transition: 'all .15s',
}}>{qf.label}
);
})}
{/* Categories — horizontal scroll, icon + label */}
категория
{[{ id: 'all', label: 'все' }, ...NYA_CATEGORIES].map(c => {
const on = category === c.id;
const renderIcon = NYA_CAT_ICONS[c.id] || NYA_CAT_ICONS.all;
return (
setCategory(c.id)} style={{
flexShrink: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 8,
width: 78, height: 76, padding: '8px 4px',
borderRadius: 'var(--radius-sm)',
background: on
? 'rgba(255,158,205,.18)'
: 'rgba(255,255,255,.04)',
border: '1px solid ' + (on ? 'var(--accent-a)' : 'var(--card-border)'),
color: on ? 'var(--accent-a)' : 'var(--text-dim)',
cursor: 'pointer',
boxShadow: on ? '0 0 14px var(--glow-soft)' : 'none',
transition: 'all .15s',
}}>
{renderIcon(22)}
{c.label}
);
})}
{/* Active tags — appear when user added tags from a figure-detail click */}
{tags.size > 0 && (
активные теги
setTags(new Set())} style={{
background: 'transparent', border: 0, padding: 4,
color: 'var(--accent-a)', fontSize: 11, fontWeight: 600, cursor: 'pointer',
}}>сбросить
{[...tags].map(t => (
# {t}
toggleTag(t)} style={{
width: 16, height: 16, borderRadius: '50%', border: 0,
background: 'rgba(58,30,77,.25)', color: '#3a1e4d',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', padding: 0,
}}>{I.close(10)}
))}
)}
{/* Sort + reset row */}
найдено: {isCatalogMode ? (catalogTotalResults || results.length) : results.length}
{hasAnyFilter && (
очистить
)}
setFilterOpen(true)} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 9999, border: 0,
background: advCount > 0
? 'linear-gradient(135deg, var(--accent-a), var(--accent-b))'
: 'rgba(255,255,255,.06)',
color: advCount > 0 ? '#3a1e4d' : 'var(--text)',
fontSize: 12, fontWeight: 600, cursor: 'pointer',
border: '1px solid ' + (advCount > 0 ? 'transparent' : 'var(--card-border)'),
boxShadow: advCount > 0 ? '0 0 10px var(--glow-soft)' : 'none',
}}>
{I.filter(14)} фильтр
{advCount > 0 && (
{advCount}
)}
setSortDesc(s => !s)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: 'transparent', border: 0, padding: 4,
color: 'var(--accent-a)', fontSize: 12, fontWeight: 600,
cursor: 'pointer',
}}>
цена {sortDesc ? '↓' : '↑'}
{catalogLoading && results.length === 0 ? (
) : results.length === 0 ? (
сбросить всё}
/>
) : (
{results.map(f => {
const isWish = (f.product_url && wishlist.has(f.product_url)) || f.wishlist;
return (
go('detail', f)} />
{ e.stopPropagation(); toggleWishlist(f); }} style={{
position: 'absolute', top: 16, right: 16,
width: 30, height: 30, borderRadius: '50%', border: 0,
background: 'rgba(20,12,40,.55)',
backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
color: isWish ? 'var(--accent-a)' : 'rgba(255,255,255,.7)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
boxShadow: isWish ? '0 0 10px var(--glow-strong)' : 'none',
}}>
{I.heart(15, isWish ? 'currentColor' : 'none')}
);
})}
)}
{hasMoreCatalog && results.length > 0 && (
{catalogLoading ? 'загружаем…' : `показать ещё (стр ${catalogPage}/${catalogTotalPages})`}
)}
setFilterOpen(false)}
value={advFilter}
onApply={setAdvFilter}
/>
);
}
// ─────────────────────────────────────────────────────────────────────────────
function WishlistScreen({ go, tweaks, wishlist, toggleWishlist }) {
const fmt = useFormat();
const figures = useFigures();
const items = figures.filter(f => (f.product_url && wishlist.has(f.product_url)) || f.wishlist);
const total = items.reduce((s, f) => s + f.price, 0);
return (
вишлист
{items.length === 0 ? (
go('search')} icon={I.search(14)}>в каталог}
/>
) : (
<>
стоимость мечт
{fmt(total)}
{items.map(f => (
go('detail', f)} />
{ e.stopPropagation(); toggleWishlist(f); }} style={{
position: 'absolute', top: 16, right: 16,
width: 30, height: 30, borderRadius: '50%', border: 0,
background: 'rgba(20,12,40,.55)',
backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
color: 'var(--accent-a)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 0 10px var(--glow-strong)',
}}>
{I.heart(15, 'currentColor')}
))}
>
)}
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 6. Settings
// ─────────────────────────────────────────────────────────────────────────────
function SettingsScreen({ go, tweaks, currency, setCurrency }) {
const figures = useFigures();
const settingsTgEnv = React.useMemo(() => {
const tg = window.Telegram && window.Telegram.WebApp;
return tg && tg.initData ? { inTelegram: true, initData: tg.initData } : { inTelegram: false };
}, []);
const [settingsMembers, setSettingsMembers] = React.useState([]);
React.useEffect(() => {
if (!settingsTgEnv.inTelegram) return;
let cancelled = false;
fetch('/api/workspace/members', { headers: { Authorization: 'tma ' + settingsTgEnv.initData } })
.then(r => r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)))
.then(data => { if (!cancelled) setSettingsMembers(data.members || []); })
.catch(e => console.error('settings.members fetch failed', e));
return () => { cancelled = true; };
}, [settingsTgEnv]);
const _roleLabel = (r) => r === 'owner' ? 'владелец' : r === 'editor' ? 'редактор' : 'наблюдатель';
const [reminders, setReminders] = React.useState(['7d', '1d']);
const [exportOpen, setExportOpen] = React.useState(false);
const [confirmLogout, setConfirmLogout] = React.useState(false);
const toast = useToast();
const tgUser = React.useMemo(() => getTgUser(), []);
const reminderOpts = [
{ id: '14d', label: 'за 14 дн' },
{ id: '7d', label: 'за 7 дн' },
{ id: '3d', label: 'за 3 дн' },
{ id: '1d', label: 'за день' },
];
const currencies = Object.entries(NYA_CURRENCIES).map(([id, c]) => ({ id, label: c.label }));
return (
настройки
{/* Profile */}
go('profile')}>
{tgUser.name}
{tgUser.username ? '@' + tgUser.username : 'без юзернейма'} · {figures.length} {ruPlural(figures.length, ['фигурка', 'фигурки', 'фигурок'])}
go('profile')}>{I.chevRight(16)}
{currencies.map(c => {
const on = currency === c.id;
return (
setCurrency(c.id)} style={{
flex: 1, padding: '10px 4px', borderRadius: 'var(--radius-sm)',
background: on ? 'linear-gradient(135deg, var(--accent-a), var(--accent-b))' : 'rgba(255,255,255,.06)',
border: '1px solid ' + (on ? 'transparent' : 'var(--card-border)'),
color: on ? '#3a1e4d' : 'var(--text)',
fontFamily: 'Quicksand, sans-serif', fontWeight: 600, fontSize: 12,
cursor: 'pointer',
boxShadow: on ? '0 0 14px var(--glow-soft)' : 'none',
}}>{c.label}
);
})}
{reminderOpts.map(r => {
const on = reminders.includes(r.id);
return (
setReminders(s => on ? s.filter(x => x !== r.id) : [...s, r.id])} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 6,
padding: '10px 12px', borderRadius: 'var(--radius-sm)',
background: on ? 'rgba(255,158,205,.16)' : 'rgba(255,255,255,.04)',
border: '1px solid ' + (on ? 'var(--accent-a)' : 'var(--card-border)'),
color: 'var(--text)',
fontFamily: 'Quicksand, sans-serif', fontWeight: 500, fontSize: 12.5,
cursor: 'pointer',
boxShadow: on ? '0 0 10px var(--glow-soft)' : 'none',
}}>
{r.label}
{on && {I.check(14)} }
);
})}
go('notifSettings')} last />
{settingsMembers.length === 0 ? (
загружаем участников…
) : settingsMembers.map(m => {
const myTg = window.Telegram?.WebApp?.initDataUnsafe?.user || {};
const photo = (m.is_me ? (myTg.photo_url || m.photo_url) : m.photo_url) || null;
return (
);
})}
go('members')} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '14px 16px', cursor: 'pointer', color: 'var(--accent-a)',
}}>
{I.plus(16)}
управлять участниками
{I.chevRight(14)}
setExportOpen(true)} />
go('about')} />
setConfirmLogout(true)} />
NyaShelf · v1.0
сделано с любовью
{/* Modals */}
setExportOpen(false)}
onExport={() => toast.show('csv отправлен в telegram', { kind: 'success' })}
/>
{ setConfirmLogout(false); toast.show('вышли из приложения'); }}
onCancel={() => setConfirmLogout(false)}
/>
);
}
function SettingsSection({ label, children }) {
return (
{label}
{children}
);
}
function SettingRow({ label, sub, value, onToggle, last }) {
return (
onToggle(!value)} style={{
position: 'relative', width: 44, height: 26, borderRadius: 9999, border: 0,
background: value ? 'linear-gradient(135deg, var(--accent-a), var(--accent-b))' : 'rgba(255,255,255,.12)',
boxShadow: value ? '0 0 10px var(--glow-soft)' : 'none',
cursor: 'pointer', padding: 0,
transition: 'background .2s',
}}>
);
}
function MemberRow({ name, role, photo, add }) {
if (add) {
return (
);
}
return (
);
}
function SimpleRow({ label, sub, danger, last, onClick }) {
return (
{!danger &&
{I.chevRight(14)} }
);
}
// ─────────────────────────────────────────────────────────────────────────────
// State screens (Loading / Empty / Error)
// ─────────────────────────────────────────────────────────────────────────────
function CatalogSkeleton({ count = 6 }) {
const shimmer = {
background: 'linear-gradient(90deg, rgba(255,255,255,.04) 0%, rgba(200,155,240,.10) 50%, rgba(255,255,255,.04) 100%)',
backgroundSize: '200% 100%',
animation: 'nya-shimmer 1.4s linear infinite',
};
return (
{Array.from({ length: count }, (_, i) => (
))}
);
}
function EmptyState({ title, text, cta }) {
return (
);
}
function LoadingScreen({ tweaks }) {
return (
собираю твои фигурки…
{[0,1,2].map(i => (
))}
);
}
function ErrorState({ onRetry, tweaks }) {
return (
что-то пошло не так
котёнок не смог достать данные. попробуем ещё раз?
повторить
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 7. Notifications
// ─────────────────────────────────────────────────────────────────────────────
const NYA_NOTIFICATIONS = [
{ id: 'n1', kind: 'pay', figureId: 'f6', text: 'оплата через 2 дня', time: 'сейчас', unread: true },
{ id: 'n2', kind: 'price', figureId: 'f9', text: 'цена снизилась на 12%', time: '1 ч назад', unread: true },
{ id: 'n3', kind: 'release', figureId: 'f3', text: 'релиз через неделю', time: 'сегодня', unread: true },
{ id: 'n4', kind: 'ship', figureId: 'f4', text: 'отправлена со склада', time: 'вчера', unread: false },
{ id: 'n5', kind: 'wishlist',figureId: 'f5', text: 'вернулась в наличие', time: '2 дн', unread: false },
{ id: 'n6', kind: 'pay', figureId: 'f1', text: 'платёж принят, спасибо', time: '3 дн', unread: false },
{ id: 'n7', kind: 'release', figureId: 'f8', text: 'релиз перенесли на месяц', time: '5 дн', unread: false },
];
const NOTIF_ICONS = {
pay: (s = 20) => ,
price: (s = 20) => ,
release: (s = 20) => ,
ship: (s = 20) => ,
wishlist: (s = 20) => I.heart(s, 'currentColor'),
};
const NOTIF_COLORS = {
pay: 'linear-gradient(135deg, #ffb3c9, #c89bf0)',
price: 'linear-gradient(135deg, #9ecdff, #c89bf0)',
release: 'linear-gradient(135deg, #f5e6a8, #ffb3c9)',
ship: 'linear-gradient(135deg, #b3f0c9, #9ecdff)',
wishlist: 'linear-gradient(135deg, #ff9ecd, #f5a3c7)',
};
function NotificationsScreen({ go, goBack, tweaks }) {
const figures = useFigures();
const [items, setItems] = React.useState(NYA_NOTIFICATIONS);
const unread = items.filter(n => n.unread).length;
const markAll = () => setItems(prev => prev.map(n => ({ ...n, unread: false })));
// group by section
const groups = React.useMemo(() => {
const today = [], earlier = [];
items.forEach(n => (['сейчас','сегодня','1 ч назад'].includes(n.time) ? today : earlier).push(n));
return [
{ label: 'новые', items: today },
{ label: 'ранее', items: earlier },
].filter(g => g.items.length > 0);
}, [items]);
return (
{/* Header */}
{I.back(20)}
уведомления
{unread > 0 ? `${unread} ${ruPlural(unread, ['новое','новых','новых'])}` : 'всё прочитано'}
{unread > 0 && (
прочитать всё
)}
{items.length === 0 ? (
) : groups.map(g => (
{g.label}
{g.items.map(n => {
const f = figures.find(x => x.id === n.figureId);
const renderIcon = NOTIF_ICONS[n.kind] || NOTIF_ICONS.pay;
return (
{ f && go('detail', f); }}
style={{ display: 'flex', alignItems: 'center', gap: 12 }}
>
{renderIcon(20)}
{f ? f.name : 'NyaShelf'}
{n.text}
{n.time}
{n.unread && (
)}
);
})}
))}
);
}
Object.assign(window, {
FigureCard, DashboardScreen, FigureDetailScreen, CalendarScreen,
SearchScreen, WishlistScreen, SettingsScreen, NotificationsScreen,
EmptyState, LoadingScreen, ErrorState, CatalogSkeleton,
NOTIF_ICONS, NOTIF_COLORS,
});