// Shell components — header, bottom nav, glass card, sparkline, switch, primitives. function MiniAppHeader({ title, subtitle, onClose, onBack, right }) { return (
{onBack ? (
) : (
{/* glossy diagonal sheen */}
{/* portal swirl */} {/* sparkle */}
)}
{title}
{subtitle &&
{subtitle}
}
{right}
); } const TABS = [ { id: 'market', icon: 'grid' }, { id: 'wallet', icon: 'wallet' }, { id: 'mine', icon: 'gift' }, { id: 'profile', icon: 'user' }, ]; // Simple i18n — RU is default, EN is provided where it matters. const LangContext = React.createContext({ lang: 'ru', setLang: () => {} }); const I18N = { tab_market: { ru: 'Маркет', en: 'Market' }, tab_wallet: { ru: 'Кошелёк', en: 'Wallet' }, tab_mine: { ru: 'Мои', en: 'My gifts' }, tab_profile: { ru: 'Профиль', en: 'Profile' }, title_market: { ru: 'vMarket', en: 'vMarket' }, sub_market: { ru: 'Маркет подарков', en: 'Gift market' }, title_wallet: { ru: 'Кошелёк', en: 'Wallet' }, sub_wallet: { ru: 'Пополнение и история', en: 'Top up & history' }, title_mine: { ru: 'Мои подарки', en: 'My gifts' }, title_profile:{ ru: 'Профиль', en: 'Profile' }, }; function useT() { const { lang } = React.useContext(LangContext); return (key) => (I18N[key] && I18N[key][lang]) || key; } function BottomNav({ active, onChange, platform }) { const t = useT(); const activeIdx = Math.max(0, TABS.findIndex(x => x.id === active)); return (
{TABS.map(x => { const I = Icons[x.icon]; return (
onChange(x.id)}> {t('tab_' + x.id)}
); })}
); } function GlassCard({ children, style, className = '', strong = false, ...rest }) { return (
{children}
); } // Animated screen content wrapper function ScreenWrap({ children }) { return
{children}
; } // ───────── tab-switch loading skeleton ───────── function SkBlock({ w = '100%', h = 12, r = 10, style }) { return
; } // Screen-aware skeleton shown for a beat while a tab "loads". function ScreenSkeleton({ tab }) { if (tab === 'market' || tab === 'mine') { return (
{tab === 'market' ? <>
: }
{Array.from({ length: 6 }).map((_, i) => (
))}
); } if (tab === 'wallet') { return (
); } // profile return (
); } // Number that animates from prev value to new value function AnimatedNumber({ value, fmt = (v) => v, duration = 1100, className = '', style = {} }) { const v = useAnimatedNumber(value, duration); return {fmt(v)}; } // Count-up number — eases from 0 → value, replays whenever `trigger` changes. // Used for the wallet balance "refresh" flourish. function CountUp({ value, duration = 900, fmt = (v) => Math.round(v), trigger = 0, className = '', style = {} }) { const [v, setV] = React.useState(0); const raf = React.useRef(0); React.useEffect(() => { const start = performance.now(); const tick = (t) => { const p = Math.min(1, (t - start) / duration); const e = 1 - Math.pow(1 - p, 3); setV(value * e); if (p < 1) raf.current = requestAnimationFrame(tick); else setV(value); }; cancelAnimationFrame(raf.current); raf.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf.current); }, [trigger, value, duration]); return {fmt(v)}; } // Toggle switch function Switch({ on, onChange }) { return
onChange(!on)} />; } // Section header function SectionHeader({ title, action, onAction, right }) { return (
{title}
{right} {action && (
{action}
)}
); } // Status dot with text function StatusPill({ on, label }) { return (
{label}
); } // Tier badge function TierBadge({ tier, mini = false, label }) { const t = TIER_COLOR[tier] || TIER_COLOR.common; return (
{label || t.name}
); } // Confetti burst (12 particles emitting from center) function Confetti() { const colors = ['#ff6b9d','#fde047','#60a5fa','#a78bfa','#34d399','#fb923c']; return (
{Array.from({length: 28}).map((_, i) => { const angle = (i / 28) * Math.PI * 2; const dist = 80 + Math.random() * 120; const dx = Math.cos(angle) * dist; const dy = Math.sin(angle) * dist; const c = colors[i % colors.length]; const size = 6 + Math.random() * 6; return ( 0.5 ? '50%' : '2px', animation: `confettiBurst 1.2s cubic-bezier(0.1, 0.5, 0.3, 1) forwards`, '--dx': dx + 'px', '--dy': dy + 'px', '--rot': (Math.random() * 720 - 360) + 'deg', }}/> ); })}
); } // Tiny helpers so any descendant of SheetShell can trigger the animated // close instead of calling raw onClose (which would yank the sheet without // the slide-down animation). is the round header button; // {close => …} hands the closer to a // caller (use on Cancel/Done buttons that should also animate down). function SheetCloseX({ size = 16, className = 'miniapp-icon-btn', style }) { const close = React.useContext(SheetCloseContext); return (
); } function WithSheetClose({ children }) { const close = React.useContext(SheetCloseContext); return children(close); } Object.assign(window, { SheetCloseX, WithSheetClose }); // ───────── SheetShell ───────── // Bottom-sheet wrapper with iOS-style drag-to-dismiss + close animation. // Drag the grip down; release past 20% of sheet height to dismiss, otherwise snaps back. // Closing via backdrop tap or onClose also animates down. // Children can request an animated close via the SheetCloseContext. const SheetCloseContext = React.createContext(() => {}); function SheetShell({ onClose, children, hideGrip = false, dismissable = true }) { const [opened, setOpened] = React.useState(false); const [closing, setClosing] = React.useState(false); const [drag, setDrag] = React.useState(0); const [dragging, setDragging] = React.useState(false); const startYRef = React.useRef(0); const sheetRef = React.useRef(null); // Scroll-lock фона (body.sheet-open) теперь ВЛАДЕЕТ App — он ставит/снимает // класс авторитетно, из своего состояния открытых модалок (см. app.jsx, // effect на `anySheet`). Раньше каждый SheetShell дёргал body.classList сам // (add на mount, снятие в rAF по факту отсутствия .sheet-backdrop) — при // быстрых open/close (карточка→оплата, drag-dismiss) это рассинхронивалось, // 'sheet-open' залипал и `.screen-scroll` навсегда оставался overflow:hidden, // пока не перезайдёшь в раздел. Единый владелец в App убирает гонку целиком. React.useEffect(() => { const id = requestAnimationFrame(() => setOpened(true)); return () => cancelAnimationFrame(id); }, []); const closeNow = React.useCallback(() => { if (closing) return; setClosing(true); setTimeout(onClose, 320); }, [closing, onClose]); const onDown = (e) => { if (!dismissable) return; setDragging(true); startYRef.current = e.clientY; try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {} }; const onMove = (e) => { if (!dragging) return; const dy = e.clientY - startYRef.current; setDrag(Math.max(0, dy)); }; const onUp = () => { if (!dragging) return; setDragging(false); const h = sheetRef.current?.offsetHeight || 600; // Light pull dismisses — drag past 20% of the sheet height closes it, // otherwise it springs back up. Closes easily on a small swipe. if (drag > h * 0.2) closeNow(); else setDrag(0); }; const transform = closing || !opened ? 'translateY(100%)' : `translateY(${drag}px)`; const transition = dragging ? 'none' : 'transform 0.36s cubic-bezier(0.2, 0.7, 0.2, 1)'; return (
e.stopPropagation()} style={{ transform, transition, willChange: 'transform' }}> {!hideGrip && (
)} {children}
); } Object.assign(window, { MiniAppHeader, BottomNav, GlassCard, ScreenWrap, AnimatedNumber, Switch, SectionHeader, StatusPill, TierBadge, Confetti, SheetShell, SheetCloseContext, LangContext, useT, TABS, SkBlock, ScreenSkeleton, CountUp, });