This commit is contained in:
Eric Ayala Brito 2026-08-17 10:08:00 -06:00
commit b498e58bc1
106 changed files with 17617 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
.output
.vinxi
.tanstack/**
.nitro
*.local
# Wrangler / Cloudflare
.wrangler/
.dev.vars
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
.lovable/project.json Normal file
View File

@ -0,0 +1,5 @@
{
"schemaVersion": 1,
"template": "tanstack_start_ts_current",
"revision": "tanstack_start_ts_current-f03b882efb96"
}

8
.prettierignore Normal file
View File

@ -0,0 +1,8 @@
node_modules
dist
.output
.vinxi
pnpm-lock.yaml
package-lock.json
bun.lock
routeTree.gen.ts

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}

10
AGENTS.md Normal file
View File

@ -0,0 +1,10 @@
<!-- LOVABLE:BEGIN -->
> [!IMPORTANT]
> This project is connected to [Lovable](https://lovable.dev). Avoid rewriting
> published git history — force pushing, or rebasing/amending/squashing commits
> that are already pushed — as it rewrites history on Lovable's side and the
> user will likely lose their project history.
>
> Commits you push to the connected branch sync back to Lovable and show up in
> the editor, so keep the branch in a working state.
<!-- LOVABLE:END -->

146
README.md Normal file
View File

@ -0,0 +1,146 @@
# Astro Partner Hub
Actúa como un Desarrollador Web Full Stack Senior y Arquitecto UI/UX experto en el stack **Astro + Alpine.js + Vanilla JS**.
Necesito que construyas una Progressive Web App (PWA) de nivel profesional, moderna (tendencias UX/UI 2026), responsive (Mobile-First optimizada para todas las pantallas de smartphones, pero adaptable a Tablets y Desktop) para la plataforma de **Socios Comerciales**.
---
### 🛠️ TECH STACK OBLIGATORIO
- **Framework Principal:** Astro (usando componentes `.astro` para la estructura y layout).
- **Reactividad Ligera:** Alpine.js (para la interactividad de la UI como modales, pestañas, menús desplegables y toggles).
- **Lógica de Negocio y PWA:** Vanilla JS puro (para consumo de API Fetch, LocalStorage, Registro de Service Worker, Notificaciones Push y lógica de cálculos).
- **Estilos:** Tailwind CSS (o CSS utilitario moderno con diseño fluido).
---
### 📱 DISEÑO Y ESTÉTICA UI/UX (Tendencias 2026)
1. **Línea Gráfica (Inspirada en el Wireframe adjunto):**
- Palette de colores primaria basada en **Amarillo/Dorado cálido (`#FACC15` o similar), Negro Profundo (`#18181B`) y Blanco / Gris claro (`#F4F4F5`)**.
- Tarjetas con bordes suavemente redondeados (`rounded-2xl` o `rounded-3xl`), sombras sutiles y botones de acción principales (*CTA*) en negro o amarillo contrastante.
- Tipografía moderna, limpia y legibilidad óptima en pantallas pequeñas.
2. **Navegación Móvil y Responsive:**
- **En Móviles:** Implementa una barra de navegación inferior fija (*Bottom Navigation Bar*) con iconos táctiles para: Inicio/Vuelos, Reservaciones, Comisiones y Perfil/Ajustes.
- **En Desktop/Tablets:** La interfaz debe transformarse automáticamente a un Dashboard con barra lateral (*Sidebar*) retraíble o navegación superior horizontal con contenedores centrados para aprovechar pantallas anchas.
---
### ⚙️ FUNCIONALIDADES Y FLUIJO DE LA APP
#### 1. Módulo de Autenticación y Registro de Socios
- **Login / Inicio de Sesión:** Formulario limpio con campos para usuario/email y contraseña, botón de inicio de sesión y enlace a registro.
- **Registro de Socios Comerciales:** Formulario estructurado que capture **obligatoriamente** los siguientes campos:
- Categoría
- Empresas asignadas
- Nombre
- Razón Social
- RUC
- Contacto Principal
- Email
- Teléfono
- Sitio Web
- Dirección
- Notas
#### 2. Módulo de Consulta de Vuelos y Reservaciones
- Buscador con filtros por fecha, origen, destino y número de pasajeros.
- Listado visual de vuelos disponibles en tarjetas interactivas.
- Flujo para **Generar Reservación** que permita seleccionar vuelo y agregar opcionales / adicionales (Souvenirs, snacks, transporte terrestre, etc.).
- **Desglose y Resumen de Compra:** Debe mostrar el total en general calculado en tiempo real con el desglose exacto de:
- Subtotal Vuelos
- Subtotal Souvenirs / Extras
- Impuestos / Tasas
- **TOTAL GENERAL**
#### 3. Módulo de Comisiones (Cálculo Automático)
- Dashboard/Resumen de comisiones ganadas por el socio comercial.
- **Lógica de negocio:** La comisión es del **10%** sobre el valor de las ventas.
- Debe incluir una vista o tarjeta resumen limpia que muestre:
- Mapeo de ventas realizadas.
- Total cobrado en vuelos y extras.
- **Comisión neta a recibir (10% calculado automáticamente)**.
#### 4. Configuración PWA y Notificaciones (Service Worker en Vanilla JS)
- Incluye la creación y configuración del archivo `manifest.json` (iconos, tema amarillo/negro, nombre de la app, `display: standalone`).
- Script de registro de un **Service Worker en Vanilla JS** para:
- Funcionar en segundo plano y soportar uso offline (caché de assets principales).
- Simular el envío y recepción de **Notificaciones Push** (por ejemplo: *"¡Reservación confirmada!"* o *"Nueva comisión acreditada del 10%"*).
---
### 📦 ENTREGABLES REQUERIDOS
1. Estructura de archivos limpia para el proyecto en Astro.
2. El archivo de layout general con la inclusión de Alpine.js y la lógica del Service Worker en Vanilla JS.
3. El componente `.astro` o página de **Login / Registro** con todos los campos especificados.
4. El componente `.astro` para **Consulta de Vuelos, Resumen de Compra y Desglose de Comisiones del 10%**.
5. Los archivos `manifest.json` y `sw.js` (Service Worker) listos para producción.
Por favor, escribe un código modular, limpio, completamente funcional y comentado donde corresponda.
This project was built with [Lovable](https://lovable.dev).
## Build with Lovable
Continue developing this project in the [Lovable editor](https://lovable.dev/projects/be1f5b9e-f0ac-441f-a123-4c938f08b54e).
- **Ship faster**: describe what you want to build and Lovable handles the code.
- **Stay in sync**: every change made in Lovable is committed straight to this repository.
- **Full ownership**: this code is yours. Push to `main` on GitHub and your changes sync back into Lovable, ready for your next prompt.
## Development
Prefer working locally? You need Node.js and npm — [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating).
```sh
git clone <this-repository-url>
cd <repository-name>
npm i
npm run dev
```

1045
bun.lock Normal file

File diff suppressed because it is too large Load Diff

7
bunfig.toml Normal file
View File

@ -0,0 +1,7 @@
[install]
saveTextLockfile = true
# 24h supply-chain guard: skip package versions published less than a day ago.
minimumReleaseAge = 86400
# Each entry bypasses the 24h guard for one package — confirm with the user
# before adding any.
minimumReleaseAgeExcludes = ["@lovable.dev/vite-tanstack-config", "@lovable.dev/mcp-js", "@lovable.dev/vite-plugin-dev-server-bridge", "@lovable.dev/vite-plugin-hmr-gate", "@lovable.dev/email-js", "@lovable.dev/webhooks-js"]

22
components.json Normal file
View File

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

40
eslint.config.js Normal file
View File

@ -0,0 +1,40 @@
import js from "@eslint/js";
import eslintPluginPrettier from "eslint-plugin-prettier/recommended";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", ".output", ".vinxi"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"no-restricted-imports": [
"error",
{
paths: [
{
name: "server-only",
message:
"TanStack Start does not use the Next.js `server-only` package. Rename the module to `*.server.ts` or mark it with `@tanstack/react-start/server-only`.",
},
],
},
],
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off",
},
},
eslintPluginPrettier,
);

7693
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

87
package.json Normal file
View File

@ -0,0 +1,87 @@
{
"name": "tanstack_start_ts",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"build:dev": "vite build --mode development",
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.101.1",
"@tanstack/react-router": "1.170.18",
"@tanstack/react-start": "1.168.32",
"@tanstack/router-plugin": "1.168.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.2",
"react-resizable-panels": "^4.6.5",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.3.4",
"vaul": "^1.1.2",
"vite-tsconfig-paths": "^6.0.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@lovable.dev/vite-tanstack-config": "2.13.1",
"@types/node": "^22.16.5",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"nitro": "3.0.260603-beta",
"prettier": "^3.7.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.2.0"
}
}

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

View File

@ -0,0 +1,16 @@
{
"name": "Socios Comerciales — Vuelos y Comisiones",
"short_name": "Socios",
"description": "Plataforma para socios comerciales: consulta de vuelos, reservaciones y comisiones del 10%.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#18181B",
"theme_color": "#FACC15",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

14
public/robots.txt Normal file
View File

@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

BIN
src/assets/exp-original.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
src/assets/exp-total.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
src/assets/exp-zero.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View File

@ -0,0 +1,11 @@
{
"version": 1,
"asset_id": "f6794663-e15a-4a76-af54-f1a7fde12707",
"project_id": "be1f5b9e-f0ac-441f-a123-4c938f08b54e",
"url": "/__l5e/assets-v1/f6794663-e15a-4a76-af54-f1a7fde12707/logo-sky-balloons-oficial.png",
"r2_key": "a/v1/be1f5b9e-f0ac-441f-a123-4c938f08b54e/f6794663-e15a-4a76-af54-f1a7fde12707/logo-sky-balloons-oficial.png",
"original_filename": "logo-sky-balloons-oficial.png",
"size": 148367,
"content_type": "image/png",
"created_at": "2026-08-17T14:09:30Z"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

174
src/components/AppShell.tsx Normal file
View File

@ -0,0 +1,174 @@
import { Link, useRouterState } from "@tanstack/react-router";
import { CalendarCheck, Wallet, UserRound, Bell, LogOut, Menu, Sparkles, Ticket } from "lucide-react";
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { useAppState } from "@/lib/store";
import { notificar, pedirPermisoNotificaciones } from "@/lib/notificaciones";
import { BrandLogo, BrandMark } from "@/components/BrandLogo";
import { useInstalarPWA } from "@/lib/pwa-install";
const NAV = [
{ to: "/vuelos", label: "Vuelos", icon: Sparkles },
{ to: "/reserva", label: "Reservar", icon: Ticket },
{ to: "/reservaciones", label: "Reservas", icon: CalendarCheck },
{ to: "/comisiones", label: "Comisiones", icon: Wallet },
{ to: "/perfil", label: "Perfil", icon: UserRound },
] as const;
export function AppShell({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: ReactNode;
}) {
const { pathname } = useRouterState({ select: (s) => s.location });
const { socio } = useAppState();
const [sidebarAbierto, setSidebarAbierto] = useState(true);
const { instalada, instalar } = useInstalarPWA();
const activarNotificaciones = async () => {
const estado = await pedirPermisoNotificaciones();
notificar(
"Notificaciones activas",
estado === "granted"
? "Te avisaremos de reservaciones confirmadas y comisiones acreditadas."
: "Verás los avisos dentro de la app.",
);
};
return (
<div className="min-h-screen bg-background font-sans">
{/* Sidebar retráctil — solo desktop/tablet */}
<aside
className={cn(
"fixed inset-y-0 left-0 z-30 hidden flex-col bg-sidebar p-4 text-sidebar-foreground transition-all duration-300 lg:flex",
sidebarAbierto ? "w-64" : "w-20",
)}
>
<div className="px-2 py-3">
<BrandLogo soloIcono={!sidebarAbierto} invertido />
</div>
<nav className="mt-6 flex flex-1 flex-col gap-1">
{NAV.map(({ to, label, icon: Icon }) => {
const activo = pathname.startsWith(to);
return (
<Link
key={to}
to={to}
className={cn(
"flex items-center gap-3 rounded-2xl px-3 py-3 text-sm font-medium transition-colors",
activo
? "bg-brand text-brand-foreground"
: "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground",
)}
>
<Icon className="size-5 shrink-0" />
{sidebarAbierto && label}
</Link>
);
})}
</nav>
<button
onClick={() => setSidebarAbierto((v) => !v)}
className="flex items-center gap-3 rounded-2xl px-3 py-3 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent"
>
<Menu className="size-5" />
{sidebarAbierto && "Contraer"}
</button>
</aside>
<div className={cn("transition-all duration-300", sidebarAbierto ? "lg:pl-64" : "lg:pl-20")}>
{/* Encabezado */}
<header className="sticky top-0 z-20 border-b border-border/70 bg-background/85 backdrop-blur-xl">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
<div className="min-w-0">
<h1 className="truncate text-xl font-bold tracking-tight sm:text-2xl">{title}</h1>
{subtitle && (
<p className="truncate text-sm text-muted-foreground">{subtitle}</p>
)}
</div>
<div className="flex items-center gap-2">
{!instalada && (
<button
onClick={async () => {
const ok = await instalar();
if (!ok)
notificar(
"Instalar la app",
"Usa “Agregar a pantalla de inicio” desde el menú de tu navegador.",
);
}}
className="flex items-center gap-2 rounded-2xl border border-border bg-card px-3 py-2.5 text-sm font-semibold shadow-soft transition-all hover:-translate-y-0.5 hover:shadow-lift active:scale-95"
>
<span className="grid size-6 place-items-center rounded-lg bg-ink p-0.5">
<BrandMark className="h-4" />
</span>
<span className="hidden sm:inline">Instalar</span>
</button>
)}
<button
onClick={activarNotificaciones}
aria-label="Activar notificaciones"
className="grid size-11 place-items-center rounded-2xl border border-border bg-card text-foreground shadow-soft transition-all hover:-translate-y-0.5 hover:bg-muted active:scale-95"
>
<Bell className="size-5" />
</button>
<Link
to="/perfil"
className="hidden items-center gap-2 rounded-2xl bg-ink px-3 py-2.5 text-sm font-medium text-ink-foreground sm:flex"
>
<span className="grid size-6 place-items-center rounded-full bg-brand text-[11px] font-bold text-brand-foreground">
{(socio?.nombre ?? "S").charAt(0).toUpperCase()}
</span>
<span className="max-w-28 truncate">{socio?.nombre ?? "Socio"}</span>
</Link>
</div>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 pb-28 pt-5 sm:px-6 lg:pb-12">{children}</main>
</div>
{/* Bottom navigation — móvil */}
<nav className="fixed inset-x-0 bottom-0 z-30 border-t border-border bg-card/95 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl lg:hidden">
<ul className="mx-auto grid max-w-md grid-cols-4">
{NAV.map(({ to, label, icon: Icon }) => {
const activo = pathname.startsWith(to);
return (
<li key={to}>
<Link
to={to}
className="flex flex-col items-center gap-1 px-2 py-3 text-[11px] font-medium"
>
<span
className={cn(
"grid size-10 place-items-center rounded-2xl transition-colors",
activo ? "bg-brand text-brand-foreground" : "text-muted-foreground",
)}
>
<Icon className="size-5" />
</span>
<span className={activo ? "text-foreground" : "text-muted-foreground"}>{label}</span>
</Link>
</li>
);
})}
</ul>
</nav>
</div>
);
}
export function SalirButton() {
return (
<span className="inline-flex items-center gap-2 text-sm">
<LogOut className="size-4" /> Cerrar sesión
</span>
);
}

View File

@ -0,0 +1,61 @@
import logoOficial from "@/assets/logo-sky-balloons-oficial.png.asset.json";
import { cn } from "@/lib/utils";
/** Identidad oficial Sky Balloons México (logo sobre fondo azul marino). */
export function BrandLogo({
className,
soloIcono = false,
invertido = false,
}: {
className?: string;
soloIcono?: boolean;
invertido?: boolean;
}) {
return (
<span className={cn("flex items-center gap-3", className)}>
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-2xl bg-ink p-1.5 shadow-soft">
<img
src={logoOficial.url}
alt="Sky Balloons México"
width={160}
height={110}
className="h-8 w-auto object-contain"
/>
</span>
{!soloIcono && (
<span className="min-w-0">
<span
className={cn(
"block truncate text-sm font-bold leading-tight tracking-tight",
invertido ? "text-ink-foreground" : "text-foreground",
)}
>
Sky Balloons México
</span>
<span
className={cn(
"block truncate text-[11px] leading-tight",
invertido ? "text-ink-foreground/60" : "text-muted-foreground",
)}
>
Portal de Socios Comerciales
</span>
</span>
)}
</span>
);
}
/** Solo el globo/logotipo, para botones e insignias. */
export function BrandMark({ className }: { className?: string }) {
return (
<img
src={logoOficial.url}
alt=""
aria-hidden="true"
width={80}
height={55}
className={cn("h-5 w-auto object-contain", className)}
/>
);
}

View File

@ -0,0 +1,89 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Check, ChevronDown, Search } from "lucide-react";
/** Select con búsqueda (autocomplete) accesible y ligero. */
export function BuscadorSelect({
opciones,
valor,
onChange,
placeholder = "Selecciona una opción",
id,
}: {
opciones: string[];
valor: string;
onChange: (v: string) => void;
placeholder?: string;
id?: string;
}) {
const [abierto, setAbierto] = useState(false);
const [query, setQuery] = useState("");
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const fuera = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setAbierto(false);
};
document.addEventListener("mousedown", fuera);
return () => document.removeEventListener("mousedown", fuera);
}, []);
const filtradas = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return opciones;
return opciones.filter((o) => o.toLowerCase().includes(q));
}, [opciones, query]);
return (
<div className="relative" ref={ref}>
<button
id={id}
type="button"
onClick={() => setAbierto((v) => !v)}
className="flex w-full items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-left text-sm transition-all hover:border-primary/60 focus:outline-none focus:ring-2 focus:ring-ring"
>
<span className={valor ? "truncate font-medium" : "truncate text-muted-foreground"}>
{valor || placeholder}
</span>
<ChevronDown
className={`size-4 shrink-0 text-muted-foreground transition-transform duration-300 ${abierto ? "rotate-180" : ""}`}
/>
</button>
{abierto && (
<div className="absolute z-40 mt-2 w-full overflow-hidden rounded-2xl border border-border bg-popover shadow-lift animate-in fade-in-0 zoom-in-95">
<div className="flex items-center gap-2 border-b border-border px-3 py-2.5">
<Search className="size-4 text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Buscar lugar de cita…"
className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<ul className="max-h-64 overflow-y-auto py-1">
{filtradas.length === 0 && (
<li className="px-4 py-3 text-sm text-muted-foreground">Sin resultados</li>
)}
{filtradas.map((o) => (
<li key={o}>
<button
type="button"
onClick={() => {
onChange(o);
setAbierto(false);
setQuery("");
}}
className="flex w-full items-center justify-between gap-2 px-4 py-2.5 text-left text-sm transition-colors hover:bg-accent"
>
<span className="truncate">{o}</span>
{valor === o && <Check className="size-4 shrink-0 text-primary" />}
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,69 @@
import { Minus, Plus } from "lucide-react";
function Contador({
label,
hint,
valor,
min = 0,
onChange,
}: {
label: string;
hint: string;
valor: number;
min?: number;
onChange: (v: number) => void;
}) {
return (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{label}</p>
<p className="truncate text-xs text-muted-foreground">{hint}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label={`Quitar ${label}`}
onClick={() => onChange(Math.max(min, valor - 1))}
className="grid size-9 place-items-center rounded-xl border border-border bg-background text-foreground transition-all hover:-translate-y-0.5 hover:border-primary hover:text-primary active:scale-95 disabled:opacity-40"
disabled={valor <= min}
>
<Minus className="size-4" />
</button>
<span className="w-6 text-center text-sm font-bold tabular-nums">{valor}</span>
<button
type="button"
aria-label={`Agregar ${label}`}
onClick={() => onChange(Math.min(20, valor + 1))}
className="grid size-9 place-items-center rounded-xl bg-primary text-primary-foreground transition-all hover:-translate-y-0.5 hover:shadow-lift active:scale-95"
>
<Plus className="size-4" />
</button>
</div>
</div>
);
}
export function PasajerosSelector({
adultos,
ninos,
setAdultos,
setNinos,
}: {
adultos: number;
ninos: number;
setAdultos: (v: number) => void;
setNinos: (v: number) => void;
}) {
return (
<div className="grid gap-3 sm:grid-cols-2">
<Contador
label="Adultos"
hint="+12 años"
valor={adultos}
min={1}
onChange={setAdultos}
/>
<Contador label="Niños" hint="4 a 12 años" valor={ninos} onChange={setNinos} />
</div>
);
}

View File

@ -0,0 +1,112 @@
import { useMemo, useState } from "react";
import { Minus, Plus } from "lucide-react";
import { CATEGORIAS_EXTRAS, EXTRAS_RESERVA } from "@/lib/extras-catalogo";
import { formatoMoneda } from "@/lib/catalogo";
import { PasajerosSelector } from "@/components/PasajerosSelector";
export function Paso1Extras({
adultos,
ninos,
setAdultos,
setNinos,
extras,
setExtra,
}: {
adultos: number;
ninos: number;
setAdultos: (v: number) => void;
setNinos: (v: number) => void;
extras: Record<string, number>;
setExtra: (id: string, cantidad: number) => void;
}) {
const [categoria, setCategoria] = useState<string>("Todos");
const lista = useMemo(
() => (categoria === "Todos" ? EXTRAS_RESERVA : EXTRAS_RESERVA.filter((e) => e.categoria === categoria)),
[categoria],
);
return (
<div className="space-y-6">
<section className="rounded-3xl bg-card p-5 shadow-soft">
<h2 className="text-base font-bold">Pasajeros</h2>
<p className="mb-4 text-sm text-muted-foreground">
Define cuántas personas volarán; los datos individuales se piden en el paso 2.
</p>
<PasajerosSelector adultos={adultos} ninos={ninos} setAdultos={setAdultos} setNinos={setNinos} />
</section>
<section className="rounded-3xl bg-card p-5 shadow-soft">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="text-base font-bold">Opcionales y adicionales</h2>
<p className="text-sm text-muted-foreground">Personaliza la experiencia con servicios extra.</p>
</div>
</div>
<div className="-mx-1 mt-4 flex gap-2 overflow-x-auto px-1 pb-1">
{CATEGORIAS_EXTRAS.map((c) => (
<button
key={c}
type="button"
onClick={() => setCategoria(c)}
className={`shrink-0 rounded-full border px-4 py-2 text-xs font-semibold transition-all active:scale-95 ${
categoria === c
? "border-primary bg-primary text-primary-foreground shadow-soft"
: "border-border bg-background text-muted-foreground hover:border-primary/60 hover:text-foreground"
}`}
>
{c}
</button>
))}
</div>
<ul className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{lista.map((e) => {
const cant = extras[e.id] ?? 0;
const activo = cant > 0;
return (
<li
key={e.id}
className={`group flex flex-col justify-between gap-4 rounded-3xl border p-4 transition-all duration-300 hover:-translate-y-1 hover:shadow-lift ${
activo ? "border-primary bg-accent/60 shadow-soft" : "border-border bg-card"
}`}
>
<div className="min-w-0">
<span className="inline-flex rounded-full bg-secondary/15 px-2.5 py-1 text-[11px] font-semibold text-secondary">
{e.categoria}
</span>
<p className="mt-2 text-sm font-semibold leading-snug">{e.nombre}</p>
<p className="mt-1 text-xs text-muted-foreground">Por {e.unidad.toLowerCase()}</p>
</div>
<div className="flex items-center justify-between gap-2">
<p className="text-base font-bold text-primary">{formatoMoneda(e.precio)}</p>
<div className="flex items-center gap-2">
<button
type="button"
aria-label={`Quitar ${e.nombre}`}
disabled={cant === 0}
onClick={() => setExtra(e.id, Math.max(0, cant - 1))}
className="grid size-9 place-items-center rounded-xl border border-border bg-background transition-all hover:-translate-y-0.5 hover:border-primary hover:text-primary active:scale-95 disabled:opacity-40"
>
<Minus className="size-4" />
</button>
<span className="w-6 text-center text-sm font-bold tabular-nums">{cant}</span>
<button
type="button"
aria-label={`Agregar ${e.nombre}`}
onClick={() => setExtra(e.id, cant + 1)}
className="grid size-9 place-items-center rounded-xl bg-primary text-primary-foreground transition-all hover:-translate-y-0.5 hover:shadow-lift active:scale-95"
>
<Plus className="size-4" />
</button>
</div>
</div>
</li>
);
})}
</ul>
</section>
</div>
);
}

View File

@ -0,0 +1,182 @@
import { useState } from "react";
import { CalendarDays, ChevronDown, CircleCheck, Clock, MapPin, Users } from "lucide-react";
import { BuscadorSelect } from "@/components/BuscadorSelect";
import { LUGARES_CITA, VUELO_INCLUYE } from "@/lib/extras-catalogo";
import { HORA_CITA, type PasajeroDatos, type ReservaEstado } from "@/lib/reserva-flujo";
export function Paso2Datos({
estado,
set,
setPasajero,
}: {
estado: ReservaEstado;
set: (parcial: Partial<ReservaEstado>) => void;
setPasajero: (i: number, parcial: Partial<PasajeroDatos>) => void;
}) {
const [resumenAbierto, setResumenAbierto] = useState(true);
return (
<div className="grid gap-6 xl:grid-cols-[1fr_340px]">
<div className="space-y-6">
<section className="rounded-3xl bg-card p-5 shadow-soft">
<h2 className="text-base font-bold">Datos del vuelo</h2>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="fecha" className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<CalendarDays className="mr-1 inline size-3.5" /> Fecha de vuelo
</label>
<input
id="fecha"
type="date"
value={estado.fecha}
disabled={estado.fechaPorConfirmar}
onChange={(e) => set({ fecha: e.target.value })}
className="w-full rounded-2xl border border-border bg-card px-4 py-3 text-sm transition-all hover:border-primary/60 focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
/>
<label className="mt-2 flex cursor-pointer items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={estado.fechaPorConfirmar}
onChange={(e) => set({ fechaPorConfirmar: e.target.checked })}
className="size-4 rounded border-border accent-primary"
/>
Fecha por confirmar
</label>
</div>
<div>
<span className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Clock className="mr-1 inline size-3.5" /> Hora de cita
</span>
<div className="rounded-2xl border border-border bg-muted px-4 py-3 text-sm font-semibold">
{HORA_CITA}
</div>
<p className="mt-2 text-xs text-muted-foreground">Horario fijo de recepción de pasajeros.</p>
</div>
<div className="sm:col-span-2">
<label htmlFor="lugar" className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<MapPin className="mr-1 inline size-3.5" /> Lugar de cita
</label>
<BuscadorSelect
id="lugar"
opciones={LUGARES_CITA}
valor={estado.lugarCita}
onChange={(v) => set({ lugarCita: v })}
placeholder="Busca tu punto de encuentro"
/>
</div>
<div>
<span className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Tipo de vuelo
</span>
<div className="rounded-2xl border border-border bg-muted px-4 py-3 text-sm font-semibold text-muted-foreground">
Compartidos
</div>
</div>
<div>
<span className="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Idioma del pase de abordar
</span>
<div className="flex gap-2">
{(["Español", "Inglés"] as const).map((idioma) => (
<button
key={idioma}
type="button"
role="radio"
aria-checked={estado.idioma === idioma}
onClick={() => set({ idioma })}
className={`flex-1 rounded-2xl border px-4 py-3 text-sm font-semibold transition-all active:scale-95 ${
estado.idioma === idioma
? "border-primary bg-primary text-primary-foreground shadow-soft"
: "border-border bg-card hover:border-primary/60"
}`}
>
{idioma}
</button>
))}
</div>
</div>
</div>
</section>
<section className="rounded-3xl bg-card p-5 shadow-soft">
<h2 className="flex items-center gap-2 text-base font-bold">
<Users className="size-4 text-secondary" /> Datos de los pasajeros
</h2>
<p className="text-sm text-muted-foreground">
{estado.pasajeros.length} pasajero(s) según tu selección del paso 1.
</p>
<div className="mt-4 space-y-4">
{estado.pasajeros.map((p, i) => (
<div key={i} className="rounded-3xl border border-border p-4 transition-shadow hover:shadow-soft">
<p className="mb-3 text-xs font-bold uppercase tracking-wide text-primary">
Pasajero {i + 1} {i < estado.adultos ? "· Adulto" : "· Niño"}
</p>
<div className="grid gap-3 sm:grid-cols-2">
<input
value={p.nombre}
onChange={(e) => setPasajero(i, { nombre: e.target.value })}
placeholder="Nombre(s)"
className="rounded-2xl border border-border bg-card px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<input
value={p.apellidos}
onChange={(e) => setPasajero(i, { apellidos: e.target.value })}
placeholder="Apellidos"
className="rounded-2xl border border-border bg-card px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<input
value={p.telefono}
onChange={(e) => setPasajero(i, { telefono: e.target.value })}
placeholder="Teléfono de contacto"
className="rounded-2xl border border-border bg-card px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<input
value={p.peso}
onChange={(e) => setPasajero(i, { peso: e.target.value })}
placeholder="Peso aproximado (kg)"
className="rounded-2xl border border-border bg-card px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
))}
</div>
</section>
</div>
{/* Resumen del vuelo colapsable */}
<aside className="xl:sticky xl:top-24 xl:h-fit">
<div className="overflow-hidden rounded-3xl bg-ink text-ink-foreground shadow-lift">
<button
type="button"
onClick={() => setResumenAbierto((v) => !v)}
className="flex w-full items-center justify-between gap-3 px-5 py-4 text-left"
>
<div>
<p className="text-sm font-bold">Vuelo Aventura 20% OFF</p>
<p className="text-xs opacity-70">Experiencia compartida · Teotihuacán</p>
</div>
<ChevronDown className={`size-4 transition-transform duration-300 ${resumenAbierto ? "rotate-180" : ""}`} />
</button>
<div
className={`grid transition-all duration-500 ease-out ${resumenAbierto ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"}`}
>
<ul className="overflow-hidden px-5 pb-5 text-sm">
{VUELO_INCLUYE.map((i) => (
<li key={i} className="flex items-start gap-2 py-1.5">
<CircleCheck className="mt-0.5 size-4 shrink-0 text-brand" />
<span className="opacity-90">{i}</span>
</li>
))}
</ul>
</div>
</div>
</aside>
</div>
);
}

View File

@ -0,0 +1,176 @@
import { useEffect, useState } from "react";
import { Building2, CreditCard, Landmark, Loader2, ShieldCheck, Store } from "lucide-react";
import { formatoMoneda } from "@/lib/catalogo";
import type { MetodoPago } from "@/lib/reserva-flujo";
const METODOS: { id: MetodoPago; label: string; icon: typeof CreditCard }[] = [
{ id: "tarjeta", label: "Pago con tarjeta", icon: CreditCard },
{ id: "referencia", label: "Referencia bancaria", icon: Landmark },
{ id: "tiendas", label: "Tiendas / OXXO", icon: Store },
{ id: "sucursal", label: "Sucursal / Globopuerto", icon: Building2 },
];
export function Paso3Pago({
subtotal,
total,
impuestos,
metodoPago,
setMetodo,
pagoConfirmado,
confirmarPago,
}: {
subtotal: number;
total: number;
impuestos: number;
metodoPago: MetodoPago;
setMetodo: (m: MetodoPago) => void;
pagoConfirmado: boolean;
confirmarPago: () => void;
}) {
const [notificando, setNotificando] = useState(false);
const [notificado, setNotificado] = useState(false);
useEffect(() => {
if (metodoPago !== "sucursal") {
setNotificando(false);
setNotificado(false);
return;
}
setNotificando(true);
setNotificado(false);
const t = setTimeout(() => {
setNotificando(false);
setNotificado(true);
}, 1800);
return () => clearTimeout(t);
}, [metodoPago]);
return (
<div className="grid gap-6 xl:grid-cols-[1fr_340px]">
<section className="rounded-3xl bg-card p-5 shadow-soft">
<h2 className="text-base font-bold">Datos para el pago</h2>
<p className="text-sm text-muted-foreground">Elige el método con el que se liquidará la reservación.</p>
<div className="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{METODOS.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setMetodo(id)}
className={`flex items-center gap-2 rounded-2xl border px-4 py-3 text-left text-sm font-semibold transition-all hover:-translate-y-0.5 active:scale-95 ${
metodoPago === id
? "border-primary bg-primary text-primary-foreground shadow-soft"
: "border-border bg-card hover:border-primary/60"
}`}
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{label}</span>
</button>
))}
</div>
<div className="mt-5 rounded-3xl border border-border p-5 animate-in fade-in-0 slide-in-from-bottom-2">
{metodoPago === "tarjeta" && (
<div className="grid gap-3 sm:grid-cols-2">
<input
placeholder="Nombre en la tarjeta"
className="rounded-2xl border border-border bg-card px-4 py-3 text-sm sm:col-span-2 focus:outline-none focus:ring-2 focus:ring-ring"
/>
<input
placeholder="0000 0000 0000 0000"
inputMode="numeric"
className="rounded-2xl border border-border bg-card px-4 py-3 text-sm sm:col-span-2 focus:outline-none focus:ring-2 focus:ring-ring"
/>
<input
placeholder="MM / AA"
className="rounded-2xl border border-border bg-card px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<input
placeholder="CVV"
className="rounded-2xl border border-border bg-card px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="flex items-center gap-2 text-xs text-muted-foreground sm:col-span-2">
<ShieldCheck className="size-4 text-success" /> Entorno de demostración: no se procesan cargos reales.
</p>
</div>
)}
{metodoPago === "referencia" && (
<dl className="grid gap-3 text-sm sm:grid-cols-2">
{[
["Banco", "BBVA México"],
["Beneficiario", "Sky Balloons México SA de CV"],
["CLABE", "012 180 00123456789 4"],
["Referencia", "SB-90241-MX"],
].map(([k, v]) => (
<div key={k} className="rounded-2xl bg-muted px-4 py-3">
<dt className="text-xs uppercase tracking-wide text-muted-foreground">{k}</dt>
<dd className="font-semibold">{v}</dd>
</div>
))}
</dl>
)}
{metodoPago === "tiendas" && (
<div className="text-center">
<p className="text-sm text-muted-foreground">Presenta este código en cualquier tienda participante.</p>
<div className="mx-auto mt-4 flex h-24 max-w-sm items-end justify-center gap-[3px] rounded-2xl bg-muted px-4 py-4">
{Array.from({ length: 46 }).map((_, i) => (
<span
key={i}
className="w-[3px] rounded-sm bg-foreground"
style={{ height: `${30 + ((i * 37) % 60)}%` }}
/>
))}
</div>
<p className="mt-3 font-mono text-sm font-bold tracking-widest">9 8412 03357 12</p>
</div>
)}
{metodoPago === "sucursal" && (
<div className="flex min-h-24 flex-col items-center justify-center gap-3 text-center">
{notificando && (
<>
<Loader2 className="size-7 animate-spin text-primary" />
<p className="text-sm font-semibold">Notificando a sucursal…</p>
</>
)}
{notificado && (
<div className="animate-in fade-in-0 zoom-in-95">
<ShieldCheck className="mx-auto size-8 text-success" />
<p className="mt-2 text-sm font-bold">Notificación enviada.</p>
<p className="text-sm text-muted-foreground">Podrá realizar su pago al llegar.</p>
</div>
)}
</div>
)}
</div>
</section>
<aside className="xl:sticky xl:top-24 xl:h-fit">
<div className="rounded-3xl bg-ink p-6 text-ink-foreground shadow-lift">
<p className="text-xs uppercase tracking-wide opacity-70">Subtotal</p>
<p className="text-3xl font-bold text-brand">{formatoMoneda(subtotal)} MXN</p>
<dl className="mt-4 space-y-2 text-sm">
<div className="flex justify-between">
<dt className="opacity-70">Impuestos y tasas</dt>
<dd className="font-semibold">{formatoMoneda(impuestos)}</dd>
</div>
<div className="flex justify-between border-t border-white/15 pt-2">
<dt className="font-bold uppercase tracking-wide">Total</dt>
<dd className="text-lg font-bold">{formatoMoneda(total)}</dd>
</div>
</dl>
<button
type="button"
onClick={confirmarPago}
disabled={pagoConfirmado || (metodoPago === "sucursal" && !notificado)}
className="mt-6 w-full rounded-2xl bg-brand px-4 py-3.5 text-sm font-bold text-brand-foreground transition-all hover:-translate-y-0.5 active:scale-[0.99] disabled:opacity-50"
>
{pagoConfirmado ? "Pago confirmado" : "Confirmar pago"}
</button>
</div>
</aside>
</div>
);
}

View File

@ -0,0 +1,86 @@
import { PartyPopper, Sparkles } from "lucide-react";
import { nombreCertificado, type Certificado, type PasajeroDatos } from "@/lib/reserva-flujo";
export function Paso4Certificados({
pasajeros,
certificados,
setCertificado,
finalizar,
finalizado,
}: {
pasajeros: PasajeroDatos[];
certificados: Certificado[];
setCertificado: (i: number, parcial: Partial<Certificado>) => void;
finalizar: () => void;
finalizado: boolean;
}) {
return (
<div className="space-y-6">
<section className="rounded-3xl bg-ink p-6 text-center text-ink-foreground shadow-lift animate-in fade-in-0 zoom-in-95">
<PartyPopper className="mx-auto size-8 text-brand" />
<h2 className="mt-2 text-2xl font-bold">¡Lugares Confirmados!</h2>
<p className="mt-1 text-sm opacity-80">
Personaliza los certificados de vuelo de cada pasajero antes de emitir los pases.
</p>
</section>
<section className="rounded-3xl bg-card p-5 shadow-soft">
<h3 className="flex items-center gap-2 text-base font-bold">
<Sparkles className="size-4 text-secondary" /> Certificados de vuelo
</h3>
<p className="text-sm text-muted-foreground">
Si dejas el nombre vacío usaremos el registrado en el paso 2.
</p>
<div className="mt-4 space-y-4">
{pasajeros.map((p, i) => {
const cert = certificados[i];
const fallback = nombreCertificado(undefined, p, i);
return (
<div key={i} className="rounded-3xl border border-border p-4 transition-shadow hover:shadow-soft">
<p className="mb-3 text-xs font-bold uppercase tracking-wide text-primary">Pasajero {i + 1}</p>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs text-muted-foreground">
¿Cómo aparecerá el nombre en el certificado de vuelo?
</label>
<input
value={cert?.nombre ?? ""}
onChange={(e) => setCertificado(i, { nombre: e.target.value })}
placeholder={fallback}
className="w-full rounded-2xl border border-border bg-card px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="mb-1.5 block text-xs text-muted-foreground">
Frase adicional (opcional)
</label>
<input
value={cert?.frase ?? ""}
onChange={(e) => setCertificado(i, { frase: e.target.value })}
placeholder="Ej. 'Para la mejor persona'"
className="w-full rounded-2xl border border-border bg-card px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<p className="mt-3 rounded-2xl bg-accent/60 px-4 py-2.5 text-xs text-foreground">
Vista previa: <span className="font-bold">{nombreCertificado(cert, p, i)}</span>
{cert?.frase?.trim() ? ` — “${cert.frase.trim()}”` : ""}
</p>
</div>
);
})}
</div>
<button
type="button"
onClick={finalizar}
disabled={finalizado}
className="mt-6 w-full rounded-2xl bg-primary px-4 py-3.5 text-sm font-bold text-primary-foreground transition-all hover:-translate-y-0.5 hover:shadow-lift active:scale-[0.99] disabled:opacity-50"
>
{finalizado ? "Pases emitidos" : "Finalizar Reservación y Emitir Pases"}
</button>
</section>
</div>
);
}

View File

@ -0,0 +1,51 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium cursor-pointer transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -0,0 +1,115 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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 sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@ -0,0 +1,49 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
),
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -0,0 +1,32 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md 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",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,101 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
),
);
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
);
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@ -0,0 +1,49 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,177 @@
"use client";
import * as React from "react";
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("relative flex flex-col gap-4 md:flex-row", defaultClassNames.months),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"h-(--cell-size) w-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"h-(--cell-size) w-(--cell-size) select-none p-0 aria-disabled:opacity-50",
defaultClassNames.button_next,
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption,
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
defaultClassNames.dropdown_root,
),
dropdown: cn("bg-popover absolute inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
defaultClassNames.weekday,
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn("w-(--cell-size) select-none", defaultClassNames.week_number_header),
week_number: cn(
"text-muted-foreground select-none text-[0.8rem]",
defaultClassNames.week_number,
),
day: cn(
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
defaultClassNames.day,
),
range_start: cn("bg-accent rounded-l-md", defaultClassNames.range_start),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside,
),
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />;
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
}
if (orientation === "right") {
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
}
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers["focused"]) ref.current?.focus();
}, [modifiers]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers["selected"] &&
!modifiers["range_start"] &&
!modifiers["range_end"] &&
!modifiers["range_middle"]
}
data-range-start={modifiers["range_start"]}
data-range-end={modifiers["range_end"]}
data-range-middle={modifiers["range_middle"]}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-(--cell-size) flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View File

@ -0,0 +1,55 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
{...props}
/>
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
),
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -0,0 +1,240 @@
import * as React from "react";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
});
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
},
);
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = "CarouselNext";
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

331
src/components/ui/chart.tsx Normal file
View File

@ -0,0 +1,331 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@ -0,0 +1,26 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("grid place-content-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@ -0,0 +1,11 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
);
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@ -0,0 +1,186 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 origin-(--radix-context-menu-content-transform-origin)",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 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 origin-(--radix-context-menu-content-transform-origin)",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex 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">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex 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">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
);
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@ -0,0 +1,104 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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 sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background cursor-pointer transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@ -0,0 +1,98 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@ -0,0 +1,187 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 origin-(--radix-dropdown-menu-content-transform-origin)",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 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 origin-(--radix-dropdown-menu-content-transform-origin)",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors 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">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors 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">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

171
src/components/ui/form.tsx Normal file
View File

@ -0,0 +1,171 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
if (!itemContext) {
throw new Error("useFormField should be used within <FormItem>");
}
const fieldState = getFieldState(fieldContext.name, formState);
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue | null>(null);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : children;
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@ -0,0 +1,27 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 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 origin-(--radix-hover-card-content-transform-origin)",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@ -0,0 +1,73 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Minus } from "lucide-react";
import { cn } from "@/lib/utils";
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
));
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
));
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] ?? {
char: null,
hasFakeCaret: false,
isActive: false,
};
return (
<div
ref={ref}
className={cn(
"relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-1 ring-ring",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Minus />
</div>
));
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors 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-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };

View File

@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
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<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@ -0,0 +1,228 @@
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
function MenubarMenu({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu {...props} />;
}
function MenubarGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group {...props} />;
}
function MenubarPortal({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal {...props} />;
}
function MenubarRadioGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return <MenubarPrimitive.RadioGroup {...props} />;
}
function MenubarSub({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm",
className,
)}
{...props}
/>
));
Menubar.displayName = MenubarPrimitive.Root.displayName;
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className,
)}
{...props}
/>
));
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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 origin-(--radix-menubar-content-transform-origin)",
className,
)}
{...props}
/>
));
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in 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 origin-(--radix-menubar-content-transform-origin)",
className,
)}
{...props}
/>
</MenubarPrimitive.Portal>
));
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className,
)}
{...props}
/>
));
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex 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">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
));
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName;
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex 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">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-4 w-4 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
));
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName;
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
{...props}
/>
);
};
MenubarShortcut.displayname = "MenubarShortcut";
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
};

View File

@ -0,0 +1,120 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const NavigationMenu = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
>(({ 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<
React.ElementRef<typeof NavigationMenuPrimitive.List>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
>(({ 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 cursor-pointer 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 disabled:cursor-not-allowed data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent",
);
const NavigationMenuTrigger = React.forwardRef<
React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<NavigationMenuPrimitive.Trigger
ref={ref}
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
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<
React.ElementRef<typeof NavigationMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
>(({ 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<
React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
>(({ 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<
React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
>(({ 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

@ -0,0 +1,98 @@
import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import { ButtonProps, buttonVariants } from "@/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul ref={ref} className={cn("flex flex-row items-center gap-1", className)} {...props} />
),
);
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
({ className, ...props }, ref) => <li ref={ref} className={cn("", className)} {...props} />,
);
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">;
const PaginationLink = ({ className, isActive, size = "icon", ...props }: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@ -0,0 +1,31 @@
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 PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ 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 origin-(--radix-popover-content-transform-origin)",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@ -0,0 +1,25 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-2 w-full overflow-hidden rounded-full bg-primary/20", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@ -0,0 +1,36 @@
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<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow cursor-pointer focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-3.5 w-3.5 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@ -0,0 +1,37 @@
import { GripVertical } from "lucide-react";
import { Group, Panel, Separator } from "react-resizable-panels";
import { cn } from "@/lib/utils";
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof Group>) => (
<Group
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
{...props}
/>
);
const ResizablePanel = Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof Separator> & {
withHandle?: boolean;
}) => (
<Separator
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</Separator>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@ -0,0 +1,44 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@ -0,0 +1,152 @@
"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<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background cursor-pointer data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring 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<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ 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<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ 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<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-y-auto overflow-x-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 origin-(--radix-select-content-transform-origin)",
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<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ 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-2 pr-8 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 right-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<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ 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

@ -0,0 +1,24 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ 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;
export { Separator };

122
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,122 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background cursor-pointer transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@ -0,0 +1,744 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
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, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
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";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(
(
{
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
},
ref,
) => {
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: boolean | ((value: boolean) => boolean)) => {
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: KeyboardEvent) => {
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<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
},
);
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(
(
{
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
},
ref,
) => {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className,
)}
ref={ref}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
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
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
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)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(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"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
},
);
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<
React.ElementRef<typeof Button>,
React.ComponentProps<typeof Button>
>(({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
});
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[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 group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
},
);
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(
({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
{...props}
/>
);
},
);
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<
React.ElementRef<typeof Input>,
React.ComponentProps<typeof Input>
>(({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className,
)}
{...props}
/>
);
});
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
},
);
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
},
);
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentProps<typeof Separator>
>(({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
});
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
},
);
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
},
);
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
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 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & { asChild?: boolean }
>(({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="group-action"
className={cn(
"absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring cursor-pointer transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarGroupAction.displayName = "SidebarGroupAction";
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
),
);
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
),
);
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
({ className, ...props }, ref) => (
<li
ref={ref}
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
),
);
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring cursor-pointer transition-[width,height,padding] 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 disabled:cursor-not-allowed group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
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",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(
(
{
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
},
ref,
) => {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
ref={ref}
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>
);
},
);
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring cursor-pointer transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSkeleton = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
showIcon?: boolean;
}
>(({ className, showIcon = false, ...props }, ref) => {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
ref={ref}
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,
} as React.CSSProperties
}
/>
</div>
);
});
SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
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}
/>
),
);
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(
({ ...props }, ref) => <li ref={ref} {...props} />,
);
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
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 outline-none ring-sidebar-ring cursor-pointer 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 disabled:cursor-not-allowed aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
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,7 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-primary/10", className)} {...props} />;
}
export { Skeleton };

View File

@ -0,0 +1,23 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@ -0,0 +1,23 @@
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@ -0,0 +1,27 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@ -0,0 +1,94 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
),
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
));
TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View File

@ -0,0 +1,53 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background cursor-pointer transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<HTMLTextAreaElement, React.ComponentProps<"textarea">>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };

View File

@ -0,0 +1,57 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
size: "default",
variant: "default",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };

View File

@ -0,0 +1,42 @@
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium cursor-pointer transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
));
Toggle.displayName = TogglePrimitive.Root.displayName;
export { Toggle, toggleVariants };

View File

@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 origin-(--radix-tooltip-content-transform-origin)",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

19
src/hooks/use-mobile.tsx Normal file
View File

@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(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;
}

188
src/lib/catalogo.ts Normal file
View File

@ -0,0 +1,188 @@
// Catálogo de experiencias de vuelo en globo aerostático (Sky Balloons México).
import expTotal from "@/assets/exp-total.jpg";
import expOriginal from "@/assets/exp-original.jpg";
import expZero from "@/assets/exp-zero.jpg";
export type Vuelo = {
id: string;
nombre: string;
tipo: "Compartido" | "Privado";
zona: string;
transporte: "Con transporte desde CDMX" | "Sin transporte (llegada por cuenta propia)";
salida: string;
duracion: string;
precio: number; // por persona
precioAnterior: number;
descuento: number; // %
calificacion: number;
reseñas: number;
destacado?: string;
incluye: string[];
imagen: string;
};
export type Extra = {
id: string;
nombre: string;
categoria: "Souvenir" | "Snack" | "Transporte" | "Servicio";
precio: number;
};
export const TASA_IMPUESTO = 0.16; // 16% impuestos y tasas
export const TASA_COMISION = 0.1; // 10% de comisión para el socio comercial
export const FACTOR_NINO = 0.83; // tarifa de niños (4 a 12 años) sobre el precio de adulto
export const ZONAS_VUELO = [
"Teotihuacán, Edo. de México",
"Tequisquiapan, Querétaro",
"Valle de Bravo, Edo. de México",
];
export const VUELOS: Vuelo[] = [
{
id: "SB-TOTAL",
nombre: "Vuelo en Globo Total",
tipo: "Compartido",
zona: "Teotihuacán, Edo. de México",
transporte: "Con transporte desde CDMX",
salida: "5:30 AM",
duracion: "60 min de vuelo",
precio: 2299,
precioAnterior: 2600,
descuento: 10,
calificacion: 4.3,
reseñas: 91,
destacado: "Más reservado",
incluye: [
"Transporte incluido",
"Café y pan a tu llegada",
"Brindis con vino espumoso",
"Seguro por pasajero",
"Certificado de vuelo",
],
imagen: expTotal,
},
{
id: "SB-ORIGINAL",
nombre: "Vuelo en Globo Original",
tipo: "Compartido",
zona: "Teotihuacán, Edo. de México",
transporte: "Sin transporte (llegada por cuenta propia)",
salida: "6:00 AM",
duracion: "45 min de vuelo",
precio: 1899,
precioAnterior: 2150,
descuento: 12,
calificacion: 4.6,
reseñas: 148,
destacado: "Mejor valorado",
incluye: [
"Café y pan a tu llegada",
"Brindis con vino espumoso",
"Seguro por pasajero",
"Fotografía grupal",
],
imagen: expOriginal,
},
{
id: "SB-ZERO",
nombre: "Vuelo en Globo Zero",
tipo: "Compartido",
zona: "Tequisquiapan, Querétaro",
transporte: "Sin transporte (llegada por cuenta propia)",
salida: "6:15 AM",
duracion: "40 min de vuelo",
precio: 1599,
precioAnterior: 1750,
descuento: 9,
calificacion: 4.1,
reseñas: 63,
incluye: ["Seguro por pasajero", "Café de bienvenida", "Certificado de vuelo"],
imagen: expZero,
},
{
id: "SB-PRIVADO",
nombre: "Vuelo en Globo Privado",
tipo: "Privado",
zona: "Teotihuacán, Edo. de México",
transporte: "Con transporte desde CDMX",
salida: "5:45 AM",
duracion: "60 min de vuelo",
precio: 8990,
precioAnterior: 9990,
descuento: 10,
calificacion: 4.9,
reseñas: 37,
destacado: "Experiencia premium",
incluye: [
"Globo exclusivo para tu grupo",
"Transporte incluido",
"Desayuno buffet",
"Brindis con vino espumoso",
"Seguro por pasajero",
],
imagen: expTotal,
},
{
id: "SB-SUNSET",
nombre: "Vuelo en Globo Valle",
tipo: "Compartido",
zona: "Valle de Bravo, Edo. de México",
transporte: "Sin transporte (llegada por cuenta propia)",
salida: "6:30 AM",
duracion: "50 min de vuelo",
precio: 2499,
precioAnterior: 2790,
descuento: 10,
calificacion: 4.5,
reseñas: 74,
incluye: ["Café y pan a tu llegada", "Brindis con vino espumoso", "Seguro por pasajero"],
imagen: expZero,
},
{
id: "SB-FAMILIAR",
nombre: "Vuelo en Globo Familiar",
tipo: "Compartido",
zona: "Teotihuacán, Edo. de México",
transporte: "Con transporte desde CDMX",
salida: "5:30 AM",
duracion: "45 min de vuelo",
precio: 2099,
precioAnterior: 2350,
descuento: 11,
calificacion: 4.4,
reseñas: 112,
destacado: "Ideal para niños",
incluye: [
"Transporte incluido",
"Tarifa especial para niños",
"Café y pan a tu llegada",
"Seguro por pasajero",
],
imagen: expOriginal,
},
];
export const EXTRAS: Extra[] = [
{ id: "ex-souv-1", nombre: "Kit de souvenirs Sky Balloons", categoria: "Souvenir", precio: 420 },
{ id: "ex-souv-2", nombre: "Álbum fotográfico del vuelo", categoria: "Souvenir", precio: 260 },
{ id: "ex-snack-1", nombre: "Desayuno buffet completo", categoria: "Snack", precio: 320 },
{ id: "ex-snack-2", nombre: "Brindis premium ilimitado", categoria: "Snack", precio: 340 },
{ id: "ex-trans-1", nombre: "Transporte privado desde CDMX", categoria: "Transporte", precio: 890 },
{ id: "ex-trans-2", nombre: "Traslado compartido desde CDMX", categoria: "Transporte", precio: 350 },
{ id: "ex-serv-1", nombre: "Video 4K del vuelo con dron", categoria: "Servicio", precio: 640 },
{ id: "ex-serv-2", nombre: "Cobertura de asistencia extendida", categoria: "Servicio", precio: 210 },
];
export const CATEGORIAS_SOCIO = [
"Agencia de viajes",
"Operador turístico",
"Corporativo",
"Freelance / Promotor",
];
export const formatoMoneda = (valor: number) =>
new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN", maximumFractionDigits: 0 }).format(
valor,
);

View File

@ -0,0 +1,61 @@
// Borrador del cotizador persistido en localStorage y compartido entre vistas.
import { useEffect, useState } from "react";
export const DRAFT_KEY = "skyballoons_draft_reservation";
const EVENTO = "skyballoons-draft-change";
export type CotizadorDraft = {
zona: string;
transporte: string;
fecha: string;
fechaPorConfirmar: boolean;
adultos: number;
ninos: number;
};
export const draftInicial: CotizadorDraft = {
zona: "",
transporte: "",
fecha: "",
fechaPorConfirmar: false,
adultos: 2,
ninos: 0,
};
export function leerDraft(): CotizadorDraft {
if (typeof window === "undefined") return draftInicial;
try {
const raw = window.localStorage.getItem(DRAFT_KEY);
return raw ? { ...draftInicial, ...(JSON.parse(raw) as Partial<CotizadorDraft>) } : draftInicial;
} catch {
return draftInicial;
}
}
export function guardarDraft(parcial: Partial<CotizadorDraft>) {
if (typeof window === "undefined") return;
const siguiente = { ...leerDraft(), ...parcial };
window.localStorage.setItem(DRAFT_KEY, JSON.stringify(siguiente));
window.dispatchEvent(new CustomEvent(EVENTO));
return siguiente;
}
/** Lee el borrador de forma reactiva y segura para SSR. */
export function useDraft() {
const [draft, setDraft] = useState<CotizadorDraft>(draftInicial);
const [listo, setListo] = useState(false);
useEffect(() => {
const sync = () => setDraft(leerDraft());
sync();
setListo(true);
window.addEventListener(EVENTO, sync);
window.addEventListener("storage", sync);
return () => {
window.removeEventListener(EVENTO, sync);
window.removeEventListener("storage", sync);
};
}, []);
return { draft, listo };
}

81
src/lib/error-capture.ts Normal file
View File

@ -0,0 +1,81 @@
// Captures the original Error out-of-band so server.ts can recover the stack
// when h3 has already swallowed the throw into a generic 500 Response.
let lastCapturedError: { error: unknown; at: number } | undefined;
const TTL_MS = 5_000;
function record(error: unknown) {
lastCapturedError = { error, at: Date.now() };
}
// h3's HTTPError serializes to {"status":500,"unhandled":true,"message":"HTTPError"} —
// no stack, no cause — so a plain console.error(error) reaches the log pipeline with
// the failure detail stripped. Expand Error-like args into a string that keeps the
// message, stack, and the full cause chain.
const CAUSE_DEPTH_LIMIT = 5;
const DESCRIPTION_LENGTH_LIMIT = 8_000;
export function describeError(error: unknown): string {
const parts: string[] = [];
let current: unknown = error;
for (let depth = 0; depth < CAUSE_DEPTH_LIMIT && current != null; depth++) {
if (!(current instanceof Error)) {
parts.push(typeof current === "string" ? current : safeStringify(current));
break;
}
const label = depth === 0 ? "" : "caused by: ";
const status = describeStatus(current);
parts.push(`${label}${current.stack ?? `${current.name}: ${current.message}`}${status}`);
current = current.cause;
}
return parts.join("\n").slice(0, DESCRIPTION_LENGTH_LIMIT);
}
function describeStatus(error: Error): string {
const { status, statusCode } = error as { status?: unknown; statusCode?: unknown };
const value = status ?? statusCode;
return typeof value === "number" ? ` (status ${value})` : "";
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? String(value);
} catch {
return String(value);
}
}
function isErrorLike(value: unknown): value is Error {
return value instanceof Error;
}
// Wrap console.error so errors logged by any layer — including h3's internal
// unhandled-error logging, which this file cannot hook directly — are both
// recorded for consumeLastCapturedError and expanded before serialization.
const originalConsoleError = console.error.bind(console);
console.error = (...args: unknown[]) => {
const expanded = args.map((arg) => {
if (!isErrorLike(arg)) return arg;
record(arg);
return describeError(arg);
});
originalConsoleError(...expanded);
};
if (typeof globalThis.addEventListener === "function") {
globalThis.addEventListener("error", (event) => record((event as ErrorEvent).error ?? event));
globalThis.addEventListener("unhandledrejection", (event) =>
record((event as PromiseRejectionEvent).reason),
);
}
export function consumeLastCapturedError(): unknown {
if (!lastCapturedError) return undefined;
if (Date.now() - lastCapturedError.at > TTL_MS) {
lastCapturedError = undefined;
return undefined;
}
const { error } = lastCapturedError;
lastCapturedError = undefined;
return error;
}

30
src/lib/error-page.ts Normal file
View File

@ -0,0 +1,30 @@
export function renderErrorPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>This page didn't load</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body { font: 15px/1.5 system-ui, -apple-system, sans-serif; background: #fafafa; color: #111; display: grid; place-items: center; min-height: 100vh; margin: 0; padding: 1.5rem; }
.card { max-width: 28rem; width: 100%; text-align: center; padding: 2rem; }
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
p { color: #4b5563; margin: 0 0 1.5rem; }
.actions { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
a, button { padding: 0.5rem 1rem; border-radius: 0.375rem; font: inherit; cursor: pointer; text-decoration: none; border: 1px solid transparent; }
.primary { background: #111; color: #fff; }
.secondary { background: #fff; color: #111; border-color: #d1d5db; }
</style>
</head>
<body>
<div class="card">
<h1>This page didn't load</h1>
<p>Something went wrong on our end. You can try refreshing or head back home.</p>
<div class="actions">
<button class="primary" onclick="location.reload()">Try again</button>
<a class="secondary" href="/">Go home</a>
</div>
</div>
</body>
</html>`;
}

117
src/lib/extras-catalogo.ts Normal file
View File

@ -0,0 +1,117 @@
// Catálogo de opcionales y adicionales del flujo de reservación (Sky Balloons México).
export type ExtraCatalogo = {
id: string;
nombre: string;
precio: number;
unidad: string;
categoria: "Brindis" | "Gastronomía" | "Detalles" | "Tours y guías" | "Hospedaje" | "Transporte" | "Recuerdos";
};
export const EXTRAS_RESERVA: ExtraCatalogo[] = [
{ id: "vino-sky", nombre: "Botella de Vino Sky Balloons", precio: 490, unidad: "Botella", categoria: "Brindis" },
{ id: "moet-brut-375", nombre: "Brindis Champagne Moet Brut Imperial 375 ml", precio: 1120, unidad: "Pieza", categoria: "Brindis" },
{ id: "moet-brut-750", nombre: "Brindis Champagne Moet Brut Imperial 750 ml", precio: 2050, unidad: "Pieza", categoria: "Brindis" },
{ id: "moet-ice-750", nombre: "Brindis Champagne Moet Ice Imperial 750 ml", precio: 2470, unidad: "Pieza", categoria: "Brindis" },
{ id: "desayuno-cielo", nombre: 'Desayuno en Restaurante "El Cielo"', precio: 175, unidad: "Servicio", categoria: "Gastronomía" },
{ id: "rosas", nombre: "Docena de Rosas", precio: 430, unidad: "Paquete", categoria: "Detalles" },
{ id: "guia-bilingue", nombre: "Guía Bilingüe para Zona Arqueológica", precio: 1700, unidad: "1 a 6 personas", categoria: "Tours y guías" },
{ id: "guia-es-en", nombre: "Guía Español/Inglés para Zona Arqueológica", precio: 1300, unidad: "1 a 6 personas", categoria: "Tours y guías" },
{ id: "villa-campanas", nombre: "Hospedaje Villa Las Campanas (Doble) 3", precio: 1100, unidad: "Pareja", categoria: "Hospedaje" },
{ id: "oviedo-cuadruple", nombre: "Hotel Oviedo - Club Campestre - Hab. Cuádruple 3", precio: 2100, unidad: "1 a 4 personas", categoria: "Hospedaje" },
{ id: "oviedo-doble", nombre: "Hotel Oviedo - Club Campestre - Hab. Doble 3", precio: 1500, unidad: "1 a 3 personas", categoria: "Hospedaje" },
{ id: "oviedo-sencilla", nombre: "Hotel Oviedo - Club Campestre - Hab. Sencilla 3", precio: 1500, unidad: "1 a 3 personas", categoria: "Hospedaje" },
{ id: "quinto-sol-doble", nombre: "Hotel Quinto Sol Hab. Doble 3", precio: 1900, unidad: "Pareja", categoria: "Hospedaje" },
{ id: "spa-villas-doble", nombre: "Hotel SPA Villas Teotihuacan Hab. Doble 3", precio: 1500, unidad: "Pareja", categoria: "Hospedaje" },
{ id: "kit-cumple", nombre: "Kit Cumpleañero", precio: 500, unidad: "Paquete", categoria: "Detalles" },
{ id: "manta-aniversario", nombre: "Manta Feliz Aniversario", precio: 150, unidad: "Servicio", categoria: "Detalles" },
{ id: "manta-cumple", nombre: "Manta Feliz Cumpleaños", precio: 150, unidad: "Servicio", categoria: "Detalles" },
{ id: "manta-personalizada", nombre: "Manta Personalizada", precio: 600, unidad: "Pieza", categoria: "Detalles" },
{ id: "manta-novia", nombre: "Manta ¿Quieres ser mi novia?", precio: 150, unidad: "Servicio", categoria: "Detalles" },
{ id: "manta-te-amo", nombre: "Manta Te Amo", precio: 150, unidad: "Servicio", categoria: "Detalles" },
{ id: "manta-casar", nombre: "Manta ¿Te quieres casar conmigo?", precio: 150, unidad: "Servicio", categoria: "Detalles" },
{ id: "sesion-foto", nombre: "Sesión Fotográfica Sky", precio: 1500, unidad: "Servicio", categoria: "Recuerdos" },
{ id: "tour-bici", nombre: "Tour en Bicicleta | Puebleando 2 Hrs 1/2", precio: 350, unidad: "Adulto", categoria: "Tours y guías" },
{ id: "cuatrimoto", nombre: "Tour en Cuatrimoto Individual", precio: 1000, unidad: "Persona", categoria: "Tours y guías" },
{ id: "transporte-privado", nombre: "Transporte Privado (1-4 personas)", precio: 1950, unidad: "1 a 4 personas", categoria: "Transporte" },
{ id: "transporte-redondo", nombre: "Transporte Redondo", precio: 490, unidad: "Persona", categoria: "Transporte" },
{ id: "dron-horizontal", nombre: "Video de Dron Horizontal", precio: 990, unidad: "Servicio", categoria: "Recuerdos" },
{ id: "reel-360", nombre: "Video Reel Vertical 360°", precio: 990, unidad: "Servicio", categoria: "Recuerdos" },
];
export const CATEGORIAS_EXTRAS = [
"Todos",
"Brindis",
"Gastronomía",
"Detalles",
"Tours y guías",
"Hospedaje",
"Transporte",
"Recuerdos",
] as const;
export const VUELO_INCLUYE = [
"Experiencia compartida",
"Transporte Redondo desde CDMX",
"Coffee break",
"Brindis con el Capitán",
"Diploma personalizado",
"Desayuno en Restaurante",
"Acceso a Pirámides",
"Visita Taller de Obsidiana",
"Seguro de aviación",
];
export const LUGARES_CITA = [
"Hotel Hilton Mexico City Reforma",
"Ángel de la Independencia CDMX",
"Sky Balloons México Teotihuacán",
"Hotel Quinto Sol",
"Barceló México Reforma",
"Hotel NH Mexico City Centro Histórico",
"Camino Real Polanco",
"Hotel Exe Suites San Marino",
"Fiesta Americana Reforma",
"InterContinental Presidente Polanco",
"Four Seasons Hotel Mexico City",
"Habita Hotel Polanco",
"The Paragon Hotel Mexico City Santa Fe",
"Fiesta Americana Mexico Toreo",
"Villas Teotihuacan Hotel SPA",
"Four Points by Sheraton",
"Hotel Parque México Boutique",
"Block Suites",
"Hilton Mexico City Santa Fe",
"Globos Aerostáticos Mexico",
"Hotel Benedicta",
"Zócalo Central Hotel",
"Auditorio Nacional",
"Hotel Royal Pedregal",
"Hotel Canadá",
"Camino Real Aeropuerto",
"Hotel MX Centro",
"Hotel MX Condesa",
"Selina Mexico City",
"Hotel MX Roma",
"Umbral Curio Collection",
"Hotel MX Garibaldi",
"Hotel MX Lagunilla",
"Hotel Bristol",
"City Express CDMX La Villa",
"City Express La Raza",
"Fiesta Inn Fórum Buenavista",
"Hotel Sevilla",
"Holiday Inn Express",
"Museo Soumaya",
"Museo Frida Kahlo",
"Fuente de los Coyotes",
"City Express Plus Patio Universidad",
"Hotel BelAir Unique WTC",
"Fiesta Inn Insurgentes Viaducto",
"Fiesta Americana Mexico Satelite",
"Hotel Ibis México Perinorte",
"Fuente de la Diana Cazadora",
"Letras Monumentales Cuautitlán",
"Hotel Castropol",
"Hotel-Posada Jatziri",
"Club Campestre Teotihuacán",
];

View File

@ -0,0 +1,58 @@
type LovableErrorOptions = {
mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
handled?: boolean;
severity?: "error" | "warning" | "info";
};
type LovableEvents = {
captureException?: (
error: unknown,
context?: Record<string, unknown>,
options?: LovableErrorOptions,
) => void;
};
declare global {
interface Window {
__lovableEvents?: LovableEvents;
__lovableReportRuntimeError?: (payload: {
message: string;
stack?: string;
filename?: string;
}) => void;
}
}
export function reportLovableError(error: unknown, context: Record<string, unknown> = {}) {
if (typeof window === "undefined") return;
window.__lovableEvents?.captureException?.(
error,
{
source: "react_error_boundary",
route: window.location.pathname,
...context,
},
{
mechanism: "react_error_boundary",
handled: false,
severity: "error",
},
);
// Prod React does not rethrow boundary-caught errors to window.onerror, so the
// editor's telemetry never sees them. Forward to lovable.js's reporting hook,
// which is present only inside the editor preview.
// Loaders and server fns commonly throw a raw Response; String(it) is the
// opaque "[object Response]", so pull out the status and URL instead.
const message =
error instanceof Response
? `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`
: error instanceof Error
? error.message
: String(error);
const stack = error instanceof Error ? error.stack : undefined;
window.__lovableReportRuntimeError?.({
message,
...(stack !== undefined && { stack }),
filename: window.location.pathname,
});
}

24
src/lib/notificaciones.ts Normal file
View File

@ -0,0 +1,24 @@
// Notificaciones push simuladas: usa la API Notification cuando hay permiso,
// y siempre muestra un toast dentro de la app como respaldo.
import { toast } from "sonner";
export async function pedirPermisoNotificaciones(): Promise<NotificationPermission | "unsupported"> {
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
if (Notification.permission === "granted") return "granted";
try {
return await Notification.requestPermission();
} catch {
return "denied";
}
}
export function notificar(titulo: string, cuerpo: string) {
toast.success(titulo, { description: cuerpo });
if (typeof window !== "undefined" && "Notification" in window && Notification.permission === "granted") {
try {
new Notification(titulo, { body: cuerpo, icon: "/favicon.ico" });
} catch {
/* silencioso: algunos navegadores exigen Service Worker */
}
}
}

44
src/lib/pwa-install.ts Normal file
View File

@ -0,0 +1,44 @@
// Detección y disparo del prompt de instalación (PWA).
import { useEffect, useState } from "react";
type PromptEvent = Event & { prompt: () => Promise<void>; userChoice?: Promise<unknown> };
let guardado: PromptEvent | null = null;
export function useInstalarPWA() {
const [disponible, setDisponible] = useState(false);
const [instalada, setInstalada] = useState(false);
useEffect(() => {
setDisponible(Boolean(guardado));
setInstalada(window.matchMedia("(display-mode: standalone)").matches);
const onPrompt = (e: Event) => {
e.preventDefault();
guardado = e as PromptEvent;
setDisponible(true);
};
const onInstalada = () => {
guardado = null;
setDisponible(false);
setInstalada(true);
};
window.addEventListener("beforeinstallprompt", onPrompt);
window.addEventListener("appinstalled", onInstalada);
return () => {
window.removeEventListener("beforeinstallprompt", onPrompt);
window.removeEventListener("appinstalled", onInstalada);
};
}, []);
const instalar = async () => {
if (!guardado) return false;
await guardado.prompt();
guardado = null;
setDisponible(false);
return true;
};
return { disponible, instalada, instalar };
}

88
src/lib/reserva-flujo.ts Normal file
View File

@ -0,0 +1,88 @@
// Lógica de negocio del wizard de reservación (sin UI).
import { EXTRAS_RESERVA } from "./extras-catalogo";
import { TASA_COMISION, TASA_IMPUESTO } from "./catalogo";
export const PRECIO_ADULTO = 2720;
export const PRECIO_NINO = 2270;
export const HORA_CITA = "04:30 AM";
export type PasajeroDatos = {
nombre: string;
apellidos: string;
telefono: string;
peso: string;
};
export type Certificado = { nombre: string; frase: string };
export type MetodoPago = "tarjeta" | "referencia" | "tiendas" | "sucursal";
export type ReservaEstado = {
adultos: number;
ninos: number;
extras: Record<string, number>;
fecha: string;
fechaPorConfirmar: boolean;
lugarCita: string;
idioma: "Español" | "Inglés";
pasajeros: PasajeroDatos[];
metodoPago: MetodoPago;
pagoConfirmado: boolean;
certificados: Certificado[];
};
export const estadoInicialReserva: ReservaEstado = {
adultos: 2,
ninos: 0,
extras: {},
fecha: "",
fechaPorConfirmar: false,
lugarCita: "",
idioma: "Español",
pasajeros: [],
metodoPago: "tarjeta",
pagoConfirmado: false,
certificados: [],
};
export const pasajeroVacio = (): PasajeroDatos => ({ nombre: "", apellidos: "", telefono: "", peso: "" });
export function extrasSeleccionados(extras: Record<string, number>) {
return EXTRAS_RESERVA.filter((e) => (extras[e.id] ?? 0) > 0).map((e) => ({
...e,
cantidad: extras[e.id] ?? 0,
importe: e.precio * (extras[e.id] ?? 0),
}));
}
export function calcularReserva(estado: ReservaEstado) {
const subtotalVuelos = estado.adultos * PRECIO_ADULTO + estado.ninos * PRECIO_NINO;
const subtotalExtras = extrasSeleccionados(estado.extras).reduce((a, e) => a + e.importe, 0);
const base = subtotalVuelos + subtotalExtras;
return {
subtotalVuelos,
subtotalExtras,
subtotal: base,
impuestos: base * TASA_IMPUESTO,
total: base * (1 + TASA_IMPUESTO),
comision: base * TASA_COMISION,
};
}
/** Nombre final del certificado: usa el nombre del Paso 2 como fallback. */
export function nombreCertificado(cert: Certificado | undefined, pasajero: PasajeroDatos | undefined, indice: number) {
const escrito = cert?.nombre?.trim();
if (escrito) return escrito;
const base = `${pasajero?.nombre ?? ""} ${pasajero?.apellidos ?? ""}`.trim();
return base || `Pasajero ${indice + 1}`;
}
export const totalPasajeros = (e: ReservaEstado) => e.adultos + e.ninos;
export function validarPaso2(e: ReservaEstado) {
if (!e.fechaPorConfirmar && !e.fecha) return "Selecciona la fecha de vuelo o marca “Fecha por confirmar”.";
if (!e.lugarCita) return "Selecciona el lugar de cita.";
const incompleto = e.pasajeros.some((p) => !p.nombre.trim() || !p.apellidos.trim());
if (incompleto) return "Completa nombre y apellidos de cada pasajero.";
return null;
}

135
src/lib/store.ts Normal file
View File

@ -0,0 +1,135 @@
// Estado de la app persistido en LocalStorage, con suscripción para React.
import { useEffect, useState } from "react";
import { FACTOR_NINO, TASA_COMISION, TASA_IMPUESTO, type Extra, type Vuelo } from "./catalogo";
export type Socio = {
categoria: string;
empresas: string[];
nombre: string;
razonSocial: string;
ruc: string;
contacto: string;
email: string;
telefono: string;
sitioWeb: string;
direccion: string;
notas: string;
};
export type EstadoVenta = "Comisión pendiente" | "Pagado";
export type Reservacion = {
id: string;
creadaEn: string;
vuelo: Vuelo;
pasajeros: number;
adultos?: number;
ninos?: number;
cliente?: string;
estado?: EstadoVenta;
extras: { extra: Extra; cantidad: number }[];
subtotalVuelos: number;
subtotalExtras: number;
impuestos: number;
total: number;
comision: number;
};
export type AppState = {
socio: Socio | null;
sesionActiva: boolean;
reservaciones: Reservacion[];
};
const KEY = "socios-comerciales-state-v1";
const EVENTO = "socios-state-change";
const estadoInicial: AppState = { socio: null, sesionActiva: false, reservaciones: [] };
export function leerEstado(): AppState {
if (typeof window === "undefined") return estadoInicial;
try {
const raw = window.localStorage.getItem(KEY);
return raw ? { ...estadoInicial, ...(JSON.parse(raw) as AppState) } : estadoInicial;
} catch {
return estadoInicial;
}
}
export function escribirEstado(actualizar: (prev: AppState) => AppState) {
const siguiente = actualizar(leerEstado());
window.localStorage.setItem(KEY, JSON.stringify(siguiente));
window.dispatchEvent(new CustomEvent(EVENTO));
return siguiente;
}
/** Hook de lectura reactiva del estado (seguro para SSR). */
export function useAppState(): AppState {
const [estado, setEstado] = useState<AppState>(estadoInicial);
useEffect(() => {
const sync = () => setEstado(leerEstado());
sync();
window.addEventListener(EVENTO, sync);
window.addEventListener("storage", sync);
return () => {
window.removeEventListener(EVENTO, sync);
window.removeEventListener("storage", sync);
};
}, []);
return estado;
}
/** Cálculo del desglose de una compra en tiempo real. */
export function calcularDesglose(
vuelo: Vuelo | null,
pasajeros: number,
extras: { extra: Extra; cantidad: number }[],
) {
const subtotalVuelos = vuelo ? vuelo.precio * Math.max(1, pasajeros) : 0;
const subtotalExtras = extras.reduce((acc, e) => acc + e.extra.precio * e.cantidad, 0);
const base = subtotalVuelos + subtotalExtras;
const impuestos = base * TASA_IMPUESTO;
const total = base + impuestos;
return { subtotalVuelos, subtotalExtras, impuestos, total, comision: base * TASA_COMISION };
}
export function guardarReservacion(r: Omit<Reservacion, "id" | "creadaEn">) {
const id = `RES-${Math.random().toString(36).slice(2, 7).toUpperCase()}`;
escribirEstado((prev) => ({
...prev,
reservaciones: [{ ...r, id, creadaEn: new Date().toISOString() }, ...prev.reservaciones],
}));
return id;
}
export function cerrarSesion() {
escribirEstado((prev) => ({ ...prev, sesionActiva: false }));
}
/** Desglose tipo taquilla: adultos a tarifa completa y niños (4 a 12 años) con factor. */
export function calcularDesgloseTaquilla(
vuelo: Vuelo | null,
adultos: number,
ninos: number,
extras: { extra: Extra; cantidad: number }[],
) {
const precioAdulto = vuelo?.precio ?? 0;
const precioNino = Math.round(precioAdulto * FACTOR_NINO);
const subtotalVuelos = precioAdulto * Math.max(0, adultos) + precioNino * Math.max(0, ninos);
const subtotalExtras = extras.reduce((acc, e) => acc + e.extra.precio * e.cantidad, 0);
const base = subtotalVuelos + subtotalExtras;
const impuestos = base * TASA_IMPUESTO;
return {
precioAdulto,
precioNino,
subtotalVuelos,
subtotalExtras,
impuestos,
total: base + impuestos,
comision: base * TASA_COMISION,
};
}

6
src/lib/utils.ts Normal file
View File

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

168
src/routeTree.gen.ts Normal file
View File

@ -0,0 +1,168 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ComisionesRouteImport } from './routes/comisiones'
import { Route as PerfilRouteImport } from './routes/perfil'
import { Route as ReservaRouteImport } from './routes/reserva'
import { Route as ReservacionesRouteImport } from './routes/reservaciones'
import { Route as VuelosRouteImport } from './routes/vuelos'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ComisionesRoute = ComisionesRouteImport.update({
id: '/comisiones',
path: '/comisiones',
getParentRoute: () => rootRouteImport,
} as any)
const PerfilRoute = PerfilRouteImport.update({
id: '/perfil',
path: '/perfil',
getParentRoute: () => rootRouteImport,
} as any)
const ReservaRoute = ReservaRouteImport.update({
id: '/reserva',
path: '/reserva',
getParentRoute: () => rootRouteImport,
} as any)
const ReservacionesRoute = ReservacionesRouteImport.update({
id: '/reservaciones',
path: '/reservaciones',
getParentRoute: () => rootRouteImport,
} as any)
const VuelosRoute = VuelosRouteImport.update({
id: '/vuelos',
path: '/vuelos',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/comisiones': typeof ComisionesRoute
'/perfil': typeof PerfilRoute
'/reserva': typeof ReservaRoute
'/reservaciones': typeof ReservacionesRoute
'/vuelos': typeof VuelosRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/comisiones': typeof ComisionesRoute
'/perfil': typeof PerfilRoute
'/reserva': typeof ReservaRoute
'/reservaciones': typeof ReservacionesRoute
'/vuelos': typeof VuelosRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/comisiones': typeof ComisionesRoute
'/perfil': typeof PerfilRoute
'/reserva': typeof ReservaRoute
'/reservaciones': typeof ReservacionesRoute
'/vuelos': typeof VuelosRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
'/' | '/comisiones' | '/perfil' | '/reserva' | '/reservaciones' | '/vuelos'
fileRoutesByTo: FileRoutesByTo
to:
'/' | '/comisiones' | '/perfil' | '/reserva' | '/reservaciones' | '/vuelos'
id:
| '__root__'
| '/'
| '/comisiones'
| '/perfil'
| '/reserva'
| '/reservaciones'
| '/vuelos'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ComisionesRoute: typeof ComisionesRoute
PerfilRoute: typeof PerfilRoute
ReservaRoute: typeof ReservaRoute
ReservacionesRoute: typeof ReservacionesRoute
VuelosRoute: typeof VuelosRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/comisiones': {
id: '/comisiones'
path: '/comisiones'
fullPath: '/comisiones'
preLoaderRoute: typeof ComisionesRouteImport
parentRoute: typeof rootRouteImport
}
'/perfil': {
id: '/perfil'
path: '/perfil'
fullPath: '/perfil'
preLoaderRoute: typeof PerfilRouteImport
parentRoute: typeof rootRouteImport
}
'/reserva': {
id: '/reserva'
path: '/reserva'
fullPath: '/reserva'
preLoaderRoute: typeof ReservaRouteImport
parentRoute: typeof rootRouteImport
}
'/reservaciones': {
id: '/reservaciones'
path: '/reservaciones'
fullPath: '/reservaciones'
preLoaderRoute: typeof ReservacionesRouteImport
parentRoute: typeof rootRouteImport
}
'/vuelos': {
id: '/vuelos'
path: '/vuelos'
fullPath: '/vuelos'
preLoaderRoute: typeof VuelosRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ComisionesRoute: ComisionesRoute,
PerfilRoute: PerfilRoute,
ReservaRoute: ReservaRoute,
ReservacionesRoute: ReservacionesRoute,
VuelosRoute: VuelosRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { startInstance } from './start.ts'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
config: Awaited<ReturnType<typeof startInstance.getOptions>>
}
}

16
src/router.tsx Normal file
View File

@ -0,0 +1,16 @@
import { QueryClient } from "@tanstack/react-query";
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
export const getRouter = () => {
const queryClient = new QueryClient();
const router = createRouter({
routeTree,
context: { queryClient },
scrollRestoration: true,
defaultPreloadStaleTime: 0,
});
return router;
};

21
src/routes/README.md Normal file
View File

@ -0,0 +1,21 @@
# Routes
TanStack Start uses **file-based routing**. Every `.tsx` file in this directory
defines a route. Do **not** create `src/pages/`, `src/routes/_app/index.tsx`, or
`app/layout.tsx` — those are Next.js / Remix conventions. The only root layout
is `src/routes/__root.tsx`.
## Conventions
| File | URL |
| --- | --- |
| `index.tsx` | `/` |
| `about.tsx` | `/about` |
| `users/index.tsx` | `/users` |
| `users/$id.tsx` | `/users/:id` (dynamic — bare `$`, no curly braces) |
| `posts/{-$category}.tsx` | `/posts/:category?` (optional segment) |
| `files/$.tsx` | `/files/*` (splat — read via `_splat` param, never `*`) |
| `_layout.tsx` | layout route (renders children via `<Outlet />`) |
| `__root.tsx` | app shell — wraps every page; preserve `<Outlet />` |
`routeTree.gen.ts` is auto-generated. Don't edit it by hand.

145
src/routes/__root.tsx Normal file
View File

@ -0,0 +1,145 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
Outlet,
Link,
createRootRouteWithContext,
useRouter,
HeadContent,
Scripts,
} from "@tanstack/react-router";
import { useEffect, type ReactNode } from "react";
import appCss from "../styles.css?url";
import { reportLovableError } from "../lib/lovable-error-reporting";
import { Toaster } from "@/components/ui/sonner";
function NotFoundComponent() {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="max-w-md text-center">
<h1 className="text-7xl font-bold text-foreground">404</h1>
<h2 className="mt-4 text-xl font-semibold text-foreground">Page not found</h2>
<p className="mt-2 text-sm text-muted-foreground">
The page you're looking for doesn't exist or has been moved.
</p>
<div className="mt-6">
<Link
to="/"
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Go home
</Link>
</div>
</div>
</div>
);
}
function ErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
console.error(error);
const router = useRouter();
useEffect(() => {
reportLovableError(error, { boundary: "tanstack_root_error_component" });
}, [error]);
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="max-w-md text-center">
<h1 className="text-xl font-semibold tracking-tight text-foreground">
This page didn't load
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Something went wrong on our end. You can try refreshing or head back home.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-2">
<button
onClick={() => {
router.invalidate();
reset();
}}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Try again
</button>
<a
href="/"
className="inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent"
>
Go home
</a>
</div>
</div>
</div>
);
}
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
head: () => ({
meta: [
{ charSet: "utf-8" },
{
name: "viewport",
content: "width=device-width, initial-scale=1, viewport-fit=cover",
},
{ title: "Socios Comerciales | Vuelos, Reservaciones y Comisiones" },
{
name: "description",
content:
"PWA para socios comerciales: consulta vuelos, genera reservaciones con extras y cobra tu comisión del 10%.",
},
{ property: "og:title", content: "Socios Comerciales" },
{
property: "og:description",
content: "Vuelos, reservaciones y comisiones automáticas del 10% para socios comerciales.",
},
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "theme-color", content: "#FACC15" },
{ name: "apple-mobile-web-app-capable", content: "yes" },
{ name: "apple-mobile-web-app-status-bar-style", content: "black-translucent" },
],
links: [
{
rel: "stylesheet",
href: appCss,
},
{ rel: "icon", href: "/favicon.png", type: "image/png" },
{ rel: "apple-touch-icon", href: "/icons/icon-192.png" },
{ rel: "manifest", href: "/manifest.webmanifest" },
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap",
},
],
}),
shellComponent: RootShell,
component: RootComponent,
notFoundComponent: NotFoundComponent,
errorComponent: ErrorComponent,
});
function RootShell({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}
function RootComponent() {
const { queryClient } = Route.useRouteContext();
return (
<QueryClientProvider client={queryClient}>
{/* Required: nested routes render here. Removing <Outlet /> breaks all child routes. */}
<Outlet />
<Toaster position="top-center" richColors />
</QueryClientProvider>
);
}

117
src/routes/comisiones.tsx Normal file
View File

@ -0,0 +1,117 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { TrendingUp, Wallet } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { TASA_COMISION, formatoMoneda } from "@/lib/catalogo";
import { useAppState } from "@/lib/store";
import { BadgeEstado, descripcionPax, fechaCorta } from "@/routes/reservaciones";
export const Route = createFileRoute("/comisiones")({
head: () => ({
meta: [
{ title: "Comisiones del 10% | Socios Comerciales" },
{
name: "description",
content:
"Dashboard de comisiones: ventas mapeadas, total cobrado en vuelos y extras, y comisión neta del 10% calculada automáticamente.",
},
{ property: "og:title", content: "Comisiones del 10%" },
{ property: "og:description", content: "Resumen de ventas y comisión neta del socio comercial." },
],
}),
component: ComisionesPage,
});
function ComisionesPage() {
const { reservaciones } = useAppState();
const totalVuelos = reservaciones.reduce((a, r) => a + r.subtotalVuelos, 0);
const totalExtras = reservaciones.reduce((a, r) => a + r.subtotalExtras, 0);
const totalCobrado = reservaciones.reduce((a, r) => a + r.total, 0);
const comisionNeta = (totalVuelos + totalExtras) * TASA_COMISION;
return (
<AppShell title="Comisiones" subtitle="10% automático sobre el valor de tus ventas">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Tarjeta label="Ventas realizadas" valor={String(reservaciones.length)} />
<Tarjeta label="Cobrado en vuelos" valor={formatoMoneda(totalVuelos)} />
<Tarjeta label="Cobrado en extras" valor={formatoMoneda(totalExtras)} />
<Tarjeta label="Total cobrado (con impuestos)" valor={formatoMoneda(totalCobrado)} />
</div>
<section
className="mt-5 rounded-3xl p-6 text-brand-foreground shadow-lift"
style={{ background: "var(--gradient-brand)" }}
>
<p className="flex items-center gap-2 text-xs font-bold uppercase tracking-wide opacity-70">
<Wallet className="size-4" /> Comisión neta a recibir (10%)
</p>
<p className="mt-2 text-4xl font-bold">{formatoMoneda(comisionNeta)}</p>
<p className="mt-2 text-sm opacity-70">
Calculada sobre {formatoMoneda(totalVuelos + totalExtras)} de venta neta (vuelos + extras, sin
impuestos).
</p>
</section>
<section className="mt-5 rounded-3xl bg-card p-5 shadow-soft">
<h2 className="flex items-center gap-2 text-base font-bold">
<TrendingUp className="size-4" /> Mapeo de ventas
</h2>
{reservaciones.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">
Todavía no hay ventas registradas.{" "}
<Link to="/vuelos" className="font-semibold text-foreground underline">
Genera tu primera reservación
</Link>
</p>
) : (
<div className="mt-4 overflow-x-auto">
<table className="w-full min-w-[840px] text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-2 pb-3">Fecha</th>
<th className="px-2 pb-3">Folio</th>
<th className="px-2 pb-3">Cliente</th>
<th className="px-2 pb-3">Experiencia</th>
<th className="px-2 pb-3 text-right">Vuelos</th>
<th className="px-2 pb-3 text-right">Extras</th>
<th className="px-2 pb-3 text-right">Total</th>
<th className="px-2 pb-3 text-right">Comisión 10%</th>
<th className="px-2 pb-3">Estado</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{reservaciones.map((r) => (
<tr key={r.id} className="transition-colors hover:bg-muted/50">
<td className="px-2 py-3 whitespace-nowrap text-muted-foreground">{fechaCorta(r.creadaEn)}</td>
<td className="px-2 py-3 font-semibold">{r.id}</td>
<td className="px-2 py-3">{r.cliente ?? "—"}</td>
<td className="px-2 py-3">
<span className="block">{r.vuelo.nombre}</span>
<span className="text-xs text-muted-foreground">{descripcionPax(r)}</span>
</td>
<td className="px-2 py-3 text-right">{formatoMoneda(r.subtotalVuelos)}</td>
<td className="px-2 py-3 text-right">{formatoMoneda(r.subtotalExtras)}</td>
<td className="px-2 py-3 text-right">{formatoMoneda(r.total)}</td>
<td className="px-2 py-3 text-right font-bold">{formatoMoneda(r.comision)}</td>
<td className="px-2 py-3">
<BadgeEstado estado={r.estado ?? "Comisión pendiente"} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</AppShell>
);
}
function Tarjeta({ label, valor }: { label: string; valor: string }) {
return (
<div className="rounded-3xl bg-card p-5 shadow-soft">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="mt-2 text-xl font-bold">{valor}</p>
</div>
);
}

339
src/routes/index.tsx Normal file
View File

@ -0,0 +1,339 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { Lock, Mail } from "lucide-react";
import { BrandLogo } from "@/components/BrandLogo";
import { CATEGORIAS_SOCIO } from "@/lib/catalogo";
import { escribirEstado, leerEstado, type Socio } from "@/lib/store";
import { notificar } from "@/lib/notificaciones";
import { cn } from "@/lib/utils";
import hero1 from "@/assets/hero-globos-1.jpg";
import hero2 from "@/assets/hero-globos-2.jpg";
import hero3 from "@/assets/hero-globos-3.jpg";
export const Route = createFileRoute("/")({
head: () => ({
meta: [
{ title: "Acceso Socios | Sky Balloons México" },
{
name: "description",
content:
"Inicia sesión o regístrate como socio comercial de Sky Balloons México: cotiza vuelos en globo, genera reservaciones y gana el 10% de comisión.",
},
{ property: "og:title", content: "Portal de Socios Comerciales | Sky Balloons México" },
{
property: "og:description",
content: "Eleva tus ventas con Sky Balloons: vuelos en globo, reservaciones y comisiones del 10%.",
},
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary_large_image" },
],
}),
component: AuthPage,
});
const socioVacio: Socio = {
categoria: CATEGORIAS_SOCIO[0] ?? "",
empresas: [],
nombre: "",
razonSocial: "",
ruc: "",
contacto: "",
email: "",
telefono: "",
sitioWeb: "",
direccion: "",
notas: "",
};
const inputCls =
"w-full rounded-2xl border border-border bg-card px-4 py-3 text-sm outline-none transition-all placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-ring";
const SLIDES = [
{
img: hero1,
titulo: "Eleva tus ventas con Sky Balloons",
texto: "Vuelos en globo sobre las pirámides de Teotihuacán al amanecer.",
},
{
img: hero2,
titulo: "Panel exclusivo para Socios Comerciales",
texto: "Cotiza, reserva y da seguimiento a cada experiencia en segundos.",
},
{
img: hero3,
titulo: "10% de comisión en automático",
texto: "Cada reservación confirmada acredita tu comisión al instante.",
},
];
function HeroCarrusel() {
const [i, setI] = useState(0);
useEffect(() => {
const t = setInterval(() => setI((p) => (p + 1) % SLIDES.length), 6000);
return () => clearInterval(t);
}, []);
return (
<section className="relative hidden overflow-hidden lg:block">
{SLIDES.map((s, idx) => (
<img
key={s.titulo}
src={s.img}
alt={s.titulo}
width={1280}
height={1600}
className={cn(
"absolute inset-0 size-full object-cover transition-all duration-[1400ms] ease-out",
idx === i ? "scale-100 opacity-100" : "scale-105 opacity-0",
)}
/>
))}
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(180deg, oklch(0.23 0.075 265 / 0.55) 0%, oklch(0.23 0.075 265 / 0.35) 40%, oklch(0.18 0.06 265 / 0.94) 100%)",
}}
/>
<div className="relative flex h-full flex-col justify-between p-12 text-ink-foreground">
<BrandLogo invertido />
<div className="max-w-lg">
{SLIDES.map((s, idx) => (
<div
key={s.titulo}
className={cn(
"transition-all duration-700",
idx === i ? "block translate-y-0 opacity-100" : "hidden translate-y-2 opacity-0",
)}
>
<h2 className="text-4xl font-bold leading-tight tracking-tight xl:text-5xl">{s.titulo}</h2>
<p className="mt-4 text-sm text-ink-foreground/75">{s.texto}</p>
</div>
))}
<div className="mt-8 flex items-center gap-2">
{SLIDES.map((s, idx) => (
<button
key={s.titulo}
onClick={() => setI(idx)}
aria-label={`Ver diapositiva ${idx + 1}`}
className={cn(
"h-1.5 rounded-full transition-all duration-300",
idx === i ? "w-10 bg-ink-foreground" : "w-4 bg-ink-foreground/40 hover:bg-ink-foreground/70",
)}
/>
))}
</div>
</div>
<p className="text-xs text-ink-foreground/50">PWA instalable · Sky Balloons México</p>
</div>
</section>
);
}
function AuthPage() {
const navigate = useNavigate();
const [tab, setTab] = useState<"login" | "registro">("login");
const [form, setForm] = useState<Socio>(socioVacio);
const [correo, setCorreo] = useState("");
const [error, setError] = useState<string | null>(null);
const set = (k: keyof Socio, v: string | string[]) => setForm((p) => ({ ...p, [k]: v }));
const iniciarSesion = (e: React.FormEvent) => {
e.preventDefault();
const existente = leerEstado().socio;
escribirEstado((prev) => ({
...prev,
sesionActiva: true,
socio: existente ?? { ...socioVacio, nombre: correo.split("@")[0] || "Socio", email: correo },
}));
notificar("Sesión iniciada", "Bienvenido de nuevo al portal de Sky Balloons México.");
navigate({ to: "/vuelos" });
};
const registrar = (e: React.FormEvent) => {
e.preventDefault();
const requeridos: (keyof Socio)[] = [
"categoria",
"nombre",
"razonSocial",
"ruc",
"contacto",
"email",
"telefono",
"sitioWeb",
"direccion",
"notas",
];
const faltan = requeridos.filter((k) => !String(form[k]).trim());
if (faltan.length) {
setError("Todos los campos son obligatorios para dar de alta al socio comercial.");
return;
}
setError(null);
escribirEstado((prev) => ({ ...prev, socio: form, sesionActiva: true }));
notificar("Registro completado", `Socio ${form.nombre} dado de alta correctamente.`);
navigate({ to: "/vuelos" });
};
return (
<div className="grid min-h-screen font-sans lg:grid-cols-2">
<HeroCarrusel />
{/* Formularios */}
<section className="flex items-center justify-center px-4 py-10 sm:px-8 lg:px-12">
<div className="w-full max-w-xl">
<div className="mb-8 lg:hidden">
<BrandLogo />
</div>
<div className="mb-6 grid grid-cols-2 gap-1 rounded-2xl bg-muted p-1">
{(["login", "registro"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"rounded-xl px-4 py-2.5 text-sm font-semibold transition-all duration-300",
tab === t
? "bg-card text-foreground shadow-soft"
: "text-muted-foreground hover:text-foreground",
)}
>
{t === "login" ? "Iniciar sesión" : "Registro de socio"}
</button>
))}
</div>
{tab === "login" ? (
<form
onSubmit={iniciarSesion}
className="rounded-3xl bg-card p-6 shadow-soft transition-shadow duration-300 hover:shadow-lift sm:p-8"
>
<h1 className="text-2xl font-bold tracking-tight">Bienvenido de vuelta</h1>
<p className="mt-1 text-sm text-muted-foreground">
Accede con tus credenciales de socio comercial.
</p>
<div className="mt-6 space-y-4">
<label className="block">
<span className="mb-1.5 block text-sm font-medium">Usuario o email</span>
<div className="relative">
<Mail className="absolute left-4 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
required
type="text"
value={correo}
onChange={(e) => setCorreo(e.target.value)}
placeholder="socio@empresa.com"
className={`${inputCls} pl-11`}
/>
</div>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">Contraseña</span>
<div className="relative">
<Lock className="absolute left-4 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
required
type="password"
placeholder="••••••••"
className={`${inputCls} pl-11`}
/>
</div>
</label>
</div>
<button
type="submit"
className="mt-6 w-full rounded-2xl bg-primary px-4 py-3.5 text-sm font-bold text-primary-foreground transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lift active:scale-[0.99]"
>
Iniciar sesión
</button>
<button
type="button"
onClick={() => setTab("registro")}
className="mt-4 w-full text-sm text-muted-foreground transition-colors hover:text-foreground"
>
¿Nuevo socio? <span className="font-semibold text-foreground underline">Regístrate aquí</span>
</button>
</form>
) : (
<form
onSubmit={registrar}
className="rounded-3xl bg-card p-6 shadow-soft transition-shadow duration-300 hover:shadow-lift sm:p-8"
>
<h1 className="text-2xl font-bold tracking-tight">Registro de socio comercial</h1>
<p className="mt-1 text-sm text-muted-foreground">Todos los campos son obligatorios.</p>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<label className="block sm:col-span-2">
<span className="mb-1.5 block text-sm font-medium">Categoría</span>
<select
value={form.categoria}
onChange={(e) => set("categoria", e.target.value)}
className={inputCls}
>
{CATEGORIAS_SOCIO.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</label>
{(
[
["nombre", "Nombre", "Nombre del socio"],
["razonSocial", "Razón Social", "Razón social completa"],
["ruc", "RUC", "RUC / RFC fiscal"],
["contacto", "Contacto Principal", "Nombre del contacto"],
["email", "Email", "contacto@empresa.com"],
["telefono", "Teléfono", "+52 55 0000 0000"],
["sitioWeb", "Sitio Web", "https://empresa.com"],
["direccion", "Dirección", "Calle, ciudad, país"],
] as [keyof Socio, string, string][]
).map(([key, label, ph]) => (
<label key={key} className="block">
<span className="mb-1.5 block text-sm font-medium">{label}</span>
<input
value={form[key] as string}
onChange={(e) => set(key, e.target.value)}
placeholder={ph}
type={key === "email" ? "email" : "text"}
className={inputCls}
/>
</label>
))}
<label className="block sm:col-span-2">
<span className="mb-1.5 block text-sm font-medium">Notas</span>
<textarea
rows={3}
value={form.notas}
onChange={(e) => set("notas", e.target.value)}
placeholder="Acuerdos comerciales, condiciones especiales…"
className={inputCls}
/>
</label>
</div>
{error && (
<p className="mt-4 rounded-2xl bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</p>
)}
<button
type="submit"
className="mt-6 w-full rounded-2xl bg-primary px-4 py-3.5 text-sm font-bold text-primary-foreground transition-all duration-300 hover:-translate-y-0.5 hover:shadow-lift active:scale-[0.99]"
>
Crear cuenta de socio
</button>
</form>
)}
</div>
</section>
</div>
);
}

144
src/routes/perfil.tsx Normal file
View File

@ -0,0 +1,144 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Bell, Download, LogOut } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { cerrarSesion, useAppState } from "@/lib/store";
import { BrandMark } from "@/components/BrandLogo";
import { useInstalarPWA } from "@/lib/pwa-install";
import { notificar, pedirPermisoNotificaciones } from "@/lib/notificaciones";
export const Route = createFileRoute("/perfil")({
head: () => ({
meta: [
{ title: "Perfil y ajustes del socio | Socios Comerciales" },
{
name: "description",
content:
"Consulta los datos fiscales y comerciales de tu cuenta de socio, activa notificaciones e instala la app.",
},
{ property: "og:title", content: "Perfil y ajustes del socio" },
{ property: "og:description", content: "Datos del socio comercial, notificaciones e instalación PWA." },
],
}),
component: PerfilPage,
});
function PerfilPage() {
const { socio } = useAppState();
const navigate = useNavigate();
const { disponible, instalada, instalar } = useInstalarPWA();
const campos: [string, string][] = socio
? [
["Categoría", socio.categoria],
["Empresas asignadas", socio.empresas.join(", ") || "—"],
["Nombre", socio.nombre],
["Razón Social", socio.razonSocial],
["RUC", socio.ruc],
["Contacto Principal", socio.contacto],
["Email", socio.email],
["Teléfono", socio.telefono],
["Sitio Web", socio.sitioWeb],
["Dirección", socio.direccion],
["Notas", socio.notas],
]
: [];
return (
<AppShell title="Perfil y ajustes" subtitle="Datos del socio comercial">
<section className="rounded-3xl bg-card p-5 shadow-soft">
<div className="flex items-center gap-4">
<span className="grid size-14 place-items-center rounded-3xl bg-brand text-xl font-bold text-brand-foreground">
{(socio?.nombre ?? "S").charAt(0).toUpperCase()}
</span>
<div className="min-w-0">
<p className="truncate text-lg font-bold">{socio?.nombre ?? "Socio"}</p>
<p className="truncate text-sm text-muted-foreground">{socio?.email ?? "Sin sesión de registro"}</p>
</div>
</div>
<dl className="mt-6 grid gap-3 sm:grid-cols-2">
{campos.map(([k, v]) => (
<div key={k} className="rounded-2xl bg-muted px-4 py-3">
<dt className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{k}</dt>
<dd className="mt-1 break-words text-sm font-medium">{v || "—"}</dd>
</div>
))}
{campos.length === 0 && (
<p className="text-sm text-muted-foreground">
Completa el registro de socio para ver aquí tus datos.
</p>
)}
</dl>
</section>
<section className="mt-5 grid gap-3 sm:grid-cols-2">
<button
onClick={async () => {
const estado = await pedirPermisoNotificaciones();
notificar(
"Notificaciones",
estado === "granted" ? "Activadas correctamente." : "Se mostrarán dentro de la app.",
);
}}
className="flex items-center gap-3 rounded-3xl bg-card p-5 text-left shadow-soft"
>
<Bell className="size-5 text-brand" />
<span>
<span className="block text-sm font-semibold">Activar notificaciones</span>
<span className="block text-xs text-muted-foreground">
Reservaciones confirmadas y comisiones acreditadas
</span>
</span>
</button>
<div className="rounded-3xl bg-ink p-5 text-ink-foreground shadow-lift">
<div className="flex items-center gap-3">
<span className="grid size-11 shrink-0 place-items-center rounded-2xl bg-white/10 p-1.5">
<BrandMark className="h-7" />
</span>
<span className="min-w-0">
<span className="block text-sm font-bold">Instalar la app Sky Balloons</span>
<span className="block text-xs opacity-70">
Acceso directo, pantalla completa y avisos de comisiones
</span>
</span>
</div>
<button
type="button"
disabled={instalada}
onClick={async () => {
const ok = await instalar();
if (!ok && !instalada) {
notificar(
"Instalar la app",
"Usa “Agregar a pantalla de inicio” desde el menú de tu navegador.",
);
}
}}
className="mt-4 flex w-full items-center justify-center gap-3 rounded-2xl bg-white px-4 py-3.5 text-ink transition-all hover:-translate-y-0.5 hover:shadow-lift active:scale-[0.99] disabled:opacity-60"
>
<Download className="size-5" />
<span className="text-left leading-tight">
<span className="block text-[10px] font-semibold uppercase tracking-widest opacity-60">
{instalada ? "Ya instalada" : disponible ? "Disponible ahora" : "Instalación web"}
</span>
<span className="block text-sm font-bold">
{instalada ? "App en tu dispositivo" : "Instalar aplicación"}
</span>
</span>
</button>
</div>
</section>
<button
onClick={() => {
cerrarSesion();
navigate({ to: "/" });
}}
className="mt-5 flex w-full items-center justify-center gap-2 rounded-2xl bg-ink px-4 py-3.5 text-sm font-semibold text-ink-foreground"
>
<LogOut className="size-4" /> Cerrar sesión
</button>
</AppShell>
);
}

227
src/routes/reserva.tsx Normal file
View File

@ -0,0 +1,227 @@
import { createFileRoute } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { useDraft } from "@/lib/cotizador-draft";
import { ArrowLeft, ArrowRight, Check } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { Paso1Extras } from "@/components/reserva/Paso1Extras";
import { Paso2Datos } from "@/components/reserva/Paso2Datos";
import { Paso3Pago } from "@/components/reserva/Paso3Pago";
import { Paso4Certificados } from "@/components/reserva/Paso4Certificados";
import { formatoMoneda } from "@/lib/catalogo";
import { notificar } from "@/lib/notificaciones";
import {
calcularReserva,
estadoInicialReserva,
extrasSeleccionados,
pasajeroVacio,
totalPasajeros,
validarPaso2,
type Certificado,
type PasajeroDatos,
type ReservaEstado,
} from "@/lib/reserva-flujo";
const PASOS = ["Pasajeros y extras", "Datos del vuelo", "Pago", "Certificados"];
export const Route = createFileRoute("/reserva")({
head: () => ({
meta: [
{ title: "Reservación en 4 pasos | Sky Balloons México" },
{
name: "description",
content:
"Flujo de reservación multietapas: elige pasajeros y extras, captura datos del vuelo, paga y personaliza los certificados de vuelo.",
},
{ property: "og:title", content: "Reservación en 4 pasos | Sky Balloons México" },
{
property: "og:description",
content: "Wizard de compra para socios comerciales: extras, datos de pasajeros, pago y certificados.",
},
{ property: "og:type", content: "website" },
{ name: "twitter:card", content: "summary_large_image" },
],
}),
component: ReservaWizard,
});
function ReservaWizard() {
const { draft, listo } = useDraft();
const [paso, setPaso] = useState(1);
const [estado, setEstado] = useState<ReservaEstado>(estadoInicialReserva);
const [finalizado, setFinalizado] = useState(false);
const [error, setError] = useState<string | null>(null);
const set = (parcial: Partial<ReservaEstado>) => setEstado((p) => ({ ...p, ...parcial }));
// Previsualiza los datos capturados en el cotizador (localStorage).
useEffect(() => {
if (!listo) return;
setEstado((p) => ({
...p,
adultos: draft.adultos,
ninos: draft.ninos,
fecha: draft.fecha || p.fecha,
fechaPorConfirmar: draft.fechaPorConfirmar,
}));
}, [listo]);
// Sincroniza la lista de pasajeros con los contadores del paso 1.
const cantidad = totalPasajeros(estado);
useEffect(() => {
setEstado((p) => {
if (p.pasajeros.length === cantidad) return p;
const pasajeros = Array.from({ length: cantidad }, (_, i) => p.pasajeros[i] ?? pasajeroVacio());
const certificados = Array.from({ length: cantidad }, (_, i) => p.certificados[i] ?? { nombre: "", frase: "" });
return { ...p, pasajeros, certificados };
});
}, [cantidad]);
const desglose = useMemo(() => calcularReserva(estado), [estado]);
const extras = extrasSeleccionados(estado.extras);
const setExtra = (id: string, cant: number) =>
setEstado((p) => ({ ...p, extras: { ...p.extras, [id]: cant } }));
const setPasajero = (i: number, parcial: Partial<PasajeroDatos>) =>
setEstado((p) => ({
...p,
pasajeros: p.pasajeros.map((x, idx) => (idx === i ? { ...x, ...parcial } : x)),
}));
const setCertificado = (i: number, parcial: Partial<Certificado>) =>
setEstado((p) => ({
...p,
certificados: p.certificados.map((x, idx) => (idx === i ? { ...x, ...parcial } : x)),
}));
const avanzar = () => {
setError(null);
if (paso === 1 && cantidad < 1) return setError("Agrega al menos un pasajero.");
if (paso === 2) {
const err = validarPaso2(estado);
if (err) return setError(err);
}
if (paso === 3 && !estado.pagoConfirmado) return setError("Confirma el pago para continuar.");
setPaso((p) => Math.min(4, p + 1));
};
const confirmarPago = () => {
set({ pagoConfirmado: true });
notificar("Pago registrado", `Total ${formatoMoneda(desglose.total)} · método ${estado.metodoPago}`);
setPaso(4);
};
const finalizar = () => {
setFinalizado(true);
notificar("¡Reservación finalizada!", `Pases emitidos para ${cantidad} pasajero(s).`);
notificar("Comisión del 10% acreditada", `Se acreditaron ${formatoMoneda(desglose.comision)} a tu cuenta.`);
};
return (
<AppShell title="Nueva reservación" subtitle="Vuelo Aventura 20% OFF · Teotihuacán">
{/* Indicador de pasos */}
<ol className="mb-6 flex items-center gap-2 overflow-x-auto pb-1">
{PASOS.map((label, i) => {
const n = i + 1;
const activo = paso === n;
const completo = paso > n;
return (
<li key={label} className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => n < paso && setPaso(n)}
className={`flex items-center gap-2 rounded-full border px-4 py-2 text-xs font-semibold transition-all duration-300 ${
activo
? "border-primary bg-primary text-primary-foreground shadow-soft"
: completo
? "border-success/40 bg-success/10 text-foreground"
: "border-border bg-card text-muted-foreground"
}`}
>
<span className="grid size-5 place-items-center rounded-full bg-background/30 text-[11px] font-bold">
{completo ? <Check className="size-3" /> : n}
</span>
{label}
</button>
{n < PASOS.length && <span className="h-px w-4 bg-border sm:w-8" />}
</li>
);
})}
</ol>
<div key={paso} className="animate-in fade-in-0 slide-in-from-bottom-3 duration-500">
{paso === 1 && (
<Paso1Extras
adultos={estado.adultos}
ninos={estado.ninos}
setAdultos={(v) => set({ adultos: v })}
setNinos={(v) => set({ ninos: v })}
extras={estado.extras}
setExtra={setExtra}
/>
)}
{paso === 2 && <Paso2Datos estado={estado} set={set} setPasajero={setPasajero} />}
{paso === 3 && (
<Paso3Pago
subtotal={desglose.subtotal}
impuestos={desglose.impuestos}
total={desglose.total}
metodoPago={estado.metodoPago}
setMetodo={(m) => set({ metodoPago: m })}
pagoConfirmado={estado.pagoConfirmado}
confirmarPago={confirmarPago}
/>
)}
{paso === 4 &&
(estado.pagoConfirmado ? (
<Paso4Certificados
pasajeros={estado.pasajeros}
certificados={estado.certificados}
setCertificado={setCertificado}
finalizar={finalizar}
finalizado={finalizado}
/>
) : (
<p className="rounded-3xl bg-card p-6 text-sm text-muted-foreground shadow-soft">
Este paso se habilita al confirmar el pago.
</p>
))}
</div>
{error && (
<p className="mt-4 rounded-2xl bg-destructive/10 px-4 py-3 text-sm font-medium text-destructive">{error}</p>
)}
{/* Barra de navegación del wizard */}
<div className="sticky bottom-20 z-20 mt-6 flex items-center justify-between gap-3 rounded-3xl bg-card/90 p-4 shadow-lift backdrop-blur lg:bottom-4">
<div className="min-w-0">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{cantidad} pax · {extras.length} extras
</p>
<p className="truncate text-lg font-bold">{formatoMoneda(desglose.total)}</p>
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => {
setError(null);
setPaso((p) => Math.max(1, p - 1));
}}
disabled={paso === 1}
className="flex items-center gap-2 rounded-2xl border border-border px-4 py-3 text-sm font-semibold transition-all hover:-translate-y-0.5 active:scale-95 disabled:opacity-40"
>
<ArrowLeft className="size-4" /> Atrás
</button>
<button
type="button"
onClick={avanzar}
disabled={paso === 4}
className="flex items-center gap-2 rounded-2xl bg-primary px-5 py-3 text-sm font-bold text-primary-foreground transition-all hover:-translate-y-0.5 hover:shadow-lift active:scale-95 disabled:opacity-40"
>
Continuar <ArrowRight className="size-4" />
</button>
</div>
</div>
</AppShell>
);
}

View File

@ -0,0 +1,389 @@
import { createFileRoute, Link } from "@tanstack/react-router";
import { useEffect, useMemo, useState } from "react";
import { Minus, Plus, Ticket, UserRound } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { EXTRAS, TASA_IMPUESTO, VUELOS, formatoMoneda } from "@/lib/catalogo";
import {
calcularDesgloseTaquilla,
guardarReservacion,
useAppState,
type Reservacion,
} from "@/lib/store";
import { guardarDraft, useDraft } from "@/lib/cotizador-draft";
import { notificar } from "@/lib/notificaciones";
type Busqueda = { vuelo: string | undefined; pax: number | undefined };
export const Route = createFileRoute("/reservaciones")({
validateSearch: (search: Record<string, unknown>): Busqueda => ({
vuelo: typeof search["vuelo"] === "string" ? search["vuelo"] : undefined,
pax: Number(search["pax"]) > 0 ? Number(search["pax"]) : undefined,
}),
head: () => ({
meta: [
{ title: "Reservaciones y resumen de compra | Socios Comerciales" },
{
name: "description",
content:
"Arma reservaciones con vuelos y extras, y consulta el desglose de subtotales, impuestos y total general en tiempo real.",
},
{ property: "og:title", content: "Reservaciones y resumen de compra" },
{
property: "og:description",
content: "Genera reservaciones con souvenirs, snacks y transporte, con desglose automático.",
},
],
}),
component: ReservacionesPage,
});
function ReservacionesPage() {
const { vuelo: vueloParam } = Route.useSearch();
const { reservaciones } = useAppState();
const { draft, listo } = useDraft();
const [vueloId, setVueloId] = useState(vueloParam ?? VUELOS[0]?.id ?? "");
const [adultos, setAdultos] = useState(2);
const [ninos, setNinos] = useState(0);
const [cliente, setCliente] = useState("");
const [cantidades, setCantidades] = useState<Record<string, number>>({});
// Previsualiza los datos capturados en el cotizador.
useEffect(() => {
if (!listo) return;
setAdultos(draft.adultos);
setNinos(draft.ninos);
}, [listo]);
// Mantiene sincronizada la taquilla con el borrador compartido.
useEffect(() => {
if (!listo) return;
guardarDraft({ adultos, ninos });
}, [listo, adultos, ninos]);
const vuelo = useMemo(() => VUELOS.find((v) => v.id === vueloId) ?? null, [vueloId]);
const extrasSeleccionados = useMemo(
() =>
EXTRAS.filter((e) => (cantidades[e.id] ?? 0) > 0).map((extra) => ({
extra,
cantidad: cantidades[extra.id] ?? 0,
})),
[cantidades],
);
// Desglose recalculado en tiempo real (adultos + niños)
const desglose = calcularDesgloseTaquilla(vuelo, adultos, ninos, extrasSeleccionados);
const pasajeros = adultos + ninos;
const cambiarExtra = (id: string, delta: number) =>
setCantidades((prev) => ({ ...prev, [id]: Math.max(0, (prev[id] ?? 0) + delta) }));
const confirmar = () => {
if (!vuelo || pasajeros < 1) return;
const id = guardarReservacion({
vuelo,
pasajeros,
adultos,
ninos,
cliente: cliente.trim() || "Cliente sin nombre",
estado: "Comisión pendiente",
extras: extrasSeleccionados,
subtotalVuelos: desglose.subtotalVuelos,
subtotalExtras: desglose.subtotalExtras,
impuestos: desglose.impuestos,
total: desglose.total,
comision: desglose.comision,
});
setCantidades({});
notificar("¡Reservación confirmada!", `Folio ${id} · Total ${formatoMoneda(desglose.total)}`);
notificar(
"Nueva comisión acreditada del 10%",
`Se acreditaron ${formatoMoneda(desglose.comision)} a tu cuenta de socio.`,
);
};
return (
<AppShell title="Reservaciones" subtitle="Taquilla, extras y confirmación">
<div className="grid gap-5 lg:grid-cols-[1fr_380px]">
<div className="space-y-5">
{/* Vuelo + taquilla */}
<section className="rounded-3xl bg-card p-5 shadow-soft">
<h2 className="text-base font-bold">1. Vuelo seleccionado</h2>
<div className="mt-4 grid gap-3">
<select
value={vueloId}
onChange={(e) => setVueloId(e.target.value)}
className="w-full rounded-2xl border border-border bg-card px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-ring"
>
{VUELOS.map((v) => (
<option key={v.id} value={v.id}>
{v.nombre} · {v.zona} · {formatoMoneda(v.precio)}
</option>
))}
</select>
{vuelo && (
<p className="text-sm text-muted-foreground">
{vuelo.tipo} · Salida: {vuelo.salida} · {vuelo.duracion} · {vuelo.transporte}
</p>
)}
{/* Taquilla */}
<div className="rounded-2xl border border-border bg-muted/40 p-3">
<p className="px-1 pb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Taquilla de boletos
</p>
<div className="grid gap-2 sm:grid-cols-2">
<FilaTaquilla
label="Adultos"
hint="+12 años"
precio={desglose.precioAdulto}
valor={adultos}
min={1}
onChange={setAdultos}
/>
<FilaTaquilla
label="Niños"
hint="4 a 12 años"
precio={desglose.precioNino}
valor={ninos}
onChange={setNinos}
/>
</div>
<p className="px-1 pt-2 text-xs text-muted-foreground">
{adultos} adulto(s) + {ninos} niño(s) = {pasajeros} pasajero(s) ·{" "}
<span className="font-semibold text-foreground">
Subtotal boletos {formatoMoneda(desglose.subtotalVuelos)}
</span>
</p>
</div>
<label className="block">
<span className="mb-1.5 block text-xs font-semibold text-muted-foreground">
Cliente principal
</span>
<div className="relative">
<UserRound className="pointer-events-none absolute left-4 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
value={cliente}
onChange={(e) => setCliente(e.target.value)}
placeholder="Nombre y apellidos del titular"
className="w-full rounded-2xl border border-border bg-card px-4 py-3 pl-11 text-sm outline-none focus:border-primary focus:ring-2 focus:ring-ring"
/>
</div>
</label>
</div>
</section>
{/* Extras */}
<section className="rounded-3xl bg-card p-5 shadow-soft">
<h2 className="text-base font-bold">2. Opcionales y adicionales</h2>
<p className="text-sm text-muted-foreground">Souvenirs, snacks, transporte y servicios.</p>
<ul className="mt-4 grid gap-2 sm:grid-cols-2">
{EXTRAS.map((e) => {
const cant = cantidades[e.id] ?? 0;
return (
<li
key={e.id}
className={`flex items-center justify-between gap-3 rounded-2xl border p-3 transition-colors ${
cant > 0 ? "border-brand bg-brand/10" : "border-border"
}`}
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{e.nombre}</p>
<p className="text-xs text-muted-foreground">
{e.categoria} · {formatoMoneda(e.precio)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
onClick={() => cambiarExtra(e.id, -1)}
aria-label={`Quitar ${e.nombre}`}
className="grid size-8 place-items-center rounded-xl bg-muted transition-all hover:-translate-y-0.5 active:scale-95"
>
<Minus className="size-3.5" />
</button>
<span className="w-5 text-center text-sm font-semibold">{cant}</span>
<button
onClick={() => cambiarExtra(e.id, 1)}
aria-label={`Agregar ${e.nombre}`}
className="grid size-8 place-items-center rounded-xl bg-ink text-ink-foreground transition-all hover:-translate-y-0.5 active:scale-95"
>
<Plus className="size-3.5" />
</button>
</div>
</li>
);
})}
</ul>
</section>
{/* Historial de ventas */}
<section className="rounded-3xl bg-card p-5 shadow-soft">
<h2 className="text-base font-bold">Mis reservaciones</h2>
<p className="text-sm text-muted-foreground">Historial de ventas del socio comercial.</p>
{reservaciones.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">
Aún no generas reservaciones.{" "}
<Link to="/vuelos" className="font-semibold text-foreground underline">
Buscar vuelos
</Link>
</p>
) : (
<div className="mt-4 overflow-x-auto">
<table className="w-full min-w-[760px] text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-2 pb-3">Fecha</th>
<th className="px-2 pb-3">Folio</th>
<th className="px-2 pb-3">Cliente</th>
<th className="px-2 pb-3">Experiencia</th>
<th className="px-2 pb-3 text-right">Boletos</th>
<th className="px-2 pb-3 text-right">Extras</th>
<th className="px-2 pb-3 text-right">Total</th>
<th className="px-2 pb-3">Estado</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{reservaciones.map((r) => (
<tr key={r.id} className="transition-colors hover:bg-muted/50">
<td className="px-2 py-3 whitespace-nowrap text-muted-foreground">{fechaCorta(r.creadaEn)}</td>
<td className="px-2 py-3 font-semibold">{r.id}</td>
<td className="px-2 py-3">{r.cliente ?? "—"}</td>
<td className="px-2 py-3">
<span className="block">{r.vuelo.nombre}</span>
<span className="text-xs text-muted-foreground">{descripcionPax(r)}</span>
</td>
<td className="px-2 py-3 text-right">{formatoMoneda(r.subtotalVuelos)}</td>
<td className="px-2 py-3 text-right">{formatoMoneda(r.subtotalExtras)}</td>
<td className="px-2 py-3 text-right font-bold">{formatoMoneda(r.total)}</td>
<td className="px-2 py-3">
<BadgeEstado estado={r.estado ?? "Comisión pendiente"} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
{/* Resumen de compra */}
<aside className="lg:sticky lg:top-24 lg:h-fit">
<div className="rounded-3xl bg-ink p-6 text-ink-foreground shadow-lift">
<h2 className="flex items-center gap-2 text-base font-bold">
<Ticket className="size-4 text-brand" /> Resumen de compra
</h2>
<dl className="mt-5 space-y-3 text-sm">
<Fila label={`Boletos (${adultos} adulto/s, ${ninos} niño/s)`} valor={desglose.subtotalVuelos} />
<Fila label="Subtotal souvenirs / extras" valor={desglose.subtotalExtras} />
<Fila
label={`Impuestos y tasas (${Math.round(TASA_IMPUESTO * 100)}%)`}
valor={desglose.impuestos}
/>
<div className="border-t border-white/15 pt-3" />
<div className="flex items-baseline justify-between">
<dt className="font-bold uppercase tracking-wide">Total general</dt>
<dd className="text-2xl font-bold text-brand">{formatoMoneda(desglose.total)}</dd>
</div>
<div className="flex items-center justify-between rounded-2xl bg-white/5 px-4 py-3">
<dt className="text-xs uppercase tracking-wide opacity-70">Tu comisión (10%)</dt>
<dd className="font-bold">{formatoMoneda(desglose.comision)}</dd>
</div>
</dl>
<button
onClick={confirmar}
disabled={!vuelo || pasajeros < 1}
className="mt-6 w-full rounded-2xl bg-brand px-4 py-3.5 text-sm font-bold text-brand-foreground transition-transform active:scale-[0.99] disabled:opacity-50"
>
Confirmar reservación
</button>
</div>
</aside>
</div>
</AppShell>
);
}
function FilaTaquilla({
label,
hint,
precio,
valor,
min = 0,
onChange,
}: {
label: string;
hint: string;
precio: number;
valor: number;
min?: number;
onChange: (v: number) => void;
}) {
return (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card px-4 py-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">
{label} <span className="text-xs font-normal text-muted-foreground">({hint})</span>
</p>
<p className="truncate text-xs text-muted-foreground">{formatoMoneda(precio)} c/u</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
aria-label={`Quitar ${label}`}
disabled={valor <= min}
onClick={() => onChange(Math.max(min, valor - 1))}
className="grid size-9 place-items-center rounded-xl border border-border bg-background transition-all hover:-translate-y-0.5 hover:border-primary hover:text-primary active:scale-95 disabled:opacity-40"
>
<Minus className="size-4" />
</button>
<span className="w-6 text-center text-sm font-bold tabular-nums">{valor}</span>
<button
type="button"
aria-label={`Agregar ${label}`}
onClick={() => onChange(Math.min(20, valor + 1))}
className="grid size-9 place-items-center rounded-xl bg-primary text-primary-foreground transition-all hover:-translate-y-0.5 hover:shadow-lift active:scale-95"
>
<Plus className="size-4" />
</button>
</div>
</div>
);
}
export function BadgeEstado({ estado }: { estado: string }) {
const pagado = estado === "Pagado";
return (
<span
className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 py-1 text-[11px] font-bold ${
pagado ? "bg-success/15 text-success" : "bg-brand/20 text-ink"
}`}
>
<span className={`size-1.5 rounded-full ${pagado ? "bg-success" : "bg-brand"}`} />
{estado}
</span>
);
}
export function fechaCorta(iso: string) {
const d = new Date(iso);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" });
}
export function descripcionPax(r: Reservacion) {
if (r.adultos === undefined && r.ninos === undefined) return `${r.pasajeros} pax`;
return `${r.adultos ?? 0} adulto(s) · ${r.ninos ?? 0} niño(s)`;
}
function Fila({ label, valor }: { label: string; valor: number }) {
return (
<div className="flex items-center justify-between">
<dt className="opacity-70">{label}</dt>
<dd className="font-semibold">{formatoMoneda(valor)}</dd>
</div>
);
}

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