// 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 */}
{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 (
onChange(k)} title={p.label} style={{
position: 'relative',
padding: 0, border: 0, cursor: 'pointer',
height: 52, borderRadius: 7,
background: `linear-gradient(160deg, ${p.bg2} 0%, ${p.bg0} 100%)`,
boxShadow: on
? `0 0 0 1.5px ${p.accentA}, 0 4px 12px rgba(0,0,0,.25)`
: `0 0 0 .5px rgba(0,0,0,.15), 0 1px 3px rgba(0,0,0,.1)`,
overflow: 'hidden',
}}>
{on && (
)}
);
})}
);
}
// ─────────────────────────────────────────────────────────────────────────────
// 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( );