// components.jsx — shared UI primitives for the mobile prototype const { useState, useEffect, useRef, useMemo, useCallback } = React; // ── Logo ──────────────────────────────────────────────────────────────── // The NADI YOGA badge stamp. Image already contains the wordmark, lotus + // tree mark and heartbeat motif, so we don't add a separate text label by // default. Pass `withWordmark` to render a side-by-side lockup with a // larger "nadi" type next to it. function Logo({ size = 64, withWordmark = false, color = "var(--ink)", style }) { const mark = ( Nadi Yoga ); if (!withWordmark) { return {mark}; } return ( {mark} nadi ); } // ── Mono micro-label ─────────────────────────────────────────────────── function Mono({ children, color = "var(--ink-soft)", size = 10.5, style }) { return {children}; } // ── Eyebrow ──────────────────────────────────────────────────────────── function Eyebrow({ children, color = "var(--ink-soft)", style }) { return {"\\\\"}{children}; } // ── Buttons (mobile-sized; minimum 48px hit target) ──────────────────── function Button({ children, variant = "primary", size = "md", onClick, style, disabled, fullWidth, type }) { const sizes = { sm: { padding: "10px 14px", fontSize: 12, letterSpacing: "0.06em", minHeight: 36 }, md: { padding: "14px 22px", fontSize: 12.5, letterSpacing: "0.08em", minHeight: 48 }, lg: { padding: "18px 28px", fontSize: 13, letterSpacing: "0.1em", minHeight: 56 }, }; const variants = { primary: { background: "var(--ink)", color: "var(--bg)", border: "1px solid var(--ink)" }, accent: { background: "var(--primary)", color: "var(--primary-ink)", border: "1px solid var(--primary)" }, ghost: { background: "transparent", color: "var(--ink)", border: "1px solid var(--ink)" }, soft: { background: "var(--surface-2)", color: "var(--ink)", border: "1px solid var(--rule)" }, plain: { background: "transparent", color: "var(--ink)", border: "1px solid transparent" }, dangerGhost: { background: "transparent", color: "var(--primary)", border: "1px solid var(--primary)" }, }; return ( ); } // ── Avatar (initials in tinted circle) ───────────────────────────────── function Avatar({ name, initials, tone, size = 32, border, anon }) { if (anon) { return ( ); } return ( {initials || (name || "").slice(0, 2).toUpperCase()} ); } // Stack of friend avatars with overlap. `anonCount` adds a hollow masked // dot to represent friends attending anonymously, plus "+N" for overflow. function FriendStack({ friends, size = 24, max = 4, border = "var(--bg)", anonCount = 0 }) { const arr = friends.slice(0, max); const more = friends.length - arr.length; return ( {arr.map((f, i) => ( ))} {anonCount > 0 && ( )} {more > 0 && ( +{more} )} ); } // ── Breath dot (the brand signature) ─────────────────────────────────── function BreathDot({ size = 12, color = "var(--primary)", duration = 8 }) { return ; } // ── Top bar (per-screen) ─────────────────────────────────────────────── function TopBar({ title, eyebrow, right, onBack, style }) { return (
{onBack && ( )}
{eyebrow &&
{eyebrow}
} {title &&

{title}

}
{right}
); } // ── Bottom Tab Bar ───────────────────────────────────────────────────── const TAB_DEFS = [ { id: "today", label: "Today" }, { id: "book", label: "Book" }, { id: "saved", label: "Saved" }, { id: "me", label: "Me" }, ]; function TabIcon({ id, active }) { const stroke = active ? "var(--ink)" : "var(--ink-soft)"; const fill = "none"; const sw = 1.4; if (id === "today") return ( ); if (id === "book") return ( {active && } ); if (id === "saved") return ( ); if (id === "me") return ( ); return null; } function TabBar({ tab, setTab, badges = {} }) { return ( ); } // ── Bottom Sheet ─────────────────────────────────────────────────────── function Sheet({ open, onClose, children, peek = false, maxHeight = "86%" }) { const [mounted, setMounted] = useState(open); const [closing, setClosing] = useState(false); useEffect(() => { if (open) { setMounted(true); setClosing(false); } else if (mounted) { setClosing(true); const t = setTimeout(() => { setMounted(false); setClosing(false); }, 220); return () => clearTimeout(t); } }, [open]); if (!mounted) return null; return (
{children}
); } // ── Full-screen overlay (for confirmations) ─────────────────────────── function FullCover({ open, children, onClose }) { const [mounted, setMounted] = useState(open); const [closing, setClosing] = useState(false); useEffect(() => { if (open) { setMounted(true); setClosing(false); } else if (mounted) { setClosing(true); const t = setTimeout(() => { setMounted(false); setClosing(false); }, 220); return () => clearTimeout(t); } }, [open]); if (!mounted) return null; return (
{children}
); } // ── Toast ───────────────────────────────────────────────────────────── function Toast({ msg }) { if (!msg) return null; return (
{msg}
); } // ── Inline form field ───────────────────────────────────────────────── function Field({ label, value, onChange, placeholder, type = "text", style }) { return ( ); } // ── Pill/Chip ───────────────────────────────────────────────────────── function Chip({ children, active, onClick, color }) { return ( ); } // ── Stretchy headline that animates on mount ────────────────────────── function StretchHeadline({ children, size = 56, style }) { return (

{children}

); } // ── Tile : the universal cream card with optional accent left rail ──── function Tile({ children, accent, surface = "var(--bg)", border = "var(--rule)", style, onClick }) { const Comp = onClick ? "button" : "div"; return ( {children} ); } // ── Status bar (mock — fakes phone status bar at very top) ─────────── function StatusBar() { return (
14:08 5G
); } function PullToRefresh({ onRefresh, children }) { const { useState, useRef, useEffect } = React; const [pullProgress, setPullProgress] = useState(0); // 0 to 100 const [refreshing, setRefreshing] = useState(false); const containerRef = useRef(null); const touchStart = useRef(0); const isAtTop = useRef(true); useEffect(() => { const el = containerRef.current; if (!el) return; const handleTouchStart = (e) => { isAtTop.current = el.scrollTop === 0; if (isAtTop.current) { touchStart.current = e.touches[0].clientY; } }; const handleTouchMove = (e) => { if (!isAtTop.current) return; const currentY = e.touches[0].clientY; const dy = currentY - touchStart.current; if (dy > 0) { // Dragging down at the top const pull = Math.min(100, dy * 0.4); setPullProgress(pull); if (e.cancelable) e.preventDefault(); } }; const handleTouchEnd = async () => { if (!isAtTop.current) return; if (pullProgress > 60 && !refreshing) { setRefreshing(true); setPullProgress(100); try { if (onRefresh) { await onRefresh(); } else if (window.NADI_REFRESH_DATA) { await window.NADI_REFRESH_DATA(); } } catch (e) { console.error(e); } finally { setRefreshing(false); setPullProgress(0); } } else { setPullProgress(0); } }; el.addEventListener("touchstart", handleTouchStart, { passive: true }); el.addEventListener("touchmove", handleTouchMove, { passive: false }); el.addEventListener("touchend", handleTouchEnd, { passive: true }); return () => { el.removeEventListener("touchstart", handleTouchStart); el.removeEventListener("touchmove", handleTouchMove); el.removeEventListener("touchend", handleTouchEnd); }; }, [pullProgress, refreshing, onRefresh]); const height = refreshing ? 50 : pullProgress * 0.5; return (
0 ? "1px solid var(--rule)" : "none", transition: refreshing ? "height 0.2s ease, opacity 0.2s ease" : "none", overflow: "hidden", flexShrink: 0 }}>
{refreshing ? "refreshing..." : (pullProgress > 60 ? "release to refresh" : "pull to refresh")}
{children}
); } Object.assign(window, { Logo, Mono, Eyebrow, Button, Avatar, FriendStack, BreathDot, TopBar, TabBar, TAB_DEFS, Sheet, FullCover, Toast, Field, Chip, StretchHeadline, Tile, StatusBar, PullToRefresh, });