// nyashelf-app.jsx // Top-level app: phone frame + screen router + tweaks panel + keyframes. const NYA_KEYFRAMES = ` @keyframes nya-twinkle { 0%, 100% { opacity: var(--start, .35); } 50% { opacity: 1; } } @keyframes nya-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-6px); } } @keyframes nya-bounce { 0%, 100% { transform: translateY(0); opacity: .4; } 50% { transform: translateY(-6px); opacity: 1; } } @keyframes nya-fade-down { 0% { opacity: 0; transform: translateY(-6px); } 100% { opacity: 1; transform: translateY(0); } } @keyframes nya-fade-in { 0% { opacity: 0; } 100% { opacity: 1; } } @keyframes nya-slide-up { 0% { transform: translateY(100%); } 100% { transform: translateY(0); } } @keyframes nya-toast-in { 0% { opacity: 0; transform: translateY(20px) scale(.95); } 100% { opacity: 1; transform: translateY(0) scale(1); } } @keyframes nya-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } @keyframes nya-spin { 100% { transform: rotate(360deg); } } @keyframes nya-petal-fall { 0% { transform: translateY(-10px) rotate(0deg); opacity: 0; } 10% { opacity: 1; } 100% { transform: translateY(800px) rotate(360deg); opacity: 0; } } /* Animations-off variant: disable everything inside .nya-anims-off */ .nya-anims-off * { animation: none !important; transition: none !important; } /* nicer scrollbar inside the phone */ .nya-scroll::-webkit-scrollbar { width: 0; height: 0; } .nya-scroll { scrollbar-width: none; } /* status bar tweak for ghost cat icon row */ .nya-status-bar { position: absolute; top: 0; left: 0; right: 0; height: 47px; display: flex; align-items: center; justify-content: space-between; padding: 0 28px; z-index: 50; font-family: -apple-system, system-ui; color: #fff; } `; function PhoneFrame({ tweaks, children, nav, fullscreen = false }) { const bg = ` radial-gradient(ellipse 120% 80% at 80% 20%, ${getPaletteValue(tweaks.palette, 'bg2')}55 0%, transparent 50%), radial-gradient(ellipse 100% 70% at 20% 70%, ${getPaletteValue(tweaks.palette, 'bg0')}66 0%, transparent 50%), linear-gradient(170deg, ${getPaletteValue(tweaks.palette, 'bg1')} 0%, ${getPaletteValue(tweaks.palette, 'bg0')} 50%, ${getPaletteValue(tweaks.palette, 'bg1')} 100%) `; if (fullscreen) { // Telegram WebApp mode — content fills the whole viewport. No fake // iOS bezel / status bar / dynamic island; Telegram already provides those. return (
{tweaks.animations && tweaks.decor > 20 && ( )}
{children}
{nav}
); } return (
{/* dynamic island */}
{/* status bar */}
9:41
{tweaks.animations && tweaks.decor > 20 && }
{children}
{nav}
); } function getPaletteValue(palKey, k) { const p = NYA_PALETTES[palKey] || NYA_PALETTES.night; return ({ bg0: p.bg0, bg1: p.bg1, bg2: p.bg2, glow: p.glow })[k]; } // Drifting petal/sparkle layer — full-phone, ambient function DriftingPetals({ palette, density }) { const items = React.useMemo(() => { const count = Math.round(4 + density / 20); let s = 13; const rng = () => { s = (s*1103515245+12345) & 0x7fffffff; return s / 0x7fffffff; }; return Array.from({ length: count }, (_, i) => ({ i, x: rng() * 100, dur: 14 + rng() * 14, delay: -rng() * 20, size: 8 + rng() * 8, kind: rng() > 0.5 ? 'petal' : 'sparkle', })); }, [density]); const col = palette === 'sakura' ? '#ffd9eb' : palette === 'moonlit' ? '#c4e4ff' : palette === 'twilight' ? '#c89bf0' : '#ffb3d9'; return (
{items.map(it => (
{it.kind === 'petal' ? : }
))}
); } // ───────────────────────────────────────────────────────────────────────────── // Custom palette picker — visual grid of swatches function PalettePicker({ value, onChange }) { const keys = Object.keys(NYA_PALETTES); return (
{keys.map(k => { const p = NYA_PALETTES[k]; const on = value === k; return ( ); })}
); } // ───────────────────────────────────────────────────────────────────────────── // Router // ───────────────────────────────────────────────────────────────────────────── // Detect Telegram WebApp environment. Sets up the SDK and returns true // when we're really inside Telegram (so we can hide dev-only chrome and // authenticate API calls with initData). function useTelegramWebApp() { return React.useMemo(() => { const tg = typeof window !== 'undefined' && window.Telegram && window.Telegram.WebApp; if (!tg || !tg.initData) { return { inTelegram: false, initData: null, tg: null }; } try { tg.ready(); } catch (_) {} try { tg.expand(); } catch (_) {} // Stop the swipe-down-to-close gesture from yanking the Telegram // chrome along — without this, every scroll attempt drags the // WebApp header down with the page. try { tg.disableVerticalSwipes && tg.disableVerticalSwipes(); } catch (_) {} return { inTelegram: true, initData: tg.initData, tg }; }, []); } function App() { const tgEnv = useTelegramWebApp(); const [t, setTweak] = useTweaks(window.TWEAK_DEFAULTS); const [stack, setStack] = React.useState([{ screen: 'home', figureId: null }]); const route = stack[stack.length - 1]; const [appState, setAppState] = React.useState(tgEnv.inTelegram ? 'loading' : 'ready'); // 'loading' | 'ready' | 'error' const [currency, setCurrency] = React.useState('eur'); const [searchTags, setSearchTags] = React.useState(new Set()); const [figures, setFigures] = React.useState(NYA_FIGURES); const [stats, setStats] = React.useState(null); // Wishlist is tracked by product URL (not figure id) so it survives // catalog→wishlist transitions: a catalog item has id "cat-" but the // backend stores it as "wish" after POST. The URL is the // common key across both shapes. const [wishlist, setWishlist] = React.useState(() => { const s = new Set(); NYA_FIGURES.forEach(f => f.wishlist && f.product_url && s.add(f.product_url)); return s; }); // Track first-load so the loading screen has a minimum visible time // on cold start but subsequent refetches (after wishlist toggles, etc.) // resolve as fast as possible. const splashShownRef = React.useRef(false); const refetchFigures = React.useCallback(async () => { if (!tgEnv.inTelegram) return; const MIN_SPLASH_MS = 900; const start = Date.now(); try { const r = await fetch('/api/figures', { headers: { Authorization: 'tma ' + tgEnv.initData }, }); if (!r.ok) throw new Error('HTTP ' + r.status); const data = await r.json(); if (Array.isArray(data.figures)) { setFigures(data.figures); const urls = new Set(); data.figures.forEach(f => f.wishlist && f.product_url && urls.add(f.product_url)); setWishlist(urls); } if (data.stats) setStats(data.stats); if (!splashShownRef.current) { const wait = Math.max(0, MIN_SPLASH_MS - (Date.now() - start)); if (wait > 0) await new Promise(r => setTimeout(r, wait)); splashShownRef.current = true; } setAppState('ready'); } catch (e) { console.error('figures fetch failed', e); if (!splashShownRef.current) { const wait = Math.max(0, MIN_SPLASH_MS - (Date.now() - start)); if (wait > 0) await new Promise(r => setTimeout(r, wait)); splashShownRef.current = true; } setAppState('error'); } }, [tgEnv.inTelegram, tgEnv.initData]); // Pull live data from the backend when we're running inside Telegram. // In a plain browser (design preview) we keep the mock NYA_FIGURES. React.useEffect(() => { refetchFigures(); }, [refetchFigures]); // scroll-to-top on every navigation const scrollTop = () => setTimeout(() => { const el = document.querySelector('.nya-scroll'); if (el) el.scrollTop = 0; }, 0); // Bottom-nav tab switch — replaces the stack so back doesn't chain through tabs const goTab = React.useCallback((screen) => { setStack([{ screen, figureId: null }]); scrollTop(); }, []); // Push a sub-screen onto the history stack. Second arg can be either // a figure id (string) or the whole figure object — passing the object // lets the detail screen render catalog items that aren't in figures yet. const go = React.useCallback((screen, figureIdOrObj) => { const isObj = figureIdOrObj && typeof figureIdOrObj === 'object'; const figureId = isObj ? figureIdOrObj.id : (figureIdOrObj || null); const figureData = isObj ? figureIdOrObj : null; setStack(s => [...s, { screen, figureId, figureData }]); scrollTop(); }, []); // Pop the stack. If only the root is left, fall back to home. const goBack = React.useCallback(() => { setStack(s => { if (s.length > 1) { scrollTop(); return s.slice(0, -1); } scrollTop(); return [{ screen: 'home', figureId: null }]; }); }, []); const toggleWishlist = React.useCallback(async (figureOrId) => { // Accept either a full figure object (preferred) or just an id // (legacy callers) — we still need a product_url to call the API. let figure = (typeof figureOrId === 'object' && figureOrId) ? figureOrId : figures.find(f => f.id === figureOrId); if (!figure || !figure.product_url) return; const url = figure.product_url; const wasIn = wishlist.has(url); // Optimistic toggle so the heart flips immediately. setWishlist(prev => { const next = new Set(prev); if (wasIn) next.delete(url); else next.add(url); return next; }); if (!tgEnv.inTelegram) return; // dev mode — local state only const revert = () => setWishlist(prev => { const next = new Set(prev); if (wasIn) next.add(url); else next.delete(url); return next; }); const tgAlert = (msg) => { try { window.Telegram?.WebApp?.showAlert?.(msg); } catch (_) {} }; try { if (wasIn) { const cur = figures.find(f => f.product_url === url); const trackedId = cur && cur.tracked_id; if (trackedId) { const r = await fetch('/api/wishlist/' + trackedId, { method: 'DELETE', headers: { Authorization: 'tma ' + tgEnv.initData }, }); if (!r.ok) throw new Error('HTTP ' + r.status); } } else { const r = await fetch('/api/wishlist', { method: 'POST', headers: { Authorization: 'tma ' + tgEnv.initData, 'Content-Type': 'application/json', }, body: JSON.stringify({ product_url: url, name: figure.name || null, image_url: figure.image_url || null, }), }); if (r.status === 409) { // Already part of an order — not a real error; just inform. let detail = 'товар уже в твоих заказах'; try { const j = await r.json(); detail = j.detail || detail; } catch (_) {} tgAlert(detail); revert(); return; } if (!r.ok) throw new Error('HTTP ' + r.status); } // Server is the source of truth for figure list + flags. await refetchFigures(); } catch (e) { console.error('wishlist toggle failed', e); tgAlert('Не получилось обновить вишлист — попробуй ещё раз'); revert(); } }, [wishlist, figures, tgEnv, refetchFigures]); const themeStyle = buildThemeStyle(t); const showLoading = () => { setAppState('loading'); setTimeout(() => setAppState('ready'), 2500); }; const showError = () => setAppState('error'); // Click a tag anywhere → jump to catalog with it pre-filtered const openCatalogWithTag = React.useCallback((tag) => { setSearchTags(prev => { const n = new Set(prev); n.add(tag); return n; }); go('search'); }, [go]); const renderScreen = () => { if (appState === 'loading') return ; if (appState === 'error') return setAppState('ready')} />; const s = route.screen; if (s === 'home') return ; if (s === 'detail') return ; if (s === 'calendar') return ; if (s === 'search') return ; if (s === 'wishlist') return ; if (s === 'settings') return ; if (s === 'notifications') return ; if (s === 'profile') return ; if (s === 'notifSettings') return ; if (s === 'members') return ; if (s === 'about') return ; return ; }; const navActive = ({ home: 'home', detail: 'home', calendar: 'calendar', search: 'search', wishlist: 'wishlist', settings: 'settings', profile: 'settings', notifSettings: 'settings', members: 'settings', about: 'settings' })[route.screen]; return (
{tgEnv.inTelegram ? ( goTab(id)} /> : null} >
{renderScreen()}
) : (
goTab(id)} /> : null} >
{renderScreen()}
)} {/* Dev-only tweaks panel: hidden when running inside Telegram. */} {!tgEnv.inTelegram && ( setTweak('palette', v)} /> setTweak('glow', v)} /> setTweak('decor', v)} /> setTweak('radius', v)} /> setTweak('animations', v)} /> )}
); } // Re-export with the proper value for TweakColor — that control accepts arrays directly // We need to set the active value as the array, not the string. Override: read current // palette and pass as colors array. ReactDOM.createRoot(document.getElementById('root')).render();