// 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 = (
);
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 (
);
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 (
);
}
// ── 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,
});