update the ui
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
const eslintConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals"),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
|
|||||||
+8
-4
@@ -1,8 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import '../styles/globals.css';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'SupportHub',
|
title: 'SupportHub | Customer Support Platform',
|
||||||
description: 'Enterprise Support Platform',
|
description: 'Enterprise AI-powered customer support and ticketing platform for modern teams.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -11,8 +12,11 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className="h-full scroll-smooth">
|
||||||
<body>{children}</body>
|
<body className="min-h-full flex flex-col font-sans bg-background text-foreground antialiased">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './ui';
|
||||||
|
export * from './marketing';
|
||||||
|
export * from './layout';
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Headphones, Github, Twitter, Linkedin } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Container } from '@/components/marketing/container';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { IconButton } from '@/components/ui/icon-button';
|
||||||
|
|
||||||
|
export interface FooterLink {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
external?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FooterColumn {
|
||||||
|
title: string;
|
||||||
|
links: FooterLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FooterProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
brandName?: string;
|
||||||
|
description?: string;
|
||||||
|
columns?: FooterColumn[];
|
||||||
|
copyright?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultColumns: FooterColumn[] = [
|
||||||
|
{
|
||||||
|
title: 'Product',
|
||||||
|
links: [
|
||||||
|
{ label: 'AI Support Co-Pilot', href: '#co-pilot' },
|
||||||
|
{ label: 'Omnichannel Ticketing', href: '#ticketing' },
|
||||||
|
{ label: 'Knowledge Base', href: '#knowledge-base' },
|
||||||
|
{ label: 'Analytics & SLA', href: '#analytics' },
|
||||||
|
{ label: 'Integrations', href: '#integrations' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Solutions',
|
||||||
|
links: [
|
||||||
|
{ label: 'Enterprise SaaS', href: '#enterprise' },
|
||||||
|
{ label: 'E-Commerce', href: '#ecommerce' },
|
||||||
|
{ label: 'FinTech', href: '#fintech' },
|
||||||
|
{ label: 'Startups', href: '#startups' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Resources',
|
||||||
|
links: [
|
||||||
|
{ label: 'Documentation', href: '#docs' },
|
||||||
|
{ label: 'API Reference', href: '#api' },
|
||||||
|
{ label: 'Guides & Case Studies', href: '#guides' },
|
||||||
|
{ label: 'Community', href: '#community' },
|
||||||
|
{ label: 'Status Page', href: '#status' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Company',
|
||||||
|
links: [
|
||||||
|
{ label: 'About Us', href: '#about' },
|
||||||
|
{ label: 'Careers', href: '#careers' },
|
||||||
|
{ label: 'Blog', href: '#blog' },
|
||||||
|
{ label: 'Press Kit', href: '#press' },
|
||||||
|
{ label: 'Contact Sales', href: '#contact' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Legal',
|
||||||
|
links: [
|
||||||
|
{ label: 'Privacy Policy', href: '#privacy' },
|
||||||
|
{ label: 'Terms of Service', href: '#terms' },
|
||||||
|
{ label: 'Security & SOC 2', href: '#security' },
|
||||||
|
{ label: 'GDPR Compliance', href: '#gdpr' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const Footer = React.forwardRef<HTMLElement, FooterProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
brandName = 'SupportHub',
|
||||||
|
description = 'The enterprise customer support platform built for modern high-growth SaaS teams.',
|
||||||
|
columns = defaultColumns,
|
||||||
|
copyright = `© ${new Date().getFullYear()} SupportHub, Inc. All rights reserved.`,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<footer
|
||||||
|
ref={ref}
|
||||||
|
className={cn('w-full border-t border-border bg-card/50 text-foreground pt-16 pb-12', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Container size="wide">
|
||||||
|
<div className="grid grid-cols-1 gap-12 lg:grid-cols-6 mb-16">
|
||||||
|
{/* Brand Information Column */}
|
||||||
|
<div className="lg:col-span-2 flex flex-col gap-4">
|
||||||
|
<a href="/" className="flex items-center gap-2.5">
|
||||||
|
<div className="h-9 w-9 rounded-xl bg-primary text-primary-foreground flex items-center justify-center font-bold shadow-sm">
|
||||||
|
<Headphones className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold tracking-tight text-foreground">
|
||||||
|
{brandName}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed max-w-sm">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<IconButton aria-label="GitHub" variant="ghost" size="sm" asChild>
|
||||||
|
<a href="https://github.com" target="_blank" rel="noopener noreferrer">
|
||||||
|
<Github className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</IconButton>
|
||||||
|
<IconButton aria-label="Twitter" variant="ghost" size="sm" asChild>
|
||||||
|
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer">
|
||||||
|
<Twitter className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</IconButton>
|
||||||
|
<IconButton aria-label="LinkedIn" variant="ghost" size="sm" asChild>
|
||||||
|
<a href="https://linkedin.com" target="_blank" rel="noopener noreferrer">
|
||||||
|
<Linkedin className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link Columns */}
|
||||||
|
<div className="lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-8">
|
||||||
|
{columns.map((col, idx) => (
|
||||||
|
<div key={idx} className="flex flex-col gap-3">
|
||||||
|
<h4 className="text-xs font-semibold text-foreground uppercase tracking-wider">
|
||||||
|
{col.title}
|
||||||
|
</h4>
|
||||||
|
<ul className="flex flex-col gap-2.5">
|
||||||
|
{col.links.map((link, linkIdx) => (
|
||||||
|
<li key={linkIdx}>
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
className="text-xs sm:text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Bar */}
|
||||||
|
<div className="pt-8 border-t border-border/60 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
|
||||||
|
<p>{copyright}</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="success" size="sm" dot>
|
||||||
|
All Systems Operational
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Footer.displayName = 'Footer';
|
||||||
|
|
||||||
|
export { Footer };
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './navbar';
|
||||||
|
export * from './mobile-menu';
|
||||||
|
export * from './footer';
|
||||||
|
export * from './page-container';
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { IconButton } from '@/components/ui/icon-button';
|
||||||
|
|
||||||
|
export interface NavLinkItem {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
badge?: string;
|
||||||
|
external?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileMenuProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
brand: React.ReactNode;
|
||||||
|
navLinks: NavLinkItem[];
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MobileMenu: React.FC<MobileMenuProps> = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
brand,
|
||||||
|
navLinks,
|
||||||
|
actions,
|
||||||
|
}) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 lg:hidden">
|
||||||
|
{/* Backdrop Overlay */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Slide-over Drawer Panel */}
|
||||||
|
<div className="fixed inset-y-0 right-0 z-50 w-full max-w-xs bg-background p-6 shadow-dialog border-l border-border animate-slide-in-right flex flex-col justify-between overflow-y-auto">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between pb-4 border-b border-border">
|
||||||
|
<div onClick={onClose}>{brand}</div>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Close mobile menu"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Links */}
|
||||||
|
<nav className="flex flex-col gap-2">
|
||||||
|
{navLinks.map((link, idx) => (
|
||||||
|
<a
|
||||||
|
key={idx}
|
||||||
|
href={link.href}
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex items-center justify-between py-2.5 px-3 rounded-lg text-base font-semibold text-foreground hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<span>{link.label}</span>
|
||||||
|
{link.badge && (
|
||||||
|
<Badge variant="primary" size="sm">
|
||||||
|
{link.badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
{actions && (
|
||||||
|
<div className="pt-6 border-t border-border flex flex-col gap-3">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { MobileMenu };
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Menu, Headphones } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Container } from '@/components/marketing/container';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { IconButton } from '@/components/ui/icon-button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { MobileMenu, NavLinkItem } from './mobile-menu';
|
||||||
|
|
||||||
|
export interface NavbarProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
brandName?: string;
|
||||||
|
brandLogo?: React.ReactNode;
|
||||||
|
navLinks?: NavLinkItem[];
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
sticky?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultNavLinks: NavLinkItem[] = [
|
||||||
|
{ label: 'Features', href: '#features' },
|
||||||
|
{ label: 'Solutions', href: '#solutions' },
|
||||||
|
{ label: 'Pricing', href: '#pricing' },
|
||||||
|
{ label: 'Resources', href: '#resources' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const Navbar = React.forwardRef<HTMLElement, NavbarProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
brandName = 'SupportHub',
|
||||||
|
brandLogo,
|
||||||
|
navLinks = defaultNavLinks,
|
||||||
|
actions,
|
||||||
|
sticky = true,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||||
|
const [isScrolled, setIsScrolled] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (window.scrollY > 20) {
|
||||||
|
setIsScrolled(true);
|
||||||
|
} else {
|
||||||
|
setIsScrolled(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logoNode = brandLogo || (
|
||||||
|
<a href="/" className="flex items-center gap-2.5 group">
|
||||||
|
<div className="h-9 w-9 rounded-xl bg-primary text-primary-foreground flex items-center justify-center font-bold shadow-sm group-hover:scale-105 transition-transform">
|
||||||
|
<Headphones className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold tracking-tight text-foreground group-hover:text-primary transition-colors">
|
||||||
|
{brandName}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
const actionNode = actions || (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<a href="/login">Sign In</a>
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="sm" asChild>
|
||||||
|
<a href="/get-started">Start Free Trial</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'w-full z-40 transition-all duration-200 border-b',
|
||||||
|
sticky ? 'sticky top-0' : 'relative',
|
||||||
|
isScrolled
|
||||||
|
? 'bg-background/80 backdrop-blur-md border-border shadow-subtle py-3'
|
||||||
|
: 'bg-background/40 backdrop-blur-sm border-transparent py-4',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Container size="wide">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
{/* Brand Logo */}
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
{logoNode}
|
||||||
|
|
||||||
|
{/* Desktop Nav Links */}
|
||||||
|
<nav className="hidden lg:flex items-center gap-6">
|
||||||
|
{navLinks.map((link, idx) => (
|
||||||
|
<a
|
||||||
|
key={idx}
|
||||||
|
href={link.href}
|
||||||
|
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span>{link.label}</span>
|
||||||
|
{link.badge && (
|
||||||
|
<Badge variant="primary" size="sm">
|
||||||
|
{link.badge}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Actions */}
|
||||||
|
<div className="hidden lg:flex items-center gap-3">{actionNode}</div>
|
||||||
|
|
||||||
|
{/* Mobile Menu Trigger */}
|
||||||
|
<div className="flex items-center lg:hidden">
|
||||||
|
<IconButton
|
||||||
|
aria-label="Open navigation menu"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setMobileMenuOpen(true)}
|
||||||
|
>
|
||||||
|
<Menu className="h-6 w-6" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{/* Mobile Slide-over Menu */}
|
||||||
|
<MobileMenu
|
||||||
|
open={mobileMenuOpen}
|
||||||
|
onClose={() => setMobileMenuOpen(false)}
|
||||||
|
brand={logoNode}
|
||||||
|
navLinks={navLinks}
|
||||||
|
actions={actionNode}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Navbar.displayName = 'Navbar';
|
||||||
|
|
||||||
|
export { Navbar };
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ToastProvider } from '@/components/ui/toast';
|
||||||
|
import { Navbar, NavbarProps } from './navbar';
|
||||||
|
import { Footer, FooterProps } from './footer';
|
||||||
|
|
||||||
|
export interface PageContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
showNavbar?: boolean;
|
||||||
|
showFooter?: boolean;
|
||||||
|
navbarProps?: NavbarProps;
|
||||||
|
footerProps?: FooterProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PageContainer = React.forwardRef<HTMLDivElement, PageContainerProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
showNavbar = true,
|
||||||
|
showFooter = true,
|
||||||
|
navbarProps,
|
||||||
|
footerProps,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('min-h-screen flex flex-col bg-background text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showNavbar && <Navbar {...navbarProps} />}
|
||||||
|
<main className="flex-1 flex flex-col">{children}</main>
|
||||||
|
{showFooter && <Footer {...footerProps} />}
|
||||||
|
</div>
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
PageContainer.displayName = 'PageContainer';
|
||||||
|
|
||||||
|
export { PageContainer };
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Lock } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface BrowserMockupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
url?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BrowserMockup = React.forwardRef<HTMLDivElement, BrowserMockupProps>(
|
||||||
|
({ className, url = 'https://app.supporthub.io', children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-2xl border border-border bg-card shadow-dialog overflow-hidden flex flex-col',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Browser Header Bar */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border bg-muted/60 px-4 py-3 select-none">
|
||||||
|
{/* Window Traffic Lights */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="h-3 w-3 rounded-full bg-rose-500/80 inline-block" />
|
||||||
|
<span className="h-3 w-3 rounded-full bg-amber-500/80 inline-block" />
|
||||||
|
<span className="h-3 w-3 rounded-full bg-emerald-500/80 inline-block" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address Bar */}
|
||||||
|
<div className="flex items-center justify-center gap-2 rounded-lg bg-background/80 px-4 py-1 text-xs text-muted-foreground border border-border/50 max-w-md w-full mx-4 shadow-subtle truncate font-mono">
|
||||||
|
<Lock className="h-3 w-3 text-emerald-500 shrink-0" />
|
||||||
|
<span className="truncate">{url}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div className="w-12 hidden sm:block" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Viewport Content */}
|
||||||
|
<div className="relative w-full overflow-hidden bg-background">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
BrowserMockup.displayName = 'BrowserMockup';
|
||||||
|
|
||||||
|
export { BrowserMockup };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface ContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: 'narrow' | 'default' | 'wide' | 'full';
|
||||||
|
as?: React.ElementType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
narrow: 'max-w-4xl',
|
||||||
|
default: 'max-w-7xl',
|
||||||
|
wide: 'max-w-7xl lg:max-w-[90rem]',
|
||||||
|
full: 'max-w-none',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Container = React.forwardRef<HTMLDivElement, ContainerProps>(
|
||||||
|
({ className, size = 'default', as: Component = 'div', children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mx-auto w-full px-4 sm:px-6 lg:px-8',
|
||||||
|
sizeClasses[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Container.displayName = 'Container';
|
||||||
|
|
||||||
|
export { Container };
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Container } from './container';
|
||||||
|
import { Eyebrow } from './eyebrow';
|
||||||
|
|
||||||
|
export interface CTASectionProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||||
|
title: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
primaryAction?: React.ReactNode;
|
||||||
|
secondaryAction?: React.ReactNode;
|
||||||
|
eyebrow?: string;
|
||||||
|
variant?: 'default' | 'card' | 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
|
const CTASection = React.forwardRef<HTMLDivElement, CTASectionProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryAction,
|
||||||
|
secondaryAction,
|
||||||
|
eyebrow,
|
||||||
|
variant = 'card',
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={cn('w-full py-12', className)} {...props}>
|
||||||
|
<Container>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden rounded-3xl p-8 sm:p-12 lg:p-16 text-center flex flex-col items-center gap-6 shadow-float',
|
||||||
|
variant === 'card' && 'bg-gradient-to-b from-primary/10 via-background to-accent/20 border border-primary/20',
|
||||||
|
variant === 'dark' && 'bg-slate-950 text-slate-50 border border-slate-800',
|
||||||
|
variant === 'default' && 'bg-card border border-border'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Background Glow */}
|
||||||
|
<div className="absolute -top-24 left-1/2 -translate-x-1/2 h-48 w-96 rounded-full bg-primary/20 blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
|
||||||
|
|
||||||
|
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-extrabold tracking-tight max-w-3xl leading-tight">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl leading-relaxed">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(primaryAction || secondaryAction) && (
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mt-4 w-full sm:w-auto">
|
||||||
|
{primaryAction}
|
||||||
|
{secondaryAction}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
CTASection.displayName = 'CTASection';
|
||||||
|
|
||||||
|
export { CTASection };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface EyebrowProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
variant?: 'default' | 'primary' | 'outline';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Eyebrow = React.forwardRef<HTMLDivElement, EyebrowProps>(
|
||||||
|
({ className, icon, variant = 'primary', children, ...props }, ref) => {
|
||||||
|
const variantClasses = {
|
||||||
|
default: 'bg-muted text-muted-foreground border-border/60',
|
||||||
|
primary: 'bg-primary/10 text-primary border-primary/20 hover:bg-primary/15',
|
||||||
|
outline: 'bg-background text-foreground border-border shadow-subtle',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold tracking-wide uppercase transition-colors shrink-0',
|
||||||
|
variantClasses[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon && <span className="shrink-0">{icon}</span>}
|
||||||
|
<span>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Eyebrow.displayName = 'Eyebrow';
|
||||||
|
|
||||||
|
export { Eyebrow };
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
export interface FeatureCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
badge?: string;
|
||||||
|
linkText?: string;
|
||||||
|
onLinkClick?: () => void;
|
||||||
|
variant?: 'default' | 'bordered' | 'flat' | 'gradient';
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeatureCard = React.forwardRef<HTMLDivElement, FeatureCardProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
badge,
|
||||||
|
linkText,
|
||||||
|
onLinkClick,
|
||||||
|
variant = 'default',
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const variantClasses = {
|
||||||
|
default: 'bg-card border-border hover:border-primary/30 hover:shadow-float',
|
||||||
|
bordered: 'bg-background border-2 border-border hover:border-primary/50',
|
||||||
|
flat: 'bg-muted/40 border-transparent shadow-none hover:bg-muted/70',
|
||||||
|
gradient: 'bg-gradient-to-b from-card to-accent/20 border-border/80 hover:border-primary/40',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={ref}
|
||||||
|
hoverable
|
||||||
|
className={cn('group relative overflow-hidden transition-all duration-300', variantClasses[variant], className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CardContent className="p-6 sm:p-8 flex flex-col h-full gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-12 w-12 rounded-xl bg-primary/10 text-primary flex items-center justify-center font-bold text-xl group-hover:bg-primary group-hover:text-primary-foreground transition-all duration-300 shrink-0">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
{badge && <Badge variant="secondary" size="sm">{badge}</Badge>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col gap-2 mt-2">
|
||||||
|
<h3 className="text-xl font-bold tracking-tight text-foreground group-hover:text-primary transition-colors">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{linkText && (
|
||||||
|
<div className="pt-2 mt-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLinkClick}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-semibold text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
<span>{linkText}</span>
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
FeatureCard.displayName = 'FeatureCard';
|
||||||
|
|
||||||
|
export { FeatureCard };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface GradientTextProps extends React.HTMLAttributes<HTMLSpanElement> {
|
||||||
|
gradient?: 'primary' | 'brand' | 'emerald' | 'amber' | 'subtle';
|
||||||
|
as?: React.ElementType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gradientClasses = {
|
||||||
|
primary: 'bg-gradient-to-r from-indigo-600 via-indigo-500 to-purple-600 dark:from-indigo-400 dark:via-indigo-300 dark:to-purple-400',
|
||||||
|
brand: 'bg-gradient-to-r from-primary via-indigo-500 to-violet-600 dark:from-indigo-400 dark:to-violet-400',
|
||||||
|
emerald: 'bg-gradient-to-r from-emerald-600 to-teal-500 dark:from-emerald-400 dark:to-teal-300',
|
||||||
|
amber: 'bg-gradient-to-r from-amber-600 to-orange-500 dark:from-amber-400 dark:to-orange-300',
|
||||||
|
subtle: 'bg-gradient-to-r from-foreground via-foreground/90 to-foreground/70',
|
||||||
|
};
|
||||||
|
|
||||||
|
const GradientText = React.forwardRef<HTMLSpanElement, GradientTextProps>(
|
||||||
|
({ className, gradient = 'primary', as: Component = 'span', children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'bg-clip-text text-transparent inline-block',
|
||||||
|
gradientClasses[gradient],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
GradientText.displayName = 'GradientText';
|
||||||
|
|
||||||
|
export { GradientText };
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export * from './section';
|
||||||
|
export * from './section-header';
|
||||||
|
export * from './container';
|
||||||
|
export * from './gradient-text';
|
||||||
|
export * from './eyebrow';
|
||||||
|
export * from './feature-card';
|
||||||
|
export * from './step-card';
|
||||||
|
export * from './metric-card';
|
||||||
|
export * from './logo-cloud';
|
||||||
|
export * from './testimonial-card';
|
||||||
|
export * from './cta-section';
|
||||||
|
export * from './product-mockup';
|
||||||
|
export * from './browser-mockup';
|
||||||
|
export * from './screenshot-frame';
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface LogoItem {
|
||||||
|
name: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
svg?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogoCloudProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
title?: string;
|
||||||
|
logos: LogoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LogoCloud = React.forwardRef<HTMLDivElement, LogoCloudProps>(
|
||||||
|
({ className, title, logos, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('w-full flex flex-col items-center gap-8 py-6', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<p className="text-center text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-8 md:grid-cols-4 lg:grid-cols-6 items-center justify-items-center w-full opacity-75 hover:opacity-100 transition-opacity">
|
||||||
|
{logos.map((logo, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-center p-2 text-muted-foreground hover:text-foreground grayscale hover:grayscale-0 transition-all duration-200"
|
||||||
|
title={logo.name}
|
||||||
|
>
|
||||||
|
{logo.svg ? (
|
||||||
|
logo.svg
|
||||||
|
) : logo.logoUrl ? (
|
||||||
|
<img
|
||||||
|
src={logo.logoUrl}
|
||||||
|
alt={logo.name}
|
||||||
|
className="h-8 w-auto object-contain max-w-[120px]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="font-bold text-lg tracking-tight">{logo.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
LogoCloud.displayName = 'LogoCloud';
|
||||||
|
|
||||||
|
export { LogoCloud };
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
export interface MetricCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
trend?: string;
|
||||||
|
trendDirection?: 'up' | 'down' | 'neutral';
|
||||||
|
subtext?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MetricCard = React.forwardRef<HTMLDivElement, MetricCardProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
trend,
|
||||||
|
trendDirection = 'up',
|
||||||
|
subtext,
|
||||||
|
icon,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const trendIcons = {
|
||||||
|
up: <TrendingUp className="h-3.5 w-3.5" />,
|
||||||
|
down: <TrendingDown className="h-3.5 w-3.5" />,
|
||||||
|
neutral: <Minus className="h-3.5 w-3.5" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const trendVariants = {
|
||||||
|
up: 'success' as const,
|
||||||
|
down: 'destructive' as const,
|
||||||
|
neutral: 'secondary' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={ref}
|
||||||
|
className={cn('bg-card border-border hover:shadow-float transition-all duration-200', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CardContent className="p-6 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-baseline gap-3 my-1">
|
||||||
|
<span className="text-3xl sm:text-4xl font-extrabold tracking-tight text-foreground">
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{trend && (
|
||||||
|
<Badge variant={trendVariants[trendDirection]} size="sm" className="gap-1">
|
||||||
|
{trendIcons[trendDirection]}
|
||||||
|
<span>{trend}</span>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subtext && (
|
||||||
|
<p className="text-xs text-muted-foreground leading-normal">{subtext}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
MetricCard.displayName = 'MetricCard';
|
||||||
|
|
||||||
|
export { MetricCard };
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { BrowserMockup } from './browser-mockup';
|
||||||
|
|
||||||
|
export interface ProductMockupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
url?: string;
|
||||||
|
glow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductMockup = React.forwardRef<HTMLDivElement, ProductMockupProps>(
|
||||||
|
({ className, url, glow = true, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('relative w-full max-w-5xl mx-auto my-8', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Subtle Ambient Radial Glow behind the mockup */}
|
||||||
|
{glow && (
|
||||||
|
<div
|
||||||
|
className="absolute -inset-4 bg-gradient-to-tr from-primary/30 to-purple-500/20 blur-3xl opacity-50 rounded-[2.5rem] pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<BrowserMockup url={url}>{children}</BrowserMockup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ProductMockup.displayName = 'ProductMockup';
|
||||||
|
|
||||||
|
export { ProductMockup };
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
|
export interface ScreenshotFrameProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
src?: string;
|
||||||
|
alt?: string;
|
||||||
|
caption?: string;
|
||||||
|
aspectRatio?: '16/9' | '4/3' | '1/1' | 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScreenshotFrame = React.forwardRef<HTMLDivElement, ScreenshotFrameProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
src,
|
||||||
|
alt = 'App Screenshot',
|
||||||
|
caption,
|
||||||
|
aspectRatio = '16/9',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [loaded, setLoaded] = React.useState(false);
|
||||||
|
|
||||||
|
const aspectClasses = {
|
||||||
|
'16/9': 'aspect-video',
|
||||||
|
'4/3': 'aspect-[4/3]',
|
||||||
|
'1/1': 'aspect-square',
|
||||||
|
auto: 'aspect-auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure
|
||||||
|
ref={ref}
|
||||||
|
className={cn('w-full flex flex-col gap-2 group', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative w-full overflow-hidden rounded-xl border border-border bg-muted shadow-card transition-all duration-300 group-hover:shadow-float',
|
||||||
|
aspectClasses[aspectRatio]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{src ? (
|
||||||
|
<>
|
||||||
|
{!loaded && (
|
||||||
|
<Skeleton className="absolute inset-0 h-full w-full rounded-xl" />
|
||||||
|
)}
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
className={cn(
|
||||||
|
'h-full w-full object-cover transition-opacity duration-300',
|
||||||
|
loaded ? 'opacity-100' : 'opacity-0'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{caption && (
|
||||||
|
<figcaption className="text-center text-xs text-muted-foreground mt-1 font-medium">
|
||||||
|
{caption}
|
||||||
|
</figcaption>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ScreenshotFrame.displayName = 'ScreenshotFrame';
|
||||||
|
|
||||||
|
export { ScreenshotFrame };
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Eyebrow } from './eyebrow';
|
||||||
|
|
||||||
|
export interface SectionHeaderProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
|
||||||
|
eyebrow?: React.ReactNode;
|
||||||
|
title: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
align?: 'left' | 'center';
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SectionHeader = React.forwardRef<HTMLDivElement, SectionHeaderProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
align = 'center',
|
||||||
|
actions,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-4 max-w-3xl mb-12 sm:mb-16',
|
||||||
|
align === 'center' && 'mx-auto text-center items-center',
|
||||||
|
align === 'left' && 'items-start text-left',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{eyebrow && (
|
||||||
|
typeof eyebrow === 'string' ? (
|
||||||
|
<Eyebrow>{eyebrow}</Eyebrow>
|
||||||
|
) : (
|
||||||
|
eyebrow
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-foreground leading-[1.15]">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{description && (
|
||||||
|
<p className="text-base sm:text-lg text-muted-foreground leading-relaxed max-w-2xl">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{actions && <div className="mt-2 flex flex-wrap gap-4 items-center">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
SectionHeader.displayName = 'SectionHeader';
|
||||||
|
|
||||||
|
export { SectionHeader };
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface SectionProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
spacing?: 'sm' | 'md' | 'lg' | 'none';
|
||||||
|
background?: 'default' | 'muted' | 'dark' | 'gradient';
|
||||||
|
}
|
||||||
|
|
||||||
|
const spacingClasses = {
|
||||||
|
none: 'py-0',
|
||||||
|
sm: 'py-12 sm:py-16',
|
||||||
|
md: 'py-16 sm:py-24',
|
||||||
|
lg: 'py-20 sm:py-32',
|
||||||
|
};
|
||||||
|
|
||||||
|
const backgroundClasses = {
|
||||||
|
default: 'bg-background text-foreground',
|
||||||
|
muted: 'bg-muted/50 text-foreground border-y border-border/50',
|
||||||
|
dark: 'bg-slate-950 text-slate-50 dark:bg-slate-950 dark:text-slate-50',
|
||||||
|
gradient: 'bg-gradient-to-b from-background via-accent/30 to-background text-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Section = React.forwardRef<HTMLElement, SectionProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
spacing = 'md',
|
||||||
|
background = 'default',
|
||||||
|
children,
|
||||||
|
id,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden w-full',
|
||||||
|
spacingClasses[spacing],
|
||||||
|
backgroundClasses[background],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Section.displayName = 'Section';
|
||||||
|
|
||||||
|
export { Section };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface StepCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
stepNumber: number | string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
isLast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StepCard = React.forwardRef<HTMLDivElement, StepCardProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
stepNumber,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
isLast = false,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const formattedStep =
|
||||||
|
typeof stepNumber === 'number' && stepNumber < 10
|
||||||
|
? `0${stepNumber}`
|
||||||
|
: stepNumber;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('relative flex flex-col p-6 rounded-2xl border border-border bg-card shadow-card hover:border-primary/30 transition-all duration-300', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-3xl font-black tracking-tight text-primary/30 font-mono">
|
||||||
|
{formattedStep}
|
||||||
|
</span>
|
||||||
|
{icon && (
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-accent text-accent-foreground flex items-center justify-center">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 className="text-lg font-bold text-foreground mb-2 tracking-tight">{title}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">{description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
StepCard.displayName = 'StepCard';
|
||||||
|
|
||||||
|
export { StepCard };
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Star, Quote } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
|
||||||
|
export interface TestimonialCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
quote: string;
|
||||||
|
authorName: string;
|
||||||
|
authorTitle: string;
|
||||||
|
authorCompany?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
rating?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TestimonialCard = React.forwardRef<HTMLDivElement, TestimonialCardProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
quote,
|
||||||
|
authorName,
|
||||||
|
authorTitle,
|
||||||
|
authorCompany,
|
||||||
|
avatarUrl,
|
||||||
|
rating = 5,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const initials = authorName
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('')
|
||||||
|
.substring(0, 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={ref}
|
||||||
|
hoverable
|
||||||
|
className={cn('bg-card border-border hover:shadow-float transition-all duration-300 flex flex-col justify-between', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CardContent className="p-6 sm:p-8 flex flex-col h-full justify-between gap-6">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-1 text-amber-400">
|
||||||
|
{Array.from({ length: rating }).map((_, i) => (
|
||||||
|
<Star key={i} className="h-4 w-4 fill-amber-400 text-amber-400" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Quote className="h-6 w-6 text-muted-foreground/30" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm sm:text-base text-foreground leading-relaxed italic">
|
||||||
|
“{quote}”
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 pt-4 border-t border-border/60">
|
||||||
|
<Avatar size="md">
|
||||||
|
<AvatarImage src={avatarUrl} alt={authorName} />
|
||||||
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h5 className="text-sm font-bold text-foreground leading-tight">{authorName}</h5>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{authorTitle}
|
||||||
|
{authorCompany && <span className="font-medium text-foreground/80"> • {authorCompany}</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TestimonialCard.displayName = 'TestimonialCard';
|
||||||
|
|
||||||
|
export { TestimonialCard };
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AccordionContextValue {
|
||||||
|
openItems: string[];
|
||||||
|
toggleItem: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccordionContext = React.createContext<AccordionContextValue | null>(null);
|
||||||
|
|
||||||
|
export interface AccordionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
type?: 'single' | 'multiple';
|
||||||
|
defaultValue?: string | string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Accordion = React.forwardRef<HTMLDivElement, AccordionProps>(
|
||||||
|
({ type = 'single', defaultValue, children, className, ...props }, ref) => {
|
||||||
|
const [openItems, setOpenItems] = React.useState<string[]>(() => {
|
||||||
|
if (!defaultValue) return [];
|
||||||
|
return Array.isArray(defaultValue) ? defaultValue : [defaultValue];
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleItem = React.useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setOpenItems((prev) => {
|
||||||
|
if (type === 'single') {
|
||||||
|
return prev.includes(value) ? [] : [value];
|
||||||
|
} else {
|
||||||
|
return prev.includes(value)
|
||||||
|
? prev.filter((v) => v !== value)
|
||||||
|
: [...prev, value];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[type]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionContext.Provider value={{ openItems, toggleItem }}>
|
||||||
|
<div ref={ref} className={cn('divide-y divide-border border-b border-t border-border', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AccordionContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Accordion.displayName = 'Accordion';
|
||||||
|
|
||||||
|
interface AccordionItemContextValue {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccordionItemContext = React.createContext<AccordionItemContextValue | null>(null);
|
||||||
|
|
||||||
|
export interface AccordionItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccordionItem = React.forwardRef<HTMLDivElement, AccordionItemProps>(
|
||||||
|
({ value, children, className, ...props }, ref) => (
|
||||||
|
<AccordionItemContext.Provider value={{ value }}>
|
||||||
|
<div ref={ref} className={cn('py-1', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AccordionItemContext.Provider>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
AccordionItem.displayName = 'AccordionItem';
|
||||||
|
|
||||||
|
export interface AccordionTriggerProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
||||||
|
|
||||||
|
const AccordionTrigger = React.forwardRef<HTMLButtonElement, AccordionTriggerProps>(
|
||||||
|
({ children, className, ...props }, ref) => {
|
||||||
|
const accContext = React.useContext(AccordionContext);
|
||||||
|
const itemContext = React.useContext(AccordionItemContext);
|
||||||
|
|
||||||
|
if (!accContext || !itemContext) {
|
||||||
|
throw new Error('AccordionTrigger must be used inside Accordion and AccordionItem');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOpen = accContext.openItems.includes(itemContext.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
onClick={() => accContext.toggleItem(itemContext.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-between py-4 font-medium text-foreground transition-all hover:underline text-left text-base',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span>{children}</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 shrink-0 transition-transform duration-200 text-muted-foreground',
|
||||||
|
isOpen && 'rotate-180 text-primary'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
AccordionTrigger.displayName = 'AccordionTrigger';
|
||||||
|
|
||||||
|
export interface AccordionContentProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
const AccordionContent = React.forwardRef<HTMLDivElement, AccordionContentProps>(
|
||||||
|
({ children, className, ...props }, ref) => {
|
||||||
|
const accContext = React.useContext(AccordionContext);
|
||||||
|
const itemContext = React.useContext(AccordionItemContext);
|
||||||
|
|
||||||
|
if (!accContext || !itemContext) {
|
||||||
|
throw new Error('AccordionContent must be used inside Accordion and AccordionItem');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOpen = accContext.openItems.includes(itemContext.value);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'pb-4 pt-0 text-sm text-muted-foreground leading-relaxed animate-fade-in',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
AccordionContent.displayName = 'AccordionContent';
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { AlertCircle, CheckCircle2, AlertTriangle, Info } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: 'default' | 'info' | 'success' | 'warning' | 'destructive';
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantMap = {
|
||||||
|
default: 'bg-background text-foreground border-border',
|
||||||
|
info: 'bg-sky-50 dark:bg-sky-950/30 text-sky-900 dark:text-sky-200 border-sky-200 dark:border-sky-800',
|
||||||
|
success: 'bg-emerald-50 dark:bg-emerald-950/30 text-emerald-900 dark:text-emerald-200 border-emerald-200 dark:border-emerald-800',
|
||||||
|
warning: 'bg-amber-50 dark:bg-amber-950/30 text-amber-900 dark:text-amber-200 border-amber-200 dark:border-amber-800',
|
||||||
|
destructive: 'bg-rose-50 dark:bg-rose-950/30 text-rose-900 dark:text-rose-200 border-rose-200 dark:border-rose-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultIcons = {
|
||||||
|
default: <Info className="h-5 w-5 text-primary" />,
|
||||||
|
info: <Info className="h-5 w-5 text-sky-600 dark:text-sky-400" />,
|
||||||
|
success: <CheckCircle2 className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />,
|
||||||
|
warning: <AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />,
|
||||||
|
destructive: <AlertCircle className="h-5 w-5 text-rose-600 dark:text-rose-400" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||||
|
({ className, variant = 'default', icon, children, ...props }, ref) => {
|
||||||
|
const displayIcon = icon !== undefined ? icon : defaultIcons[variant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(
|
||||||
|
'relative w-full rounded-xl border p-4 flex gap-3 items-start shadow-subtle',
|
||||||
|
variantMap[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{displayIcon && <div className="shrink-0 mt-0.5">{displayIcon}</div>}
|
||||||
|
<div className="flex-1 flex flex-col gap-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Alert.displayName = 'Alert';
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn('font-semibold text-sm leading-tight tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertTitle.displayName = 'AlertTitle';
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-xs opacity-90 leading-relaxed [&_p]:leading-relaxed', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDescription.displayName = 'AlertDescription';
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface AvatarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
status?: 'online' | 'offline' | 'busy' | 'away';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
sm: 'h-8 w-8 text-xs',
|
||||||
|
md: 'h-10 w-10 text-sm',
|
||||||
|
lg: 'h-12 w-12 text-base',
|
||||||
|
xl: 'h-16 w-16 text-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
|
||||||
|
({ className, size = 'md', status, children, ...props }, ref) => {
|
||||||
|
const statusColors = {
|
||||||
|
online: 'bg-emerald-500',
|
||||||
|
offline: 'bg-slate-400',
|
||||||
|
busy: 'bg-rose-500',
|
||||||
|
away: 'bg-amber-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex shrink-0 rounded-full overflow-visible',
|
||||||
|
sizeMap[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="relative flex h-full w-full overflow-hidden rounded-full border border-border bg-muted items-center justify-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{status && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-background ring-1 ring-background',
|
||||||
|
statusColors[status]
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Avatar.displayName = 'Avatar';
|
||||||
|
|
||||||
|
export interface AvatarImageProps
|
||||||
|
extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
|
onLoadingStatusChange?: (status: 'loading' | 'loaded' | 'error') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<HTMLImageElement, AvatarImageProps>(
|
||||||
|
({ className, src, alt = '', ...props }, ref) => {
|
||||||
|
const [hasError, setHasError] = React.useState(false);
|
||||||
|
|
||||||
|
if (!src || hasError) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
ref={ref}
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
onError={() => setHasError(true)}
|
||||||
|
className={cn('aspect-square h-full w-full object-cover', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
AvatarImage.displayName = 'AvatarImage';
|
||||||
|
|
||||||
|
export interface AvatarFallbackProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<HTMLDivElement, AvatarFallbackProps>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full items-center justify-center rounded-full bg-muted font-medium text-muted-foreground uppercase',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
AvatarFallback.displayName = 'AvatarFallback';
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback };
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?:
|
||||||
|
| 'default'
|
||||||
|
| 'primary'
|
||||||
|
| 'secondary'
|
||||||
|
| 'outline'
|
||||||
|
| 'success'
|
||||||
|
| 'warning'
|
||||||
|
| 'destructive'
|
||||||
|
| 'info';
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
dot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'md',
|
||||||
|
dot = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const variants = {
|
||||||
|
default:
|
||||||
|
'border-transparent bg-primary/10 text-primary hover:bg-primary/20',
|
||||||
|
primary:
|
||||||
|
'border-transparent bg-primary/10 text-primary hover:bg-primary/20',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
outline: 'border-border text-foreground bg-background',
|
||||||
|
success:
|
||||||
|
'border-transparent bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/20',
|
||||||
|
warning:
|
||||||
|
'border-transparent bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive/10 text-destructive hover:bg-destructive/20',
|
||||||
|
info: 'border-transparent bg-sky-500/10 text-sky-600 dark:text-sky-400 hover:bg-sky-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dotColors = {
|
||||||
|
default: 'bg-primary',
|
||||||
|
primary: 'bg-primary',
|
||||||
|
secondary: 'bg-secondary-foreground',
|
||||||
|
outline: 'bg-foreground',
|
||||||
|
success: 'bg-emerald-500',
|
||||||
|
warning: 'bg-amber-500',
|
||||||
|
destructive: 'bg-destructive',
|
||||||
|
info: 'bg-sky-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-2 py-0.5 text-xs font-medium',
|
||||||
|
md: 'px-2.5 py-1 text-xs font-semibold',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 shrink-0',
|
||||||
|
variants[variant],
|
||||||
|
sizes[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{dot && (
|
||||||
|
<span
|
||||||
|
className={cn('h-1.5 w-1.5 rounded-full', dotColors[variant])}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Badge.displayName = 'Badge';
|
||||||
|
|
||||||
|
export { Badge };
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link';
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'icon';
|
||||||
|
isLoading?: boolean;
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
isLoading = false,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
asChild = false,
|
||||||
|
type = 'button',
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const baseStyles =
|
||||||
|
'inline-flex items-center justify-center font-medium rounded-lg transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-[0.98] select-none';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary:
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md border border-transparent',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80 border border-transparent',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-background hover:bg-accent hover:text-accent-foreground text-foreground shadow-subtle',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground text-foreground',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline p-0 h-auto',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'h-8 px-3 text-xs gap-1.5',
|
||||||
|
md: 'h-10 px-4 text-sm gap-2',
|
||||||
|
lg: 'h-12 px-6 text-base gap-2.5',
|
||||||
|
icon: 'h-10 w-10 p-0 text-sm justify-center',
|
||||||
|
};
|
||||||
|
|
||||||
|
const combinedClassName = cn(baseStyles, variants[variant], sizes[size], className);
|
||||||
|
|
||||||
|
if (asChild && React.isValidElement(children)) {
|
||||||
|
return React.cloneElement(children as React.ReactElement<{ className?: string }>, {
|
||||||
|
className: cn(combinedClassName, (children.props as { className?: string }).className),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
className={combinedClassName}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-current shrink-0" />
|
||||||
|
) : (
|
||||||
|
leftIcon && <span className="shrink-0">{leftIcon}</span>
|
||||||
|
)}
|
||||||
|
{children && <span>{children}</span>}
|
||||||
|
{!isLoading && rightIcon && <span className="shrink-0">{rightIcon}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button };
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
hoverable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
||||||
|
({ className, hoverable = false, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border border-border bg-card text-card-foreground shadow-card transition-all duration-200',
|
||||||
|
hoverable && 'hover:shadow-float hover:-translate-y-0.5 hover:border-primary/20',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn('font-semibold text-lg leading-none tracking-tight text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground leading-relaxed', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex items-center p-6 pt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
};
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DialogContextValue {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogContext = React.createContext<DialogContextValue | null>(null);
|
||||||
|
|
||||||
|
export interface DialogProps {
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dialog: React.FC<DialogProps> = ({
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
defaultOpen = false,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
|
||||||
|
const isOpen = controlledOpen !== undefined ? controlledOpen : uncontrolledOpen;
|
||||||
|
|
||||||
|
const handleOpenChange = React.useCallback(
|
||||||
|
(newOpen: boolean) => {
|
||||||
|
if (controlledOpen === undefined) {
|
||||||
|
setUncontrolledOpen(newOpen);
|
||||||
|
}
|
||||||
|
onOpenChange?.(newOpen);
|
||||||
|
},
|
||||||
|
[controlledOpen, onOpenChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogContext.Provider value={{ open: isOpen, onOpenChange: handleOpenChange }}>
|
||||||
|
{children}
|
||||||
|
</DialogContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DialogTriggerProps {
|
||||||
|
asChild?: boolean;
|
||||||
|
children: React.ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogTrigger: React.FC<DialogTriggerProps> = ({ children }) => {
|
||||||
|
const context = React.useContext(DialogContext);
|
||||||
|
if (!context) throw new Error('DialogTrigger must be used within Dialog');
|
||||||
|
|
||||||
|
return React.cloneElement(children, {
|
||||||
|
onClick: (e: React.MouseEvent) => {
|
||||||
|
children.props.onClick?.(e);
|
||||||
|
context.onOpenChange(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
showClose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||||
|
({ className, children, showClose = true, ...props }, ref) => {
|
||||||
|
const context = React.useContext(DialogContext);
|
||||||
|
if (!context) throw new Error('DialogContent must be used within Dialog');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && context.open) {
|
||||||
|
context.onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
|
if (!context.open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm animate-fade-in"
|
||||||
|
onClick={() => context.onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
{/* Modal Panel */}
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 w-full max-w-lg rounded-xl border border-border bg-background p-6 shadow-dialog animate-scale-in flex flex-col gap-4',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showClose && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => context.onOpenChange(false)}
|
||||||
|
className="absolute right-4 top-4 rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
DialogContent.displayName = 'DialogContent';
|
||||||
|
|
||||||
|
const DialogHeader: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = 'DialogHeader';
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
HTMLHeadingElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h2
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = 'DialogTitle';
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground leading-relaxed', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = 'DialogDescription';
|
||||||
|
|
||||||
|
const DialogFooter: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 gap-2 mt-4',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = 'DialogFooter';
|
||||||
|
|
||||||
|
const DialogClose: React.FC<{ children: React.ReactElement }> = ({ children }) => {
|
||||||
|
const context = React.useContext(DialogContext);
|
||||||
|
if (!context) throw new Error('DialogClose must be used within Dialog');
|
||||||
|
|
||||||
|
return React.cloneElement(children, {
|
||||||
|
onClick: (e: React.MouseEvent) => {
|
||||||
|
children.props.onClick?.(e);
|
||||||
|
context.onOpenChange(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogClose,
|
||||||
|
};
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DropdownContextValue {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownContext = React.createContext<DropdownContextValue | null>(null);
|
||||||
|
|
||||||
|
export interface DropdownProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dropdown: React.FC<DropdownProps> = ({ children }) => {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownContext.Provider value={{ open, setOpen }}>
|
||||||
|
<div ref={dropdownRef} className="relative inline-block text-left">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</DropdownContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DropdownTriggerProps {
|
||||||
|
children: React.ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownTrigger: React.FC<DropdownTriggerProps> = ({ children }) => {
|
||||||
|
const context = React.useContext(DropdownContext);
|
||||||
|
if (!context) throw new Error('DropdownTrigger must be used within Dropdown');
|
||||||
|
|
||||||
|
return React.cloneElement(children, {
|
||||||
|
onClick: (e: React.MouseEvent) => {
|
||||||
|
children.props.onClick?.(e);
|
||||||
|
context.setOpen(!context.open);
|
||||||
|
},
|
||||||
|
'aria-expanded': context.open,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DropdownContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
align?: 'left' | 'right' | 'center';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownContent = React.forwardRef<HTMLDivElement, DropdownContentProps>(
|
||||||
|
({ className, align = 'left', children, ...props }, ref) => {
|
||||||
|
const context = React.useContext(DropdownContext);
|
||||||
|
if (!context) throw new Error('DropdownContent must be used within Dropdown');
|
||||||
|
|
||||||
|
if (!context.open) return null;
|
||||||
|
|
||||||
|
const alignClasses = {
|
||||||
|
left: 'left-0 origin-top-left',
|
||||||
|
right: 'right-0 origin-top-right',
|
||||||
|
center: 'left-1/2 -translate-x-1/2 origin-top',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="menu"
|
||||||
|
className={cn(
|
||||||
|
'absolute z-50 mt-2 min-w-[12rem] rounded-xl border border-border bg-popover p-1 text-popover-foreground shadow-dropdown animate-scale-in focus:outline-none',
|
||||||
|
alignClasses[align],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
DropdownContent.displayName = 'DropdownContent';
|
||||||
|
|
||||||
|
export interface DropdownItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
disabled?: boolean;
|
||||||
|
destructive?: boolean;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DropdownItem = React.forwardRef<HTMLDivElement, DropdownItemProps>(
|
||||||
|
({ className, disabled, destructive, icon, children, onClick, ...props }, ref) => {
|
||||||
|
const context = React.useContext(DropdownContext);
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (disabled) return;
|
||||||
|
onClick?.(e);
|
||||||
|
context?.setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="menuitem"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm font-medium outline-none transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
destructive && 'text-destructive hover:bg-destructive/10 hover:text-destructive',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon && <span className="h-4 w-4 shrink-0 text-muted-foreground">{icon}</span>}
|
||||||
|
<span className="flex-1">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
DropdownItem.displayName = 'DropdownItem';
|
||||||
|
|
||||||
|
const DropdownLabel: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={cn('px-2.5 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DropdownLabel.displayName = 'DropdownLabel';
|
||||||
|
|
||||||
|
const DropdownSeparator: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}) => (
|
||||||
|
<div className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} />
|
||||||
|
);
|
||||||
|
DropdownSeparator.displayName = 'DropdownSeparator';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dropdown,
|
||||||
|
DropdownTrigger,
|
||||||
|
DropdownContent,
|
||||||
|
DropdownItem,
|
||||||
|
DropdownLabel,
|
||||||
|
DropdownSeparator,
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
'aria-label': string;
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
isLoading?: boolean;
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
variant = 'ghost',
|
||||||
|
size = 'md',
|
||||||
|
isLoading = false,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
asChild = false,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
type = 'button',
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const baseStyles =
|
||||||
|
'inline-flex items-center justify-center rounded-lg transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-95 shrink-0';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground text-foreground shadow-subtle',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground text-muted-foreground hover:text-foreground',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'h-8 w-8 text-xs',
|
||||||
|
md: 'h-10 w-10 text-sm',
|
||||||
|
lg: 'h-12 w-12 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
const combinedClassName = cn(baseStyles, variants[variant], sizes[size], className);
|
||||||
|
|
||||||
|
if (asChild && React.isValidElement(children)) {
|
||||||
|
return React.cloneElement(children as React.ReactElement<{ className?: string; 'aria-label'?: string }>, {
|
||||||
|
className: cn(combinedClassName, (children.props as { className?: string }).className),
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type={type}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
className={combinedClassName}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
IconButton.displayName = 'IconButton';
|
||||||
|
|
||||||
|
export { IconButton };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export * from './button';
|
||||||
|
export * from './icon-button';
|
||||||
|
export * from './input';
|
||||||
|
export * from './textarea';
|
||||||
|
export * from './badge';
|
||||||
|
export * from './card';
|
||||||
|
export * from './avatar';
|
||||||
|
export * from './separator';
|
||||||
|
export * from './tabs';
|
||||||
|
export * from './accordion';
|
||||||
|
export * from './tooltip';
|
||||||
|
export * from './dialog';
|
||||||
|
export * from './dropdown';
|
||||||
|
export * from './popover';
|
||||||
|
export * from './toast';
|
||||||
|
export * from './alert';
|
||||||
|
export * from './progress';
|
||||||
|
export * from './skeleton';
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
helperText?: string;
|
||||||
|
error?: string;
|
||||||
|
leftIcon?: React.ReactNode;
|
||||||
|
rightIcon?: React.ReactNode;
|
||||||
|
containerClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
type = 'text',
|
||||||
|
label,
|
||||||
|
helperText,
|
||||||
|
error,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
containerClassName,
|
||||||
|
id,
|
||||||
|
disabled,
|
||||||
|
required,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const generatedId = React.useId();
|
||||||
|
const inputId = id || generatedId;
|
||||||
|
const helperId = `${inputId}-helper`;
|
||||||
|
const errorId = `${inputId}-error`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full flex flex-col gap-1.5', containerClassName)}>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="text-sm font-medium text-foreground flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative flex items-center w-full">
|
||||||
|
{leftIcon && (
|
||||||
|
<div className="absolute left-3 text-muted-foreground pointer-events-none flex items-center justify-center">
|
||||||
|
{leftIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
id={inputId}
|
||||||
|
type={type}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={
|
||||||
|
error ? errorId : helperText ? helperId : undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 shadow-subtle',
|
||||||
|
leftIcon && 'pl-9',
|
||||||
|
rightIcon && 'pr-9',
|
||||||
|
error && 'border-destructive focus-visible:ring-destructive',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{rightIcon && (
|
||||||
|
<div className="absolute right-3 text-muted-foreground pointer-events-none flex items-center justify-center">
|
||||||
|
{rightIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p id={errorId} className="text-xs text-destructive font-medium">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : helperText ? (
|
||||||
|
<p id={helperId} className="text-xs text-muted-foreground">
|
||||||
|
{helperText}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface PopoverContextValue {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PopoverContext = React.createContext<PopoverContextValue | null>(null);
|
||||||
|
|
||||||
|
export interface PopoverProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Popover: React.FC<PopoverProps> = ({ children }) => {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const popoverRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverContext.Provider value={{ open, setOpen }}>
|
||||||
|
<div ref={popoverRef} className="relative inline-block text-left">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</PopoverContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PopoverTriggerProps {
|
||||||
|
children: React.ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PopoverTrigger: React.FC<PopoverTriggerProps> = ({ children }) => {
|
||||||
|
const context = React.useContext(PopoverContext);
|
||||||
|
if (!context) throw new Error('PopoverTrigger must be used within Popover');
|
||||||
|
|
||||||
|
return React.cloneElement(children, {
|
||||||
|
onClick: (e: React.MouseEvent) => {
|
||||||
|
children.props.onClick?.(e);
|
||||||
|
context.setOpen(!context.open);
|
||||||
|
},
|
||||||
|
'aria-expanded': context.open,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PopoverContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
align?: 'left' | 'right' | 'center';
|
||||||
|
}
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<HTMLDivElement, PopoverContentProps>(
|
||||||
|
({ className, align = 'center', children, ...props }, ref) => {
|
||||||
|
const context = React.useContext(PopoverContext);
|
||||||
|
if (!context) throw new Error('PopoverContent must be used within Popover');
|
||||||
|
|
||||||
|
if (!context.open) return null;
|
||||||
|
|
||||||
|
const alignClasses = {
|
||||||
|
left: 'left-0 origin-top-left',
|
||||||
|
right: 'right-0 origin-top-right',
|
||||||
|
center: 'left-1/2 -translate-x-1/2 origin-top',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'absolute z-50 mt-2 w-72 rounded-xl border border-border bg-popover p-4 text-popover-foreground shadow-dropdown outline-none animate-scale-in',
|
||||||
|
alignClasses[align],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
PopoverContent.displayName = 'PopoverContent';
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent };
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
value?: number;
|
||||||
|
max?: number;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
showLabel?: boolean;
|
||||||
|
indicatorClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heightMap = {
|
||||||
|
sm: 'h-1.5',
|
||||||
|
md: 'h-2.5',
|
||||||
|
lg: 'h-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
value = 0,
|
||||||
|
max = 100,
|
||||||
|
size = 'md',
|
||||||
|
showLabel = false,
|
||||||
|
indicatorClassName,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const percentage = Math.min(Math.max(0, (value / max) * 100), 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col gap-1.5">
|
||||||
|
{showLabel && (
|
||||||
|
<div className="flex justify-between items-center text-xs font-medium text-muted-foreground">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span>{Math.round(percentage)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={value}
|
||||||
|
className={cn(
|
||||||
|
'relative w-full overflow-hidden rounded-full bg-secondary',
|
||||||
|
heightMap[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full w-full flex-1 bg-primary transition-all duration-300 ease-in-out rounded-full',
|
||||||
|
indicatorClassName
|
||||||
|
)}
|
||||||
|
style={{ transform: `translateX(-${100 - percentage}%)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Progress.displayName = 'Progress';
|
||||||
|
|
||||||
|
export { Progress };
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
orientation?: 'horizontal' | 'vertical';
|
||||||
|
label?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const isHorizontal = orientation === 'horizontal';
|
||||||
|
const displayLabel = label || children;
|
||||||
|
|
||||||
|
if (displayLabel && isHorizontal) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
className={cn('flex items-center w-full my-4 text-xs text-muted-foreground uppercase tracking-wider', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex-grow h-px bg-border" />
|
||||||
|
<span className="px-3 font-medium text-muted-foreground">{displayLabel}</span>
|
||||||
|
<div className="flex-grow h-px bg-border" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
isHorizontal ? 'h-px w-full my-2' : 'h-full w-px mx-2 inline-block align-middle',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Separator.displayName = 'Separator';
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: 'text' | 'circular' | 'rectangular';
|
||||||
|
}
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
variant = 'rectangular',
|
||||||
|
...props
|
||||||
|
}: SkeletonProps) {
|
||||||
|
const variantClasses = {
|
||||||
|
text: 'h-4 w-full rounded',
|
||||||
|
circular: 'rounded-full',
|
||||||
|
rectangular: 'rounded-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'animate-pulse bg-muted/80',
|
||||||
|
variantClasses[variant],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TabsContextValue {
|
||||||
|
value: string;
|
||||||
|
onValueChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsContext = React.createContext<TabsContextValue | null>(null);
|
||||||
|
|
||||||
|
export interface TabsProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
defaultValue?: string;
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tabs = React.forwardRef<HTMLDivElement, TabsProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
defaultValue,
|
||||||
|
value: controlledValue,
|
||||||
|
onValueChange,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [selectedTab, setSelectedTab] = React.useState(defaultValue || '');
|
||||||
|
|
||||||
|
const currentTab = controlledValue !== undefined ? controlledValue : selectedTab;
|
||||||
|
|
||||||
|
const handleValueChange = React.useCallback(
|
||||||
|
(val: string) => {
|
||||||
|
if (controlledValue === undefined) {
|
||||||
|
setSelectedTab(val);
|
||||||
|
}
|
||||||
|
onValueChange?.(val);
|
||||||
|
},
|
||||||
|
[controlledValue, onValueChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContext.Provider value={{ value: currentTab, onValueChange: handleValueChange }}>
|
||||||
|
<div ref={ref} className={cn('w-full flex flex-col', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TabsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Tabs.displayName = 'Tabs';
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="tablist"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-10 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground w-full sm:w-auto',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = 'TabsList';
|
||||||
|
|
||||||
|
export interface TabsTriggerProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
|
||||||
|
({ className, value, children, ...props }, ref) => {
|
||||||
|
const context = React.useContext(TabsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('TabsTrigger must be used within a Tabs component');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = context.value === value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => context.onValueChange(value)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
isActive
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
TabsTrigger.displayName = 'TabsTrigger';
|
||||||
|
|
||||||
|
export interface TabsContentProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
|
||||||
|
({ className, value, children, ...props }, ref) => {
|
||||||
|
const context = React.useContext(TabsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('TabsContent must be used within a Tabs component');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.value !== value) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="tabpanel"
|
||||||
|
tabIndex={0}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 animate-fade-in',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
TabsContent.displayName = 'TabsContent';
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label?: string;
|
||||||
|
helperText?: string;
|
||||||
|
error?: string;
|
||||||
|
containerClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
helperText,
|
||||||
|
error,
|
||||||
|
containerClassName,
|
||||||
|
id,
|
||||||
|
disabled,
|
||||||
|
required,
|
||||||
|
rows = 4,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const generatedId = React.useId();
|
||||||
|
const textareaId = id || generatedId;
|
||||||
|
const helperId = `${textareaId}-helper`;
|
||||||
|
const errorId = `${textareaId}-error`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('w-full flex flex-col gap-1.5', containerClassName)}>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={textareaId}
|
||||||
|
className="text-sm font-medium text-foreground flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
id={textareaId}
|
||||||
|
rows={rows}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={
|
||||||
|
error ? errorId : helperText ? helperId : undefined
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 shadow-subtle resize-y',
|
||||||
|
error && 'border-destructive focus-visible:ring-destructive',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error ? (
|
||||||
|
<p id={errorId} className="text-xs text-destructive font-medium">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : helperText ? (
|
||||||
|
<p id={helperId} className="text-xs text-muted-foreground">
|
||||||
|
{helperText}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { CheckCircle2, AlertCircle, AlertTriangle, Info, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
|
export interface ToastMessage {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
variant?: ToastVariant;
|
||||||
|
duration?: number;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
toast: (options: Omit<ToastMessage, 'id'>) => void;
|
||||||
|
dismiss: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = React.createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useToast = () => {
|
||||||
|
const context = React.useContext(ToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToast must be used within a ToastProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
|
||||||
|
|
||||||
|
const toast = React.useCallback((options: Omit<ToastMessage, 'id'>) => {
|
||||||
|
const id = Math.random().toString(36).substring(2, 9);
|
||||||
|
const newToast: ToastMessage = { id, duration: 4000, variant: 'default', ...options };
|
||||||
|
|
||||||
|
setToasts((prev) => [...prev, newToast]);
|
||||||
|
|
||||||
|
if (newToast.duration && newToast.duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, newToast.duration);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = React.useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toast, dismiss }}>
|
||||||
|
{children}
|
||||||
|
{/* Toast viewport */}
|
||||||
|
<div className="fixed bottom-0 right-0 z-50 flex max-h-screen w-full flex-col-reverse p-4 sm:max-w-[420px] gap-2 pointer-events-none">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<ToastItem key={t.id} toast={t} onDismiss={() => dismiss(t.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
default: Info,
|
||||||
|
info: Info,
|
||||||
|
success: CheckCircle2,
|
||||||
|
error: AlertCircle,
|
||||||
|
warning: AlertTriangle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantClasses: Record<ToastVariant, string> = {
|
||||||
|
default: 'border-border bg-background text-foreground',
|
||||||
|
info: 'border-sky-500/20 bg-sky-50 dark:bg-sky-950/40 text-sky-900 dark:text-sky-200',
|
||||||
|
success: 'border-emerald-500/20 bg-emerald-50 dark:bg-emerald-950/40 text-emerald-900 dark:text-emerald-200',
|
||||||
|
error: 'border-rose-500/20 bg-rose-50 dark:bg-rose-950/40 text-rose-900 dark:text-rose-200',
|
||||||
|
warning: 'border-amber-500/20 bg-amber-50 dark:bg-amber-950/40 text-amber-900 dark:text-amber-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconClasses: Record<ToastVariant, string> = {
|
||||||
|
default: 'text-primary',
|
||||||
|
info: 'text-sky-500',
|
||||||
|
success: 'text-emerald-500',
|
||||||
|
error: 'text-rose-500',
|
||||||
|
warning: 'text-amber-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToastItem: React.FC<{ toast: ToastMessage; onDismiss: () => void }> = ({
|
||||||
|
toast,
|
||||||
|
onDismiss,
|
||||||
|
}) => {
|
||||||
|
const variant = toast.variant || 'default';
|
||||||
|
const IconComponent = iconMap[variant];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-auto flex w-full items-start justify-between gap-3 rounded-xl border p-4 shadow-dropdown transition-all animate-slide-in-right',
|
||||||
|
variantClasses[variant]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconComponent className={cn('h-5 w-5 shrink-0 mt-0.5', iconClasses[variant])} />
|
||||||
|
<div className="flex-1 flex flex-col gap-1">
|
||||||
|
{toast.title && <h5 className="font-semibold text-sm leading-tight">{toast.title}</h5>}
|
||||||
|
{toast.description && (
|
||||||
|
<p className="text-xs opacity-90 leading-relaxed">{toast.description}</p>
|
||||||
|
)}
|
||||||
|
{toast.action && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toast.action.onClick}
|
||||||
|
className="mt-2 text-xs font-semibold underline underline-offset-2 hover:opacity-80 self-start"
|
||||||
|
>
|
||||||
|
{toast.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="rounded-md p-1 opacity-70 hover:opacity-100 transition-opacity"
|
||||||
|
aria-label="Dismiss toast"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ToastItem as Toast };
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface TooltipProps {
|
||||||
|
content: React.ReactNode;
|
||||||
|
position?: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
children: React.ReactElement;
|
||||||
|
className?: string;
|
||||||
|
delayMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tooltip: React.FC<TooltipProps> = ({
|
||||||
|
content,
|
||||||
|
position = 'top',
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
delayMs = 200,
|
||||||
|
}) => {
|
||||||
|
const [isVisible, setIsVisible] = React.useState(false);
|
||||||
|
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const showTooltip = () => {
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setIsVisible(true);
|
||||||
|
}, delayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
setIsVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const positionClasses = {
|
||||||
|
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
|
||||||
|
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
|
||||||
|
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
|
||||||
|
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative inline-flex"
|
||||||
|
onMouseEnter={showTooltip}
|
||||||
|
onMouseLeave={hideTooltip}
|
||||||
|
onFocus={showTooltip}
|
||||||
|
onBlur={hideTooltip}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{isVisible && content && (
|
||||||
|
<div
|
||||||
|
role="tooltip"
|
||||||
|
className={cn(
|
||||||
|
'absolute z-50 whitespace-nowrap rounded-md bg-slate-900 px-2.5 py-1 text-xs font-medium text-slate-50 shadow-md animate-fade-in pointer-events-none dark:bg-slate-100 dark:text-slate-900',
|
||||||
|
positionClasses[position],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Tooltip };
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './utils';
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines class names using clsx and merges Tailwind CSS classes using tailwind-merge.
|
||||||
|
*/
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--primary: 239 84% 60%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--accent: 239 84% 96%;
|
||||||
|
--accent-foreground: 239 84% 40%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--success: 142.1 76.2% 36.3%;
|
||||||
|
--success-foreground: 355.7 100% 97.3%;
|
||||||
|
|
||||||
|
--warning: 38 92% 50%;
|
||||||
|
--warning-foreground: 48 96% 12%;
|
||||||
|
|
||||||
|
--info: 199 89% 48%;
|
||||||
|
--info-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 239 84% 60%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 224 71% 4%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 224 71% 6%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--popover: 224 71% 6%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--primary: 239 84% 67%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--accent: 239 84% 20%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--success: 142.1 70.6% 45.3%;
|
||||||
|
--success-foreground: 144 100% 97.3%;
|
||||||
|
|
||||||
|
--warning: 38 92% 50%;
|
||||||
|
--warning-foreground: 48 96% 12%;
|
||||||
|
|
||||||
|
--info: 199 89% 58%;
|
||||||
|
--info-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 239 84% 67%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground antialiased selection:bg-primary/20 selection:text-primary;
|
||||||
|
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
export const tokens = {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: '#f0f3ff',
|
||||||
|
100: '#e0e7ff',
|
||||||
|
200: '#c7d2fe',
|
||||||
|
300: '#a5b4fc',
|
||||||
|
400: '#818cf8',
|
||||||
|
500: '#6366f1',
|
||||||
|
600: '#4f46e5',
|
||||||
|
700: '#4338ca',
|
||||||
|
800: '#3730a3',
|
||||||
|
900: '#312e81',
|
||||||
|
950: '#1e1b4b',
|
||||||
|
},
|
||||||
|
neutral: {
|
||||||
|
50: '#f8fafc',
|
||||||
|
100: '#f1f5f9',
|
||||||
|
200: '#e2e8f0',
|
||||||
|
300: '#cbd5e1',
|
||||||
|
400: '#94a3b8',
|
||||||
|
500: '#64748b',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
950: '#020617',
|
||||||
|
},
|
||||||
|
semantic: {
|
||||||
|
success: '#10b981',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
error: '#ef4444',
|
||||||
|
info: '#06b6d4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
mono: 'JetBrains Mono, Menlo, Monaco, Consolas, monospace',
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
xs: ['0.75rem', { lineHeight: '1rem' }],
|
||||||
|
sm: ['0.875rem', { lineHeight: '1.25rem' }],
|
||||||
|
base: ['1rem', { lineHeight: '1.5rem' }],
|
||||||
|
lg: ['1.125rem', { lineHeight: '1.75rem' }],
|
||||||
|
xl: ['1.25rem', { lineHeight: '1.75rem' }],
|
||||||
|
'2xl': ['1.5rem', { lineHeight: '2rem' }],
|
||||||
|
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
|
||||||
|
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
|
||||||
|
'5xl': ['3rem', { lineHeight: '1' }],
|
||||||
|
},
|
||||||
|
fontWeight: {
|
||||||
|
normal: '400',
|
||||||
|
medium: '500',
|
||||||
|
semibold: '600',
|
||||||
|
bold: '700',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
containerPadding: '2rem',
|
||||||
|
sectionSm: '3rem',
|
||||||
|
sectionMd: '5rem',
|
||||||
|
sectionLg: '8rem',
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
sm: '0.25rem',
|
||||||
|
md: '0.375rem',
|
||||||
|
lg: '0.5rem',
|
||||||
|
xl: '0.75rem',
|
||||||
|
'2xl': '1rem',
|
||||||
|
full: '9999px',
|
||||||
|
},
|
||||||
|
shadows: {
|
||||||
|
subtle: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||||
|
card: '0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.04)',
|
||||||
|
float: '0 4px 16px -2px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04)',
|
||||||
|
dropdown: '0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.05)',
|
||||||
|
},
|
||||||
|
breakpoints: {
|
||||||
|
sm: '640px',
|
||||||
|
md: '768px',
|
||||||
|
lg: '1024px',
|
||||||
|
xl: '1280px',
|
||||||
|
'2xl': '1536px',
|
||||||
|
},
|
||||||
|
transitions: {
|
||||||
|
fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
normal: '250ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
slow: '350ms cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type DesignTokens = typeof tokens;
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: '2rem',
|
||||||
|
screens: {
|
||||||
|
'2xl': '1400px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
50: '#f0f3ff',
|
||||||
|
100: '#e0e7ff',
|
||||||
|
500: '#6366f1',
|
||||||
|
600: '#4f46e5',
|
||||||
|
700: '#4338ca',
|
||||||
|
900: '#312e81',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
DEFAULT: 'hsl(var(--success))',
|
||||||
|
foreground: 'hsl(var(--success-foreground))',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
DEFAULT: 'hsl(var(--warning))',
|
||||||
|
foreground: 'hsl(var(--warning-foreground))',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
DEFAULT: 'hsl(var(--info))',
|
||||||
|
foreground: 'hsl(var(--info-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
xl: 'calc(var(--radius) + 4px)',
|
||||||
|
'2xl': 'calc(var(--radius) + 8px)',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
subtle: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||||
|
card: '0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.04)',
|
||||||
|
float: '0 4px 16px -2px rgba(0, 0, 0, 0.08), 0 2px 4px -2px rgba(0, 0, 0, 0.04)',
|
||||||
|
dropdown: '0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.05)',
|
||||||
|
dialog: '0 20px 35px -10px rgba(0, 0, 0, 0.2), 0 10px 15px -5px rgba(0, 0, 0, 0.08)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'accordion-down': {
|
||||||
|
from: { height: '0' },
|
||||||
|
to: { height: 'var(--radix-accordion-content-height, auto)' },
|
||||||
|
},
|
||||||
|
'accordion-up': {
|
||||||
|
from: { height: 'var(--radix-accordion-content-height, auto)' },
|
||||||
|
to: { height: '0' },
|
||||||
|
},
|
||||||
|
'fade-in': {
|
||||||
|
from: { opacity: '0' },
|
||||||
|
to: { opacity: '1' },
|
||||||
|
},
|
||||||
|
'fade-out': {
|
||||||
|
from: { opacity: '1' },
|
||||||
|
to: { opacity: '0' },
|
||||||
|
},
|
||||||
|
'scale-in': {
|
||||||
|
from: { opacity: '0', transform: 'scale(0.95)' },
|
||||||
|
to: { opacity: '1', transform: 'scale(1)' },
|
||||||
|
},
|
||||||
|
'slide-in-right': {
|
||||||
|
from: { transform: 'translateX(100%)' },
|
||||||
|
to: { transform: 'translateX(0)' },
|
||||||
|
},
|
||||||
|
'slide-out-right': {
|
||||||
|
from: { transform: 'translateX(0)' },
|
||||||
|
to: { transform: 'translateX(100%)' },
|
||||||
|
},
|
||||||
|
pulse: {
|
||||||
|
'0%, 100%': { opacity: '1' },
|
||||||
|
'50%': { opacity: '0.4' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
'fade-in': 'fade-in 0.2s ease-out',
|
||||||
|
'fade-out': 'fade-out 0.2s ease-in',
|
||||||
|
'scale-in': 'scale-in 0.2s ease-out',
|
||||||
|
'slide-in-right': 'slide-in-right 0.3s ease-out',
|
||||||
|
'slide-out-right': 'slide-out-right 0.3s ease-in',
|
||||||
|
pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
"lib": [
|
"lib": [
|
||||||
"dom",
|
"dom",
|
||||||
"dom.iterable",
|
"dom.iterable",
|
||||||
|
|||||||
Reference in New Issue
Block a user