// Shell components — header, bottom nav, glass card, sparkline, switch, primitives.
function MiniAppHeader({ title, subtitle, onClose, onBack, right }) {
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 (
);
}
// 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,
});