[200~refact: reestructuración completa a shadcn/ui, nuevo tema tweakcn e interactividad avanzada

This commit is contained in:
Edgar Javier Ortiz 2026-06-09 14:13:30 -03:00
parent 277dce6513
commit 63dc9163a5
58 changed files with 5917 additions and 4415 deletions

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,15 +1,13 @@
<!doctype html>
<html lang="en" class="dark scroll-smooth">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RepairIT</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@ -2,9 +2,7 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
"@/*": ["./src/*"]
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -10,41 +10,31 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.8",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@fontsource-variable/geist": "^5.2.9",
"@tailwindcss/vite": "^4.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.9.0",
"next": "^16.2.4",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2",
"shadcn": "^4.4.0",
"tailwind-merge": "^3.5.0",
"lucide-react": "^1.17.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.5.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0",
"shadcn": "^4.11.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.2.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.2.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"postcss": "^8.5.10",
"tailwindcss": "^4.2.4",
"vite": "^8.0.10"
"globals": "^17.6.0",
"vite": "^8.0.12"
}
}

View File

@ -0,0 +1,141 @@
# PROPUESTA DE PROYECTO FINAL - TALLER III
## Sistema de Gestión y Trazabilidad para Servicios Técnicos: "RepairIT"
**Alumno:** Edgar Javier Ortiz
**Carrera:** Tecnicatura Superior en Desarrollo de Software
**Institución:** IES La Cocha
**Año:** 2026
---
## 1. ESPECIFICACIONES DEL SISTEMA (ALCANCE)
### 1.1. Descripción General
**RepairIT** es una plataforma web SaaS desacoplada, diseñada específicamente para optimizar la gestión operativa, el control de inventario y la comunicación con el cliente dentro de talleres de reparación tecnológica. El sistema automatiza el flujo de trabajo desde el ingreso del dispositivo hasta su entrega final, ofreciendo un canal público de consulta en tiempo real para los usuarios del servicio.
### 1.2. Objetivos del Proyecto
* **Objetivo General:** Desarrollar e implementar una aplicación web reactiva y escalable que centralice la administración de órdenes de reparación y audite el stock de insumos técnicos, mejorando la transparencia hacia el cliente final mediante identificadores únicos de seguimiento.
* **Objetivos Específicos:**
* Segmentar la plataforma mediante subdominios funcionales independientes para aislar el panel administrativo de la consulta pública.
* Implementar un algoritmo de generación de identificadores únicos no secuenciales (NanoID) para prescindir del uso de contraseñas por parte del cliente.
* Automatizar el descuento dinámico de stock de componentes asociados a cada reparación realizada.
* Proveer módulos de exportación de datos en formatos PDF y Excel del lado del cliente.
### 1.3. Alcance del Proyecto (MVP)
* **Dentro del Alcance:**
* Panel de autenticación (Login) para el personal técnico y administradores mediante tokens JWT.
* Módulo de gestión de órdenes de servicio (Creación, asignación de técnico, diagnóstico, presupuesto, estados y cierre).
* Módulo de inventario y control de stock de repuestos con alertas de bajo suministro.
* Interfaz de consulta pública para clientes basada en parámetros de URL dinámicos.
* Motor de exportación de presupuestos a PDF y auditorías de stock a planillas Excel.
* **Fuera del Alcance:**
* Pasarela de pagos online dentro de la plataforma (las transacciones monetarias se gestionan de forma física o externa al sistema).
* Aplicación móvil nativa en esta primera fase del ciclo de vida del software.
### 1.4. Requisitos Funcionales (RF)
* **RF01 - Autenticación de Usuarios Internos:** El sistema debe permitir el inicio de sesión exclusivo a técnicos y administradores registrados mediante credenciales encriptadas.
* **RF02 - Registro de Órdenes de Servicio:** El sistema debe permitir la carga de un nuevo equipo ingresado, asociando los datos del cliente, marca, modelo, fallas reportadas y un técnico responsable.
* **RF03 - Generación de Enlace Único:** Al registrar un equipo, el sistema debe generar automáticamente un código NanoID seguro y asociarlo a una URL de consulta pública.
* **RF04 - Gestión de Estados de Reparación:** El técnico debe poder actualizar el estado del equipo siguiendo un flujo lógico (*Ingresado*, *En Diagnóstico*, *Presupuestado*, *En Reparación*, *Listo para Entregar*, *Entregado*).
* **RF05 - Carga de Presupuestos:** El sistema debe permitir asignar un costo de mano de obra y desglosar los componentes utilizados en la reparación para conformar el presupuesto total.
* **RF06 - Control de Inventario Automatizado:** Al finalizar una orden de servicio o confirmar el uso de un repuesto, el sistema debe descontar automáticamente las unidades correspondientes de la colección de stock en la base de datos.
* **RF07 - Consulta de Estado Pública:** El cliente debe poder visualizar la barra de progreso de su equipo ingresando directamente al enlace único generado, sin requerir la creación de una cuenta o inicio de sesión tradicional.
* **RF08 - Exportación Documental:** El administrador debe poder descargar presupuestos estructurados en formato PDF y reportes analíticos de stock de insumos en archivos Excel (.xlsx).
### 1.5. Requisitos No Funcionales (RNF)
* **RNF01 - Rendimiento:** El tiempo de respuesta de las consultas de estado en la interfaz pública no debe superar los 200 milisegundos, optimizado mediante ejecución perimetral.
* **RNF02 - Seguridad (Persistencia):** Las contraseñas de los usuarios internos deben almacenarse utilizando algoritmos de hash seguros (bcrypt) en el servidor.
* **RNF03 - Seguridad (API):** Todos los endpoints privados de la API RESTful deben estar protegidos mediante middlewares que validen la vigencia y firma de tokens JWT.
* **RNF04 - Disponibilidad:** La infraestructura de alojamiento debe garantizar una disponibilidad del servicio del 99.9% utilizando redes de distribución global.
* **RNF05 - Integridad de Datos:** La base de datos debe validar que el stock de insumos técnicos nunca adopte valores cuantitativos negativos.
* **RNF06 - Interfaz Adaptativa (Responsive):** La interfaz visual debe ser 100% responsive, adaptándose de manera fluida a dispositivos móviles, tablets y computadoras de escritorio.
* **RNF07 - Arquitectura Desacoplada (Subdominios):** El sistema debe segmentar sus entornos de producción mediante subdominios independientes: `dashboard.repairit.cloud` para la administración del taller y `seguimiento.repairit.cloud` para el uso exclusivo del cliente.
---
## 2. ARQUITECTURA Y TECNOLOGÍAS A UTILIZAR
Para el desarrollo del ecosistema web de **RepairIT**, se ha seleccionado un stack tecnológico moderno, robusto y completamente desacoplado (basado en el patrón Cliente-Servidor), optimizando tanto el rendimiento en producción como la agilidad en solitario del desarrollador:
### 2.1. Capa de Presentación (Frontend)
* **JavaScript / React.js (Vite):** Biblioteca orientada a componentes para el desarrollo de la interfaz de usuario como una *Single Page Application* (SPA). Facilita el renderizado dinámico, la reutilización de código y evita la recarga completa del navegador, brindando una experiencia de usuario fluida e instantánea.
* **Tailwind CSS (v4):** Framework de diseño basado en clases utilitarias de última generación. Permite construir una interfaz adaptativa, limpia y minimalista de forma ágil, reduciendo drásticamente el peso de las hojas de estilo CSS globales.
* **shadcn/ui:** Colección de componentes de interfaz reutilizables, accesibles y altamente estéticos que se copian de manera nativa en el proyecto. Utilizado para maquetar modales de confirmación avanzadas, tablas de datos y layouts del Dashboard operativo.
* **React Router:** Motor de enrutamiento dinámico encargado de gestionar la navegación interna del lado del cliente. Permite capturar los parámetros variables de las URLs (como el código de seguimiento) y procesar las vistas específicas sin interferencia del servidor.
### 2.2. Capa de Lógica de Negocio y Servidor (Backend)
* **Node.js y Express.js:** Entorno de ejecución en tiempo de reloj de JavaScript y framework minimalista para la construcción de la API RESTful. Express se encarga de la exposición de endpoints lógicos, el control de rutas del servidor, la gestión de middlewares de seguridad (validación de sesiones JWT) y el procesamiento de la lógica de negocio.
* **NanoID:** Librería de generación de identificadores únicos por software. Desarrolla strings alfanuméricos compactos, seguros y no secuenciales, ideales para generar los tokens de consulta pública de los clientes sin exponer los IDs internos secuenciales de la base de datos.
* **jsPDF y SheetJS:** Módulos especializados para la exportación de información del lado del cliente (*Client-side data exporting*). **jsPDF** procesa presupuestos estructurados y órdenes de servicio en formato PDF cerrado, mientras que **SheetJS** convierte las colecciones de datos del inventario a matrices nativas de Excel (.xlsx), ahorrando recursos de cómputo en el servidor.
### 2.3. Capa de Persistencia (Base de Datos)
* **MongoDB:** Base de datos No Relacional (NoSQL) orientada a documentos JSON/BSON. Su esquema flexible y dinámico permite almacenar el histórico de las reparaciones técnicas con campos variables (debido a las diferencias físicas entre dispositivos) sin la necesidad de ejecutar migraciones complejas de datos.
### 2.4. Capa de Red e Infraestructura
* **Cloudflare:** Plataforma de red global utilizada para la delegación DNS, gestión de certificados SSL automáticos y seguridad perimetral. Permite configurar y aislar las reglas de enrutamiento para los subdominios operativos (`dashboard.` y `seguimiento.`).
* **Cloudflare Workers:** Infraestructura *serverless* orientada a la computación en el borde (*Edge Computing*). Hospeda y ejecuta el código del backend en los nodos perimetrales más cercanos a la ubicación geográfica del usuario, disminuyendo drásticamente la latencia de las conexiones HTTP y asegurando costos operativos iniciales nulos ($0 USD).
---
## 3. METODOLOGÍA DE DESARROLLO
Para la gestión y ejecución del proyecto **RepairIT**, se ha seleccionado una **metodología híbrida adaptada para desarrollo individual**. Este enfoque estratégico combina el rigor predictivo de los modelos tradicionales para la fase de planificación, con la flexibilidad y velocidad de los marcos ágiles para la fase de construcción del software.
Dado que el proyecto es llevado a cabo por un único desarrollador, esta hibridación optimiza los tiempos de entrega al eliminar la sobrecarga administrativa de las ceremonias grupales (como en Scrum puro), sin perder el control de la calidad ni la trazabilidad del proceso.
La metodología se estructura en dos componentes operativos fundamentales:
### 3.1. Planificación Predictiva (Roadmap Estratégico)
Se establece un *Roadmap* lineal dividido en seis fases macro secuenciales. Bajo un enfoque *Frontend-First*, se prioriza la construcción de la interfaz visual de forma local, postergando la configuración de la infraestructura de red perimetral para las etapas finales de integración y despliegue:
* **Fase I: Desarrollo de Interfaces (Frontend-First):** Maquetación y diseño de la aplicación web utilizando React y componentes de shadcn/ui. Se implementa el enrutamiento dinámico para simular la navegación y se usan datos simulados (*mock data*) para validar de forma local las interfaces del Dashboard y el Seguimiento.
* **Fase II: Diseño y Modelado de Datos:** Definición de esquemas no relacionales en la base de datos (MongoDB) y estructuración de las colecciones necesarias para soportar la información y los estados que ya fueron planteados en la interfaz visual.
* **Fase III: Lógica de Negocio y Servidor (Backend):** Construcción local de la API RESTful bajo Node.js y Express. Desarrollo de los endpoints, controladores de rutas, lógica de autenticación y algoritmos esenciales (como la generación de tokens NanoID).
* **Fase IV: Infraestructura y Entorno de Red:** Configuración avanzada de la capa perimetral en la nube (Cloudflare), creación de reglas de enrutamiento para los subdominios (`dashboard.` y `seguimiento.`), asignación de certificados SSL y preparación de los entornos para producción.
* **Fase V: Integración y Consumo de la API:** Conexión de ambas capas mediante peticiones asincrónicas HTTP. Se reemplazan los datos simulados de la Fase I por el consumo real de los servicios del backend a través de la infraestructura de red configurada.
* **Fase VI: Extensibilidad y Módulos de Reportes:** Implementación de las herramientas de auditoría para el taller, desarrollando los componentes de exportación de archivos dinámicos (documentación en PDF con jsPDF y planillas de stock en Excel con SheetJS).
### 3.2. Ejecución Ágil (Marco Kanban)
Una vez delimitado el Roadmap, la fase de codificación diaria se gestiona bajo un enfoque puramente ágil e iterativo para absorber cambios técnicos de manera fluida:
* **Gestión Visual del Flujo (Kanban):** Se utiliza un tablero de tareas continuo (subdividido en *Backlog, To-Do, In Progress, Testing y Done*) en el repositorio local para controlar el inventario de funciones. Esto permite concentrar el esfuerzo en un único requerimiento por ciclo, optimizando la tasa de entrega y evitando la dispersión cognitiva.
* **Desarrollo Incremental y Refactorización (XP):** Se prioriza la entrega de un Producto Mínimo Viable (MVP) funcional en cada fase. El software se somete a ciclos constantes de refactorización para mantener la cohesión del código, asegurar el desacoplamiento de componentes en React y garantizar un despliegue limpio y escalable en la infraestructura del servidor.
---
## 4. VIABILIDAD DEL PROYECTO
### 4.1. Viabilidad Técnica
La factibilidad técnica de **RepairIT** está plenamente respaldada por la madurez, documentación y compatibilidad del ecosistema seleccionado. El uso de React en su versión moderna junto con Express permite un desacoplamiento perfecto entre la interfaz de usuario y la lógica del servidor, facilitando el mantenimiento independiente de cada capa.
Asimismo, la persistencia en MongoDB ofrece la flexibilidad necesaria para modificar los esquemas de los históricos de reparación sin necesidad de realizar migraciones complejas de bases de datos relacionales. La infraestructura perimetral provista por Cloudflare mitiga los riesgos de caídas del servicio y simplifica la administración de los subdominios funcionales de forma centralizada.
### 4.2. Viabilidad Económica y Operativa
El modelo de costos operativos para la puesta en marcha del proyecto se ha diseñado bajo una premisa de optimización de recursos, reduciendo la inversión inicial a cero:
* **Capa de Infraestructura y Servidor:** El despliegue inicial de la plataforma se estructurará sobre una arquitectura serverless aprovechando la capa gratuita de Cloudflare Workers. Esto permite anular los costos operativos de hosting durante las fases de desarrollo, pruebas y validación académica, garantizando un presupuesto de $0 USD para la puesta en marcha del MVP. El modelo contempla una escalabilidad elástica: en caso de una adopción comercial masiva que supere las 100.000 peticiones diarias, el sistema migrará al plan premium de Workers ($5 USD/mes), manteniendo la viabilidad económica.
* **Persistencia de Datos:** Se utiliza el nivel gratuito de clústeres en la nube (MongoDB Atlas), el cual proporciona hasta 512 MB de almacenamiento. Esta capacidad es suficiente para la persistencia de miles de tickets de servicio técnico activos y registros de inventario sin generar facturación.
---
## 5. CRONOGRAMA DE EJECUCIÓN (PLAN DE TRABAJO)
Para asegurar la entrega oportuna del proyecto final y mantener una tasa de avance constante, las seis fases definidas en el Roadmap estratégico se distribuyen temporalmente en bloques secuenciales a lo largo de un período estimado de 9 semanas:
* **Semanas 1 - 3 (Fase I - Frontend-First):** Diseño y desarrollo de componentes visuales en React (Dashboard, Login y Seguimiento) utilizando las clases utilitarias de Tailwind CSS y validación de flujos de navegación con datos locales simulados (*mock data*).
* **Semana 4 (Fase II - Modelado de Datos):** Configuración del entorno de persistencia en MongoDB, diseño de los esquemas de colecciones y establecimiento de las relaciones lógicas entre clientes, equipos y componentes técnicos.
* **Semanas 5 - 6 (Fase III - Lógica de Servidor):** Construcción del servidor local con Express, codificación de controladores, ruteadores de la API RESTful y middlewares de autenticación por tokens JWT.
* **Semana 7 (Fase IV - Infraestructura de Red):** Configuración de la capa perimetral en Cloudflare, aprovisionamiento de certificados SSL automatizados y declaración de las reglas de ruteo para el aislamiento de los subdominios público y privado.
* **Semana 8 (Fase V - Integración):** Enlace de las capas cliente-servidor mediante peticiones asincrónicas HTTP (`fetch`), depuración de respuestas JSON y validación del comportamiento del sistema de extremo a extremo (*End-to-End*).
* **Semana 9 (Fase VI - Extensibilidad):** Programación de los módulos de salida y exportación de archivos dinámicos, generación automatizada de presupuestos impresos con jsPDF y hojas de cálculo de inventario con SheetJS.
---
## 6. RESULTADOS ESPERADOS Y CONCLUSIONES
Con la implementación de **RepairIT**, se proyecta alcanzar un sistema integral de gestión de servicios técnicos que resuelva las problemáticas de comunicación y desorganización administrativa comunes en los talleres de reparación tecnológicos.
### Indicadores de Éxito del Software:
1. **Trazabilidad Absoluta:** Centralización del 100% de los históricos de los equipos ingresados, accesibles mediante un identificador único alfanumérico seguro (NanoID).
2. **Optimización del Rendimiento:** Reducción de los tiempos de carga en la consulta de estados de reparación gracias a la ejecución de código en servidores perimetrales de baja latencia (Edge Computing).
3. **Reducción de Costos Operativos:** Demostración práctica de la factibilidad de montar una arquitectura de software de nivel empresarial y alta disponibilidad con un costo de mantenimiento mensual nulo ($0 USD) en su etapa de lanzamiento.

184
frontend/src/App.css Normal file
View File

@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@ -1,19 +1,47 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { LandingPage } from './components/landing-page';
import TermsPage from './components/terms-page';
import PrivacyPage from './components/privacy-page';
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Toaster } from "@/components/ui/sonner";
function App() {
// Layouts
import PublicLayout from "@/components/layout/PublicLayout";
import DashboardLayout from "@/components/layout/DashboardLayout";
// Vistas Públicas
import LandingPage from "@/views/public/LandingPage";
import TrackingPage from "@/views/public/TrackingPage";
import LoginPage from "@/views/admin/LoginPage";
import PrivacyPage from "@/views/public/PrivacyPage";
import TermsPage from "@/views/public/TermsPage";
// Vistas Privadas (Dashboard)
import DashboardPage from "@/views/admin/DashboardPage";
import OrdersPage from "@/views/admin/OrdersPage";
import InventoryPage from "@/views/admin/InventoryPage";
import ProfilePage from "@/views/admin/ProfilePage";
export default function App() {
return (
<Router>
<BrowserRouter>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/terms" element={<TermsPage />} />
<Route path="/privacy" element={<PrivacyPage />} />
{/* Entorno Público (seguimiento.repairit.cloud) */}
<Route path="/" element={<PublicLayout />}>
<Route index element={<LandingPage />} />
<Route path="seguimiento/:id" element={<TrackingPage />} />
<Route path="login" element={<LoginPage />} />
<Route path="privacidad" element={<PrivacyPage />} />
<Route path="terminos" element={<TermsPage />} />
</Route>
{/* Entorno Administrativo (dashboard.repairit.cloud) */}
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<DashboardPage />} />
<Route path="ordenes" element={<OrdersPage />} />
<Route path="inventario" element={<InventoryPage />} />
<Route path="perfil" element={<ProfilePage />} />
</Route>
</Routes>
</Router>
<Toaster position="top-center" richColors />
</BrowserRouter>
);
}
export default App;

View File

@ -1,72 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { DialogClose } from "@/components/ui/dialog";
export default function ContactForm() {
return (
<div className="w-full px-6 py-8 sm:px-10">
<form className="w-full text-left" onSubmit={(e) => e.preventDefault()}>
<div className="mb-6">
<h3 className="text-2xl font-bold text-foreground">
Contactar a Ventas
</h3>
<p className="text-sm text-muted-foreground mt-1">
Dejanos tus datos y nos pondremos en contacto para ofrecerte el plan ideal para tu taller de reparación.
</p>
</div>
<Separator className="bg-border/50 mb-6" />
<div className="space-y-4 bg-surface/30 p-5 rounded-lg border border-border/50">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="contactName">Nombre Completo</Label>
<Input id="contactName" placeholder="Ej. Juan Pérez" className="bg-background" required />
</div>
<div className="space-y-2">
<Label htmlFor="workshopName">Nombre del Taller</Label>
<Input id="workshopName" placeholder="Ej. PC Fixers" className="bg-background" required />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="contactEmail">Correo Electrónico</Label>
<Input id="contactEmail" type="email" placeholder="taller@ejemplo.com" className="bg-background" required />
</div>
<div className="space-y-2">
<Label htmlFor="contactPhone">Teléfono / WhatsApp</Label>
<Input id="contactPhone" type="tel" placeholder="+54 9 11 1234-5678" className="bg-background" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="contactMessage">¿En qué podemos ayudarte?</Label>
<Textarea
id="contactMessage"
placeholder="Contanos sobre tu taller, volumen de reparaciones o consultas sobre nuestros planes..."
className="bg-background min-h-[100px] resize-y"
required
/>
</div>
</div>
<Separator className="bg-border/50 my-6" />
<div className="flex flex-col sm:flex-row items-center justify-end gap-3 pb-2">
<DialogClose asChild>
<Button type="button" variant="ghost" className="w-full sm:w-auto">
Cancelar
</Button>
</DialogClose>
<Button type="submit" className="w-full sm:w-auto bg-brand hover:bg-brand-light text-white font-medium px-8">
Enviar Mensaje
</Button>
</div>
</form>
</div>
);
}

View File

@ -1,29 +0,0 @@
import React from 'react';
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import ContactForm from './ContactForm';
export function ContactSection() {
return (
<section id="contacto" className="bg-background min-h-[60vh] flex flex-col justify-center py-16 relative z-10 border-t border-border/40 w-full">
<div className="w-full mx-auto max-w-4xl px-6 text-center">
<h2 className="text-4xl font-bold tracking-tight lg:text-5xl text-foreground mb-6">
Impulsá tu taller hoy
</h2>
<p className="text-lg text-muted-foreground mb-10 max-w-2xl mx-auto">
Ponete en contacto con nosotros para conocer más sobre nuestros planes. Descubrí cómo RepairIT puede ayudarte a escalar tu taller.
</p>
<Dialog>
<DialogTrigger asChild>
<Button size="lg" className="px-12 py-6 text-lg bg-brand hover:bg-brand-light text-white font-medium shadow-[0_0_20px_rgba(59,130,246,0.1)]">
Contactar a Ventas
</Button>
</DialogTrigger>
<DialogContent className="w-[95vw] sm:max-w-2xl bg-background border-border p-0 overflow-hidden rounded-xl">
<ContactForm />
</DialogContent>
</Dialog>
</div>
</section>
);
}

View File

@ -1,47 +0,0 @@
import React from 'react';
import { BGPattern } from './ui/bg-pattern';
import RepairForm from './form-layout';
import {
Dialog,
DialogContent,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
const Hero = () => {
return (
<section id="inicio" className="relative overflow-hidden min-h-screen flex flex-col justify-center pt-16">
{/* Patron de fondo del hero */}
<BGPattern variant="grid" mask="fade-edges" fill="rgba(255, 255, 255, 0.05)" />
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 className="text-5xl md:text-7xl font-bold tracking-tight mb-8">
Gestiona tu taller con <br className="hidden md:block" />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-light via-brand to-brand-dark">
precisión absoluta
</span>
</h1>
<p className="max-w-2xl mx-auto text-lg md:text-xl text-text-muted mb-10">
Trazabilidad completa de reparaciones, control de inventario y comunicación profesional con tus clientes, todo en una sola plataforma.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Dialog>
<DialogTrigger asChild>
<Button size="lg" className="w-full sm:w-auto px-8 bg-brand hover:bg-brand-light text-white font-medium">
Comenzar Ahora
</Button>
</DialogTrigger>
<DialogContent className="w-[95vw] sm:max-w-4xl lg:max-w-6xl bg-background border-border p-0 overflow-hidden rounded-xl">
<RepairForm />
</DialogContent>
</Dialog>
</div>
</div>
</section>
);
};
export default Hero;

View File

@ -1,76 +0,0 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { ClipboardCheck, Package, MessageSquare } from 'lucide-react'
export function Features() {
return (
<section id="caracteristicas" className="bg-zinc-50 min-h-screen flex flex-col justify-center py-16 dark:bg-transparent relative z-10 w-full">
<div className="w-full mx-auto max-w-5xl px-6">
<div className="text-center">
<h2 className="text-balance text-4xl font-bold tracking-tight lg:text-5xl text-foreground">
Todo lo que tu taller necesita
</h2>
<p className="mt-4 text-muted-foreground max-w-2xl mx-auto">
Herramientas diseñadas específicamente para agilizar la reparación de hardware y mejorar la experiencia de tus clientes.
</p>
</div>
<div
className="mx-auto mt-8 grid w-full max-w-sm grid-cols-1 gap-6 *:text-center md:max-w-full md:grid-cols-3 md:mt-16">
<Card className="group shadow-black-950/5 bg-surface/40 border-border/50 backdrop-blur-sm">
<CardHeader className="pb-3">
<CardDecorator>
<ClipboardCheck className="size-6 text-brand" aria-hidden />
</CardDecorator>
<h3 className="mt-6 font-semibold text-lg text-foreground">Trazabilidad Total</h3>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground leading-relaxed">Hacé un seguimiento detallado del estado de cada equipo, desde su ingreso hasta la entrega final al cliente.</p>
</CardContent>
</Card>
<Card className="group shadow-black-950/5 bg-surface/40 border-border/50 backdrop-blur-sm">
<CardHeader className="pb-3">
<CardDecorator>
<Package className="size-6 text-brand" aria-hidden />
</CardDecorator>
<h3 className="mt-6 font-semibold text-lg text-foreground">Control de Inventario</h3>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground leading-relaxed">Gestiona tus repuestos y componentes de forma eficiente para que nunca te falte lo esencial en una reparación.</p>
</CardContent>
</Card>
<Card className="group shadow-black-950/5 bg-surface/40 border-border/50 backdrop-blur-sm">
<CardHeader className="pb-3">
<CardDecorator>
<MessageSquare className="size-6 text-brand" aria-hidden />
</CardDecorator>
<h3 className="mt-6 font-semibold text-lg text-foreground">Comunicación Profesional</h3>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground leading-relaxed">Mantené a tus clientes informados con reportes claros y presupuestos detallados directamente desde la plataforma.</p>
</CardContent>
</Card>
</div>
</div>
</section>
);
}
const CardDecorator = ({
children
}) => (
<div
aria-hidden
className="relative mx-auto size-36 [mask-image:radial-gradient(ellipse_50%_50%_at_50%_50%,#000_70%,transparent_100%)]">
<div
className="absolute inset-0 [--border:black] dark:[--border:white] bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] bg-[size:24px_24px] opacity-10" />
<div
className="bg-background absolute inset-0 m-auto flex size-12 items-center justify-center border-t border-l">{children}</div>
</div>
)

View File

@ -1,47 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
const links = [
{ title: 'Inicio', href: '#inicio' },
{ title: 'Características', href: '#caracteristicas' },
{ title: 'Contacto', href: '#contacto' },
];
export function FooterSection() {
return (
<footer className="py-12 border-t border-border/40 bg-background z-10 relative">
<div className="mx-auto max-w-5xl px-6">
<div className="flex items-center justify-center mb-8">
<span className="text-2xl font-bold tracking-tight text-foreground">
Repair<span className="text-brand">IT</span>
</span>
</div>
<div className="mt-8 mb-4 flex flex-wrap justify-center gap-6 text-sm">
{links.map((link, index) => (
<a
key={index}
href={link.href}
className="text-muted-foreground hover:text-foreground transition-colors duration-150">
{link.title}
</a>
))}
</div>
<div className="mt-4 flex flex-col items-center gap-4">
<span className="text-muted-foreground block text-center text-sm">
© {new Date().getFullYear()} RepairIT. Todos los derechos reservados.
</span>
<div className="flex flex-wrap justify-center gap-6 text-xs text-text-muted/60">
<Link to="/terms" className="hover:text-foreground transition-colors">
Términos y Condiciones
</Link>
<Link to="/privacy" className="hover:text-foreground transition-colors">
Privacidad
</Link>
</div>
</div>
</div>
</footer>
);
}

View File

@ -1,150 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { DialogClose } from "@/components/ui/dialog";
export default function RepairForm() {
const [deviceType, setDeviceType] = useState("laptop");
return (
<div className="w-full max-h-[90vh] overflow-y-auto px-6 py-8 sm:px-10">
<form className="w-full text-left" onSubmit={(e) => e.preventDefault()}>
<div className="mb-6">
<h3 className="text-2xl font-bold text-foreground">
Registrar Nueva Reparación
</h3>
<p className="text-sm text-muted-foreground mt-1">
Completa los datos del cliente y los detalles técnicos del equipo para generar la orden de servicio.
</p>
</div>
<Separator className="bg-border/50 mb-6" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-10">
{/* Columna Izquierda: Información Personal */}
<div className="space-y-6">
<div>
<h4 className="text-lg font-semibold text-foreground">Información Personal</h4>
<p className="text-sm text-muted-foreground">Datos de contacto del cliente.</p>
</div>
<div className="space-y-4 bg-surface/30 p-5 rounded-lg border border-border/50">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">Nombre</Label>
<Input id="firstName" placeholder="Ej. Juan" className="bg-background" required />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Apellido</Label>
<Input id="lastName" placeholder="Ej. Pérez" className="bg-background" required />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Correo Electrónico</Label>
<Input id="email" type="email" placeholder="juan@ejemplo.com" className="bg-background" required />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Teléfono / WhatsApp</Label>
<Input id="phone" type="tel" placeholder="+54 9 11 1234-5678" className="bg-background" required />
</div>
</div>
</div>
{/* Columna Derecha: Detalles del Equipo */}
<div className="space-y-6">
<div>
<h4 className="text-lg font-semibold text-foreground">Detalles del Equipo</h4>
<p className="text-sm text-muted-foreground">Especificaciones y fallo reportado.</p>
</div>
<div className="space-y-4 bg-surface/30 p-5 rounded-lg border border-border/50">
<div className="space-y-2">
<Label htmlFor="deviceType">Tipo de Equipo</Label>
<Select value={deviceType} onValueChange={setDeviceType}>
<SelectTrigger id="deviceType" className="w-full bg-background">
<SelectValue placeholder="Selecciona el tipo" />
</SelectTrigger>
<SelectContent>
<SelectItem value="laptop">Notebook</SelectItem>
<SelectItem value="pc">PC de Escritorio</SelectItem>
</SelectContent>
</Select>
</div>
{deviceType === "laptop" ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="brand">Marca</Label>
<Input id="brand" placeholder="Ej. Lenovo, HP..." className="bg-background" required />
</div>
<div className="space-y-2">
<Label htmlFor="model">Modelo</Label>
<Input id="model" placeholder="Ej. ThinkPad T490" className="bg-background" required />
</div>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="specs">Componentes Principales</Label>
<Textarea
id="specs"
placeholder="Ej. Mother ASUS B450, Ryzen 5, 16GB RAM..."
className="bg-background min-h-[60px] resize-y"
required
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="issue">Descripción del Fallo</Label>
<Textarea
id="issue"
placeholder="Describinos detalladamente el problema..."
className="bg-background min-h-[80px] resize-y"
required
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Contraseña del equipo</Label>
<span className="text-xs text-muted-foreground">Opcional</span>
</div>
<Input id="password" type="text" placeholder="Pin o contraseña" className="bg-background" />
</div>
</div>
</div>
</div>
<Separator className="bg-border/50 my-6" />
{/* Acciones */}
<div className="flex flex-col sm:flex-row items-center justify-end gap-3 pb-2">
<DialogClose asChild>
<Button type="button" variant="ghost" className="w-full sm:w-auto">
Cancelar
</Button>
</DialogClose>
<Button type="submit" className="w-full sm:w-auto bg-brand hover:bg-brand-light text-white font-medium px-8">
Registrar Reparación
</Button>
</div>
</form>
</div>
);
}

View File

@ -1,20 +0,0 @@
import React from 'react';
import Hero from './Hero';
import { NavigationBar } from './navigation-bar';
import { Features } from './features';
import { ContactSection } from './ContactSection';
import { FooterSection } from './footer';
export function LandingPage() {
return (
<div className="min-h-screen bg-background text-text-main font-sans selection:bg-brand/30 pt-16">
<NavigationBar />
<main>
<Hero />
<Features />
<ContactSection />
</main>
<FooterSection />
</div>
);
}

View File

@ -0,0 +1,221 @@
import { NavLink, Outlet, useNavigate, useLocation } from "react-router-dom";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
SidebarProvider,
Sidebar,
SidebarContent,
SidebarHeader,
SidebarFooter,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarTrigger,
SidebarInset
} from "@/components/ui/sidebar";
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import {
LayoutDashboard,
ClipboardList,
Package,
LogOut,
Wrench,
Store,
Settings
} from "lucide-react";
import { toast } from "sonner";
export default function DashboardLayout() {
const navigate = useNavigate();
const location = useLocation();
const handleLogout = () => {
toast.success("Sesión cerrada correctamente.");
navigate("/");
};
// Determinar los Breadcrumbs basados en la ruta actual
const getBreadcrumbs = () => {
const path = location.pathname;
if (path === "/dashboard/ordenes") {
return (
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<NavLink to="/dashboard">Panel</NavLink>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Órdenes de Servicio</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
);
}
if (path === "/dashboard/inventario") {
return (
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<NavLink to="/dashboard">Panel</NavLink>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Inventario de Insumos</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
);
}
if (path === "/dashboard/perfil") {
return (
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<NavLink to="/dashboard">Panel</NavLink>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Perfil de Taller</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
);
}
return (
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>Inicio</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
);
};
return (
<SidebarProvider>
<div className="flex min-h-screen w-full bg-background text-foreground font-sans selection:bg-primary/30 selection:text-primary">
{/* Sidebar Oficial de shadcn/ui */}
<Sidebar className="border-r border-border/80 bg-card">
<SidebarHeader className="p-6 border-b border-border/30">
{/* Logo de la Empresa */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center text-primary-foreground font-bold">
<Wrench className="w-4 h-4" />
</div>
<span className="font-outfit text-lg font-black tracking-tight text-foreground">
Repair<span className="text-primary">IT</span>
</span>
</div>
</div>
</SidebarHeader>
<SidebarContent className="p-4 pt-6">
<SidebarMenu className="space-y-1">
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard"}>
<NavLink to="/dashboard" end className="flex items-center gap-3 w-full">
<LayoutDashboard className="w-4 h-4" />
<span>Inicio</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard/ordenes"}>
<NavLink to="/dashboard/ordenes" className="flex items-center gap-3 w-full">
<ClipboardList className="w-4 h-4" />
<span>Órdenes de Servicio</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={location.pathname === "/dashboard/inventario"}>
<NavLink to="/dashboard/inventario" className="flex items-center gap-3 w-full">
<Package className="w-4 h-4" />
<span>Inventario de Insumos</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarContent>
<SidebarFooter className="p-4 border-t border-border/85 space-y-4 bg-card/65">
<div className="flex items-center justify-between px-2 w-full gap-2">
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center font-bold text-xs text-primary uppercase shrink-0">
EO
</div>
<div className="text-xs truncate min-w-0">
<span className="text-foreground font-bold block leading-none truncate">Edgar Ortiz</span>
<span className="text-muted-foreground block text-[10px] mt-1 truncate">Administrador</span>
</div>
</div>
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground cursor-pointer shrink-0"
title="Configuración de Taller"
>
<NavLink to="/dashboard/perfil">
<Settings className="w-4 h-4" />
</NavLink>
</Button>
</div>
<Button
variant="outline"
onClick={handleLogout}
className="w-full bg-background hover:bg-red-950/20 text-muted-foreground hover:text-red-400 border-border hover:border-red-950/50 text-xs font-bold py-2 rounded-lg transition-all cursor-pointer flex items-center justify-center gap-2 select-none"
>
<LogOut className="w-3.5 h-3.5" />
Cerrar Sesión
</Button>
</SidebarFooter>
</Sidebar>
{/* Sidebar Inset para contener el área de trabajo y la barra de navegación superior */}
<SidebarInset className="flex-1 flex flex-col min-w-0 bg-background">
{/* Cabecera Superior con Trigger y Breadcrumbs */}
<header className="h-16 border-b border-border/80 flex items-center justify-between px-6 bg-card/20 backdrop-blur-sm sticky top-0 z-40">
<div className="flex items-center gap-4">
<SidebarTrigger className="text-muted-foreground hover:text-foreground" />
<Separator orientation="vertical" className="h-4 !self-center" />
<Breadcrumb className="flex items-center">
{getBreadcrumbs()}
</Breadcrumb>
</div>
<div className="text-[10px] text-muted-foreground font-mono select-none">
Taller Central &bull; Activo
</div>
</header>
{/* Contenido Dinámico de las Páginas del Dashboard */}
<main className="flex-grow p-6 md:p-8 overflow-y-auto max-w-7xl w-full mx-auto">
<Outlet />
</main>
</SidebarInset>
</div>
</SidebarProvider>
);
}

View File

@ -0,0 +1,93 @@
import { Link, Outlet } from "react-router-dom";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
export default function PublicLayout() {
return (
<div className="min-h-screen bg-background text-foreground flex flex-col font-sans selection:bg-primary/30 selection:text-primary relative">
{/* Navbar con Efecto de Desenfoque y Vidrio (Glassmorphism) */}
<header className="sticky top-0 z-50 bg-card/60 backdrop-blur-md border-b border-border/80">
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
{/* Logo del Proyecto (RepairIT) */}
<Link to="/" className="flex items-center gap-2">
<span className="font-outfit text-2xl font-black tracking-tight text-foreground">
Repair<span className="text-primary">IT</span>
</span>
</Link>
{/* Menú de Navegación de la Landing - Optimizado para Móviles */}
<nav className="flex items-center gap-4 sm:gap-6">
<Link
to="/"
className="hidden md:block text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Inicio
</Link>
<a
href="#caracteristicas"
className="hidden md:block text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Servicios
</a>
<a
href="#precios"
className="hidden md:block text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Precios
</a>
<a
href="#faq"
className="hidden md:block text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Preguntas
</a>
<a
href="#contacto"
className="hidden md:block text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Contacto
</a>
<Separator orientation="vertical" className="hidden md:block h-4 !self-center" />
<Button asChild variant="outline" size="sm" className="text-primary border-primary/20 hover:border-primary/40 bg-primary/10 hover:bg-primary/20 font-bold transition-all duration-200">
<Link to="/login">Panel Técnico</Link>
</Button>
</nav>
</div>
</header>
{/* Contenedor Principal (Hijo) con Efectos de Fondo Encapsulados */}
<main className="flex-1 flex flex-col relative z-10 overflow-hidden">
{/* Efectos de Fondo Decorativos */}
<div className="absolute top-0 left-1/4 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] pointer-events-none" />
<Outlet />
</main>
{/* Footer Tecnológico - Optimizado para Móviles */}
<footer className="bg-card/40 border-t border-border py-8 relative z-10">
<div className="max-w-7xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-6">
<div className="text-center md:text-left">
<span className="font-outfit text-xl font-black tracking-tight text-foreground">
Repair<span className="text-primary">IT</span>
</span>
<p className="text-xs text-muted-foreground mt-1">
Sistema de Trazabilidad para Talleres de Servicio Técnico.
</p>
</div>
<div className="flex flex-col sm:flex-row items-center gap-4 sm:gap-6 text-xs text-muted-foreground font-medium w-full md:w-auto">
<div className="flex items-center gap-6">
<Link to="/privacidad" className="hover:text-foreground transition-colors">Privacidad</Link>
<Link to="/terminos" className="hover:text-foreground transition-colors">Términos</Link>
</div>
<Separator orientation="vertical" className="hidden sm:block h-4" />
<p className="text-muted-foreground/85 text-center sm:text-left">&copy; 2026 RepairIT. Edgar Javier Ortiz.</p>
</div>
</div>
</footer>
</div>
);
}

View File

@ -1,70 +0,0 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu";
import { Link } from 'react-router-dom';
import { Button } from "@/components/ui/button";
export function NavigationBar({ isSimple = false }) {
return (
<header className="fixed top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className={`container mx-auto h-16 max-w-7xl items-center px-4 sm:px-6 lg:px-8 ${isSimple ? 'flex justify-center' : 'grid grid-cols-3'}`}>
{/* Logo */}
<div className={`flex items-center ${isSimple ? '' : 'justify-self-start'}`}>
<Link to="/" className="text-xl font-bold tracking-tight text-foreground">
Repair<span className="text-brand">IT</span>
</Link>
</div>
{!isSimple && (
<>
{/* Enlaces centrales (Escritorio únicamente) */}
<div className="hidden md:flex justify-self-center">
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink asChild>
<a href="/#inicio" className={cn(navigationMenuTriggerStyle(), "bg-transparent text-muted-foreground hover:text-foreground")}>
Inicio
</a>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild>
<a href="/#caracteristicas" className={cn(navigationMenuTriggerStyle(), "bg-transparent text-muted-foreground hover:text-foreground")}>
Características
</a>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild>
<a href="/#contacto" className={cn(navigationMenuTriggerStyle(), "bg-transparent text-muted-foreground hover:text-foreground")}>
Contacto
</a>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
{/* Botones de acción */}
<div className="flex items-center gap-4 justify-self-end">
<Button variant="ghost" className="hidden sm:inline-flex text-muted-foreground hover:text-foreground">
Iniciar Sesión
</Button>
</div>
</>
)}
</div>
</header>
);
}

View File

@ -1,24 +0,0 @@
import React from 'react';
import { NavigationBar } from './navigation-bar';
import { FooterSection } from './footer';
const PrivacyPage = () => {
return (
<div className="min-h-screen bg-background text-text-main font-sans pt-16">
<NavigationBar isSimple={true} />
<main className="max-w-4xl mx-auto px-6 py-20">
<section className="mb-16">
<h1 className="text-4xl font-bold mb-8 text-foreground">Política de Privacidad</h1>
<div className="space-y-6 text-text-muted leading-relaxed">
<p>
En RepairIT, nos tomamos muy en serio la privacidad de tus datos y los de tus clientes.
</p>
</div>
</section>
</main>
<FooterSection />
</div>
);
};
export default PrivacyPage;

View File

@ -1,24 +0,0 @@
import React from 'react';
import { NavigationBar } from './navigation-bar';
import { FooterSection } from './footer';
const TermsPage = () => {
return (
<div className="min-h-screen bg-background text-text-main font-sans pt-16">
<NavigationBar isSimple={true} />
<main className="max-w-4xl mx-auto px-6 py-20">
<section className="mb-16">
<h1 className="text-4xl font-bold mb-8 text-foreground">Términos y Condiciones</h1>
<div className="space-y-6 text-text-muted leading-relaxed">
<p>
Bienvenido a RepairIT. Al utilizar nuestra plataforma, aceptas cumplir con los siguientes términos y condiciones.
</p>
</div>
</section>
</main>
<FooterSection />
</div>
);
};
export default TermsPage;

View File

@ -0,0 +1,78 @@
import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({
className,
...props
}) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props} />
);
}
function AccordionItem({
className,
...props
}) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props} />
);
}
function AccordionTrigger({
className,
children,
...props
}) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}>
{children}
<ChevronDownIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}>
{children}
</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,174 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}) {
return (<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />);
}
function AlertDialogPortal({
...props
}) {
return (<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />);
}
function AlertDialogOverlay({
className,
...props
}) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props} />
);
}
function AlertDialogContent({
className,
size = "default",
...props
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props} />
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props} />
);
}
function AlertDialogFooter({
className,
...props
}) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props} />
);
}
function AlertDialogMedia({
className,
...props
}) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props} />
);
}
function AlertDialogTitle({
className,
...props
}) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props} />
);
}
function AlertDialogDescription({
className,
...props
}) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props} />
);
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={cn(className)} {...props} />
</Button>
);
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={cn(className)} {...props} />
</Button>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@ -0,0 +1,78 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props} />
);
}
function AlertTitle({
className,
...props
}) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props} />
);
}
function AlertDescription({
className,
...props
}) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props} />
);
}
function AlertAction({
className,
...props
}) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props} />
);
}
export { Alert, AlertTitle, AlertDescription, AlertAction }

View File

@ -1,20 +1,24 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
@ -25,10 +29,19 @@ const badgeVariants = cva(
function Badge({
className,
variant,
variant = "default",
asChild = false,
...props
}) {
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props} />
);
}
export { Badge, badgeVariants }

View File

@ -1,61 +0,0 @@
import React from 'react';
import { cn } from '../../lib/utils';
const maskClasses = {
'fade-edges': '[mask-image:radial-gradient(ellipse_at_center,var(--color-background),transparent)]',
'fade-center': '[mask-image:radial-gradient(ellipse_at_center,transparent,var(--color-background))]',
'fade-top': '[mask-image:linear-gradient(to_bottom,transparent,var(--color-background))]',
'fade-bottom': '[mask-image:linear-gradient(to_bottom,var(--color-background),transparent)]',
'fade-left': '[mask-image:linear-gradient(to_right,transparent,var(--color-background))]',
'fade-right': '[mask-image:linear-gradient(to_right,var(--color-background),transparent)]',
'fade-x': '[mask-image:linear-gradient(to_right,transparent,var(--color-background),transparent)]',
'fade-y': '[mask-image:linear-gradient(to_bottom,transparent,var(--color-background),transparent)]',
none: '',
};
function geBgImage(variant, fill, size) {
switch (variant) {
case 'dots':
return `radial-gradient(${fill} 1px, transparent 1px)`;
case 'grid':
return `linear-gradient(to right, ${fill} 1px, transparent 1px), linear-gradient(to bottom, ${fill} 1px, transparent 1px)`;
case 'diagonal-stripes':
return `repeating-linear-gradient(45deg, ${fill}, ${fill} 1px, transparent 1px, transparent ${size}px)`;
case 'horizontal-lines':
return `linear-gradient(to bottom, ${fill} 1px, transparent 1px)`;
case 'vertical-lines':
return `linear-gradient(to right, ${fill} 1px, transparent 1px)`;
case 'checkerboard':
return `linear-gradient(45deg, ${fill} 25%, transparent 25%), linear-gradient(-45deg, ${fill} 25%, transparent 25%), linear-gradient(45deg, transparent 75%, ${fill} 75%), linear-gradient(-45deg, transparent 75%, ${fill} 75%)`;
default:
return undefined;
}
}
const BGPattern = ({
variant = 'grid',
mask = 'none',
size = 24,
fill = '#252525',
className,
style,
...props
}) => {
const bgSize = `${size}px ${size}px`;
const backgroundImage = geBgImage(variant, fill, size);
return (
<div
className={cn('absolute inset-0 z-0 size-full', maskClasses[mask], className)}
style={{
backgroundImage,
backgroundSize: bgSize,
...style,
}}
{...props}
/>
);
};
BGPattern.displayName = 'BGPattern';
export { BGPattern };

View File

@ -0,0 +1,121 @@
import * as React from "react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({
className,
...props
}) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props} />
);
}
function BreadcrumbList({
className,
...props
}) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props} />
);
}
function BreadcrumbItem({
className,
...props
}) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props} />
);
}
function BreadcrumbLink({
asChild,
className,
...props
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props} />
);
}
function BreadcrumbPage({
className,
...props
}) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props} />
);
}
function BreadcrumbSeparator({
children,
className,
...props
}) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}>
{children ?? (
<ChevronRightIcon />
)}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-5 items-center justify-center [&>svg]:size-4", className)}
{...props}>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -1,29 +1,37 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority";
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
@ -33,15 +41,23 @@ const buttonVariants = cva(
}
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props} />
);
})
Button.displayName = "Button"
}
export { Button, buttonVariants }

View File

@ -2,49 +2,113 @@ import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
{...props} />
))
Card.displayName = "Card"
function Card({
className,
size = "default",
...props
}) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props} />
);
}
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props} />
))
CardHeader.displayName = "CardHeader"
function CardHeader({
className,
...props
}) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props} />
);
}
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props} />
))
CardTitle.displayName = "CardTitle"
function CardTitle({
className,
...props
}) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props} />
);
}
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props} />
))
CardDescription.displayName = "CardDescription"
function CardDescription({
className,
...props
}) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props} />
);
}
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
function CardAction({
className,
...props
}) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props} />
);
}
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props} />
))
CardFooter.displayName = "CardFooter"
function CardContent({
className,
...props
}) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props} />
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
function CardFooter({
className,
...props
}) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props} />
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -1,24 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
function Checkbox({
className,
...props
}) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5">
<CheckIcon />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox }

View File

@ -1,117 +0,0 @@
import { cn } from "@/lib/utils";
import React from "react";
const CpuArchitecture = ({
className,
width = "100%",
height = "100%",
text = "R-IT",
showCpuConnections = true,
animateText = true,
}) => {
return (
<svg
className={cn("text-muted", className)}
width={width}
height={height}
// viewBox="0 0 200 100" // Original
// Chip is at x=85, y=40, width=30, height=20.
// Pins stick out about 10 units in each direction.
viewBox="70 25 60 50">
{/* CPU Box */}
<g>
{/* Cpu connections (Pins) */}
{showCpuConnections && (
<g fill="url(#cpu-connection-gradient)">
{/* Top pins */}
<rect x="92" y="37" width="2.5" height="5" rx="0.7" />
<rect x="100" y="37" width="2.5" height="5" rx="0.7" />
<rect x="108" y="37" width="2.5" height="5" rx="0.7" />
<rect x="116" y="37" width="2.5" height="5" rx="0.7" />
{/* Bottom pins */}
<rect x="92" y="58" width="2.5" height="5" rx="0.7" />
<rect x="100" y="58" width="2.5" height="5" rx="0.7" />
<rect x="108" y="58" width="2.5" height="5" rx="0.7" />
<rect x="116" y="58" width="2.5" height="5" rx="0.7" />
{/* Left pins */}
<rect x="82" y="45" width="5" height="2.5" rx="0.7" />
<rect x="82" y="52.5" width="5" height="2.5" rx="0.7" />
{/* Right pins */}
<rect x="128" y="45" width="5" height="2.5" rx="0.7" />
<rect x="128" y="52.5" width="5" height="2.5" rx="0.7" />
</g>
)}
{/* Main CPU Rectangle */}
<rect
x="85"
y="40"
width="45"
height="20"
rx="2"
fill="#181818"
filter="url(#cpu-light-shadow)" />
{/* CPU Text */}
<text
x="107.5"
y="52.5"
fontSize="7.5"
textAnchor="middle"
fill={animateText ? "url(#cpu-text-gradient)" : "white"}
fontWeight="600"
letterSpacing="0.05em">
{text}
</text>
</g>
<defs>
<filter id="cpu-light-shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="1.5" dy="1.5" stdDeviation="1" floodColor="black" floodOpacity="0.3" />
</filter>
{/* Cpu connection gradient */}
<linearGradient id="cpu-connection-gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#888888" />
<stop offset="100%" stopColor="#333333" />
</linearGradient>
{/* Add CPU Text Gradient */}
<linearGradient id="cpu-text-gradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#60a5fa">
<animate
attributeName="offset"
values="-2; -1; 0"
dur="3s"
repeatCount="indefinite"
calcMode="spline"
keyTimes="0; 0.5; 1"
keySplines="0.4 0 0.2 1; 0.4 0 0.2 1" />
</stop>
<stop offset="25%" stopColor="white">
<animate
attributeName="offset"
values="-1; 0; 1"
dur="3s"
repeatCount="indefinite"
calcMode="spline"
keyTimes="0; 0.5; 1"
keySplines="0.4 0 0.2 1; 0.4 0 0.2 1" />
</stop>
<stop offset="50%" stopColor="#60a5fa">
<animate
attributeName="offset"
values="0; 1; 2;"
dur="3s"
repeatCount="indefinite"
calcMode="spline"
keyTimes="0; 0.5; 1"
keySplines="0.4 0 0.2 1; 0.4 0 0.2 1" />
</stop>
</linearGradient>
</defs>
</svg>
);
};
export { CpuArchitecture };

View File

@ -1,151 +0,0 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props} />
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
<XIcon />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({
className,
...props
}) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props} />
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-heading text-base leading-none font-medium", className)}
{...props} />
);
}
function DialogDescription({
className,
...props
}) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props} />
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

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

View File

@ -2,18 +2,21 @@ import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
function Input({
className,
type,
...props
}) {
return (
<input
type={type}
data-slot="input"
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
ref={ref}
{...props} />
);
})
Input.displayName = "Input"
}
export { Input }

View File

@ -1,16 +1,21 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } from "class-variance-authority";
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
))
Label.displayName = LabelPrimitive.Root.displayName
function Label({
className,
...props
}) {
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 }

View File

@ -1,104 +0,0 @@
import * as React from "react"
import { ChevronDownIcon } from "@radix-ui/react-icons"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
const NavigationMenu = React.forwardRef(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Root
ref={ref}
className={cn(
"relative z-10 flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}>
{children}
<NavigationMenuViewport />
</NavigationMenuPrimitive.Root>
))
NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
const NavigationMenuList = React.forwardRef(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.List
ref={ref}
className={cn(
"group flex flex-1 list-none items-center justify-center space-x-1",
className
)}
{...props} />
))
NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
const NavigationMenuItem = NavigationMenuPrimitive.Item
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[active]:bg-accent/50 data-[state=open]:bg-accent/50"
)
const NavigationMenuTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true" />
</NavigationMenuPrimitive.Trigger>
))
NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
const NavigationMenuContent = React.forwardRef(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Content
ref={ref}
className={cn(
"left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
className
)}
{...props} />
))
NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
const NavigationMenuLink = NavigationMenuPrimitive.Link
const NavigationMenuViewport = React.forwardRef(({ className, ...props }, ref) => (
<div className={cn("absolute left-0 top-full flex justify-center")}>
<NavigationMenuPrimitive.Viewport
className={cn(
"origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
ref={ref}
{...props} />
</div>
))
NavigationMenuViewport.displayName =
NavigationMenuPrimitive.Viewport.displayName
const NavigationMenuIndicator = React.forwardRef(({ className, ...props }, ref) => (
<NavigationMenuPrimitive.Indicator
ref={ref}
className={cn(
"top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
className
)}
{...props}>
<div
className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Indicator>
))
NavigationMenuIndicator.displayName =
NavigationMenuPrimitive.Indicator.displayName
export {
navigationMenuTriggerStyle,
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
}

View File

@ -1,25 +0,0 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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",
className
)}
{...props} />
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
{...props}>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="size-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
</ProgressPrimitive.Root>
);
}
export { Progress }

View File

@ -1,29 +0,0 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef(({ className, ...props }, ref) => {
return (<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />);
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem }

View File

@ -1,122 +0,0 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
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}
{...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)]")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props} />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props} />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -1,23 +1,25 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef((
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props} />
))
Separator.displayName = SeparatorPrimitive.Root.displayName
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props} />
);
}
export { Separator }

View File

@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({
...props
}) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props} />
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}>
{children}
{showCloseButton && (
<SheetPrimitive.Close data-slot="sheet-close" asChild>
<Button variant="ghost" className="absolute top-3 right-3" size="icon-sm">
<XIcon />
<span className="sr-only">Close</span>
</Button>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({
className,
...props
}) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props} />
);
}
function SheetFooter({
className,
...props
}) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} />
);
}
function SheetTitle({
className,
...props
}) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-heading text-base font-medium text-foreground", className)}
{...props} />
);
}
function SheetDescription({
className,
...props
}) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props} />
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,657 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { Slot } from "radix-ui"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
const SidebarContext = React.createContext(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback((value) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
}, [setOpenProp, open])
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo(() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar])
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style
}
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}>
{children}
</div>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE
}
}
side={side}>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar">
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)} />
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border">
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({
className,
...props
}) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props} />
);
}
function SidebarInset({
className,
...props
}) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props} />
);
}
function SidebarInput({
className,
...props
}) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props} />
);
}
function SidebarHeader({
className,
...props
}) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props} />
);
}
function SidebarFooter({
className,
...props
}) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props} />
);
}
function SidebarSeparator({
className,
...props
}) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props} />
);
}
function SidebarContent({
className,
...props
}) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props} />
);
}
function SidebarGroup({
className,
...props
}) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props} />
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props} />
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
)}
{...props} />
);
}
function SidebarGroupContent({
className,
...props
}) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props} />
);
}
function SidebarMenu({
className,
...props
}) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props} />
);
}
function SidebarMenuItem({
className,
...props
}) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props} />
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}) {
const Comp = asChild ? Slot.Root : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props} />
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip} />
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
)}
{...props} />
);
}
function SidebarMenuBadge({
className,
...props
}) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props} />
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}>
{showIcon && (
<Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width
}
} />
</div>
);
}
function SidebarMenuSub({
className,
...props
}) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props} />
);
}
function SidebarMenuSubItem({
className,
...props
}) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props} />
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
)}
{...props} />
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props} />
);
}
export { Skeleton }

View File

@ -0,0 +1,48 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner";
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({
...props
}) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={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)"
}
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props} />
);
}
export { Toaster }

View File

@ -2,17 +2,19 @@ import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
function Textarea({
className,
...props
}) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
ref={ref}
{...props} />
);
})
Textarea.displayName = "Textarea"
}
export { Textarea }

View File

@ -0,0 +1,51 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}) {
return (<TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />);
}
function Tooltip({
...props
}) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
}) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 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 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}>
{children}
<TooltipPrimitive.Arrow
className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }

View File

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange);
}, [])
return !!isMobile
}

View File

@ -5,140 +5,195 @@
@custom-variant dark (&:is(.dark *));
@theme {
--color-brand-light: #60a5fa; /* Blue 400 */
--color-brand: #3b82f6; /* Blue 500 */
--color-brand-dark: #2563eb; /* Blue 600 */
--color-background: #09090b; /* Zinc 950 (Almost Black) */
--color-surface: #18181b; /* Zinc 900 */
--color-surface-hover: #27272a;/* Zinc 800 */
--color-text-main: #f8fafc; /* Slate 50 */
--color-text-muted: #a1a1aa; /* Zinc 400 */
}
@layer base {
body {
background-color: var(--color-background);
color: var(--color-text-main);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
@apply bg-background text-foreground;
}
* {
@apply border-border outline-ring/50;
}
html {
@apply font-sans;
}
}
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--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);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--font-heading: var(--font-sans);
--font-sans: Inter, sans-serif;
--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);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--font-mono: JetBrains Mono, monospace;
--font-serif: Source Serif 4, serif;
--radius: 1.15rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
}
:root {
--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.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--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);
--background: oklch(1.0000 0 0);
--foreground: oklch(0.3211 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.3211 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.3211 0 0);
--primary: oklch(0.6225 0.2041 259.9027);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9665 0.0045 258.3247);
--secondary-foreground: oklch(0.4419 0.0375 257.2811);
--muted: oklch(0.9846 0.0017 247.8389);
--muted-foreground: oklch(0.5471 0.0321 263.2921);
--accent: oklch(0.9510 0.0267 237.5723);
--accent-foreground: oklch(0.3742 0.1844 263.9420);
--destructive: oklch(0.6496 0.2362 26.9032);
--border: oklch(0.9271 0.0075 260.7315);
--input: oklch(0.9271 0.0075 260.7315);
--ring: oklch(0.6225 0.2041 259.9027);
--radius: 1.15rem;
--sidebar: oklch(0.9846 0.0017 247.8389);
--sidebar-foreground: oklch(0.3211 0 0);
--sidebar-primary: oklch(0.6225 0.2041 259.9027);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.9510 0.0267 237.5723);
--sidebar-accent-foreground: oklch(0.3742 0.1844 263.9420);
--sidebar-border: oklch(0.9271 0.0075 260.7315);
--sidebar-ring: oklch(0.6225 0.2041 259.9027);
--destructive-foreground: oklch(1.0000 0 0);
--chart-1: oklch(0.6225 0.2041 259.9027);
--chart-2: oklch(0.5469 0.2507 262.8085);
--chart-3: oklch(0.4902 0.2693 263.7106);
--chart-4: oklch(0.4234 0.2370 263.9162);
--chart-5: oklch(0.3742 0.1844 263.9420);
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--shadow-color: oklch(0 0 0);
--shadow-opacity: 0.1;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-offset-x: 0;
--shadow-offset-y: 1px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
}
.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.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--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);
--background: oklch(0.2046 0 0);
--foreground: oklch(0.9219 0 0);
--card: oklch(0.2686 0 0);
--card-foreground: oklch(0.9219 0 0);
--popover: oklch(0.2686 0 0);
--popover-foreground: oklch(0.9219 0 0);
--primary: oklch(0.6225 0.2041 259.9027);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.2686 0 0);
--secondary-foreground: oklch(0.9219 0 0);
--muted: oklch(0.2393 0 0);
--muted-foreground: oklch(0.7155 0 0);
--accent: oklch(0.5802 0.1915 259.7416);
--accent-foreground: oklch(0.8820 0.0588 253.9688);
--destructive: oklch(0.6496 0.2362 26.9032);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0.3715 0 0);
--input: oklch(0.3715 0 0);
--ring: oklch(0.6225 0.2041 259.9027);
--sidebar: oklch(0.2046 0 0);
--sidebar-foreground: oklch(0.9219 0 0);
--sidebar-primary: oklch(0.6225 0.2041 259.9027);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.6225 0.2041 259.9027);
--sidebar-accent-foreground: oklch(0.8820 0.0588 253.9688);
--sidebar-border: oklch(0.3715 0 0);
--sidebar-ring: oklch(0.6225 0.2041 259.9027);
--chart-1: oklch(0.7122 0.1526 254.9868);
--chart-2: oklch(0.6225 0.2041 259.9027);
--chart-3: oklch(0.5739 0.2334 262.7735);
--chart-4: oklch(0.6225 0.2041 259.9027);
--chart-5: oklch(0.6225 0.2041 259.9027);
--radius: 1.15rem;
--font-sans: Inter, sans-serif;
--font-serif: Source Serif 4, serif;
--font-mono: JetBrains Mono, monospace;
--shadow-color: oklch(0 0 0);
--shadow-opacity: 0.1;
--shadow-blur: 3px;
--shadow-spread: 0px;
--shadow-offset-x: 0;
--shadow-offset-y: 1px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
letter-spacing: var(--tracking-normal);
}
html {
@apply font-sans;
}
}

View File

@ -0,0 +1,178 @@
import { useState, useEffect } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { AlertCircle, ShieldAlert, PackageCheck, Wrench, ArrowUpRight } from "lucide-react";
import { Link } from "react-router-dom";
export default function DashboardPage() {
const [loading, setLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setLoading(false);
}, 1000); // Retraso de 1 segundo para ver el skeleton de carga
return () => clearTimeout(timer);
}, []);
if (loading) {
return (
<div className="space-y-8 animate-fade-in">
<div className="space-y-2">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-4 w-72" />
</div>
{/* Grid de Skeletons para tarjetas */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Skeleton className="h-28 w-full rounded-2xl" />
<Skeleton className="h-28 w-full rounded-2xl" />
<Skeleton className="h-28 w-full rounded-2xl" />
<Skeleton className="h-28 w-full rounded-2xl" />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
<Skeleton className="h-[280px] w-full rounded-2xl" />
</div>
<div>
<Skeleton className="h-[280px] w-full rounded-2xl" />
</div>
</div>
</div>
);
}
return (
<div className="space-y-8 animate-fade-in">
{/* Cabecera interna */}
<div className="space-y-1">
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
Panel de Control
</h1>
<p className="text-xs text-muted-foreground font-light">
Resumen operativo del taller y control de stock de insumos técnicos.
</p>
</div>
{/* Grid de Estadísticas Rápidas mediante Cards de shadcn/ui */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Card 1: Órdenes Activas */}
<Card className="bg-card/45 border-border shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Órdenes Activas</span>
<Wrench className="h-4 w-4 text-primary" />
</CardHeader>
<CardContent>
<span className="text-3xl font-outfit font-black text-foreground block">12</span>
<span className="text-[10px] text-primary block font-medium mt-1">4 en diagnóstico, 8 en reparación</span>
</CardContent>
</Card>
{/* Card 2: Entregados este Mes */}
<Card className="bg-card/45 border-border shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Entregas del Mes</span>
<PackageCheck className="h-4 w-4 text-emerald-400" />
</CardHeader>
<CardContent>
<span className="text-3xl font-outfit font-black text-foreground block">45</span>
<span className="text-[10px] text-emerald-400 block font-medium mt-1">+15% respecto al mes anterior</span>
</CardContent>
</Card>
{/* Card 3: Repuestos en Stock */}
<Card className="bg-card/45 border-border shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Items en Inventario</span>
<ShieldAlert className="h-4 w-4 text-muted-foreground/60" />
</CardHeader>
<CardContent>
<span className="text-3xl font-outfit font-black text-foreground block">84</span>
<span className="text-[10px] text-muted-foreground block font-medium mt-1">Distribuidos en 14 categorías</span>
</CardContent>
</Card>
{/* Card 4: Alertas de Stock */}
<Card className="bg-destructive/5 border-destructive/20 shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<span className="text-xs font-semibold text-destructive/80 uppercase tracking-wider">Alertas de Stock</span>
<AlertCircle className="h-4 w-4 text-destructive" />
</CardHeader>
<CardContent>
<span className="text-3xl font-outfit font-black text-destructive block">2</span>
<span className="text-[10px] text-destructive/80 block font-medium mt-1">Requieren compra urgente</span>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Tabla de Órdenes Recientes mediante Card de shadcn */}
<Card className="lg:col-span-2 bg-card/20 border-border shadow-sm">
<CardHeader className="border-b border-border/50 py-4 flex flex-row items-center justify-between">
<CardTitle className="font-outfit text-base font-bold text-foreground/90">
Ingresos Recientes
</CardTitle>
<span className="text-[9px] text-muted-foreground uppercase tracking-widest font-mono">Actualizado hace 5m</span>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground text-xs font-light leading-relaxed max-w-sm">
Aquí se desplegará el listado interactivo con filtros rápidos de diagnóstico. Ya podés probar la pestaña de Órdenes para dar de alta dispositivos.
</p>
<Button asChild size="sm" variant="outline" className="mt-4 border-border text-xs gap-1 font-bold">
<Link to="/dashboard/ordenes">
Ir a Registro de Dispositivos <ArrowUpRight className="w-3.5 h-3.5" />
</Link>
</Button>
</CardContent>
</Card>
{/* Alertas de Stock Detalladas mediante Alert de shadcn/ui */}
<Card className="bg-card/20 border-border shadow-sm">
<CardHeader className="border-b border-border/50 py-4">
<CardTitle className="font-outfit text-base font-bold text-foreground/90">
Críticos de Inventario
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<Alert variant="destructive" className="bg-destructive/10 border-destructive/20 text-foreground p-3.5">
<AlertCircle className="h-4 w-4 stroke-destructive" />
<div className="ml-2">
<AlertTitle className="text-xs font-bold font-outfit flex items-center justify-between">
<span>Pasta Térmica Arctic MX-6</span>
<Badge variant="destructive" className="text-[8px] font-bold py-0.5 px-1 uppercase tracking-wider shrink-0 select-none">Bajo Stock</Badge>
</AlertTitle>
<AlertDescription className="text-[10px] text-muted-foreground mt-1">
Mínimo requerido: 5 jeringas | Actual en stock: 2 jeringas.
</AlertDescription>
</div>
</Alert>
<Alert variant="destructive" className="bg-destructive/10 border-destructive/20 text-foreground p-3.5">
<AlertCircle className="h-4 w-4 stroke-destructive" />
<div className="ml-2">
<AlertTitle className="text-xs font-bold font-outfit flex items-center justify-between">
<span>Alcohol Isopropílico 1L</span>
<Badge variant="destructive" className="text-[8px] font-bold py-0.5 px-1 uppercase tracking-wider shrink-0 select-none">Bajo Stock</Badge>
</AlertTitle>
<AlertDescription className="text-[10px] text-muted-foreground mt-1">
Mínimo requerido: 3 botellas | Actual en stock: 1 botella.
</AlertDescription>
</div>
</Alert>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Package } from "lucide-react";
import { toast } from "sonner";
export default function InventoryPage() {
return (
<div className="space-y-6 animate-fade-in">
<div className="space-y-1">
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
Inventario de Insumos
</h1>
<p className="text-xs text-muted-foreground font-light">
Control de stock y alertas de reposición de componentes técnicos.
</p>
</div>
<Card className="bg-card/25 border-border shadow-md">
<CardContent className="flex flex-col items-center justify-center py-20 text-center space-y-4">
<div className="w-12 h-12 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
<Package className="w-6 h-6" />
</div>
<div className="space-y-1">
<p className="text-foreground/90 font-bold text-sm">Módulo de Inventario en Desarrollo</p>
<p className="max-w-md mx-auto text-xs text-muted-foreground leading-relaxed font-light">
En la siguiente iteración de la Fase I implementaremos el control cuantitativo de insumos, auditoría automática de repuestos y la simulación de alerta de bajo suministro.
</p>
</div>
<Button variant="outline" className="text-xs font-bold border-border" onClick={() => toast.info("Módulo en desarrollo.")}>
Simular Escaneo de Stock
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,112 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { toast } from "sonner";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const navigate = useNavigate();
const handleLogin = (e) => {
e.preventDefault();
// Simular el inicio de sesión y redirigir al Dashboard
toast.success("¡Acceso concedido!", {
description: `Bienvenido de vuelta, técnico.`
});
navigate("/dashboard");
};
return (
<div className="flex-grow flex items-center justify-center bg-background px-6 py-12 relative overflow-hidden">
{/* Background Glow */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] bg-primary/5 rounded-full blur-[80px] pointer-events-none" />
<Card className="max-w-md w-full bg-card/40 border-border p-8 rounded-2xl shadow-2xl backdrop-blur relative z-10 space-y-6">
{/* Título y descripción */}
<div className="text-center space-y-2">
<CardTitle className="font-outfit text-2xl font-bold text-foreground tracking-tight border-0 pb-0">
Acceso al Panel Técnico
</CardTitle>
<CardDescription className="text-xs text-muted-foreground font-light">
Ingresa tus credenciales autorizadas para gestionar las órdenes y el inventario.
</CardDescription>
</div>
{/* Formulario */}
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="login-email" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block">
Correo Electrónico
</Label>
<Input
id="login-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="tecnico@repairit.cloud"
className="w-full bg-background/85 border-border rounded-lg px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground/50 focus-visible:ring-1 focus-visible:ring-primary transition-all"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="login-password" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block">
Contraseña
</Label>
<Input
id="login-password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full bg-background/85 border-border rounded-lg px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground/50 focus-visible:ring-1 focus-visible:ring-primary transition-all"
/>
</div>
{/* Recordarme en este dispositivo */}
<div className="flex items-center space-x-2 pt-1">
<Checkbox
id="remember"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked)}
className="border-border focus-visible:ring-primary"
/>
<Label
htmlFor="remember"
className="text-xs text-muted-foreground font-light cursor-pointer select-none"
>
Recordarme en este dispositivo
</Label>
</div>
<Button
type="submit"
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-sm py-2.5 rounded-lg shadow-lg shadow-primary/15 transition-all mt-3 cursor-pointer select-none"
>
Iniciar Sesión
</Button>
</form>
<div className="text-center pt-2">
<Button
variant="link"
onClick={() => navigate("/")}
className="text-xs text-muted-foreground hover:text-foreground font-medium transition-colors cursor-pointer select-none"
>
&larr; Volver a la consulta pública
</Button>
</div>
</Card>
</div>
);
}

View File

@ -0,0 +1,472 @@
import { useState } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { toast } from "sonner";
import { PlusCircle, ClipboardList, Trash2, ChevronDown } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export default function OrdersPage() {
// Estado para la tabla dinámica de órdenes de servicio
const [orders, setOrders] = useState([
{
id: "RT-2026-ASUS",
clientName: "Edgar Javier Ortiz",
clientDni: "44.375.912",
deviceType: "Notebook",
deviceModel: "ASUS ROG Strix G15",
serial: "SN-98218-ASUS",
issue: "Calentamiento excesivo y reinicios aleatorios.",
status: "presupuestado",
date: "05/06/2026"
},
{
id: "RT-2026-MOCK",
clientName: "Cliente Registrado",
clientDni: "29.987.654",
deviceType: "Celular",
deviceModel: "Samsung S23 Ultra",
serial: "SN-PENDIENTE",
issue: "Pantalla astillada tras caída física y puerto de carga flojo.",
status: "diagnostico",
date: "08/06/2026"
}
]);
// Pestaña activa
const [activeTab, setActiveTab] = useState("registro");
// Estados del formulario
const [clientName, setClientName] = useState("");
const [clientDni, setClientDni] = useState("");
const [clientPhone, setClientPhone] = useState("");
const [clientEmail, setClientEmail] = useState("");
const [deviceType, setDeviceType] = useState("Notebook");
const [deviceModel, setDeviceModel] = useState("");
const [serial, setSerial] = useState("");
const [issue, setIssue] = useState("");
const [acceptTerms, setAcceptTerms] = useState(false);
// Registrar orden
const handleRegisterOrder = (e) => {
e.preventDefault();
if (!acceptTerms) {
toast.error("Debe aceptar los términos de recepción técnica.");
return;
}
const trackingCode = `RT-2026-${Math.random().toString(36).substring(2, 6).toUpperCase()}`;
const newOrder = {
id: trackingCode,
clientName,
clientDni,
deviceType,
deviceModel,
serial: serial || "SN-PENDIENTE",
issue,
status: "ingresado",
date: new Date().toLocaleDateString("es-AR")
};
setOrders([newOrder, ...orders]);
toast.success("¡Dispositivo Registrado!", {
description: `Código de seguimiento: ${trackingCode}.`,
});
// Resetear formulario y mover a la pestaña de listado
setClientName("");
setClientDni("");
setClientPhone("");
setClientEmail("");
setDeviceType("Notebook");
setDeviceModel("");
setSerial("");
setIssue("");
setAcceptTerms(false);
setActiveTab("control");
};
// Eliminar orden localmente (simulado)
const handleDeleteOrder = (id) => {
setOrders(orders.filter(o => o.id !== id));
toast.error(`Orden ${id} eliminada del listado.`);
};
// Manejar cambio de estado en la tabla
const handleStatusChange = (id, newStatus) => {
setOrders(prev => prev.map(o => o.id === id ? { ...o, status: newStatus } : o));
toast.success(`Estado de orden ${id} actualizado`, {
description: `Nuevo estado: ${newStatus.toUpperCase()}`,
});
};
// Selector interactivo de estado de tipo Dropdown Menu de shadcn
const getStatusDropdown = (orderId, currentStatus) => {
let colorClass = "";
let statusLabel = "";
switch (currentStatus) {
case "ingresado":
colorClass = "bg-sky-500/10 text-sky-400 border-sky-500/25 hover:bg-sky-500/20";
statusLabel = "Ingresado";
break;
case "diagnostico":
colorClass = "bg-amber-500/10 text-amber-400 border-amber-500/25 hover:bg-amber-500/20";
statusLabel = "En Diagnóstico";
break;
case "presupuestado":
colorClass = "bg-purple-500/10 text-purple-400 border-purple-500/25 hover:bg-purple-500/20";
statusLabel = "Presupuestado";
break;
case "reparacion":
colorClass = "bg-blue-500/10 text-blue-400 border-blue-500/25 hover:bg-blue-500/20";
statusLabel = "En Reparación";
break;
case "listo":
colorClass = "bg-emerald-500/10 text-emerald-400 border-emerald-500/25 hover:bg-emerald-500/20";
statusLabel = "Listo";
break;
default:
colorClass = "bg-zinc-800 text-zinc-400 border-zinc-700/60 hover:bg-zinc-800/80";
statusLabel = "Entregado";
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className={`h-8 border px-2.5 text-xs font-semibold rounded-md flex items-center gap-1.5 cursor-pointer transition-colors shadow-sm select-none ${colorClass}`}
>
{statusLabel}
<ChevronDown className="w-3.5 h-3.5 opacity-60 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="bg-card border-border/80 text-foreground w-44 shadow-xl p-1.5 space-y-1">
<DropdownMenuItem
onClick={() => handleStatusChange(orderId, "ingresado")}
className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-sky-500/10 text-sky-400 border-sky-500/15 hover:bg-sky-500/20 hover:text-sky-350 focus:bg-sky-500/20 focus:text-sky-350"
>
Ingresado
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStatusChange(orderId, "diagnostico")}
className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-amber-500/10 text-amber-400 border-amber-500/15 hover:bg-amber-500/20 hover:text-amber-350 focus:bg-amber-500/20 focus:text-amber-350"
>
En Diagnóstico
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStatusChange(orderId, "presupuestado")}
className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-purple-500/10 text-purple-400 border-purple-500/15 hover:bg-purple-500/20 hover:text-purple-350 focus:bg-purple-500/20 focus:text-purple-350"
>
Presupuestado
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStatusChange(orderId, "reparacion")}
className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-blue-500/10 text-blue-400 border-blue-500/15 hover:bg-blue-500/20 hover:text-blue-350 focus:bg-blue-500/20 focus:text-blue-350"
>
En Reparación
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStatusChange(orderId, "listo")}
className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-emerald-500/10 text-emerald-400 border-emerald-500/15 hover:bg-emerald-500/20 hover:text-emerald-350 focus:bg-emerald-500/20 focus:text-emerald-350"
>
Listo
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStatusChange(orderId, "entregado")}
className="text-xs font-semibold rounded-md py-1.5 px-2.5 transition-colors cursor-pointer select-none border bg-zinc-800 text-zinc-400 border-zinc-700/60 hover:bg-zinc-800/80 hover:text-zinc-350 focus:bg-zinc-800/80 focus:text-zinc-350"
>
Entregado
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
return (
<div className="space-y-6">
{/* Cabecera interna */}
<div className="space-y-1">
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
Órdenes de Servicio
</h1>
<p className="text-xs text-muted-foreground font-light">
Alta de dispositivos ingresados y control de trazabilidad técnica del taller.
</p>
</div>
{/* Selector de Pestañas (Tabs) estilo shadcn */}
<div className="flex border-b border-border/60 gap-6">
<button
onClick={() => setActiveTab("registro")}
className={`pb-3 text-sm font-semibold tracking-wide border-b-2 transition-all cursor-pointer relative ${
activeTab === "registro"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Registrar Ingreso
</button>
<button
onClick={() => setActiveTab("control")}
className={`pb-3 text-sm font-semibold tracking-wide border-b-2 transition-all cursor-pointer relative ${
activeTab === "control"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Control de Trazabilidad y Estados
</button>
</div>
<div className="animate-fade-in pt-2">
{activeTab === "registro" ? (
<div className="max-w-2xl mx-auto w-full">
{/* Formulario de Ingreso de Dispositivo (Card shadcn) */}
<Card className="bg-card/40 border-border p-6 shadow-xl backdrop-blur-sm space-y-6">
<div className="space-y-1">
<CardTitle className="font-outfit text-lg font-bold text-foreground flex items-center gap-2 border-0 pb-0">
<PlusCircle className="w-5 h-5 text-primary" />
Ingreso de Equipo
</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
Registrá los datos del cliente y el dispositivo a reparar.
</CardDescription>
</div>
<form onSubmit={handleRegisterOrder} className="space-y-4">
{/* Sección: Datos del Cliente */}
<div className="space-y-3">
<span className="text-[10px] font-bold text-primary uppercase tracking-wider block">1. Datos del Cliente</span>
<div className="space-y-1.5">
<Label htmlFor="client-name" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Nombre Completo</Label>
<Input
id="client-name"
type="text"
required
value={clientName}
onChange={(e) => setClientName(e.target.value)}
placeholder="Edgar Ortiz"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="client-dni" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">DNI / Identificación</Label>
<Input
id="client-dni"
type="text"
required
value={clientDni}
onChange={(e) => setClientDni(e.target.value)}
placeholder="44.375.912"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs font-mono"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="client-phone" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Teléfono</Label>
<Input
id="client-phone"
type="text"
required
value={clientPhone}
onChange={(e) => setClientPhone(e.target.value)}
placeholder="+54 381 1234567"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="client-email" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Email</Label>
<Input
id="client-email"
type="email"
required
value={clientEmail}
onChange={(e) => setClientEmail(e.target.value)}
placeholder="edgar@repairit.cloud"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
</div>
<Separator className="border-border/60" />
{/* Sección: Datos del Equipo */}
<div className="space-y-3">
<span className="text-[10px] font-bold text-primary uppercase tracking-wider block">2. Datos del Equipo</span>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="device-type" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Tipo</Label>
<select
id="device-type"
value={deviceType}
onChange={(e) => setDeviceType(e.target.value)}
className="w-full h-9 bg-background/85 border border-border rounded-md px-3 py-1 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="Notebook">Notebook</option>
<option value="PC Escritorio">PC Escritorio</option>
<option value="Consola">Consola</option>
<option value="Celular">Celular</option>
<option value="Otro">Otro</option>
</select>
</div>
<div className="space-y-1.5">
<Label htmlFor="device-serial" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Número de Serie</Label>
<Input
id="device-serial"
type="text"
value={serial}
onChange={(e) => setSerial(e.target.value)}
placeholder="SN-XXXXX"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs font-mono"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="device-model" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Marca y Modelo</Label>
<Input
id="device-model"
type="text"
required
value={deviceModel}
onChange={(e) => setDeviceModel(e.target.value)}
placeholder="ASUS ROG Strix G15"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="device-issue" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Falla Reportada</Label>
<Textarea
id="device-issue"
required
rows={3}
value={issue}
onChange={(e) => setIssue(e.target.value)}
placeholder="Describí los síntomas del dispositivo..."
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs resize-none"
/>
</div>
</div>
{/* Checkbox de Recepción */}
<div className="flex items-start space-x-2 pt-2">
<Checkbox
id="terms"
checked={acceptTerms}
onCheckedChange={(checked) => setAcceptTerms(checked)}
className="border-border focus-visible:ring-primary"
/>
<div className="grid gap-1 leading-none">
<Label
htmlFor="terms"
className="text-[10px] text-muted-foreground font-light leading-tight select-none cursor-pointer"
>
Confirmo la recepción física del equipo y autorizo la apertura del dispositivo para su diagnóstico.
</Label>
</div>
</div>
<Button
type="submit"
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs py-2 rounded-md transition-all select-none cursor-pointer mt-2"
>
Registrar Ingreso
</Button>
</form>
</Card>
</div>
) : (
/* Listado y Control de Órdenes Registradas (Card shadcn) */
<Card className="bg-card/20 border-border p-6 shadow-sm flex flex-col justify-between">
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-border/50 pb-3">
<CardTitle className="font-outfit text-base font-bold text-foreground/90 flex items-center gap-2 border-0 pb-0">
<ClipboardList className="w-4.5 h-4.5 text-muted-foreground" />
Control de Trazabilidad
</CardTitle>
<span className="text-[9px] text-muted-foreground uppercase tracking-widest font-mono font-bold">Total: {orders.length} Equipos</span>
</div>
{orders.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-border/60 text-muted-foreground uppercase tracking-wider font-semibold">
<th className="py-3 px-2">Código</th>
<th className="py-3 px-2">Cliente</th>
<th className="py-3 px-2">Dispositivo</th>
<th className="py-3 px-2">Fecha</th>
<th className="py-3 px-2">Estado (Editar)</th>
<th className="py-3 px-2 text-right">Acción</th>
</tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={o.id} className="border-b border-border/40 hover:bg-card/30 transition-colors">
<td className="py-3.5 px-2 font-mono font-bold text-primary">{o.id}</td>
<td className="py-3.5 px-2">
<span className="font-medium block">{o.clientName}</span>
<span className="text-[10px] text-muted-foreground font-mono">{o.clientDni}</span>
</td>
<td className="py-3.5 px-2">
<span className="font-medium block">{o.deviceModel}</span>
<span className="text-[10px] text-muted-foreground">{o.deviceType}</span>
</td>
<td className="py-3.5 px-2 text-muted-foreground">{o.date}</td>
<td className="py-3.5 px-2">{getStatusDropdown(o.id, o.status)}</td>
<td className="py-3.5 px-2 text-right">
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleDeleteOrder(o.id)}
className="text-muted-foreground hover:text-red-400 hover:bg-red-950/15"
title="Eliminar de la lista de prueba"
>
<Trash2 className="w-4 h-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-16 space-y-2">
<p className="text-xs text-muted-foreground leading-normal font-light">
No hay órdenes de servicio cargadas. Ve a la pestaña de "Registrar Ingreso" para añadir un ingreso técnico.
</p>
</div>
)}
</div>
<div className="text-[10px] text-muted-foreground/80 mt-6 border-t border-border/40 pt-3">
* Nota: Los registros agregados persisten únicamente en el estado local de la sesión de prueba de React.
</div>
</Card>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,159 @@
import { useState } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
Store,
Save,
CheckCircle
} from "lucide-react";
export default function ProfilePage() {
const [venueName, setVenueName] = useState("RepairIT Tucumán Centro");
const [address, setAddress] = useState("San Martín 650, San Miguel de Tucumán");
const [phone, setPhone] = useState("+54 381 4223344");
const [email, setEmail] = useState("tucuman@repairit.cloud");
const handleSave = (e) => {
e.preventDefault();
toast.success("¡Configuración del Taller Guardada!", {
description: `Los datos de la sucursal "${venueName}" han sido actualizados con éxito.`,
});
};
return (
<div className="space-y-6">
{/* Cabecera interna */}
<div className="space-y-1">
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight flex items-center gap-2">
<Store className="w-8 h-8 text-primary" />
Perfil de Taller (Venue)
</h1>
<p className="text-xs text-muted-foreground font-light">
Gestión del perfil del local y estado de la suscripción de la sucursal.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Columna Izquierda (2/3): Datos principales del taller */}
<div className="lg:col-span-2 space-y-6">
{/* Card 1: Datos de la Sucursal */}
<Card className="bg-card/40 border-border p-6 shadow-xl backdrop-blur-sm">
<CardHeader className="p-0 pb-5 border-b border-border/50">
<CardTitle className="font-outfit text-base font-bold text-foreground flex items-center gap-2 border-0 pb-0">
Datos del Establecimiento
</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
Información de contacto que se mostrará en los recibos y el seguimiento del cliente.
</CardDescription>
</CardHeader>
<CardContent className="p-0 pt-6">
<form onSubmit={handleSave} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="venue-name" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Nombre del Taller</Label>
<Input
id="venue-name"
type="text"
required
value={venueName}
onChange={(e) => setVenueName(e.target.value)}
placeholder="RepairIT Tucumán Centro"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="venue-email" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Email de Contacto</Label>
<Input
id="venue-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="tucuman@repairit.cloud"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="venue-phone" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Teléfono Comercial</Label>
<Input
id="venue-phone"
type="text"
required
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+54 381 4223344"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="venue-address" className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider block">Dirección Física</Label>
<Input
id="venue-address"
type="text"
required
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="San Martín 650, San Miguel de Tucumán"
className="bg-background/85 border-border focus-visible:ring-1 focus-visible:ring-primary text-xs"
/>
</div>
</div>
<div className="flex justify-end pt-2">
<Button
type="submit"
className="bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs py-2 px-6 rounded-md transition-all cursor-pointer flex items-center gap-2 select-none"
>
<Save className="w-4 h-4" />
Guardar Cambios
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
{/* Columna Derecha (1/3): Suscripción */}
<div className="space-y-6">
{/* Card 2: Suscripción */}
<Card className="bg-card/25 border-border p-6 shadow-sm space-y-4">
<h3 className="font-outfit text-base font-bold text-foreground border-b border-border pb-3 flex items-center gap-2">
Suscripción
</h3>
<div className="space-y-3.5 text-xs font-light">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Plan Licenciado</span>
<Badge className="bg-primary hover:bg-primary text-primary-foreground text-[10px] font-extrabold uppercase">Multi-Taller Pro</Badge>
</div>
<div className="flex items-center justify-between border-t border-border/30 pt-2.5">
<span className="text-muted-foreground">Vencimiento</span>
<span className="text-foreground font-mono font-medium">31/12/2026</span>
</div>
<div className="flex items-center justify-between border-t border-border/30 pt-2.5">
<span className="text-muted-foreground">Estado del Pago</span>
<span className="text-emerald-400 font-bold flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 fill-emerald-400/10" />
Al Día
</span>
</div>
</div>
</Card>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,487 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardHeader, CardTitle, CardContent, CardDescription, CardFooter } from "@/components/ui/card";
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion";
import { toast } from "sonner";
export default function LandingPage() {
const [trackingId, setTrackingId] = useState("");
const navigate = useNavigate();
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < 640);
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
// Estado del formulario de contacto
const [contactName, setContactName] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [contactMessage, setContactMessage] = useState("");
const [acceptTerms, setAcceptTerms] = useState(false);
const handleSearch = (e) => {
e.preventDefault();
if (trackingId.trim()) {
navigate(`/seguimiento/${trackingId.trim()}`);
}
};
const handleContactSubmit = (e) => {
e.preventDefault();
if (!acceptTerms) {
toast.error("Debe aceptar los términos de privacidad.");
return;
}
// Simulación del envío de mensaje de contacto
toast.success("¡Mensaje enviado con éxito!", {
description: `Gracias por contactarte, ${contactName}. Te responderemos a la brevedad.`,
});
// Resetear formulario
setContactName("");
setContactEmail("");
setContactMessage("");
setAcceptTerms(false);
};
return (
<div className="flex-grow flex flex-col">
{/* Sección Hero sin cuadrícula y sin tag de servicio */}
<section className="relative pt-20 pb-24 px-6 flex flex-col items-center justify-center text-center overflow-hidden min-h-[80vh]">
{/* Glow Central */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[350px] bg-primary/10 rounded-full blur-[100px] pointer-events-none" />
<div className="relative z-10 max-w-3xl mx-auto space-y-8">
{/* Título Principal */}
<h1 className="font-outfit text-5xl md:text-6xl font-extrabold text-foreground tracking-tight leading-[1.1]">
Tu taller de servicio técnico, más{" "}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-primary/60">
transparente
</span>{" "}
que nunca
</h1>
{/* Subtítulo */}
<p className="text-muted-foreground text-base md:text-lg max-w-xl mx-auto font-light leading-relaxed">
Hacemos el seguimiento de tu reparación simple. Consultá el estado de tu equipo en tiempo real y aprobá presupuestos con un solo clic, sin contraseñas.
</p>
{/* Buscador de NanoID (CTA) - Optimizado para móviles */}
<form
onSubmit={handleSearch}
className="max-w-md mx-auto flex flex-row gap-2 bg-card/60 p-2 rounded-xl border border-border/80 shadow-2xl backdrop-blur w-full"
>
<Input
type="text"
placeholder={isMobile ? "Código (ej. RT-2026)" : "Código de seguimiento (ej. RT-2026)"}
value={trackingId}
onChange={(e) => setTrackingId(e.target.value)}
className="flex-grow bg-transparent border-0 border-none px-2 sm:px-4 py-3 text-sm text-foreground placeholder-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0 min-w-0"
required
/>
<Button
type="submit"
className="bg-primary hover:bg-primary/90 text-primary-foreground font-bold px-4 sm:px-6 py-3 cursor-pointer select-none shrink-0"
>
<span className="hidden sm:inline">Consultar Estado</span>
<span className="sm:hidden">Consultar</span>
</Button>
</form>
{/* Ejemplo rápido para testing */}
<div className="text-xs text-muted-foreground font-medium">
¿Querés probar? Ingresá <span onClick={() => setTrackingId("demo-id")} className="text-primary hover:underline cursor-pointer font-bold">demo-id</span> para ver una reparación simulada.
</div>
</div>
</section>
{/* Sección de Características */}
<section id="caracteristicas" className="py-20 px-6 bg-background border-t border-border/50 relative z-10">
<div className="max-w-7xl mx-auto space-y-12">
<div className="text-center max-w-2xl mx-auto space-y-3">
<h2 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
¿Por qué elegir RepairIT?
</h2>
<p className="text-muted-foreground text-sm font-light">
Nuestra plataforma está diseñada para ofrecerte una visión transparente en cada etapa de la reparación.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 pt-4">
{/* Feature 1 con Card de shadcn */}
<Card className="bg-card/45 border-border/60 hover:border-border transition-colors duration-200">
<CardHeader className="space-y-4">
<div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<CardTitle className="font-outfit text-lg font-semibold text-foreground">Trazabilidad Instantánea</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm font-light leading-relaxed">
Olvidate de las llamadas telefónicas. A través de tu enlace único podés ver si tu equipo está ingresado, diagnosticado o listo para retirar.
</p>
</CardContent>
</Card>
{/* Feature 2 con Card de shadcn */}
<Card className="bg-card/45 border-border/60 hover:border-border transition-colors duration-200">
<CardHeader className="space-y-4">
<div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<CardTitle className="font-outfit text-lg font-semibold text-foreground">Aprobación Online</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm font-light leading-relaxed">
Visualizá el presupuesto de mano de obra y repuestos en detalle. Podés aprobar o rechazar la reparación desde tu misma interfaz de consulta.
</p>
</CardContent>
</Card>
{/* Feature 3 con Card de shadcn */}
<Card className="bg-card/45 border-border/60 hover:border-border transition-colors duration-200">
<CardHeader className="space-y-4">
<div className="w-10 h-10 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<CardTitle className="font-outfit text-lg font-semibold text-foreground">Control de Insumos</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm font-light leading-relaxed">
El sistema audita el stock técnico y descuenta automáticamente los componentes utilizados, asegurando que siempre tengamos repuestos disponibles.
</p>
</CardContent>
</Card>
</div>
</div>
</section>
{/* Sección de Precios */}
<section id="precios" className="py-20 px-6 bg-card/10 border-t border-border/50 relative z-10">
<div className="max-w-7xl mx-auto space-y-12">
<div className="text-center max-w-2xl mx-auto space-y-3">
<h2 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
Planes a tu Medida
</h2>
<p className="text-muted-foreground text-sm font-light">
Escogé el nivel de potencia y gestión que tu taller necesita para crecer.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 pt-4 items-stretch">
{/* Tier 1: Inicial */}
<Card className="flex flex-col justify-between min-h-[480px] overflow-visible bg-card/45 border-border/60 hover:border-border transition-all duration-200 hover:-translate-y-1">
<CardHeader className="space-y-2">
<CardTitle className="font-outfit text-xl font-bold text-foreground">Plan Inicial</CardTitle>
<CardDescription className="text-xs text-muted-foreground font-light">Ideal para técnicos independientes que comienzan.</CardDescription>
</CardHeader>
<CardContent className="space-y-6 flex-grow">
<div className="flex items-baseline gap-1">
<span className="text-4xl font-extrabold text-foreground font-outfit">$1</span>
<span className="text-muted-foreground text-xs font-light">/ mes</span>
</div>
<div className="border-t border-border/40 my-4" />
<ul className="space-y-3 text-xs text-muted-foreground font-light">
<li className="flex items-start gap-2.5">
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
<span>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</span>
</li>
<li className="flex items-start gap-2.5">
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
<span>Sed do eiusmod tempor incididunt ut labore.</span>
</li>
<li className="flex items-start gap-2.5">
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
<span>Ut enim ad minim veniam, quis nostrud.</span>
</li>
</ul>
</CardContent>
<CardFooter className="pt-4 border-t border-border/30 bg-muted/20">
<Button className="w-full bg-secondary hover:bg-secondary/90 text-secondary-foreground font-semibold py-2 rounded-lg cursor-pointer" variant="outline">
Comenzar gratis
</Button>
</CardFooter>
</Card>
{/* Tier 2: Taller Pro (Highlighted/Popular) */}
<Card className="flex flex-col justify-between min-h-[480px] overflow-visible bg-card/60 border-primary/50 hover:border-primary transition-all duration-200 hover:-translate-y-1 relative ring-1 ring-primary/20">
<div className="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
<Badge className="bg-primary hover:bg-primary text-primary-foreground font-semibold text-[10px] uppercase tracking-wider px-2.5 py-0.5">
Más Popular
</Badge>
</div>
<CardHeader className="space-y-2">
<CardTitle className="font-outfit text-xl font-bold text-foreground">Taller Pro</CardTitle>
<CardDescription className="text-xs text-muted-foreground font-light">Para talleres medianos con flujo constante de clientes.</CardDescription>
</CardHeader>
<CardContent className="space-y-6 flex-grow">
<div className="flex items-baseline gap-1">
<span className="text-4xl font-extrabold text-foreground font-outfit">$1</span>
<span className="text-muted-foreground text-xs font-light">/ mes</span>
</div>
<div className="border-t border-border/40 my-4" />
<ul className="space-y-3 text-xs text-muted-foreground font-light">
<li className="flex items-start gap-2.5">
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
<span>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</span>
</li>
<li className="flex items-start gap-2.5">
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
<span>Duis aute irure dolor in reprehenderit in voluptate.</span>
</li>
<li className="flex items-start gap-2.5">
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
<span>Excepteur sint occaecat cupidatat non proident.</span>
</li>
<li className="flex items-start gap-2.5">
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
<span>Sunt in culpa qui officia deserunt mollit.</span>
</li>
</ul>
</CardContent>
<CardFooter className="pt-4 border-t border-border/30 bg-primary/5">
<Button className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-bold py-2 rounded-lg cursor-pointer">
Suscribirse Ahora
</Button>
</CardFooter>
</Card>
{/* Tier 3: Multi-Sucursal */}
<Card className="flex flex-col justify-between min-h-[480px] overflow-visible bg-card/45 border-border/60 hover:border-border transition-all duration-200 hover:-translate-y-1">
<CardHeader className="space-y-2">
<CardTitle className="font-outfit text-xl font-bold text-foreground">Multi-Sucursal</CardTitle>
<CardDescription className="text-xs text-muted-foreground font-light">Para redes de talleres o franquicias de soporte técnico.</CardDescription>
</CardHeader>
<CardContent className="space-y-6 flex-grow">
<div className="flex items-baseline gap-1">
<span className="text-4xl font-extrabold text-foreground font-outfit">$1</span>
<span className="text-muted-foreground text-xs font-light">/ mes</span>
</div>
<div className="border-t border-border/40 my-4" />
<ul className="space-y-3 text-xs text-muted-foreground font-light">
<li className="flex items-start gap-2.5">
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
<span>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</span>
</li>
<li className="flex items-start gap-2.5">
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
<span>Velit esse cillum dolore eu fugiat nulla pariatur.</span>
</li>
<li className="flex items-start gap-2.5">
<svg className="w-4 h-4 text-primary shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
<span>Mollis pretium lorem primis iaculis cubilia.</span>
</li>
</ul>
</CardContent>
<CardFooter className="pt-4 border-t border-border/30 bg-muted/20">
<Button className="w-full bg-secondary hover:bg-secondary/90 text-secondary-foreground font-semibold py-2 rounded-lg cursor-pointer" variant="outline">
Contactar Ventas
</Button>
</CardFooter>
</Card>
</div>
</div>
</section>
{/* Sección de Preguntas Frecuentes (FAQ) */}
<section id="faq" className="py-20 px-6 bg-background border-t border-border/50 relative z-10">
<div className="max-w-3xl mx-auto space-y-12">
<div className="text-center space-y-3">
<h2 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
Preguntas Frecuentes
</h2>
<p className="text-muted-foreground text-sm font-light">
Todo lo que necesitás saber sobre RepairIT y nuestro sistema de trazabilidad.
</p>
</div>
<div className="pt-4">
<Accordion type="single" collapsible className="w-full space-y-4">
<AccordionItem value="item-1" className="border border-border/60 bg-card/45 rounded-lg px-4 hover:border-border transition-colors">
<AccordionTrigger className="text-base text-foreground font-semibold py-4 hover:no-underline font-outfit">
¿Cómo funciona el seguimiento de mi reparación?
</AccordionTrigger>
<AccordionContent className="text-muted-foreground text-sm font-light pb-4 leading-relaxed">
Vas a recibir un código de seguimiento único al registrar tu equipo en nuestro taller. Solo tenes que ingresarlo en el buscador de la página de inicio para ver el estado de tu equipo en tiempo real, sin necesidad de crear una cuenta o recordar contraseñas.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2" className="border border-border/60 bg-card/45 rounded-lg px-4 hover:border-border transition-colors">
<AccordionTrigger className="text-base text-foreground font-semibold py-4 hover:no-underline font-outfit">
¿Puedo aprobar presupuestos en línea?
</AccordionTrigger>
<AccordionContent className="text-muted-foreground text-sm font-light pb-4 leading-relaxed">
Sí. Cuando el técnico realice el diagnóstico y cargue el presupuesto de mano de obra e insumos, podrás visualizar el detalle en tu panel de seguimiento y aprobar o rechazar la reparación de forma digital con un solo clic.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3" className="border border-border/60 bg-card/45 rounded-lg px-4 hover:border-border transition-colors">
<AccordionTrigger className="text-base text-foreground font-semibold py-4 hover:no-underline font-outfit">
¿Tienen cargos ocultos adicionales?
</AccordionTrigger>
<AccordionContent className="text-muted-foreground text-sm font-light pb-4 leading-relaxed">
No, para nada. La transparencia es nuestro pilar fundamental. Desglosamos cada centavo en el presupuesto de repuestos y mano de obra. Los planes de suscripción de taller también están fijados sin sorpresas ni letra chica.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-4" className="border border-border/60 bg-card/45 rounded-lg px-4 hover:border-border transition-colors">
<AccordionTrigger className="text-base text-foreground font-semibold py-4 hover:no-underline font-outfit">
¿Mis datos personales y del dispositivo están seguros?
</AccordionTrigger>
<AccordionContent className="text-muted-foreground text-sm font-light pb-4 leading-relaxed">
Absolutamente. Cumplimos rigurosamente con las normativas locales de protección de datos personales. Toda tu información y la de tus equipos está cifrada y almacenada en servidores de alta seguridad.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-5" className="border border-border/60 bg-card/45 rounded-lg px-4 hover:border-border transition-colors">
<AccordionTrigger className="text-base text-foreground font-semibold py-4 hover:no-underline font-outfit">
¿Qué sucede si tengo más de una sucursal o taller?
</AccordionTrigger>
<AccordionContent className="text-muted-foreground text-sm font-light pb-4 leading-relaxed">
Disponemos de un plan Multi-Sucursal especialmente diseñado para redes de soporte técnico. Te permite gestionar múltiples tenants o locaciones con perfiles independientes pero administrados centralmente.
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</section>
{/* Sección de Contacto e Integración Typography */}
<section id="contacto" className="py-20 px-6 bg-card/10 border-t border-border/50 relative z-10">
<div className="max-w-2xl mx-auto space-y-10">
<div className="text-center space-y-3">
<h2 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
Ponete en Contacto
</h2>
<p className="text-muted-foreground text-sm font-light">
¿Tenés alguna consulta técnica o comercial sobre nuestro servicio? Escribinos.
</p>
</div>
<Card className="bg-card/60 border-border p-6 shadow-xl backdrop-blur-md">
<form onSubmit={handleContactSubmit} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label htmlFor="name" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block">Nombre Completo</label>
<Input
id="name"
type="text"
required
value={contactName}
onChange={(e) => setContactName(e.target.value)}
placeholder="Edgar Ortiz"
className="bg-background/80 border-border focus-visible:ring-1 focus-visible:ring-primary text-sm"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="email" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block">Email</label>
<Input
id="email"
type="email"
required
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
placeholder="edgar@repairit.cloud"
className="bg-background/80 border-border focus-visible:ring-1 focus-visible:ring-primary text-sm"
/>
</div>
</div>
<div className="space-y-1.5">
<label htmlFor="message" className="text-xs font-semibold text-muted-foreground uppercase tracking-wider block">Mensaje / Consulta</label>
<Textarea
id="message"
required
rows={4}
value={contactMessage}
onChange={(e) => setContactMessage(e.target.value)}
placeholder="Detallá tu consulta aquí..."
className="bg-background/80 border-border focus-visible:ring-1 focus-visible:ring-primary text-sm resize-none"
/>
</div>
{/* Checkbox de Privacidad */}
<div className="flex items-start space-x-3 pt-2">
<Checkbox
id="privacy"
checked={acceptTerms}
onCheckedChange={(checked) => setAcceptTerms(checked)}
className="mt-0.5 border-border focus-visible:ring-primary"
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="privacy"
className="text-xs text-muted-foreground font-light leading-tight select-none cursor-pointer"
>
Acepto que mis datos sean tratados de acuerdo con las{" "}
<span className="text-primary hover:underline font-semibold cursor-pointer">Políticas de Privacidad</span> y los{" "}
<span className="text-primary hover:underline font-semibold cursor-pointer">Términos de Servicio</span> de RepairIT.
</label>
</div>
</div>
<Button
type="submit"
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-sm py-2.5 rounded-lg transition-all mt-2 cursor-pointer select-none"
>
Enviar Consulta
</Button>
</form>
</Card>
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,52 @@
import { Link } from "react-router-dom";
import { Separator } from "@/components/ui/separator";
export default function PrivacyPage() {
return (
<div className="flex-1 max-w-3xl w-full mx-auto px-6 py-16 space-y-8 animate-fade-in">
<div className="space-y-3">
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors text-xs font-semibold uppercase tracking-wider">
&larr; Volver al Inicio
</Link>
<h1 className="font-outfit text-4xl font-black text-foreground tracking-tight leading-none pt-2">
Políticas de Privacidad
</h1>
<p className="text-xs text-muted-foreground font-mono">
Última actualización: 9 de Junio de 2026
</p>
</div>
<Separator />
<article className="prose prose-slate dark:prose-invert text-sm text-muted-foreground leading-relaxed space-y-6">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
</p>
<div className="space-y-3">
<h2 className="font-outfit text-xl font-bold text-foreground">
Lorem Ipsum Dolor
</h2>
<p>
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
<ul className="list-disc pl-5 space-y-1">
<li>Ut enim ad minim veniam, quis nostrud.</li>
<li>Ullamco laboris nisi ut aliquip ex ea commodo.</li>
<li>Duis aute irure dolor in reprehenderit in voluptate.</li>
<li>Velit esse cillum dolore eu fugiat nulla pariatur.</li>
</ul>
</div>
<div className="space-y-3">
<h2 className="font-outfit text-xl font-bold text-foreground">
Consectetur Adipiscing
</h2>
<p>
Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris. Integer in mauris eu nibh euismod gravida. Duis ac tellus et risus vulputate vehicula. Donec porta diam eu massa. Quisque diam lorem, interdum vitae, dapibus ac, scelerisque vitae, pede.
</p>
</div>
</article>
</div>
);
}

View File

@ -0,0 +1,52 @@
import { Link } from "react-router-dom";
import { Separator } from "@/components/ui/separator";
export default function TermsPage() {
return (
<div className="flex-1 max-w-3xl w-full mx-auto px-6 py-16 space-y-8 animate-fade-in">
<div className="space-y-3">
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors text-xs font-semibold uppercase tracking-wider">
&larr; Volver al Inicio
</Link>
<h1 className="font-outfit text-4xl font-black text-foreground tracking-tight leading-none pt-2">
Términos de Servicio
</h1>
<p className="text-xs text-muted-foreground font-mono">
Última actualización: 9 de Junio de 2026
</p>
</div>
<Separator />
<article className="prose prose-slate dark:prose-invert text-sm text-muted-foreground leading-relaxed space-y-6">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
</p>
<div className="space-y-3">
<h2 className="font-outfit text-xl font-bold text-foreground">
Lorem Ipsum Dolor
</h2>
<p>
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
</p>
<ul className="list-disc pl-5 space-y-1">
<li>Ut enim ad minim veniam, quis nostrud.</li>
<li>Ullamco laboris nisi ut aliquip ex ea commodo.</li>
<li>Duis aute irure dolor in reprehenderit in voluptate.</li>
<li>Velit esse cillum dolore eu fugiat nulla pariatur.</li>
</ul>
</div>
<div className="space-y-3">
<h2 className="font-outfit text-xl font-bold text-foreground">
Consectetur Adipiscing
</h2>
<p>
Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis sollicitudin mauris. Integer in mauris eu nibh euismod gravida. Duis ac tellus et risus vulputate vehicula. Donec porta diam eu massa. Quisque diam lorem, interdum vitae, dapibus ac, scelerisque vitae, pede.
</p>
</div>
</article>
</div>
);
}

View File

@ -0,0 +1,434 @@
import { useParams, Link } from "react-router-dom";
import { useState, useEffect } from "react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
// Lista de estados en orden secuencial
const STEPS = [
{ key: "ingresado", label: "Ingresado", desc: "Equipo recibido" },
{ key: "diagnostico", label: "En Diagnóstico", desc: "Evaluando fallas" },
{ key: "presupuestado", label: "Presupuestado", desc: "Costo listo" },
{ key: "reparacion", label: "En Reparación", desc: "Técnico trabajando" },
{ key: "listo", label: "Listo para Entregar", desc: "Pruebas superadas" },
{ key: "entregado", label: "Entregado", desc: "Retirado por cliente" }
];
export default function TrackingPage() {
const { id } = useParams();
const [loading, setLoading] = useState(true);
const [order, setOrder] = useState(null);
useEffect(() => {
// Simular llamada a la API
const timer = setTimeout(() => {
if (id === "demo-id") {
setOrder({
id: "demo-id",
nanoId: "RT-2026-ASUS",
client: "Edgar Javier Ortiz",
dni: "44375912",
phone: "+54 381 1234567",
email: "edgar.ortiz@gmail.com",
device: "Notebook ASUS ROG Strix G15",
serial: "SN-98218-ASUS",
report: "Calentamiento excesivo (alcanza 95°C) y reinicios aleatorios durante sesiones de renderizado o juegos.",
currentStatus: "listo", // Empezar en presupuestado para poder probar el flujo de aprobación
dateIngress: "05/06/2026",
budget: {
items: [
{ desc: "Mantenimiento térmico preventivo (limpieza + cambio de pasta térmica Arctic MX-6)", price: 15000 },
{ desc: "Reemplazo de Cooler FAN original (lado izquierdo - fallando rodamientos)", price: 25000 }
],
approved: false, // Iniciar sin aprobar para el testing interactivo
dateApproved: null
},
history: [
{ date: "05/06/2026 10:30", text: "Equipo ingresado al sistema por recepción física." },
{ date: "06/06/2026 14:15", text: "Diagnóstico finalizado. Se detectó cooler izquierdo trabado y pasta térmica original cristalizada." }
]
});
} else {
// Orden genérica para cualquier otro ID
setOrder({
id: id,
nanoId: id.toUpperCase(),
client: "Cliente Registrado",
dni: "29.987.654",
phone: "+54 381 4567890",
email: "cliente.registrado@email.com",
device: "Dispositivo Tecnológico Genérico",
serial: "SN-PENDIENTE",
report: "Diagnóstico inicial pendiente de revisión.",
currentStatus: "diagnostico",
dateIngress: "08/06/2026",
budget: null,
history: [
{ date: "08/06/2026 08:00", text: "Equipo ingresado. En espera de evaluación del departamento técnico." }
]
});
}
setLoading(false);
}, 1200); // Demora para visualizar el skeleton de carga
return () => clearTimeout(timer);
}, [id]);
// Manejo de la aprobación del presupuesto en el cliente
const handleApproveBudget = () => {
if (!order) return;
// Simular aprobación
setOrder(prev => ({
...prev,
currentStatus: "reparacion", // Pasa a reparación al aprobar
budget: {
...prev.budget,
approved: true,
dateApproved: new Date().toLocaleDateString("es-AR")
},
history: [
...prev.history,
{
date: new Date().toLocaleString("es-AR"),
text: "Presupuesto aprobado de forma online por el cliente. Inicio de trabajos técnicos autorizado."
}
]
}));
toast.success("¡Presupuesto Aprobado!", {
description: "El departamento técnico ha sido notificado para iniciar la reparación."
});
};
// Obtener el índice del estado actual
const currentStepIndex = order ? STEPS.findIndex(s => s.key === order.currentStatus) : 0;
// Porcentaje del progreso (basado en el índice)
const progressPercent = order ? (currentStepIndex / (STEPS.length - 1)) * 100 : 0;
// Renderizador de Skeleton de carga
if (loading) {
return (
<div className="flex-1 max-w-5xl w-full mx-auto px-6 py-12 space-y-10">
<div className="border-b border-border pb-6">
<div className="space-y-3">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-96" />
</div>
</div>
{/* Skeleton de la Línea de Tiempo */}
<Skeleton className="h-28 w-full rounded-2xl" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-8">
<Skeleton className="h-44 w-full rounded-2xl" />
<Skeleton className="h-44 w-full rounded-2xl" />
<Skeleton className="h-64 w-full rounded-2xl" />
</div>
<div className="space-y-8">
<Skeleton className="h-[380px] w-full rounded-2xl" />
</div>
</div>
</div>
);
}
return (
<div className="flex-1 max-w-5xl w-full mx-auto px-6 py-12 space-y-10">
{/* Cabecera del Seguimiento */}
<div className="border-b border-border pb-6">
<div className="space-y-1.5">
<div className="flex items-center gap-3">
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors text-xs font-semibold uppercase tracking-wider flex items-center gap-1">
&larr; Volver
</Link>
<Separator orientation="vertical" className="h-3" />
<span className="text-primary text-xs font-bold uppercase tracking-wider">Orden de Servicio</span>
</div>
<h1 className="font-outfit text-3xl font-bold text-foreground tracking-tight">
Seguimiento de Equipo: <span className="text-muted-foreground">{order.nanoId}</span>
</h1>
</div>
</div>
{/* LINEA DE TIEMPO DEL ESTADO CON PROGRESS BAR DE SHADCN */}
<Card className="bg-card/20 border-border p-8 rounded-2xl relative overflow-hidden shadow-xl">
<div className="relative flex flex-col gap-8 w-full">
<div className="relative flex flex-col md:flex-row justify-between items-start md:items-center gap-8 md:gap-0 w-full">
{/* Componente Progress nativo de shadcn - Centrado al píxel */}
<div className="absolute left-[calc(100%/12)] right-[calc(100%/12)] top-[12px] -translate-y-1/2 h-1 pointer-events-none z-0 hidden md:block">
<Progress value={progressPercent} className="h-1 bg-border [&>div]:bg-primary" />
</div>
{STEPS.map((step, idx) => {
const isCompleted = idx <= currentStepIndex;
const isActive = idx === currentStepIndex;
return (
<div
key={step.key}
className="relative z-10 flex items-start md:flex-col md:items-center gap-4 md:gap-2.5 text-left md:text-center flex-1 w-full md:w-auto"
>
{/* Línea vertical de conexión en móvil */}
{idx < STEPS.length - 1 && (
<div className="absolute left-[11px] top-[24px] bottom-[-32px] w-[2px] bg-border z-0 md:hidden">
<div className={`w-full bg-primary transition-all duration-300 ${idx < currentStepIndex ? "h-full" : "h-0"}`} />
</div>
)}
{/* Círculo indicador */}
<div
className={`w-6 h-6 rounded-full flex items-center justify-center border-2 transition-all duration-300 shrink-0 ${isActive
? "bg-primary border-primary text-primary-foreground shadow-lg shadow-primary/30 scale-105"
: isCompleted
? "bg-primary border-primary text-primary-foreground"
: "bg-background border-border text-muted-foreground"
}`}
>
{isActive ? (
<span className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
) : isCompleted ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
) : null}
</div>
{/* Etiquetas */}
<div className="space-y-0.5 pt-0.5 md:pt-0">
<span className={`text-xs font-bold block ${isCompleted ? "text-foreground" : "text-muted-foreground"}`}>
{step.label}
</span>
<span className="text-[10px] text-muted-foreground font-light block leading-tight md:max-w-[125px] md:mx-auto">
{step.desc}
</span>
</div>
</div>
);
})}
</div>
</div>
</Card>
{/* DETALLES Y PRESUPUESTO */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Columna Izquierda (2/3): Datos del Cliente, Equipo e Historial */}
<div className="lg:col-span-2 space-y-8">
{/* Datos del Cliente con Card de shadcn */}
<Card className="bg-card/25 border-border shadow-md">
<CardHeader className="border-b border-border/50 py-4">
<CardTitle className="font-outfit text-base font-bold text-foreground">
Datos del Cliente
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-5 text-xs">
<div>
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Cliente</span>
<span className="text-foreground text-sm mt-1 block font-medium">{order.client}</span>
</div>
<div>
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">DNI / Identificación</span>
<span className="text-foreground text-sm mt-1 block font-mono">{order.dni}</span>
</div>
<div>
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Teléfono</span>
<span className="text-foreground text-sm mt-1 block">{order.phone}</span>
</div>
<div className="sm:col-span-3">
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Email</span>
<span className="text-foreground text-sm mt-1 block">{order.email}</span>
</div>
</div>
</CardContent>
</Card>
{/* Ficha Técnica con Card de shadcn */}
<Card className="bg-card/25 border-border shadow-md">
<CardHeader className="border-b border-border/50 py-4">
<CardTitle className="font-outfit text-base font-bold text-foreground">
Detalles del Dispositivo
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-2 gap-5 text-xs">
<div>
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Dispositivo</span>
<span className="text-foreground text-sm mt-1 block font-medium">{order.device}</span>
</div>
<div>
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Número de Serie</span>
<span className="text-foreground/90 text-sm mt-1 block font-mono font-bold">{order.serial}</span>
</div>
<div className="col-span-2">
<span className="text-muted-foreground block uppercase tracking-wider font-semibold">Falla Reportada</span>
<p className="text-foreground/90 text-sm mt-2 leading-relaxed font-light bg-background/50 p-3.5 rounded-lg border border-border/40">
{order.report}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Historial de Trazabilidad con Card de shadcn */}
<Card className="bg-card/25 border-border shadow-md">
<CardHeader className="border-b border-border/50 py-4">
<CardTitle className="font-outfit text-base font-bold text-foreground">
Historial del Servicio
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="space-y-6 relative before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-[1px] before:bg-border">
{order.history.map((log, index) => (
<div key={index} className="flex gap-4 relative z-10 text-xs">
<div className="w-[23px] h-[23px] rounded-full bg-background border border-border flex items-center justify-center shrink-0 shadow-sm">
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
</div>
<div className="space-y-1">
<span className="text-muted-foreground font-mono font-medium block">{log.date}</span>
<p className="text-foreground/95 text-sm font-light leading-relaxed">{log.text}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Columna Derecha (1/3): Presupuesto y Acciones */}
<div className="space-y-8">
{/* Ficha de Presupuesto con Card de shadcn */}
<Card className="bg-card/30 border-border p-6 shadow-xl flex flex-col justify-between h-full">
<div className="space-y-5">
<h3 className="font-outfit text-lg font-bold text-foreground border-b border-border pb-2.5">
Presupuesto del Servicio
</h3>
{order.budget ? (
<div className="space-y-5">
{/* Desglose */}
<div className="space-y-3.5">
{order.budget.items.map((item, idx) => (
<div key={idx} className="text-xs space-y-1 border-b border-border/40 pb-2">
<p className="text-foreground/95 leading-relaxed font-light">{item.desc}</p>
<span className="text-primary font-bold block">${item.price.toLocaleString("es-AR")}</span>
</div>
))}
</div>
{/* Total */}
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-muted-foreground font-semibold uppercase">Total Estimado</span>
<span className="text-xl font-outfit font-extrabold text-foreground">
${order.budget.items.reduce((sum, i) => sum + i.price, 0).toLocaleString("es-AR")}
</span>
</div>
{/* Estado de Aprobación mediante Alert de shadcn */}
{order.budget.approved ? (
<Alert className="bg-emerald-950/20 border-emerald-500/25 text-emerald-400 p-3 shadow-sm">
<svg className="w-4 h-4 shrink-0 stroke-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<AlertTitle className="text-xs font-bold font-outfit ml-2">Aprobado</AlertTitle>
<AlertDescription className="text-[10px] opacity-90 ml-2">
Autorizado el {order.budget.dateApproved}
</AlertDescription>
</Alert>
) : (
<div className="space-y-3 pt-2">
<Alert variant="destructive" className="bg-amber-950/20 border-amber-500/30 text-amber-400 p-3 shadow-sm">
<svg className="w-4 h-4 stroke-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<AlertTitle className="text-xs font-bold font-outfit ml-2">Espera de Aprobación</AlertTitle>
<AlertDescription className="text-[10px] opacity-90 ml-2">
Requiere autorización del cliente para iniciar tareas.
</AlertDescription>
</Alert>
{/* Modal de Confirmación AlertDialog para Aprobar */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="w-full bg-primary hover:bg-primary/95 text-primary-foreground font-bold text-xs py-2.5 rounded-lg transition-all active:scale-[0.99] cursor-pointer">
Aprobar Presupuesto
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="bg-card border-border shadow-2xl">
<AlertDialogHeader>
<AlertDialogTitle className="font-outfit text-lg font-bold text-foreground">
¿Confirmar aprobación del presupuesto?
</AlertDialogTitle>
<AlertDialogDescription className="text-muted-foreground text-xs font-light">
Al aprobar, autorizás al taller a realizar el recambio de cooler y mantenimiento térmico especificado por un total de <strong className="text-foreground font-bold">${order.budget.items.reduce((sum, i) => sum + i.price, 0).toLocaleString("es-AR")}</strong>.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="bg-background border-border text-foreground hover:bg-muted font-bold text-xs">Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleApproveBudget} className="bg-primary hover:bg-primary/90 text-primary-foreground font-bold text-xs">
Confirmar Aprobación
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</div>
) : (
<div className="text-center py-8 space-y-3">
<p className="text-xs text-muted-foreground leading-relaxed font-light">
El técnico aún no ha cargado el presupuesto detallado de mano de obra y repuestos.
</p>
<Badge variant="secondary" className="px-3 py-1 font-bold text-[10px] uppercase tracking-wider text-muted-foreground">
Pendiente de Carga
</Badge>
</div>
)}
</div>
{/* Acciones */}
<div className="pt-4 border-t border-border mt-6">
<Button
variant="outline"
onClick={() => toast.info("Módulo de exportación PDF disponible en la Fase VI.")}
className="w-full bg-background hover:bg-muted text-foreground border-border text-xs font-bold py-2.5 rounded-lg transition-all active:scale-[0.99] cursor-pointer flex items-center justify-center gap-2 select-none"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Exportar Presupuesto (PDF)
</Button>
</div>
</Card>
</div>
</div>
</div>
);
}

View File

@ -1,14 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from "path"
import { fileURLToPath } from 'url'
import path from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
// https://vite.dev/config/
export default defineConfig({
plugins: [
tailwindcss(),
react()
],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),