Browse Source

Initial commit: Add Next.js project with dark mode and invoice functionality

Matthew Trejo 1 month ago
parent
commit
2951a3f2ee

+ 22 - 0
components.json

@@ -0,0 +1,22 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "new-york",
+  "rsc": true,
+  "tsx": true,
+  "tailwind": {
+    "config": "",
+    "css": "src/app/globals.css",
+    "baseColor": "neutral",
+    "cssVariables": true,
+    "prefix": ""
+  },
+  "iconLibrary": "lucide",
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils",
+    "ui": "@/components/ui",
+    "lib": "@/lib",
+    "hooks": "@/hooks"
+  },
+  "registries": {}
+}

+ 108 - 0
docs/add-dark-mode.md

@@ -0,0 +1,108 @@
+---
+title: Next.js
+description: Adding dark mode to your next app.
+---
+
+<Steps>
+
+## Install next-themes
+
+Start by installing `next-themes`:
+
+```bash
+npm install next-themes
+```
+
+## Create a theme provider
+
+```tsx title="components/theme-provider.tsx" showLineNumbers
+"use client"
+
+import * as React from "react"
+import { ThemeProvider as NextThemesProvider } from "next-themes"
+
+export function ThemeProvider({
+  children,
+  ...props
+}: React.ComponentProps<typeof NextThemesProvider>) {
+  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
+}
+```
+
+## Wrap your root layout
+
+Add the `ThemeProvider` to your root layout and add the `suppressHydrationWarning` prop to the `html` tag.
+
+```tsx {1,6,9-14,16} title="app/layout.tsx" showLineNumbers
+import { ThemeProvider } from "@/components/theme-provider"
+
+export default function RootLayout({ children }: RootLayoutProps) {
+  return (
+    <>
+      <html lang="en" suppressHydrationWarning>
+        <head />
+        <body>
+          <ThemeProvider
+            attribute="class"
+            defaultTheme="system"
+            enableSystem
+            disableTransitionOnChange
+          >
+            {children}
+          </ThemeProvider>
+        </body>
+      </html>
+    </>
+  )
+}
+```
+
+## Add a mode toggle
+
+Place a mode toggle on your site to toggle between light and dark mode.
+
+```tsx
+"use client"
+
+import * as React from "react"
+import { Moon, Sun } from "lucide-react"
+import { useTheme } from "next-themes"
+
+import { Button } from "@/components/ui/button"
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+export function ModeToggle() {
+  const { setTheme } = useTheme()
+
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <Button variant="outline" size="icon">
+          <Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
+          <Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
+          <span className="sr-only">Toggle theme</span>
+        </Button>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent align="end">
+        <DropdownMenuItem onClick={() => setTheme("light")}>
+          Light
+        </DropdownMenuItem>
+        <DropdownMenuItem onClick={() => setTheme("dark")}>
+          Dark
+        </DropdownMenuItem>
+        <DropdownMenuItem onClick={() => setTheme("system")}>
+          System
+        </DropdownMenuItem>
+      </DropdownMenuContent>
+    </DropdownMenu>
+  )
+}
+
+```
+
+</Steps>

+ 225 - 0
docs/factura.php

@@ -0,0 +1,225 @@
+<?php
+// Función para generar el XML de la factura
+function generarFacturaXML($datos) {
+    // Crear documento XML
+    $xml = new DOMDocument('1.0', 'UTF-8');
+    $xml->formatOutput = true;
+    
+    // Raíz: factura
+    $factura = $xml->createElement('factura');
+    $factura->setAttribute('id', 'comprobante');
+    $factura->setAttribute('version', '1.1.0');
+    $xml->appendChild($factura);
+    
+    // infoTributaria
+    $infoTributaria = $xml->createElement('infoTributaria');
+    $factura->appendChild($infoTributaria);
+    
+    $infoTributaria->appendChild($xml->createElement('ambiente', $datos['ambiente'])); // 1=Pruebas, 2=Producción
+    $infoTributaria->appendChild($xml->createElement('tipoEmision', $datos['tipoEmision'])); // 1=Normal
+    $infoTributaria->appendChild($xml->createElement('razonSocial', $datos['razonSocial']));
+    $infoTributaria->appendChild($xml->createElement('nombreComercial', $datos['nombreComercial']));
+    $infoTributaria->appendChild($xml->createElement('ruc', $datos['ruc']));
+    $infoTributaria->appendChild($xml->createElement('claveAcceso', $datos['claveAcceso'])); // Genera una clave única (ver algoritmo SRI)
+    $infoTributaria->appendChild($xml->createElement('codDoc', '01')); // 01=Factura
+    $infoTributaria->appendChild($xml->createElement('estab', $datos['estab']));
+    $infoTributaria->appendChild($xml->createElement('ptoEmi', $datos['ptoEmi']));
+    $infoTributaria->appendChild($xml->createElement('secuencial', $datos['secuencial']));
+    $infoTributaria->appendChild($xml->createElement('dirMatriz', $datos['dirMatriz']));
+    
+    // infoFactura
+    $infoFactura = $xml->createElement('infoFactura');
+    $factura->appendChild($infoFactura);
+    
+    $infoFactura->appendChild($xml->createElement('fechaEmision', $datos['fechaEmision']));
+    $infoFactura->appendChild($xml->createElement('dirEstablecimiento', $datos['dirEstablecimiento']));
+    $infoFactura->appendChild($xml->createElement('contribuyenteEspecial', $datos['contribuyenteEspecial']));
+    $infoFactura->appendChild($xml->createElement('obligadoContabilidad', $datos['obligadoContabilidad']));
+    
+    // Identificación del comprador
+    $tipoIdentificacionComprador = $xml->createElement('tipoIdentificacionComprador', $datos['tipoIdentificacionComprador']);
+    $infoFactura->appendChild($tipoIdentificacionComprador);
+    $razonSocialComprador = $xml->createElement('razonSocialComprador', $datos['razonSocialComprador']);
+    $infoFactura->appendChild($razonSocialComprador);
+    $identificacionComprador = $xml->createElement('identificacionComprador', $datos['identificacionComprador']);
+    $infoFactura->appendChild($identificacionComprador);
+    $direccionComprador = $xml->createElement('direccionComprador', $datos['direccionComprador']);
+    $infoFactura->appendChild($direccionComprador);
+    
+    $infoFactura->appendChild($xml->createElement('totalSinImpuestos', $datos['totalSinImpuestos']));
+    $infoFactura->appendChild($xml->createElement('totalDescuento', $datos['totalDescuento']));
+    
+    // totalConImpuestos (simplificado: solo IVA)
+    $totalConImpuestos = $xml->createElement('totalConImpuestos');
+    $infoFactura->appendChild($totalConImpuestos);
+    $totalImpuesto = $xml->createElement('totalImpuesto');
+    $totalConImpuestos->appendChild($totalImpuesto);
+    $totalImpuesto->appendChild($xml->createElement('codigo', '2')); // IVA
+    $totalImpuesto->appendChild($xml->createElement('codigoPorcentaje', $datos['codigoPorcentaje']));
+    $totalImpuesto->appendChild($xml->createElement('baseImponible', $datos['baseImponible']));
+    $totalImpuesto->appendChild($xml->createElement('valor', $datos['valorImpuesto']));
+    
+    $infoFactura->appendChild($xml->createElement('propina', '0.00'));
+    $infoFactura->appendChild($xml->createElement('importeTotal', $datos['importeTotal']));
+    $infoFactura->appendChild($xml->createElement('moneda', 'DOLAR'));
+    
+    // pagos (simplificado)
+    $pagos = $xml->createElement('pagos');
+    $infoFactura->appendChild($pagos);
+    $pago = $xml->createElement('pago');
+    $pagos->appendChild($pago);
+    $pago->appendChild($xml->createElement('formaPago', $datos['formaPago']));
+    $pago->appendChild($xml->createElement('total', $datos['importeTotal']));
+    
+    // detalles
+    $detalles = $xml->createElement('detalles');
+    $factura->appendChild($detalles);
+    foreach ($datos['detalles'] as $item) {
+        $detalle = $xml->createElement('detalle');
+        $detalles->appendChild($detalle);
+        $detalle->appendChild($xml->createElement('codigoPrincipal', $item['codigoPrincipal']));
+        $detalle->appendChild($xml->createElement('descripcion', $item['descripcion']));
+        $detalle->appendChild($xml->createElement('cantidad', $item['cantidad']));
+        $detalle->appendChild($xml->createElement('precioUnitario', $item['precioUnitario']));
+        $detalle->appendChild($xml->createElement('descuento', $item['descuento']));
+        $detalle->appendChild($xml->createElement('precioTotalSinImpuesto', $item['precioTotalSinImpuesto']));
+        
+        // impuestos por detalle (IVA)
+        $impuestos = $xml->createElement('impuestos');
+        $detalle->appendChild($impuestos);
+        $impuesto = $xml->createElement('impuesto');
+        $impuestos->appendChild($impuesto);
+        $impuesto->appendChild($xml->createElement('codigo', '2'));
+        $impuesto->appendChild($xml->createElement('codigoPorcentaje', $item['codigoPorcentaje']));
+        $impuesto->appendChild($xml->createElement('tarifa', $item['tarifa']));
+        $impuesto->appendChild($xml->createElement('baseImponible', $item['baseImponible']));
+        $impuesto->appendChild($xml->createElement('valor', $item['valorImpuesto']));
+    }
+    
+    // infoAdicional (opcional)
+    $infoAdicional = $xml->createElement('infoAdicional');
+    $factura->appendChild($infoAdicional);
+    $campoAdicional = $xml->createElement('campoAdicional');
+    $campoAdicional->setAttribute('nombre', 'Email');
+    $campoAdicional->nodeValue = $datos['emailComprador'];
+    $infoAdicional->appendChild($campoAdicional);
+    
+    return $xml->saveXML();
+}
+
+// Procesar formulario
+$xmlGenerado = '';
+if ($_SERVER['REQUEST_METHOD'] == 'POST') {
+    // Recopilar datos del formulario
+    $datos = [
+        'ambiente' => $_POST['ambiente'],
+        'tipoEmision' => $_POST['tipoEmision'],
+        'razonSocial' => $_POST['razonSocial'],
+        'nombreComercial' => $_POST['nombreComercial'],
+        'ruc' => $_POST['ruc'],
+        'claveAcceso' => $_POST['claveAcceso'], // Debes generar esto dinámicamente
+        'estab' => $_POST['estab'],
+        'ptoEmi' => $_POST['ptoEmi'],
+        'secuencial' => $_POST['secuencial'],
+        'dirMatriz' => $_POST['dirMatriz'],
+        'fechaEmision' => $_POST['fechaEmision'],
+        'dirEstablecimiento' => $_POST['dirEstablecimiento'],
+        'contribuyenteEspecial' => $_POST['contribuyenteEspecial'],
+        'obligadoContabilidad' => $_POST['obligadoContabilidad'],
+        'tipoIdentificacionComprador' => $_POST['tipoIdentificacionComprador'],
+        'razonSocialComprador' => $_POST['razonSocialComprador'],
+        'identificacionComprador' => $_POST['identificacionComprador'],
+        'direccionComprador' => $_POST['direccionComprador'],
+        'totalSinImpuestos' => $_POST['totalSinImpuestos'],
+        'totalDescuento' => $_POST['totalDescuento'],
+        'codigoPorcentaje' => $_POST['codigoPorcentaje'],
+        'baseImponible' => $_POST['baseImponible'],
+        'valorImpuesto' => $_POST['valorImpuesto'],
+        'importeTotal' => $_POST['importeTotal'],
+        'formaPago' => $_POST['formaPago'],
+        'emailComprador' => $_POST['emailComprador'],
+        'detalles' => [
+            [
+                'codigoPrincipal' => $_POST['codigoPrincipal1'],
+                'descripcion' => $_POST['descripcion1'],
+                'cantidad' => $_POST['cantidad1'],
+                'precioUnitario' => $_POST['precioUnitario1'],
+                'descuento' => $_POST['descuento1'],
+                'precioTotalSinImpuesto' => $_POST['precioTotalSinImpuesto1'],
+                'codigoPorcentaje' => $_POST['codigoPorcentaje1'],
+                'tarifa' => $_POST['tarifa1'],
+                'baseImponible' => $_POST['baseImponible1'],
+                'valorImpuesto' => $_POST['valorImpuesto1']
+            ]
+            // Agrega más items si es necesario
+        ]
+    ];
+    
+    $xmlGenerado = generarFacturaXML($datos);
+    
+    // Opcional: Guardar en archivo
+    file_put_contents('factura_generada.xml', $xmlGenerado);
+}
+?>
+
+<!DOCTYPE html>
+<html lang="es">
+<head>
+    <meta charset="UTF-8">
+    <title>Generar Factura XML para SRI</title>
+</head>
+<body>
+    <h1>Formulario para Generar Factura Electrónica (SRI Ecuador)</h1>
+    <form method="post">
+        <h2>Info Tributaria</h2>
+        Ambiente: <input type="text" name="ambiente" value="1" required><br>
+        Tipo Emisión: <input type="text" name="tipoEmision" value="1" required><br>
+        Razón Social: <input type="text" name="razonSocial" required><br>
+        Nombre Comercial: <input type="text" name="nombreComercial" required><br>
+        RUC: <input type="text" name="ruc" required><br>
+        Clave de Acceso: <input type="text" name="claveAcceso" required> (Genera una única)<br>
+        Establecimiento: <input type="text" name="estab" required><br>
+        Punto de Emisión: <input type="text" name="ptoEmi" required><br>
+        Secuencial: <input type="text" name="secuencial" required><br>
+        Dirección Matriz: <input type="text" name="dirMatriz" required><br>
+        
+        <h2>Info Factura</h2>
+        Fecha Emisión: <input type="date" name="fechaEmision" required><br>
+        Dirección Establecimiento: <input type="text" name="dirEstablecimiento" required><br>
+        Contribuyente Especial: <input type="text" name="contribuyenteEspecial"><br>
+        Obligado Contabilidad: <input type="text" name="obligadoContabilidad" value="SI" required><br>
+        Tipo Identificación Comprador: <input type="text" name="tipoIdentificacionComprador" value="04" required> (04=RUC)<br>
+        Razón Social Comprador: <input type="text" name="razonSocialComprador" required><br>
+        Identificación Comprador: <input type="text" name="identificacionComprador" required><br>
+        Dirección Comprador: <input type="text" name="direccionComprador" required><br>
+        Total Sin Impuestos: <input type="text" name="totalSinImpuestos" required><br>
+        Total Descuento: <input type="text" name="totalDescuento" value="0.00" required><br>
+        Código Porcentaje IVA: <input type="text" name="codigoPorcentaje" value="2" required><br>
+        Base Imponible IVA: <input type="text" name="baseImponible" required><br>
+        Valor Impuesto: <input type="text" name="valorImpuesto" required><br>
+        Importe Total: <input type="text" name="importeTotal" required><br>
+        Forma de Pago: <input type="text" name="formaPago" value="01" required> (01=Efectivo)<br>
+        Email Comprador: <input type="email" name="emailComprador"><br>
+        
+        <h2>Detalles (Item 1)</h2>
+        Código Principal: <input type="text" name="codigoPrincipal1" required><br>
+        Descripción: <input type="text" name="descripcion1" required><br>
+        Cantidad: <input type="text" name="cantidad1" required><br>
+        Precio Unitario: <input type="text" name="precioUnitario1" required><br>
+        Descuento: <input type="text" name="descuento1" value="0.00" required><br>
+        Precio Total Sin Impuesto: <input type="text" name="precioTotalSinImpuesto1" required><br>
+        Código Porcentaje IVA: <input type="text" name="codigoPorcentaje1" value="2" required><br>
+        Tarifa IVA: <input type="text" name="tarifa1" value="15.00" required><br>
+        Base Imponible IVA: <input type="text" name="baseImponible1" required><br>
+        Valor Impuesto: <input type="text" name="valorImpuesto1" required><br>
+        
+        <button type="submit">Generar XML</button>
+    </form>
+    
+    <?php if ($xmlGenerado): ?>
+        <h2>XML Generado:</h2>
+        <textarea rows="20" cols="100"><?php echo htmlspecialchars($xmlGenerado); ?></textarea>
+        <br><a href="factura_generada.xml" download>Descargar XML</a>
+    <?php endif; ?>
+</body>
+</html>

File diff suppressed because it is too large
+ 930 - 4
package-lock.json


+ 19 - 5
package.json

@@ -9,18 +9,32 @@
     "lint": "eslint"
   },
   "dependencies": {
+    "@radix-ui/react-checkbox": "^1.3.3",
+    "@radix-ui/react-dialog": "^1.1.15",
+    "@radix-ui/react-dropdown-menu": "^2.1.16",
+    "@radix-ui/react-label": "^2.1.7",
+    "@radix-ui/react-select": "^2.2.6",
+    "@radix-ui/react-slot": "^1.2.3",
+    "@radix-ui/react-switch": "^1.2.6",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
+    "lucide-react": "^0.548.0",
+    "next": "16.0.1",
+    "next-themes": "^0.4.6",
     "react": "19.2.0",
     "react-dom": "19.2.0",
-    "next": "16.0.1"
+    "sonner": "^2.0.7",
+    "tailwind-merge": "^3.3.1"
   },
   "devDependencies": {
-    "typescript": "^5",
+    "@tailwindcss/postcss": "^4",
     "@types/node": "^20",
     "@types/react": "^19",
     "@types/react-dom": "^19",
-    "@tailwindcss/postcss": "^4",
-    "tailwindcss": "^4",
     "eslint": "^9",
-    "eslint-config-next": "16.0.1"
+    "eslint-config-next": "16.0.1",
+    "tailwindcss": "^4",
+    "tw-animate-css": "^1.4.0",
+    "typescript": "^5"
   }
 }

+ 841 - 0
src/app/factura/page.tsx

@@ -0,0 +1,841 @@
+"use client"
+
+import { useState, useCallback, useMemo } from "react"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Textarea } from "@/components/ui/textarea"
+import { toast } from "sonner"
+import { Trash2, Plus, Download } from "lucide-react"
+
+// Interfaces TypeScript para tipos estrictos
+interface InfoTributaria {
+  ambiente: string
+  tipoEmision: string
+  razonSocial: string
+  nombreComercial: string
+  ruc: string
+  claveAcceso: string
+  estab: string
+  ptoEmi: string
+  secuencial: string
+  dirMatriz: string
+}
+
+interface InfoFactura {
+  fechaEmision: string
+  dirEstablecimiento: string
+  contribuyenteEspecial: string
+  obligadoContabilidad: string
+  tipoIdentificacionComprador: string
+  razonSocialComprador: string
+  identificacionComprador: string
+  direccionComprador: string
+  totalSinImpuestos: string
+  totalDescuento: string
+  codigoPorcentaje: string
+  baseImponible: string
+  valorImpuesto: string
+  importeTotal: string
+  formaPago: string
+  emailComprador: string
+}
+
+interface DetalleItem {
+  id: string
+  codigoPrincipal: string
+  descripcion: string
+  cantidad: string
+  precioUnitario: string
+  descuento: string
+  precioTotalSinImpuesto: string
+  codigoPorcentaje: string
+  tarifa: string
+  baseImponible: string
+  valorImpuesto: string
+}
+
+interface FacturaData {
+  infoTributaria: InfoTributaria
+  infoFactura: InfoFactura
+  detalles: DetalleItem[]
+}
+
+// Componente para item de detalle
+function DetalleItemForm({ 
+  item, 
+  onChange, 
+  onRemove 
+}: { 
+  item: DetalleItem
+  onChange: (item: DetalleItem) => void
+  onRemove: () => void
+}) {
+  const handleChange = (field: keyof DetalleItem, value: string) => {
+    const updatedItem = { ...item, [field]: value }
+    
+    // Cálculos automáticos
+    if (field === 'cantidad' || field === 'precioUnitario' || field === 'descuento') {
+      const cantidad = parseFloat(updatedItem.cantidad) || 0
+      const precioUnitario = parseFloat(updatedItem.precioUnitario) || 0
+      const descuento = parseFloat(updatedItem.descuento) || 0
+      
+      const subtotal = cantidad * precioUnitario
+      const precioTotalSinImpuesto = Math.max(0, subtotal - descuento)
+      
+      updatedItem.precioTotalSinImpuesto = precioTotalSinImpuesto.toFixed(2)
+      updatedItem.baseImponible = precioTotalSinImpuesto.toFixed(2)
+      
+      // Calcular IVA (12% por defecto)
+      const tarifa = parseFloat(updatedItem.tarifa) || 12
+      updatedItem.valorImpuesto = (precioTotalSinImpuesto * tarifa / 100).toFixed(2)
+    }
+    
+    if (field === 'tarifa') {
+      const baseImponible = parseFloat(updatedItem.baseImponible) || 0
+      const tarifa = parseFloat(value) || 12
+      updatedItem.valorImpuesto = (baseImponible * tarifa / 100).toFixed(2)
+    }
+    
+    onChange(updatedItem)
+  }
+
+  return (
+    <Card className="p-4">
+      <div className="flex justify-between items-center mb-4">
+        <h4 className="text-sm font-medium">Detalle del Producto</h4>
+        <Button
+          type="button"
+          variant="destructive"
+          size="sm"
+          onClick={onRemove}
+        >
+          <Trash2 className="h-4 w-4" />
+        </Button>
+      </div>
+      
+      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+        <div className="space-y-2">
+          <Label htmlFor={`codigo-${item.id}`}>Código Principal</Label>
+          <Input
+            id={`codigo-${item.id}`}
+            value={item.codigoPrincipal}
+            onChange={(e) => handleChange('codigoPrincipal', e.target.value)}
+            placeholder="PROD001"
+          />
+        </div>
+        
+        <div className="space-y-2">
+          <Label htmlFor={`descripcion-${item.id}`}>Descripción</Label>
+          <Input
+            id={`descripcion-${item.id}`}
+            value={item.descripcion}
+            onChange={(e) => handleChange('descripcion', e.target.value)}
+            placeholder="Descripción del producto"
+          />
+        </div>
+        
+        <div className="space-y-2">
+          <Label htmlFor={`cantidad-${item.id}`}>Cantidad</Label>
+          <Input
+            id={`cantidad-${item.id}`}
+            type="number"
+            step="0.01"
+            value={item.cantidad}
+            onChange={(e) => handleChange('cantidad', e.target.value)}
+            placeholder="1"
+          />
+        </div>
+        
+        <div className="space-y-2">
+          <Label htmlFor={`precio-unitario-${item.id}`}>Precio Unitario</Label>
+          <Input
+            id={`precio-unitario-${item.id}`}
+            type="number"
+            step="0.01"
+            value={item.precioUnitario}
+            onChange={(e) => handleChange('precioUnitario', e.target.value)}
+            placeholder="10.00"
+          />
+        </div>
+        
+        <div className="space-y-2">
+          <Label htmlFor={`descuento-${item.id}`}>Descuento</Label>
+          <Input
+            id={`descuento-${item.id}`}
+            type="number"
+            step="0.01"
+            value={item.descuento}
+            onChange={(e) => handleChange('descuento', e.target.value)}
+            placeholder="0.00"
+          />
+        </div>
+        
+        <div className="space-y-2">
+          <Label htmlFor={`precio-total-${item.id}`}>Total sin Impuesto</Label>
+          <Input
+            id={`precio-total-${item.id}`}
+            value={item.precioTotalSinImpuesto}
+            readOnly
+            className="bg-muted"
+          />
+        </div>
+        
+        <div className="space-y-2">
+          <Label htmlFor={`codigo-porcentaje-${item.id}`}>Código IVA</Label>
+          <Select value={item.codigoPorcentaje} onValueChange={(value) => handleChange('codigoPorcentaje', value)}>
+            <SelectTrigger>
+              <SelectValue placeholder="Seleccionar" />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="2">2 - IVA</SelectItem>
+              <SelectItem value="0">0 - No objeto de IVA</SelectItem>
+            </SelectContent>
+          </Select>
+        </div>
+        
+        <div className="space-y-2">
+          <Label htmlFor={`tarifa-${item.id}`}>Tarifa IVA (%)</Label>
+          <Select value={item.tarifa} onValueChange={(value) => handleChange('tarifa', value)}>
+            <SelectTrigger>
+              <SelectValue placeholder="Seleccionar" />
+            </SelectTrigger>
+            <SelectContent>
+              <SelectItem value="12">12%</SelectItem>
+              <SelectItem value="0">0%</SelectItem>
+              <SelectItem value="14">14%</SelectItem>
+            </SelectContent>
+          </Select>
+        </div>
+        
+        <div className="space-y-2">
+          <Label htmlFor={`valor-impuesto-${item.id}`}>Valor Impuesto</Label>
+          <Input
+            id={`valor-impuesto-${item.id}`}
+            value={item.valorImpuesto}
+            readOnly
+            className="bg-muted"
+          />
+        </div>
+      </div>
+    </Card>
+  )
+}
+
+// Funciones de validación específicas para Ecuador
+const validateRUC = (ruc: string): boolean => {
+  // Validación básica de RUC ecuatoriano (13 dígitos)
+  return /^\d{13}$/.test(ruc)
+}
+
+const validateCedula = (cedula: string): boolean => {
+  // Validación básica de cédula ecuatoriana (10 dígitos)
+  return /^\d{10}$/.test(cedula)
+}
+
+const validateClaveAcceso = (clave: string): boolean => {
+  // Validación básica de clave de acceso SRI (49 dígitos)
+  return /^\d{49}$/.test(clave)
+}
+
+export default function FacturaPage() {
+  // Estado inicial
+  const [infoTributaria, setInfoTributaria] = useState<InfoTributaria>({
+    ambiente: '1',
+    tipoEmision: '1',
+    razonSocial: '',
+    nombreComercial: '',
+    ruc: '',
+    claveAcceso: '',
+    estab: '001',
+    ptoEmi: '001',
+    secuencial: '000000001',
+    dirMatriz: ''
+  })
+
+  const [infoFactura, setInfoFactura] = useState<InfoFactura>({
+    fechaEmision: new Date().toISOString().split('T')[0],
+    dirEstablecimiento: '',
+    contribuyenteEspecial: '',
+    obligadoContabilidad: 'SI',
+    tipoIdentificacionComprador: '04',
+    razonSocialComprador: '',
+    identificacionComprador: '',
+    direccionComprador: '',
+    totalSinImpuestos: '0.00',
+    totalDescuento: '0.00',
+    codigoPorcentaje: '2',
+    baseImponible: '0.00',
+    valorImpuesto: '0.00',
+    importeTotal: '0.00',
+    formaPago: '01',
+    emailComprador: ''
+  })
+
+  const [detalles, setDetalles] = useState<DetalleItem[]>([
+    {
+      id: '1',
+      codigoPrincipal: '',
+      descripcion: '',
+      cantidad: '1',
+      precioUnitario: '0.00',
+      descuento: '0.00',
+      precioTotalSinImpuesto: '0.00',
+      codigoPorcentaje: '2',
+      tarifa: '12',
+      baseImponible: '0.00',
+      valorImpuesto: '0.00'
+    }
+  ])
+
+  const [xmlGenerado, setXmlGenerado] = useState('')
+
+  // Calcular totales automáticamente
+  const calcularTotales = useCallback(() => {
+    let totalSinImpuestos = 0
+    let totalDescuento = 0
+    let baseImponibleIVA = 0
+    let valorIVA = 0
+
+    detalles.forEach(item => {
+      const subtotal = parseFloat(item.precioTotalSinImpuesto) || 0
+      const descuento = parseFloat(item.descuento) || 0
+      const impuesto = parseFloat(item.valorImpuesto) || 0
+
+      totalSinImpuestos += subtotal
+      totalDescuento += descuento
+      baseImponibleIVA += subtotal
+      valorIVA += impuesto
+    })
+
+    const importeTotal = totalSinImpuestos + valorIVA
+
+    setInfoFactura(prev => ({
+      ...prev,
+      totalSinImpuestos: totalSinImpuestos.toFixed(2),
+      totalDescuento: totalDescuento.toFixed(2),
+      baseImponible: baseImponibleIVA.toFixed(2),
+      valorImpuesto: valorIVA.toFixed(2),
+      importeTotal: importeTotal.toFixed(2)
+    }))
+  }, [detalles])
+
+  // Recalcular totales cuando cambian los detalles
+  useMemo(() => {
+    calcularTotales()
+  }, [detalles, calcularTotales])
+
+  // Manejar cambios en info tributaria
+  const handleInfoTributariaChange = (field: keyof InfoTributaria, value: string) => {
+    setInfoTributaria(prev => ({ ...prev, [field]: value }))
+  }
+
+  // Manejar cambios en info factura
+  const handleInfoFacturaChange = (field: keyof InfoFactura, value: string) => {
+    setInfoFactura(prev => ({ ...prev, [field]: value }))
+  }
+
+  // Agregar nuevo detalle
+  const agregarDetalle = () => {
+    const nuevoDetalle: DetalleItem = {
+      id: Date.now().toString(),
+      codigoPrincipal: '',
+      descripcion: '',
+      cantidad: '1',
+      precioUnitario: '0.00',
+      descuento: '0.00',
+      precioTotalSinImpuesto: '0.00',
+      codigoPorcentaje: '2',
+      tarifa: '12',
+      baseImponible: '0.00',
+      valorImpuesto: '0.00'
+    }
+    setDetalles(prev => [...prev, nuevoDetalle])
+  }
+
+  // Actualizar detalle
+  const actualizarDetalle = (id: string, updatedItem: DetalleItem) => {
+    setDetalles(prev => prev.map(item => item.id === id ? updatedItem : item))
+  }
+
+  // Eliminar detalle
+  const eliminarDetalle = (id: string) => {
+    if (detalles.length > 1) {
+      setDetalles(prev => prev.filter(item => item.id !== id))
+      toast.success('Item eliminado')
+    } else {
+      toast.error('Debe haber al menos un item')
+    }
+  }
+
+  // Generar XML
+  const generarXML = () => {
+    // Validaciones básicas
+    if (!validateRUC(infoTributaria.ruc)) {
+      toast.error('RUC inválido. Debe tener 13 dígitos')
+      return
+    }
+
+    if (!validateClaveAcceso(infoTributaria.claveAcceso)) {
+      toast.error('Clave de acceso inválida. Debe tener 49 dígitos')
+      return
+    }
+
+    if (!infoFactura.identificacionComprador) {
+      toast.error('La identificación del comprador es requerida')
+      return
+    }
+
+    if (infoFactura.tipoIdentificacionComprador === '04' && !validateRUC(infoFactura.identificacionComprador)) {
+      toast.error('RUC del comprador inválido')
+      return
+    }
+
+    // Generar XML (simulación del código PHP)
+    let xml = `<?xml version="1.0" encoding="UTF-8"?>
+<factura id="comprobante" version="1.1.0">
+  <infoTributaria>
+    <ambiente>${infoTributaria.ambiente}</ambiente>
+    <tipoEmision>${infoTributaria.tipoEmision}</tipoEmision>
+    <razonSocial>${infoTributaria.razonSocial}</razonSocial>
+    <nombreComercial>${infoTributaria.nombreComercial}</nombreComercial>
+    <ruc>${infoTributaria.ruc}</ruc>
+    <claveAcceso>${infoTributaria.claveAcceso}</claveAcceso>
+    <codDoc>01</codDoc>
+    <estab>${infoTributaria.estab}</estab>
+    <ptoEmi>${infoTributaria.ptoEmi}</ptoEmi>
+    <secuencial>${infoTributaria.secuencial}</secuencial>
+    <dirMatriz>${infoTributaria.dirMatriz}</dirMatriz>
+  </infoTributaria>
+  
+  <infoFactura>
+    <fechaEmision>${infoFactura.fechaEmision}</fechaEmision>
+    <dirEstablecimiento>${infoFactura.dirEstablecimiento}</dirEstablecimiento>
+    <contribuyenteEspecial>${infoFactura.contribuyenteEspecial}</contribuyenteEspecial>
+    <obligadoContabilidad>${infoFactura.obligadoContabilidad}</obligadoContabilidad>
+    <tipoIdentificacionComprador>${infoFactura.tipoIdentificacionComprador}</tipoIdentificacionComprador>
+    <razonSocialComprador>${infoFactura.razonSocialComprador}</razonSocialComprador>
+    <identificacionComprador>${infoFactura.identificacionComprador}</identificacionComprador>
+    <direccionComprador>${infoFactura.direccionComprador}</direccionComprador>
+    <totalSinImpuestos>${infoFactura.totalSinImpuestos}</totalSinImpuestos>
+    <totalDescuento>${infoFactura.totalDescuento}</totalDescuento>
+    
+    <totalConImpuestos>
+      <totalImpuesto>
+        <codigo>2</codigo>
+        <codigoPorcentaje>${infoFactura.codigoPorcentaje}</codigoPorcentaje>
+        <baseImponible>${infoFactura.baseImponible}</baseImponible>
+        <valor>${infoFactura.valorImpuesto}</valor>
+      </totalImpuesto>
+    </totalConImpuestos>
+    
+    <propina>0.00</propina>
+    <importeTotal>${infoFactura.importeTotal}</importeTotal>
+    <moneda>DOLAR</moneda>
+    
+    <pagos>
+      <pago>
+        <formaPago>${infoFactura.formaPago}</formaPago>
+        <total>${infoFactura.importeTotal}</total>
+      </pago>
+    </pagos>
+  </infoFactura>
+  
+  <detalles>`
+
+    detalles.forEach(item => {
+      xml += `
+    <detalle>
+      <codigoPrincipal>${item.codigoPrincipal}</codigoPrincipal>
+      <descripcion>${item.descripcion}</descripcion>
+      <cantidad>${item.cantidad}</cantidad>
+      <precioUnitario>${item.precioUnitario}</precioUnitario>
+      <descuento>${item.descuento}</descuento>
+      <precioTotalSinImpuesto>${item.precioTotalSinImpuesto}</precioTotalSinImpuesto>
+      
+      <impuestos>
+        <impuesto>
+          <codigo>2</codigo>
+          <codigoPorcentaje>${item.codigoPorcentaje}</codigoPorcentaje>
+          <tarifa>${item.tarifa}</tarifa>
+          <baseImponible>${item.baseImponible}</baseImponible>
+          <valor>${item.valorImpuesto}</valor>
+        </impuesto>
+      </impuestos>
+    </detalle>`
+    })
+
+    xml += `
+  </detalles>
+  
+  <infoAdicional>
+    <campoAdicional nombre="Email">${infoFactura.emailComprador}</campoAdicional>
+  </infoAdicional>
+</factura>`
+
+    setXmlGenerado(xml)
+    toast.success('XML generado exitosamente')
+  }
+
+  // Descargar XML
+  const descargarXML = () => {
+    if (!xmlGenerado) {
+      toast.error('Debe generar el XML primero')
+      return
+    }
+
+    const blob = new Blob([xmlGenerado], { type: 'application/xml' })
+    const url = URL.createObjectURL(blob)
+    const a = document.createElement('a')
+    a.href = url
+    a.download = `factura_${infoTributaria.ruc}_${new Date().getTime()}.xml`
+    document.body.appendChild(a)
+    a.click()
+    document.body.removeChild(a)
+    URL.revokeObjectURL(url)
+    toast.success('XML descargado')
+  }
+
+  return (
+    <div className="min-h-screen bg-background p-8">
+      <div className="mx-auto max-w-6xl space-y-6">
+        {/* Header */}
+        <div className="text-center">
+          <h1 className="text-4xl font-bold tracking-tight">Factura Electrónica SRI Ecuador</h1>
+          <p className="mt-2 text-muted-foreground">
+            Generador de XML para facturación electrónica符合SRI
+          </p>
+        </div>
+
+        {/* Info Tributaria */}
+        <Card>
+          <CardHeader>
+            <CardTitle>Información Tributaria</CardTitle>
+            <CardDescription>Datos del emisor de la factura</CardDescription>
+          </CardHeader>
+          <CardContent>
+            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+              <div className="space-y-2">
+                <Label htmlFor="ambiente">Ambiente</Label>
+                <Select value={infoTributaria.ambiente} onValueChange={(value) => handleInfoTributariaChange('ambiente', value)}>
+                  <SelectTrigger>
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="1">1 - Pruebas</SelectItem>
+                    <SelectItem value="2">2 - Producción</SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="tipoEmision">Tipo Emisión</Label>
+                <Select value={infoTributaria.tipoEmision} onValueChange={(value) => handleInfoTributariaChange('tipoEmision', value)}>
+                  <SelectTrigger>
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="1">1 - Normal</SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="ruc">RUC *</Label>
+                <Input
+                  id="ruc"
+                  value={infoTributaria.ruc}
+                  onChange={(e) => handleInfoTributariaChange('ruc', e.target.value)}
+                  placeholder="13 dígitos"
+                  maxLength={13}
+                />
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="razonSocial">Razón Social *</Label>
+                <Input
+                  id="razonSocial"
+                  value={infoTributaria.razonSocial}
+                  onChange={(e) => handleInfoTributariaChange('razonSocial', e.target.value)}
+                  placeholder="Empresa S.A."
+                />
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="nombreComercial">Nombre Comercial *</Label>
+                <Input
+                  id="nombreComercial"
+                  value={infoTributaria.nombreComercial}
+                  onChange={(e) => handleInfoTributariaChange('nombreComercial', e.target.value)}
+                  placeholder="Nombre Comercial"
+                />
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="claveAcceso">Clave de Acceso *</Label>
+                <Input
+                  id="claveAcceso"
+                  value={infoTributaria.claveAcceso}
+                  onChange={(e) => handleInfoTributariaChange('claveAcceso', e.target.value)}
+                  placeholder="49 dígitos"
+                  maxLength={49}
+                />
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="estab">Establecimiento</Label>
+                <Input
+                  id="estab"
+                  value={infoTributaria.estab}
+                  onChange={(e) => handleInfoTributariaChange('estab', e.target.value)}
+                  placeholder="001"
+                  maxLength={3}
+                />
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="ptoEmi">Punto Emisión</Label>
+                <Input
+                  id="ptoEmi"
+                  value={infoTributaria.ptoEmi}
+                  onChange={(e) => handleInfoTributariaChange('ptoEmi', e.target.value)}
+                  placeholder="001"
+                  maxLength={3}
+                />
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="secuencial">Secuencial</Label>
+                <Input
+                  id="secuencial"
+                  value={infoTributaria.secuencial}
+                  onChange={(e) => handleInfoTributariaChange('secuencial', e.target.value)}
+                  placeholder="000000001"
+                  maxLength={9}
+                />
+              </div>
+              
+              <div className="space-y-2 lg:col-span-3">
+                <Label htmlFor="dirMatriz">Dirección Matriz *</Label>
+                <Input
+                  id="dirMatriz"
+                  value={infoTributaria.dirMatriz}
+                  onChange={(e) => handleInfoTributariaChange('dirMatriz', e.target.value)}
+                  placeholder="Av. Principal 123 y Secundaria"
+                />
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
+        {/* Info Factura */}
+        <Card>
+          <CardHeader>
+            <CardTitle>Información de Factura</CardTitle>
+            <CardDescription>Datos de la factura y comprador</CardDescription>
+          </CardHeader>
+          <CardContent>
+            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+              <div className="space-y-2">
+                <Label htmlFor="fechaEmision">Fecha Emisión *</Label>
+                <Input
+                  id="fechaEmision"
+                  type="date"
+                  value={infoFactura.fechaEmision}
+                  onChange={(e) => handleInfoFacturaChange('fechaEmision', e.target.value)}
+                />
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="dirEstablecimiento">Dirección Establecimiento *</Label>
+                <Input
+                  id="dirEstablecimiento"
+                  value={infoFactura.dirEstablecimiento}
+                  onChange={(e) => handleInfoFacturaChange('dirEstablecimiento', e.target.value)}
+                  placeholder="Av. Secundaria 456"
+                />
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="contribuyenteEspecial">Contribuyente Especial</Label>
+                <Input
+                  id="contribuyenteEspecial"
+                  value={infoFactura.contribuyenteEspecial}
+                  onChange={(e) => handleInfoFacturaChange('contribuyenteEspecial', e.target.value)}
+                  placeholder="Opcional"
+                />
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="obligadoContabilidad">Obligado Contabilidad</Label>
+                <Select value={infoFactura.obligadoContabilidad} onValueChange={(value) => handleInfoFacturaChange('obligadoContabilidad', value)}>
+                  <SelectTrigger>
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="SI">SI</SelectItem>
+                    <SelectItem value="NO">NO</SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="tipoIdentificacionComprador">Tipo Identificación Comprador</Label>
+                <Select value={infoFactura.tipoIdentificacionComprador} onValueChange={(value) => handleInfoFacturaChange('tipoIdentificacionComprador', value)}>
+                  <SelectTrigger>
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="04">04 - RUC</SelectItem>
+                    <SelectItem value="05">05 - Cédula</SelectItem>
+                    <SelectItem value="06">06 - Pasaporte</SelectItem>
+                    <SelectItem value="07">07 - Consumidor Final</SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="identificacionComprador">Identificación Comprador *</Label>
+                <Input
+                  id="identificacionComprador"
+                  value={infoFactura.identificacionComprador}
+                  onChange={(e) => handleInfoFacturaChange('identificacionComprador', e.target.value)}
+                  placeholder="Según tipo seleccionado"
+                />
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="razonSocialComprador">Razón Social Comprador *</Label>
+                <Input
+                  id="razonSocialComprador"
+                  value={infoFactura.razonSocialComprador}
+                  onChange={(e) => handleInfoFacturaChange('razonSocialComprador', e.target.value)}
+                  placeholder="Nombre del comprador"
+                />
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="direccionComprador">Dirección Comprador *</Label>
+                <Input
+                  id="direccionComprador"
+                  value={infoFactura.direccionComprador}
+                  onChange={(e) => handleInfoFacturaChange('direccionComprador', e.target.value)}
+                  placeholder="Dirección del comprador"
+                />
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="formaPago">Forma de Pago</Label>
+                <Select value={infoFactura.formaPago} onValueChange={(value) => handleInfoFacturaChange('formaPago', value)}>
+                  <SelectTrigger>
+                    <SelectValue />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="01">01 - Efectivo</SelectItem>
+                    <SelectItem value="15">15 - Transferencia</SelectItem>
+                    <SelectItem value="16">16 - Tarjeta Crédito</SelectItem>
+                    <SelectItem value="17">17 - Tarjeta Débito</SelectItem>
+                    <SelectItem value="20">20 - Otros</SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="emailComprador">Email Comprador</Label>
+                <Input
+                  id="emailComprador"
+                  type="email"
+                  value={infoFactura.emailComprador}
+                  onChange={(e) => handleInfoFacturaChange('emailComprador', e.target.value)}
+                  placeholder="email@ejemplo.com"
+                />
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
+        {/* Detalles */}
+        <Card>
+          <CardHeader>
+            <div className="flex justify-between items-center">
+              <div>
+                <CardTitle>Detalles de Productos/Servicios</CardTitle>
+                <CardDescription>Agregar los productos o servicios de la factura</CardDescription>
+              </div>
+              <Button type="button" onClick={agregarDetalle} className="flex items-center gap-2">
+                <Plus className="h-4 w-4" />
+                Agregar Item
+              </Button>
+            </div>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            {detalles.map((item) => (
+              <DetalleItemForm
+                key={item.id}
+                item={item}
+                onChange={(updatedItem) => actualizarDetalle(item.id, updatedItem)}
+                onRemove={() => eliminarDetalle(item.id)}
+              />
+            ))}
+          </CardContent>
+        </Card>
+
+        {/* Totales */}
+        <Card>
+          <CardHeader>
+            <CardTitle>Resumen de Totales</CardTitle>
+          </CardHeader>
+          <CardContent>
+            <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+              <div className="space-y-2">
+                <Label>Total Sin Impuestos</Label>
+                <Input value={infoFactura.totalSinImpuestos} readOnly className="bg-muted" />
+              </div>
+              <div className="space-y-2">
+                <Label>Valor IVA</Label>
+                <Input value={infoFactura.valorImpuesto} readOnly className="bg-muted" />
+              </div>
+              <div className="space-y-2">
+                <Label>Importe Total</Label>
+                <Input value={infoFactura.importeTotal} readOnly className="bg-muted font-bold text-lg" />
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
+        {/* Acciones */}
+        <div className="flex gap-4">
+          <Button onClick={generarXML} className="flex-1">
+            Generar XML
+          </Button>
+          {xmlGenerado && (
+            <Button variant="outline" onClick={descargarXML} className="flex items-center gap-2">
+              <Download className="h-4 w-4" />
+              Descargar XML
+            </Button>
+          )}
+        </div>
+
+        {/* XML Generado */}
+        {xmlGenerado && (
+          <Card>
+            <CardHeader>
+              <CardTitle>XML Generado</CardTitle>
+              <CardDescription>XML generado para el SRI</CardDescription>
+            </CardHeader>
+            <CardContent>
+              <Textarea
+                value={xmlGenerado}
+                readOnly
+                rows={20}
+                className="font-mono text-sm"
+              />
+            </CardContent>
+          </Card>
+        )}
+      </div>
+    </div>
+  )
+}

+ 109 - 13
src/app/globals.css

@@ -1,26 +1,122 @@
 @import "tailwindcss";
+@import "tw-animate-css";
 
-:root {
-  --background: #ffffff;
-  --foreground: #171717;
-}
+@custom-variant dark (&:is(.dark *));
 
 @theme inline {
   --color-background: var(--background);
   --color-foreground: var(--foreground);
   --font-sans: var(--font-geist-sans);
   --font-mono: var(--font-geist-mono);
+  --color-sidebar-ring: var(--sidebar-ring);
+  --color-sidebar-border: var(--sidebar-border);
+  --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+  --color-sidebar-accent: var(--sidebar-accent);
+  --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+  --color-sidebar-primary: var(--sidebar-primary);
+  --color-sidebar-foreground: var(--sidebar-foreground);
+  --color-sidebar: var(--sidebar);
+  --color-chart-5: var(--chart-5);
+  --color-chart-4: var(--chart-4);
+  --color-chart-3: var(--chart-3);
+  --color-chart-2: var(--chart-2);
+  --color-chart-1: var(--chart-1);
+  --color-ring: var(--ring);
+  --color-input: var(--input);
+  --color-border: var(--border);
+  --color-destructive: var(--destructive);
+  --color-accent-foreground: var(--accent-foreground);
+  --color-accent: var(--accent);
+  --color-muted-foreground: var(--muted-foreground);
+  --color-muted: var(--muted);
+  --color-secondary-foreground: var(--secondary-foreground);
+  --color-secondary: var(--secondary);
+  --color-primary-foreground: var(--primary-foreground);
+  --color-primary: var(--primary);
+  --color-popover-foreground: var(--popover-foreground);
+  --color-popover: var(--popover);
+  --color-card-foreground: var(--card-foreground);
+  --color-card: var(--card);
+  --radius-sm: calc(var(--radius) - 4px);
+  --radius-md: calc(var(--radius) - 2px);
+  --radius-lg: var(--radius);
+  --radius-xl: calc(var(--radius) + 4px);
 }
 
-@media (prefers-color-scheme: dark) {
-  :root {
-    --background: #0a0a0a;
-    --foreground: #ededed;
-  }
+:root {
+  --radius: 0.625rem;
+  --background: oklch(1 0 0);
+  --foreground: oklch(0.145 0 0);
+  --card: oklch(1 0 0);
+  --card-foreground: oklch(0.145 0 0);
+  --popover: oklch(1 0 0);
+  --popover-foreground: oklch(0.145 0 0);
+  --primary: oklch(0.205 0 0);
+  --primary-foreground: oklch(0.985 0 0);
+  --secondary: oklch(0.97 0 0);
+  --secondary-foreground: oklch(0.205 0 0);
+  --muted: oklch(0.97 0 0);
+  --muted-foreground: oklch(0.556 0 0);
+  --accent: oklch(0.97 0 0);
+  --accent-foreground: oklch(0.205 0 0);
+  --destructive: oklch(0.577 0.245 27.325);
+  --border: oklch(0.922 0 0);
+  --input: oklch(0.922 0 0);
+  --ring: oklch(0.708 0 0);
+  --chart-1: oklch(0.646 0.222 41.116);
+  --chart-2: oklch(0.6 0.118 184.704);
+  --chart-3: oklch(0.398 0.07 227.392);
+  --chart-4: oklch(0.828 0.189 84.429);
+  --chart-5: oklch(0.769 0.188 70.08);
+  --sidebar: oklch(0.985 0 0);
+  --sidebar-foreground: oklch(0.145 0 0);
+  --sidebar-primary: oklch(0.205 0 0);
+  --sidebar-primary-foreground: oklch(0.985 0 0);
+  --sidebar-accent: oklch(0.97 0 0);
+  --sidebar-accent-foreground: oklch(0.205 0 0);
+  --sidebar-border: oklch(0.922 0 0);
+  --sidebar-ring: oklch(0.708 0 0);
 }
 
-body {
-  background: var(--background);
-  color: var(--foreground);
-  font-family: Arial, Helvetica, sans-serif;
+.dark {
+  --background: oklch(0.145 0 0);
+  --foreground: oklch(0.985 0 0);
+  --card: oklch(0.205 0 0);
+  --card-foreground: oklch(0.985 0 0);
+  --popover: oklch(0.205 0 0);
+  --popover-foreground: oklch(0.985 0 0);
+  --primary: oklch(0.922 0 0);
+  --primary-foreground: oklch(0.205 0 0);
+  --secondary: oklch(0.269 0 0);
+  --secondary-foreground: oklch(0.985 0 0);
+  --muted: oklch(0.269 0 0);
+  --muted-foreground: oklch(0.708 0 0);
+  --accent: oklch(0.269 0 0);
+  --accent-foreground: oklch(0.985 0 0);
+  --destructive: oklch(0.704 0.191 22.216);
+  --border: oklch(1 0 0 / 10%);
+  --input: oklch(1 0 0 / 15%);
+  --ring: oklch(0.556 0 0);
+  --chart-1: oklch(0.488 0.243 264.376);
+  --chart-2: oklch(0.696 0.17 162.48);
+  --chart-3: oklch(0.769 0.188 70.08);
+  --chart-4: oklch(0.627 0.265 303.9);
+  --chart-5: oklch(0.645 0.246 16.439);
+  --sidebar: oklch(0.205 0 0);
+  --sidebar-foreground: oklch(0.985 0 0);
+  --sidebar-primary: oklch(0.488 0.243 264.376);
+  --sidebar-primary-foreground: oklch(0.985 0 0);
+  --sidebar-accent: oklch(0.269 0 0);
+  --sidebar-accent-foreground: oklch(0.985 0 0);
+  --sidebar-border: oklch(1 0 0 / 10%);
+  --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+  * {
+    @apply border-border outline-ring/50;
+  }
+  body {
+    @apply bg-background text-foreground;
+  }
 }

+ 14 - 4
src/app/layout.tsx

@@ -1,6 +1,8 @@
 import type { Metadata } from "next";
 import { Geist, Geist_Mono } from "next/font/google";
 import "./globals.css";
+import { ThemeProvider } from "@/components/theme-provider";
+import { Toaster } from "@/components/ui/sonner";
 
 const geistSans = Geist({
   variable: "--font-geist-sans",
@@ -13,8 +15,8 @@ const geistMono = Geist_Mono({
 });
 
 export const metadata: Metadata = {
-  title: "Create Next App",
-  description: "Generated by create next app",
+  title: "Sumire - shadcn/ui Demo",
+  description: "Proyecto demo de Next.js con shadcn/ui",
 };
 
 export default function RootLayout({
@@ -23,11 +25,19 @@ export default function RootLayout({
   children: React.ReactNode;
 }>) {
   return (
-    <html lang="en">
+    <html lang="es" suppressHydrationWarning>
       <body
         className={`${geistSans.variable} ${geistMono.variable} antialiased`}
       >
-        {children}
+        <ThemeProvider
+          attribute="class"
+          defaultTheme="system"
+          enableSystem
+          disableTransitionOnChange
+        >
+          {children}
+          <Toaster />
+        </ThemeProvider>
       </body>
     </html>
   );

+ 277 - 56
src/app/page.tsx

@@ -1,65 +1,286 @@
-import Image from "next/image";
+"use client"
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Badge } from "@/components/ui/badge";
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Textarea } from "@/components/ui/textarea";
+import { Switch } from "@/components/ui/switch";
+import { toast } from "sonner";
+import { ModeToggle } from "@/components/mode-toggle";
 
 export default function Home() {
+  const [name, setName] = useState("");
+  const [email, setEmail] = useState("");
+  const [message, setMessage] = useState("");
+  const [selectedOption, setSelectedOption] = useState("");
+  const [isChecked, setIsChecked] = useState(false);
+  const [isSwitchOn, setIsSwitchOn] = useState(false);
+
+  const handleSubmit = (e: React.FormEvent) => {
+    e.preventDefault();
+    toast.success("Formulario enviado exitosamente!");
+    console.log({ name, email, message, selectedOption, isChecked, isSwitchOn });
+  };
+
+  const handleDialogOpen = () => {
+    toast.info("Dialog abierto");
+  };
+
   return (
-    <div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
-      <main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
-        <Image
-          className="dark:invert"
-          src="/next.svg"
-          alt="Next.js logo"
-          width={100}
-          height={20}
-          priority
-        />
-        <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
-          <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
-            To get started, edit the page.tsx file.
-          </h1>
-          <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
-            Looking for a starting point or more instructions? Head over to{" "}
-            <a
-              href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-              className="font-medium text-zinc-950 dark:text-zinc-50"
-            >
-              Templates
-            </a>{" "}
-            or the{" "}
-            <a
-              href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-              className="font-medium text-zinc-950 dark:text-zinc-50"
-            >
-              Learning
-            </a>{" "}
-            center.
+    <div className="min-h-screen bg-background p-8">
+      <div className="mx-auto max-w-6xl space-y-8">
+        {/* Header */}
+        <div className="text-center">
+          <div className="flex justify-end mb-4">
+            <ModeToggle />
+          </div>
+          <h1 className="text-4xl font-bold tracking-tight">Sumire - shadcn/ui Demo</h1>
+          <p className="mt-2 text-muted-foreground">
+            Demostración completa de componentes shadcn/ui en Next.js
           </p>
+          <div className="mt-4 flex justify-center gap-2">
+            <Badge variant="default">Next.js</Badge>
+            <Badge variant="secondary">shadcn/ui</Badge>
+            <Badge variant="outline">Tailwind CSS</Badge>
+          </div>
+        </div>
+
+        {/* Buttons Section */}
+        <Card>
+          <CardHeader>
+            <CardTitle>Botones</CardTitle>
+            <CardDescription>Diferentes variantes y estados de botones</CardDescription>
+          </CardHeader>
+          <CardContent className="space-y-4">
+            <div className="flex flex-wrap gap-2">
+              <Button>Botón Primario</Button>
+              <Button variant="secondary">Secundario</Button>
+              <Button variant="destructive">Destructivo</Button>
+              <Button variant="outline">Outline</Button>
+              <Button variant="ghost">Ghost</Button>
+              <Button variant="link">Link</Button>
+            </div>
+            <div className="flex flex-wrap gap-2">
+              <Button size="sm">Pequeño</Button>
+              <Button size="default">Por Defecto</Button>
+              <Button size="lg">Grande</Button>
+            </div>
+            <div className="flex flex-wrap gap-2">
+              <Button disabled>Deshabilitado</Button>
+              <Button variant="secondary">Cargando...</Button>
+            </div>
+          </CardContent>
+        </Card>
+
+        {/* Form Section */}
+        <Card>
+          <CardHeader>
+            <CardTitle>Formulario</CardTitle>
+            <CardDescription>Ejemplo de formulario con diferentes campos</CardDescription>
+          </CardHeader>
+          <CardContent>
+            <form onSubmit={handleSubmit} className="space-y-4">
+              <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                <div className="space-y-2">
+                  <Label htmlFor="name">Nombre</Label>
+                  <Input
+                    id="name"
+                    value={name}
+                    onChange={(e) => setName(e.target.value)}
+                    placeholder="Tu nombre"
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="email">Email</Label>
+                  <Input
+                    id="email"
+                    type="email"
+                    value={email}
+                    onChange={(e) => setEmail(e.target.value)}
+                    placeholder="tu@email.com"
+                  />
+                </div>
+              </div>
+              
+              <div className="space-y-2">
+                <Label htmlFor="select">Opción</Label>
+                <Select value={selectedOption} onValueChange={setSelectedOption}>
+                  <SelectTrigger>
+                    <SelectValue placeholder="Selecciona una opción" />
+                  </SelectTrigger>
+                  <SelectContent>
+                    <SelectItem value="option1">Opción 1</SelectItem>
+                    <SelectItem value="option2">Opción 2</SelectItem>
+                    <SelectItem value="option3">Opción 3</SelectItem>
+                  </SelectContent>
+                </Select>
+              </div>
+
+              <div className="space-y-2">
+                <Label htmlFor="message">Mensaje</Label>
+                <Textarea
+                  id="message"
+                  value={message}
+                  onChange={(e) => setMessage(e.target.value)}
+                  placeholder="Escribe tu mensaje aquí..."
+                  rows={3}
+                />
+              </div>
+
+              <div className="flex items-center space-x-4">
+                <div className="flex items-center space-x-2">
+                  <Checkbox
+                    id="terms"
+                    checked={isChecked}
+                    onCheckedChange={(checked) => setIsChecked(checked as boolean)}
+                  />
+                  <Label htmlFor="terms">Acepto los términos y condiciones</Label>
+                </div>
+                <div className="flex items-center space-x-2">
+                  <Switch
+                    id="notifications"
+                    checked={isSwitchOn}
+                    onCheckedChange={setIsSwitchOn}
+                  />
+                  <Label htmlFor="notifications">Notificaciones</Label>
+                </div>
+              </div>
+
+              <Button type="submit" className="w-full">
+                Enviar Formulario
+              </Button>
+            </form>
+          </CardContent>
+        </Card>
+
+        {/* Dialog Section */}
+        <Card>
+          <CardHeader>
+            <CardTitle>Dialog</CardTitle>
+            <CardDescription>Ejemplo de diálogo modal</CardDescription>
+          </CardHeader>
+          <CardContent>
+            <Dialog>
+              <DialogTrigger asChild>
+                <Button variant="outline" onClick={handleDialogOpen}>
+                  Abrir Diálogo
+                </Button>
+              </DialogTrigger>
+              <DialogContent>
+                <DialogHeader>
+                  <DialogTitle>Diálogo de Ejemplo</DialogTitle>
+                  <DialogDescription>
+                    Este es un ejemplo de diálogo modal usando shadcn/ui. 
+                    Puedes colocar cualquier contenido aquí.
+                  </DialogDescription>
+                </DialogHeader>
+                <div className="py-4">
+                  <p className="text-sm text-muted-foreground">
+                    Los diálogos son perfectos para confirmaciones, formularios 
+                    o mostrar información importante sin navegar away de la página actual.
+                  </p>
+                </div>
+                <div className="flex justify-end gap-2">
+                  <Button variant="outline">Cancelar</Button>
+                  <Button>Confirmar</Button>
+                </div>
+              </DialogContent>
+            </Dialog>
+          </CardContent>
+        </Card>
+
+        {/* Status Cards */}
+        <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+          <Card>
+            <CardHeader className="pb-3">
+              <CardTitle className="text-sm font-medium">Total Usuarios</CardTitle>
+            </CardHeader>
+            <CardContent>
+              <div className="text-2xl font-bold">1,234</div>
+              <p className="text-xs text-muted-foreground">
+                <span className="text-green-600">+12%</span> desde el mes pasado
+              </p>
+            </CardContent>
+          </Card>
+          
+          <Card>
+            <CardHeader className="pb-3">
+              <CardTitle className="text-sm font-medium">Ingresos</CardTitle>
+            </CardHeader>
+            <CardContent>
+              <div className="text-2xl font-bold">$12,345</div>
+              <p className="text-xs text-muted-foreground">
+                <span className="text-green-600">+8%</span> desde el mes pasado
+              </p>
+            </CardContent>
+          </Card>
+          
+          <Card>
+            <CardHeader className="pb-3">
+              <CardTitle className="text-sm font-medium">Tasa de Conversión</CardTitle>
+            </CardHeader>
+            <CardContent>
+              <div className="text-2xl font-bold">3.2%</div>
+              <p className="text-xs text-muted-foreground">
+                <span className="text-red-600">-2%</span> desde el mes pasado
+              </p>
+            </CardContent>
+          </Card>
         </div>
-        <div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
-          <a
-            className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
-            href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            <Image
-              className="dark:invert"
-              src="/vercel.svg"
-              alt="Vercel logomark"
-              width={16}
-              height={16}
-            />
-            Deploy Now
-          </a>
-          <a
-            className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
-            href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            Documentation
-          </a>
+
+        {/* Theme Toggle Demo */}
+        <Card>
+          <CardHeader>
+            <CardTitle>Selector de Tema</CardTitle>
+            <CardDescription>Cambia entre modo claro, oscuro y sistema</CardDescription>
+          </CardHeader>
+          <CardContent className="flex items-center justify-between">
+            <div>
+              <p className="text-sm font-medium">Modo Actual</p>
+              <p className="text-sm text-muted-foreground">
+                Usa el botón para cambiar entre Light, Dark y System
+              </p>
+            </div>
+            <ModeToggle />
+          </CardContent>
+        </Card>
+
+        {/* Badges Showcase */}
+        <Card>
+          <CardHeader>
+            <CardTitle>Insignias (Badges)</CardTitle>
+            <CardDescription>Diferentes variantes de badges</CardDescription>
+          </CardHeader>
+          <CardContent>
+            <div className="flex flex-wrap gap-2">
+              <Badge>Default</Badge>
+              <Badge variant="secondary">Secondary</Badge>
+              <Badge variant="destructive">Destructive</Badge>
+              <Badge variant="outline">Outline</Badge>
+              <Badge className="bg-green-500 hover:bg-green-600">Success</Badge>
+              <Badge className="bg-yellow-500 hover:bg-yellow-600">Warning</Badge>
+              <Badge className="bg-blue-500 hover:bg-blue-600">Info</Badge>
+            </div>
+          </CardContent>
+        </Card>
+
+        {/* Footer */}
+        <div className="text-center py-8 border-t">
+          <p className="text-muted-foreground">
+            Demo creado con ❤️ usando Next.js y shadcn/ui
+          </p>
+          <div className="mt-2 flex justify-center gap-2">
+            <Badge variant="outline">v1.0.0</Badge>
+            <Badge variant="outline">TypeScript</Badge>
+          </div>
         </div>
-      </main>
+      </div>
     </div>
   );
 }

+ 40 - 0
src/components/mode-toggle.tsx

@@ -0,0 +1,40 @@
+"use client"
+
+import * as React from "react"
+import { Moon, Sun } from "lucide-react"
+import { useTheme } from "next-themes"
+
+import { Button } from "@/components/ui/button"
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+export function ModeToggle() {
+  const { setTheme } = useTheme()
+
+  return (
+    <DropdownMenu>
+      <DropdownMenuTrigger asChild>
+        <Button variant="outline" size="icon">
+          <Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
+          <Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
+          <span className="sr-only">Toggle theme</span>
+        </Button>
+      </DropdownMenuTrigger>
+      <DropdownMenuContent align="end">
+        <DropdownMenuItem onClick={() => setTheme("light")}>
+          Light
+        </DropdownMenuItem>
+        <DropdownMenuItem onClick={() => setTheme("dark")}>
+          Dark
+        </DropdownMenuItem>
+        <DropdownMenuItem onClick={() => setTheme("system")}>
+          System
+        </DropdownMenuItem>
+      </DropdownMenuContent>
+    </DropdownMenu>
+  )
+}

+ 11 - 0
src/components/theme-provider.tsx

@@ -0,0 +1,11 @@
+"use client"
+
+import * as React from "react"
+import { ThemeProvider as NextThemesProvider } from "next-themes"
+
+export function ThemeProvider({
+  children,
+  ...props
+}: React.ComponentProps<typeof NextThemesProvider>) {
+  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
+}

+ 46 - 0
src/components/ui/badge.tsx

@@ -0,0 +1,46 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+  "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+  {
+    variants: {
+      variant: {
+        default:
+          "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+        secondary:
+          "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+        destructive:
+          "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+        outline:
+          "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+)
+
+function Badge({
+  className,
+  variant,
+  asChild = false,
+  ...props
+}: React.ComponentProps<"span"> &
+  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
+  const Comp = asChild ? Slot : "span"
+
+  return (
+    <Comp
+      data-slot="badge"
+      className={cn(badgeVariants({ variant }), className)}
+      {...props}
+    />
+  )
+}
+
+export { Badge, badgeVariants }

+ 60 - 0
src/components/ui/button.tsx

@@ -0,0 +1,60 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground hover:bg-primary/90",
+        destructive:
+          "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+        outline:
+          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+        secondary:
+          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+        ghost:
+          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+      size: {
+        default: "h-9 px-4 py-2 has-[>svg]:px-3",
+        sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+        icon: "size-9",
+        "icon-sm": "size-8",
+        "icon-lg": "size-10",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  }
+)
+
+function Button({
+  className,
+  variant,
+  size,
+  asChild = false,
+  ...props
+}: React.ComponentProps<"button"> &
+  VariantProps<typeof buttonVariants> & {
+    asChild?: boolean
+  }) {
+  const Comp = asChild ? Slot : "button"
+
+  return (
+    <Comp
+      data-slot="button"
+      className={cn(buttonVariants({ variant, size, className }))}
+      {...props}
+    />
+  )
+}
+
+export { Button, buttonVariants }

+ 92 - 0
src/components/ui/card.tsx

@@ -0,0 +1,92 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card"
+      className={cn(
+        "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-header"
+      className={cn(
+        "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-title"
+      className={cn("leading-none font-semibold", className)}
+      {...props}
+    />
+  )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-description"
+      className={cn("text-muted-foreground text-sm", className)}
+      {...props}
+    />
+  )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-action"
+      className={cn(
+        "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-content"
+      className={cn("px-6", className)}
+      {...props}
+    />
+  )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="card-footer"
+      className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
+      {...props}
+    />
+  )
+}
+
+export {
+  Card,
+  CardHeader,
+  CardFooter,
+  CardTitle,
+  CardAction,
+  CardDescription,
+  CardContent,
+}

+ 32 - 0
src/components/ui/checkbox.tsx

@@ -0,0 +1,32 @@
+"use client"
+
+import * as React from "react"
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
+import { CheckIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Checkbox({
+  className,
+  ...props
+}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
+  return (
+    <CheckboxPrimitive.Root
+      data-slot="checkbox"
+      className={cn(
+        "peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+        className
+      )}
+      {...props}
+    >
+      <CheckboxPrimitive.Indicator
+        data-slot="checkbox-indicator"
+        className="grid place-content-center text-current transition-none"
+      >
+        <CheckIcon className="size-3.5" />
+      </CheckboxPrimitive.Indicator>
+    </CheckboxPrimitive.Root>
+  )
+}
+
+export { Checkbox }

+ 143 - 0
src/components/ui/dialog.tsx

@@ -0,0 +1,143 @@
+"use client"
+
+import * as React from "react"
+import * as DialogPrimitive from "@radix-ui/react-dialog"
+import { XIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Dialog({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Root>) {
+  return <DialogPrimitive.Root data-slot="dialog" {...props} />
+}
+
+function DialogTrigger({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
+  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
+}
+
+function DialogPortal({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
+  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
+}
+
+function DialogClose({
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Close>) {
+  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
+}
+
+function DialogOverlay({
+  className,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
+  return (
+    <DialogPrimitive.Overlay
+      data-slot="dialog-overlay"
+      className={cn(
+        "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DialogContent({
+  className,
+  children,
+  showCloseButton = true,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Content> & {
+  showCloseButton?: boolean
+}) {
+  return (
+    <DialogPortal data-slot="dialog-portal">
+      <DialogOverlay />
+      <DialogPrimitive.Content
+        data-slot="dialog-content"
+        className={cn(
+          "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
+          className
+        )}
+        {...props}
+      >
+        {children}
+        {showCloseButton && (
+          <DialogPrimitive.Close
+            data-slot="dialog-close"
+            className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
+          >
+            <XIcon />
+            <span className="sr-only">Close</span>
+          </DialogPrimitive.Close>
+        )}
+      </DialogPrimitive.Content>
+    </DialogPortal>
+  )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="dialog-header"
+      className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
+      {...props}
+    />
+  )
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="dialog-footer"
+      className={cn(
+        "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DialogTitle({
+  className,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Title>) {
+  return (
+    <DialogPrimitive.Title
+      data-slot="dialog-title"
+      className={cn("text-lg leading-none font-semibold", className)}
+      {...props}
+    />
+  )
+}
+
+function DialogDescription({
+  className,
+  ...props
+}: React.ComponentProps<typeof DialogPrimitive.Description>) {
+  return (
+    <DialogPrimitive.Description
+      data-slot="dialog-description"
+      className={cn("text-muted-foreground text-sm", className)}
+      {...props}
+    />
+  )
+}
+
+export {
+  Dialog,
+  DialogClose,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogOverlay,
+  DialogPortal,
+  DialogTitle,
+  DialogTrigger,
+}

+ 257 - 0
src/components/ui/dropdown-menu.tsx

@@ -0,0 +1,257 @@
+"use client"
+
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function DropdownMenu({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
+  return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
+}
+
+function DropdownMenuPortal({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
+  return (
+    <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
+  )
+}
+
+function DropdownMenuTrigger({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
+  return (
+    <DropdownMenuPrimitive.Trigger
+      data-slot="dropdown-menu-trigger"
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuContent({
+  className,
+  sideOffset = 4,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
+  return (
+    <DropdownMenuPrimitive.Portal>
+      <DropdownMenuPrimitive.Content
+        data-slot="dropdown-menu-content"
+        sideOffset={sideOffset}
+        className={cn(
+          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
+          className
+        )}
+        {...props}
+      />
+    </DropdownMenuPrimitive.Portal>
+  )
+}
+
+function DropdownMenuGroup({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
+  return (
+    <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
+  )
+}
+
+function DropdownMenuItem({
+  className,
+  inset,
+  variant = "default",
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
+  inset?: boolean
+  variant?: "default" | "destructive"
+}) {
+  return (
+    <DropdownMenuPrimitive.Item
+      data-slot="dropdown-menu-item"
+      data-inset={inset}
+      data-variant={variant}
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuCheckboxItem({
+  className,
+  children,
+  checked,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
+  return (
+    <DropdownMenuPrimitive.CheckboxItem
+      data-slot="dropdown-menu-checkbox-item"
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      checked={checked}
+      {...props}
+    >
+      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
+        <DropdownMenuPrimitive.ItemIndicator>
+          <CheckIcon className="size-4" />
+        </DropdownMenuPrimitive.ItemIndicator>
+      </span>
+      {children}
+    </DropdownMenuPrimitive.CheckboxItem>
+  )
+}
+
+function DropdownMenuRadioGroup({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
+  return (
+    <DropdownMenuPrimitive.RadioGroup
+      data-slot="dropdown-menu-radio-group"
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuRadioItem({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
+  return (
+    <DropdownMenuPrimitive.RadioItem
+      data-slot="dropdown-menu-radio-item"
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    >
+      <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
+        <DropdownMenuPrimitive.ItemIndicator>
+          <CircleIcon className="size-2 fill-current" />
+        </DropdownMenuPrimitive.ItemIndicator>
+      </span>
+      {children}
+    </DropdownMenuPrimitive.RadioItem>
+  )
+}
+
+function DropdownMenuLabel({
+  className,
+  inset,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
+  inset?: boolean
+}) {
+  return (
+    <DropdownMenuPrimitive.Label
+      data-slot="dropdown-menu-label"
+      data-inset={inset}
+      className={cn(
+        "px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuSeparator({
+  className,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
+  return (
+    <DropdownMenuPrimitive.Separator
+      data-slot="dropdown-menu-separator"
+      className={cn("bg-border -mx-1 my-1 h-px", className)}
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuShortcut({
+  className,
+  ...props
+}: React.ComponentProps<"span">) {
+  return (
+    <span
+      data-slot="dropdown-menu-shortcut"
+      className={cn(
+        "text-muted-foreground ml-auto text-xs tracking-widest",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuSub({
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
+  return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
+}
+
+function DropdownMenuSubTrigger({
+  className,
+  inset,
+  children,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
+  inset?: boolean
+}) {
+  return (
+    <DropdownMenuPrimitive.SubTrigger
+      data-slot="dropdown-menu-sub-trigger"
+      data-inset={inset}
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      <ChevronRightIcon className="ml-auto size-4" />
+    </DropdownMenuPrimitive.SubTrigger>
+  )
+}
+
+function DropdownMenuSubContent({
+  className,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
+  return (
+    <DropdownMenuPrimitive.SubContent
+      data-slot="dropdown-menu-sub-content"
+      className={cn(
+        "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export {
+  DropdownMenu,
+  DropdownMenuPortal,
+  DropdownMenuTrigger,
+  DropdownMenuContent,
+  DropdownMenuGroup,
+  DropdownMenuLabel,
+  DropdownMenuItem,
+  DropdownMenuCheckboxItem,
+  DropdownMenuRadioGroup,
+  DropdownMenuRadioItem,
+  DropdownMenuSeparator,
+  DropdownMenuShortcut,
+  DropdownMenuSub,
+  DropdownMenuSubTrigger,
+  DropdownMenuSubContent,
+}

+ 21 - 0
src/components/ui/input.tsx

@@ -0,0 +1,21 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+  return (
+    <input
+      type={type}
+      data-slot="input"
+      className={cn(
+        "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
+        "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Input }

+ 24 - 0
src/components/ui/label.tsx

@@ -0,0 +1,24 @@
+"use client"
+
+import * as React from "react"
+import * as LabelPrimitive from "@radix-ui/react-label"
+
+import { cn } from "@/lib/utils"
+
+function Label({
+  className,
+  ...props
+}: React.ComponentProps<typeof LabelPrimitive.Root>) {
+  return (
+    <LabelPrimitive.Root
+      data-slot="label"
+      className={cn(
+        "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Label }

+ 187 - 0
src/components/ui/select.tsx

@@ -0,0 +1,187 @@
+"use client"
+
+import * as React from "react"
+import * as SelectPrimitive from "@radix-ui/react-select"
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function Select({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Root>) {
+  return <SelectPrimitive.Root data-slot="select" {...props} />
+}
+
+function SelectGroup({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Group>) {
+  return <SelectPrimitive.Group data-slot="select-group" {...props} />
+}
+
+function SelectValue({
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Value>) {
+  return <SelectPrimitive.Value data-slot="select-value" {...props} />
+}
+
+function SelectTrigger({
+  className,
+  size = "default",
+  children,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
+  size?: "sm" | "default"
+}) {
+  return (
+    <SelectPrimitive.Trigger
+      data-slot="select-trigger"
+      data-size={size}
+      className={cn(
+        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      <SelectPrimitive.Icon asChild>
+        <ChevronDownIcon className="size-4 opacity-50" />
+      </SelectPrimitive.Icon>
+    </SelectPrimitive.Trigger>
+  )
+}
+
+function SelectContent({
+  className,
+  children,
+  position = "popper",
+  align = "center",
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Content>) {
+  return (
+    <SelectPrimitive.Portal>
+      <SelectPrimitive.Content
+        data-slot="select-content"
+        className={cn(
+          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
+          position === "popper" &&
+            "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
+          className
+        )}
+        position={position}
+        align={align}
+        {...props}
+      >
+        <SelectScrollUpButton />
+        <SelectPrimitive.Viewport
+          className={cn(
+            "p-1",
+            position === "popper" &&
+              "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
+          )}
+        >
+          {children}
+        </SelectPrimitive.Viewport>
+        <SelectScrollDownButton />
+      </SelectPrimitive.Content>
+    </SelectPrimitive.Portal>
+  )
+}
+
+function SelectLabel({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Label>) {
+  return (
+    <SelectPrimitive.Label
+      data-slot="select-label"
+      className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
+      {...props}
+    />
+  )
+}
+
+function SelectItem({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Item>) {
+  return (
+    <SelectPrimitive.Item
+      data-slot="select-item"
+      className={cn(
+        "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
+        className
+      )}
+      {...props}
+    >
+      <span className="absolute right-2 flex size-3.5 items-center justify-center">
+        <SelectPrimitive.ItemIndicator>
+          <CheckIcon className="size-4" />
+        </SelectPrimitive.ItemIndicator>
+      </span>
+      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+    </SelectPrimitive.Item>
+  )
+}
+
+function SelectSeparator({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
+  return (
+    <SelectPrimitive.Separator
+      data-slot="select-separator"
+      className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
+      {...props}
+    />
+  )
+}
+
+function SelectScrollUpButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
+  return (
+    <SelectPrimitive.ScrollUpButton
+      data-slot="select-scroll-up-button"
+      className={cn(
+        "flex cursor-default items-center justify-center py-1",
+        className
+      )}
+      {...props}
+    >
+      <ChevronUpIcon className="size-4" />
+    </SelectPrimitive.ScrollUpButton>
+  )
+}
+
+function SelectScrollDownButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
+  return (
+    <SelectPrimitive.ScrollDownButton
+      data-slot="select-scroll-down-button"
+      className={cn(
+        "flex cursor-default items-center justify-center py-1",
+        className
+      )}
+      {...props}
+    >
+      <ChevronDownIcon className="size-4" />
+    </SelectPrimitive.ScrollDownButton>
+  )
+}
+
+export {
+  Select,
+  SelectContent,
+  SelectGroup,
+  SelectItem,
+  SelectLabel,
+  SelectScrollDownButton,
+  SelectScrollUpButton,
+  SelectSeparator,
+  SelectTrigger,
+  SelectValue,
+}

+ 40 - 0
src/components/ui/sonner.tsx

@@ -0,0 +1,40 @@
+"use client"
+
+import {
+  CircleCheckIcon,
+  InfoIcon,
+  Loader2Icon,
+  OctagonXIcon,
+  TriangleAlertIcon,
+} from "lucide-react"
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, type ToasterProps } from "sonner"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+  const { theme = "system" } = useTheme()
+
+  return (
+    <Sonner
+      theme={theme as ToasterProps["theme"]}
+      className="toaster group"
+      icons={{
+        success: <CircleCheckIcon className="size-4" />,
+        info: <InfoIcon className="size-4" />,
+        warning: <TriangleAlertIcon className="size-4" />,
+        error: <OctagonXIcon className="size-4" />,
+        loading: <Loader2Icon className="size-4 animate-spin" />,
+      }}
+      style={
+        {
+          "--normal-bg": "var(--popover)",
+          "--normal-text": "var(--popover-foreground)",
+          "--normal-border": "var(--border)",
+          "--border-radius": "var(--radius)",
+        } as React.CSSProperties
+      }
+      {...props}
+    />
+  )
+}
+
+export { Toaster }

+ 31 - 0
src/components/ui/switch.tsx

@@ -0,0 +1,31 @@
+"use client"
+
+import * as React from "react"
+import * as SwitchPrimitive from "@radix-ui/react-switch"
+
+import { cn } from "@/lib/utils"
+
+function Switch({
+  className,
+  ...props
+}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
+  return (
+    <SwitchPrimitive.Root
+      data-slot="switch"
+      className={cn(
+        "peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
+        className
+      )}
+      {...props}
+    >
+      <SwitchPrimitive.Thumb
+        data-slot="switch-thumb"
+        className={cn(
+          "bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
+        )}
+      />
+    </SwitchPrimitive.Root>
+  )
+}
+
+export { Switch }

+ 18 - 0
src/components/ui/textarea.tsx

@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+  return (
+    <textarea
+      data-slot="textarea"
+      className={cn(
+        "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Textarea }

+ 6 - 0
src/lib/utils.ts

@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs))
+}

Some files were not shown because too many files changed in this diff