// 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 ? (

{ 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,
});