// 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 */} {/* dots indicator */}
{views.map((v, i) => ( ))}
{/* thumbnail strip — scrolls horizontally when there are many photos */}
{views.map((v, i) => ( ))}
{/* 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 => ( ))}
)} {/* 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}
{/* big image with arrows */}
e.stopPropagation()} onWheel={onWheel} onDoubleClick={onDblClick} onMouseDown={onMouseDown}>
1 ? 'grab' : 'zoom-in', userSelect: 'none', }}>
{/* zoom controls */}
{/* bottom thumbnail strip — horizontally scrollable for galleries > 4 */}
e.stopPropagation()}> {views.map((v, i) => ( ))}
)}
); } // ───────────────────────────────────────────────────────────────────────────── // 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, ['позиция', 'позиции', 'позиций'])}
{fmt(m.total)}
{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}
{fmt(f.price - f.paid)}
))}
)}
); })}
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]) => ( ))} )} {/* Quick filter rail — Just added / Hot pre-orders / Hot products */} подборки
{NYA_QUICK_FILTERS.map(qf => { const on = quick === qf.id; return ( ); })} {/* 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 ( ); })} {/* Active tags — appear when user added tags from a figure-detail click */} {tags.size > 0 && (
активные теги
{[...tags].map(t => ( #{t} ))}
)} {/* Sort + reset row */}
найдено: {isCatalogMode ? (catalogTotalResults || results.length) : results.length} {hasAnyFilter && ( )}
{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)} />
); })}
)} {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)} />
))}
)}
); } // ───────────────────────────────────────────────────────────────────────────── // 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 ( ); })}
{reminderOpts.map(r => { const on = reminders.includes(r.id); return ( ); })}
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 (
{label}
{sub &&
{sub}
}
); } function MemberRow({ name, role, photo, add }) { if (add) { return (
{I.plus(16)}
пригласить
); } return (
{name}
{role}
{I.chevRight(14)}
); } function SimpleRow({ label, sub, danger, last, onClick }) { return (
{label}
{sub &&
{sub}
}
{!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 (
{title}
{text}
{cta}
); } 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, });