diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/eslint.config.mjs b/eslint.config.mjs index e69de29..dc16add 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4f65814..2af00ae 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,9 @@ import React from 'react'; +import '../styles/globals.css'; export const metadata = { - title: 'SupportHub', - description: 'Enterprise Support Platform', + title: 'SupportHub | Customer Support Platform', + description: 'Enterprise AI-powered customer support and ticketing platform for modern teams.', }; export default function RootLayout({ @@ -11,8 +12,11 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - {children} + + + {children} + ); } + diff --git a/src/components/index.ts b/src/components/index.ts index e69de29..6de577b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -0,0 +1,3 @@ +export * from './ui'; +export * from './marketing'; +export * from './layout'; diff --git a/src/components/layout/footer.tsx b/src/components/layout/footer.tsx new file mode 100644 index 0000000..9b8cf29 --- /dev/null +++ b/src/components/layout/footer.tsx @@ -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 { + 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( + ( + { + 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 ( +
+ +
+ {/* Brand Information Column */} +
+ +
+ +
+ + {brandName} + +
+

+ {description} +

+
+ + + + + + + + + + + + + + + +
+
+ + {/* Link Columns */} +
+ {columns.map((col, idx) => ( +
+

+ {col.title} +

+ +
+ ))} +
+
+ + {/* Bottom Bar */} +
+

{copyright}

+
+ + All Systems Operational + +
+
+
+
+ ); + } +); + +Footer.displayName = 'Footer'; + +export { Footer }; diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts new file mode 100644 index 0000000..5def158 --- /dev/null +++ b/src/components/layout/index.ts @@ -0,0 +1,4 @@ +export * from './navbar'; +export * from './mobile-menu'; +export * from './footer'; +export * from './page-container'; diff --git a/src/components/layout/mobile-menu.tsx b/src/components/layout/mobile-menu.tsx new file mode 100644 index 0000000..ffb25a2 --- /dev/null +++ b/src/components/layout/mobile-menu.tsx @@ -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 = ({ + 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 ( +
+ {/* Backdrop Overlay */} + + ); +}; + +export { MobileMenu }; diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx new file mode 100644 index 0000000..f263f6c --- /dev/null +++ b/src/components/layout/navbar.tsx @@ -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 { + 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( + ( + { + 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 || ( + +
+ +
+ + {brandName} + +
+ ); + + const actionNode = actions || ( +
+ + +
+ ); + + return ( +
+ +
+ {/* Brand Logo */} +
+ {logoNode} + + {/* Desktop Nav Links */} + +
+ + {/* Desktop Actions */} +
{actionNode}
+ + {/* Mobile Menu Trigger */} +
+ setMobileMenuOpen(true)} + > + + +
+
+
+ + {/* Mobile Slide-over Menu */} + setMobileMenuOpen(false)} + brand={logoNode} + navLinks={navLinks} + actions={actionNode} + /> +
+ ); + } +); + +Navbar.displayName = 'Navbar'; + +export { Navbar }; diff --git a/src/components/layout/page-container.tsx b/src/components/layout/page-container.tsx new file mode 100644 index 0000000..0f86d6a --- /dev/null +++ b/src/components/layout/page-container.tsx @@ -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 { + showNavbar?: boolean; + showFooter?: boolean; + navbarProps?: NavbarProps; + footerProps?: FooterProps; +} + +const PageContainer = React.forwardRef( + ( + { + className, + showNavbar = true, + showFooter = true, + navbarProps, + footerProps, + children, + ...props + }, + ref + ) => { + return ( + +
+ {showNavbar && } +
{children}
+ {showFooter &&
} +
+
+ ); + } +); + +PageContainer.displayName = 'PageContainer'; + +export { PageContainer }; diff --git a/src/components/marketing/browser-mockup.tsx b/src/components/marketing/browser-mockup.tsx new file mode 100644 index 0000000..1fb7ec5 --- /dev/null +++ b/src/components/marketing/browser-mockup.tsx @@ -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 { + url?: string; + children: React.ReactNode; +} + +const BrowserMockup = React.forwardRef( + ({ className, url = 'https://app.supporthub.io', children, ...props }, ref) => { + return ( +
+ {/* Browser Header Bar */} +
+ {/* Window Traffic Lights */} +
+ + + +
+ + {/* Address Bar */} +
+ + {url} +
+ + {/* Spacer */} +
+
+ + {/* Viewport Content */} +
+ {children} +
+
+ ); + } +); + +BrowserMockup.displayName = 'BrowserMockup'; + +export { BrowserMockup }; diff --git a/src/components/marketing/container.tsx b/src/components/marketing/container.tsx new file mode 100644 index 0000000..e314a38 --- /dev/null +++ b/src/components/marketing/container.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface ContainerProps extends React.HTMLAttributes { + 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( + ({ className, size = 'default', as: Component = 'div', children, ...props }, ref) => { + return ( + + {children} + + ); + } +); + +Container.displayName = 'Container'; + +export { Container }; diff --git a/src/components/marketing/cta-section.tsx b/src/components/marketing/cta-section.tsx new file mode 100644 index 0000000..ea36b31 --- /dev/null +++ b/src/components/marketing/cta-section.tsx @@ -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, 'title'> { + title: React.ReactNode; + description?: React.ReactNode; + primaryAction?: React.ReactNode; + secondaryAction?: React.ReactNode; + eyebrow?: string; + variant?: 'default' | 'card' | 'dark'; +} + +const CTASection = React.forwardRef( + ( + { + className, + title, + description, + primaryAction, + secondaryAction, + eyebrow, + variant = 'card', + ...props + }, + ref + ) => { + return ( +
+ +
+ {/* Background Glow */} +
+ + {eyebrow && {eyebrow}} + +

+ {title} +

+ + {description && ( +

+ {description} +

+ )} + + {(primaryAction || secondaryAction) && ( +
+ {primaryAction} + {secondaryAction} +
+ )} +
+ +
+ ); + } +); + +CTASection.displayName = 'CTASection'; + +export { CTASection }; diff --git a/src/components/marketing/eyebrow.tsx b/src/components/marketing/eyebrow.tsx new file mode 100644 index 0000000..ad4fb17 --- /dev/null +++ b/src/components/marketing/eyebrow.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface EyebrowProps extends React.HTMLAttributes { + icon?: React.ReactNode; + variant?: 'default' | 'primary' | 'outline'; +} + +const Eyebrow = React.forwardRef( + ({ 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 ( +
+ {icon && {icon}} + {children} +
+ ); + } +); + +Eyebrow.displayName = 'Eyebrow'; + +export { Eyebrow }; diff --git a/src/components/marketing/feature-card.tsx b/src/components/marketing/feature-card.tsx new file mode 100644 index 0000000..b74c2be --- /dev/null +++ b/src/components/marketing/feature-card.tsx @@ -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 { + icon: React.ReactNode; + title: string; + description: string; + badge?: string; + linkText?: string; + onLinkClick?: () => void; + variant?: 'default' | 'bordered' | 'flat' | 'gradient'; +} + +const FeatureCard = React.forwardRef( + ( + { + 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 ( + + +
+
+ {icon} +
+ {badge && {badge}} +
+ +
+

+ {title} +

+

+ {description} +

+
+ + {linkText && ( +
+ +
+ )} +
+
+ ); + } +); + +FeatureCard.displayName = 'FeatureCard'; + +export { FeatureCard }; diff --git a/src/components/marketing/gradient-text.tsx b/src/components/marketing/gradient-text.tsx new file mode 100644 index 0000000..6cabdbb --- /dev/null +++ b/src/components/marketing/gradient-text.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface GradientTextProps extends React.HTMLAttributes { + 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( + ({ className, gradient = 'primary', as: Component = 'span', children, ...props }, ref) => { + return ( + + {children} + + ); + } +); + +GradientText.displayName = 'GradientText'; + +export { GradientText }; diff --git a/src/components/marketing/index.ts b/src/components/marketing/index.ts new file mode 100644 index 0000000..6c81b95 --- /dev/null +++ b/src/components/marketing/index.ts @@ -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'; diff --git a/src/components/marketing/logo-cloud.tsx b/src/components/marketing/logo-cloud.tsx new file mode 100644 index 0000000..46d2b48 --- /dev/null +++ b/src/components/marketing/logo-cloud.tsx @@ -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 { + title?: string; + logos: LogoItem[]; +} + +const LogoCloud = React.forwardRef( + ({ className, title, logos, ...props }, ref) => { + return ( +
+ {title && ( +

+ {title} +

+ )} +
+ {logos.map((logo, index) => ( +
+ {logo.svg ? ( + logo.svg + ) : logo.logoUrl ? ( + {logo.name} + ) : ( + {logo.name} + )} +
+ ))} +
+
+ ); + } +); + +LogoCloud.displayName = 'LogoCloud'; + +export { LogoCloud }; diff --git a/src/components/marketing/metric-card.tsx b/src/components/marketing/metric-card.tsx new file mode 100644 index 0000000..fc8e573 --- /dev/null +++ b/src/components/marketing/metric-card.tsx @@ -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 { + value: string; + label: string; + trend?: string; + trendDirection?: 'up' | 'down' | 'neutral'; + subtext?: string; + icon?: React.ReactNode; +} + +const MetricCard = React.forwardRef( + ( + { + className, + value, + label, + trend, + trendDirection = 'up', + subtext, + icon, + ...props + }, + ref + ) => { + const trendIcons = { + up: , + down: , + neutral: , + }; + + const trendVariants = { + up: 'success' as const, + down: 'destructive' as const, + neutral: 'secondary' as const, + }; + + return ( + + +
+ + {label} + + {icon &&
{icon}
} +
+ +
+ + {value} + + {trend && ( + + {trendIcons[trendDirection]} + {trend} + + )} +
+ + {subtext && ( +

{subtext}

+ )} +
+
+ ); + } +); + +MetricCard.displayName = 'MetricCard'; + +export { MetricCard }; diff --git a/src/components/marketing/product-mockup.tsx b/src/components/marketing/product-mockup.tsx new file mode 100644 index 0000000..569182d --- /dev/null +++ b/src/components/marketing/product-mockup.tsx @@ -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 { + url?: string; + glow?: boolean; +} + +const ProductMockup = React.forwardRef( + ({ className, url, glow = true, children, ...props }, ref) => { + return ( +
+ {/* Subtle Ambient Radial Glow behind the mockup */} + {glow && ( + + ); + } +); + +ProductMockup.displayName = 'ProductMockup'; + +export { ProductMockup }; diff --git a/src/components/marketing/screenshot-frame.tsx b/src/components/marketing/screenshot-frame.tsx new file mode 100644 index 0000000..b2870b6 --- /dev/null +++ b/src/components/marketing/screenshot-frame.tsx @@ -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 { + src?: string; + alt?: string; + caption?: string; + aspectRatio?: '16/9' | '4/3' | '1/1' | 'auto'; +} + +const ScreenshotFrame = React.forwardRef( + ( + { + 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 ( +
+
+ {src ? ( + <> + {!loaded && ( + + )} + {alt} setLoaded(true)} + className={cn( + 'h-full w-full object-cover transition-opacity duration-300', + loaded ? 'opacity-100' : 'opacity-0' + )} + /> + + ) : ( + children + )} +
+ {caption && ( +
+ {caption} +
+ )} +
+ ); + } +); + +ScreenshotFrame.displayName = 'ScreenshotFrame'; + +export { ScreenshotFrame }; diff --git a/src/components/marketing/section-header.tsx b/src/components/marketing/section-header.tsx new file mode 100644 index 0000000..b0ad353 --- /dev/null +++ b/src/components/marketing/section-header.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { Eyebrow } from './eyebrow'; + +export interface SectionHeaderProps extends Omit, 'title'> { + eyebrow?: React.ReactNode; + title: React.ReactNode; + description?: React.ReactNode; + align?: 'left' | 'center'; + actions?: React.ReactNode; +} + +const SectionHeader = React.forwardRef( + ( + { + className, + eyebrow, + title, + description, + align = 'center', + actions, + ...props + }, + ref + ) => { + return ( +
+ {eyebrow && ( + typeof eyebrow === 'string' ? ( + {eyebrow} + ) : ( + eyebrow + ) + )} +

+ {title} +

+ {description && ( +

+ {description} +

+ )} + {actions &&
{actions}
} +
+ ); + } +); + +SectionHeader.displayName = 'SectionHeader'; + +export { SectionHeader }; diff --git a/src/components/marketing/section.tsx b/src/components/marketing/section.tsx new file mode 100644 index 0000000..0c06c82 --- /dev/null +++ b/src/components/marketing/section.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface SectionProps extends React.HTMLAttributes { + 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( + ( + { + className, + spacing = 'md', + background = 'default', + children, + id, + ...props + }, + ref + ) => { + return ( +
+ {children} +
+ ); + } +); + +Section.displayName = 'Section'; + +export { Section }; diff --git a/src/components/marketing/step-card.tsx b/src/components/marketing/step-card.tsx new file mode 100644 index 0000000..a39b436 --- /dev/null +++ b/src/components/marketing/step-card.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface StepCardProps extends React.HTMLAttributes { + stepNumber: number | string; + title: string; + description: string; + icon?: React.ReactNode; + isLast?: boolean; +} + +const StepCard = React.forwardRef( + ( + { + className, + stepNumber, + title, + description, + icon, + isLast = false, + ...props + }, + ref + ) => { + const formattedStep = + typeof stepNumber === 'number' && stepNumber < 10 + ? `0${stepNumber}` + : stepNumber; + + return ( +
+
+ + {formattedStep} + + {icon && ( +
+ {icon} +
+ )} +
+ +

{title}

+

{description}

+
+ ); + } +); + +StepCard.displayName = 'StepCard'; + +export { StepCard }; diff --git a/src/components/marketing/testimonial-card.tsx b/src/components/marketing/testimonial-card.tsx new file mode 100644 index 0000000..0f2fe75 --- /dev/null +++ b/src/components/marketing/testimonial-card.tsx @@ -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 { + quote: string; + authorName: string; + authorTitle: string; + authorCompany?: string; + avatarUrl?: string; + rating?: number; +} + +const TestimonialCard = React.forwardRef( + ( + { + className, + quote, + authorName, + authorTitle, + authorCompany, + avatarUrl, + rating = 5, + ...props + }, + ref + ) => { + const initials = authorName + .split(' ') + .map((n) => n[0]) + .join('') + .substring(0, 2); + + return ( + + +
+
+
+ {Array.from({ length: rating }).map((_, i) => ( + + ))} +
+ +
+ +

+ “{quote}” +

+
+ +
+ + + {initials} + +
+
{authorName}
+

+ {authorTitle} + {authorCompany && • {authorCompany}} +

+
+
+
+
+ ); + } +); + +TestimonialCard.displayName = 'TestimonialCard'; + +export { TestimonialCard }; diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..fa52667 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -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(null); + +export interface AccordionProps extends React.HTMLAttributes { + type?: 'single' | 'multiple'; + defaultValue?: string | string[]; +} + +const Accordion = React.forwardRef( + ({ type = 'single', defaultValue, children, className, ...props }, ref) => { + const [openItems, setOpenItems] = React.useState(() => { + 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 ( + +
+ {children} +
+
+ ); + } +); +Accordion.displayName = 'Accordion'; + +interface AccordionItemContextValue { + value: string; +} + +const AccordionItemContext = React.createContext(null); + +export interface AccordionItemProps extends React.HTMLAttributes { + value: string; +} + +const AccordionItem = React.forwardRef( + ({ value, children, className, ...props }, ref) => ( + +
+ {children} +
+
+ ) +); +AccordionItem.displayName = 'AccordionItem'; + +export interface AccordionTriggerProps + extends React.ButtonHTMLAttributes {} + +const AccordionTrigger = React.forwardRef( + ({ 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 ( + + ); + } +); +AccordionTrigger.displayName = 'AccordionTrigger'; + +export interface AccordionContentProps + extends React.HTMLAttributes {} + +const AccordionContent = React.forwardRef( + ({ 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 ( +
+ {children} +
+ ); + } +); +AccordionContent.displayName = 'AccordionContent'; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..91dbba7 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -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 { + 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: , + success: , + warning: , + destructive: , +}; + +const Alert = React.forwardRef( + ({ className, variant = 'default', icon, children, ...props }, ref) => { + const displayIcon = icon !== undefined ? icon : defaultIcons[variant]; + + return ( +
+ {displayIcon &&
{displayIcon}
} +
{children}
+
+ ); + } +); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..9b66b98 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -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 { + 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( + ({ 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 ( +
+
+ {children} +
+ {status && ( +
+ ); + } +); +Avatar.displayName = 'Avatar'; + +export interface AvatarImageProps + extends React.ImgHTMLAttributes { + onLoadingStatusChange?: (status: 'loading' | 'loaded' | 'error') => void; +} + +const AvatarImage = React.forwardRef( + ({ className, src, alt = '', ...props }, ref) => { + const [hasError, setHasError] = React.useState(false); + + if (!src || hasError) return null; + + return ( + {alt} setHasError(true)} + className={cn('aspect-square h-full w-full object-cover', className)} + {...props} + /> + ); + } +); +AvatarImage.displayName = 'AvatarImage'; + +export interface AvatarFallbackProps + extends React.HTMLAttributes {} + +const AvatarFallback = React.forwardRef( + ({ className, children, ...props }, ref) => ( +
+ {children} +
+ ) +); +AvatarFallback.displayName = 'AvatarFallback'; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..5a99485 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,88 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface BadgeProps extends React.HTMLAttributes { + variant?: + | 'default' + | 'primary' + | 'secondary' + | 'outline' + | 'success' + | 'warning' + | 'destructive' + | 'info'; + size?: 'sm' | 'md'; + dot?: boolean; +} + +const Badge = React.forwardRef( + ( + { + 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 ( +
+ {dot && ( +
+ ); + } +); + +Badge.displayName = 'Badge'; + +export { Badge }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..f2e14a6 --- /dev/null +++ b/src/components/ui/button.tsx @@ -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 { + 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( + ( + { + 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.displayName = 'Button'; + +export { Button }; diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..596c531 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface CardProps extends React.HTMLAttributes { + hoverable?: boolean; +} + +const Card = React.forwardRef( + ({ className, hoverable = false, ...props }, ref) => ( +
+ ) +); +Card.displayName = 'Card'; + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = 'CardHeader'; + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = 'CardTitle'; + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = 'CardDescription'; + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = 'CardContent'; + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = 'CardFooter'; + +export { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +}; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..76ee2c2 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -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(null); + +export interface DialogProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; + defaultOpen?: boolean; + children: React.ReactNode; +} + +const Dialog: React.FC = ({ + 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 ( + + {children} + + ); +}; + +export interface DialogTriggerProps { + asChild?: boolean; + children: React.ReactElement; +} + +const DialogTrigger: React.FC = ({ 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 { + showClose?: boolean; +} + +const DialogContent = React.forwardRef( + ({ 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 ( +
+ {/* Backdrop */} +
context.onOpenChange(false)} + /> + {/* Modal Panel */} +
+ {children} + {showClose && ( + + )} +
+
+ ); + } +); +DialogContent.displayName = 'DialogContent'; + +const DialogHeader: React.FC> = ({ + className, + ...props +}) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogTitle = React.forwardRef< + HTMLHeadingElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +DialogTitle.displayName = 'DialogTitle'; + +const DialogDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)); +DialogDescription.displayName = 'DialogDescription'; + +const DialogFooter: React.FC> = ({ + 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, +}; diff --git a/src/components/ui/dropdown.tsx b/src/components/ui/dropdown.tsx new file mode 100644 index 0000000..4d9f81d --- /dev/null +++ b/src/components/ui/dropdown.tsx @@ -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(null); + +export interface DropdownProps { + children: React.ReactNode; +} + +const Dropdown: React.FC = ({ children }) => { + const [open, setOpen] = React.useState(false); + const dropdownRef = React.useRef(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 ( + +
+ {children} +
+
+ ); +}; + +export interface DropdownTriggerProps { + children: React.ReactElement; +} + +const DropdownTrigger: React.FC = ({ 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 { + align?: 'left' | 'right' | 'center'; +} + +const DropdownContent = React.forwardRef( + ({ 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 ( +
+ {children} +
+ ); + } +); +DropdownContent.displayName = 'DropdownContent'; + +export interface DropdownItemProps extends React.HTMLAttributes { + disabled?: boolean; + destructive?: boolean; + icon?: React.ReactNode; +} + +const DropdownItem = React.forwardRef( + ({ className, disabled, destructive, icon, children, onClick, ...props }, ref) => { + const context = React.useContext(DropdownContext); + + const handleClick = (e: React.MouseEvent) => { + if (disabled) return; + onClick?.(e); + context?.setOpen(false); + }; + + return ( +
+ {icon && {icon}} + {children} +
+ ); + } +); +DropdownItem.displayName = 'DropdownItem'; + +const DropdownLabel: React.FC> = ({ + className, + ...props +}) => ( +
+); +DropdownLabel.displayName = 'DropdownLabel'; + +const DropdownSeparator: React.FC> = ({ + className, + ...props +}) => ( +
+); +DropdownSeparator.displayName = 'DropdownSeparator'; + +export { + Dropdown, + DropdownTrigger, + DropdownContent, + DropdownItem, + DropdownLabel, + DropdownSeparator, +}; diff --git a/src/components/ui/icon-button.tsx b/src/components/ui/icon-button.tsx new file mode 100644 index 0000000..8e4ace6 --- /dev/null +++ b/src/components/ui/icon-button.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface IconButtonProps extends React.ButtonHTMLAttributes { + 'aria-label': string; + variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive'; + size?: 'sm' | 'md' | 'lg'; + isLoading?: boolean; + asChild?: boolean; +} + +const IconButton = React.forwardRef( + ( + { + 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 ( + + ); + } +); + +IconButton.displayName = 'IconButton'; + +export { IconButton }; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000..c3c9873 --- /dev/null +++ b/src/components/ui/index.ts @@ -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'; diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..70d9e0f --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface InputProps extends React.InputHTMLAttributes { + label?: string; + helperText?: string; + error?: string; + leftIcon?: React.ReactNode; + rightIcon?: React.ReactNode; + containerClassName?: string; +} + +const Input = React.forwardRef( + ( + { + 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 ( +
+ {label && ( + + )} +
+ {leftIcon && ( +
+ {leftIcon} +
+ )} + + {rightIcon && ( +
+ {rightIcon} +
+ )} +
+ {error ? ( +

+ {error} +

+ ) : helperText ? ( +

+ {helperText} +

+ ) : null} +
+ ); + } +); + +Input.displayName = 'Input'; + +export { Input }; diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..31f4951 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -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(null); + +export interface PopoverProps { + children: React.ReactNode; +} + +const Popover: React.FC = ({ children }) => { + const [open, setOpen] = React.useState(false); + const popoverRef = React.useRef(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 ( + +
+ {children} +
+
+ ); +}; + +export interface PopoverTriggerProps { + children: React.ReactElement; +} + +const PopoverTrigger: React.FC = ({ 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 { + align?: 'left' | 'right' | 'center'; +} + +const PopoverContent = React.forwardRef( + ({ 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 ( +
+ {children} +
+ ); + } +); +PopoverContent.displayName = 'PopoverContent'; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..f6b3abe --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,69 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface ProgressProps extends React.HTMLAttributes { + 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( + ( + { + className, + value = 0, + max = 100, + size = 'md', + showLabel = false, + indicatorClassName, + ...props + }, + ref + ) => { + const percentage = Math.min(Math.max(0, (value / max) * 100), 100); + + return ( +
+ {showLabel && ( +
+ Progress + {Math.round(percentage)}% +
+ )} +
+
+
+
+ ); + } +); + +Progress.displayName = 'Progress'; + +export { Progress }; diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..43b69b0 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface SeparatorProps extends React.HTMLAttributes { + orientation?: 'horizontal' | 'vertical'; + label?: React.ReactNode; +} + +const Separator = React.forwardRef( + ( + { + className, + orientation = 'horizontal', + label, + children, + ...props + }, + ref + ) => { + const isHorizontal = orientation === 'horizontal'; + const displayLabel = label || children; + + if (displayLabel && isHorizontal) { + return ( +
+
+ {displayLabel} +
+
+ ); + } + + return ( +
+ ); + } +); + +Separator.displayName = 'Separator'; + +export { Separator }; diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..ff86684 --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface SkeletonProps extends React.HTMLAttributes { + 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 ( +
+ ); +} + +export { Skeleton }; diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..3965f14 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -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(null); + +export interface TabsProps extends React.HTMLAttributes { + defaultValue?: string; + value?: string; + onValueChange?: (value: string) => void; +} + +const Tabs = React.forwardRef( + ( + { + 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 ( + +
+ {children} +
+
+ ); + } +); +Tabs.displayName = 'Tabs'; + +const TabsList = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +TabsList.displayName = 'TabsList'; + +export interface TabsTriggerProps + extends React.ButtonHTMLAttributes { + value: string; +} + +const TabsTrigger = React.forwardRef( + ({ 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 ( + + ); + } +); +TabsTrigger.displayName = 'TabsTrigger'; + +export interface TabsContentProps + extends React.HTMLAttributes { + value: string; +} + +const TabsContent = React.forwardRef( + ({ 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 ( +
+ {children} +
+ ); + } +); +TabsContent.displayName = 'TabsContent'; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx new file mode 100644 index 0000000..3eb224f --- /dev/null +++ b/src/components/ui/textarea.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +export interface TextareaProps + extends React.TextareaHTMLAttributes { + label?: string; + helperText?: string; + error?: string; + containerClassName?: string; +} + +const Textarea = React.forwardRef( + ( + { + 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 ( +
+ {label && ( + + )} +