"use client";
import React, { FC, ReactNode, forwardRef } from "react";
import { CloseCircle, MoreCircle } from "iconsax-react";
import Link from "next/link";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/lagos/ui/button";
// Types
export type CardVariant =
| "default"
| "primary"
| "secondary"
| "success"
| "warning"
| "error"
| "info"
| "glass";
export type CardSize = "xs" | "sm" | "md" | "lg" | "xl";
export type CardElevation = "flat" | "raised" | "floating" | "elevated";
export type CardBorder =
| "default"
| "bordered"
| "dashed"
| "dotted"
| "borderless";
export interface ActionObject {
name: string;
path?: string;
onClick?: () => void;
disabled?: boolean;
type?: "default" | "primary" | "secondary" | "danger" | "ghost" | "tertiary";
icon?: ReactNode;
iconStart?: boolean;
external?: boolean;
size?: "sm" | "md" | "lg";
}
// CVA variants for the main card container
const cardVariants = cva(
"relative overflow-hidden rounded-3xl border transition-all duration-200 dark:bg-gray-900",
{
variants: {
variant: {
default:
"border-gray-100 bg-white text-gray-800 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200",
primary:
"border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200",
secondary:
"border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200",
success:
"border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200",
warning:
"border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-200",
error:
"border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200",
info: "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200",
glass:
"border-white/20 bg-white/10 text-gray-800 backdrop-blur-xl dark:border-gray-700/50 dark:bg-gray-900/10 dark:text-gray-200",
},
size: {
xs: "p-3 text-sm",
sm: "p-4 text-sm",
md: "p-6 text-base",
lg: "p-8 text-lg",
xl: "p-10 text-xl",
},
elevation: {
flat: "shadow-none",
raised: "shadow-sm hover:shadow-md",
floating: "shadow-lg hover:shadow-xl",
elevated: "shadow-xl hover:shadow-2xl",
},
border: {
default: "",
bordered: "border-2",
dashed: "border-2 border-dashed",
dotted: "border-2 border-dotted",
borderless: "border-0",
},
interactive: {
true: "cursor-pointer transition-all duration-200 hover:scale-[1.02] active:scale-[0.98]",
false: "",
},
hoverable: {
true: "transition-all duration-200 hover:shadow-lg",
false: "",
},
clickable: {
true: "cursor-pointer transition-all duration-200 hover:brightness-105 active:brightness-95",
false: "",
},
loading: {
true: "pointer-events-none opacity-75",
false: "",
},
horizontal: {
true: "flex flex-row items-start gap-4",
false: "flex flex-col gap-2",
},
},
defaultVariants: {
variant: "default",
size: "md",
elevation: "flat",
border: "default",
interactive: false,
hoverable: false,
clickable: false,
loading: false,
horizontal: false,
},
}
);
// CVA variants for card header
const cardHeaderVariants = cva(
" border-b border-gray-100 dark:border-gray-700",
{
variants: {
borderless: {
true: "border-b-0 pb-0",
false: "",
},
compact: {
true: "mb-0.5 pb-0.5",
false: "",
},
},
defaultVariants: {
borderless: true,
compact: false,
},
}
);
// CVA variants for card title
const cardTitleVariants = cva("font-semibold leading-tight", {
variants: {
size: {
xs: "text-sm font-medium",
sm: "text-base font-medium",
md: "text-lg",
lg: "text-xl font-bold",
xl: "text-2xl font-bold",
},
},
defaultVariants: {
size: "md",
},
});
// CVA variants for card subtitle
const cardSubtitleVariants = cva("opacity-75", {
variants: {
size: {
xs: "text-xs",
sm: "text-sm",
md: "text-sm",
lg: "text-base",
xl: "text-lg",
},
},
defaultVariants: {
size: "md",
},
});
// CVA variants for card icon
const cardIconVariants = cva("flex items-center justify-center rounded-2xl", {
variants: {
variant: {
default: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
primary: "bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-400",
secondary:
"bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400",
success:
"bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400",
warning:
"bg-yellow-100 text-yellow-600 dark:bg-yellow-900 dark:text-yellow-400",
error: "bg-red-100 text-red-600 dark:bg-red-900 dark:text-red-400",
info: "bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-400",
glass: "bg-white/20 text-gray-600 dark:bg-gray-700/20 dark:text-gray-400",
},
size: {
xs: "h-6 w-6 rounded-lg",
sm: "h-8 w-8 rounded-xl",
md: "h-12 w-12",
lg: "h-16 w-16 rounded-3xl",
xl: "h-20 w-20 rounded-3xl",
},
},
defaultVariants: {
variant: "default",
size: "md",
},
});
// CVA variants for card footer
const cardFooterVariants = cva(
"border-t pt-2 border-gray-100 dark:border-gray-700",
{
variants: {
borderless: {
true: "border-t-0 pt-0",
false: "",
},
alignment: {
left: "flex flex-wrap gap-2",
right: "flex justify-end gap-2",
center: "flex justify-center gap-2",
between: "flex justify-between gap-2",
},
},
defaultVariants: {
borderless: true,
alignment: "left",
},
}
);
// CVA variants for card media
const cardMediaVariants = cva("overflow-hidden", {
variants: {
horizontal: {
true: "w-24 flex-shrink-0 rounded-2xl",
false: "-m-6 mb-4 rounded-t-3xl",
},
size: {
xs: "",
sm: "",
md: "",
lg: "",
xl: "",
},
},
compoundVariants: [
{
horizontal: true,
size: "sm",
className: "w-16 rounded-xl",
},
{
horizontal: true,
size: "lg",
className: "w-32 rounded-3xl",
},
],
defaultVariants: {
horizontal: false,
size: "md",
},
});
// CVA variants for progress bar
const progressBarVariants = cva(
"absolute left-0 h-1 bg-blue-500 transition-all duration-300",
{
variants: {
position: {
top: "top-0",
bottom: "bottom-0",
},
thickness: {
thin: "h-0.5",
normal: "h-1",
thick: "h-2",
},
},
defaultVariants: {
position: "bottom",
thickness: "normal",
},
}
);
// CVA variants for notification dot
const notificationVariants = cva(
"absolute -right-1 -top-1 h-3 w-3 rounded-full",
{
variants: {
variant: {
default: "bg-red-500",
primary: "bg-blue-500",
success: "bg-green-500",
warning: "bg-yellow-500",
},
size: {
small: "h-2 w-2",
normal: "h-3 w-3",
large: "h-4 w-4",
},
},
defaultVariants: {
variant: "default",
size: "normal",
},
}
);
export interface CardProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
VariantProps<typeof cardVariants> {
// Content
title?: string | ReactNode;
subtitle?: string | ReactNode;
children?: ReactNode;
icon?: ReactNode;
media?: ReactNode;
badge?: ReactNode;
badges?: Array<ReactNode>;
// Actions
actions?: Array<ActionObject | ReactNode>;
primaryAction?: ActionObject | ReactNode;
onClose?: () => void;
onMore?: () => void;
// Interactions
onClick?: () => void;
// States
collapsed?: boolean;
// Header/Footer
headerDirection?: "row" | "col";
headerBorderless?: boolean;
footerBorderless?: boolean;
footerAlignment?: "left" | "right" | "center" | "between";
// Accessibility
role?: string;
tabIndex?: number;
// Progress
progress?: number;
progressPosition?: "top" | "bottom";
progressThickness?: "thin" | "normal" | "thick";
// Notification
notification?: boolean;
notificationVariant?: "default" | "primary" | "success" | "warning";
notificationSize?: "small" | "normal" | "large";
// Link functionality
href?: string;
external?: boolean;
}
// Type guard for ActionObject
const isActionObject = (action: unknown): action is ActionObject => {
return (
typeof action === "object" &&
action !== null &&
!React.isValidElement(action) &&
"name" in (action as never)
);
};
// Card Component
const Card = forwardRef<HTMLDivElement, CardProps>(
(
{
title,
subtitle,
children,
icon,
media,
badge,
badges,
variant = "default",
size = "md",
elevation = "flat",
border = "default",
horizontal = false,
className,
actions,
primaryAction,
onClose,
onMore,
interactive = false,
hoverable = false,
clickable = false,
onClick,
loading = false,
collapsed = false,
headerDirection = "row",
headerBorderless = true,
footerBorderless = true,
footerAlignment = "left",
role,
tabIndex,
progress,
progressPosition = "bottom",
progressThickness = "normal",
notification = false,
notificationVariant = "default",
notificationSize = "normal",
href,
external = false,
...props
},
ref
) => {
const getActionSize = (): "sm" | "md" | "lg" => {
switch (size) {
case "xs":
case "sm":
return "sm";
case "lg":
case "xl":
return "lg";
default:
return "md";
}
};
const getActionVariant = (actionType: string) => {
switch (actionType) {
case "default":
return "primary";
case "primary":
return "primary";
case "secondary":
return "secondary";
case "tertiary":
return "tertiary";
case "ghost":
return "ghost";
case "danger":
return "danger";
default:
return "primary";
}
};
// Render action
const renderAction = (action: ActionObject, index: number) => {
const variant = getActionVariant(action.type || "default");
const buttonSize = action.size || getActionSize();
const content = (
<>
{action.icon &&
action.iconStart &&
// <span className="icon">{action.icon}</span>
action.icon}
<span>{action.name}</span>
{action.icon &&
!action.iconStart &&
// <span className="icon">{action.icon}</span>
// add the "icon" class to the action.icon directly instead of wrapping within a span
action.icon}
</>
);
// If path exists, render Link with Button
if (action.path) {
if (action.external) {
return (
<Button
key={index}
asChild
variant={variant}
size={buttonSize}
disabled={action.disabled}
>
<a href={action.path} target="_blank" rel="noopener noreferrer">
{content}
</a>
</Button>
);
} else {
return (
<Button
key={index}
asChild
variant={variant}
size={buttonSize}
disabled={action.disabled}
>
<Link href={action.path}>{content}</Link>
</Button>
);
}
}
// Otherwise render Button with onClick
return (
<Button
key={index}
variant={variant}
size={buttonSize}
onClick={action.onClick}
disabled={action.disabled}
>
{content}
</Button>
);
};
const renderActionItem = (
action: ActionObject | ReactNode,
index: number
) => {
if (isActionObject(action)) {
return renderAction(action, index);
}
return <React.Fragment key={index}>{action}</React.Fragment>;
};
const renderPrimaryAction = (action: ActionObject | ReactNode) => {
if (isActionObject(action)) {
return renderAction(action, 0);
}
return <React.Fragment>{action}</React.Fragment>;
};
// Card content
const cardContent = (
<div
ref={ref}
className={cn(
cardVariants({
variant,
size,
elevation,
border,
interactive,
hoverable,
clickable,
loading,
horizontal,
}),
className
)}
onClick={onClick}
role={role}
tabIndex={tabIndex}
{...props}
>
{/* Progress Bar */}
{progress !== undefined && (
<div
className={progressBarVariants({
position: progressPosition,
thickness: progressThickness,
})}
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
/>
)}
{/* Notification Dot */}
{notification && (
<div
className={notificationVariants({
variant: notificationVariant,
size: notificationSize,
})}
/>
)}
{/* Badges */}
{badges && badges.length > 0 ? (
<div className="absolute right-3 top-3 z-10 flex flex-wrap justify-end gap-2">
{badges.map((badge, index) => (
<div key={index}>{badge}</div>
))}
</div>
) : (
badge && <div className="absolute right-3 top-3 z-10">{badge}</div>
)}
{/* Close/More Actions */}
{(onClose || onMore) && (
<div className="absolute right-3 top-3 z-10 flex gap-1">
{onMore && (
<Button
onClick={onMore}
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label="More options"
>
<MoreCircle className="h-4 w-4" />
</Button>
)}
{onClose && (
<Button
onClick={onClose}
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
aria-label="Close"
>
<CloseCircle className="h-4 w-4" />
</Button>
)}
</div>
)}
{/* Media for horizontal layout */}
{media && horizontal && (
<div className={cardMediaVariants({ horizontal: true, size })}>
{typeof media === "string" ? (
<img src={media} alt="" className="h-full w-full object-cover" />
) : (
media
)}
</div>
)}
{/* Main Content Container */}
<div className="flex h-full flex-1 flex-col gap-2">
{/* Media for vertical layout */}
{media && !horizontal && (
<div className={cardMediaVariants({ horizontal: false })}>
{typeof media === "string" ? (
<img
src={media}
alt=""
className="h-full w-full object-cover"
/>
) : (
media
)}
</div>
)}
{/* Header */}
{(title || subtitle || icon) && (
<div
className={cardHeaderVariants({
borderless: headerBorderless,
})}
>
<div
className={`flex items-start gap-3 ${
headerDirection === "row" ? "flex-row" : "flex-col"
}`}
>
{icon && (
<div className={cardIconVariants({ variant, size })}>
{icon}
</div>
)}
<div className="flex-1">
{title && (
<h3 className={cardTitleVariants({ size })}>{title}</h3>
)}
{subtitle && (
<p className={cardSubtitleVariants({ size })}>{subtitle}</p>
)}
</div>
</div>
</div>
)}
{/* Content */}
{children && (
<div
className={cn(
"space-y-4",
collapsed &&
"max-h-0 overflow-hidden opacity-0 transition-all duration-300 ease-in-out",
!collapsed &&
"opacity-100 transition-all duration-300 ease-in-out"
)}
>
{children}
</div>
)}
{/* Primary Action */}
{primaryAction && (
<div className="mt-4">{renderPrimaryAction(primaryAction)}</div>
)}
{/* Footer with Actions */}
{actions && actions.length > 0 && (
<div
className={cn(
cardFooterVariants({
borderless: footerBorderless,
alignment: footerAlignment,
}),
"mt-auto"
)}
>
{actions.map((action, index) => renderActionItem(action, index))}
</div>
)}
</div>
{/* Loading Overlay */}
{loading && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/50 opacity-100">
<div className="flex items-center gap-2 rounded-lg bg-white px-3 py-2 text-sm font-medium dark:bg-gray-800">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-500 border-t-transparent"></div>
Loading...
</div>
</div>
)}
</div>
);
// Wrap with Link if href is provided
if (href) {
if (external) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="block focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-3xl"
>
{cardContent}
</a>
);
} else {
return (
<Link
href={href}
className="block focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded-3xl"
>
{cardContent}
</Link>
);
}
}
return cardContent;
}
);
Card.displayName = "Card";
// Additional utility components for card layouts
export const CardGrid: FC<{
children: ReactNode;
cols?: 1 | 2 | 3 | 4;
spacing?: "dense" | "normal" | "loose";
className?: string;
}> = ({ children, cols = 3, spacing = "normal", className }) => {
const gridClasses = cn(
"grid",
{
"grid-cols-1": cols === 1,
"grid-cols-1 md:grid-cols-2": cols === 2,
"grid-cols-1 md:grid-cols-2 lg:grid-cols-3": cols === 3,
"grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4": cols === 4,
},
{
"gap-4": spacing === "dense",
"gap-6": spacing === "normal",
"gap-8": spacing === "loose",
},
className
);
return <div className={gridClasses}>{children}</div>;
};
export const CardList: FC<{
children: ReactNode;
spacing?: "compact" | "normal" | "loose";
className?: string;
}> = ({ children, spacing = "normal", className }) => {
const listClasses = cn(
{
"space-y-2": spacing === "compact",
"space-y-4": spacing === "normal",
"space-y-6": spacing === "loose",
},
className
);
return <div className={listClasses}>{children}</div>;
};
export const CardSkeleton: FC<{
size?: CardSize;
showIcon?: boolean;
lines?: number;
}> = ({ size = "md", showIcon = false, lines = 3 }) => {
return (
<div className={cardVariants({ size, className: "animate-pulse" })}>
<div className="flex items-start gap-3">
{showIcon && (
<div
className={cn(
"rounded-full bg-gray-200 dark:bg-gray-700",
size === "sm" ? "h-8 w-8" : "h-12 w-12"
)}
/>
)}
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded dark:bg-gray-700 w-2/3" />
<div className="h-3 bg-gray-200 rounded dark:bg-gray-700 w-1/2" />
</div>
</div>
<div className="space-y-2 mt-4">
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={cn(
"h-3 bg-gray-200 rounded dark:bg-gray-700",
i === lines - 1 ? "w-3/4" : "w-full"
)}
/>
))}
</div>
</div>
);
};
export { Card, cardVariants };