// nyashelf-atoms.jsx // Atomic UI components: GlassCard, GradientButton, StatusPill, NyaInput, BottomNav, etc. // ─── GlassCard ────────────────────────────────────────────────────────────── function GlassCard({ children, style = {}, glow = false, padding = 14, onClick, active = false }) { return (
{children}
); } // ─── GradientButton ───────────────────────────────────────────────────────── // Pill-shaped pink/lilac gradient button with soft glow. function GradientButton({ children, onClick, style = {}, size = 'md', icon, full = false, disabled = false }) { const h = size === 'sm' ? 36 : size === 'lg' ? 52 : 44; const fs = size === 'sm' ? 13 : size === 'lg' ? 16 : 14.5; return ( ); } // ─── GhostButton ──────────────────────────────────────────────────────────── function GhostButton({ children, onClick, style = {}, size = 'md', icon, full = false }) { const h = size === 'sm' ? 36 : size === 'lg' ? 52 : 44; const fs = size === 'sm' ? 13 : size === 'lg' ? 16 : 14.5; return ( ); } // ─── StatusPill ───────────────────────────────────────────────────────────── const STATUS_DEFS = { available: { label: 'в наличии', dot: '#6fdb9c', bg: 'rgba(111,219,156,.14)' }, preorder: { label: 'предзаказ', dot: '#f5d56e', bg: 'rgba(245,213,110,.14)' }, soon: { label: 'скоро', dot: '#6fb8ff', bg: 'rgba(111,184,255,.14)' }, paid: { label: 'оплачено', dot: '#c89bf0', bg: 'rgba(200,155,240,.14)' }, shipped: { label: 'в пути', dot: '#9ecdff', bg: 'rgba(158,205,255,.14)' }, delivered: { label: 'получено', dot: '#6fdb9c', bg: 'rgba(111,219,156,.14)' }, overdue: { label: 'просрочка', dot: '#ff8aa8', bg: 'rgba(255,138,168,.16)' } }; function StatusPill({ status, size = 'md' }) { const d = STATUS_DEFS[status] || STATUS_DEFS.preorder; const small = size === 'sm'; return ( {d.label} ); } // ─── NyaInput ──────────────────────────────────────────────────────────────── function NyaInput({ value, onChange, placeholder, icon, type = 'text', style = {}, autoFocus = false }) { return (
{icon && {icon}} onChange?.(e.target.value)} placeholder={placeholder} style={{ flex: 1, background: 'transparent', border: 0, outline: 0, color: 'var(--text)', fontFamily: 'Quicksand, sans-serif', fontSize: 14.5, fontWeight: 500, minWidth: 0 }} />
); } // ─── IconChip ─────────────────────────────────────────────────────────────── function IconChip({ children, onClick, active = false, style = {} }) { return ( ); } // ─── Icons (line-style kawaii) ────────────────────────────────────────────── const I = { home: (s = 22) => , calendar: (s = 22) => , search: (s = 22) => , heart: (s = 22, fill = 'none') => , settings: (s = 22) => , back: (s = 22) => , filter: (s = 18) => , plus: (s = 18) => , chevDown: (s = 16) => , chevRight: (s = 16) => , bell: (s = 18) => , share: (s = 18) => , check: (s = 16) => , close: (s = 16) => }; // ─── BottomNav ────────────────────────────────────────────────────────────── function BottomNav({ active, onChange }) { const items = [ { id: 'home', label: 'главная', icon: I.home }, { id: 'calendar', label: 'оплаты', icon: I.calendar }, { id: 'search', label: 'каталог', icon: I.search }, { id: 'wishlist', label: 'вишлист', icon: I.heart }, { id: 'settings', label: 'настройки', icon: I.settings }]; return (
{items.map((it) => { const on = active === it.id; return ( ); })}
); } // ─── Section helpers ─────────────────────────────────────────────────────── function ScreenTitle({ children, subtitle, action }) { return (

{children}

{subtitle &&
{subtitle}
}
{action}
); } function SectionLabel({ children, action }) { return (

{children}

{action}
); } // ─── TgAvatar / TgUser ────────────────────────────────────────────────────── // Read the current Telegram user from window.Telegram.WebApp.initDataUnsafe. // Falls back to a designer-friendly mock when running outside Telegram so the // prototype always shows something sensible. function getTgUser() { const tg = window.Telegram?.WebApp?.initDataUnsafe?.user; if (tg && tg.first_name) { return { name: tg.first_name + (tg.last_name ? ' ' + tg.last_name : ''), username: tg.username || null, photo: tg.photo_url || null, }; } return { name: 'настя', username: 'nastya', photo: null }; } // Circular avatar with optional Telegram photo. Falls back to initial + gradient // in the figure tone hash style if no photo is available. function TgAvatar({ user, size = 56, glow = true, style = {} }) { const u = user || getTgUser(); const initial = (u.name || '?').trim()[0].toUpperCase(); // hue derived from name → consistent personalized gradient let h = 0; for (const c of (u.name || '')) h = (h + c.charCodeAt(0) * 7) % 360; return (
{glow && (
)}
{u.photo ? ( {u.name} { e.currentTarget.style.display = 'none'; }} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} /> ) : ( {initial} )}
); } // ─── PullToRefresh ────────────────────────────────────────────────────────── // Wraps a scroll container's first child. When the user drags down at scrollTop // 0, shows a refresh indicator with the mascot. Releases past threshold trigger // onRefresh(). Works with touch + mouse-drag (the desktop preview case). function PullToRefresh({ onRefresh, children, threshold = 70 }) { const [pull, setPull] = React.useState(0); const [refreshing, setRefreshing] = React.useState(false); const startY = React.useRef(null); const ref = React.useRef(null); const getScroller = () => { let el = ref.current; while (el) { const cs = getComputedStyle(el); if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') return el; el = el.parentElement; } return null; }; const beginDrag = (y) => { if (refreshing) return false; const sc = getScroller(); if (!sc || sc.scrollTop > 0) return false; startY.current = y; return true; }; const moveDrag = (y) => { if (refreshing || startY.current == null) return; const dy = y - startY.current; if (dy <= 0) return; // resistance curve — easier to start pulling, harder past threshold const resisted = Math.pow(dy, 0.85); setPull(Math.min(resisted / threshold, 1.4)); }; const endDrag = async () => { if (refreshing || startY.current == null) return; startY.current = null; if (pull >= 1) { setRefreshing(true); setPull(1); try { await Promise.resolve(onRefresh?.()); } finally { setRefreshing(false); setPull(0); } } else { setPull(0); } }; const onTouchStart = (e) => beginDrag(e.touches[0].clientY); const onTouchMove = (e) => moveDrag(e.touches[0].clientY); const onTouchEnd = () => endDrag(); // Mouse-drag fallback for desktop const onMouseDown = (e) => { if (!beginDrag(e.clientY)) return; const onMove = (ev) => moveDrag(ev.clientY); const onUp = () => { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); endDrag(); }; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }; const indicatorH = refreshing ? 70 : pull * 60; const rot = (pull * 360) % 360; return (
0 ? 'none' : 'height .2s ease', }}>
= 1 || refreshing ? '0 0 14px var(--glow-strong)' : 'none', transition: 'box-shadow .15s', }}> = 1 || refreshing ? 'var(--accent-a)' : 'var(--text-dim)'} opacity={1} />
{children}
); } // ─── Skeletons ────────────────────────────────────────────────────────────── function SkeletonCard({ height = 268, padding = 10 }) { return (
); } function SkeletonLine({ width = '100%', height = 12 }) { return (
); } function SkeletonRow() { return (
); } Object.assign(window, { GlassCard, GradientButton, GhostButton, StatusPill, NyaInput, IconChip, BottomNav, ScreenTitle, SectionLabel, I, STATUS_DEFS, TgAvatar, getTgUser, PullToRefresh, SkeletonCard, SkeletonLine, SkeletonRow, });