TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import Link from "next/link";5import { AnimatePresence, motion } from "framer-motion";6import { X } from "lucide-react";7import { cn } from "@/lib/utils";8import { formatSC } from "@spinza/shared";910/* ------------------------------------------------------------------ Button */1112type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "accent" | "outline";13type ButtonSize = "sm" | "md" | "lg" | "xl" | "icon";1415export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {16 variant?: ButtonVariant;17 size?: ButtonSize;18 loading?: boolean;19 href?: string;20}2122const variantClass: Record<ButtonVariant, string> = {23 primary: "bg-fg text-bg hover:bg-white active:scale-[0.98] shadow-[0_10px_30px_-12px_rgba(255,255,255,0.35)]",24 accent: "bg-[linear-gradient(180deg,#e8cf8f,#c9a961)] text-[#1a1406] hover:brightness-110 active:scale-[0.98] shadow-glow",25 secondary: "surface-2 text-fg hover:bg-surface-3",26 outline: "border border-line-2 text-fg hover:bg-surface-2",27 ghost: "text-fg-2 hover:text-fg hover:bg-surface-2",28 danger: "bg-danger/15 text-danger border border-danger/30 hover:bg-danger/25",29};3031const sizeClass: Record<ButtonSize, string> = {32 sm: "h-9 px-3 text-[13px] rounded-sm gap-1.5",33 md: "h-11 px-4 text-sm rounded-md gap-2",34 lg: "h-12 px-6 text-[15px] rounded-md gap-2",35 xl: "h-14 px-8 text-base rounded-lg gap-2.5",36 icon: "h-11 w-11 rounded-md",37};3839export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button({ className, variant = "primary", size = "md", loading, href, children, disabled, ...props }, ref) {40 const cls = cn("inline-flex items-center justify-center font-semibold tracking-tight transition-all duration-200 select-none focus-ring disabled:opacity-50 disabled:pointer-events-none whitespace-nowrap", variantClass[variant], sizeClass[size], className);41 if (href) {42 return (43 <Link href={href} className={cls} aria-disabled={disabled}>44 {children}45 </Link>46 );47 }48 return (49 <button ref={ref} className={cls} disabled={disabled || loading} {...props}>50 {loading ? <Spinner className="h-4 w-4" /> : null}51 {children}52 </button>53 );54});5556export function Spinner({ className }: { className?: string }) {57 return (58 <svg className={cn("animate-spin", className)} viewBox="0 0 24 24" fill="none" aria-hidden>59 <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />60 <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />61 </svg>62 );63}6465/* -------------------------------------------------------------------- Card */6667export function Card({ className, children, as: Tag = "div", ...props }: React.HTMLAttributes<HTMLDivElement> & { as?: React.ElementType }) {68 return (69 <Tag className={cn("surface rounded-lg", className)} {...props}>70 {children}71 </Tag>72 );73}7475/* ------------------------------------------------------------------- Badge */7677export function Badge({ className, tone = "neutral", children }: { className?: string; tone?: "neutral" | "accent" | "success" | "danger" | "info" | "new"; children: React.ReactNode }) {78 const tones = {79 neutral: "bg-surface-2 text-fg-2 border-line",80 accent: "bg-accent-soft text-accent-2 border-accent/30",81 success: "bg-success/10 text-success border-success/30",82 danger: "bg-danger/10 text-danger border-danger/30",83 info: "bg-info/10 text-info border-info/30",84 new: "bg-[#ff5c7a]/15 text-[#ff8aa0] border-[#ff5c7a]/30",85 };86 return <span className={cn("inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider", tones[tone], className)}>{children}</span>;87}8889/* ------------------------------------------------------------------- Input */9091export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement> & { label?: string; hint?: string; error?: string | null }>(function Input({ className, label, hint, error, id, ...props }, ref) {92 const generatedId = React.useId();93 const inputId = id ?? generatedId;94 return (95 <label className="block" htmlFor={inputId}>96 {label ? <span className="mb-1.5 block text-[13px] font-medium text-fg-2">{label}</span> : null}97 <input98 ref={ref}99 id={inputId}100 className={cn(101 "h-12 w-full rounded-md border bg-bg-1 px-4 text-[15px] text-fg placeholder:text-fg-4 transition-colors focus-ring",102 error ? "border-danger/60" : "border-line-2 focus:border-accent/60",103 className,104 )}105 {...props}106 />107 {error ? <span className="mt-1.5 block text-[13px] text-danger">{error}</span> : hint ? <span className="mt-1.5 block text-[13px] text-fg-3">{hint}</span> : null}108 </label>109 );110});111112/* ------------------------------------------------------------------ Switch */113114export function Switch({ checked, onChange, label, description }: { checked: boolean; onChange: (v: boolean) => void; label: string; description?: string }) {115 return (116 <button type="button" role="switch" aria-checked={checked} onClick={() => onChange(!checked)} className="flex w-full items-center justify-between gap-4 rounded-md px-1 py-3 text-left tap focus-ring">117 <span>118 <span className="block text-[15px] font-medium text-fg">{label}</span>119 {description ? <span className="block text-[13px] text-fg-3">{description}</span> : null}120 </span>121 <span className={cn("relative h-7 w-12 shrink-0 rounded-full border transition-colors", checked ? "bg-accent border-accent" : "bg-surface-3 border-line-2")}>122 <span className={cn("absolute top-0.5 h-[22px] w-[22px] rounded-full bg-white shadow transition-transform", checked ? "translate-x-[22px]" : "translate-x-0.5")} />123 </span>124 </button>125 );126}127128/* ---------------------------------------------------------------- Progress */129130export function Progress({ value, max = 1, className, tone = "accent" }: { value: number; max?: number; className?: string; tone?: "accent" | "success" | "info" | "credit" }) {131 const pct = Math.max(0, Math.min(100, (value / (max || 1)) * 100));132 const tones = { accent: "bg-[linear-gradient(90deg,#c9a961,#e8cf8f)]", success: "bg-success", info: "bg-info", credit: "bg-credit" };133 return (134 <div className={cn("h-1.5 w-full overflow-hidden rounded-full bg-surface-3", className)} role="progressbar" aria-valuenow={Math.round(pct)} aria-valuemin={0} aria-valuemax={100}>135 <div className={cn("h-full rounded-full transition-[width] duration-700 ease-out", tones[tone])} style={{ width: `${pct}%` }} />136 </div>137 );138}139140/* -------------------------------------------------------------- Credits */141142export function Credits({ amount, className, size = "md", sign }: { amount: number; className?: string; size?: "sm" | "md" | "lg" | "xl"; sign?: boolean }) {143 const s = { sm: "text-sm", md: "text-base", lg: "text-2xl", xl: "text-4xl sm:text-5xl" }[size];144 return (145 <span className={cn("font-semibold tabular text-credit", s, className)}>146 {sign && amount > 0 ? "+" : ""}147 {formatSC(amount, { unit: false })}148 <span className="ml-1 text-[0.6em] font-bold tracking-wider text-credit/70">SC</span>149 </span>150 );151}152153/* ------------------------------------------------------------------ Sheet */154155export function Sheet({ open, onClose, title, children, side = "bottom", className }: { open: boolean; onClose: () => void; title?: string; children: React.ReactNode; side?: "bottom" | "right" | "center"; className?: string }) {156 React.useEffect(() => {157 if (!open) return;158 const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();159 window.addEventListener("keydown", onKey);160 document.body.style.overflow = "hidden";161 return () => {162 window.removeEventListener("keydown", onKey);163 document.body.style.overflow = "";164 };165 }, [open, onClose]);166 const variants = {167 bottom: { initial: { y: "100%" }, animate: { y: 0 }, exit: { y: "100%" }, cls: "inset-x-0 bottom-0 rounded-t-xl max-h-[88dvh] sm:inset-x-auto sm:left-1/2 sm:-translate-x-1/2 sm:bottom-6 sm:w-[560px] sm:rounded-xl" },168 right: { initial: { x: "100%" }, animate: { x: 0 }, exit: { x: "100%" }, cls: "inset-y-0 right-0 w-full max-w-md" },169 center: { initial: { scale: 0.96, opacity: 0 }, animate: { scale: 1, opacity: 1 }, exit: { scale: 0.96, opacity: 0 }, cls: "left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[calc(100%-2rem)] max-w-lg rounded-xl" },170 }[side];171 return (172 <AnimatePresence>173 {open ? (174 <>175 <motion.div className="fixed inset-0 bg-black/70 backdrop-blur-sm" style={{ zIndex: "var(--z-sheet)" }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} />176 <motion.div177 role="dialog"178 aria-modal179 className={cn("fixed glass overflow-y-auto shadow-2xl", variants.cls, className)}180 style={{ zIndex: "calc(var(--z-sheet) + 1)", paddingBottom: side === "bottom" ? "var(--safe-bottom)" : undefined }}181 initial={variants.initial}182 animate={variants.animate}183 exit={variants.exit}184 transition={{ type: "spring", stiffness: 380, damping: 36 }}185 >186 <div className="flex items-center justify-between px-5 pt-4 pb-2">187 <h2 className="text-lg font-semibold tracking-tight">{title}</h2>188 <button onClick={onClose} className="tap -mr-2 grid place-items-center rounded-md text-fg-3 hover:text-fg focus-ring" aria-label="Close">189 <X className="h-5 w-5" />190 </button>191 </div>192 <div className="px-5 pb-5">{children}</div>193 </motion.div>194 </>195 ) : null}196 </AnimatePresence>197 );198}199200/* ------------------------------------------------------------------- Tabs */201202export function Tabs<T extends string>({ value, onChange, items, className }: { value: T; onChange: (v: T) => void; items: { value: T; label: string }[]; className?: string }) {203 return (204 <div className={cn("inline-flex rounded-md bg-surface p-1 border border-line", className)} role="tablist">205 {items.map((it) => (206 <button key={it.value} role="tab" aria-selected={value === it.value} onClick={() => onChange(it.value)} className={cn("h-9 rounded-sm px-3.5 text-[13px] font-semibold transition-colors focus-ring", value === it.value ? "bg-surface-3 text-fg shadow" : "text-fg-3 hover:text-fg-2")}>207 {it.label}208 </button>209 ))}210 </div>211 );212}213214/* ------------------------------------------------------------- Empty state */215216export function Empty({ title, description, action, icon }: { title: string; description?: string; action?: React.ReactNode; icon?: React.ReactNode }) {217 return (218 <div className="surface rounded-lg px-6 py-12 text-center">219 {icon ? <div className="mx-auto mb-4 grid h-12 w-12 place-items-center rounded-full bg-surface-2 text-fg-3">{icon}</div> : null}220 <h3 className="text-lg font-semibold tracking-tight">{title}</h3>221 {description ? <p className="mx-auto mt-1.5 max-w-sm text-sm text-fg-3">{description}</p> : null}222 {action ? <div className="mt-5">{action}</div> : null}223 </div>224 );225}226227/* --------------------------------------------------------------- Skeleton */228229export function Skeleton({ className }: { className?: string }) {230 return <div className={cn("animate-pulse-soft rounded-md bg-surface-2", className)} />;231}232233/* ------------------------------------------------------------ SectionHead */234235export function SectionHead({ title, eyebrow, action, className }: { title: string; eyebrow?: string; action?: React.ReactNode; className?: string }) {236 return (237 <div className={cn("mb-4 flex items-end justify-between gap-4", className)}>238 <div>239 {eyebrow ? <div className="eyebrow mb-1">{eyebrow}</div> : null}240 <h2 className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</h2>241 </div>242 {action}243 </div>244 );245}246247export { motion, AnimatePresence };248