SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
11.9 KB · 248 lines tsx
Raw Blame History
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