// components.jsx — INDIECASH shared atoms // All components consume theme + accent via props (no global context — keeps // JSX scoping simple across multiple Babel files). // ─── Icon ──────────────────────────────────────────────────────── // Wrapper around Lucide. Re-runs createIcons after mount so newly-rendered // stubs get swapped for SVG. size in px, stroke in px. function Icon({ name, size = 20, color = 'currentColor', stroke = 1.75, style }) { const ref = React.useRef(null); React.useEffect(() => { if (window.lucide && ref.current) window.lucide.createIcons({ attrs: { 'stroke-width': stroke, width: size, height: size }, // limit scope so we don't keep re-rendering icons elsewhere nameAttr: 'data-lucide', }); }, [name, size, stroke]); return ( ); } // ─── INDIECASH logo badge ──────────────────────────────────────── // Circular emerald badge with stylized $ glyph — matches INDIE Group // circular-badge vocabulary. size=mark only; wordmark is a separate component. function CashBadge({ size = 64, accent }) { const a = accent || PALETTES.emerald; return ( {/* stylized $ — vertical bar + S curve, all white */} ); } function CashWordmark({ size = 22, color = 'currentColor' }) { // ALL-CAPS condensed display: matches INDIE Group wordmark voice. return ( INDIE·CASH ); } // ─── Card ──────────────────────────────────────────────────────── function Card({ theme, children, style, onClick, padding = 16, radius = 18, hoverable = false }) { return (
{children}
); } // ─── Avatar ────────────────────────────────────────────────────── // Solid disc with initials. Stable hue per member (passed in). function Avatar({ member, size = 36, ring, style }) { if (!member) return null; if (!member.name && !member.short && !member.full_name) member = Object.assign({}, member, {name:'?'}); if (!member) return null; return (
{memberInitials(member.name || member.full_name || member.short || '?')}
); } // Stacked avatars (-8px overlap) for shared-goal contributors etc. function AvatarStack({ members, size = 24, max = 4, ringColor = '#fff' }) { const shown = members.slice(0, max); const extra = members.length - shown.length; return (
{shown.map((m, i) => (
))} {extra > 0 && (
+{extra}
)}
); } // ─── Category disc ─────────────────────────────────────────────── // Soft tinted disc + colored stroke icon. Used in transaction list. function CatDisc({ cat, size = 38 }) { if (!cat) return null; return (
); } // ─── Chip / Pill ───────────────────────────────────────────────── function Chip({ children, active, onClick, theme, style }) { return ( ); } // ─── Badge ─────────────────────────────────────────────────────── function Badge({ children, color = 'neutral', theme, style }) { const tones = { neutral: { bg: theme.surfaceAlt, fg: theme.fg2 }, success: { bg: theme.successSoft, fg: theme.success }, danger: { bg: theme.dangerSoft, fg: theme.danger }, warning: { bg: theme.warningSoft, fg: theme.warning }, accent: { bg: theme.accent.tint, fg: theme.accent[600] }, }; const t = tones[color] || tones.neutral; return ( {children} ); } // ─── Progress bar ──────────────────────────────────────────────── // Color shifts by pct — green < 70 < amber < 90 < red. function ProgressBar({ value, max, theme, height = 6, color, showValues }) { const pct = Math.min(100, (value / max) * 100); const auto = pct >= 100 ? theme.danger : pct >= 90 ? theme.danger : pct >= 75 ? theme.warning : theme.accent[500]; const fill = color || auto; return (
); } // ─── Section header ───────────────────────────────────────────── function SectionHeader({ title, action, onAction, theme, style }) { return (

{title}

{action && ( )}
); } // ─── 7-day area chart (SVG) ───────────────────────────────────── function AreaChart7d({ data, labels, theme, accent, height = 100, width = 350 }) { const max = Math.max(...data, 1); const n = data.length; const padX = 8, padY = 10; const innerW = width - padX * 2; const innerH = height - padY * 2; const x = (i) => padX + (innerW * i) / (n - 1); const y = (v) => padY + innerH - (v / max) * innerH; // smooth path via catmull-rom → cubic let path = `M ${x(0)} ${y(data[0])}`; for (let i = 0; i < n - 1; i++) { const x1 = x(i), y1 = y(data[i]); const x2 = x(i + 1), y2 = y(data[i + 1]); const cx = (x1 + x2) / 2; path += ` C ${cx} ${y1}, ${cx} ${y2}, ${x2} ${y2}`; } const area = path + ` L ${x(n - 1)} ${padY + innerH} L ${x(0)} ${padY + innerH} Z`; // index of today (last point) — highlight dot const last = n - 1; const gid = `g-${accent[500].replace('#','')}`; return (
{/* hairline baseline */} {/* today dot */}
{labels.map((l, i) => ( {l} ))}
); } // ─── Quick-action button (Pemasukan / Pengeluaran on dashboard) ─ function QuickAction({ kind, label, accent, theme, onClick }) { // income → accent gradient | expense → outlined neutral const isIncome = kind === 'income'; return ( ); } // ─── Bottom-sheet shell (used by Transaction form, etc.) ──────── function BottomSheet({ open, onClose, theme, children, height = '85%', maxHeight = 720 }) { const [mounted, setMounted] = React.useState(open); const [visible, setVisible] = React.useState(open); React.useEffect(() => { if (open) { setMounted(true); // double-rAF so the transform transition runs from initial state requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true))); } else { setVisible(false); const t = setTimeout(() => setMounted(false), 240); return () => clearTimeout(t); } }, [open]); if (!mounted) return null; return (
{/* scrim */}
{/* sheet */}
{/* grabber */}
{children}
); } // ─── Empty state ──────────────────────────────────────────────── function EmptyState({ icon = 'inbox', title, hint, theme }) { return (
{title}
{hint &&
{hint}
}
); } Object.assign(window, { Icon, CashBadge, CashWordmark, Card, Avatar, AvatarStack, CatDisc, Chip, Badge, ProgressBar, SectionHeader, AreaChart7d, QuickAction, BottomSheet, EmptyState, });