diff --git a/front/.gitignore b/front/.gitignore
new file mode 100644
index 0000000..1f74212
--- /dev/null
+++ b/front/.gitignore
@@ -0,0 +1,7 @@
+node_modules
+dist
+.env
+.env.local
+*.tsbuildinfo
+
+.vercel
diff --git a/front/.npmrc b/front/.npmrc
new file mode 100644
index 0000000..521a9f7
--- /dev/null
+++ b/front/.npmrc
@@ -0,0 +1 @@
+legacy-peer-deps=true
diff --git a/front/README.md b/front/README.md
new file mode 100644
index 0000000..98bf10c
--- /dev/null
+++ b/front/README.md
@@ -0,0 +1,48 @@
+# Booking Platform, frontend
+
+React 19 + Vite + TypeScript + Tailwind 4. Consume la API del backend sin modificarlo y sin ninguna key.
+
+## Ejecutar
+
+```bash
+cd front
+npm install
+npm run dev # http://localhost:3000
+```
+
+El puerto 3000 es intencional: los enlaces de los correos de Supabase apuntan al Site URL (`localhost:3000`).
+
+Por defecto el proxy de Vite envía `/api` al backend de Render. Para usar un backend local:
+
+```bash
+cp .env.example .env # y pon VITE_API_TARGET=http://localhost:8080
+```
+
+Render (plan gratis) duerme el servicio; la primera petición puede tardar ~2 minutos y la app lo avisa.
+
+## Por qué hay un proxy
+
+El backend no configura CORS (`SecurityConfig`), así que el navegador debe llamar a `/api` en su propio origen.
+En desarrollo lo hace Vite (`vite.config.ts`); en Vercel lo hace `vercel.json` (rewrites hacia Render).
+
+## Despliegue en Vercel
+
+Importa la carpeta `front/` como proyecto (framework Vite). No requiere variables de entorno.
+
+## Rutas
+
+| Ruta | Quién | Pantalla |
+|---|---|---|
+| `/` | todos | Catálogo con filtros |
+| `/ingresar`, `/registro`, `/registro-negocio`, `/recuperar` | anónimos | Acceso y registro |
+| `/verificar-correo`, `/restablecer-clave` | todos | Destino de los enlaces de correo (`token_hash` o `token`) |
+| `/bandeja` | autenticados | Correos de la plataforma |
+| `/negocio`, `/negocio/:id/...` | PROVIDER, ADMIN | Resumen, tipos, recursos, servicios, agendas |
+| `/admin/proveedores`, `/admin/clientes` | ADMIN | Listados, acciones con motivo, CSV |
+
+## Límites conocidos del backend
+
+- No hay endpoint de refresh: al vencer el token se pide iniciar sesión de nuevo.
+- No hay módulo de reservas todavía, por eso no hay flujo de reserva.
+- El rol dentro del negocio (OWNER/MANAGER/STAFF) no se expone; si falta permiso, el backend responde 403 y se muestra el motivo.
+- Un usuario ADMIN se asigna a mano en Supabase (`app_metadata.role = "admin"`).
diff --git a/front/index.html b/front/index.html
new file mode 100644
index 0000000..7fd148f
--- /dev/null
+++ b/front/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+ Reservas — encuentra y reserva servicios
+
+
+
+
+
+
diff --git a/front/package-lock.json b/front/package-lock.json
new file mode 100644
index 0000000..0643ac9
--- /dev/null
+++ b/front/package-lock.json
@@ -0,0 +1,2332 @@
+{
+ "name": "bookingplatform-front",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "bookingplatform-front",
+ "version": "0.1.0",
+ "dependencies": {
+ "@hookform/resolvers": "^6.0.0",
+ "@phosphor-icons/react": "^2.1.10",
+ "@tanstack/react-query": "^5.105.0",
+ "clsx": "^2.1.1",
+ "react": "^19.3.1",
+ "react-dom": "^19.3.1",
+ "react-hook-form": "^7.91.0",
+ "react-router-dom": "^7.18.4",
+ "sonner": "^2.0.8",
+ "zod": "^4.6.7"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.3.3",
+ "@types/node": "^26.6.5",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.2",
+ "tailwindcss": "^4.3.3",
+ "typescript": "^7.0.2",
+ "vite": "^8.3.5"
+ }
+ },
+ "node_modules/@hookform/resolvers": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-6.0.0.tgz",
+ "integrity": "sha512-6iBzQnB+YwAveVc8HUi6QYLITn5X+xNKRMFIcNXHZlvp2aTI7yWbj4n/JodXxirMCcBc4VCjb2aYAJh4x07mVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/utils": "^0.3.0"
+ },
+ "peerDependencies": {
+ "@sinclair/typebox": ">=0.25.24",
+ "@standard-schema/spec": "^1.0.0",
+ "@typeschema/main": ">=0.13.7",
+ "@vinejs/vine": "^2.0.0 || ^3.0.0 || ^4.0.0",
+ "ajv": "^8.12.0",
+ "ajv-errors": "^3.0.0",
+ "ajv-formats": "^2.1.1",
+ "arktype": "^2.0.0",
+ "ata-validator": "^1.23.0",
+ "class-transformer": ">=0.4.0",
+ "class-validator": ">=0.12.0",
+ "computed-types": "^1.0.0",
+ "effect": "^4.0.0",
+ "fluentvalidation-ts": "^3.0.0",
+ "fp-ts": "^2.7.0",
+ "io-ts": "^2.0.0",
+ "joi": "^17.0.0 || ^18.0.0",
+ "nope-validator": ">=0.12.0",
+ "react-hook-form": "^7.55.0",
+ "superstruct": ">=0.12.0",
+ "typanion": "^3.3.2",
+ "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc",
+ "vest": ">=6.0.0",
+ "yup": "^1.0.0",
+ "zod": "^3.25.0 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@sinclair/typebox": {
+ "optional": true
+ },
+ "@standard-schema/spec": {
+ "optional": true
+ },
+ "@typeschema/main": {
+ "optional": true
+ },
+ "@vinejs/vine": {
+ "optional": true
+ },
+ "ajv": {
+ "optional": true
+ },
+ "ajv-errors": {
+ "optional": true
+ },
+ "ajv-formats": {
+ "optional": true
+ },
+ "arktype": {
+ "optional": true
+ },
+ "ata-validator": {
+ "optional": true
+ },
+ "class-transformer": {
+ "optional": true
+ },
+ "class-validator": {
+ "optional": true
+ },
+ "computed-types": {
+ "optional": true
+ },
+ "effect": {
+ "optional": true
+ },
+ "fluentvalidation-ts": {
+ "optional": true
+ },
+ "fp-ts": {
+ "optional": true
+ },
+ "io-ts": {
+ "optional": true
+ },
+ "joi": {
+ "optional": true
+ },
+ "nope-validator": {
+ "optional": true
+ },
+ "superstruct": {
+ "optional": true
+ },
+ "typanion": {
+ "optional": true
+ },
+ "valibot": {
+ "optional": true
+ },
+ "vest": {
+ "optional": true
+ },
+ "yup": {
+ "optional": true
+ },
+ "zod": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.155.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
+ "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@phosphor-icons/react": {
+ "version": "2.1.10",
+ "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
+ "integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8",
+ "react-dom": ">= 16.8"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
+ "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
+ "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
+ "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
+ "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
+ "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
+ "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
+ "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
+ "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
+ "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
+ "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
+ "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
+ "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
+ "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
+ "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
+ "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+ "version": "2.8.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+ "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
+ "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.105.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.105.0.tgz",
+ "integrity": "sha512-7p56vRmT/z4UJ6sctGIdl5WRUQyUv53IUpUwEKi2g97zMbO7zh8ZsCLffGAvCPBR4xD72e3VXsDyLwXHhoNTMQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.105.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.105.0.tgz",
+ "integrity": "sha512-y+hMuDERvgg6ay1da0V1a6ZC0ZONt7dELxSFkqRZuwm7p1TTZs0MoXrPlBRflIpRoYjvzQVYxycnLImpFLhWrw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.105.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "@types/react": "^18 || ^19",
+ "react": "^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "26.6.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz",
+ "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
+ "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
+ "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.3.0"
+ }
+ },
+ "node_modules/@typescript/typescript-aix-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
+ "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
+ "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-darwin-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
+ "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-freebsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
+ "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
+ "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
+ "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-loong64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
+ "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-mips64el": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
+ "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-ppc64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
+ "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-riscv64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
+ "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-s390x": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
+ "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-linux-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
+ "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-netbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
+ "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-openbsd-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
+ "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-sunos-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
+ "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-arm64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
+ "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@typescript/typescript-win32-x64": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
+ "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=16.20.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz",
+ "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "oxc-transform-react": "^0.152.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "oxc-transform-react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.26.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz",
+ "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
+ "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.20",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
+ "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
+ "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.28.0"
+ },
+ "peerDependencies": {
+ "react": "^19.3.1"
+ }
+ },
+ "node_modules/react-hook-form": {
+ "version": "7.91.0",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.91.0.tgz",
+ "integrity": "sha512-5BEBUnCBwa28AFXYTYmOaB/+CSpJ/+CWVASHEvIGUzFdHyUiPHeRhG7r5hOZ8nZi0yP7pbHdFNXO/p31EKTJFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router": {
+ "version": "7.18.4",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz",
+ "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.4",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz",
+ "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.4"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.15",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
+ "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.155.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.15",
+ "@rolldown/binding-android-arm64": "1.2.15",
+ "@rolldown/binding-darwin-arm64": "1.2.15",
+ "@rolldown/binding-darwin-x64": "1.2.15",
+ "@rolldown/binding-freebsd-x64": "1.2.15",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.15",
+ "@rolldown/binding-linux-arm64-musl": "1.2.15",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.15",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.15",
+ "@rolldown/binding-linux-x64-gnu": "1.2.15",
+ "@rolldown/binding-linux-x64-musl": "1.2.15",
+ "@rolldown/binding-openharmony-arm64": "1.2.15",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.15",
+ "@rolldown/binding-win32-x64-msvc": "1.2.15"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.28.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
+ "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
+ "license": "MIT"
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.3",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz",
+ "integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg==",
+ "license": "MIT"
+ },
+ "node_modules/sonner": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz",
+ "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+ "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc"
+ },
+ "engines": {
+ "node": ">=16.20.0"
+ },
+ "optionalDependencies": {
+ "@typescript/typescript-aix-ppc64": "7.0.2",
+ "@typescript/typescript-darwin-arm64": "7.0.2",
+ "@typescript/typescript-darwin-x64": "7.0.2",
+ "@typescript/typescript-freebsd-arm64": "7.0.2",
+ "@typescript/typescript-freebsd-x64": "7.0.2",
+ "@typescript/typescript-linux-arm": "7.0.2",
+ "@typescript/typescript-linux-arm64": "7.0.2",
+ "@typescript/typescript-linux-loong64": "7.0.2",
+ "@typescript/typescript-linux-mips64el": "7.0.2",
+ "@typescript/typescript-linux-ppc64": "7.0.2",
+ "@typescript/typescript-linux-riscv64": "7.0.2",
+ "@typescript/typescript-linux-s390x": "7.0.2",
+ "@typescript/typescript-linux-x64": "7.0.2",
+ "@typescript/typescript-netbsd-arm64": "7.0.2",
+ "@typescript/typescript-netbsd-x64": "7.0.2",
+ "@typescript/typescript-openbsd-arm64": "7.0.2",
+ "@typescript/typescript-openbsd-x64": "7.0.2",
+ "@typescript/typescript-sunos-x64": "7.0.2",
+ "@typescript/typescript-win32-arm64": "7.0.2",
+ "@typescript/typescript-win32-x64": "7.0.2"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.3.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
+ "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.30",
+ "rolldown": "~1.2.14",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1 || ^0.8.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.6.7",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
+ "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/front/package.json b/front/package.json
new file mode 100644
index 0000000..d7fb7e7
--- /dev/null
+++ b/front/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "bookingplatform-front",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview --port 3000",
+ "typecheck": "tsc -b --noEmit"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.3.3",
+ "@types/node": "^26.6.5",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.2",
+ "tailwindcss": "^4.3.3",
+ "typescript": "^7.0.2",
+ "vite": "^8.3.5"
+ },
+ "dependencies": {
+ "@hookform/resolvers": "^6.0.0",
+ "@phosphor-icons/react": "^2.1.10",
+ "@tanstack/react-query": "^5.105.0",
+ "clsx": "^2.1.1",
+ "react": "^19.3.1",
+ "react-dom": "^19.3.1",
+ "react-hook-form": "^7.91.0",
+ "react-router-dom": "^7.18.4",
+ "sonner": "^2.0.8",
+ "zod": "^4.6.7"
+ }
+}
diff --git a/front/public/favicon.svg b/front/public/favicon.svg
new file mode 100644
index 0000000..d5aa8aa
--- /dev/null
+++ b/front/public/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/front/src/App.tsx b/front/src/App.tsx
new file mode 100644
index 0000000..5154ff1
--- /dev/null
+++ b/front/src/App.tsx
@@ -0,0 +1,92 @@
+import { lazy, Suspense } from 'react'
+import { Link, Navigate, Route, Routes } from 'react-router-dom'
+import { RequireAuth, PublicOnly } from '@/auth/guards'
+import { Shell } from '@/components/layout/Shell'
+import { WakeNotice } from '@/components/layout/WakeNotice'
+import { LinkButton } from '@/components/ui/Button'
+import { PageLoader } from '@/components/ui/Spinner'
+
+// Routes are split per screen so the catalog does not ship the admin and provider code.
+const CatalogPage = lazy(() => import('@/pages/public/CatalogPage'))
+const LoginPage = lazy(() => import('@/pages/public/LoginPage'))
+const RegisterClientPage = lazy(() => import('@/pages/public/RegisterClientPage'))
+const RegisterProviderPage = lazy(() => import('@/pages/public/RegisterProviderPage'))
+const VerifyEmailPage = lazy(() => import('@/pages/public/VerifyEmailPage'))
+const ForgotPasswordPage = lazy(() => import('@/pages/public/RecoveryPages').then((m) => ({ default: m.ForgotPasswordPage })))
+const ResetPasswordPage = lazy(() => import('@/pages/public/RecoveryPages').then((m) => ({ default: m.ResetPasswordPage })))
+const InboxPage = lazy(() => import('@/pages/account/InboxPage'))
+const OrganizationsPage = lazy(() => import('@/pages/provider/OrganizationsPage'))
+const OrganizationLayout = lazy(() => import('@/pages/provider/OrganizationLayout'))
+const SummaryPage = lazy(() => import('@/pages/provider/SummaryPage'))
+const ResourceTypesPage = lazy(() => import('@/pages/provider/ResourceTypesPage'))
+const ResourcesPage = lazy(() => import('@/pages/provider/ResourcesPage'))
+const ServicesPage = lazy(() => import('@/pages/provider/ServicesPage'))
+const AgendasPage = lazy(() => import('@/pages/provider/AgendasPage'))
+const AdminLayout = lazy(() => import('@/pages/admin/AdminLayout'))
+const AdminProvidersPage = lazy(() => import('@/pages/admin/AdminProvidersPage'))
+const AdminClientsPage = lazy(() => import('@/pages/admin/AdminClientsPage'))
+
+function NotFound() {
+ return (
+
+
Error 404
+
No encontramos esta página
+
La dirección puede estar mal escrita o la página ya no existe.
+
Volver al catálogo
+
+ Ir a ingresar
+
+
+ )
+}
+
+export default function App() {
+ return (
+ <>
+ }>
+
+ }>
+ } />
+
+ {/* Links in the emails Supabase sends land here (Site URL), signed in or not */}
+ } />
+ } />
+
+ }>
+ } />
+ } />
+ } />
+ } />
+
+
+ }>
+ } />
+
+
+ }>
+ } />
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ }>
+ }>
+ } />
+ } />
+ } />
+
+
+
+ } />
+
+
+
+
+ >
+ )
+}
diff --git a/front/src/api/client.ts b/front/src/api/client.ts
new file mode 100644
index 0000000..2bb6927
--- /dev/null
+++ b/front/src/api/client.ts
@@ -0,0 +1,144 @@
+const BASE = '/api/v1'
+// Render's free plan sleeps after ~15 min; the first call was measured at ~100 s.
+const TIMEOUT_MS = 150_000
+const STORAGE_KEY = 'bp.session'
+
+export interface StoredSession {
+ accessToken: string
+ expiresAt: number
+}
+
+/** The RFC 9457 body the backend returns for every error (GlobalExceptionHandler). */
+export class ApiError extends Error {
+ readonly status: number
+ readonly errorCode: string
+ readonly details: Record
+ readonly traceId?: string
+
+ constructor(status: number, errorCode: string, detail: string, details: Record = {}, traceId?: string) {
+ super(detail)
+ this.name = 'ApiError'
+ this.status = status
+ this.errorCode = errorCode
+ this.details = details
+ this.traceId = traceId
+ }
+}
+
+let session: StoredSession | null = readSession()
+let onUnauthorized: () => void = () => {}
+
+function readSession(): StoredSession | null {
+ try {
+ const raw = sessionStorage.getItem(STORAGE_KEY)
+ if (!raw) return null
+ const parsed = JSON.parse(raw) as StoredSession
+ return parsed.expiresAt > Date.now() ? parsed : null
+ } catch {
+ return null
+ }
+}
+
+export function saveSession(next: StoredSession | null) {
+ session = next
+ try {
+ if (next) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(next))
+ else sessionStorage.removeItem(STORAGE_KEY)
+ } catch {
+ /* storage can be blocked; the session then lives in memory only */
+ }
+}
+
+export const getSession = () => session
+export const setUnauthorizedHandler = (fn: () => void) => {
+ onUnauthorized = fn
+}
+
+type Query = Record
+
+export function toQuery(params?: Query): string {
+ if (!params) return ''
+ const search = new URLSearchParams()
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined && value !== null && value !== '') search.set(key, String(value))
+ }
+ const text = search.toString()
+ return text ? `?${text}` : ''
+}
+
+interface Options {
+ method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
+ body?: unknown
+ query?: Query
+ signal?: AbortSignal
+ /** Do not treat a 401 as an expired session (used by login). */
+ anonymous?: boolean
+}
+
+async function send(path: string, { method = 'GET', body, query, signal, anonymous }: Options = {}): Promise {
+ const headers: Record = { Accept: 'application/json, application/problem+json, text/csv' }
+ if (body !== undefined) headers['Content-Type'] = 'application/json'
+ if (session && !anonymous) headers.Authorization = `Bearer ${session.accessToken}`
+
+ const controller = new AbortController()
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
+ signal?.addEventListener('abort', () => controller.abort())
+
+ let response: Response
+ try {
+ response = await fetch(`${BASE}${path}${toQuery(query)}`, {
+ method,
+ headers,
+ body: body === undefined ? undefined : JSON.stringify(body),
+ signal: controller.signal,
+ })
+ } catch (error) {
+ if (signal?.aborted) throw error
+ throw new ApiError(0, 'NETWORK_ERROR', 'No se pudo conectar con el servidor')
+ } finally {
+ clearTimeout(timer)
+ }
+
+ if (response.ok) return response
+
+ let problem: Record = {}
+ try {
+ problem = await response.json()
+ } catch {
+ /* non-JSON error (proxy, gateway) */
+ }
+ const details =
+ problem.details && typeof problem.details === 'object' ? (problem.details as Record) : {}
+ const error = new ApiError(
+ response.status,
+ typeof problem.errorCode === 'string' ? problem.errorCode : `HTTP_${response.status}`,
+ typeof problem.detail === 'string' ? problem.detail : response.statusText,
+ details,
+ typeof problem.traceId === 'string' ? problem.traceId : undefined,
+ )
+ if (response.status === 401 && session && !anonymous) {
+ saveSession(null)
+ onUnauthorized()
+ }
+ throw error
+}
+
+export async function api(path: string, options?: Options): Promise {
+ const response = await send(path, options)
+ if (response.status === 204) return undefined as T
+ const text = await response.text()
+ return (text ? JSON.parse(text) : undefined) as T
+}
+
+/** Authenticated download: the CSV exports need the Bearer header, so a plain link would not work. */
+export async function download(path: string, query: Query, filename: string): Promise {
+ const response = await send(path, { query })
+ const url = URL.createObjectURL(await response.blob())
+ const link = document.createElement('a')
+ link.href = url
+ link.download = filename
+ document.body.appendChild(link)
+ link.click()
+ link.remove()
+ URL.revokeObjectURL(url)
+}
diff --git a/front/src/api/endpoints.ts b/front/src/api/endpoints.ts
new file mode 100644
index 0000000..f37b7b8
--- /dev/null
+++ b/front/src/api/endpoints.ts
@@ -0,0 +1,132 @@
+import { api, download } from './client'
+import type {
+ AdminListQuery,
+ Agenda,
+ Block,
+ CatalogQuery,
+ CatalogResponse,
+ Category,
+ City,
+ ClientRow,
+ ConfirmEmailResponse,
+ CreateAgendaBody,
+ CreateServiceBody,
+ DayOfWeek,
+ InboxEmail,
+ LoginResponse,
+ Me,
+ OfferedService,
+ Organization,
+ Page,
+ PolicyBody,
+ ProviderRegistrationResponse,
+ ProviderRow,
+ RegisterClientBody,
+ RegisterClientResponse,
+ RegisterProviderBody,
+ Resource,
+ ResourceType,
+ ServiceDetails,
+} from './types'
+
+const org = (id: string) => `/organizations/${id}`
+
+export const authApi = {
+ login: (email: string, password: string) =>
+ api('/auth/login', { method: 'POST', body: { email, password }, anonymous: true }),
+ logout: () => api('/auth/logout', { method: 'POST' }),
+ me: () => api('/auth/me'),
+ requestRecovery: (email: string) =>
+ api('/auth/password-recovery-requests', { method: 'POST', body: { email }, anonymous: true }),
+ resetPassword: (tokenHash: string, newPassword: string) =>
+ api('/auth/password-resets', { method: 'POST', body: { tokenHash, newPassword }, anonymous: true }),
+}
+
+export const registrationApi = {
+ client: (body: RegisterClientBody) =>
+ api('/registrations', { method: 'POST', body, anonymous: true }),
+ resend: (email: string) =>
+ api('/registrations/verification-resends', { method: 'POST', body: { email }, anonymous: true }),
+ confirm: (tokenHash: string) =>
+ api('/registrations/email-verifications', {
+ method: 'POST',
+ body: { tokenHash },
+ anonymous: true,
+ }),
+ provider: (body: RegisterProviderBody) =>
+ api('/provider-registrations', { method: 'POST', body, anonymous: true }),
+}
+
+export const referenceApi = {
+ categories: () => api('/organization-categories'),
+ cities: () => api('/cities'),
+}
+
+export const catalogApi = {
+ search: (query: CatalogQuery, signal?: AbortSignal) =>
+ api('/catalog/services', { query: { ...query }, signal }),
+}
+
+export const inboxApi = {
+ emails: () => api('/notifications/emails'),
+}
+
+export const organizationApi = {
+ mine: () => api('/organizations'),
+ one: (id: string) => api(org(id)),
+ changePolicy: (id: string, body: PolicyBody) =>
+ api(`${org(id)}/operation-policy`, { method: 'PUT', body }),
+
+ resourceTypes: (id: string) => api(`${org(id)}/resource-types`),
+ createResourceType: (id: string, body: { name: string; description?: string }) =>
+ api(`${org(id)}/resource-types`, { method: 'POST', body }),
+
+ resources: (id: string) => api(`${org(id)}/resources`),
+ createResource: (
+ id: string,
+ body: { resourceTypeId: string; locationId: string; name: string; capacity: number },
+ ) => api(`${org(id)}/resources`, { method: 'POST', body }),
+ scheduleMaintenance: (id: string, resourceId: string, body: { startsAt: string; endsAt: string; reason?: string }) =>
+ api(`${org(id)}/resources/${resourceId}/maintenances`, { method: 'POST', body }),
+ retireResource: (id: string, resourceId: string) =>
+ api(`${org(id)}/resources/${resourceId}/retirement`, { method: 'POST' }),
+
+ services: (id: string) => api(`${org(id)}/services`),
+ createService: (id: string, body: CreateServiceBody) =>
+ api(`${org(id)}/services`, { method: 'POST', body }),
+ updateService: (id: string, serviceId: string, body: ServiceDetails) =>
+ api(`${org(id)}/services/${serviceId}`, { method: 'PUT', body }),
+ activateService: (id: string, serviceId: string) =>
+ api(`${org(id)}/services/${serviceId}/activation`, { method: 'POST' }),
+ deactivateService: (id: string, serviceId: string) =>
+ api(`${org(id)}/services/${serviceId}/deactivation`, { method: 'POST' }),
+ deleteService: (id: string, serviceId: string) =>
+ api(`${org(id)}/services/${serviceId}`, { method: 'DELETE' }),
+
+ agendas: (id: string) => api(`${org(id)}/agendas`),
+ createAgenda: (id: string, body: CreateAgendaBody) =>
+ api(`${org(id)}/agendas`, { method: 'POST', body }),
+ replaceBlocks: (id: string, agendaId: string, blocks: Block[]) =>
+ api(`${org(id)}/agendas/${agendaId}/blocks`, { method: 'PUT', body: { blocks } }),
+ changeGranularity: (id: string, agendaId: string, slotMinutes: number) =>
+ api(`${org(id)}/agendas/${agendaId}/granularity`, { method: 'PUT', body: { slotMinutes } }),
+ copyDay: (id: string, agendaId: string, fromDay: DayOfWeek, toDays: DayOfWeek[]) =>
+ api(`${org(id)}/agendas/${agendaId}/day-copies`, { method: 'POST', body: { fromDay, toDays } }),
+}
+
+export const adminApi = {
+ providers: (query: AdminListQuery) => api>('/admin/organizations', { query: { ...query } }),
+ exportProviders: (query: AdminListQuery) =>
+ download('/admin/organizations/export', { ...query, page: undefined, size: undefined }, 'proveedores.csv'),
+ providerAction: (
+ id: string,
+ action: 'approval' | 'rejection' | 'reactivation' | 'suspension',
+ body: { reason: string; confirm?: boolean },
+ ) => api(`/admin/organizations/${id}/${action}`, { method: 'POST', body }),
+
+ clients: (query: AdminListQuery) => api>('/admin/clients', { query: { ...query } }),
+ exportClients: (query: AdminListQuery) =>
+ download('/admin/clients/export', { ...query, page: undefined, size: undefined }, 'clientes.csv'),
+ clientAction: (id: string, action: 'suspension' | 'reactivation', reason: string) =>
+ api<{ id: string; status: string }>(`/admin/clients/${id}/${action}`, { method: 'POST', body: { reason } }),
+}
diff --git a/front/src/api/hooks.ts b/front/src/api/hooks.ts
new file mode 100644
index 0000000..e33514c
--- /dev/null
+++ b/front/src/api/hooks.ts
@@ -0,0 +1,8 @@
+import { useQuery } from '@tanstack/react-query'
+import { referenceApi } from './endpoints'
+
+// Catalogs change rarely, so one fetch per session is enough.
+const STATIC = { staleTime: Infinity, gcTime: Infinity }
+
+export const useCategories = () => useQuery({ queryKey: ['categories'], queryFn: referenceApi.categories, ...STATIC })
+export const useCities = () => useQuery({ queryKey: ['cities'], queryFn: referenceApi.cities, ...STATIC })
diff --git a/front/src/api/types.ts b/front/src/api/types.ts
new file mode 100644
index 0000000..e635553
--- /dev/null
+++ b/front/src/api/types.ts
@@ -0,0 +1,270 @@
+// Mirrors the backend DTOs (see the back-end *Request / *Response records and OpenAPI at /v3/api-docs).
+
+export type Role = 'CLIENT' | 'PROVIDER' | 'ADMIN'
+export type NotificationChannel = 'EMAIL' | 'SMS' | 'WHATSAPP'
+export type IdentificationType = 'NIT' | 'CC' | 'CE' | 'PASAPORTE'
+export type OrganizationStatus = 'PENDING_APPROVAL' | 'ACTIVE' | 'SUSPENDED' | 'REJECTED'
+export type ClientStatus = 'PENDING_VERIFICATION' | 'ACTIVE' | 'SUSPENDED'
+export type ServiceStatus = 'ACTIVE' | 'INACTIVE'
+export type ResourceCondition = 'AVAILABLE' | 'IN_MAINTENANCE' | 'RETIRED'
+export type DayOfWeek = 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY' | 'SUNDAY'
+
+export interface LoginResponse {
+ accessToken: string
+ refreshToken: string
+ tokenType: string
+ expiresIn: number
+}
+export interface Me {
+ id: string
+ email: string
+ role: Role | null
+}
+
+export interface Page {
+ items: T[]
+ total: number
+ page: number
+ size: number
+}
+
+export interface Category {
+ id: string
+ name: string
+}
+export interface City {
+ id: string
+ name: string
+ region: string
+}
+
+// Catalog (public)
+export interface ServiceCard {
+ serviceId: string
+ name: string
+ description: string | null
+ provider: { id: string; name: string; category: string }
+ location: { id: string; name: string; city: string; region: string }
+ durationMinutes: number
+ price: number
+ currency: string
+ customerCapacity: number
+ group: boolean
+}
+export interface CatalogResponse extends Page {
+ message: string | null
+}
+export interface CatalogQuery {
+ q?: string
+ categoryId?: string
+ cityId?: string
+ minPrice?: string
+ maxPrice?: string
+ sort?: 'name' | 'price' | 'duration' | 'provider'
+ direction?: 'asc' | 'desc'
+ page?: number
+ size?: number
+}
+
+// Registrations
+export interface RegisterClientBody {
+ fullName: string
+ document: string
+ birthDate: string
+ email: string
+ phone: string
+ city: string
+ notificationChannel: NotificationChannel
+ password: string
+}
+export interface RegisterClientResponse {
+ clientId: string
+ email: string
+ status: ClientStatus
+ message: string
+}
+export interface ConfirmEmailResponse {
+ clientId: string | null
+ email: string
+ status: ClientStatus | null
+ message: string
+}
+export interface LocationBody {
+ name: string
+ address: string
+ cityId: string
+ timezone?: string
+}
+export interface RegisterProviderBody {
+ email: string
+ password: string
+ name: string
+ categoryId: string
+ identificationType: IdentificationType
+ identificationNumber: string
+ contactEmail?: string
+ contactPhone: string
+ timezone?: string
+ locations: LocationBody[]
+ bookingNoticeMinutesMin: number
+ bookingNoticeMinutesMax: number
+ freeCancellationWindowMinutes: number
+}
+export interface ProviderRegistrationResponse {
+ organizationId: string
+ ownerUserId: string
+ email: string
+ status: OrganizationStatus
+ message: string
+}
+
+// Organizations
+export interface Organization {
+ id: string
+ name: string
+ categoryId: string
+ identificationType: IdentificationType
+ identificationNumber: string
+ contactEmail: string
+ contactPhone: string
+ timezone: string
+ status: OrganizationStatus
+ approvedAt: string | null
+ locations: { id: string; name: string; address: string; cityId: string; timezone: string }[]
+ operationPolicy: {
+ version: number
+ bookingNoticeMinutesMin: number
+ bookingNoticeMinutesMax: number
+ freeCancellationWindowMinutes: number
+ }
+}
+export interface PolicyBody {
+ bookingNoticeMinutesMin: number
+ bookingNoticeMinutesMax: number
+ freeCancellationWindowMinutes: number
+}
+
+// Resources
+export interface ResourceType {
+ id: string
+ name: string
+ description: string | null
+}
+export interface Maintenance {
+ id: string
+ startsAt: string
+ endsAt: string
+ reason: string | null
+}
+export interface Resource {
+ id: string
+ resourceTypeId: string
+ locationId: string
+ name: string
+ capacity: number
+ condition: ResourceCondition
+ maintenances: Maintenance[]
+}
+
+// Services
+export interface ServiceDetails {
+ name: string
+ description?: string
+ durationMinutes: number
+ preparationMinutes?: number
+ cleanupMinutes?: number
+ price: number
+ customerCapacity?: number
+}
+export interface CreateServiceBody {
+ details: ServiceDetails
+ locationIds: string[]
+ requirements: { resourceTypeId: string; quantity?: number }[]
+}
+export interface OfferedService {
+ id: string
+ name: string
+ description: string | null
+ durationMinutes: number
+ preparationMinutes: number
+ cleanupMinutes: number
+ price: number
+ currency: string
+ customerCapacity: number
+ group: boolean
+ status: ServiceStatus
+ locationIds: string[]
+ requirements: { resourceTypeId: string; quantity: number }[]
+}
+
+// Agendas
+export interface Block {
+ dayOfWeek: DayOfWeek
+ startTime: string
+ endTime: string
+}
+export interface Agenda {
+ id: string
+ name: string
+ locationId: string
+ resourceId: string | null
+ slotMinutes: number
+ validFrom: string
+ validUntil: string | null
+ serviceIds: string[]
+ blocks: Block[]
+}
+export interface CreateAgendaBody {
+ name: string
+ locationId: string
+ resourceId?: string
+ slotMinutes: number
+ validFrom: string
+ validUntil?: string
+ serviceIds: string[]
+ blocks: Block[]
+}
+
+// Admin
+export interface ProviderRow {
+ id: string
+ name: string
+ identificationType: IdentificationType
+ identificationNumber: string
+ category: string
+ contactEmail: string
+ contactPhone: string
+ cities: string | null
+ status: OrganizationStatus
+ registeredAt: string
+ approvedAt: string | null
+}
+export interface ClientRow {
+ id: string
+ fullName: string
+ document: string
+ email: string
+ phone: string
+ city: string
+ notificationChannel: NotificationChannel
+ status: ClientStatus
+ registeredAt: string
+}
+export interface AdminListQuery {
+ status?: string
+ categoryId?: string
+ city?: string
+ registeredFrom?: string
+ registeredTo?: string
+ sort?: string
+ direction?: 'asc' | 'desc'
+ page?: number
+ size?: number
+}
+
+export interface InboxEmail {
+ id: string
+ subject: string
+ body: string
+ sentAt: string
+}
diff --git a/front/src/auth/AuthContext.tsx b/front/src/auth/AuthContext.tsx
new file mode 100644
index 0000000..bb31cff
--- /dev/null
+++ b/front/src/auth/AuthContext.tsx
@@ -0,0 +1,113 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import { getSession, saveSession, setUnauthorizedHandler } from '@/api/client'
+import { authApi } from '@/api/endpoints'
+import type { Me } from '@/api/types'
+
+interface AuthState {
+ user: Me | null
+ /** True while a stored session is being checked against /auth/me. */
+ loading: boolean
+ login: (email: string, password: string) => Promise
+ logout: () => Promise
+}
+
+const AuthContext = createContext(null)
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const queryClient = useQueryClient()
+ const [user, setUser] = useState(null)
+ const [loading, setLoading] = useState(() => getSession() !== null)
+ const expiryTimer = useRef(undefined)
+
+ const clear = useCallback(() => {
+ window.clearTimeout(expiryTimer.current)
+ saveSession(null)
+ setUser(null)
+ queryClient.clear()
+ }, [queryClient])
+
+ // There is no refresh endpoint (AuthController), so the session ends when the access token does.
+ const scheduleExpiry = useCallback(
+ (expiresAt: number) => {
+ window.clearTimeout(expiryTimer.current)
+ const wait = Math.min(expiresAt - Date.now(), 2 ** 31 - 1)
+ expiryTimer.current = window.setTimeout(() => {
+ clear()
+ toast.info('Tu sesión expiró. Inicia sesión de nuevo.')
+ }, Math.max(wait, 0))
+ },
+ [clear],
+ )
+
+ useEffect(() => {
+ setUnauthorizedHandler(() => {
+ clear()
+ toast.info('Tu sesión expiró. Inicia sesión de nuevo.')
+ })
+ }, [clear])
+
+ useEffect(() => {
+ const stored = getSession()
+ if (!stored) return
+ let cancelled = false
+ authApi
+ .me()
+ .then((me) => {
+ if (cancelled) return
+ setUser(me)
+ scheduleExpiry(stored.expiresAt)
+ })
+ .catch(() => !cancelled && clear())
+ .finally(() => !cancelled && setLoading(false))
+ return () => {
+ cancelled = true
+ }
+ }, [clear, scheduleExpiry])
+
+ const login = useCallback(
+ async (email: string, password: string) => {
+ const tokens = await authApi.login(email, password)
+ const expiresAt = Date.now() + tokens.expiresIn * 1000
+ saveSession({ accessToken: tokens.accessToken, expiresAt })
+ try {
+ const me = await authApi.me()
+ queryClient.clear()
+ setUser(me)
+ scheduleExpiry(expiresAt)
+ return me
+ } catch (error) {
+ saveSession(null)
+ throw error
+ }
+ },
+ [queryClient, scheduleExpiry],
+ )
+
+ const logout = useCallback(async () => {
+ try {
+ await authApi.logout()
+ } catch {
+ /* the local session ends either way */
+ }
+ clear()
+ }, [clear])
+
+ const value = useMemo(() => ({ user, loading, login, logout }), [user, loading, login, logout])
+ return {children}
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useAuth(): AuthState {
+ const context = useContext(AuthContext)
+ if (!context) throw new Error('useAuth must be used inside AuthProvider')
+ return context
+}
+
+/** Where each role lands after signing in. */
+export function homeFor(role: Me['role']): string {
+ if (role === 'ADMIN') return '/admin/proveedores'
+ if (role === 'PROVIDER') return '/negocio'
+ return '/'
+}
diff --git a/front/src/auth/guards.tsx b/front/src/auth/guards.tsx
new file mode 100644
index 0000000..e58a105
--- /dev/null
+++ b/front/src/auth/guards.tsx
@@ -0,0 +1,23 @@
+import { Navigate, Outlet, useLocation } from 'react-router-dom'
+import type { Role } from '@/api/types'
+import { PageLoader } from '@/components/ui/Spinner'
+import { homeFor, useAuth } from './AuthContext'
+
+/** Needs a signed-in user, optionally of one of `roles`. The backend enforces the same (SecurityConfig). */
+export function RequireAuth({ roles }: { roles?: Role[] }) {
+ const { user, loading } = useAuth()
+ const location = useLocation()
+
+ if (loading) return
+ if (!user) return
+ if (roles && (!user.role || !roles.includes(user.role))) return
+ return
+}
+
+/** Sign-in and sign-up screens make no sense once signed in. */
+export function PublicOnly() {
+ const { user, loading } = useAuth()
+ if (loading) return
+ if (user) return
+ return
+}
diff --git a/front/src/components/layout/Shell.tsx b/front/src/components/layout/Shell.tsx
new file mode 100644
index 0000000..f808ab8
--- /dev/null
+++ b/front/src/components/layout/Shell.tsx
@@ -0,0 +1,209 @@
+import { useState } from 'react'
+import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
+import clsx from 'clsx'
+import { Envelope, List, SignOut, X } from '@phosphor-icons/react'
+import { toast } from 'sonner'
+import { useAuth } from '@/auth/AuthContext'
+import { buttonClass } from '@/components/ui/Button'
+
+function Logo() {
+ return (
+
+
+
+
+
+
+ Reservas
+
+ )
+}
+
+const navLink = ({ isActive }: { isActive: boolean }) =>
+ clsx(
+ 'inline-flex min-h-11 items-center rounded-md px-3 text-sm font-medium transition-colors',
+ isActive ? 'bg-accent-soft text-accent-ink' : 'text-muted hover:bg-sunken hover:text-ink',
+ )
+
+function Header() {
+ const { user, logout } = useAuth()
+ const navigate = useNavigate()
+ const [open, setOpen] = useState(false)
+
+ const handleLogout = async () => {
+ setOpen(false)
+ await logout()
+ toast.success('Cerraste sesión')
+ navigate('/')
+ }
+ const close = () => setOpen(false)
+
+ const links = (
+ <>
+
+ Catálogo
+
+ {user?.role === 'PROVIDER' && (
+
+ Mi negocio
+
+ )}
+ {user?.role === 'ADMIN' && (
+
+ Administración
+
+ )}
+ {user && (
+
+
+ Bandeja
+
+ )}
+ {!user && (
+
+ Para negocios
+
+ )}
+ >
+ )
+
+ return (
+
+ )
+}
+
+function Footer() {
+ return (
+
+ )
+}
+
+export function Shell() {
+ return (
+
+ )
+}
+
+/** Centered narrow column for the sign-in and sign-up forms. */
+export function AuthPage({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
+ return (
+
+
{title}
+ {description &&
{description}
}
+
{children}
+
+ )
+}
+
+export function TabNav({ items }: { items: { to: string; label: string; end?: boolean }[] }) {
+ return (
+
+
+ {items.map((item) => (
+
+
+ clsx(
+ '-mb-px inline-flex min-h-11 items-center border-b-2 px-3 text-sm font-medium transition-colors',
+ isActive ? 'border-accent text-accent-ink' : 'border-transparent text-muted hover:text-ink',
+ )
+ }
+ >
+ {item.label}
+
+
+ ))}
+
+
+ )
+}
diff --git a/front/src/components/layout/WakeNotice.tsx b/front/src/components/layout/WakeNotice.tsx
new file mode 100644
index 0000000..40d0410
--- /dev/null
+++ b/front/src/components/layout/WakeNotice.tsx
@@ -0,0 +1,34 @@
+import { useEffect, useState } from 'react'
+import { useIsFetching, useIsMutating } from '@tanstack/react-query'
+import { Spinner } from '@/components/ui/Spinner'
+
+/**
+ * Render's free plan sleeps the backend after ~15 minutes; the first request then takes up to a
+ * minute. If anything stays in flight for a few seconds, say why instead of leaving a blank wait.
+ */
+export function WakeNotice() {
+ const busy = useIsFetching() + useIsMutating() > 0
+ const [slow, setSlow] = useState(false)
+
+ useEffect(() => {
+ if (!busy) {
+ setSlow(false)
+ return
+ }
+ const timer = window.setTimeout(() => setSlow(true), 6000)
+ return () => window.clearTimeout(timer)
+ }, [busy])
+
+ if (!slow) return null
+ return (
+
+
+
+ Despertando el servidor… La primera carga después de un rato sin uso puede tardar un par de minutos.
+
+
+ )
+}
diff --git a/front/src/components/ui/Badge.tsx b/front/src/components/ui/Badge.tsx
new file mode 100644
index 0000000..e60e329
--- /dev/null
+++ b/front/src/components/ui/Badge.tsx
@@ -0,0 +1,24 @@
+import clsx from 'clsx'
+import type { Tone } from '@/lib/format'
+
+const tones: Record = {
+ ok: 'bg-ok-soft text-ok',
+ warn: 'bg-warn-soft text-warn',
+ danger: 'bg-danger-soft text-danger',
+ info: 'bg-info-soft text-info',
+ neutral: 'bg-sunken text-muted',
+}
+
+/** Status is always text as well as color. */
+export function Badge({ tone = 'neutral', children }: { tone?: Tone; children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function StatusBadge({ map, value }: { map: Record; value: string }) {
+ const entry = map[value]
+ return {entry?.label ?? value}
+}
diff --git a/front/src/components/ui/Blocks.tsx b/front/src/components/ui/Blocks.tsx
new file mode 100644
index 0000000..2f164e9
--- /dev/null
+++ b/front/src/components/ui/Blocks.tsx
@@ -0,0 +1,105 @@
+import type { ReactNode } from 'react'
+import clsx from 'clsx'
+import { CaretLeft, CaretRight, CheckCircle, Info, Warning, WarningCircle } from '@phosphor-icons/react'
+import { errorMessage, traceOf } from '@/lib/errors'
+import { Button } from './Button'
+
+export function PageHeader({ title, description, actions }: { title: string; description?: string; actions?: ReactNode }) {
+ return (
+
+
+
{title}
+ {description &&
{description}
}
+
+ {actions &&
{actions}
}
+
+ )
+}
+
+export function Card({ className, children }: { className?: string; children: ReactNode }) {
+ return
+}
+
+type AlertTone = 'info' | 'ok' | 'warn' | 'danger'
+const alertStyles: Record = {
+ info: { box: 'border-info/25 bg-info-soft text-ink', icon: Info },
+ ok: { box: 'border-ok/25 bg-ok-soft text-ink', icon: CheckCircle },
+ warn: { box: 'border-warn/25 bg-warn-soft text-ink', icon: Warning },
+ danger: { box: 'border-danger/25 bg-danger-soft text-ink', icon: WarningCircle },
+}
+
+export function Alert({ tone = 'info', title, children }: { tone?: AlertTone; title?: string; children?: ReactNode }) {
+ const { box, icon: Icon } = alertStyles[tone]
+ return (
+
+
+
+ {title &&
{title}
}
+ {children &&
{children}
}
+
+
+ )
+}
+
+/** A failed request: what happened in plain words, a retry, and the trace id when it is the server's fault. */
+export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
+ const trace = traceOf(error)
+ return (
+
+
+ {errorMessage(error)}
+ {trace && Código de soporte: {trace} }
+
+ {onRetry && (
+
+ Reintentar
+
+ )}
+
+ )
+}
+
+export function EmptyState({ title, description, action }: { title: string; description?: string; action?: ReactNode }) {
+ return (
+
+
{title}
+ {description &&
{description}
}
+ {action &&
{action}
}
+
+ )
+}
+
+export function Pagination({
+ page,
+ size,
+ total,
+ onPage,
+}: {
+ page: number
+ size: number
+ total: number
+ onPage: (page: number) => void
+}) {
+ const pages = Math.max(1, Math.ceil(total / size))
+ if (total === 0) return null
+ const from = page * size + 1
+ const to = Math.min(total, (page + 1) * size)
+ return (
+
+
+ {from}–{to} de {total}
+
+
+ onPage(page - 1)}>
+ Anterior
+
+
+ Página {page + 1} de {pages}
+
+ = pages} onClick={() => onPage(page + 1)}>
+ Siguiente
+
+
+
+ )
+}
diff --git a/front/src/components/ui/Button.tsx b/front/src/components/ui/Button.tsx
new file mode 100644
index 0000000..f5564a9
--- /dev/null
+++ b/front/src/components/ui/Button.tsx
@@ -0,0 +1,61 @@
+import { forwardRef, type ButtonHTMLAttributes } from 'react'
+import { Link, type LinkProps } from 'react-router-dom'
+import clsx from 'clsx'
+import { Spinner } from './Spinner'
+
+type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
+type Size = 'sm' | 'md' | 'lg'
+
+const base =
+ 'inline-flex items-center justify-center gap-2 rounded-md font-semibold whitespace-nowrap transition-colors duration-150 ' +
+ 'disabled:opacity-50 disabled:pointer-events-none select-none'
+
+const variants: Record = {
+ primary: 'bg-accent text-white hover:bg-accent-hover',
+ secondary: 'bg-surface text-ink border border-line-strong hover:bg-sunken',
+ ghost: 'text-muted hover:bg-sunken hover:text-ink',
+ danger: 'bg-danger text-white hover:bg-[#8f1c13]',
+}
+// min-h-11 = 44px touch target on md and lg
+const sizes: Record = {
+ sm: 'min-h-9 px-3 text-sm',
+ md: 'min-h-11 px-4 text-sm',
+ lg: 'min-h-12 px-6 text-base',
+}
+
+export const buttonClass = (variant: Variant = 'primary', size: Size = 'md', className?: string) =>
+ clsx(base, variants[variant], sizes[size], className)
+
+interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: Variant
+ size?: Size
+ loading?: boolean
+}
+
+export const Button = forwardRef(function Button(
+ { variant, size, loading, disabled, className, children, type = 'button', ...rest },
+ ref,
+) {
+ return (
+
+ {loading && }
+ {children}
+
+ )
+})
+
+export function LinkButton({
+ variant,
+ size,
+ className,
+ ...rest
+}: LinkProps & { variant?: Variant; size?: Size }) {
+ return
+}
diff --git a/front/src/components/ui/Form.tsx b/front/src/components/ui/Form.tsx
new file mode 100644
index 0000000..cb11552
--- /dev/null
+++ b/front/src/components/ui/Form.tsx
@@ -0,0 +1,111 @@
+import { forwardRef, useId, type InputHTMLAttributes, type ReactNode, type SelectHTMLAttributes, type TextareaHTMLAttributes } from 'react'
+import clsx from 'clsx'
+
+const control =
+ 'block w-full min-h-11 rounded-md border bg-surface px-3 py-2 text-ink placeholder:text-faint ' +
+ 'transition-colors duration-150 hover:border-faint focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25 ' +
+ 'disabled:bg-sunken disabled:text-faint'
+
+interface FieldProps {
+ label: string
+ error?: string
+ hint?: string
+ required?: boolean
+ className?: string
+ children: (ids: { id: string; describedBy?: string; invalid: boolean }) => ReactNode
+}
+
+/** Label above the control, hint and error below it, wired together for screen readers. */
+export function Field({ label, error, hint, required, className, children }: FieldProps) {
+ const id = useId()
+ const hintId = hint ? `${id}-hint` : undefined
+ const errorId = error ? `${id}-error` : undefined
+ const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined
+ return (
+
+
+ {label}
+ {required && * }
+
+ {children({ id, describedBy, invalid: Boolean(error) })}
+ {hint && !error && (
+
+ {hint}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
+
+type Common = { label: string; error?: string; hint?: string; wrapperClassName?: string }
+
+export const Input = forwardRef>(function Input(
+ { label, error, hint, wrapperClassName, className, required, ...rest },
+ ref,
+) {
+ return (
+
+ {({ id, describedBy, invalid }) => (
+
+ )}
+
+ )
+})
+
+export const Select = forwardRef>(function Select(
+ { label, error, hint, wrapperClassName, className, required, children, ...rest },
+ ref,
+) {
+ return (
+
+ {({ id, describedBy, invalid }) => (
+
+ {children}
+
+ )}
+
+ )
+})
+
+export const Textarea = forwardRef>(
+ function Textarea({ label, error, hint, wrapperClassName, className, required, ...rest }, ref) {
+ return (
+
+ {({ id, describedBy, invalid }) => (
+
+ )}
+
+ )
+ },
+)
diff --git a/front/src/components/ui/Modal.tsx b/front/src/components/ui/Modal.tsx
new file mode 100644
index 0000000..bdf1770
--- /dev/null
+++ b/front/src/components/ui/Modal.tsx
@@ -0,0 +1,54 @@
+import { useEffect, useRef, type ReactNode } from 'react'
+import { X } from '@phosphor-icons/react'
+
+interface ModalProps {
+ open: boolean
+ title: string
+ description?: string
+ onClose: () => void
+ children: ReactNode
+}
+
+/** Native : focus trap, Esc to close and inert background come from the platform. */
+export function Modal({ open, title, description, onClose, children }: ModalProps) {
+ const ref = useRef(null)
+
+ useEffect(() => {
+ const dialog = ref.current
+ if (!dialog) return
+ if (open && !dialog.open) dialog.showModal()
+ if (!open && dialog.open) dialog.close()
+ }, [open])
+
+ return (
+ event.target === ref.current && onClose()}
+ aria-labelledby="modal-title"
+ className="m-auto w-[min(32rem,calc(100%-2rem))] max-h-[90dvh] overflow-y-auto rounded-lg border border-line bg-surface p-0 text-ink shadow-xl backdrop:bg-ink/40"
+ >
+ {open && (
+
+
+
+
+ {title}
+
+ {description &&
{description}
}
+
+
+
+
+
+ {children}
+
+ )}
+
+ )
+}
diff --git a/front/src/components/ui/ReasonModal.tsx b/front/src/components/ui/ReasonModal.tsx
new file mode 100644
index 0000000..4dcc054
--- /dev/null
+++ b/front/src/components/ui/ReasonModal.tsx
@@ -0,0 +1,86 @@
+import { useState } from 'react'
+import { ApiError } from '@/api/client'
+import { errorMessage } from '@/lib/errors'
+import { Alert } from './Blocks'
+import { Button } from './Button'
+import { Textarea } from './Form'
+import { Modal } from './Modal'
+
+interface Props {
+ open: boolean
+ title: string
+ description?: string
+ confirmLabel: string
+ danger?: boolean
+ /** Runs the action; `confirm` is true on the second try after the backend asked for confirmation. */
+ onSubmit: (reason: string, confirm: boolean) => Promise
+ onClose: () => void
+}
+
+/**
+ * Every admin status change demands a reason (HU-003). A 409 CONFIRMATION_REQUIRED, which the
+ * backend answers when a suspension affects future bookings, turns into an explicit second step.
+ */
+export function ReasonModal({ open, title, description, confirmLabel, danger, onSubmit, onClose }: Props) {
+ const [reason, setReason] = useState('')
+ const [pending, setPending] = useState(false)
+ const [error, setError] = useState(null)
+ const [affected, setAffected] = useState(null)
+
+ const close = () => {
+ setReason('')
+ setError(null)
+ setAffected(null)
+ onClose()
+ }
+
+ const submit = async (event: React.FormEvent) => {
+ event.preventDefault()
+ if (!reason.trim()) return setError('Escribe el motivo.')
+ setPending(true)
+ setError(null)
+ try {
+ await onSubmit(reason.trim(), affected !== null)
+ close()
+ } catch (e) {
+ if (e instanceof ApiError && e.errorCode === 'CONFIRMATION_REQUIRED') {
+ setAffected(e.details.affectedBookings ?? '?')
+ } else {
+ setError(errorMessage(e))
+ }
+ } finally {
+ setPending(false)
+ }
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/front/src/components/ui/Spinner.tsx b/front/src/components/ui/Spinner.tsx
new file mode 100644
index 0000000..8f503e6
--- /dev/null
+++ b/front/src/components/ui/Spinner.tsx
@@ -0,0 +1,28 @@
+import clsx from 'clsx'
+
+export function Spinner({ className }: { className?: string }) {
+ return (
+
+
+
+
+ )
+}
+
+export function PageLoader({ label = 'Cargando…' }: { label?: string }) {
+ return (
+
+ )
+}
+
+export function Skeleton({ className }: { className?: string }) {
+ return
+}
diff --git a/front/src/index.css b/front/src/index.css
new file mode 100644
index 0000000..5bdaac2
--- /dev/null
+++ b/front/src/index.css
@@ -0,0 +1,150 @@
+@import 'tailwindcss';
+
+/*
+ * Design tokens. Warm neutrals + one deep teal accent; flat surfaces separated by 1px borders.
+ * Type scale 1.25 (major third) on a 16px base. Contrast checked for WCAG 2.1 AA:
+ * ink on canvas 15:1, muted on canvas 6.9:1, white on accent 5.5:1.
+ */
+@theme {
+ --font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
+ --font-display: 'Fraunces', Georgia, 'Times New Roman', serif;
+
+ --color-canvas: #f6f3ee;
+ --color-surface: #ffffff;
+ --color-sunken: #efeae2;
+ --color-line: #e2dcd1;
+ --color-line-strong: #c9c1b3;
+ --color-ink: #1c1917;
+ --color-muted: #57534e;
+ --color-faint: #78716c;
+
+ --color-accent: #0f766e;
+ --color-accent-hover: #0c615a;
+ --color-accent-soft: #e3f1ef;
+ --color-accent-ink: #0a4d47;
+
+ --color-danger: #b42318;
+ --color-danger-soft: #fdeceb;
+ --color-warn: #92400e;
+ --color-warn-soft: #fef3e2;
+ --color-ok: #166534;
+ --color-ok-soft: #e7f5ec;
+ --color-info: #1d4ed8;
+ --color-info-soft: #e8effd;
+
+ --text-xs: 0.8rem;
+ --text-sm: 0.875rem;
+ --text-base: 1rem;
+ --text-lg: 1.25rem;
+ --text-xl: 1.563rem;
+ --text-2xl: 1.953rem;
+ --text-3xl: 2.441rem;
+ --text-4xl: 3.052rem;
+
+ --radius-sm: 6px;
+ --radius-md: 10px;
+ --radius-lg: 14px;
+}
+
+@layer base {
+ html {
+ color-scheme: light;
+ -webkit-text-size-adjust: 100%;
+ scroll-padding-top: 5rem;
+ }
+ button,
+ a,
+ input,
+ select,
+ textarea,
+ summary {
+ touch-action: manipulation;
+ }
+ dialog {
+ overscroll-behavior: contain;
+ }
+ body {
+ background: var(--color-canvas);
+ color: var(--color-ink);
+ font-family: var(--font-sans);
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ }
+ h1,
+ h2,
+ h3 {
+ font-family: var(--font-display);
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ line-height: 1.15;
+ text-wrap: balance;
+ }
+ p {
+ text-wrap: pretty;
+ }
+ :focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+ border-radius: 4px;
+ }
+ ::selection {
+ background: var(--color-accent-soft);
+ }
+ /* 16px minimum keeps iOS from zooming into inputs */
+ input,
+ select,
+ textarea {
+ font-size: 1rem;
+ }
+ button,
+ [role='button'],
+ a {
+ -webkit-tap-highlight-color: transparent;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+}
+
+@keyframes fade-up {
+ from {
+ opacity: 0;
+ transform: translateY(6px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+@keyframes shimmer {
+ 0% {
+ background-position: 200% 0;
+ }
+ 100% {
+ background-position: -200% 0;
+ }
+}
+
+.animate-in {
+ animation: fade-up 240ms ease-out both;
+}
+.skeleton {
+ background: linear-gradient(90deg, var(--color-sunken) 25%, #f7f3ec 50%, var(--color-sunken) 75%);
+ background-size: 200% 100%;
+ animation: shimmer 1.4s linear infinite;
+ border-radius: var(--radius-sm);
+}
diff --git a/front/src/lib/errors.ts b/front/src/lib/errors.ts
new file mode 100644
index 0000000..5daf085
--- /dev/null
+++ b/front/src/lib/errors.ts
@@ -0,0 +1,52 @@
+import { ApiError } from '@/api/client'
+
+/** Backend messages are in English; users see these. Keyed by ErrorCode.java. */
+const MESSAGES: Record = {
+ VALIDATION_ERROR: 'Revisa los campos marcados.',
+ MINOR_NOT_ALLOWED: 'El registro solo está permitido para mayores de 18 años.',
+ PASSWORD_TOO_WEAK: 'La contraseña no cumple la política de seguridad.',
+ VERIFICATION_TOKEN_INVALID: 'El enlace de verificación no es válido o ya se usó.',
+ AUTH_REQUIRED: 'Inicia sesión para continuar.',
+ INVALID_CREDENTIALS: 'Correo o contraseña incorrectos.',
+ AUTH_TOKEN_INVALID: 'Tu sesión no es válida. Inicia sesión de nuevo.',
+ RESOURCE_NOT_FOUND: 'No encontramos lo que buscas.',
+ METHOD_NOT_ALLOWED: 'Operación no permitida.',
+ EMAIL_NOT_CONFIRMED: 'Aún no confirmas tu correo. Revisa tu bandeja o solicita un nuevo enlace.',
+ ACCESS_DENIED: 'No tienes permisos para hacer esto. Solo el propietario o un gerente puede modificarlo.',
+ DUPLICATE_EMAIL: 'Ese correo ya está registrado.',
+ DUPLICATE_DOCUMENT: 'Ese documento ya está registrado.',
+ DUPLICATE_IDENTIFICATION: 'Ya existe un proveedor con esa identificación.',
+ INVALID_STATUS_TRANSITION: 'El estado actual no permite esta operación.',
+ DUPLICATE_NAME: 'Ese nombre ya está en uso.',
+ OVERLAPPING_PERIOD: 'El periodo se cruza con uno existente.',
+ HAS_FUTURE_BOOKINGS: 'Hay reservas futuras que debes resolver primero.',
+ HAS_BOOKINGS: 'Tiene reservas, así que solo puede desactivarse.',
+ INSUFFICIENT_RESOURCES: 'No hay recursos activos suficientes para ofrecer este servicio.',
+ CONFIRMATION_REQUIRED: 'Esta operación tiene consecuencias y requiere confirmación.',
+ RATE_LIMITED: 'Demasiadas solicitudes. Intenta de nuevo en un momento.',
+ ACCOUNT_LOCKED: 'Cuenta bloqueada temporalmente por intentos fallidos.',
+ UPSTREAM_AUTH_ERROR: 'El servicio de identidad no está disponible. Intenta más tarde.',
+ INTERNAL_ERROR: 'Ocurrió un error inesperado.',
+ NETWORK_ERROR: 'No pudimos conectar con el servidor. Revisa tu conexión.',
+}
+
+export function errorMessage(error: unknown): string {
+ if (error instanceof ApiError) {
+ if (error.errorCode === 'ACCOUNT_LOCKED' && error.details.retryAfterMinutes) {
+ return `Cuenta bloqueada por intentos fallidos. Vuelve a intentarlo en ${error.details.retryAfterMinutes} minutos.`
+ }
+ return MESSAGES[error.errorCode] ?? 'No pudimos completar la operación.'
+ }
+ return 'Ocurrió un error inesperado.'
+}
+
+/** Support reference for failures the user cannot fix. */
+export function traceOf(error: unknown): string | undefined {
+ return error instanceof ApiError && error.status >= 500 ? error.traceId : undefined
+}
+
+/** Server-side field errors (details = field → message) to map onto form fields. */
+export function fieldErrors(error: unknown): [string, string][] {
+ if (!(error instanceof ApiError) || error.errorCode !== 'VALIDATION_ERROR') return []
+ return Object.entries(error.details)
+}
diff --git a/front/src/lib/format.ts b/front/src/lib/format.ts
new file mode 100644
index 0000000..12fcf6d
--- /dev/null
+++ b/front/src/lib/format.ts
@@ -0,0 +1,55 @@
+import type { ClientStatus, DayOfWeek, OrganizationStatus, ResourceCondition, ServiceStatus } from '@/api/types'
+
+const cop = new Intl.NumberFormat('es-CO', { style: 'currency', currency: 'COP', maximumFractionDigits: 0 })
+export const formatPrice = (value: number) => cop.format(value)
+
+const dateTime = new Intl.DateTimeFormat('es-CO', { dateStyle: 'medium', timeStyle: 'short' })
+const dateOnly = new Intl.DateTimeFormat('es-CO', { dateStyle: 'medium' })
+export const formatDateTime = (iso: string) => dateTime.format(new Date(iso))
+export const formatDate = (iso: string) => dateOnly.format(new Date(iso.length === 10 ? `${iso}T00:00:00` : iso))
+
+export function formatDuration(minutes: number): string {
+ if (minutes < 60) return `${minutes} min`
+ const h = Math.floor(minutes / 60)
+ const m = minutes % 60
+ return m ? `${h} h ${m} min` : `${h} h`
+}
+
+export const DAYS: { value: DayOfWeek; label: string; short: string }[] = [
+ { value: 'MONDAY', label: 'Lunes', short: 'Lun' },
+ { value: 'TUESDAY', label: 'Martes', short: 'Mar' },
+ { value: 'WEDNESDAY', label: 'Miércoles', short: 'Mié' },
+ { value: 'THURSDAY', label: 'Jueves', short: 'Jue' },
+ { value: 'FRIDAY', label: 'Viernes', short: 'Vie' },
+ { value: 'SATURDAY', label: 'Sábado', short: 'Sáb' },
+ { value: 'SUNDAY', label: 'Domingo', short: 'Dom' },
+]
+export const dayLabel = (day: DayOfWeek) => DAYS.find((d) => d.value === day)?.label ?? day
+
+export type Tone = 'ok' | 'warn' | 'danger' | 'info' | 'neutral'
+
+export const ORGANIZATION_STATUS: Record = {
+ PENDING_APPROVAL: { label: 'Pendiente de aprobación', tone: 'warn' },
+ ACTIVE: { label: 'Activo', tone: 'ok' },
+ SUSPENDED: { label: 'Suspendido', tone: 'danger' },
+ REJECTED: { label: 'Rechazado', tone: 'danger' },
+}
+export const CLIENT_STATUS: Record = {
+ PENDING_VERIFICATION: { label: 'Sin verificar', tone: 'warn' },
+ ACTIVE: { label: 'Activo', tone: 'ok' },
+ SUSPENDED: { label: 'Suspendido', tone: 'danger' },
+}
+export const SERVICE_STATUS: Record = {
+ ACTIVE: { label: 'Activo', tone: 'ok' },
+ INACTIVE: { label: 'Inactivo', tone: 'neutral' },
+}
+export const RESOURCE_CONDITION: Record = {
+ AVAILABLE: { label: 'Disponible', tone: 'ok' },
+ IN_MAINTENANCE: { label: 'En mantenimiento', tone: 'warn' },
+ RETIRED: { label: 'Retirado', tone: 'neutral' },
+}
+
+export const CHANNELS = { EMAIL: 'Correo', SMS: 'SMS', WHATSAPP: 'WhatsApp' } as const
+
+/** Local datetime-local value ("2026-11-03T08:00") to the Instant the API expects. */
+export const toInstant = (local: string) => new Date(local).toISOString()
diff --git a/front/src/lib/forms.ts b/front/src/lib/forms.ts
new file mode 100644
index 0000000..6850219
--- /dev/null
+++ b/front/src/lib/forms.ts
@@ -0,0 +1,21 @@
+import type { FieldValues, Path, UseFormSetError } from 'react-hook-form'
+import { fieldErrors } from './errors'
+
+/**
+ * Puts the backend's per-field messages (details = field → message, already validated server side)
+ * on the form. `rename` maps API field names to form paths when the form groups them differently.
+ * Returns true when at least one message was placed.
+ */
+export function applyServerErrors(
+ error: unknown,
+ setError: UseFormSetError,
+ rename: (field: string) => string = (field) => field,
+): boolean {
+ const entries = fieldErrors(error)
+ for (const [field, message] of entries) {
+ // Spring reports "locations[0].name"; react-hook-form paths use "locations.0.name"
+ const path = rename(field).replace(/\[(\d+)\]/g, '.$1')
+ setError(path as Path, { type: 'server', message })
+ }
+ return entries.length > 0
+}
diff --git a/front/src/lib/schemas.ts b/front/src/lib/schemas.ts
new file mode 100644
index 0000000..e51b8d9
--- /dev/null
+++ b/front/src/lib/schemas.ts
@@ -0,0 +1,122 @@
+import { z } from 'zod'
+
+// Same rules as the backend DTOs and PasswordPolicy, so most mistakes show before a round trip.
+
+export const email = z.string().trim().min(1, 'Ingresa tu correo').email('Correo no válido').max(160, 'Máximo 160 caracteres')
+
+export const password = z
+ .string()
+ .min(8, 'Mínimo 8 caracteres')
+ .max(72, 'Máximo 72 caracteres')
+ .regex(/[A-Z]/, 'Incluye una mayúscula')
+ .regex(/[a-z]/, 'Incluye una minúscula')
+ .regex(/[0-9]/, 'Incluye un número')
+ .regex(/[^A-Za-z0-9]/, 'Incluye un carácter especial')
+
+export const phone = z.string().trim().regex(/^\+?[0-9]{7,15}$/, 'Entre 7 y 15 dígitos, con + opcional al inicio')
+
+function isAdult(iso: string): boolean {
+ const birth = new Date(`${iso}T00:00:00`)
+ const limit = new Date()
+ limit.setFullYear(limit.getFullYear() - 18)
+ return birth <= limit
+}
+
+export const clientSchema = z.object({
+ fullName: z.string().trim().min(1, 'Ingresa tu nombre completo').max(120, 'Máximo 120 caracteres'),
+ document: z.string().trim().regex(/^[A-Za-z0-9-]{5,20}$/, '5 a 20 letras, números o guiones'),
+ birthDate: z
+ .string()
+ .min(1, 'Ingresa tu fecha de nacimiento')
+ .refine((v) => new Date(v) < new Date(), 'Debe ser una fecha pasada')
+ .refine(isAdult, 'Debes ser mayor de 18 años'),
+ email,
+ phone,
+ city: z.string().trim().min(1, 'Ingresa tu ciudad').max(80, 'Máximo 80 caracteres'),
+ notificationChannel: z.enum(['EMAIL', 'SMS', 'WHATSAPP']),
+ password,
+})
+export type ClientForm = z.infer
+
+export const loginSchema = z.object({
+ email: z.string().trim().min(1, 'Ingresa tu correo').email('Correo no válido'),
+ password: z.string().min(1, 'Ingresa tu contraseña'),
+})
+
+export const resetSchema = z
+ .object({ newPassword: password, confirm: z.string() })
+ .refine((v) => v.newPassword === v.confirm, { path: ['confirm'], message: 'Las contraseñas no coinciden' })
+
+const minutes = (label: string) =>
+ z.coerce.number({ message: `${label}: ingresa un número` }).int('Debe ser entero').min(0, 'No puede ser negativo')
+
+export const policySchema = z
+ .object({
+ bookingNoticeMinutesMin: minutes('Anticipación mínima'),
+ bookingNoticeMinutesMax: minutes('Anticipación máxima'),
+ freeCancellationWindowMinutes: minutes('Cancelación gratuita'),
+ })
+ .refine((v) => v.bookingNoticeMinutesMin <= v.bookingNoticeMinutesMax, {
+ path: ['bookingNoticeMinutesMax'],
+ message: 'Debe ser mayor o igual a la mínima',
+ })
+
+export const providerSchema = z.object({
+ email,
+ password,
+ name: z.string().trim().min(1, 'Ingresa el nombre del negocio').max(160, 'Máximo 160 caracteres'),
+ categoryId: z.string().min(1, 'Elige una categoría'),
+ identificationType: z.enum(['NIT', 'CC', 'CE', 'PASAPORTE']),
+ identificationNumber: z.string().trim().regex(/^(?=.*[A-Za-z0-9])[A-Za-z0-9-]{5,20}$/, '5 a 20 letras, números o guiones'),
+ contactEmail: z.union([z.literal(''), email]),
+ contactPhone: phone,
+ locations: z
+ .array(
+ z.object({
+ name: z.string().trim().min(1, 'Ingresa el nombre de la sede').max(120, 'Máximo 120 caracteres'),
+ address: z.string().trim().min(1, 'Ingresa la dirección').max(200, 'Máximo 200 caracteres'),
+ cityId: z.string().min(1, 'Elige una ciudad'),
+ }),
+ )
+ .min(1, 'Agrega al menos una sede')
+ .max(20, 'Máximo 20 sedes'),
+ policy: policySchema,
+})
+export type ProviderForm = z.input
+
+export const resourceTypeSchema = z.object({
+ name: z.string().trim().min(1, 'Ingresa un nombre').max(80, 'Máximo 80 caracteres'),
+ description: z.string().trim().max(300, 'Máximo 300 caracteres').optional(),
+})
+
+export const resourceSchema = z.object({
+ resourceTypeId: z.string().min(1, 'Elige un tipo'),
+ locationId: z.string().min(1, 'Elige una sede'),
+ name: z.string().trim().min(1, 'Ingresa un nombre').max(120, 'Máximo 120 caracteres'),
+ capacity: z.coerce.number({ message: 'Ingresa un número' }).int().min(1, 'Mínimo 1').max(1000, 'Máximo 1000'),
+})
+
+export const maintenanceSchema = z
+ .object({
+ startsAt: z.string().min(1, 'Elige el inicio'),
+ endsAt: z.string().min(1, 'Elige el fin'),
+ reason: z.string().trim().max(300, 'Máximo 300 caracteres').optional(),
+ })
+ .refine((v) => !v.startsAt || !v.endsAt || new Date(v.endsAt) > new Date(v.startsAt), {
+ path: ['endsAt'],
+ message: 'El fin debe ser posterior al inicio',
+ })
+
+const optionalMinutes = (max: number) =>
+ z.coerce.number({ message: 'Ingresa un número' }).int().min(0, 'No puede ser negativo').max(max, `Máximo ${max}`)
+
+export const serviceSchema = z.object({
+ name: z.string().trim().min(1, 'Ingresa un nombre').max(120, 'Máximo 120 caracteres'),
+ description: z.string().trim().max(500, 'Máximo 500 caracteres').optional(),
+ durationMinutes: z.coerce.number({ message: 'Ingresa un número' }).int().min(1, 'Mínimo 1').max(1440, 'Máximo 1440'),
+ preparationMinutes: optionalMinutes(1440),
+ cleanupMinutes: optionalMinutes(1440),
+ price: z.coerce.number({ message: 'Ingresa un número' }).min(0, 'No puede ser negativo'),
+ customerCapacity: z.coerce.number({ message: 'Ingresa un número' }).int().min(1, 'Mínimo 1').max(500, 'Máximo 500'),
+})
+export type ServiceForm = z.input
diff --git a/front/src/lib/useListParams.ts b/front/src/lib/useListParams.ts
new file mode 100644
index 0000000..57b21c2
--- /dev/null
+++ b/front/src/lib/useListParams.ts
@@ -0,0 +1,35 @@
+import { useSearchParams } from 'react-router-dom'
+import type { AdminListQuery } from '@/api/types'
+
+export const PAGE_SIZE = 20
+
+/** Admin listing filters live in the URL, so a filtered view can be shared and survives a reload. */
+export function useListParams(defaultSort = 'registered') {
+ const [params, setParams] = useSearchParams()
+ const get = (key: string) => params.get(key) ?? ''
+
+ const update = (changes: Record) => {
+ const next = new URLSearchParams(params)
+ for (const [key, value] of Object.entries(changes)) {
+ if (value) next.set(key, value)
+ else next.delete(key)
+ }
+ if (!('pagina' in changes)) next.delete('pagina')
+ setParams(next, { replace: true })
+ }
+
+ const query: AdminListQuery = {
+ status: get('estado') || undefined,
+ categoryId: get('categoria') || undefined,
+ city: get('ciudad') || undefined,
+ registeredFrom: get('desde') || undefined,
+ registeredTo: get('hasta') || undefined,
+ sort: get('orden') || defaultSort,
+ direction: get('dir') === 'asc' ? 'asc' : 'desc',
+ page: Math.max(0, Number(get('pagina')) || 0),
+ size: PAGE_SIZE,
+ }
+ const hasFilters = ['estado', 'categoria', 'ciudad', 'desde', 'hasta'].some((key) => params.has(key))
+
+ return { get, update, query, hasFilters, reset: () => setParams({}, { replace: true }) }
+}
diff --git a/front/src/main.tsx b/front/src/main.tsx
new file mode 100644
index 0000000..4f1c1ec
--- /dev/null
+++ b/front/src/main.tsx
@@ -0,0 +1,34 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { BrowserRouter } from 'react-router-dom'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { Toaster } from 'sonner'
+import { ApiError } from '@/api/client'
+import { AuthProvider } from '@/auth/AuthContext'
+import App from './App'
+import './index.css'
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000,
+ refetchOnWindowFocus: false,
+ // A 4xx will not fix itself; only network trouble and 5xx are worth another try
+ retry: (count, error) => count < 1 && !(error instanceof ApiError && error.status >= 400 && error.status < 500),
+ },
+ mutations: { retry: false },
+ },
+})
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+
+
+
+ ,
+)
diff --git a/front/src/pages/account/InboxPage.tsx b/front/src/pages/account/InboxPage.tsx
new file mode 100644
index 0000000..d4cf466
--- /dev/null
+++ b/front/src/pages/account/InboxPage.tsx
@@ -0,0 +1,42 @@
+import { useQuery } from '@tanstack/react-query'
+import { inboxApi } from '@/api/endpoints'
+import { EmptyState, ErrorState, PageHeader } from '@/components/ui/Blocks'
+import { Skeleton } from '@/components/ui/Spinner'
+import { formatDateTime } from '@/lib/format'
+
+export default function InboxPage() {
+ const emails = useQuery({ queryKey: ['inbox'], queryFn: inboxApi.emails })
+
+ return (
+
+
+ {emails.isPending ? (
+
+ {Array.from({ length: 3 }, (_, i) => (
+
+ ))}
+
+ ) : emails.isError ? (
+
emails.refetch()} />
+ ) : emails.data.length === 0 ? (
+
+ ) : (
+
+ {emails.data.map((mail) => (
+
+
+
+ {mail.subject}
+
+ {formatDateTime(mail.sentAt)}
+
+
+ {mail.body}
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/front/src/pages/admin/AdminClientsPage.tsx b/front/src/pages/admin/AdminClientsPage.tsx
new file mode 100644
index 0000000..2a0d22a
--- /dev/null
+++ b/front/src/pages/admin/AdminClientsPage.tsx
@@ -0,0 +1,166 @@
+import { useState } from 'react'
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { DownloadSimple } from '@phosphor-icons/react'
+import { toast } from 'sonner'
+import { adminApi } from '@/api/endpoints'
+import type { ClientRow } from '@/api/types'
+import { StatusBadge } from '@/components/ui/Badge'
+import { EmptyState, ErrorState, Pagination } from '@/components/ui/Blocks'
+import { Button } from '@/components/ui/Button'
+import { Input, Select } from '@/components/ui/Form'
+import { ReasonModal } from '@/components/ui/ReasonModal'
+import { Skeleton } from '@/components/ui/Spinner'
+import { errorMessage } from '@/lib/errors'
+import { CHANNELS, CLIENT_STATUS, formatDate } from '@/lib/format'
+import { useListParams } from '@/lib/useListParams'
+
+const SORTS = [
+ { value: 'registered', label: 'Fecha de registro' },
+ { value: 'name', label: 'Nombre' },
+ { value: 'email', label: 'Correo' },
+ { value: 'city', label: 'Ciudad' },
+ { value: 'status', label: 'Estado' },
+]
+
+export default function AdminClientsPage() {
+ const { get, update, query, hasFilters, reset } = useListParams()
+ const queryClient = useQueryClient()
+ const [target, setTarget] = useState<{ row: ClientRow; action: 'suspension' | 'reactivation' } | null>(null)
+
+ const list = useQuery({
+ queryKey: ['admin-clients', query],
+ queryFn: () => adminApi.clients(query),
+ placeholderData: keepPreviousData,
+ })
+ const exportCsv = useMutation({
+ mutationFn: () => adminApi.exportClients(query),
+ onError: (error) => toast.error(errorMessage(error)),
+ })
+
+ const run = async (reason: string) => {
+ if (!target) return
+ await adminApi.clientAction(target.row.id, target.action, reason)
+ toast.success(target.action === 'suspension' ? 'Cliente suspendido' : 'Cliente reactivado')
+ queryClient.invalidateQueries({ queryKey: ['admin-clients'] })
+ }
+
+ const data = list.data
+ return (
+
+
+
+
+ {list.isPending ? (
+
+ ) : list.isError ? (
+
list.refetch()} />
+ ) : data && data.items.length === 0 ? (
+
+ ) : (
+ data && (
+ <>
+
+
+ Clientes registrados
+
+
+ Cliente
+ Contacto
+ Ciudad
+ Avisos
+ Estado
+ Registro
+ Acciones
+
+
+
+ {data.items.map((row) => (
+
+
+ {row.fullName}
+ {row.document}
+
+
+ {row.email}
+ {row.phone}
+
+ {row.city}
+ {CHANNELS[row.notificationChannel]}
+
+ {formatDate(row.registeredAt)}
+
+
+ {row.status === 'ACTIVE' && (
+ setTarget({ row, action: 'suspension' })}>
+ Suspender
+
+ )}
+ {row.status === 'SUSPENDED' && (
+ setTarget({ row, action: 'reactivation' })}>
+ Reactivar
+
+ )}
+
+
+
+ ))}
+
+
+
+ update({ pagina: p ? String(p) : null })} />
+ >
+ )
+ )}
+
+
+ {target && (
+
setTarget(null)}
+ />
+ )}
+
+ )
+}
diff --git a/front/src/pages/admin/AdminLayout.tsx b/front/src/pages/admin/AdminLayout.tsx
new file mode 100644
index 0000000..dc1d5e1
--- /dev/null
+++ b/front/src/pages/admin/AdminLayout.tsx
@@ -0,0 +1,17 @@
+import { Outlet } from 'react-router-dom'
+import { TabNav } from '@/components/layout/Shell'
+
+export default function AdminLayout() {
+ return (
+
+
Administración
+
+
+
+ )
+}
diff --git a/front/src/pages/admin/AdminProvidersPage.tsx b/front/src/pages/admin/AdminProvidersPage.tsx
new file mode 100644
index 0000000..ff9464a
--- /dev/null
+++ b/front/src/pages/admin/AdminProvidersPage.tsx
@@ -0,0 +1,185 @@
+import { useState } from 'react'
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { DownloadSimple } from '@phosphor-icons/react'
+import { toast } from 'sonner'
+import { adminApi } from '@/api/endpoints'
+import { useCategories } from '@/api/hooks'
+import type { ProviderRow } from '@/api/types'
+import { StatusBadge } from '@/components/ui/Badge'
+import { EmptyState, ErrorState, Pagination } from '@/components/ui/Blocks'
+import { Button } from '@/components/ui/Button'
+import { Input, Select } from '@/components/ui/Form'
+import { ReasonModal } from '@/components/ui/ReasonModal'
+import { Skeleton } from '@/components/ui/Spinner'
+import { errorMessage } from '@/lib/errors'
+import { formatDate, ORGANIZATION_STATUS } from '@/lib/format'
+import { useListParams } from '@/lib/useListParams'
+
+type Action = 'approval' | 'rejection' | 'suspension' | 'reactivation'
+const ACTIONS: Record = {
+ approval: { title: 'Aprobar proveedor', confirm: 'Aprobar', danger: false, description: 'Pasa a estar activo y recibe un correo de aprobación.', done: 'Proveedor aprobado' },
+ rejection: { title: 'Rechazar solicitud', confirm: 'Rechazar', danger: true, description: 'La solicitud queda rechazada.', done: 'Solicitud rechazada' },
+ suspension: { title: 'Suspender proveedor', confirm: 'Suspender', danger: true, description: 'Deja de ser visible y reservable.', done: 'Proveedor suspendido' },
+ reactivation: { title: 'Reactivar proveedor', confirm: 'Reactivar', danger: false, description: 'Vuelve a estar visible y reservable.', done: 'Proveedor reactivado' },
+}
+const SORTS = [
+ { value: 'registered', label: 'Fecha de registro' },
+ { value: 'name', label: 'Nombre' },
+ { value: 'status', label: 'Estado' },
+ { value: 'category', label: 'Categoría' },
+]
+
+export default function AdminProvidersPage() {
+ const { get, update, query, hasFilters, reset } = useListParams()
+ const categories = useCategories()
+ const queryClient = useQueryClient()
+ const [target, setTarget] = useState<{ row: ProviderRow; action: Action } | null>(null)
+
+ const list = useQuery({
+ queryKey: ['admin-providers', query],
+ queryFn: () => adminApi.providers(query),
+ placeholderData: keepPreviousData,
+ })
+
+ const exportCsv = useMutation({
+ mutationFn: () => adminApi.exportProviders(query),
+ onError: (error) => toast.error(errorMessage(error)),
+ })
+
+ const run = async (reason: string, confirm: boolean) => {
+ if (!target) return
+ await adminApi.providerAction(target.row.id, target.action, target.action === 'suspension' ? { reason, confirm } : { reason })
+ toast.success(ACTIONS[target.action].done)
+ queryClient.invalidateQueries({ queryKey: ['admin-providers'] })
+ }
+
+ const actionsFor = (status: ProviderRow['status']): Action[] =>
+ status === 'PENDING_APPROVAL' ? ['approval', 'rejection'] : status === 'ACTIVE' ? ['suspension'] : status === 'SUSPENDED' ? ['reactivation'] : []
+
+ const data = list.data
+ return (
+
+
+
+
+ {list.isPending ? (
+
+ ) : list.isError ? (
+
list.refetch()} />
+ ) : data && data.items.length === 0 ? (
+
+ ) : (
+ data && (
+ <>
+
+
+ Proveedores registrados
+
+
+ Negocio
+ Categoría
+ Contacto
+ Ciudades
+ Estado
+ Registro
+ Acciones
+
+
+
+ {data.items.map((row) => (
+
+
+ {row.name}
+ {row.identificationType} {row.identificationNumber}
+
+ {row.category}
+
+ {row.contactEmail}
+ {row.contactPhone}
+
+ {row.cities ?? '—'}
+
+ {formatDate(row.registeredAt)}
+
+
+ {actionsFor(row.status).map((action) => (
+ setTarget({ row, action })}
+ aria-label={`${ACTIONS[action].confirm} ${row.name}`}
+ >
+ {ACTIONS[action].confirm}
+
+ ))}
+
+
+
+ ))}
+
+
+
+ update({ pagina: p ? String(p) : null })} />
+ >
+ )
+ )}
+
+
+ {target && (
+
setTarget(null)}
+ />
+ )}
+
+ )
+}
diff --git a/front/src/pages/provider/AgendasPage.tsx b/front/src/pages/provider/AgendasPage.tsx
new file mode 100644
index 0000000..bd302c5
--- /dev/null
+++ b/front/src/pages/provider/AgendasPage.tsx
@@ -0,0 +1,312 @@
+import { useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { Plus } from '@phosphor-icons/react'
+import { toast } from 'sonner'
+import { organizationApi } from '@/api/endpoints'
+import type { Agenda, Block, DayOfWeek } from '@/api/types'
+import { ApiError } from '@/api/client'
+import { Alert, EmptyState, ErrorState } from '@/components/ui/Blocks'
+import { Button } from '@/components/ui/Button'
+import { Input, Select } from '@/components/ui/Form'
+import { Modal } from '@/components/ui/Modal'
+import { Skeleton } from '@/components/ui/Spinner'
+import { errorMessage } from '@/lib/errors'
+import { DAYS, formatDate } from '@/lib/format'
+import { BlocksEditor, blocksProblem, hhmm, normalizeBlocks } from './BlocksEditor'
+import { useOrganization } from './OrganizationLayout'
+
+const SLOTS = [15, 30, 60]
+
+function summarize(blocks: Block[]): string {
+ return DAYS.map((day) => {
+ const ranges = blocks
+ .filter((b) => b.dayOfWeek === day.value)
+ .sort((a, b) => a.startTime.localeCompare(b.startTime))
+ .map((b) => `${hhmm(b.startTime)}–${hhmm(b.endTime)}`)
+ return ranges.length ? `${day.short} ${ranges.join(', ')}` : null
+ })
+ .filter(Boolean)
+ .join(' · ')
+}
+
+/** HAS_FUTURE_BOOKINGS lists the bookings that would break; the message says what to do. */
+function agendaError(error: unknown): string {
+ if (error instanceof ApiError && error.errorCode === 'HAS_FUTURE_BOOKINGS') {
+ return 'Hay reservas futuras que quedarían fuera de este cambio. Resuélvelas primero.'
+ }
+ return errorMessage(error)
+}
+
+export default function AgendasPage() {
+ const org = useOrganization()
+ const queryClient = useQueryClient()
+ const agendas = useQuery({ queryKey: ['agendas', org.id], queryFn: () => organizationApi.agendas(org.id) })
+ const services = useQuery({ queryKey: ['services', org.id], queryFn: () => organizationApi.services(org.id) })
+ const resources = useQuery({ queryKey: ['resources', org.id], queryFn: () => organizationApi.resources(org.id) })
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ['agendas', org.id] })
+
+ // ---- create
+ const [creating, setCreating] = useState(false)
+ const [name, setName] = useState('')
+ const [locationId, setLocationId] = useState(org.locations.length === 1 ? org.locations[0].id : '')
+ const [resourceId, setResourceId] = useState('')
+ const [slotMinutes, setSlotMinutes] = useState(30)
+ const [validFrom, setValidFrom] = useState(new Date().toISOString().slice(0, 10))
+ const [validUntil, setValidUntil] = useState('')
+ const [serviceIds, setServiceIds] = useState([])
+ const [blocks, setBlocks] = useState([])
+ const [formError, setFormError] = useState(null)
+
+ const create = useMutation({
+ mutationFn: () =>
+ organizationApi.createAgenda(org.id, {
+ name: name.trim(),
+ locationId,
+ resourceId: resourceId || undefined,
+ slotMinutes,
+ validFrom,
+ validUntil: validUntil || undefined,
+ serviceIds,
+ blocks,
+ }),
+ onSuccess: (created) => {
+ refresh()
+ toast.success(`Agenda «${created.name}» creada`)
+ setCreating(false)
+ setName('')
+ setBlocks([])
+ setServiceIds([])
+ },
+ onError: (error) => setFormError(agendaError(error)),
+ })
+
+ const submitCreate = (event: React.FormEvent) => {
+ event.preventDefault()
+ const problem =
+ (!name.trim() && 'Ingresa un nombre.') ||
+ (!locationId && 'Elige una sede.') ||
+ (!validFrom && 'Elige desde cuándo es válida.') ||
+ (validUntil && validUntil < validFrom && 'La fecha final no puede ser anterior a la inicial.') ||
+ (serviceIds.length === 0 && 'Elige al menos un servicio que se pueda reservar en esta agenda.') ||
+ blocksProblem(blocks)
+ setFormError(problem || null)
+ if (!problem) create.mutate()
+ }
+
+ // ---- manage one
+ const [managing, setManaging] = useState(null)
+ const [draft, setDraft] = useState([])
+ const [manageError, setManageError] = useState(null)
+ const [copyFrom, setCopyFrom] = useState('MONDAY')
+ const [copyTo, setCopyTo] = useState([])
+
+ const open = (agenda: Agenda) => {
+ setManaging(agenda)
+ setDraft(normalizeBlocks(agenda.blocks))
+ setManageError(null)
+ setCopyTo([])
+ }
+ const applyResult = (agenda: Agenda, message: string) => {
+ refresh()
+ setManaging(agenda)
+ setDraft(normalizeBlocks(agenda.blocks))
+ setManageError(null)
+ toast.success(message)
+ }
+ const onManageError = (error: unknown) => setManageError(agendaError(error))
+
+ const saveBlocks = useMutation({
+ mutationFn: () => organizationApi.replaceBlocks(org.id, managing!.id, draft),
+ onSuccess: (agenda) => applyResult(agenda, 'Horarios guardados'),
+ onError: onManageError,
+ })
+ const changeGranularity = useMutation({
+ mutationFn: (minutes: number) => organizationApi.changeGranularity(org.id, managing!.id, minutes),
+ onSuccess: (agenda) => applyResult(agenda, 'Intervalo actualizado'),
+ onError: onManageError,
+ })
+ const copyDay = useMutation({
+ mutationFn: () => organizationApi.copyDay(org.id, managing!.id, copyFrom, copyTo),
+ onSuccess: (agenda) => applyResult(agenda, 'Horario copiado'),
+ onError: onManageError,
+ })
+
+ const serviceName = (id: string) => services.data?.find((s) => s.id === id)?.name ?? '—'
+ const locationName = (id: string) => org.locations.find((l) => l.id === id)?.name ?? '—'
+ const locationResources = resources.data?.filter((r) => r.locationId === locationId && r.condition !== 'RETIRED') ?? []
+ const noServices = services.isSuccess && services.data.length === 0
+ const draftProblem = managing ? blocksProblem(draft) : null
+
+ return (
+
+
+
+ Una agenda define cuándo atiendes en una sede, o con un recurso en particular, y cada cuánto se ofrecen los horarios.
+
+
{ create.reset(); setFormError(null); setCreating(true) }} disabled={noServices}>
+ Nueva agenda
+
+
+
+ {noServices && (
+
+
+
+ Ir a servicios
+
+
+
+ )}
+
+ {agendas.isPending ? (
+
+ ) : agendas.isError ? (
+
agendas.refetch()} />
+ ) : agendas.data.length === 0 ? (
+
+ ) : (
+
+ {agendas.data.map((agenda) => (
+
+ {agenda.name}
+
+ {locationName(agenda.locationId)}
+ {agenda.resourceId && ` · ${resources.data?.find((r) => r.id === agenda.resourceId)?.name ?? 'recurso'}`} · cada{' '}
+ {agenda.slotMinutes} min
+
+
+ Desde {formatDate(agenda.validFrom)}
+ {agenda.validUntil ? ` hasta ${formatDate(agenda.validUntil)}` : ', sin fecha de fin'}
+
+ {summarize(agenda.blocks)}
+ Servicios: {agenda.serviceIds.map(serviceName).join(', ')}
+
+ open(agenda)}>
+ Gestionar horarios
+
+
+
+ ))}
+
+ )}
+
+ setCreating(false)} title="Nueva agenda">
+
+ setName(e.target.value)} maxLength={120} />
+ { setLocationId(e.target.value); setResourceId('') }}
+ >
+ Elige una sede
+ {org.locations.map((l) => (
+ {l.name}
+ ))}
+
+ setResourceId(e.target.value)} disabled={!locationId}>
+ Toda la sede
+ {locationResources.map((r) => (
+ {r.name}
+ ))}
+
+
+ setSlotMinutes(Number(e.target.value))}>
+ {SLOTS.map((s) => (
+ {s} minutos
+ ))}
+
+ setValidFrom(e.target.value)} />
+ setValidUntil(e.target.value)} />
+
+
+
+ Servicios que se reservan aquí *
+ {services.data?.map((service) => (
+
+ setServiceIds((ids) => (e.target.checked ? [...ids, service.id] : ids.filter((id) => id !== service.id)))}
+ />
+ {service.name}
+
+ ))}
+
+
+
+
+ {formError && }
+
+ setCreating(false)}>Cancelar
+ Crear agenda
+
+
+
+
+ setManaging(null)}
+ title={managing ? `Horarios de ${managing.name}` : 'Horarios'}
+ description="Los cambios se validan contra las reservas futuras."
+ >
+ {managing && (
+
+ {manageError &&
}
+
+
+ {draftProblem &&
{draftProblem}
}
+
+ saveBlocks.mutate()}>
+ Guardar horarios
+
+
+
+
+ changeGranularity.mutate(Number(e.target.value))}
+ disabled={changeGranularity.isPending}
+ >
+ {SLOTS.map((s) => (
+ {s} minutos
+ ))}
+
+
+
+
+ Copiar el horario de un día a otros (se guarda al instante)
+ setCopyFrom(e.target.value as DayOfWeek)}>
+ {DAYS.map((d) => (
+ {d.label}
+ ))}
+
+
+ {DAYS.filter((d) => d.value !== copyFrom).map((d) => (
+
+ setCopyTo((days) => (e.target.checked ? [...days, d.value] : days.filter((x) => x !== d.value)))}
+ />
+ {d.short}
+
+ ))}
+
+ copyDay.mutate()}>
+ Copiar
+
+
+
+ )}
+
+
+ )
+}
diff --git a/front/src/pages/provider/BlocksEditor.tsx b/front/src/pages/provider/BlocksEditor.tsx
new file mode 100644
index 0000000..785cd39
--- /dev/null
+++ b/front/src/pages/provider/BlocksEditor.tsx
@@ -0,0 +1,97 @@
+import { Plus, Trash } from '@phosphor-icons/react'
+import type { Block, DayOfWeek } from '@/api/types'
+import { Button } from '@/components/ui/Button'
+import { DAYS } from '@/lib/format'
+
+/** The API answers "08:00:00"; time inputs and the request use "08:00". */
+export const hhmm = (time: string) => time.slice(0, 5)
+export const normalizeBlocks = (blocks: Block[]): Block[] =>
+ blocks.map((b) => ({ ...b, startTime: hhmm(b.startTime), endTime: hhmm(b.endTime) }))
+
+/** First problem found in a weekly schedule, in plain words, or null when it is valid. */
+export function blocksProblem(blocks: Block[]): string | null {
+ if (blocks.length === 0) return 'Agrega al menos un horario.'
+ for (const day of DAYS) {
+ const ofDay = blocks.filter((b) => b.dayOfWeek === day.value).sort((a, b) => a.startTime.localeCompare(b.startTime))
+ for (let i = 0; i < ofDay.length; i++) {
+ const block = ofDay[i]
+ if (!block.startTime || !block.endTime) return `${day.label}: completa la hora de inicio y de fin.`
+ if (block.endTime <= block.startTime) return `${day.label}: la hora de fin debe ser posterior a la de inicio.`
+ if (i > 0 && block.startTime < ofDay[i - 1].endTime) return `${day.label}: hay horarios que se cruzan.`
+ }
+ }
+ return null
+}
+
+const timeClass =
+ 'min-h-11 rounded-md border border-line-strong bg-surface px-2 text-ink focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/25'
+
+interface Props {
+ value: Block[]
+ onChange: (blocks: Block[]) => void
+}
+
+/** One row per day of the week with its opening ranges. */
+export function BlocksEditor({ value, onChange }: Props) {
+ const add = (day: DayOfWeek) => onChange([...value, { dayOfWeek: day, startTime: '08:00', endTime: '12:00' }])
+ const patch = (target: Block, changes: Partial) => onChange(value.map((b) => (b === target ? { ...b, ...changes } : b)))
+ const remove = (target: Block) => onChange(value.filter((b) => b !== target))
+ const copyDay = (from: DayOfWeek, to: DayOfWeek[]) => {
+ const source = value.filter((b) => b.dayOfWeek === from)
+ onChange([...value.filter((b) => !to.includes(b.dayOfWeek)), ...to.flatMap((day) => source.map((b) => ({ ...b, dayOfWeek: day })))])
+ }
+
+ return (
+
+ {DAYS.map((day) => {
+ const ofDay = value.filter((b) => b.dayOfWeek === day.value)
+ return (
+
+
{day.label}
+
+ {ofDay.length === 0 &&
Cerrado
}
+ {ofDay.map((block, i) => (
+
+ patch(block, { startTime: e.target.value })}
+ className={timeClass}
+ />
+ a
+ patch(block, { endTime: e.target.value })}
+ className={timeClass}
+ />
+ remove(block)}>
+
+
+
+ ))}
+
+
add(day.value)}>
+ Horario
+
+ {ofDay.length > 0 && (
+ <>
+
copyDay(day.value, DAYS.filter((d) => d.value !== day.value && d.value !== 'SATURDAY' && d.value !== 'SUNDAY').map((d) => d.value))}
+ >
+ Copiar a días hábiles
+
+ >
+ )}
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/front/src/pages/provider/OrganizationLayout.tsx b/front/src/pages/provider/OrganizationLayout.tsx
new file mode 100644
index 0000000..95e72f8
--- /dev/null
+++ b/front/src/pages/provider/OrganizationLayout.tsx
@@ -0,0 +1,80 @@
+import { createContext, useContext } from 'react'
+import { Link, Outlet, useParams } from 'react-router-dom'
+import { useQuery } from '@tanstack/react-query'
+import { CaretLeft } from '@phosphor-icons/react'
+import { organizationApi } from '@/api/endpoints'
+import type { Organization } from '@/api/types'
+import { TabNav } from '@/components/layout/Shell'
+import { StatusBadge } from '@/components/ui/Badge'
+import { Alert, ErrorState } from '@/components/ui/Blocks'
+import { PageLoader } from '@/components/ui/Spinner'
+import { ORGANIZATION_STATUS } from '@/lib/format'
+import { ApiError } from '@/api/client'
+
+const OrganizationContext = createContext(null)
+
+export function useOrganization(): Organization {
+ const org = useContext(OrganizationContext)
+ if (!org) throw new Error('useOrganization must be used inside OrganizationLayout')
+ return org
+}
+
+export default function OrganizationLayout() {
+ const { orgId = '' } = useParams()
+ const org = useQuery({ queryKey: ['organization', orgId], queryFn: () => organizationApi.one(orgId) })
+
+ if (org.isPending) return
+ if (org.isError) {
+ // Outside the provider every path answers 404 (ADR-0008), so it reads as "not yours or not there".
+ const missing = org.error instanceof ApiError && org.error.status === 404
+ return missing ? (
+
+
+ Volver a mis negocios
+
+
+ ) : (
+ org.refetch()} />
+ )
+ }
+
+ const data = org.data
+ const base = `/negocio/${data.id}`
+ return (
+
+
+
+
Mis negocios
+
+
+
{data.name}
+
+
+ {data.status === 'PENDING_APPROVAL' && (
+
+
+ Puedes ir configurando todo. Los clientes lo verán cuando un administrador lo apruebe.
+
+
+ )}
+ {(data.status === 'SUSPENDED' || data.status === 'REJECTED') && (
+
+
+ Revisa tu bandeja de correo o contacta al soporte de la plataforma.
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/front/src/pages/provider/OrganizationsPage.tsx b/front/src/pages/provider/OrganizationsPage.tsx
new file mode 100644
index 0000000..b728d13
--- /dev/null
+++ b/front/src/pages/provider/OrganizationsPage.tsx
@@ -0,0 +1,56 @@
+import { Link } from 'react-router-dom'
+import { useQuery } from '@tanstack/react-query'
+import { ArrowRight } from '@phosphor-icons/react'
+import { organizationApi } from '@/api/endpoints'
+import { StatusBadge } from '@/components/ui/Badge'
+import { EmptyState, ErrorState, PageHeader } from '@/components/ui/Blocks'
+import { LinkButton } from '@/components/ui/Button'
+import { Skeleton } from '@/components/ui/Spinner'
+import { ORGANIZATION_STATUS } from '@/lib/format'
+
+export default function OrganizationsPage() {
+ const orgs = useQuery({ queryKey: ['organizations'], queryFn: organizationApi.mine })
+
+ return (
+
+
+ {orgs.isPending ? (
+
+
+
+
+ ) : orgs.isError ? (
+
orgs.refetch()} />
+ ) : orgs.data.length === 0 ? (
+ Registrar negocio}
+ />
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/front/src/pages/provider/ResourceTypesPage.tsx b/front/src/pages/provider/ResourceTypesPage.tsx
new file mode 100644
index 0000000..92640e6
--- /dev/null
+++ b/front/src/pages/provider/ResourceTypesPage.tsx
@@ -0,0 +1,101 @@
+import { useState } from 'react'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { Plus } from '@phosphor-icons/react'
+import { toast } from 'sonner'
+import type { z } from 'zod'
+import { ApiError } from '@/api/client'
+import { organizationApi } from '@/api/endpoints'
+import { Alert, EmptyState, ErrorState } from '@/components/ui/Blocks'
+import { Button } from '@/components/ui/Button'
+import { Input, Textarea } from '@/components/ui/Form'
+import { Modal } from '@/components/ui/Modal'
+import { Skeleton } from '@/components/ui/Spinner'
+import { errorMessage } from '@/lib/errors'
+import { applyServerErrors } from '@/lib/forms'
+import { resourceTypeSchema } from '@/lib/schemas'
+import { useOrganization } from './OrganizationLayout'
+
+type FormValues = z.infer
+
+export default function ResourceTypesPage() {
+ const org = useOrganization()
+ const queryClient = useQueryClient()
+ const [open, setOpen] = useState(false)
+ const types = useQuery({ queryKey: ['resource-types', org.id], queryFn: () => organizationApi.resourceTypes(org.id) })
+
+ const {
+ register,
+ handleSubmit,
+ reset,
+ setError,
+ formState: { errors },
+ } = useForm({ resolver: zodResolver(resourceTypeSchema) })
+
+ const create = useMutation({
+ mutationFn: (v: FormValues) => organizationApi.createResourceType(org.id, { name: v.name, description: v.description || undefined }),
+ onSuccess: (created) => {
+ queryClient.invalidateQueries({ queryKey: ['resource-types', org.id] })
+ toast.success(`Tipo «${created.name}» creado`)
+ reset()
+ setOpen(false)
+ },
+ onError: (error) => {
+ if (applyServerErrors(error, setError)) return
+ if (error instanceof ApiError && error.errorCode === 'DUPLICATE_NAME') {
+ setError('name', { message: 'Ya tienes un tipo con ese nombre' })
+ }
+ },
+ })
+
+ return (
+
+
+
+ Un tipo de recurso es una clase de cosa que tu negocio usa para atender: silla, sala, cancha, profesional.
+
+
setOpen(true)}>
+ Nuevo tipo
+
+
+
+ {types.isPending ? (
+
+ ) : types.isError ? (
+
types.refetch()} />
+ ) : types.data.length === 0 ? (
+ setOpen(true)}>Crear el primero}
+ />
+ ) : (
+
+ {types.data.map((type) => (
+
+ {type.name}
+ {type.description && {type.description}
}
+
+ ))}
+
+ )}
+
+ setOpen(false)} title="Nuevo tipo de recurso">
+ create.mutate(v))} className="flex flex-col gap-4">
+
+
+ {create.isError && !errors.name && }
+
+ setOpen(false)}>
+ Cancelar
+
+
+ Crear
+
+
+
+
+
+ )
+}
diff --git a/front/src/pages/provider/ResourcesPage.tsx b/front/src/pages/provider/ResourcesPage.tsx
new file mode 100644
index 0000000..8542127
--- /dev/null
+++ b/front/src/pages/provider/ResourcesPage.tsx
@@ -0,0 +1,238 @@
+import { useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { Plus, Wrench } from '@phosphor-icons/react'
+import { toast } from 'sonner'
+import type { z } from 'zod'
+import { organizationApi } from '@/api/endpoints'
+import type { Resource } from '@/api/types'
+import { StatusBadge } from '@/components/ui/Badge'
+import { Alert, EmptyState, ErrorState } from '@/components/ui/Blocks'
+import { Button } from '@/components/ui/Button'
+import { Input, Select, Textarea } from '@/components/ui/Form'
+import { Modal } from '@/components/ui/Modal'
+import { Skeleton } from '@/components/ui/Spinner'
+import { errorMessage } from '@/lib/errors'
+import { applyServerErrors } from '@/lib/forms'
+import { formatDateTime, RESOURCE_CONDITION, toInstant } from '@/lib/format'
+import { maintenanceSchema, resourceSchema } from '@/lib/schemas'
+import { useOrganization } from './OrganizationLayout'
+
+type ResourceInput = z.input
+type ResourceOutput = z.output
+type MaintenanceForm = z.infer
+
+export default function ResourcesPage() {
+ const org = useOrganization()
+ const queryClient = useQueryClient()
+ const [creating, setCreating] = useState(false)
+ const [maintaining, setMaintaining] = useState(null)
+ const [retiring, setRetiring] = useState(null)
+
+ const types = useQuery({ queryKey: ['resource-types', org.id], queryFn: () => organizationApi.resourceTypes(org.id) })
+ const resources = useQuery({ queryKey: ['resources', org.id], queryFn: () => organizationApi.resources(org.id) })
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ['resources', org.id] })
+
+ const typeName = (id: string) => types.data?.find((t) => t.id === id)?.name ?? '—'
+ const locationName = (id: string) => org.locations.find((l) => l.id === id)?.name ?? '—'
+
+ // Create
+ const createForm = useForm({
+ resolver: zodResolver(resourceSchema),
+ defaultValues: { resourceTypeId: '', locationId: org.locations.length === 1 ? org.locations[0].id : '', capacity: 1 },
+ })
+ const create = useMutation({
+ mutationFn: (v: ResourceOutput) => organizationApi.createResource(org.id, v),
+ onSuccess: (created) => {
+ refresh()
+ toast.success(`Recurso «${created.name}» registrado`)
+ createForm.reset()
+ setCreating(false)
+ },
+ onError: (error) => {
+ if (!applyServerErrors(error, createForm.setError)) createForm.setError('name', { message: errorMessage(error) })
+ },
+ })
+
+ // Maintenance
+ const maintenanceForm = useForm({ resolver: zodResolver(maintenanceSchema) })
+ const maintain = useMutation({
+ mutationFn: (v: MaintenanceForm) =>
+ organizationApi.scheduleMaintenance(org.id, maintaining!.id, {
+ startsAt: toInstant(v.startsAt),
+ endsAt: toInstant(v.endsAt),
+ reason: v.reason || undefined,
+ }),
+ onSuccess: () => {
+ refresh()
+ toast.success('Mantenimiento programado')
+ maintenanceForm.reset()
+ setMaintaining(null)
+ },
+ })
+
+ // Retire
+ const retire = useMutation({
+ mutationFn: () => organizationApi.retireResource(org.id, retiring!.id),
+ onSuccess: () => {
+ refresh()
+ toast.success('Recurso retirado')
+ setRetiring(null)
+ },
+ })
+
+ const noTypes = types.isSuccess && types.data.length === 0
+
+ return (
+
+
+
+ Los recursos son lo concreto que atiende: cada silla, sala o profesional, en una sede y con su capacidad.
+
+
setCreating(true)} disabled={noTypes}>
+ Nuevo recurso
+
+
+
+ {noTypes && (
+
+
+
+ Ir a tipos de recurso
+
+
+
+ )}
+
+ {resources.isPending ? (
+
+ ) : resources.isError ? (
+
resources.refetch()} />
+ ) : resources.data.length === 0 ? (
+
+ ) : (
+
+
+ Recursos del negocio
+
+
+ Nombre
+ Tipo
+ Sede
+ Capacidad
+ Estado
+ Acciones
+
+
+
+ {resources.data.map((resource) => (
+
+
+ {resource.name}
+ {resource.maintenances.length > 0 && (
+
+ {resource.maintenances.map((m) => (
+
+ Mantenimiento {formatDateTime(m.startsAt)} → {formatDateTime(m.endsAt)}
+ {m.reason && ` · ${m.reason}`}
+
+ ))}
+
+ )}
+
+ {typeName(resource.resourceTypeId)}
+ {locationName(resource.locationId)}
+ {resource.capacity}
+
+
+
+
+ {resource.condition !== 'RETIRED' && (
+
+ { maintain.reset(); setMaintaining(resource) }}>
+ Mantenimiento
+
+ { retire.reset(); setRetiring(resource) }}>
+ Retirar
+
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ setCreating(false)} title="Nuevo recurso">
+ create.mutate(v))} className="flex flex-col gap-4">
+
+
+ Elige un tipo
+ {types.data?.map((t) => (
+ {t.name}
+ ))}
+
+
+ Elige una sede
+ {org.locations.map((l) => (
+ {l.name}
+ ))}
+
+
+
+ setCreating(false)}>Cancelar
+ Registrar
+
+
+
+
+ setMaintaining(null)}
+ title="Programar mantenimiento"
+ description={maintaining ? `«${maintaining.name}» no ofrecerá horarios durante este periodo.` : undefined}
+ >
+ maintain.mutate(v))} className="flex flex-col gap-4">
+ {maintain.isError && }
+
+
+
+
+
+
+ setMaintaining(null)}>Cancelar
+ Programar
+
+
+
+
+ setRetiring(null)}
+ title="¿Retirar este recurso?"
+ description={retiring ? `«${retiring.name}» dejará de estar disponible y no podrás volver a activarlo.` : undefined}
+ >
+
+ {retire.isError &&
}
+
+ setRetiring(null)}>Cancelar
+ retire.mutate()}>Retirar recurso
+
+
+
+
+ )
+}
diff --git a/front/src/pages/provider/ServicesPage.tsx b/front/src/pages/provider/ServicesPage.tsx
new file mode 100644
index 0000000..f9d243c
--- /dev/null
+++ b/front/src/pages/provider/ServicesPage.tsx
@@ -0,0 +1,333 @@
+import { useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useForm, type FieldErrors, type UseFormRegister } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { Plus, Trash } from '@phosphor-icons/react'
+import { toast } from 'sonner'
+import type { z } from 'zod'
+import { organizationApi } from '@/api/endpoints'
+import type { OfferedService } from '@/api/types'
+import { StatusBadge } from '@/components/ui/Badge'
+import { Alert, EmptyState, ErrorState } from '@/components/ui/Blocks'
+import { Button } from '@/components/ui/Button'
+import { Input, Select, Textarea } from '@/components/ui/Form'
+import { Modal } from '@/components/ui/Modal'
+import { Skeleton } from '@/components/ui/Spinner'
+import { errorMessage } from '@/lib/errors'
+import { applyServerErrors } from '@/lib/forms'
+import { formatDuration, formatPrice, SERVICE_STATUS } from '@/lib/format'
+import { serviceSchema } from '@/lib/schemas'
+import { useOrganization } from './OrganizationLayout'
+
+type Input_ = z.input
+type Output_ = z.output
+interface Requirement {
+ resourceTypeId: string
+ quantity: number
+}
+
+const EMPTY: Input_ = {
+ name: '',
+ description: '',
+ durationMinutes: 30,
+ preparationMinutes: 0,
+ cleanupMinutes: 0,
+ price: 0,
+ customerCapacity: 1,
+}
+
+function DetailFields({ register, errors }: { register: UseFormRegister; errors: FieldErrors }) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+export default function ServicesPage() {
+ const org = useOrganization()
+ const queryClient = useQueryClient()
+ const services = useQuery({ queryKey: ['services', org.id], queryFn: () => organizationApi.services(org.id) })
+ const types = useQuery({ queryKey: ['resource-types', org.id], queryFn: () => organizationApi.resourceTypes(org.id) })
+ const refresh = () => queryClient.invalidateQueries({ queryKey: ['services', org.id] })
+
+ const [creating, setCreating] = useState(false)
+ const [editing, setEditing] = useState(null)
+ const [deleting, setDeleting] = useState(null)
+ const [locationIds, setLocationIds] = useState(org.locations.length === 1 ? [org.locations[0].id] : [])
+ const [requirements, setRequirements] = useState([{ resourceTypeId: '', quantity: 1 }])
+ const [structureError, setStructureError] = useState(null)
+
+ const createForm = useForm({ resolver: zodResolver(serviceSchema), defaultValues: EMPTY })
+ const editForm = useForm({ resolver: zodResolver(serviceSchema), defaultValues: EMPTY })
+
+ const toDetails = (v: Output_) => ({
+ name: v.name,
+ description: v.description || undefined,
+ durationMinutes: v.durationMinutes,
+ preparationMinutes: v.preparationMinutes,
+ cleanupMinutes: v.cleanupMinutes,
+ price: v.price,
+ customerCapacity: v.customerCapacity,
+ })
+
+ const create = useMutation({
+ mutationFn: (v: Output_) =>
+ organizationApi.createService(org.id, {
+ details: toDetails(v),
+ locationIds,
+ requirements: requirements.filter((r) => r.resourceTypeId).map((r) => ({ resourceTypeId: r.resourceTypeId, quantity: r.quantity })),
+ }),
+ onSuccess: (created) => {
+ refresh()
+ toast.success(`Servicio «${created.name}» creado. Actívalo para que aparezca en el catálogo.`)
+ createForm.reset(EMPTY)
+ setCreating(false)
+ },
+ onError: (error) => {
+ const placed = applyServerErrors(error, createForm.setError, (f) => f.replace(/^details\./, ''))
+ if (!placed) createForm.setError('name', { message: errorMessage(error) })
+ },
+ })
+
+ const update = useMutation({
+ mutationFn: (v: Output_) => organizationApi.updateService(org.id, editing!.id, toDetails(v)),
+ onSuccess: () => {
+ refresh()
+ toast.success('Servicio actualizado. Las reservas existentes conservan su horario.')
+ setEditing(null)
+ },
+ onError: (error) => {
+ if (!applyServerErrors(error, editForm.setError)) editForm.setError('name', { message: errorMessage(error) })
+ },
+ })
+
+ const toggle = useMutation({
+ mutationFn: (service: OfferedService) =>
+ service.status === 'ACTIVE'
+ ? organizationApi.deactivateService(org.id, service.id)
+ : organizationApi.activateService(org.id, service.id),
+ onSuccess: (updated) => {
+ refresh()
+ toast.success(updated.status === 'ACTIVE' ? 'Servicio activado' : 'Servicio desactivado')
+ },
+ onError: (error) => toast.error(errorMessage(error)),
+ })
+
+ const remove = useMutation({
+ mutationFn: () => organizationApi.deleteService(org.id, deleting!.id),
+ onSuccess: () => {
+ refresh()
+ toast.success('Servicio eliminado')
+ setDeleting(null)
+ },
+ })
+
+ const submitCreate = createForm.handleSubmit((v) => {
+ const valid = requirements.filter((r) => r.resourceTypeId)
+ if (locationIds.length === 0) return setStructureError('Elige al menos una sede.')
+ if (valid.length === 0) return setStructureError('Elige al menos un tipo de recurso que necesita el servicio.')
+ setStructureError(null)
+ create.mutate(v)
+ })
+
+ const openEdit = (service: OfferedService) => {
+ update.reset()
+ editForm.reset({
+ name: service.name,
+ description: service.description ?? '',
+ durationMinutes: service.durationMinutes,
+ preparationMinutes: service.preparationMinutes,
+ cleanupMinutes: service.cleanupMinutes,
+ price: service.price,
+ customerCapacity: service.customerCapacity,
+ })
+ setEditing(service)
+ }
+
+ const typeName = (id: string) => types.data?.find((t) => t.id === id)?.name ?? '—'
+ const locationName = (id: string) => org.locations.find((l) => l.id === id)?.name ?? '—'
+ const noTypes = types.isSuccess && types.data.length === 0
+
+ return (
+
+
+
+ Lo que ofreces a tus clientes. Un servicio nuevo nace inactivo; actívalo cuando tengas los recursos listos.
+
+
{ create.reset(); setStructureError(null); setCreating(true) }} disabled={noTypes}>
+ Nuevo servicio
+
+
+
+ {noTypes && (
+
+
+
+ Ir a tipos de recurso
+
+
+
+ )}
+
+ {services.isPending ? (
+
+ ) : services.isError ? (
+
services.refetch()} />
+ ) : services.data.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ setCreating(false)} title="Nuevo servicio">
+
+
+
+
+ Sedes donde se presta *
+ {org.locations.map((location) => (
+
+
+ setLocationIds((ids) => (e.target.checked ? [...ids, location.id] : ids.filter((id) => id !== location.id)))
+ }
+ />
+ {location.name}
+
+ ))}
+
+
+
+ Recursos que necesita *
+ {requirements.map((req, index) => (
+
+ setRequirements((rs) => rs.map((r, i) => (i === index ? { ...r, resourceTypeId: e.target.value } : r)))}
+ >
+ Elige un tipo
+ {types.data?.map((t) => (
+ {t.name}
+ ))}
+
+ setRequirements((rs) => rs.map((r, i) => (i === index ? { ...r, quantity: Number(e.target.value) || 1 } : r)))}
+ />
+ setRequirements((rs) => rs.filter((_, i) => i !== index))}
+ >
+
+
+
+ ))}
+ setRequirements((rs) => [...rs, { resourceTypeId: '', quantity: 1 }])}>
+ Agregar otro
+
+
+
+ {structureError && }
+
+ setCreating(false)}>Cancelar
+ Crear servicio
+
+
+
+
+ setEditing(null)}
+ title="Editar servicio"
+ description="Las sedes y los recursos requeridos se definen al crear el servicio."
+ >
+ update.mutate(v))} className="flex flex-col gap-4">
+
+
+ setEditing(null)}>Cancelar
+ Guardar cambios
+
+
+
+
+ setDeleting(null)}
+ title="¿Eliminar este servicio?"
+ description={deleting ? `«${deleting.name}» se borra por completo. Si ya tiene reservas, desactívalo en su lugar.` : undefined}
+ >
+
+ {remove.isError &&
}
+
+ setDeleting(null)}>Cancelar
+ remove.mutate()}>Eliminar
+
+
+
+
+ )
+}
diff --git a/front/src/pages/provider/SummaryPage.tsx b/front/src/pages/provider/SummaryPage.tsx
new file mode 100644
index 0000000..3da9d7e
--- /dev/null
+++ b/front/src/pages/provider/SummaryPage.tsx
@@ -0,0 +1,169 @@
+import { useState } from 'react'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation, useQueryClient } from '@tanstack/react-query'
+import { PencilSimple } from '@phosphor-icons/react'
+import { toast } from 'sonner'
+import type { z } from 'zod'
+import { organizationApi } from '@/api/endpoints'
+import { useCategories, useCities } from '@/api/hooks'
+import { Alert, Card } from '@/components/ui/Blocks'
+import { Button } from '@/components/ui/Button'
+import { Input } from '@/components/ui/Form'
+import { Modal } from '@/components/ui/Modal'
+import { errorMessage } from '@/lib/errors'
+import { applyServerErrors } from '@/lib/forms'
+import { formatDate } from '@/lib/format'
+import { policySchema } from '@/lib/schemas'
+import { useOrganization } from './OrganizationLayout'
+
+type PolicyInput = z.input
+type PolicyOutput = z.output
+
+/** 90 -> "1 h 30 min"; the API stores minutes, people think in hours and days. */
+function humanMinutes(total: number): string {
+ if (total === 0) return '0 min'
+ const days = Math.floor(total / 1440)
+ const hours = Math.floor((total % 1440) / 60)
+ const minutes = total % 60
+ return [days && `${days} d`, hours && `${hours} h`, minutes && `${minutes} min`].filter(Boolean).join(' ')
+}
+
+function Row({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
{label}
+ {children}
+
+ )
+}
+
+export default function SummaryPage() {
+ const org = useOrganization()
+ const categories = useCategories()
+ const cities = useCities()
+ const queryClient = useQueryClient()
+ const [editing, setEditing] = useState(false)
+ const policy = org.operationPolicy
+
+ const {
+ register,
+ handleSubmit,
+ setError,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(policySchema),
+ values: {
+ bookingNoticeMinutesMin: policy.bookingNoticeMinutesMin,
+ bookingNoticeMinutesMax: policy.bookingNoticeMinutesMax,
+ freeCancellationWindowMinutes: policy.freeCancellationWindowMinutes,
+ },
+ })
+
+ const save = useMutation({
+ mutationFn: (values: PolicyOutput) => organizationApi.changePolicy(org.id, values),
+ onSuccess: (updated) => {
+ queryClient.setQueryData(['organization', org.id], updated)
+ setEditing(false)
+ toast.success('Reglas actualizadas. Aplican a las reservas nuevas.')
+ },
+ onError: (error) => {
+ if (!applyServerErrors(error, setError)) toast.error(errorMessage(error))
+ },
+ })
+
+ const category = categories.data?.find((c) => c.id === org.categoryId)?.name
+ const cityName = (id: string) => cities.data?.find((c) => c.id === id)?.name
+
+ return (
+
+
+ Datos del negocio
+
+ {category ?? '—'}
+
+ {org.identificationType} {org.identificationNumber}
+
+ {org.contactEmail}
+ {org.contactPhone}
+ {org.timezone}
+ {org.approvedAt && {formatDate(org.approvedAt)}
}
+
+ Los datos del negocio y las sedes se definen al registrarlo.
+
+
+
+
+
Reglas de reserva
+
setEditing(true)}>
+ Editar
+
+
+
+ {humanMinutes(policy.bookingNoticeMinutesMin)}
+ {humanMinutes(policy.bookingNoticeMinutesMax)}
+ hasta {humanMinutes(policy.freeCancellationWindowMinutes)} antes
+ {policy.version}
+
+
+
+
+ Sedes
+
+
+
+
setEditing(false)}
+ title="Editar reglas de reserva"
+ description="Se crea una nueva versión. Las reservas existentes conservan las reglas con las que se hicieron."
+ >
+ save.mutate(v))} className="flex flex-col gap-4">
+
+
+
+ {save.isError && !Object.keys(errors).length && }
+
+ setEditing(false)}>
+ Cancelar
+
+
+ Guardar
+
+
+
+
+
+ )
+}
diff --git a/front/src/pages/public/CatalogPage.tsx b/front/src/pages/public/CatalogPage.tsx
new file mode 100644
index 0000000..c1133c7
--- /dev/null
+++ b/front/src/pages/public/CatalogPage.tsx
@@ -0,0 +1,249 @@
+import { useEffect, useState, type FormEvent } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import { keepPreviousData, useQuery } from '@tanstack/react-query'
+import { Clock, MagnifyingGlass, MapPin, UsersThree } from '@phosphor-icons/react'
+import { catalogApi } from '@/api/endpoints'
+import { useCategories, useCities } from '@/api/hooks'
+import type { CatalogQuery, ServiceCard } from '@/api/types'
+import { Button } from '@/components/ui/Button'
+import { Input, Select } from '@/components/ui/Form'
+import { Badge } from '@/components/ui/Badge'
+import { EmptyState, ErrorState, Pagination } from '@/components/ui/Blocks'
+import { Skeleton } from '@/components/ui/Spinner'
+import { formatDuration, formatPrice } from '@/lib/format'
+
+const PAGE_SIZE = 12
+const SORTS: { value: string; label: string; sort: NonNullable; direction: 'asc' | 'desc' }[] = [
+ { value: 'name-asc', label: 'Nombre (A–Z)', sort: 'name', direction: 'asc' },
+ { value: 'price-asc', label: 'Precio: menor a mayor', sort: 'price', direction: 'asc' },
+ { value: 'price-desc', label: 'Precio: mayor a menor', sort: 'price', direction: 'desc' },
+ { value: 'duration-asc', label: 'Duración: más corta', sort: 'duration', direction: 'asc' },
+ { value: 'provider-asc', label: 'Proveedor (A–Z)', sort: 'provider', direction: 'asc' },
+]
+
+function ServiceItem({ item }: { item: ServiceCard }) {
+ return (
+
+
+
{item.provider.category}
+ {item.group && (
+
+
+ Grupal, {item.customerCapacity} cupos
+
+ )}
+
+ {item.name}
+ {item.provider.name}
+ {item.description && {item.description}
}
+
+
+
+
Ubicación
+
+
+ {item.location.name}, {item.location.city}
+
+
+
+
Duración
+
+ {formatDuration(item.durationMinutes)}
+
+
+
+ {formatPrice(item.price)}
+
+ )
+}
+
+function ResultsSkeleton() {
+ return (
+
+ {Array.from({ length: 6 }, (_, i) => (
+
+
+
+
+
+
+
+ ))}
+
+ )
+}
+
+export default function CatalogPage() {
+ const [params, setParams] = useSearchParams()
+ const categories = useCategories()
+ const cities = useCities()
+
+ const q = params.get('q') ?? ''
+ const categoryId = params.get('categoryId') ?? ''
+ const cityId = params.get('cityId') ?? ''
+ const minPrice = params.get('minPrice') ?? ''
+ const maxPrice = params.get('maxPrice') ?? ''
+ const order = params.get('orden') ?? 'name-asc'
+ const page = Math.max(0, Number(params.get('pagina') ?? 0) || 0)
+ const sort = SORTS.find((s) => s.value === order) ?? SORTS[0]
+
+ // The keyword is typed freely and applied on submit; the other filters apply at once.
+ const [keyword, setKeyword] = useState(q)
+ useEffect(() => setKeyword(q), [q])
+
+ const update = (changes: Record) => {
+ const next = new URLSearchParams(params)
+ for (const [key, value] of Object.entries(changes)) {
+ if (value) next.set(key, value)
+ else next.delete(key)
+ }
+ if (!('pagina' in changes)) next.delete('pagina')
+ setParams(next, { replace: true })
+ }
+
+ const query: CatalogQuery = {
+ q: q || undefined,
+ categoryId: categoryId || undefined,
+ cityId: cityId || undefined,
+ minPrice: minPrice || undefined,
+ maxPrice: maxPrice || undefined,
+ sort: sort.sort,
+ direction: sort.direction,
+ page,
+ size: PAGE_SIZE,
+ }
+ const result = useQuery({
+ queryKey: ['catalog', query],
+ queryFn: ({ signal }) => catalogApi.search(query, signal),
+ placeholderData: keepPreviousData,
+ })
+
+ const handleSearch = (event: FormEvent) => {
+ event.preventDefault()
+ update({ q: keyword.trim() || null })
+ }
+ const hasFilters = Boolean(q || categoryId || cityId || minPrice || maxPrice)
+ const data = result.data
+
+ return (
+
+
+
+
+
+
+ {result.isPending ? (
+
+ ) : result.isError ? (
+ result.refetch()} />
+ ) : data && data.items.length === 0 ? (
+ setParams({}, { replace: true })}>
+ Restablecer filtros
+
+ ) : undefined
+ }
+ />
+ ) : (
+ data && (
+ <>
+
+ {data.total} {data.total === 1 ? 'servicio' : 'servicios'}
+
+
+ {data.items.map((item) => (
+
+ ))}
+
+ update({ pagina: p ? String(p) : null })} />
+ >
+ )
+ )}
+
+
+ )
+}
diff --git a/front/src/pages/public/LoginPage.tsx b/front/src/pages/public/LoginPage.tsx
new file mode 100644
index 0000000..d66cd26
--- /dev/null
+++ b/front/src/pages/public/LoginPage.tsx
@@ -0,0 +1,93 @@
+import { useState } from 'react'
+import { Link, useLocation, useNavigate } from 'react-router-dom'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import { ApiError } from '@/api/client'
+import { registrationApi } from '@/api/endpoints'
+import { homeFor, useAuth } from '@/auth/AuthContext'
+import { AuthPage } from '@/components/layout/Shell'
+import { Alert } from '@/components/ui/Blocks'
+import { Button } from '@/components/ui/Button'
+import { Input } from '@/components/ui/Form'
+import { errorMessage, traceOf } from '@/lib/errors'
+import { loginSchema } from '@/lib/schemas'
+import type { z } from 'zod'
+
+type LoginForm = z.infer
+
+export default function LoginPage() {
+ const { login } = useAuth()
+ const navigate = useNavigate()
+ const location = useLocation()
+ const from = (location.state as { from?: string } | null)?.from
+ const [formError, setFormError] = useState(null)
+
+ const {
+ register,
+ handleSubmit,
+ getValues,
+ formState: { errors },
+ } = useForm({ resolver: zodResolver(loginSchema) })
+
+ const signIn = useMutation({
+ mutationFn: (values: LoginForm) => login(values.email, values.password),
+ onSuccess: (me) => navigate(from && from !== '/ingresar' ? from : homeFor(me.role), { replace: true }),
+ onError: setFormError,
+ })
+
+ const resend = useMutation({
+ mutationFn: () => registrationApi.resend(getValues('email')),
+ onSuccess: () => toast.success('Si el correo existe, te enviamos un nuevo enlace de verificación.'),
+ onError: (error) => toast.error(errorMessage(error)),
+ })
+
+ const notConfirmed = formError instanceof ApiError && formError.errorCode === 'EMAIL_NOT_CONFIRMED'
+
+ return (
+
+ {
+ setFormError(null)
+ signIn.mutate(values)
+ })}
+ className="flex flex-col gap-4"
+ >
+ {formError != null && (
+
+ {traceOf(formError) && Código de soporte: {traceOf(formError)} }
+ {notConfirmed && (
+ resend.mutate()}>
+ Reenviar enlace de verificación
+
+ )}
+
+ )}
+
+
+
+
+ ¿Olvidaste tu contraseña?
+
+
+
+ {signIn.isPending ? 'Ingresando…' : 'Ingresar'}
+
+
+
+ ¿Aún no tienes cuenta?{' '}
+
+ Crear cuenta
+
+
+
+ )
+}
diff --git a/front/src/pages/public/RecoveryPages.tsx b/front/src/pages/public/RecoveryPages.tsx
new file mode 100644
index 0000000..cfb4ffd
--- /dev/null
+++ b/front/src/pages/public/RecoveryPages.tsx
@@ -0,0 +1,114 @@
+import { useState } from 'react'
+import { Link, useSearchParams } from 'react-router-dom'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation } from '@tanstack/react-query'
+import type { z } from 'zod'
+import { authApi } from '@/api/endpoints'
+import { AuthPage } from '@/components/layout/Shell'
+import { Alert } from '@/components/ui/Blocks'
+import { Button, LinkButton } from '@/components/ui/Button'
+import { Input } from '@/components/ui/Form'
+import { errorMessage } from '@/lib/errors'
+import { applyServerErrors } from '@/lib/forms'
+import { resetSchema } from '@/lib/schemas'
+
+export function ForgotPasswordPage() {
+ const { register, handleSubmit, formState: { errors } } = useForm<{ email: string }>()
+ const request = useMutation({ mutationFn: authApi.requestRecovery })
+
+ if (request.isSuccess) {
+ return (
+
+
+ Si hay una cuenta con ese correo, te enviamos un enlace para restablecer la contraseña. Puede tardar unos minutos.
+
+
+ Volver a ingresar
+
+
+ )
+ }
+
+ return (
+
+ request.mutate(v.email))} className="flex flex-col gap-4">
+ {request.isError && }
+
+
+ Enviar enlace
+
+
+
+ )
+}
+
+type ResetForm = z.infer
+
+export function ResetPasswordPage() {
+ const [params] = useSearchParams()
+ const [pasted, setPasted] = useState('')
+ const token = params.get('token_hash') ?? params.get('token') ?? pasted.trim()
+
+ const { register, handleSubmit, setError, formState: { errors } } = useForm({ resolver: zodResolver(resetSchema) })
+ const reset = useMutation({
+ mutationFn: (v: ResetForm) => authApi.resetPassword(token, v.newPassword),
+ onError: (error) => applyServerErrors(error, setError),
+ })
+
+ if (reset.isSuccess) {
+ return (
+
+
+
Ya puedes ingresar con tu nueva contraseña.
+
+ Ingresar
+
+
+
+ )
+ }
+
+ const hasLinkToken = Boolean(params.get('token_hash') ?? params.get('token'))
+ return (
+
+ token && reset.mutate(v))} className="flex flex-col gap-4">
+ {reset.isError && !Object.keys(errors).length && }
+ {!hasLinkToken && (
+ setPasted(e.target.value)}
+ />
+ )}
+
+
+
+ Guardar contraseña
+
+
+
+ )
+}
diff --git a/front/src/pages/public/RegisterClientPage.tsx b/front/src/pages/public/RegisterClientPage.tsx
new file mode 100644
index 0000000..aa4128e
--- /dev/null
+++ b/front/src/pages/public/RegisterClientPage.tsx
@@ -0,0 +1,133 @@
+import { useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation } from '@tanstack/react-query'
+import { EnvelopeSimple } from '@phosphor-icons/react'
+import { registrationApi } from '@/api/endpoints'
+import { AuthPage } from '@/components/layout/Shell'
+import { Alert } from '@/components/ui/Blocks'
+import { Button } from '@/components/ui/Button'
+import { Input, Select } from '@/components/ui/Form'
+import { errorMessage, traceOf } from '@/lib/errors'
+import { applyServerErrors } from '@/lib/forms'
+import { CHANNELS } from '@/lib/format'
+import { clientSchema, type ClientForm } from '@/lib/schemas'
+
+export default function RegisterClientPage() {
+ const [formError, setFormError] = useState(null)
+ const {
+ register,
+ handleSubmit,
+ setError,
+ formState: { errors },
+ } = useForm({ resolver: zodResolver(clientSchema), defaultValues: { notificationChannel: 'EMAIL' } })
+
+ const signUp = useMutation({
+ mutationFn: registrationApi.client,
+ onError: (error) => {
+ if (!applyServerErrors(error, setError)) setFormError(error)
+ else setFormError(null)
+ },
+ })
+
+ if (signUp.isSuccess) {
+ return (
+
+
+
+
+
+
+ Te enviamos un enlace de verificación a {signUp.data.email} . Tu cuenta queda pendiente hasta que
+ lo abras; después podrás ingresar.
+
+
¿No llega? Revisa spam o pide otro enlace desde la pantalla de verificación.
+
+
+ Ir a verificación
+
+
+
+
+ )
+ }
+
+ return (
+
+ {
+ setFormError(null)
+ signUp.mutate(values)
+ })}
+ className="flex flex-col gap-4"
+ >
+ {formError != null && (
+
+ {traceOf(formError) && Código de soporte: {traceOf(formError)} }
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {Object.entries(CHANNELS).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+ {signUp.isPending ? 'Creando cuenta…' : 'Crear cuenta'}
+
+
+
+ ¿Ya tienes cuenta?{' '}
+
+ Ingresar
+
+
+
+ )
+}
diff --git a/front/src/pages/public/RegisterProviderPage.tsx b/front/src/pages/public/RegisterProviderPage.tsx
new file mode 100644
index 0000000..5c0d52e
--- /dev/null
+++ b/front/src/pages/public/RegisterProviderPage.tsx
@@ -0,0 +1,288 @@
+import { useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useFieldArray, useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useMutation } from '@tanstack/react-query'
+import { Plus, Trash } from '@phosphor-icons/react'
+import clsx from 'clsx'
+import type { z } from 'zod'
+import { registrationApi } from '@/api/endpoints'
+import { useCategories, useCities } from '@/api/hooks'
+import { Alert, Card } from '@/components/ui/Blocks'
+import { Button } from '@/components/ui/Button'
+import { Input, Select } from '@/components/ui/Form'
+import { errorMessage, traceOf } from '@/lib/errors'
+import { applyServerErrors } from '@/lib/forms'
+import { providerSchema } from '@/lib/schemas'
+
+type FormInput = z.input
+type FormOutput = z.output
+
+const STEPS = [
+ { title: 'Tu cuenta', fields: ['email', 'password'] },
+ { title: 'Tu negocio', fields: ['name', 'categoryId', 'identificationType', 'identificationNumber', 'contactEmail', 'contactPhone'] },
+ { title: 'Sedes y reglas', fields: ['locations', 'policy'] },
+] as const
+
+const POLICY_FIELDS = ['bookingNoticeMinutesMin', 'bookingNoticeMinutesMax', 'freeCancellationWindowMinutes']
+
+export default function RegisterProviderPage() {
+ const [step, setStep] = useState(0)
+ const [formError, setFormError] = useState(null)
+ const categories = useCategories()
+ const cities = useCities()
+
+ const {
+ register,
+ control,
+ handleSubmit,
+ trigger,
+ setError,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(providerSchema),
+ defaultValues: {
+ identificationType: 'NIT',
+ contactEmail: '',
+ locations: [{ name: '', address: '', cityId: '' }],
+ policy: { bookingNoticeMinutesMin: 60, bookingNoticeMinutesMax: 43200, freeCancellationWindowMinutes: 1440 },
+ },
+ })
+ const { fields, append, remove } = useFieldArray({ control, name: 'locations' })
+
+ const signUp = useMutation({
+ mutationFn: (values: FormOutput) =>
+ registrationApi.provider({
+ email: values.email,
+ password: values.password,
+ name: values.name,
+ categoryId: values.categoryId,
+ identificationType: values.identificationType,
+ identificationNumber: values.identificationNumber,
+ contactEmail: values.contactEmail || undefined,
+ contactPhone: values.contactPhone,
+ locations: values.locations,
+ ...values.policy,
+ }),
+ onError: (error) => {
+ const placed = applyServerErrors(error, setError, (field) =>
+ POLICY_FIELDS.includes(field) ? `policy.${field}` : field,
+ )
+ setFormError(placed ? null : error)
+ if (placed) setStep(0)
+ },
+ })
+
+ const next = async () => {
+ const valid = await trigger([...STEPS[step].fields] as never)
+ if (valid) setStep((s) => s + 1)
+ }
+
+ if (signUp.isSuccess) {
+ return (
+
+
+ Recibimos tu solicitud
+
+
+ Un administrador lo revisará. Mientras tanto no será visible para los clientes. Cuando lo apruebe, te llegará un
+ correo a tu bandeja.
+
+
+ Primero confirma el correo {signUp.data.email} con el enlace que te
+ enviamos, para poder ingresar.
+
+
+
+ Ir a verificación
+
+
+ Ingresar
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
Registra tu negocio
+
Publica tus servicios y recibe reservas. Un administrador aprueba cada negocio antes de mostrarlo.
+
+
+ {STEPS.map((s, i) => (
+
+
+
+ {i + 1}. {s.title}
+
+
+ ))}
+
+
+
{
+ setFormError(null)
+ signUp.mutate(values)
+ },
+ (invalid) => {
+ // Jump back to the first step that holds an invalid field
+ const keys = Object.keys(invalid)
+ const index = STEPS.findIndex((s) => s.fields.some((f) => keys.includes(f)))
+ if (index >= 0) setStep(index)
+ },
+ )}
+ className="mt-6 rounded-lg border border-line bg-surface p-5 sm:p-6"
+ >
+ {formError != null && (
+
+
+ {traceOf(formError) && Código de soporte: {traceOf(formError)} }
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {step > 0 ? (
+ setStep(step - 1)}>
+ Atrás
+
+ ) : (
+
+ )}
+ {step < STEPS.length - 1 ? (
+ Continuar
+ ) : (
+
+ {signUp.isPending ? 'Enviando…' : 'Enviar solicitud'}
+
+ )}
+
+
+
+ )
+}
diff --git a/front/src/pages/public/VerifyEmailPage.tsx b/front/src/pages/public/VerifyEmailPage.tsx
new file mode 100644
index 0000000..38bc75f
--- /dev/null
+++ b/front/src/pages/public/VerifyEmailPage.tsx
@@ -0,0 +1,99 @@
+import { useEffect, useRef } from 'react'
+import { Link, useSearchParams } from 'react-router-dom'
+import { useForm } from 'react-hook-form'
+import { useMutation } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import { registrationApi } from '@/api/endpoints'
+import { AuthPage } from '@/components/layout/Shell'
+import { Alert } from '@/components/ui/Blocks'
+import { Button, LinkButton } from '@/components/ui/Button'
+import { Input } from '@/components/ui/Form'
+import { PageLoader } from '@/components/ui/Spinner'
+import { errorMessage } from '@/lib/errors'
+
+/**
+ * Landing of the verification email. Supabase appends the one-time token to the Site URL
+ * (docs/operations/runbook.md); without the dashboard template change the user pastes it here.
+ */
+export default function VerifyEmailPage() {
+ const [params] = useSearchParams()
+ const token = params.get('token_hash') ?? params.get('token')
+ const started = useRef(false)
+
+ const confirm = useMutation({ mutationFn: registrationApi.confirm })
+ const resend = useMutation({
+ mutationFn: registrationApi.resend,
+ onSuccess: () => toast.success('Si el correo existe, te enviamos un nuevo enlace.'),
+ onError: (error) => toast.error(errorMessage(error)),
+ })
+
+ // The token is single use: the ref keeps React's dev double-effect from spending it twice.
+ useEffect(() => {
+ if (token && !started.current) {
+ started.current = true
+ confirm.mutate(token)
+ }
+ }, [token, confirm])
+
+ const manual = useForm<{ token: string }>()
+ const resendForm = useForm<{ email: string }>()
+
+ if (confirm.isPending) return
+
+ if (confirm.isSuccess) {
+ return (
+
+
+
{confirm.data.message}
+
+ Ingresar
+
+
+
+ )
+ }
+
+ return (
+
+
+ {confirm.isError &&
}
+
+
confirm.mutate(v.token.trim()))} className="flex flex-col gap-3">
+
+ Confirmar correo
+
+
+
resend.mutate(v.email.trim()))}
+ className="flex flex-col gap-3 border-t border-line pt-5"
+ >
+ ¿El enlace venció o ya se usó?
+
+
+ Reenviar enlace
+
+
+
+ Volver a ingresar
+
+
+
+ )
+}
diff --git a/front/tsconfig.json b/front/tsconfig.json
new file mode 100644
index 0000000..6494613
--- /dev/null
+++ b/front/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "types": ["vite/client", "node"],
+ "paths": { "@/*": ["./src/*"] }
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/front/vercel.json b/front/vercel.json
new file mode 100644
index 0000000..ebb9e98
--- /dev/null
+++ b/front/vercel.json
@@ -0,0 +1,6 @@
+{
+ "rewrites": [
+ { "source": "/api/:path*", "destination": "https://bookingplatform-81wi.onrender.com/api/:path*" },
+ { "source": "/((?!api/).*)", "destination": "/index.html" }
+ ]
+}
diff --git a/front/vite.config.ts b/front/vite.config.ts
new file mode 100644
index 0000000..59a034c
--- /dev/null
+++ b/front/vite.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig, loadEnv } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+import path from 'node:path'
+
+// The backend has no CORS (SecurityConfig), so the browser must call /api on its own origin.
+// In dev Vite proxies it; in production vercel.json rewrites it. No key ever lives in the front.
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '')
+ const target = env.VITE_API_TARGET || 'https://bookingplatform-81wi.onrender.com'
+ return {
+ plugins: [react(), tailwindcss()],
+ resolve: { alias: { '@': path.resolve(import.meta.dirname, 'src') } },
+ server: {
+ // Supabase email links point to the Site URL (localhost:3000), see docs/operations/runbook.md
+ port: 3000,
+ strictPort: true,
+ proxy: { '/api': { target, changeOrigin: true, secure: true, timeout: 150_000, proxyTimeout: 150_000 } },
+ },
+ preview: { port: 3000, proxy: { '/api': { target, changeOrigin: true, secure: true } } },
+ }
+})