feat: sitio comercial FSPM con micro CRM interno

Sitio público orientado a venta consultiva e implementación de las
recomendaciones de la auditoría de marca y mercado:

- Inicio con tres rutas (industria, riesgo, cumplimiento) y clasificador
  de riesgo en el héroe, contra el hallazgo de que el visitante tenía que
  inferir qué solución le correspondía.
- Cinco páginas por segmento prioritario y catálogo comparable por clase
  de fuego, capacidad y agente.
- Los claims sin evidencia por modelo quedan aislados y se presentan como
  compromiso de documentación, nunca como característica del producto.
- SEO técnico completo: metadatos por página, sitemap derivado de los
  datos, JSON-LD, iconos y tarjeta social generadas.

Micro CRM:

- Captura por formulario embebido y por widget de WhatsApp que registra
  antes de abrir la conversación.
- Atribución de primer toque que sobrevive a la navegación.
- Deduplicación de contacto; un mismo contacto acumula oportunidades.
- Motor de asignación por reglas con reparto por turno de respaldo.
- Panel con dos roles de alcance distinto, tablero analítico, bitácora,
  pedidos y reglas. No enlazado desde el sitio público y con noindex.

Empaquetado en Docker multietapa con la base de demostración horneada en
la imagen y el volumen de datos separado, para que los leads capturados
sobrevivan a los redespliegues.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Uriel Jareth
2026-08-28 00:07:20 -06:00
co-authored by Claude Opus 5
parent 122e06a4bb
commit 01b817d098
649 changed files with 26690 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
# Lo que nunca debe entrar al contexto de construcción.
# Cada línea aquí es menos peso, menos invalidación de caché y menos riesgo de
# hornear un secreto o la base de datos de alguien dentro de la imagen.
# --- dependencias y salidas: se reconstruyen dentro de la imagen ---
node_modules
.next
out
build
dist
*.tsbuildinfo
next-env.d.ts
# --- control de versiones ---
.git
.gitignore
.gitattributes
.github
# --- base de datos: JAMÁS dentro de la imagen ---
# La base vive en el volumen /app/data. Copiar la local metería datos de
# desarrollo en producción y, peor, los volvería el punto de partida.
data
*.db
*.db-journal
*.sqlite
*.sqlite3
# --- entorno: los secretos se pasan al contenedor, no se hornean ---
.env
.env.*
!.env.example
# --- documentación y notas de trabajo ---
# No hacen falta dentro de la imagen; DESPLIEGUE.md se lee en el repositorio.
docs
*.md
# --- editor y sistema operativo ---
.vscode
.idea
.claude
*.swp
*.swo
.DS_Store
Thumbs.db
desktop.ini
# --- el propio empaquetado ---
Dockerfile
.dockerignore
docker-compose.yml
docker-compose.*.yml
# nota: docker-entrypoint.sh NO va aquí. El Dockerfile lo necesita en el
# contexto para copiarlo al contenedor.
+15
View File
@@ -0,0 +1,15 @@
# Base de datos (SQLite en volumen)
DATABASE_URL="file:../data/fspm.db"
# Secreto de sesión del panel — cambiar en cada despliegue
AUTH_SECRET="cambia-esto-por-un-secreto-largo-y-aleatorio"
# URL pública canónica (SEO, sitemap, Open Graph)
NEXT_PUBLIC_SITE_URL="https://fspm-demo.urieljareth.org"
# WhatsApp comercial en formato internacional sin signos
NEXT_PUBLIC_WHATSAPP="525540348395"
# Acceso rapido de demostracion en /admin/login (entrar de un clic como cualquier perfil).
# Ponlo en "false" para apagarlo en produccion.
DEMO_ACCESO_RAPIDO="true"
+2
View File
@@ -4,3 +4,5 @@ node_modules/
*.db-journal *.db-journal
.env .env
.env.local .env.local
.env.coolify
tests/reporte/
+183
View File
@@ -0,0 +1,183 @@
# check=skip=SecretsUsedInArgOrEnv
# La directiva de arriba tiene que ser la primera línea del archivo. Silencia la
# advertencia de BuildKit por el AUTH_SECRET de la etapa de construcción, que es
# un relleno para que `next build` no falle: no llega a la imagen final ni se
# usa en ejecución. Sin esto, cada construcción sale con una advertencia y se
# acaba aprendiendo a ignorar también las advertencias de verdad.
# =============================================================================
# FSPM — imagen de producción
#
# Tres etapas sobre node:22-alpine, aprovechando output: "standalone" de
# next.config.ts. La idea de cada etapa:
#
# deps instala las dependencias en su propia capa, así un cambio de código
# no vuelve a bajar node_modules.
# build genera el cliente de Prisma, hornea una base de demostración y
# compila Next.
# runner imagen mínima que solo sabe arrancar el servidor, con usuario no
# root y la base de datos en un volumen.
#
# OJO CON LAS VARIABLES NEXT_PUBLIC_*
# Next las sustituye literalmente durante `next build`, no las lee al arrancar.
# Por eso NEXT_PUBLIC_SITE_URL y NEXT_PUBLIC_WHATSAPP van como ARG de
# construcción: cambiar el dominio exige reconstruir la imagen, no basta con
# editar el entorno del contenedor. Está explicado en docs/DESPLIEGUE.md.
# =============================================================================
# --- etapa 1: dependencias -----------------------------------------------------
FROM node:22-alpine AS deps
# libc6-compat y openssl: Prisma los necesita en Alpine para sus motores.
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
# El esquema entra antes de npm ci porque el postinstall de @prisma/client
# intenta generar el cliente y sin esquema deja una advertencia.
COPY package.json package-lock.json ./
COPY prisma/schema.prisma ./prisma/schema.prisma
# Aquí NO se define NODE_ENV=production: hacen falta las dependencias de
# desarrollo (prisma, tsx, typescript, tailwind) para construir y para sembrar.
RUN npm ci
# CLI de Prisma aislada, con sus dependencias propias resueltas en su propio
# árbol. El punto de entrada del contenedor la necesita para aplicar el esquema
# al arrancar, y copiar paquetes suelto por suelto no funciona: la CLI arrastra
# dependencias transitivas (effect, entre otras) que no son evidentes.
# La versión se lee de package.json, así que no puede desincronizarse.
#
# Después de instalarla se le quitan dos cosas que no va a usar nunca: los
# mapas de código fuente y el motor de consultas. Esta CLI solo ejecuta
# `db push`, que usa el motor de esquema; el motor de consultas que la
# aplicación necesita es el que viene dentro de la salida standalone. Son unos
# 40 MB menos en la imagen final.
RUN npm install --no-save --no-audit --no-fund --prefix /prisma-cli \
"prisma@$(node -p "require('/app/package.json').devDependencies.prisma")" \
&& find /prisma-cli/node_modules -type f -name "*.map" -delete \
&& rm -f /prisma-cli/node_modules/@prisma/engines/libquery_engine-*.so.node
# --- etapa 2: construcción -----------------------------------------------------
FROM node:22-alpine AS build
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Valores por omisión iguales a .env.example: si nadie pasa --build-arg, la
# imagen queda coherente en vez de quedar con la cadena vacía, que rompería
# `new URL(SITIO_URL)` en el layout raíz.
ARG NEXT_PUBLIC_SITE_URL="https://fspm-demo.urieljareth.org"
ARG NEXT_PUBLIC_WHATSAPP="525540348395"
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
ENV NEXT_PUBLIC_WHATSAPP=${NEXT_PUBLIC_WHATSAPP}
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# `next build` y `prisma generate` exigen que DATABASE_URL exista, aunque no se
# conecten a la base real. Este valor es solo de compilación: apunta a la base
# de demostración que se hornea aquí mismo y nunca se usa en ejecución.
ENV DATABASE_URL="file:/semilla/fspm.db"
# Igual con el secreto de sesión: existe para que el build no falle. El
# contenedor recibe el suyo por entorno.
ENV AUTH_SECRET="secreto-de-compilacion-sin-valor-en-ejecucion"
RUN npx prisma generate
# --- base de demostración horneada en la imagen ---
#
# El seed es DESTRUCTIVO: `prisma/seed.ts` hace deleteMany() de todas las tablas
# antes de reconstruir. Ejecutarlo dentro del contenedor de producción borraría
# los leads capturados en cada reinicio. Por eso se ejecuta aquí, una sola vez,
# contra una base que queda dentro de la imagen; el contenedor solo la copia al
# volumen cuando el volumen está vacío. Ese es el motivo real por el que `tsx`,
# que es dependencia de desarrollo, nunca se instala en la imagen final.
RUN mkdir -p /semilla \
&& npx prisma db push --skip-generate \
&& if [ -f prisma/seed.ts ]; then \
npx tsx prisma/seed.ts; \
else \
echo "AVISO: no existe prisma/seed.ts. La imagen queda con el esquema pero sin datos de demostración."; \
fi
RUN npm run build
# --- etapa 3: ejecución --------------------------------------------------------
FROM node:22-alpine AS runner
# openssl y libc6-compat también aquí: el cliente de Prisma los carga en
# ejecución, no solo al construir.
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
ENV NEXT_TELEMETRY_DISABLED=1
# El aviso de "hay una versión nueva de Prisma" sale en cada arranque y solo
# entrena a nadie a leer los registros. La versión se actualiza cuando el
# proyecto lo decida, no cuando el contenedor lo sugiera.
ENV PRISMA_HIDE_UPDATE_MESSAGE=true
# Ruta absoluta a propósito. Una relativa como "file:../data/fspm.db" se
# resuelve contra la carpeta del esquema y es justo el tipo de detalle que
# funciona en local y falla en el contenedor.
ENV DATABASE_URL="file:/app/data/fspm.db"
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nextjs
# CLI de Prisma, en su propio árbol para que el punto de entrada pueda aplicar
# el esquema al arrancar. Va en /app/prisma-cli, aparte del node_modules de la
# aplicación: así no puede pisar el @prisma/client generado que trae la salida
# standalone, que es el que la aplicación usa en ejecución.
COPY --from=deps /prisma-cli/node_modules ./prisma-cli/node_modules
# El esquema tiene que estar presente en ejecución: `prisma db push` lo lee.
COPY --from=build /app/prisma/schema.prisma ./prisma/schema.prisma
# Base de demostración horneada. Solo se copia al volumen si el volumen no
# tiene una base todavía.
COPY --from=build /semilla/fspm.db ./semilla/fspm.db
# Salida de Next. El standalone va al final para que su @prisma/client, que es
# el que la aplicación usa, gane sobre cualquier otro.
COPY --from=build /app/public ./public
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# El directorio de datos nace con dueño nextjs: si el volumen se crea vacío,
# Docker hereda estos permisos y el proceso no root puede escribir la base.
RUN mkdir -p /app/data \
&& chown -R nextjs:nodejs /app/data /app/semilla /app/.next
USER nextjs
# Aquí vive el SQLite y TIENE que sobrevivir a los redespliegues. Si no se
# monta, cada nueva versión de la imagen empieza con la base de demostración y
# los leads capturados se pierden. Está explicado en docs/DESPLIEGUE.md.
VOLUME ["/app/data"]
EXPOSE 3000
# La sonda toca /api/salud, que a su vez consulta la base: así un volumen no
# escribible se reporta como contenedor enfermo en vez de pasar por sano.
# --start-period cubre el `prisma db push` del arranque más el boot de Next.
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD wget --quiet --tries=1 --spider "http://127.0.0.1:${PORT}/api/salud" || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["node", "server.js"]
+96
View File
@@ -0,0 +1,96 @@
# FSPM — sitio comercial y micro CRM
Demostración construida por **Consultoría E3** para Fire Service Plus México.
Sitio público orientado a venta consultiva, con un CRM interno que registra cada
envío de formulario y de widget de WhatsApp como oportunidad, con su atribución
de campaña.
No es un maquetado: los formularios escriben en base de datos, el panel lee de
ella, y las reglas de asignación reparten los leads entre vendedores.
## De dónde salió el contenido
El sitio implementa las recomendaciones de una auditoría de marca y mercado
previa. Los hallazgos que dirigieron el diseño:
| Hallazgo de la auditoría | Cómo se resuelve aquí |
|---|---|
| Propuesta demasiado amplia, ocho categorías sin rutas | Inicio con tres rutas —industria, riesgo y cumplimiento— y cinco páginas por segmento prioritario |
| El visitante debe inferir qué solución le toca | Clasificador de riesgo en el héroe: elige operación y clases de fuego, y el sitio lo enruta |
| Claims sin condiciones ni evidencia por modelo | Aislados en `claimsPorVerificar` y presentados como compromiso de documentación, nunca como característica |
| Sin meta description ni Open Graph | Metadatos por página, sitemap derivado de los datos, JSON-LD y tarjeta social generada |
| Conversión poco consultiva | El formulario pide lo que un ingeniero necesita para cotizar, no "solicita información" |
| Sin forma de medir efectividad comercial | Atribución de primer toque que sobrevive a la navegación, y analítica por campaña en el panel |
Los datos de producto provienen de la extracción del sitio público de FSPM. Lo
que la fuente no sostiene está marcado como pendiente de verificar; nada se
inventó para rellenar.
## Stack
Next.js 15 App Router · React 19 · TypeScript estricto · Tailwind CSS v4 ·
Prisma 6 + SQLite · sesiones propias con `jose` · Playwright para pruebas.
Sin librería de gráficas: el tablero dibuja SVG a mano.
## Arrancar en local
```bash
npm install
cp .env.example .env # ajusta AUTH_SECRET
npx prisma db push
npm run db:seed # datos de demostración
npm run dev
```
Sitio en `http://localhost:3000`. El panel en `/admin/login` — **no está
enlazado desde el sitio público**, es intencional.
Las credenciales del seed se imprimen al final de `npm run db:seed`. En la
pantalla de acceso hay además entrada rápida por perfil para la demostración,
que se apaga con `DEMO_ACCESO_RAPIDO=false`.
## Estructura
```
src/app/(sitio)/ páginas públicas
src/app/admin/ panel comercial, con noindex y fuera del sitemap
src/app/api/leads/ captura: deduplica contacto, crea oportunidad, asigna
src/data/ catálogo, industrias, clases de fuego, datos de empresa
src/lib/crm/ dominio, sesiones, consultas, acciones, asignación
src/components/marca/RielClases.tsx el elemento firma del diseño
tests/ Playwright, incluidos dos proyectos WebKit
docs/CONTRATO.md sistema de diseño y reglas de contenido
docs/DESPLIEGUE.md variables, Docker y despliegue
```
## Cómo funciona la captura
1. El visitante llega con parámetros de campaña. `src/lib/utm.ts` guarda el
**primer toque** y no lo sobreescribe.
2. Navega. Envía el formulario en una ficha de producto, ya sin parámetros en
la URL.
3. `POST /api/leads` deduplica el contacto por correo o teléfono normalizado,
crea una **oportunidad nueva** —un mismo contacto acumula varias— y guarda
la campaña del primer toque junto con el producto y la URL exactos.
4. Las reglas de asignación eligen vendedor; si ninguna coincide, se reparte por
turno entre los activos.
5. El widget de WhatsApp hace lo mismo **antes** de abrir la conversación, así
el lead existe aunque la persona nunca mande el mensaje.
## Pruebas
```bash
npm run test:e2e # todos los proyectos
npm run test:webkit # solo Safari e iOS
```
Cubren rutas y metadatos, responsividad de 320 a 1440 sin desborde horizontal,
el circuito de captura con atribución, el aislamiento entre roles del panel y
accesibilidad básica.
## Alcance
Los datos del panel son ficticios. Los logotipos de clientes se muestran sin
atribuirles testimonio ni resultado: la auditoría pide confirmar la relación
antes de usarlos comercialmente. La información normativa es de referencia y no
sustituye dictamen técnico ni jurídico.
+63
View File
@@ -0,0 +1,63 @@
# =============================================================================
# Prueba local de la imagen de producción.
#
# docker compose up --build
# → http://localhost:3000
#
# No es el archivo con el que se despliega en Coolify: allá la plataforma
# construye la imagen y administra el volumen. Esto sirve para comprobar, antes
# de subir, que la imagen arranca, que la base sobrevive a un reinicio y que la
# sonda de salud responde. Ver docs/DESPLIEGUE.md.
# =============================================================================
services:
web:
build:
context: .
dockerfile: Dockerfile
# Estas dos se hornean en el bundle durante `next build`: pasarlas solo
# como environment no tendría efecto en el sitio.
args:
NEXT_PUBLIC_SITE_URL: "http://localhost:3000"
NEXT_PUBLIC_WHATSAPP: "525540348395"
image: fspm-web:local
container_name: fspm-web
ports:
- "3000:3000"
environment:
# Ruta absoluta dentro del contenedor. Debe caer dentro del volumen.
DATABASE_URL: "file:/app/data/fspm.db"
# Solo para la prueba local. En producción se genera uno nuevo:
# openssl rand -base64 48
AUTH_SECRET: "secreto-de-prueba-local-cambialo-en-produccion-por-favor"
# Repetidas aquí para que el servidor las tenga; el bundle del navegador
# ya las trae horneadas desde build.args.
NEXT_PUBLIC_SITE_URL: "http://localhost:3000"
NEXT_PUBLIC_WHATSAPP: "525540348395"
# Acceso rápido de demostración en /admin/login. En un despliegue real
# que alguien vaya a ver, ponlo en "false".
DEMO_ACCESO_RAPIDO: "true"
volumes:
# Volumen nombrado: es lo que hace que los leads capturados sobrevivan a
# `docker compose up --build`. Si esta línea se borra, cada
# reconstrucción empieza otra vez con la base de demostración.
- fspm-datos:/app/data
restart: unless-stopped
# La sonda del Dockerfile ya cubre esto; se repite aquí para que
# `docker compose ps` muestre el estado sin depender de la imagen.
healthcheck:
test: ["CMD-SHELL", "wget --quiet --tries=1 --spider http://127.0.0.1:3000/api/salud || exit 1"]
interval: 30s
timeout: 5s
start_period: 60s
retries: 3
volumes:
fspm-datos:
name: fspm-datos
+85
View File
@@ -0,0 +1,85 @@
#!/bin/sh
# =============================================================================
# Punto de entrada del contenedor de FSPM.
#
# Hace tres cosas antes de arrancar el servidor, en este orden:
#
# 1. Asegura que exista la base de datos en el volumen. Si el volumen viene
# vacío, copia la base de demostración que se horneó en la imagen durante
# la construcción.
# 2. Aplica el esquema con `prisma db push`.
# 3. Entrega el control al servidor de Next con `exec`, para que Next quede
# como PID 1 y reciba las señales de parada de Docker.
#
# CÓMO DETECTA QUE YA ESTÁ SEMBRADA
#
# Por la existencia del archivo de base de datos en el volumen ($BD).
# Si existe, no se toca su contenido: ni se copia la base de demostración
# encima, ni se ejecuta ningún seed.
#
# Y no puede duplicar el seed por una razón más fuerte que una comprobación:
# el seed no se ejecuta aquí. `prisma/seed.ts` es destructivo —borra todas las
# tablas antes de reconstruirlas— y `tsx`, que lo ejecuta, es dependencia de
# desarrollo y no está en esta imagen. La siembra ocurrió una única vez, en la
# etapa de construcción. Lo que hace este script es copiar un archivo.
#
# $MARCADOR es solo una traza legible con la fecha del primer arranque, para
# que quien opere el servidor pueda distinguir una base heredada de una nueva.
# No se usa como condición.
# =============================================================================
set -e
DIR_DATOS="/app/data"
BD="${DIR_DATOS}/fspm.db"
MARCADOR="${DIR_DATOS}/.sembrado"
SEMILLA="/app/semilla/fspm.db"
log() {
echo "[fspm] $1"
}
# --- 1. base de datos en el volumen ------------------------------------------
if [ ! -d "$DIR_DATOS" ]; then
log "el directorio de datos no existe, se crea: ${DIR_DATOS}"
mkdir -p "$DIR_DATOS"
fi
if [ ! -w "$DIR_DATOS" ]; then
log "ERROR: ${DIR_DATOS} no es escribible por el usuario $(id -un) (uid $(id -u))."
log " Si montaste una carpeta del anfitrión, dale permisos: chown -R 1001:1001 <carpeta>"
exit 1
fi
if [ -f "$BD" ]; then
log "ya existe ${BD}: su contenido no se toca."
if [ -f "$MARCADOR" ]; then
log "primer arranque de este volumen: $(cat "$MARCADOR")"
fi
elif [ -f "$SEMILLA" ]; then
log "volumen vacío: se copia la base de demostración horneada en la imagen."
cp "$SEMILLA" "$BD"
date -u '+%Y-%m-%dT%H:%M:%SZ' > "$MARCADOR" 2>/dev/null || true
log "listo. Los datos del panel son de demostración; los leads que capture el sitio se suman a esta base."
else
log "volumen vacío y la imagen no trae base de demostración: se creará vacía."
log "El panel no tendrá usuarios y no se podrá iniciar sesión en /admin/login."
fi
# --- 2. esquema ---------------------------------------------------------------
#
# Idempotente: si el esquema del código y el de la base ya coinciden, no hace
# nada. A propósito NO se pasa --accept-data-loss: si una versión nueva exigiera
# eliminar una columna o una tabla con filas, se prefiere que el arranque falle
# y quede en los registros antes que borrar leads capturados en silencio.
log "aplicando el esquema (prisma db push)"
node /app/prisma-cli/node_modules/prisma/build/index.js db push \
--schema=/app/prisma/schema.prisma \
--skip-generate
# --- 3. servidor --------------------------------------------------------------
log "arrancando Next en ${HOSTNAME}:${PORT}"
exec "$@"
+162
View File
@@ -0,0 +1,162 @@
# Contrato del proyecto — léelo completo antes de escribir código
Sitio + micro CRM para **FSPM (Fire Service Plus México)**, protección contra incendios.
Es una **demo comercial** de Consultoría E3: tiene que verse y funcionar como producción.
Raíz: `H:\MegaSync\Proyectos\fspm\fspm-web`
## Stack ya instalado — no cambies versiones, no agregues dependencias
Next.js 15.5.4 (App Router) · React 19 · TypeScript estricto · Tailwind CSS **v4**
(sin `tailwind.config`, los tokens viven en `@theme` dentro de `src/app/globals.css`) ·
Prisma 6.16 + SQLite · zod 4 · jose · bcryptjs.
Si crees que necesitas una dependencia nueva, **no la instales**: resuélvelo con lo que hay
o dilo en tu reporte final.
## Regla de propiedad de archivos — la más importante
Trabajas en paralelo con otros agentes sobre el mismo árbol. **Solo puedes crear y editar
los archivos de tu lote.** Si necesitas cambiar un archivo ajeno, no lo toques: descríbelo
en tu reporte final y el orquestador lo integra.
Archivos del orquestador, **de solo lectura para ti**:
```
src/app/globals.css src/app/layout.tsx
src/data/*.ts src/lib/db.ts
src/lib/utm.ts src/lib/crm/constantes.ts
src/lib/leads/esquema.ts src/components/marca/RielClases.tsx
src/components/site/Encabezado.tsx src/components/site/Pie.tsx
prisma/schema.prisma package.json next.config.ts .env
```
## Sistema de diseño — deriva todo de aquí, no improvises color ni tipografía
Los tokens están en `src/app/globals.css` bajo `@theme`. Úsalos como clases de Tailwind
(`bg-ink`, `text-muted`, `border-rule`, `text-signal`) o como `var(--color-…)`.
**Dos mundos, y el contraste entre ellos es la idea:**
- **Oscuro = el riesgo y la operación.** `bg-ink` (#0C1116, acero-humo azulado), paneles
`bg-ink-2` / `bg-ink-3`, filetes `border-rule`.
- **Claro = la evidencia y el documento.** `bg-paper` (#EEF1F3, blanco de plano de
ingeniería — **no** es crema), texto `text-deep`.
Alterna secciones entre los dos mundos. Es la traducción visual del diagnóstico del
estudio: convertir autoridad técnica en confianza comprable.
**Señal:** `text-signal` / `bg-signal` = #DB042B, el rojo real de FSPM extraído de su CSS.
Es señal, no relleno: úsalo en el CTA, en el riel de clases y en acentos puntuales.
`text-ember` (#F2B705) es el amarillo del traje de bombero de su propia fotografía:
**solo para estados de riesgo, advertencia y atención**, nunca decorativo.
**Tipografía**, ya cargada en el layout raíz:
- `font-display` — Archivo 800/900. Encabezados. Versalitas y tracking cerrado. Es
rotulación de equipo industrial.
- `font-sans` — IBM Plex Sans. Cuerpo.
- `font-mono` — IBM Plex Mono. **La capa de datos**: etiquetas, especificaciones, folios,
cifras, códigos, UTMs. Todo dato numérico lleva `font-variant-numeric: tabular-nums`
(ya aplicado a `.font-mono` y a `table`).
Radios casi rectos: `rounded-[3px]` / `rounded-[2px]`. Es una herramienta, no una app de consumo.
**Utilidades ya escritas — reúsalas en vez de reinventar:** `.shell` `.shell-wide`
`.eyebrow` `.btn .btn-signal .btn-ghost .btn-deep .btn-sm` `.field .field-lite
.field-label .field-error` `.panel .panel-lite` `.spec .spec-cell .spec-key .spec-val`
`.hair .hair-lite` `.grid-bg .grid-bg-lite` `.hazard` `.badge .badge-signal .badge-ember
.badge-ok` `.link` `.skip` `.anim-rise` `.anim-ignite` `.reveal`.
### Elemento firma: el riel de clases de fuego
`src/components/marca/RielClases.tsx` — cinco celdas fijas `A B C D K`, se encienden solo
las que ese producto/solución/lead cubre. Aparece en el sitio **y** en el panel. Úsalo
siempre que haya clases de fuego; no dibujes otra variante.
### Movimiento
Contenido. Una secuencia orquestada en el héroe y revelados discretos al hacer scroll con
`.reveal` + IntersectionObserver. Nada de efectos dispersos: delatan trabajo automático.
`prefers-reduced-motion` ya está respetado globalmente.
## Piso de calidad, no negociable
- **Responsive real** desde 320px. Prueba mentalmente 320 / 768 / 1280 / 1920.
- **Compatibilidad WebKit (Safari e iOS).** Es requisito explícito del cliente:
usa `-webkit-backdrop-filter` junto a `backdrop-filter`, `-webkit-appearance: none` en
controles, `-webkit-overflow-scrolling: touch` en scroll horizontal, `-webkit-tap-highlight-color`
en elementos táctiles, y evita `gap` en `inline-flex` sin fallback. Nada de `100vh` a secas
en móvil: usa `100dvh` con `100vh` de respaldo.
- **Accesibilidad:** un solo `<h1>` por página, jerarquía correcta, `aria-label` en
controles sin texto, foco visible (ya en `:focus-visible`), contraste suficiente,
navegación completa con teclado.
- Nada de texto dentro de imágenes. `next/image` con `width`/`height` o `fill` + `sizes`.
- Sin `any`. Sin `console.log` en el código final. `npx tsc --noEmit` debe pasar.
- Español de México, acentuación correcta, tono técnico y sereno.
## Honestidad del contenido — regla dura del cliente
El estudio de marca concluyó que los claims del sitio original (*"no caduca"*,
*"20 años de garantía"*, *"no requiere recarga"*) **no pueden presentarse como universales**.
`src/data/catalogo.ts` ya los aisló en `producto.claimsPorVerificar`.
- **Nunca** presentes un claim de `claimsPorVerificar` como hecho.
- Muéstralos en un bloque aparte, honesto, del tipo "Qué documentamos antes de cotizar".
Eso **es** el argumento de venta: la competencia promete, FSPM documenta.
- No inventes cifras, certificados, precios, casos de éxito ni testimonios. Si un dato no
está en `src/data/`, no existe.
- Los logotipos de clientes en `src/data/empresa.ts` se muestran **sin** atribuirles
testimonio ni resultado: el estudio pide confirmar la relación antes de usarlos como caso.
- Los casos de aplicación deben estar rotulados como ilustrativos.
## Datos disponibles (solo lectura)
- `src/data/catalogo.ts` — 8 categorías, 22 productos. `CATEGORIAS`, `PRODUCTOS`,
`getCategoria`, `getProducto`, `productosDeCategoria`.
- `src/data/industrias.ts` — los 5 segmentos prioritarios del estudio. `INDUSTRIAS`, `getIndustria`.
- `src/data/clases-fuego.ts` — `CLASES`, `CLASES_INFO`, `parseClases`, `serializeClases`.
- `src/data/empresa.ts` — `EMPRESA`, `NAV_PRINCIPAL`, `NAV_PIE`, `CLIENTES`, `PRENSA`,
`NORMAS`, `SITIO_URL`, `WHATSAPP`, `AVISO_DEMO`.
- `src/lib/crm/constantes.ts` — dominio del CRM. SQLite no guarda enums: **todo estado sale de aquí.**
- `src/lib/leads/esquema.ts` — contrato de captura de leads y helpers de normalización.
- `src/lib/utm.ts` — `capturarAtribucion()`, para clientes.
Assets en `public/media/`: `marca/`, `producto/`, `clientes/`, `prensa/`, `iconos/`, `foto/`.
**Usa solo archivos que existan**: verifícalo con `ls public/media/<carpeta>`.
## Rutas del proyecto
Público, dentro del grupo `src/app/(sitio)/`:
`/` · `/soluciones` · `/soluciones/[industria]` · `/catalogo` · `/catalogo/[categoria]` ·
`/producto/[slug]` · `/cumplimiento-nom-002` · `/evidencia` · `/casos` ·
`/preguntas-frecuentes` · `/nosotros` · `/contacto` · `/aviso-de-privacidad`
Panel, en `src/app/admin/`:
`/admin/login` · `/admin` · `/admin/oportunidades` · `/admin/oportunidades/[id]` ·
`/admin/contactos` · `/admin/contactos/[id]` · `/admin/pedidos` · `/admin/analitica` ·
`/admin/equipo` · `/admin/reglas`
**El panel no se enlaza desde ninguna parte del sitio público.** Sin enlace en el
encabezado, en el pie, ni en el sitemap. Se llega escribiendo la URL. Además va con
`robots: { index: false, follow: false }` en su metadata.
## Comandos
```
npm run dev # servidor de desarrollo
npx tsc --noEmit # verificación de tipos: tiene que pasar limpio
npx prisma db push # aplicar el esquema
npm run db:seed # sembrar datos de demo
```
No corras `npm install`. No corras `git commit`. No borres archivos ajenos.
## Tu reporte final
Termina con, en este orden:
1. Archivos creados o modificados, con una línea de qué hace cada uno.
2. Resultado literal de `npx tsc --noEmit` sobre tus archivos.
3. Lo que dejaste pendiente o lo que necesitas de otro lote.
4. Cualquier dato del sitio original que te pareció inconsistente o sin sustento.
+145
View File
@@ -0,0 +1,145 @@
# Tarea: construir el catálogo tipado de FSPM
Eres el agente de datos del proyecto. Tu única entrega es **un archivo**:
`src/data/catalogo.ts` en este repo (`H:\MegaSync\Proyectos\fspm\fspm-web`).
No toques ningún otro archivo. No instales dependencias. No corras `npm`.
## Fuente de verdad (solo lectura)
Base de conocimiento del sitio real, escrapeada:
`H:\MegaSync\Proyectos\Web Clonner Scrapper toolkit\proyectos\fspm.com.mx\conocimiento\`
Lee **estos archivos en español** (ignora los `en-*` y los que llevan sufijo hash como `-8e5a3550`):
- `index.md` — las 8 categorías y cómo se presentan
- `extintores-portatiles.md`
- `extintores-fijos.md`
- `productos-moviles.md`
- `rociadores.md`
- `agentes-extinguidores.md`
- `firemiks.md`
- `robots.md`
- `proteccion-civil.md`
- `normas-y-certificaciones.md`
- `preguntas-frecuentes.md`
## Imágenes disponibles
Ya están copiadas en `public/media/producto/` y `public/media/iconos/`. Lista real de archivos —
usa **exactamente** estos nombres, con ruta `/media/producto/<archivo>`:
```
firemiks.png
FSPM_espumas_contra_incendios_flameAde.png
fspm_extintores_facil_uso_en_mexico.png
fspm_extintores_fijos_enforcer_10.png
fspm_extintores_fijos_esfera_sin_detector_de_humo.png
fspm_extintores_garantia_en_mexico.png
fspm_extintores_largo_alcance_en_mexico.png
fspm_extintores_moviles_carretilla_100litros.png
fspm_extintores_moviles_carretilla_30litros_acero_inoxidable.png
fspm_extintores_moviles_carretilla_50litros_acero_inoxidable.png
fspm_extintores_moviles_carretilla_70litros_acero_inoxidable.png
fspm_extintores_moviles_enforcer_60.png
fspm_extintores_no_caducan_en_mexico.png
fspm_extintores_portatiles_en_mexico_enforcer_12litros.png
fspm_extintores_portatiles_en_mexico_enforcer_1litro.png
fspm_extintores_portatiles_en_mexico_enforcer_2litros.png
fspm_extintores_portatiles_en_mexico_enforcer_4litros.png
fspm_extintores_portatiles_en_mexico_enforcer_6litros.png
fspm_extintores_portatiles_en_mexico_enforcer_9litros.png
fspm_extintores_rociadores_enforcer_esfera.png
fspm_extintores_rociadores_enforcer_robot.png
fspm_extintores_rociadores_enforcer_robot_componente_de_agua.png
fspm_extintores_rociadores_enforcer_robot_panel_de_control.png
fspm_extintores_sin_recarga_en_mexico.png
FSPM-trakAde-supresion-de-polvos.png
FSPM-vaporAde.png
receptor-de-alertamiento.jpg
```
Iconos de aplicación en `/media/iconos/`: `icono-agricultura.png`, `icono-auditorios.png`,
`icono-bosques.png`, `icono-central-bomberos.png`, `icono-exterior.png`, `icono-gasolineras.png`,
`icono-hogar.png`, `icono-industria.png`, `icono-museos.png`, `icono-no-toxico.png`,
`icono-oficina.png`, `iconos-aeropuertos.png`, `iconos-minas.png`.
Si un producto no tiene imagen propia, reutiliza la más cercana de su categoría. Nunca inventes
un nombre de archivo que no esté en la lista.
## Contrato de tipos — respétalo al carácter
```ts
export type ClaseFuego = "A" | "B" | "C" | "D" | "K";
export type Categoria = {
slug: string; // kebab-case, ej. "extintores-portatiles"
nombre: string; // ej. "Extintores portátiles"
tagline: string; // 1 línea: qué problema resuelve, orientado a comprador
descripcion: string; // 2-3 frases
imagen: string; // "/media/producto/..."
aplicaciones: string[];
orden: number;
};
export type Producto = {
slug: string;
categoria: string; // debe existir en CATEGORIAS
nombre: string;
modelo: string | null; // ej. "Enforcer 12"
capacidad: string | null; // ej. "12 L"
agente: string | null; // ej. "FireAde 2000"
clases: ClaseFuego[]; // SOLO las clases que la fuente sostiene
alcance: string | null;
descarga: string | null;
imagen: string;
resumen: string; // 1-2 frases, orientado a venta, SIN claims sin sustento
caracteristicas: string[]; // bullets tomados de la fuente
aplicaciones: string[];
claimsPorVerificar: string[]; // ver regla crítica abajo
orden: number;
};
export const CATEGORIAS: Categoria[] = [ ... ];
export const PRODUCTOS: Producto[] = [ ... ];
// helpers que debes exportar también:
export function getCategoria(slug: string): Categoria | undefined
export function getProducto(slug: string): Producto | undefined
export function productosDeCategoria(slug: string): Producto[]
```
## REGLA CRÍTICA — honestidad de los datos
El sitio original hace afirmaciones de alto riesgo comercial: *"no caduca"*, *"20 años de
garantía"*, *"no requiere recarga"*, *"extingue clases A, B, C, D y K"*. Una auditoría concluyó
que **no deben presentarse como universales sin condiciones y evidencia por modelo**.
Por lo tanto:
1. **Nunca** pongas uno de esos claims en `resumen` ni en `caracteristicas` como hecho.
2. Cada claim así va en `claimsPorVerificar`, redactado como lo que hay que documentar.
Ejemplo: `"Vida útil sin caducidad: requiere carta técnica del fabricante por número de modelo"`.
3. `clases` solo lleva las clases que la fuente realmente atribuye a **ese** modelo. Si la
fuente es ambigua, pon las que sí sostiene y anota la duda en `claimsPorVerificar`.
4. No inventes capacidades, alcances, tiempos de descarga, precios ni certificados. Si el dato
no está en la fuente, es `null` o se omite del arreglo. Es mejor `null` que un número inventado.
## Cobertura esperada
- Las **8 categorías** del sitio.
- Todos los modelos concretos que la fuente identifique. Del scrape se ven al menos: portátiles
Enforcer de 1, 2, 4, 6, 9 y 12 litros; móviles Enforcer 60 y carretillas de 30, 50, 70 y 100 L;
fijos Enforcer 10 y esfera; rociadores esfera y robot; agentes FireAde 2000, flameAde, vaporAde,
trakAde; FIREMIKS; robot cañón; y receptor de alertamiento en protección civil.
Confirma cada uno contra la fuente antes de escribirlo.
## Salida
- TypeScript válido y formateado, sin `any`, sin imports.
- Español de México en todo el contenido, con acentuación correcta.
- Al terminar, escribe al final del archivo un comentario de bloque con:
las categorías y el número de productos por categoría, y la lista de datos que no pudiste
confirmar en la fuente.
Cuando acabes, di exactamente `CATALOGO LISTO` y un resumen de 3 líneas.
+119
View File
@@ -0,0 +1,119 @@
# Tarea: pruebas de calidad del sitio y del panel FSPM
Eres el agente de QA del proyecto. **No corriges código.** Tu trabajo es ejecutar
las pruebas, reproducir lo que falle, y entregar un reporte que el orquestador
pueda accionar sin volver a investigar desde cero.
Repo: `H:\MegaSync\Proyectos\fspm\fspm-web`
## Contexto
Sitio comercial de FSPM (protección contra incendios) con un micro CRM interno.
Es una demostración para directivos: un error visible cuesta la venta del
servicio, así que la barra es alta.
Lee `docs/CONTRATO.md` para conocer el sistema de diseño y las reglas de
contenido, y `README.md` para entender el circuito de captura.
## Preparación — YA ESTÁ HECHA, no la repitas
El artefacto de producción **ya está construido y corriendo en un contenedor**:
- Imagen `fspm-web:demo`, contenedor `fspm-demo`, en `http://127.0.0.1:3100`.
- Base sembrada: 5 usuarios, 60 contactos, 95 oportunidades, 18 pedidos, 6 reglas.
- `curl http://127.0.0.1:3100/api/salud` responde `{"ok":true,...}`.
Playwright está configurado con `reuseExistingServer: true` apuntando al 3100,
así que **usa ese contenedor y no arranques ningún servidor de desarrollo**.
**No corras `npm run build` ni `npm run dev` en esta máquina.** El repositorio
vive dentro de una carpeta sincronizada a la nube y el cliente de
sincronización mueve archivos de `.next` a media compilación: el build falla
con `Cannot find module './NNNN.js'` aunque el código esté bien. Ya está
comprobado. Si necesitas reiniciar la aplicación, usa
`docker restart fspm-demo`.
Verifica que el contenedor responde antes de empezar:
```bash
docker ps --filter name=fspm-demo
curl -s http://127.0.0.1:3100/api/salud
```
## Ejecución
```bash
npm run test:e2e
```
Cuatro proyectos: `escritorio-chromium`, `escritorio-webkit`, `movil-webkit`,
`movil-chico` (320px). **Los dos de WebKit son obligatorios**: es el motor de
Safari e iOS, donde más se rompe la responsividad, y el cliente lo pidió por
nombre. Un resultado que solo cubre Chromium no sirve.
El servidor de pruebas lo arranca Playwright solo, en el puerto 3100.
Las suites:
- `tests/01-humo.spec.ts` — rutas, títulos, meta description, Open Graph,
canónicas, sitemap, robots, iconos, 404.
- `tests/02-responsivo.spec.ts` — desborde horizontal de 320 a 1440, menú
móvil, widget de WhatsApp, tabla comparativa.
- `tests/03-captura.spec.ts` — el circuito comercial: campaña → navegación →
formulario, y que la atribución de primer toque sobreviva.
- `tests/04-panel.spec.ts` — panel no expuesto, sesión obligatoria, y el
aislamiento entre administrador y vendedor.
- `tests/05-accesibilidad.spec.ts` — alt, etiquetas, jerarquía de encabezados,
foco, movimiento reducido.
## Además de las pruebas automáticas: revisión visual
Toma capturas y **míralas**. Las pruebas no detectan que algo se ve mal.
```bash
npx playwright screenshot --viewport-size=1440,900 http://127.0.0.1:3100/ tests/reporte/inicio-1440.png
npx playwright screenshot --viewport-size=375,900 --full-page http://127.0.0.1:3100/ tests/reporte/inicio-375.png
```
Hazlo para `/`, `/catalogo`, una ficha de producto, `/soluciones/industria-energia`
y `/admin` tras entrar con el acceso rápido. Revisa en cada una:
- Texto encima de imagen: ¿se lee de verdad, o el degradado no alcanza?
- Espaciado entre secciones: ¿hay saltos incoherentes o secciones pegadas?
Es la falla típica cuando varios agentes escriben CSS en paralelo.
- ¿Alguna sección se ve como plantilla genérica frente al resto?
- ¿El riel de clases de fuego aparece consistente en sitio y panel?
- ¿Hay estados vacíos que se vean como errores?
- Contraste: ¿algún texto gris sobre fondo oscuro que no se alcance a leer?
## Sondeos manuales que valen la pena
1. **Doble envío.** Manda el formulario dos veces con el mismo correo y
confirma en `/admin/contactos` que hay **un contacto con dos oportunidades**,
no dos contactos. Es un requisito explícito del cliente.
2. **Fuga entre roles.** Entra como vendedor, copia el id de una oportunidad
que **no** sea suya (obtenlo entrando como administrador en otra pestaña) y
pide `/admin/oportunidades/<id>` directo. Debe negarlo. Si la muestra, es
una falla de seguridad y va primero en tu reporte.
3. **Widget sin JavaScript de terceros.** Confirma que el enlace de WhatsApp
que devuelve la API lleva el prefijo `[DEMO]` y el folio.
4. **Panel en móvil real.** 375px de ancho: ¿las tablas se pueden usar o hay
que hacer zoom?
## Reporte final — lo que necesito de ti
Nada de "todo pasó" ni de listas de pruebas verdes. Quiero:
1. **Resumen**: cuántas pruebas por proyecto, cuántas fallaron. Cifras reales,
pegadas de la salida.
2. **Fallas, ordenadas por gravedad.** Para cada una:
- qué se rompe, en qué ruta y en qué proyecto/ancho;
- el error literal de Playwright;
- el archivo y la línea donde está la causa, si lo pudiste localizar;
- si es una falla real del producto o una prueba mal escrita — dilo con
claridad, no todo lo que falla es un bug.
3. **Hallazgos visuales** de las capturas, con la ruta y el ancho.
4. **Lo que no pudiste probar** y por qué.
Sé directo. Si algo está mal hecho, dilo con el argumento técnico. Un reporte
complaciente no sirve de nada aquí.
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+34
View File
@@ -0,0 +1,34 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
// El repositorio vive dentro de una carpeta sincronizada a la nube en la
// máquina de desarrollo, y el cliente de sincronización bloquea archivos de
// `.next` a media compilación (EPERM al renombrar). `NEXT_DIST_DIR` permite
// construir fuera de esa carpeta en local. En el contenedor no se define, así
// que la salida estándar de `output: "standalone"` no cambia.
distDir: process.env.NEXT_DIST_DIR ?? ".next",
poweredByHeader: false,
compress: true,
images: {
formats: ["image/avif", "image/webp"],
},
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
],
},
{
source: "/media/:path*",
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
},
];
},
};
export default nextConfig;
+2843
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "fspm-web",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"db:push": "prisma db push",
"db:seed": "tsx prisma/seed.ts",
"db:reset": "prisma db push --force-reset && tsx prisma/seed.ts",
"test:e2e": "playwright test",
"test:webkit": "playwright test --project=escritorio-webkit --project=movil-webkit",
"test:humo": "playwright test tests/01-humo.spec.ts --project=escritorio-chromium"
},
"dependencies": {
"@prisma/client": "6.16.2",
"bcryptjs": "3.0.2",
"jose": "6.1.0",
"next": "15.5.4",
"react": "19.1.1",
"react-dom": "19.1.1",
"zod": "4.1.11"
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.13",
"@types/node": "22.18.6",
"@types/react": "19.1.13",
"@types/react-dom": "19.1.9",
"prisma": "6.16.2",
"tailwindcss": "4.1.13",
"tsx": "4.20.5",
"typescript": "5.9.2",
"@playwright/test": "1.56.0"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
+61
View File
@@ -0,0 +1,61 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Configuración de pruebas de extremo a extremo.
*
* WebKit está incluido a propósito y no es opcional: es el motor de Safari e iOS,
* donde más se rompe la responsividad, y el cliente lo pidió explícitamente.
* Una prueba que solo pasa en Chromium no prueba responsividad.
*/
const PUERTO = 3100;
const BASE = `http://127.0.0.1:${PUERTO}`;
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: true,
retries: 1,
workers: 3,
timeout: 45_000,
expect: { timeout: 8_000 },
reporter: [
["list"],
["json", { outputFile: "tests/reporte/resultado.json" }],
["html", { outputFolder: "tests/reporte/html", open: "never" }],
],
use: {
baseURL: BASE,
locale: "es-MX",
timezoneId: "America/Mexico_City",
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "off",
},
projects: [
{
name: "escritorio-chromium",
use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } },
},
{
name: "escritorio-webkit",
use: { ...devices["Desktop Safari"], viewport: { width: 1440, height: 900 } },
},
{
name: "movil-webkit",
use: { ...devices["iPhone 13"] },
},
{
name: "movil-chico",
use: { ...devices["Desktop Chrome"], viewport: { width: 320, height: 640 }, isMobile: false },
},
],
webServer: {
command: `npm run start -- --port ${PUERTO}`,
url: `${BASE}/api/salud`,
reuseExistingServer: true,
timeout: 180_000,
stdout: "pipe",
stderr: "pipe",
},
});
+4
View File
@@ -0,0 +1,4 @@
const config = {
plugins: { "@tailwindcss/postcss": {} },
};
export default config;
+220
View File
@@ -0,0 +1,220 @@
// FSPM — micro CRM
//
// SQLite no soporta enums nativos en Prisma: los campos de estado son String
// y su dominio vive en src/lib/crm/constantes.ts, que es la fuente de verdad.
// No inventes valores fuera de esas constantes.
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
/// Usuario del panel. rol: "ADMIN" | "VENDEDOR"
model User {
id String @id @default(cuid())
nombre String
email String @unique
passwordHash String
rol String @default("VENDEDOR")
puesto String?
telefono String?
activo Boolean @default(true)
creadoEn DateTime @default(now())
contactos Contact[]
oportunidades Opportunity[]
actividades Activity[]
pedidos Order[]
reglas AssignmentRule[]
@@index([rol, activo])
}
/// Persona. Un mismo contacto puede generar muchas oportunidades.
model Contact {
id String @id @default(cuid())
nombre String
email String?
telefono String?
/// email en minúsculas y sin espacios — clave de deduplicación
emailNorm String?
/// solo dígitos, últimos 10 — clave de deduplicación
telefonoNorm String?
empresa String?
puesto String?
ciudad String?
/// slug de industria, ver src/data/industrias.ts
industria String?
notas String?
propietarioId String?
propietario User? @relation(fields: [propietarioId], references: [id], onDelete: SetNull)
// atribución de primer toque: nunca se sobreescribe
primerUtmSource String?
primerUtmMedium String?
primerUtmCampaign String?
primerLandingPath String?
primerReferrer String?
creadoEn DateTime @default(now())
actualizadoEn DateTime @updatedAt
oportunidades Opportunity[]
pedidos Order[]
@@index([emailNorm])
@@index([telefonoNorm])
@@index([propietarioId])
@@index([creadoEn])
}
/// Un envío de formulario o de widget de WhatsApp convertido en oportunidad.
model Opportunity {
id String @id @default(cuid())
/// OPP-2026-0001
folio String @unique
contactoId String
contacto Contact @relation(fields: [contactoId], references: [id], onDelete: Cascade)
propietarioId String?
propietario User? @relation(fields: [propietarioId], references: [id], onDelete: SetNull)
/// "FORMULARIO" | "WHATSAPP"
origen String
/// dónde se envió: "home" | "catalogo" | "producto" | "solucion" | "contacto" | "widget"
canal String?
/// "NUEVO" | "CONTACTADO" | "DIAGNOSTICO" | "COTIZACION" | "GANADA" | "PERDIDA"
etapa String @default("NUEVO")
/// "ALTA" | "MEDIA" | "BAJA"
prioridad String @default("MEDIA")
// interés declarado
productoSlug String?
productoNombre String?
/// URL del producto de interés, tal como la vio el usuario
productoUrl String?
categoriaSlug String?
industria String?
/// clases de fuego separadas por coma, ej. "B,K"
clasesFuego String?
superficie String?
urgencia String?
mensaje String?
valorEstimado Float?
// atribución de campaña
utmSource String?
utmMedium String?
utmCampaign String?
utmTerm String?
utmContent String?
gclid String?
fbclid String?
referrer String?
landingPath String?
paginaUrl String?
dispositivo String?
userAgent String?
creadoEn DateTime @default(now())
actualizadoEn DateTime @updatedAt
primeraRespuestaEn DateTime?
cerradoEn DateTime?
motivoPerdida String?
actividades Activity[]
pedidos Order[]
@@index([etapa])
@@index([propietarioId])
@@index([contactoId])
@@index([creadoEn])
@@index([utmCampaign])
@@index([origen])
}
/// Bitácora de la oportunidad.
model Activity {
id String @id @default(cuid())
oportunidadId String
oportunidad Opportunity @relation(fields: [oportunidadId], references: [id], onDelete: Cascade)
usuarioId String?
usuario User? @relation(fields: [usuarioId], references: [id], onDelete: SetNull)
/// "NOTA" | "LLAMADA" | "CORREO" | "WHATSAPP" | "REUNION" | "CAMBIO_ETAPA" | "ASIGNACION" | "SISTEMA"
tipo String
texto String
creadoEn DateTime @default(now())
@@index([oportunidadId, creadoEn])
}
/// Pedido derivado de una oportunidad ganada.
model Order {
id String @id @default(cuid())
/// PED-2026-0001
folio String @unique
oportunidadId String?
oportunidad Opportunity? @relation(fields: [oportunidadId], references: [id], onDelete: SetNull)
contactoId String
contacto Contact @relation(fields: [contactoId], references: [id], onDelete: Cascade)
propietarioId String?
propietario User? @relation(fields: [propietarioId], references: [id], onDelete: SetNull)
/// "BORRADOR" | "CONFIRMADO" | "EN_PROCESO" | "ENVIADO" | "ENTREGADO" | "CANCELADO"
estado String @default("BORRADOR")
moneda String @default("MXN")
subtotal Float @default(0)
iva Float @default(0)
total Float @default(0)
/// JSON: [{ productoSlug, nombre, cantidad, precioUnitario }]
items String @default("[]")
notas String?
entregaEstimada DateTime?
creadoEn DateTime @default(now())
actualizadoEn DateTime @updatedAt
@@index([estado])
@@index([propietarioId])
@@index([creadoEn])
}
/// Regla de asignación de oportunidades. Se evalúan por prioridad ascendente,
/// gana la primera que coincide; si ninguna coincide, se reparte por turno
/// entre los vendedores activos.
model AssignmentRule {
id String @id @default(cuid())
nombre String
/// menor número = se evalúa primero
prioridad Int @default(100)
/// "INDUSTRIA" | "CATEGORIA" | "ORIGEN" | "CLASE" | "CUALQUIERA"
tipo String
/// valor a comparar; null para "CUALQUIERA"
valor String?
usuarioId String
usuario User @relation(fields: [usuarioId], references: [id], onDelete: Cascade)
activa Boolean @default(true)
creadoEn DateTime @default(now())
@@index([activa, prioridad])
}
/// Contadores para folios legibles. name: "oportunidad" | "pedido"
model Counter {
name String @id
valor Int @default(0)
}
+1260
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,316 @@
import type { Metadata } from "next";
import Link from "next/link";
import { AVISO_DEMO, EMPRESA, SITIO_URL } from "@/data/empresa";
/**
* /aviso-de-privacidad
*
* Reproduce, reordenado para poder leerse, el aviso publicado por FSPM en su
* sitio público, más una sección que declara qué recaba realmente el formulario
* de esta demo. Lo que no estaba en la fuente no se agregó, y la versión
* definitiva es siempre la que publique la empresa.
*/
const TITULO = "Aviso de privacidad";
const DESCRIPCION = `Aviso de privacidad de ${EMPRESA.razonSocial}: responsable, finalidades del tratamiento, transferencias, medidas de seguridad y ejercicio de derechos ARCO.`;
export const metadata: Metadata = {
title: TITULO,
description: DESCRIPCION,
alternates: { canonical: `${SITIO_URL}/aviso-de-privacidad` },
robots: { index: true, follow: true },
openGraph: {
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
description: DESCRIPCION,
url: `${SITIO_URL}/aviso-de-privacidad`,
},
};
const CORREO_ARCO = "[email protected]";
const CAMPOS_FORMULARIO = [
{ campo: "Nombre", uso: "Identificarte y dirigirnos a ti por tu nombre en el seguimiento." },
{ campo: "Correo electrónico", uso: "Enviarte la respuesta técnica y la propuesta." },
{ campo: "Teléfono", uso: "Contactarte cuando la solicitud requiere una llamada." },
{ campo: "Empresa y puesto", uso: "Entender quién decide y con qué alcance, para no dar una respuesta genérica." },
{ campo: "Ciudad o estado", uso: "Ubicar la instalación y saber qué requisitos locales pueden aplicar." },
{ campo: "Tipo de operación y superficie", uso: "Interpretar el escenario y proponer una configuración." },
{ campo: "Clases de fuego declaradas", uso: "Clasificar el riesgo que tú mismo declaras en el selector." },
{ campo: "Urgencia y mensaje", uso: "Priorizar la atención y conocer el requerimiento en tus palabras." },
{
campo: "Datos de origen de la visita",
uso: "Saber por qué campaña o página llegaste, para medir qué contenido resuelve dudas reales.",
},
];
export default function PaginaAviso() {
return (
<>
{/* ---------- encabezado ---------- */}
<section className="bg-ink pt-12 pb-10 sm:pt-16 sm:pb-12">
<div className="shell">
<nav aria-label="Ruta de navegación" className="mb-8">
<ol className="flex flex-wrap items-center gap-2 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
<li>
<Link href="/" className="hover:text-bright">
Inicio
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-bright">Aviso de privacidad</li>
</ol>
</nav>
<div className="max-w-3xl">
<p className="eyebrow mb-5">Documento legal</p>
<h1 className="font-display text-[1.875rem] font-black uppercase leading-[1.04] tracking-tight sm:text-[2.5rem]">
Aviso de privacidad
</h1>
<p className="mt-5 text-sm leading-relaxed text-muted sm:text-base">
Reproducción del aviso publicado por {EMPRESA.razonSocial}, reordenado para poder
leerse. La versión vigente y definitiva es la que la empresa publique en su propio
sitio.
</p>
</div>
<div
role="note"
aria-label="Naturaleza de este sitio"
className="mt-8 max-w-3xl rounded-[6px] border border-rule border-l-2 border-l-ember bg-ink-2 p-5"
>
<p className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-ember">
Sobre este sitio
</p>
<p className="mt-3 text-sm leading-relaxed text-muted">{AVISO_DEMO}</p>
</div>
</div>
</section>
{/* ---------- el aviso ---------- */}
<article className="border-t border-rule bg-paper py-14 text-deep sm:py-20">
<div className="shell">
<div className="max-w-3xl space-y-12">
<section aria-labelledby="responsable">
<h2 id="responsable" className="font-display text-xl font-extrabold text-deep sm:text-2xl">
1. Responsable del tratamiento
</h2>
<div className="mt-4 space-y-4 text-sm leading-relaxed text-deep-muted">
<p>
{EMPRESA.razonSocial} ({EMPRESA.nombreCorto}), empresa legalmente constituida
conforme a las leyes de la República Mexicana, con domicilio en Avenida Instituto
Técnico Industrial número 11, Colonia {EMPRESA.colonia}, Alcaldía Cuauhtémoc, C.P.
06400, {EMPRESA.ciudad}.
</p>
<p>
{EMPRESA.nombreCorto} será el responsable del tratamiento que, con motivo de la
relación jurídica que haya sido celebrada y para el cumplimiento de la misma, se le
dé a tus datos personales.
</p>
<p>
En cumplimiento de la Ley Federal de Protección de Datos Personales en Posesión de
los Particulares, pone a tu disposición el presente aviso para hacer de tu
conocimiento que los datos personales que nos proporciones, incluso aquellos
sensibles y patrimoniales o financieros, serán tratados dentro de un marco que
garantice su privacidad y el derecho a la autodeterminación informativa de las
personas.
</p>
</div>
</section>
<hr className="hair-lite" />
<section aria-labelledby="datos">
<h2 id="datos" className="font-display text-xl font-extrabold text-deep sm:text-2xl">
2. Qué datos recaba este sitio
</h2>
<p className="mt-4 text-sm leading-relaxed text-deep-muted">
Esta sección no viene del aviso original: describe exactamente lo que pide el
formulario de este sitio, para que sepas qué estás enviando antes de enviarlo.
</p>
<div
className="mt-6 overflow-x-auto rounded-[6px] border border-rule-lite bg-white [-webkit-overflow-scrolling:touch]"
tabIndex={0}
role="region"
aria-label="Datos que recaba el formulario, tabla desplazable en horizontal"
>
<table className="w-full min-w-[36rem] border-collapse text-left">
<caption className="sr-only">
Campos del formulario de contacto y para qué se usa cada uno.
</caption>
<thead>
<tr className="border-b border-rule-lite bg-paper-2">
<th
scope="col"
className="whitespace-nowrap px-4 py-3 font-mono text-[0.625rem] font-medium uppercase tracking-[0.14em] text-deep-muted"
>
Dato
</th>
<th
scope="col"
className="whitespace-nowrap px-4 py-3 font-mono text-[0.625rem] font-medium uppercase tracking-[0.14em] text-deep-muted"
>
Para qué se usa
</th>
</tr>
</thead>
<tbody>
{CAMPOS_FORMULARIO.map((fila) => (
<tr key={fila.campo} className="border-b border-rule-lite last:border-b-0">
<th scope="row" className="px-4 py-3.5 text-left align-top">
<span className="font-sans text-sm font-semibold text-deep">
{fila.campo}
</span>
</th>
<td className="px-4 py-3.5 align-top text-sm leading-relaxed text-deep-muted">
{fila.uso}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-5 text-sm leading-relaxed text-deep-muted">
El formulario no pide datos de tarjeta, contraseñas ni información financiera. Si
necesitas compartir documentación sensible de tu operación, acuérdalo por correo
antes de enviarla.
</p>
</section>
<hr className="hair-lite" />
<section aria-labelledby="finalidades">
<h2 id="finalidades" className="font-display text-xl font-extrabold text-deep sm:text-2xl">
3. Finalidades del tratamiento
</h2>
<div className="mt-4 space-y-4 text-sm leading-relaxed text-deep-muted">
<p>
Tus datos personales, incluso aquellos sensibles y patrimoniales o financieros,
serán utilizados para las siguientes finalidades: identificarte, localizarte,
contactarte, así como para cumplir las obligaciones derivadas de la relación
celebrada contigo, y cualquier otro fin compatible o análogo de los aquí
establecidos.
</p>
<p>
Esto incluye enviar, por cualquier medio físico, electrónico o magnético, de manera
enunciativa mas no limitativa: invitación a reuniones de trabajo, minutas, noticias
y anuncios en general relacionados con la institución, campañas publicitarias
afines e invitación a eventos.
</p>
</div>
</section>
<hr className="hair-lite" />
<section aria-labelledby="transferencias">
<h2 id="transferencias" className="font-display text-xl font-extrabold text-deep sm:text-2xl">
4. Transferencias
</h2>
<p className="mt-4 text-sm leading-relaxed text-deep-muted">
Queda convenido que aceptas la transferencia que {EMPRESA.nombreCorto} pudiera
realizar a sus asociados y a terceros, en cumplimiento de las finalidades
anteriormente descritas.
</p>
</section>
<hr className="hair-lite" />
<section aria-labelledby="seguridad">
<h2 id="seguridad" className="font-display text-xl font-extrabold text-deep sm:text-2xl">
5. Medidas de seguridad
</h2>
<p className="mt-4 text-sm leading-relaxed text-deep-muted">
Con el objeto de limitar el uso y divulgación de tus datos personales, se mantienen
medidas de seguridad administrativas, técnicas y físicas para evitar su daño,
pérdida, alteración, destrucción, uso, acceso o divulgación indebida.
</p>
</section>
<hr className="hair-lite" />
<section aria-labelledby="consentimiento">
<h2 id="consentimiento" className="font-display text-xl font-extrabold text-deep sm:text-2xl">
6. Excepciones al consentimiento
</h2>
<p className="mt-4 text-sm leading-relaxed text-deep-muted">
{EMPRESA.nombreCorto} no requerirá el consentimiento del titular para tratar datos
personales cuando lo haga con fundamento en lo dispuesto por la Ley Federal de
Protección de Datos Personales en Posesión de los Particulares.
</p>
</section>
<hr className="hair-lite" />
<section aria-labelledby="arco">
<h2 id="arco" className="font-display text-xl font-extrabold text-deep sm:text-2xl">
7. Derechos de acceso, rectificación, cancelación y oposición
</h2>
<div className="mt-4 space-y-4 text-sm leading-relaxed text-deep-muted">
<p>
El ejercicio de los derechos de Acceso, Rectificación, Cancelación y Oposición
respecto de tus datos personales, así como la revocación del consentimiento para su
tratamiento, podrá realizarse a través de los procedimientos implementados o
solicitándolo al siguiente correo electrónico:
</p>
<p>
<a href={`mailto:${CORREO_ARCO}`} className="link break-all font-mono text-sm">
{CORREO_ARCO}
</a>
</p>
</div>
</section>
<hr className="hair-lite" />
<section aria-labelledby="cambios">
<h2 id="cambios" className="font-display text-xl font-extrabold text-deep sm:text-2xl">
8. Cambios al aviso
</h2>
<p className="mt-4 text-sm leading-relaxed text-deep-muted">
{EMPRESA.nombreCorto} podrá enmendar o modificar el presente aviso de privacidad como
lo estime conveniente, e informará y pondrá a tu disposición el aviso actualizado en
su sitio web.
</p>
</section>
<hr className="hair-lite" />
<section aria-labelledby="contacto-aviso">
<h2 id="contacto-aviso" className="font-display text-xl font-extrabold text-deep sm:text-2xl">
9. Contacto
</h2>
<dl className="spec mt-5 sm:grid-cols-2">
<div className="spec-cell !bg-white">
<dt className="spec-key !text-deep-muted">Teléfono</dt>
<dd className="spec-val !text-deep">
<a href={`tel:${EMPRESA.telefonoTel}`} className="link">
{EMPRESA.telefono}
</a>
</dd>
</div>
<div className="spec-cell !bg-white">
<dt className="spec-key !text-deep-muted">Correo general</dt>
<dd className="spec-val !text-deep">
<a href={`mailto:${EMPRESA.correo}`} className="link break-all">
{EMPRESA.correo}
</a>
</dd>
</div>
</dl>
<p className="mt-6 text-xs leading-relaxed text-deep-muted">
Si detectas una diferencia entre este texto y el aviso publicado por la empresa,
prevalece el de la empresa.{" "}
<Link href="/contacto" className="link">
Avísanos
</Link>{" "}
y lo corregimos.
</p>
</section>
</div>
</div>
</article>
</>
);
}
+443
View File
@@ -0,0 +1,443 @@
import type { Metadata } from "next";
import Link from "next/link";
import { RielClases } from "@/components/marca/RielClases";
import { Revelar } from "@/components/site/Revelar";
import { getCategoria } from "@/data/catalogo";
import type { ClaseFuego } from "@/data/clases-fuego";
import { EMPRESA, SITIO_URL } from "@/data/empresa";
import { getIndustria } from "@/data/industrias";
/**
* /casos — escenarios de aplicación con la estructura que pide el estudio:
* problema → riesgo → solución → prueba → resultado → documentación.
*
* REGLA DURA: son escenarios construidos con fines de demostración. No
* corresponden a clientes reales, no llevan nombres de empresa y no presentan
* cifras como si fueran mediciones. El aviso va arriba, no en una nota al pie.
*/
const TITULO = "Casos de aplicación";
const DESCRIPCION =
"Cuatro escenarios ilustrativos de protección contra incendios con la estructura problema, riesgo, solución, prueba, resultado y documentación. Construidos con fines de demostración.";
export const metadata: Metadata = {
title: TITULO,
description: DESCRIPCION,
alternates: { canonical: `${SITIO_URL}/casos` },
openGraph: {
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
description: DESCRIPCION,
url: `${SITIO_URL}/casos`,
},
};
type Caso = {
id: string;
industriaSlug: string;
titulo: string;
escenario: string;
clases: ClaseFuego[];
categorias: string[];
problema: string;
riesgo: string;
solucion: string[];
prueba: string;
resultado: string[];
documentacion: string[];
};
const CASOS: Caso[] = [
{
id: "trasvase",
industriaSlug: "industria-energia",
titulo: "Patio de trasvase de solventes en una planta química",
escenario: "Escenario ilustrativo · Planta química, patio de trasvase con contención secundaria",
clases: ["A", "B", "C"],
categorias: ["agentes-extinguidores", "productos-moviles"],
problema:
"El patio de trasvase concentra el riesgo más alto de la planta y está a más de cien metros de la caseta de seguridad. La respuesta disponible es equipo portátil montado en pared, pensado para un conato de oficina, no para un derrame inflamado con producto a presión.",
riesgo:
"Un derrame encendido en contención secundaria no da minutos: escala en segundos y compromete el resto del patio. El costo real no es el equipo perdido, es el paro de línea, la investigación posterior y la renovación de la póliza.",
solucion: [
"Diagnóstico por zona con inventario de combustibles y volúmenes reales de operación.",
"Equipo móvil de mayor capacidad posicionado a distancia de recorrido, no a distancia en línea recta.",
"Selección del agente por tipo de combustible, incluida la condición de alcoholes si hay producto polar.",
"Capacitación de la brigada del turno que efectivamente está en el patio de noche.",
],
prueba:
"Demostración con fuego controlado del mismo combustible que se maneja en el patio, operada por el personal del turno y no por un instructor. Se mide el tiempo desde la alarma hasta la primera aplicación de agente y se identifica en qué punto del recorrido se pierde tiempo.",
resultado: [
"Queda documentado cuánto tarda hoy la primera respuesta en el punto más lejano, que es el dato que la operación no tenía.",
"El reposicionamiento del equipo se decide con el recorrido cronometrado, no con un plano.",
"La brigada opera el equipo por primera vez antes del incidente, no durante.",
],
documentacion: [
"Memoria del diagnóstico por zona con inventario de combustibles.",
"Ficha técnica y hoja de datos de seguridad del agente seleccionado.",
"Registro de la demostración con fecha, participantes y tiempos observados.",
"Constancia de capacitación por persona y turno.",
],
},
{
id: "bodega",
industriaSlug: "logistica-bodegas",
titulo: "Nave de almacenamiento con carga combustible y montacargas eléctricos",
escenario: "Escenario ilustrativo · Centro logístico, nave única con racks de gran altura",
clases: ["A", "B", "C"],
categorias: ["extintores-portatiles", "rociadores"],
problema:
"La nave tiene extintores suficientes en el conteo, pero el riesgo cambia de pasillo a pasillo: producto terminado en cartón, un área de carga de baterías de montacargas y una zona de residuos al fondo. El equipo está distribuido de forma uniforme sobre un riesgo que no es uniforme.",
riesgo:
"El área de carga de baterías introduce riesgo eléctrico dentro de una nave de carga combustible, y la zona de residuos genera reencendido cuando se ataca con un agente que apaga la llama pero deja brasa. Una sola ruta de salida agrava cualquier retraso.",
solucion: [
"Mapa de riesgo por zona, con la altura de almacenamiento y el tipo de carga de cada pasillo.",
"Redistribución del equipo por distancia real de recorrido del operador, no por metros cuadrados.",
"Solución de activación automática en las zonas de mayor acumulación y en el área de carga de baterías.",
"Programa de inspección mensual con responsable nombrado y bitácora por equipo.",
],
prueba:
"Recorrido cronometrado desde el punto más desfavorable de cada zona hasta el equipo más cercano, con un operador de piso y no con personal de seguridad. Verificación de que cada equipo corresponde a la clase de fuego de su zona.",
resultado: [
"Se identifican las zonas donde el equipo existente no corresponde a la clase de fuego presente.",
"La distribución deja de basarse en el conteo total y empieza a basarse en el recorrido.",
"La inspección mensual pasa de ser una intención a tener nombre, formato y fecha.",
],
documentacion: [
"Mapa de riesgo por zona con la clasificación aplicada.",
"Inventario de equipo con ubicación, clase y capacidad.",
"Bitácora de revisión mensual con responsable asignado.",
"Constancia de instalación y puesta en marcha de las soluciones automáticas.",
],
},
{
id: "gasolinera",
industriaSlug: "gasolineras-flotas",
titulo: "Estación de servicio con público en isla y autotanques en patio",
escenario: "Escenario ilustrativo · Estación de servicio urbana con seis posiciones de carga",
clases: ["A", "B", "C"],
categorias: ["productos-moviles", "extintores-portatiles"],
problema:
"La primera respuesta la da el despachador de turno, no un bombero. El equipo instalado requiere una secuencia de operación que nadie del personal ha practicado, y en la isla hay clientes a pocos metros del punto de carga.",
riesgo:
"Un fuego de hidrocarburo en isla con público presente convierte cualquier duda de operación en un problema de evacuación. Aplicar agua a chorro sobre combustible derramado esparce el fuego en lugar de contenerlo.",
solucion: [
"Equipo operable en el primer intento por personal sin entrenamiento profundo, en isla y en patio.",
"Agente adecuado para hidrocarburos, con la condición de aplicación declarada por escrito.",
"Entrenamiento del personal de los tres turnos, no solo del encargado.",
"Procedimiento de respuesta y control de derrame colocado donde ocurre el incidente.",
],
prueba:
"Práctica con fuego controlado en la que cada despachador opera el equipo una vez, sin asistencia. Se registra quién completó la secuencia y quién no, y se repite hasta que todos la completan.",
resultado: [
"Queda constancia nominal de quién puede operar el equipo y quién todavía no.",
"El procedimiento deja de vivir en una carpeta y queda colocado en la isla.",
"Se documenta la selección del agente frente a los combustibles que la estación despacha.",
],
documentacion: [
"Ficha técnica del equipo y hoja de datos de seguridad del agente.",
"Procedimiento de respuesta y control de derrame, firmado por el responsable de la estación.",
"Constancias de capacitación por persona y turno.",
"Requisitos adicionales de Protección Civil municipal, cuando la autoridad local los establezca.",
],
},
{
id: "cocina",
industriaSlug: "cocinas-hoteles",
titulo: "Cocina de hotel con freidoras bajo una sola campana",
escenario: "Escenario ilustrativo · Hotel de servicio completo, cocina central y comedor",
clases: ["A", "B", "K"],
categorias: ["rociadores", "extintores-portatiles"],
problema:
"Bajo la misma campana conviven varias freidoras y planchas. El equipo colgado en la pared no está calificado para fuego de aceite y grasa a temperatura de fritura, y el ducto de extracción acumula grasa entre limpiezas.",
riesgo:
"Aplicar agua sobre aceite hirviendo provoca proyección violenta de grasa encendida sobre quien opera el equipo. Además del riesgo a la persona, un incidente en campana y ducto detiene el servicio de alimentos y arrastra la ocupación del hotel.",
solucion: [
"Solución específica por cocina, con atención separada a campana y ducto.",
"Equipo apto para clase K en la línea de fritura, con su condición de aplicación documentada.",
"Demostración con el personal que realmente opera de noche, incluido el turno de cierre.",
"Procedimiento posterior al incidente para reanudar el servicio sin improvisar.",
],
prueba:
"Demostración de fuego de aceite en condiciones controladas, con el personal de cocina operando. Verificación de que la limpieza del ducto está registrada y de que el registro corresponde a lo que se observa en el ducto.",
resultado: [
"El equipo genérico de la pared se sustituye o se complementa con lo que sí aplica a clase K.",
"El personal de cierre sabe qué hacer y qué no hacer con el aceite encendido.",
"La reanudación del servicio queda escrita antes de necesitarla, no después.",
],
documentacion: [
"Ficha técnica del equipo apto para clase K y condiciones de aplicación.",
"Registro de limpieza de campanas y ductos con fecha y responsable.",
"Procedimiento posterior al incidente y de reanudación del servicio.",
"Constancias de capacitación del personal de cocina por turno.",
],
},
];
export default function PaginaCasos() {
return (
<>
{/* ---------- encabezado con el aviso destacado arriba ---------- */}
<section className="grid-bg bg-ink pt-12 pb-14 sm:pt-16 sm:pb-20">
<div className="shell">
<nav aria-label="Ruta de navegación" className="mb-8">
<ol className="flex flex-wrap items-center gap-2 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
<li>
<Link href="/" className="hover:text-bright">
Inicio
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-bright">Casos de aplicación</li>
</ol>
</nav>
{/* el aviso va antes del contenido, no al final */}
<div
role="note"
aria-label="Naturaleza de estos casos"
className="mb-10 rounded-[6px] border border-rule bg-ink-2 p-0 sm:mb-12"
>
<div className="hazard h-1.5 w-full rounded-t-[6px]" aria-hidden="true" />
<div className="p-5 sm:p-6">
<p className="font-mono text-[0.6875rem] font-medium uppercase tracking-[0.16em] text-ember">
Escenarios ilustrativos
</p>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-muted">
Los cuatro casos de esta página están{" "}
<span className="text-bright">
construidos con fines de demostración y no corresponden a clientes reales
</span>
. No incluyen nombres de empresa, y sus resultados se describen de forma
cualitativa: ninguna cifra de esta página debe leerse como una medición. Están
escritos para mostrar cómo se estructura un caso, no para acreditar uno.
</p>
</div>
</div>
<div className="max-w-3xl anim-rise">
<p className="eyebrow mb-5">Casos de aplicación</p>
<h1 className="font-display text-[2rem] font-black uppercase leading-[1.03] tracking-tight sm:text-[2.75rem] lg:text-5xl">
Seis pasos que convierten una compra en un expediente
</h1>
<p className="mt-6 text-base leading-relaxed text-muted sm:text-lg">
Problema, riesgo, solución, prueba, resultado y documentación. Es la estructura que
recomienda el estudio de marca y también el orden en el que conviene trabajar: cuando
la prueba se hace antes de la compra, la documentación deja de ser un trámite y se
vuelve la constancia de algo que sí ocurrió.
</p>
</div>
</div>
</section>
{/* ---------- los casos ---------- */}
{CASOS.map((caso, indice) => {
const industria = getIndustria(caso.industriaSlug);
const claro = indice % 2 === 0;
return (
<section
key={caso.id}
id={caso.id}
aria-labelledby={`caso-${caso.id}`}
className={[
"border-t py-14 sm:py-20",
claro ? "grid-bg-lite border-rule bg-paper" : "border-rule bg-ink",
].join(" ")}
>
<div className="shell">
<div className="grid gap-10 lg:grid-cols-[1fr_1.35fr] lg:gap-16">
{/* identificación del caso */}
<Revelar className="lg:sticky lg:top-24 lg:self-start">
<p
className={[
"font-mono text-[0.625rem] uppercase tracking-[0.16em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
Caso {String(indice + 1).padStart(2, "0")} de {CASOS.length}
</p>
<h2
id={`caso-${caso.id}`}
className={[
"mt-4 font-display text-2xl font-extrabold leading-tight sm:text-3xl",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
{caso.titulo}
</h2>
<p
className={[
"mt-4 font-mono text-[0.6875rem] uppercase leading-relaxed tracking-[0.1em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
{caso.escenario}
</p>
<div className="mt-7">
<RielClases
activas={caso.clases}
tamano="md"
claro={claro}
etiqueta="Clases del escenario"
/>
</div>
<ul className="mt-7 flex flex-wrap gap-2">
{caso.categorias.map((slug) => {
const categoria = getCategoria(slug);
if (!categoria) return null;
return (
<li key={slug}>
<Link
href={`/catalogo/${categoria.slug}`}
className={[
"badge [-webkit-tap-highlight-color:transparent]",
claro
? "!border-rule-lite !text-deep-muted hover:!border-signal hover:!text-deep"
: "hover:border-signal hover:text-bright",
].join(" ")}
>
{categoria.nombre}
</Link>
</li>
);
})}
</ul>
{industria ? (
<Link
href={`/soluciones/${industria.slug}`}
className="mt-7 inline-block font-mono text-[0.625rem] uppercase tracking-[0.14em] text-signal hover:text-signal-hi"
>
Segmento: {industria.nombre} &rarr;
</Link>
) : null}
</Revelar>
{/* los seis pasos */}
<div className="space-y-8">
<Paso claro={claro} n="01" titulo="Problema">
<p className={parrafo(claro)}>{caso.problema}</p>
</Paso>
<Paso claro={claro} n="02" titulo="Riesgo" acento>
<p className={parrafo(claro)}>{caso.riesgo}</p>
</Paso>
<Paso claro={claro} n="03" titulo="Solución">
<Lista claro={claro} elementos={caso.solucion} />
</Paso>
<Paso claro={claro} n="04" titulo="Prueba">
<p className={parrafo(claro)}>{caso.prueba}</p>
</Paso>
<Paso claro={claro} n="05" titulo="Resultado">
<Lista claro={claro} elementos={caso.resultado} />
<p
className={[
"mt-4 font-mono text-[0.625rem] uppercase leading-relaxed tracking-[0.1em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
Resultados descritos de forma cualitativa: este escenario no tiene mediciones
reales que reportar.
</p>
</Paso>
<Paso claro={claro} n="06" titulo="Documentación">
<Lista claro={claro} elementos={caso.documentacion} />
</Paso>
</div>
</div>
</div>
</section>
);
})}
{/* ---------- cierre ---------- */}
<section className="border-t border-rule bg-ink-2 py-14 sm:py-20">
<div className="shell">
<div className="max-w-3xl">
<Revelar>
<p className="eyebrow mb-4">Sobre los casos reales</p>
<h2 className="font-display text-2xl font-extrabold sm:text-3xl">
Por qué todavía no publicamos casos con nombre
</h2>
</Revelar>
<p className="mt-4 text-sm leading-relaxed text-muted sm:text-base">
Un caso de estudio requiere autorización del cliente sobre qué se puede contar, qué
periodo cubre y qué cifras se pueden publicar. Mientras eso no exista por escrito,
preferimos escenarios rotulados como estos antes que atribuir un resultado a una marca
que no lo ha confirmado.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link href="/contacto" className="btn btn-signal">
Plantear tu escenario
</Link>
<Link href="/evidencia" className="btn btn-ghost">
Ver el centro de evidencia
</Link>
</div>
</div>
</div>
</section>
</>
);
}
function parrafo(claro: boolean): string {
return [
"text-sm leading-relaxed sm:text-base",
claro ? "text-deep-muted" : "text-muted",
].join(" ");
}
function Paso({
n,
titulo,
claro,
acento = false,
children,
}: {
n: string;
titulo: string;
claro: boolean;
acento?: boolean;
children: React.ReactNode;
}) {
return (
<div
className={[
"border-l-2 pl-5 sm:pl-6",
acento ? "border-l-ember" : claro ? "border-l-rule-lite" : "border-l-rule",
].join(" ")}
>
<p
className={[
"font-mono text-[0.625rem] font-semibold uppercase tracking-[0.16em]",
acento ? (claro ? "text-ember-dim" : "text-ember") : "text-signal",
].join(" ")}
>
{n} · {titulo}
</p>
<div className="mt-3.5">{children}</div>
</div>
);
}
function Lista({ elementos, claro }: { elementos: string[]; claro: boolean }) {
return (
<ul className="space-y-3">
{elementos.map((elemento) => (
<li key={elemento} className="flex gap-3">
<span aria-hidden="true" className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-signal" />
<p className={parrafo(claro)}>{elemento}</p>
</li>
))}
</ul>
);
}
@@ -0,0 +1,277 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { FormularioContacto } from "@/components/formulario/FormularioContacto";
import { Revelar } from "@/components/site/Revelar";
import { TarjetaProducto } from "@/components/site/catalogo/TarjetaProducto";
import { iconoDeAplicacion } from "@/components/site/catalogo/iconos";
import { CATEGORIAS, getCategoria, productosDeCategoria } from "@/data/catalogo";
import type { ClaseFuego } from "@/data/clases-fuego";
import { SITIO_URL } from "@/data/empresa";
import { INDUSTRIAS } from "@/data/industrias";
/**
* /catalogo/[categoria] — las ocho rutas que el sitio original no tenía.
*
* Además de los productos, la página cruza con los segmentos prioritarios del
* estudio: si esta categoría aparece en el paquete recomendado de una
* industria, se enlaza. Es lo que convierte un catálogo en una ruta de compra.
*/
type Props = { params: Promise<{ categoria: string }> };
export function generateStaticParams(): Array<{ categoria: string }> {
return CATEGORIAS.map((c) => ({ categoria: c.slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { categoria: slug } = await params;
const categoria = getCategoria(slug);
if (!categoria) {
return { title: "Categoría no encontrada" };
}
const url = `${SITIO_URL}/catalogo/${categoria.slug}`;
const descripcion = `${categoria.tagline} ${productosDeCategoria(categoria.slug).length} equipos con clases de fuego, capacidad y agente declarados.`;
return {
title: categoria.nombre,
description: descripcion,
alternates: { canonical: url },
openGraph: {
title: `${categoria.nombre} | Catálogo FSPM`,
description: descripcion,
url,
images: [{ url: categoria.imagen }],
},
};
}
export default async function PaginaCategoria({ params }: Props) {
const { categoria: slug } = await params;
const categoria = getCategoria(slug);
if (!categoria) notFound();
const productos = productosDeCategoria(categoria.slug);
const industrias = INDUSTRIAS.filter((i) => i.categorias.includes(categoria.slug));
// clases que realmente cubre algún producto de la categoría
const clases = Array.from(
new Set(productos.flatMap((p) => p.clases)),
) as ClaseFuego[];
const otras = CATEGORIAS.filter((c) => c.slug !== categoria.slug);
return (
<>
{/* ---------- encabezado de categoría ---------- */}
<section className="grid-bg bg-ink pt-12 pb-14 sm:pt-16 sm:pb-16">
<div className="shell">
<nav aria-label="Ruta de navegación" className="mb-8">
<ol className="flex flex-wrap items-center gap-2 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
<li>
<Link href="/" className="hover:text-bright">
Inicio
</Link>
</li>
<li aria-hidden="true">/</li>
<li>
<Link href="/catalogo" className="hover:text-bright">
Catálogo
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-bright">{categoria.nombre}</li>
</ol>
</nav>
<div className="grid gap-10 lg:grid-cols-[1.2fr_1fr] lg:items-center">
<div className="anim-rise">
<p className="eyebrow mb-5">
Categoría {String(categoria.orden).padStart(2, "0")} de {CATEGORIAS.length}
</p>
<h1 className="font-display text-[2rem] font-black uppercase leading-[1.04] tracking-tight sm:text-[2.75rem] lg:text-5xl">
{categoria.nombre}
</h1>
<p className="mt-5 max-w-xl text-base leading-relaxed text-bright/85 sm:text-lg">
{categoria.tagline}
</p>
<p className="mt-4 max-w-xl text-sm leading-relaxed text-muted">
{categoria.descripcion}
</p>
<dl className="spec mt-8 sm:grid-cols-2">
<div className="spec-cell">
<dt className="spec-key">Equipos en la categoría</dt>
<dd className="spec-val">{productos.length}</dd>
</div>
<div className="spec-cell">
<dt className="spec-key">Clases declaradas</dt>
<dd className="spec-val">{clases.length > 0 ? clases.join(" · ") : "No aplica"}</dd>
</div>
</dl>
</div>
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-[6px] border border-rule bg-ink-2">
<Image
src={categoria.imagen}
alt={categoria.nombre}
fill
priority
sizes="(max-width: 1024px) 92vw, 34rem"
className="object-contain p-8"
/>
</div>
</div>
</div>
</section>
{/* ---------- aplicaciones ---------- */}
<section className="border-t border-rule bg-ink-2 py-12 sm:py-14" aria-labelledby="aplicaciones-titulo">
<div className="shell">
<h2
id="aplicaciones-titulo"
className="font-display text-lg font-extrabold uppercase tracking-wide sm:text-xl"
>
Dónde la ubica la fuente
</h2>
<ul className="mt-6 flex flex-wrap gap-2.5">
{categoria.aplicaciones.map((aplicacion) => {
const icono = iconoDeAplicacion(aplicacion);
return (
<li
key={aplicacion}
className="flex items-center gap-2 rounded-[3px] border border-rule-2 bg-ink px-3 py-2"
>
{icono ? (
<Image
src={icono}
alt=""
width={20}
height={20}
sizes="20px"
className="h-5 w-5 shrink-0 object-contain opacity-80"
/>
) : null}
<span className="font-mono text-[0.6875rem] uppercase tracking-[0.1em] text-bright">
{aplicacion}
</span>
</li>
);
})}
</ul>
<p className="mt-5 max-w-2xl text-xs leading-relaxed text-faint">
Las aplicaciones provienen de la información pública de FSPM. La selección final depende
del escenario real: material, superficie, personal y nivel de riesgo del centro de
trabajo.
</p>
</div>
</section>
{/* ---------- productos de la categoría ---------- */}
<section className="grid-bg-lite bg-paper py-14 sm:py-20" aria-labelledby="productos-titulo">
<div className="shell">
<Revelar>
<p className="eyebrow mb-4">Equipos</p>
<h2 id="productos-titulo" className="font-display text-2xl font-extrabold text-deep sm:text-3xl">
{productos.length === 1
? "El equipo de esta categoría"
: `Los ${productos.length} equipos de esta categoría`}
</h2>
</Revelar>
<div className="mt-9 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{productos.map((producto) => (
<TarjetaProducto key={producto.slug} producto={producto} claro sinCategoria />
))}
</div>
</div>
</section>
{/* ---------- cruce con industrias ---------- */}
{industrias.length > 0 ? (
<section className="border-t border-rule bg-ink py-14 sm:py-20" aria-labelledby="industrias-titulo">
<div className="shell">
<Revelar>
<p className="eyebrow mb-4">Dónde aplica</p>
<h2 id="industrias-titulo" className="font-display text-2xl font-extrabold sm:text-3xl">
Segmentos donde esta categoría forma parte del paquete
</h2>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted sm:text-base">
Un equipo aislado rara vez resuelve una instalación. Estas son las operaciones donde
{" "}
{categoria.nombre.toLowerCase()} entra dentro de una configuración más amplia.
</p>
</Revelar>
<ul className="mt-9 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{industrias.map((industria) => (
<li key={industria.slug}>
<Link
href={`/soluciones/${industria.slug}`}
className="group flex h-full flex-col rounded-[6px] border border-rule bg-ink-2 p-5 transition-colors duration-200 hover:border-rule-2 [-webkit-tap-highlight-color:transparent]"
>
<h3 className="font-display text-base font-extrabold leading-tight text-bright">
{industria.nombre}
</h3>
<p className="mt-2.5 text-sm leading-relaxed text-muted">{industria.dolor}</p>
<span className="mt-auto pt-5 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-signal transition-colors group-hover:text-signal-hi">
Ver la solución &rarr;
</span>
</Link>
</li>
))}
</ul>
</div>
</section>
) : null}
{/* ---------- formulario ---------- */}
<section id="cotizar" className="border-t border-rule bg-ink-2 py-14 sm:py-20">
<div className="shell">
<div className="grid gap-10 lg:grid-cols-[1fr_1.05fr] lg:items-start lg:gap-16">
<div>
<Revelar>
<p className="eyebrow mb-4">Cotizar esta categoría</p>
<h2 className="font-display text-2xl font-extrabold sm:text-3xl">
Cuéntanos la instalación y te decimos cuál de estos equipos aplica
</h2>
<p className="mt-4 text-sm leading-relaxed text-muted sm:text-base">
Y también cuál no. Descartar un equipo que no corresponde ahorra más dinero que un
descuento.
</p>
</Revelar>
<hr className="hair my-8" />
<h3 className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-faint">
Otras categorías
</h3>
<ul className="mt-4 flex flex-wrap gap-2">
{otras.map((c) => (
<li key={c.slug}>
<Link
href={`/catalogo/${c.slug}`}
className="badge hover:border-signal hover:text-bright [-webkit-tap-highlight-color:transparent]"
>
{c.nombre}
</Link>
</li>
))}
</ul>
</div>
<FormularioContacto
canal="catalogo"
titulo={`Cotizar ${categoria.nombre.toLowerCase()}`}
descripcion="Respondemos con la configuración que corresponde al escenario y la lista de documentos que debe acompañarla."
/>
</div>
</div>
</section>
</>
);
}
+240
View File
@@ -0,0 +1,240 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { FormularioContacto } from "@/components/formulario/FormularioContacto";
import { Revelar } from "@/components/site/Revelar";
import { ExploradorCatalogo } from "@/components/site/catalogo/ExploradorCatalogo";
import { CATEGORIAS, PRODUCTOS, productosDeCategoria } from "@/data/catalogo";
import { CLASES_ORDENADAS } from "@/data/clases-fuego";
import { SITIO_URL } from "@/data/empresa";
/**
* /catalogo — la corrección directa al hallazgo 5.1 del estudio: ocho
* categorías sin rutas por riesgo ni aplicación.
*
* No es una galería de producto. Es un comparador: se entra por clase de fuego
* y por aplicación, y se sale con dos o tres equipos que descartar o cotizar.
*/
const TITULO = "Catálogo de equipos y agentes contra incendio";
const DESCRIPCION =
"Compara los 22 equipos y agentes de FSPM por clase de fuego, capacidad, agente y alcance declarado. Filtra por el riesgo que tienes, no por el nombre del producto.";
export const metadata: Metadata = {
title: TITULO,
description: DESCRIPCION,
alternates: { canonical: `${SITIO_URL}/catalogo` },
openGraph: {
title: TITULO,
description: DESCRIPCION,
url: `${SITIO_URL}/catalogo`,
},
};
export default function PaginaCatalogo() {
const resumen = [
{ clave: "Categorías", valor: String(CATEGORIAS.length) },
{ clave: "Equipos y agentes", valor: String(PRODUCTOS.length) },
{ clave: "Clases de fuego", valor: `${CLASES_ORDENADAS.length} · A B C D K` },
];
return (
<>
{/* ---------- encabezado: la lógica de lectura del catálogo ---------- */}
<section className="grid-bg relative overflow-hidden bg-ink pt-14 pb-16 sm:pt-20 sm:pb-20">
<div className="shell">
<nav aria-label="Ruta de navegación" className="mb-8">
<ol className="flex flex-wrap items-center gap-2 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
<li>
<Link href="/" className="hover:text-bright">
Inicio
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-bright">Catálogo</li>
</ol>
</nav>
<div className="grid gap-12 lg:grid-cols-[1.15fr_1fr] lg:items-start">
<div className="anim-rise">
<p className="eyebrow mb-5">Catálogo técnico</p>
<h1 className="font-display text-[2.125rem] font-black uppercase leading-[1.02] tracking-tight sm:text-5xl lg:text-[3.5rem]">
Un catálogo se lee por clase de fuego, no por nombre de producto
</h1>
<p className="mt-6 max-w-xl text-base leading-relaxed text-muted sm:text-lg">
Nadie compra &laquo;un extintor&raquo;. Se compra la respuesta a un escenario: qué
se está quemando, dónde, quién va a operar el equipo y qué documentación tiene que
quedar en el expediente. Este catálogo está ordenado en ese sentido.
</p>
<dl className="spec mt-9 sm:grid-cols-3">
{resumen.map((r) => (
<div key={r.clave} className="spec-cell">
<dt className="spec-key">{r.clave}</dt>
<dd className="spec-val">{r.valor}</dd>
</div>
))}
</dl>
<div className="mt-9 flex flex-wrap gap-3">
<Link href="#cotizar" className="btn btn-signal">
Solicitar diagnóstico
</Link>
<Link href="/evidencia" className="btn btn-ghost">
Ver qué documentación aplica
</Link>
</div>
</div>
{/* cómo elegir: el criterio, no el producto */}
<aside className="panel p-6 sm:p-7" aria-labelledby="criterio-titulo">
<p className="eyebrow eyebrow-muted mb-4">Cómo elegir</p>
<h2 id="criterio-titulo" className="font-display text-lg font-extrabold sm:text-xl">
Tres preguntas antes de mirar un solo equipo
</h2>
<ol className="mt-6 space-y-5">
{[
{
n: "01",
t: "¿Qué se quema?",
d: "Define la clase de fuego. El agente equivocado no solo falla: puede empeorar el incidente.",
},
{
n: "02",
t: "¿Quién responde y en cuántos segundos?",
d: "Determina si el equipo debe ser portátil, móvil, fijo o automático.",
},
{
n: "03",
t: "¿Qué te van a pedir por escrito?",
d: "Aseguradora, auditoría y Protección Civil piden documentos distintos. Eso define el expediente.",
},
].map((paso) => (
<li key={paso.n} className="flex gap-4">
<span className="font-mono text-[0.625rem] font-semibold tracking-[0.08em] text-signal">
{paso.n}
</span>
<div>
<h3 className="font-display text-sm font-bold uppercase tracking-wide text-bright">
{paso.t}
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-muted">{paso.d}</p>
</div>
</li>
))}
</ol>
<hr className="hair my-6" />
<p className="text-xs leading-relaxed text-faint">
Si la tercera pregunta no tiene respuesta todavía, empieza por{" "}
<Link href="/cumplimiento-nom-002" className="link">
cumplimiento NOM-002
</Link>
.
</p>
</aside>
</div>
</div>
</section>
{/* ---------- filtros + tabla comparativa ---------- */}
<ExploradorCatalogo />
{/* ---------- las 8 categorías con su tagline ---------- */}
<section className="border-t border-rule bg-ink py-14 sm:py-20" aria-labelledby="categorias-titulo">
<div className="shell">
<Revelar>
<p className="eyebrow mb-4">Ocho categorías</p>
<h2 id="categorias-titulo" className="font-display text-2xl font-extrabold sm:text-3xl">
Cada categoría responde a una forma distinta de llegar al fuego
</h2>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted sm:text-base">
Portátil, móvil, fijo, automático o el agente por separado. La diferencia no es de
gama: es de quién opera, en cuánto tiempo y con qué instalación.
</p>
</Revelar>
<ul className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{CATEGORIAS.map((categoria) => {
const cuantos = productosDeCategoria(categoria.slug).length;
return (
<li key={categoria.slug}>
<Link
href={`/catalogo/${categoria.slug}`}
className="group flex h-full flex-col overflow-hidden rounded-[6px] border border-rule bg-ink-2 transition-colors duration-200 hover:border-rule-2 [-webkit-tap-highlight-color:transparent]"
>
<div className="relative aspect-[16/9] w-full overflow-hidden border-b border-rule bg-ink-3">
<Image
src={categoria.imagen}
alt={categoria.nombre}
fill
sizes="(max-width: 640px) 92vw, (max-width: 1024px) 44vw, 22rem"
className="object-contain p-6 transition-transform duration-300 group-hover:scale-[1.04]"
/>
</div>
<div className="flex flex-1 flex-col p-5">
<div className="flex items-start justify-between gap-3">
<h3 className="font-display text-lg font-extrabold leading-tight text-bright">
{categoria.nombre}
</h3>
<span className="badge shrink-0">
{cuantos} {cuantos === 1 ? "equipo" : "equipos"}
</span>
</div>
<p className="mt-3 text-sm leading-relaxed text-muted">{categoria.tagline}</p>
<span className="mt-auto pt-5 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-signal transition-colors group-hover:text-signal-hi">
Ver la categoría &rarr;
</span>
</div>
</Link>
</li>
);
})}
</ul>
</div>
</section>
{/* ---------- formulario embebido: requisito del cliente ---------- */}
<section id="cotizar" className="grid-bg-lite border-t border-rule bg-paper py-14 sm:py-20">
<div className="shell">
<div className="grid gap-10 lg:grid-cols-[1fr_1.05fr] lg:items-start lg:gap-16">
<div>
<Revelar>
<p className="eyebrow mb-4">Siguiente paso</p>
<h2 className="font-display text-2xl font-extrabold text-deep sm:text-3xl">
Dinos qué hay que proteger y te devolvemos la selección, no un folleto
</h2>
<p className="mt-4 text-sm leading-relaxed text-deep-muted sm:text-base">
Con la clase de fuego, la superficie y quién opera el equipo alcanza para proponer
una configuración y decirte qué documentación te corresponde exigir. Sin costo y sin
compromiso de compra.
</p>
</Revelar>
<ul className="mt-8 space-y-3.5">
{[
"Selección de equipo y agente por escenario, no por catálogo completo.",
"Índice de la documentación que debe acompañar la compra.",
"Referencia de lo que la NOM-002 exige según tu nivel de riesgo.",
].map((punto) => (
<li key={punto} className="flex gap-3 text-sm leading-relaxed text-deep-muted">
<span aria-hidden="true" className="mt-2 h-1 w-1 shrink-0 rounded-full bg-signal" />
{punto}
</li>
))}
</ul>
</div>
<FormularioContacto
canal="catalogo"
variante="claro"
titulo="Solicitar diagnóstico"
descripcion="Respondemos con el escenario interpretado, el equipo que aplica y la lista de documentos que deberíamos entregarte."
/>
</div>
</div>
</section>
</>
);
}
+248
View File
@@ -0,0 +1,248 @@
import type { Metadata } from "next";
import Link from "next/link";
import { FormularioContacto } from "@/components/formulario/FormularioContacto";
import { Revelar } from "@/components/site/Revelar";
import { EMPRESA, SITIO_URL, WHATSAPP } from "@/data/empresa";
/**
* /contacto
*
* Solo datos verificados: teléfono, correo, ciudad y colonia. No hay horario de
* atención, ni domicilio con número, ni mapa: nada de eso está confirmado, y
* una dirección equivocada en un sitio de protección contra incendios es un
* problema real, no un detalle.
*/
const TITULO = "Contacto";
const DESCRIPCION = `Habla con ${EMPRESA.nombre}. Teléfono, correo y solicitud de diagnóstico para operaciones que necesitan protección contra incendios documentada.`;
export const metadata: Metadata = {
title: TITULO,
description: DESCRIPCION,
alternates: { canonical: `${SITIO_URL}/contacto` },
openGraph: {
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
description: DESCRIPCION,
url: `${SITIO_URL}/contacto`,
},
};
const MENSAJE_WHATSAPP = encodeURIComponent(
"Hola, me gustaría información sobre protección contra incendios.",
);
export default function PaginaContacto() {
return (
<>
{/* ---------- encabezado ---------- */}
<section className="grid-bg bg-ink pt-12 pb-14 sm:pt-16 sm:pb-16">
<div className="shell">
<nav aria-label="Ruta de navegación" className="mb-8">
<ol className="flex flex-wrap items-center gap-2 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
<li>
<Link href="/" className="hover:text-bright">
Inicio
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-bright">Contacto</li>
</ol>
</nav>
<div className="max-w-3xl anim-rise">
<p className="eyebrow mb-5">Contacto</p>
<h1 className="font-display text-[2rem] font-black uppercase leading-[1.03] tracking-tight sm:text-[2.75rem] lg:text-5xl">
Si tu situación no puede esperar, marca
</h1>
<p className="mt-6 text-base leading-relaxed text-muted sm:text-lg">
Para todo lo demás, el formulario sirve mejor: llega con la clase de fuego, la
superficie y el tipo de operación, así que la primera respuesta puede ser técnica en
lugar de una pregunta de vuelta.
</p>
</div>
</div>
</section>
{/* ---------- datos verificados ---------- */}
<section className="border-t border-rule bg-ink-2 py-12 sm:py-14" aria-labelledby="datos-titulo">
<div className="shell">
<h2 id="datos-titulo" className="sr-only">
Datos de contacto
</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<a
href={`tel:${EMPRESA.telefonoTel}`}
className="group flex flex-col rounded-[6px] border border-rule bg-ink p-5 transition-colors duration-200 hover:border-signal [-webkit-tap-highlight-color:transparent]"
>
<span className="font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
Teléfono
</span>
<span className="mt-2.5 font-mono text-lg font-semibold text-bright transition-colors group-hover:text-signal-hi">
{EMPRESA.telefono}
</span>
<span className="mt-2 text-xs leading-relaxed text-muted">
Marca directo desde el móvil.
</span>
</a>
<a
href={`mailto:${EMPRESA.correo}`}
className="group flex flex-col rounded-[6px] border border-rule bg-ink p-5 transition-colors duration-200 hover:border-signal [-webkit-tap-highlight-color:transparent]"
>
<span className="font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
Correo
</span>
<span className="mt-2.5 break-all font-mono text-sm font-semibold text-bright transition-colors group-hover:text-signal-hi">
{EMPRESA.correo}
</span>
<span className="mt-2 text-xs leading-relaxed text-muted">
Útil si necesitas adjuntar planos o el requerimiento de tu auditoría.
</span>
</a>
<a
href={`https://api.whatsapp.com/send?phone=${WHATSAPP}&text=${MENSAJE_WHATSAPP}`}
target="_blank"
rel="noopener noreferrer"
className="group flex flex-col rounded-[6px] border border-rule bg-ink p-5 transition-colors duration-200 hover:border-signal [-webkit-tap-highlight-color:transparent]"
>
<span className="font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
WhatsApp
</span>
<span className="mt-2.5 font-mono text-sm font-semibold text-bright transition-colors group-hover:text-signal-hi">
Abrir conversación
</span>
<span className="mt-2 text-xs leading-relaxed text-muted">
El mismo número, si prefieres escribir antes de llamar.
</span>
</a>
<div className="flex flex-col rounded-[6px] border border-rule bg-ink p-5">
<span className="font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
Ubicación
</span>
<span className="mt-2.5 font-mono text-sm font-semibold text-bright">
{EMPRESA.colonia}
</span>
<span className="mt-1 font-mono text-sm text-muted">
{EMPRESA.ciudad}, {EMPRESA.pais}
</span>
<span className="mt-2 text-xs leading-relaxed text-muted">
Dinos en el formulario dónde está la instalación y te confirmamos cómo podemos
atenderla.
</span>
</div>
</div>
<p className="mt-6 max-w-3xl text-xs leading-relaxed text-faint">
Publicamos únicamente los datos que pudimos verificar en la información pública de la
empresa. Si necesitas domicilio fiscal, horario de atención o una visita presencial,
pídelos por cualquiera de estos medios y te los confirmamos por escrito.
</p>
</div>
</section>
{/* ---------- formulario completo ---------- */}
<section id="formulario" className="grid-bg-lite border-t border-rule bg-paper py-14 sm:py-20">
<div className="shell">
<div className="grid gap-10 lg:grid-cols-[1fr_1.1fr] lg:items-start lg:gap-16">
<div>
<Revelar>
<p className="eyebrow mb-4">Solicitud</p>
<h2 className="font-display text-2xl font-extrabold text-deep sm:text-3xl">
Qué pasa cuando envías el formulario
</h2>
</Revelar>
<ol className="mt-8 space-y-6">
{[
{
n: "01",
t: "Se registra con folio",
d: "Recibes un folio en pantalla. Tu solicitud queda asignada a una persona, no a un buzón compartido.",
},
{
n: "02",
t: "Se interpreta el escenario",
d: "Con la clase de fuego, la superficie y el tipo de operación que declaraste, alguien revisa qué configuración aplica y qué habría que descartar.",
},
{
n: "03",
t: "Respondemos con documentación, no con folleto",
d: "La respuesta incluye la selección propuesta y el índice de documentos que debería acompañarla. Si algo no podemos sostener por escrito, va dicho ahí.",
},
].map((paso) => (
<li key={paso.n} className="flex gap-4">
<span className="font-mono text-[0.625rem] font-semibold tracking-[0.08em] text-signal">
{paso.n}
</span>
<div>
<h3 className="font-display text-sm font-bold uppercase tracking-wide text-deep">
{paso.t}
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-deep-muted">{paso.d}</p>
</div>
</li>
))}
</ol>
<hr className="hair-lite my-8" />
<h3 className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-deep-muted">
Antes de escribir, quizá ya está respondido
</h3>
<ul className="mt-4 flex flex-wrap gap-2">
{[
{ href: "/preguntas-frecuentes", label: "Preguntas frecuentes" },
{ href: "/cumplimiento-nom-002", label: "Cumplimiento NOM-002" },
{ href: "/evidencia", label: "Centro de evidencia" },
{ href: "/catalogo", label: "Catálogo" },
].map((enlace) => (
<li key={enlace.href}>
<Link
href={enlace.href}
className="badge !border-rule-lite !text-deep-muted hover:!border-signal hover:!text-deep [-webkit-tap-highlight-color:transparent]"
>
{enlace.label}
</Link>
</li>
))}
</ul>
<h3 className="mt-8 font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-deep-muted">
Redes
</h3>
<ul className="mt-4 flex flex-wrap gap-4">
{[
{ url: EMPRESA.redes.linkedin, label: "LinkedIn" },
{ url: EMPRESA.redes.facebook, label: "Facebook" },
{ url: EMPRESA.redes.youtube, label: "YouTube" },
].map((red) => (
<li key={red.label}>
<a
href={red.url}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-[0.6875rem] uppercase tracking-[0.12em] text-deep underline decoration-rule-lite underline-offset-4 hover:decoration-signal"
>
{red.label}
</a>
</li>
))}
</ul>
</div>
<FormularioContacto
canal="contacto"
variante="claro"
compacto={false}
titulo="Solicitar diagnóstico"
descripcion="Cuéntanos qué operación hay que proteger. Te respondemos con el escenario, el equipo que aplica y qué documentación te corresponde."
/>
</div>
</div>
</section>
</>
);
}
@@ -0,0 +1,453 @@
import type { Metadata } from "next";
import Link from "next/link";
import { FormularioContacto } from "@/components/formulario/FormularioContacto";
import { Revelar } from "@/components/site/Revelar";
import { EMPRESA, NORMAS, SITIO_URL } from "@/data/empresa";
import { INDUSTRIAS } from "@/data/industrias";
/**
* /cumplimiento-nom-002 — página de intención de búsqueda alta.
*
* Regla del contenido: no se citan artículos, umbrales ni plazos que no estén
* en src/data/empresa.ts o en la fuente pública. Lo que no está verificado se
* declara como no reproducido, no se estima.
*/
const TITULO = "Cumplimiento NOM-002-STPS-2010";
const DESCRIPCION =
"Qué exige la NOM-002-STPS-2010 a un centro de trabajo, qué documentación se acumula y cómo se ve un expediente completo. Referencia orientativa, no dictamen técnico ni jurídico.";
export const metadata: Metadata = {
title: TITULO,
description: DESCRIPCION,
alternates: { canonical: `${SITIO_URL}/cumplimiento-nom-002` },
openGraph: {
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
description: DESCRIPCION,
url: `${SITIO_URL}/cumplimiento-nom-002`,
},
};
const AVISO_ALCANCE =
"Esta página es una referencia orientativa construida con información pública. La normativa puede actualizarse y complementarse por estado, municipio, tipo de instalación y autoridad competente. No sustituye un dictamen técnico ni una opinión jurídica.";
/** Elementos que la norma contempla, según la referencia de NORMAS. */
const ELEMENTOS = [
{
n: "01",
titulo: "Clasificación del riesgo de incendio",
detalle:
"Es el primer paso y condiciona todo lo demás. De la clasificación depende qué equipo, qué sistemas y qué plan te corresponden. Se determina con el procedimiento de la propia norma; los umbrales no se reproducen aquí porque dependen del material y de la superficie de cada centro de trabajo.",
},
{
n: "02",
titulo: "Equipo contra incendio",
detalle:
"Extintores disponibles, accesibles y señalizados, seleccionados según la clase de fuego presente en cada área. El equipo correcto en el lugar equivocado no cumple.",
},
{
n: "03",
titulo: "Sistemas fijos y de alarma",
detalle:
"Según el nivel de riesgo, el centro de trabajo puede requerir sistemas fijos de supresión y medios de detección y alarma, no solo equipo portátil.",
},
{
n: "04",
titulo: "Plan de atención a emergencias",
detalle:
"Documento por escrito. Es lo primero que se pide en una verificación y lo que más frecuentemente no existe o está desactualizado.",
},
{
n: "05",
titulo: "Brigadas y capacitación",
detalle:
"Personal designado, entrenado y con constancia. La capacitación sin registro documental no es demostrable ante una autoridad.",
},
{
n: "06",
titulo: "Simulacros",
detalle:
"Ejecutados y documentados. El registro del simulacro es evidencia tanto del plan como de la capacitación.",
},
];
const EXPEDIENTE = [
{
documento: "Clasificación de riesgo de incendio",
quePrueba: "Que el nivel de exigencia aplicado al centro de trabajo es el correcto.",
quienLoPide: "Verificación STPS, Protección Civil, aseguradora",
},
{
documento: "Plan de atención a emergencias",
quePrueba: "Que existe un procedimiento escrito y vigente para el escenario.",
quienLoPide: "Verificación STPS, Protección Civil",
},
{
documento: "Inventario y ubicación del equipo",
quePrueba: "Qué hay instalado, dónde, de qué clase y con qué capacidad.",
quienLoPide: "Verificación STPS, auditoría interna",
},
{
documento: "Ficha técnica y certificado por modelo",
quePrueba: "Que el equipo instalado es el que se declara y cubre las clases que se afirman.",
quienLoPide: "Auditoría, aseguradora, cliente corporativo",
},
{
documento: "Hoja de datos de seguridad del agente",
quePrueba: "Manejo, almacenamiento y respuesta ante exposición del agente extintor.",
quienLoPide: "Seguridad industrial, ambiental, auditoría",
},
{
documento: "Bitácora de revisión mensual",
quePrueba: "Que la revisión de extintores se realiza con la periodicidad debida.",
quienLoPide: "Verificación STPS, Protección Civil",
},
{
documento: "Constancias de capacitación y brigadas",
quePrueba: "Que hay personal designado y entrenado, con nombre y fecha.",
quienLoPide: "Verificación STPS",
},
{
documento: "Registro de simulacros",
quePrueba: "Que el plan se ejecuta y se corrige con lo aprendido.",
quienLoPide: "Verificación STPS, Protección Civil",
},
];
export default function PaginaCumplimiento() {
return (
<>
{/* ---------- encabezado con la advertencia de alcance ---------- */}
<section className="grid-bg bg-ink pt-12 pb-14 sm:pt-16 sm:pb-20">
<div className="shell">
<nav aria-label="Ruta de navegación" className="mb-8">
<ol className="flex flex-wrap items-center gap-2 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
<li>
<Link href="/" className="hover:text-bright">
Inicio
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-bright">Cumplimiento</li>
</ol>
</nav>
<div className="max-w-3xl anim-rise">
<p className="eyebrow mb-5">Referencia normativa</p>
<h1 className="font-display text-[2rem] font-black uppercase leading-[1.03] tracking-tight sm:text-[2.75rem] lg:text-5xl">
Cumplimiento NOM-002: qué te van a pedir y cómo se ve un expediente completo
</h1>
<p className="mt-6 text-base leading-relaxed text-muted sm:text-lg">
La norma no se cumple comprando extintores. Se cumple demostrando, por escrito, que el
riesgo está clasificado, el equipo corresponde a ese riesgo y el personal sabe qué
hacer. Esta página explica esa lógica y qué documentos la sostienen.
</p>
</div>
<div
role="note"
aria-label="Alcance de esta página"
className="mt-10 max-w-3xl rounded-[6px] border border-rule border-l-2 border-l-ember bg-ink-2 p-5 sm:p-6"
>
<p className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-ember">
Alcance de esta página
</p>
<p className="mt-3 text-sm leading-relaxed text-muted">{AVISO_ALCANCE}</p>
</div>
</div>
</section>
{/* ---------- a quién aplica ---------- */}
<section className="grid-bg-lite bg-paper py-14 sm:py-20" aria-labelledby="aplica-titulo">
<div className="shell">
<div className="grid gap-10 lg:grid-cols-[1fr_1.1fr] lg:gap-16">
<div>
<Revelar>
<p className="eyebrow mb-4">A quién aplica</p>
<h2 id="aplica-titulo" className="font-display text-2xl font-extrabold text-deep sm:text-3xl">
A todo centro de trabajo, sin mínimo de tamaño
</h2>
<p className="mt-4 text-sm leading-relaxed text-deep-muted sm:text-base">
{NORMAS[0].aplica}
</p>
<p className="mt-4 text-sm leading-relaxed text-deep-muted">
Es la razón por la que una oficina de veinte personas y una planta petroquímica
están bajo la misma norma con obligaciones muy distintas: lo que cambia no es la
norma, es la clasificación del riesgo.
</p>
</Revelar>
</div>
<div className="panel-lite p-6 sm:p-8">
<h3 className="font-display text-lg font-extrabold text-deep sm:text-xl">
Dos niveles, dos conversaciones distintas
</h3>
<div className="mt-6 space-y-6">
<div className="border-l-2 border-l-rule-lite pl-4">
<p className="font-mono text-[0.625rem] uppercase tracking-[0.14em] text-deep-muted">
Riesgo ordinario
</p>
<p className="mt-2 text-sm leading-relaxed text-deep-muted">
El foco está en tener el equipo correcto, señalizado y revisado, con un plan por
escrito y personal capacitado. La mayoría de las PyMEs y oficinas queda aquí y
el error caro es comprar de más por miedo.
</p>
</div>
<div className="border-l-2 border-l-signal pl-4">
<p className="font-mono text-[0.625rem] uppercase tracking-[0.14em] text-signal">
Riesgo alto
</p>
<p className="mt-2 text-sm leading-relaxed text-deep-muted">
La norma establece requerimientos mayores para los centros clasificados como de
riesgo alto de incendio: la conversación pasa de equipo portátil a sistemas
fijos, detección, brigadas formales y evidencia sostenida en el tiempo.
</p>
</div>
</div>
<hr className="hair-lite my-6" />
<p className="text-xs leading-relaxed text-deep-muted">
No reproducimos aquí los umbrales de clasificación: dependen del material y de la
superficie de cada centro de trabajo, y determinarlos mal invalida todo lo que se
construya encima. Ese es el primer punto de un diagnóstico.
</p>
</div>
</div>
</div>
</section>
{/* ---------- qué contempla la norma ---------- */}
<section className="border-t border-rule bg-ink py-14 sm:py-20" aria-labelledby="elementos-titulo">
<div className="shell">
<Revelar>
<p className="eyebrow mb-4">Qué contempla</p>
<h2 id="elementos-titulo" className="font-display text-2xl font-extrabold sm:text-3xl">
Seis frentes, y ninguno se cierra con una compra
</h2>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted sm:text-base">
Cada uno genera un documento distinto. Un expediente incompleto suele fallar en los
frentes que no se compran: el plan, la capacitación y el registro.
</p>
</Revelar>
<ol className="mt-10 grid gap-px bg-rule sm:grid-cols-2 lg:grid-cols-3">
{ELEMENTOS.map((elemento) => (
<li key={elemento.n} className="bg-ink-2 p-6">
<p className="font-mono text-[0.625rem] font-semibold tracking-[0.1em] text-signal">
{elemento.n}
</p>
<h3 className="mt-3 font-display text-base font-extrabold leading-tight text-bright">
{elemento.titulo}
</h3>
<p className="mt-3 text-sm leading-relaxed text-muted">{elemento.detalle}</p>
</li>
))}
</ol>
<div className="mt-10 rounded-[6px] border border-rule bg-ink-2 p-5 sm:p-6">
<p className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-faint">
Mantenimiento de extintores
</p>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-muted">
{NORMAS[1].aplica} Es la obligación recurrente que más seguido se pierde: no por
desconocerla, sino porque nadie quedó a cargo de la bitácora.
</p>
</div>
</div>
</section>
{/* ---------- el expediente ---------- */}
<section className="grid-bg-lite bg-paper py-14 sm:py-20" aria-labelledby="expediente-titulo">
<div className="shell-wide">
<Revelar>
<p className="eyebrow mb-4">Expediente</p>
<h2 id="expediente-titulo" className="font-display text-2xl font-extrabold text-deep sm:text-3xl">
Cómo se ve un expediente que aguanta una verificación
</h2>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-deep-muted sm:text-base">
Ocho documentos. Ninguno es opcional cuando alguien pregunta, y cada uno prueba algo
distinto.
</p>
</Revelar>
<p className="mt-6 font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-deep-muted sm:hidden">
Desliza la tabla para ver todas las columnas
</p>
<div
className="mt-4 overflow-x-auto rounded-[6px] border border-rule-lite bg-white [-webkit-overflow-scrolling:touch]"
tabIndex={0}
role="region"
aria-label="Documentos del expediente, tabla desplazable en horizontal"
>
<table className="w-full min-w-[46rem] border-collapse text-left">
<caption className="sr-only">
Documentos que integran un expediente de cumplimiento, qué prueba cada uno y quién
lo solicita.
</caption>
<thead>
<tr className="border-b border-rule-lite bg-paper-2">
{["Documento", "Qué prueba", "Quién lo pide"].map((c) => (
<th
key={c}
scope="col"
className="whitespace-nowrap px-4 py-3 font-mono text-[0.625rem] font-medium uppercase tracking-[0.14em] text-deep-muted"
>
{c}
</th>
))}
</tr>
</thead>
<tbody>
{EXPEDIENTE.map((fila) => (
<tr key={fila.documento} className="border-b border-rule-lite last:border-b-0">
<th scope="row" className="px-4 py-4 text-left align-top">
<span className="font-sans text-sm font-semibold text-deep">
{fila.documento}
</span>
</th>
<td className="max-w-[24rem] px-4 py-4 align-top text-sm leading-relaxed text-deep-muted">
{fila.quePrueba}
</td>
<td className="px-4 py-4 align-top font-mono text-xs leading-relaxed text-deep-muted">
{fila.quienLoPide}
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-6 max-w-3xl text-xs leading-relaxed text-deep-muted">
En una compra con nosotros, los documentos que dependen del equipo se entregan por
número de modelo.{" "}
<Link href="/evidencia" className="link">
Ver el índice de documentación por categoría
</Link>
.
</p>
</div>
</section>
{/* ---------- referencias ---------- */}
<section className="border-t border-rule bg-ink py-14 sm:py-16" aria-labelledby="referencias-titulo">
<div className="shell">
<Revelar>
<p className="eyebrow mb-4">Referencias</p>
<h2 id="referencias-titulo" className="font-display text-xl font-extrabold sm:text-2xl">
Normas citadas en esta página
</h2>
</Revelar>
<ul className="mt-8 grid gap-4 lg:grid-cols-2">
{NORMAS.map((norma) => (
<li key={norma.clave} className="panel p-5 sm:p-6">
<p className="font-mono text-xs font-semibold uppercase tracking-[0.12em] text-signal">
{norma.clave}
</p>
<h3 className="mt-3 font-display text-base font-extrabold leading-tight text-bright">
{norma.titulo}
</h3>
<p className="mt-3 text-sm leading-relaxed text-muted">{norma.aplica}</p>
<a
href={norma.fuente}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-block font-mono text-[0.625rem] uppercase tracking-[0.14em] text-bright underline decoration-rule-2 underline-offset-4 hover:decoration-signal"
>
Ver la fuente oficial
</a>
</li>
))}
</ul>
<p className="mt-8 max-w-3xl text-xs leading-relaxed text-faint">
Los enlaces apuntan al sitio del Diario Oficial de la Federación. Verifica siempre la
versión vigente: la publicación oficial manda sobre cualquier resumen, incluido este.
</p>
</div>
</section>
{/* ---------- por industria ---------- */}
<section className="border-t border-rule bg-ink-2 py-14 sm:py-16" aria-labelledby="segmentos-titulo">
<div className="shell">
<Revelar>
<p className="eyebrow eyebrow-muted mb-4">Por operación</p>
<h2 id="segmentos-titulo" className="font-display text-xl font-extrabold sm:text-2xl">
Lo que cambia según dónde trabajas
</h2>
</Revelar>
<ul className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{INDUSTRIAS.map((industria) => (
<li key={industria.slug}>
<Link
href={`/soluciones/${industria.slug}`}
className="group flex h-full flex-col rounded-[6px] border border-rule bg-ink p-5 transition-colors duration-200 hover:border-rule-2 [-webkit-tap-highlight-color:transparent]"
>
<h3 className="font-display text-base font-extrabold leading-tight text-bright">
{industria.nombre}
</h3>
<ul className="mt-3 space-y-2">
{industria.cumplimiento.map((obligacion) => (
<li key={obligacion} className="flex gap-2.5 text-xs leading-relaxed text-muted">
<span
aria-hidden="true"
className="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-signal"
/>
{obligacion}
</li>
))}
</ul>
<span className="mt-auto pt-5 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-signal transition-colors group-hover:text-signal-hi">
Ver el segmento &rarr;
</span>
</Link>
</li>
))}
</ul>
</div>
</section>
{/* ---------- formulario y cierre con la advertencia ---------- */}
<section id="validar" className="grid-bg-lite border-t border-rule bg-paper py-14 sm:py-20">
<div className="shell">
<div className="grid gap-10 lg:grid-cols-[1fr_1.05fr] lg:items-start lg:gap-16">
<div>
<Revelar>
<p className="eyebrow mb-4">Siguiente paso</p>
<h2 className="font-display text-2xl font-extrabold text-deep sm:text-3xl">
Validar cumplimiento NOM-002
</h2>
<p className="mt-4 text-sm leading-relaxed text-deep-muted sm:text-base">
Revisamos qué tienes, qué te falta y en qué orden conviene resolverlo. Si algo de tu
situación requiere un dictamen formal, te lo decimos en lugar de venderte equipo.
</p>
</Revelar>
<div
role="note"
aria-label="Alcance de esta página"
className="mt-8 rounded-[6px] border border-rule-lite border-l-2 border-l-ember bg-white p-5"
>
<p className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-deep-muted">
Recordatorio de alcance
</p>
<p className="mt-3 text-sm leading-relaxed text-deep-muted">{AVISO_ALCANCE}</p>
</div>
</div>
<FormularioContacto
canal="cumplimiento"
variante="claro"
titulo="Validar mi cumplimiento"
descripcion="Dinos qué tipo de instalación es y qué te pidieron. Respondemos con el hueco documental que vemos y por dónde empezar."
/>
</div>
</div>
</section>
</>
);
}
+561
View File
@@ -0,0 +1,561 @@
import type { Metadata } from "next";
import Link from "next/link";
import { FormularioContacto } from "@/components/formulario/FormularioContacto";
import { Revelar } from "@/components/site/Revelar";
import { CATEGORIAS, PRODUCTOS, productosDeCategoria } from "@/data/catalogo";
import { EMPRESA, NORMAS, SITIO_URL } from "@/data/empresa";
/**
* /evidencia — el centro de evidencia de la recomendación 10.3 del estudio.
*
* Regla que gobierna esta página: en la extracción pública del sitio de FSPM no
* apareció un índice verificable de documentos por modelo. Así que aquí no hay
* un solo botón de descarga. Un botón falso en una demo destruye la
* credibilidad de todo lo demás; decir la verdad sobre el estado del acervo es
* precisamente el argumento comercial.
*/
const TITULO = "Centro de evidencia técnica";
const DESCRIPCION =
"Qué documentación corresponde a cada compra de equipo contra incendio, qué aplica por categoría de producto y qué afirmaciones documentamos por número de modelo antes de cotizar.";
export const metadata: Metadata = {
title: TITULO,
description: DESCRIPCION,
alternates: { canonical: `${SITIO_URL}/evidencia` },
openGraph: {
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
description: DESCRIPCION,
url: `${SITIO_URL}/evidencia`,
},
};
type DocumentoId =
| "ficha"
| "sds"
| "certificado"
| "garantia"
| "manual"
| "bitacora"
| "instalacion"
| "capacitacion";
const DOCUMENTOS: Array<{ id: DocumentoId; nombre: string; corto: string; queEs: string }> = [
{
id: "ficha",
nombre: "Ficha técnica del modelo",
corto: "Ficha técnica",
queEs:
"El documento que sostiene cada número que aparece en una página de producto: capacidad, agente, clases de fuego con sus condiciones y alcance con las condiciones de la prueba. Si un dato no está en la ficha, no debería estar en la propuesta.",
},
{
id: "sds",
nombre: "Hoja de datos de seguridad del agente",
corto: "Hoja de seguridad",
queEs:
"Manejo, almacenamiento, incompatibilidades y respuesta ante exposición. Es el documento que pide seguridad industrial y el área ambiental, y el que permite responder si el agente puede estar donde va a estar.",
},
{
id: "certificado",
nombre: "Certificado o listado por modelo",
corto: "Certificado",
queEs:
"El certificado del laboratorio u organismo, con alcance, número de expediente y fecha. Una marca de certificación sin documento asociado a un modelo no es evidencia: es un logotipo.",
},
{
id: "garantia",
nombre: "Garantía con condiciones por escrito",
corto: "Garantía",
queEs:
"Plazo, cobertura, exclusiones y qué obligaciones de mantenimiento la conservan vigente. Un plazo sin condiciones es una cifra publicitaria.",
},
{
id: "manual",
nombre: "Manual de operación y mantenimiento",
corto: "Manual",
queEs:
"Cómo se opera, cada cuánto se revisa y qué se considera una falla. Sin manual, la capacitación se vuelve tradición oral y la garantía queda expuesta.",
},
{
id: "bitacora",
nombre: "Bitácora de revisión",
corto: "Bitácora",
queEs:
"El formato donde queda constancia de cada revisión, con fecha y responsable. Es la evidencia recurrente que una verificación pide primero.",
},
{
id: "instalacion",
nombre: "Constancia de instalación y puesta en marcha",
corto: "Instalación",
queEs:
"Para sistemas que se instalan: ubicación, configuración instalada, pruebas de arranque y quién las ejecutó. Es lo que permite reclamar si el sistema no actúa.",
},
{
id: "capacitacion",
nombre: "Constancia de capacitación",
corto: "Capacitación",
queEs:
"Nombre, fecha, contenido y quién capacitó. La capacitación sin constancia no es demostrable ante una autoridad.",
},
];
/**
* Qué documentación corresponde pedir por categoría. Es una recomendación de
* qué exigir, no una afirmación de qué existe ya publicado.
*/
const APLICA_POR_CATEGORIA: Record<string, DocumentoId[]> = {
"extintores-portatiles": ["ficha", "sds", "certificado", "garantia", "manual", "bitacora"],
"extintores-fijos": [
"ficha",
"sds",
"certificado",
"garantia",
"manual",
"bitacora",
"instalacion",
],
"productos-moviles": ["ficha", "sds", "certificado", "garantia", "manual", "bitacora"],
rociadores: ["ficha", "sds", "certificado", "garantia", "manual", "bitacora", "instalacion"],
"agentes-extinguidores": ["ficha", "sds", "certificado", "garantia", "manual"],
firemiks: ["ficha", "sds", "garantia", "manual", "instalacion"],
"proteccion-civil": ["ficha", "garantia", "manual", "instalacion", "capacitacion"],
robots: ["ficha", "sds", "garantia", "manual", "instalacion", "capacitacion"],
};
/** Lo que el sitio anterior enumera bajo el encabezado de certificaciones. */
const CERTIFICACIONES_ENUMERADAS = [
"UL 162",
"UL 711",
"NFPA 11",
"NFPA 16",
"NFPA 18",
"DIN EN 1568-3",
"LASTFIRE",
"OECD 202, 203 y 301D",
"MPA Dresden",
"Instituto de Higiene del Ruhr",
"SGS Institut Fresenius",
"ISO 9001:2015",
"NOM-154-SCFI-2005",
"NMX-S-066-SCFI-2015",
];
export default function PaginaEvidencia() {
// consolidado de claimsPorVerificar por categoría, sin repetir
const porCategoria = CATEGORIAS.map((categoria) => {
const productos = productosDeCategoria(categoria.slug);
const claims = Array.from(new Set(productos.flatMap((p) => p.claimsPorVerificar)));
return { categoria, productos, claims };
});
const totalClaims = new Set(PRODUCTOS.flatMap((p) => p.claimsPorVerificar)).size;
return (
<>
{/* ---------- encabezado ---------- */}
<section className="grid-bg bg-ink pt-12 pb-14 sm:pt-16 sm:pb-20">
<div className="shell">
<nav aria-label="Ruta de navegación" className="mb-8">
<ol className="flex flex-wrap items-center gap-2 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
<li>
<Link href="/" className="hover:text-bright">
Inicio
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-bright">Evidencia técnica</li>
</ol>
</nav>
<div className="max-w-3xl anim-rise">
<p className="eyebrow mb-5">Centro de evidencia</p>
<h1 className="font-display text-[2rem] font-black uppercase leading-[1.03] tracking-tight sm:text-[2.75rem] lg:text-5xl">
La documentación es el producto que casi nadie entrega
</h1>
<p className="mt-6 text-base leading-relaxed text-muted sm:text-lg">
Un equipo contra incendio se compra dos veces: cuando se instala y cuando alguien pide
los papeles. Esta página dice exactamente qué documentos deberían acompañar una
compra, qué aplica según la categoría y qué afirmaciones documentamos por número de
modelo antes de cotizar.
</p>
<dl className="spec mt-9 sm:grid-cols-3">
<div className="spec-cell">
<dt className="spec-key">Documentos del acervo</dt>
<dd className="spec-val">{DOCUMENTOS.length}</dd>
</div>
<div className="spec-cell">
<dt className="spec-key">Categorías indexadas</dt>
<dd className="spec-val">{CATEGORIAS.length}</dd>
</div>
<div className="spec-cell">
<dt className="spec-key">Puntos por documentar</dt>
<dd className="spec-val">{totalClaims}</dd>
</div>
</dl>
</div>
</div>
</section>
{/* ---------- el estado real del acervo: sin descargas falsas ---------- */}
<section className="border-t border-rule bg-ink-2 py-12 sm:py-16" aria-labelledby="estado-titulo">
<div className="shell">
<div className="rounded-[6px] border border-rule border-l-2 border-l-ember bg-ink p-6 sm:p-8">
<p className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-ember">
Estado del acervo
</p>
<h2 id="estado-titulo" className="mt-4 font-display text-xl font-extrabold sm:text-2xl">
Aquí no hay botones de descarga, y es a propósito
</h2>
<div className="mt-4 max-w-3xl space-y-4 text-sm leading-relaxed text-muted">
<p>
En la extracción pública del sitio de FSPM no apareció un índice verificable de
documentos por número de modelo. Podríamos haber puesto una fila de botones
&laquo;Descargar ficha&raquo; que no llevan a ningún archivo. No lo hicimos: un
botón falso destruye la credibilidad de todo lo demás que dice esta página.
</p>
<p>
Lo que sí hacemos es esto:{" "}
<span className="text-bright">
los documentos se entregan por número de modelo, a solicitud, junto con la
propuesta técnica
</span>
. Cuando un documento no exista o no cubra lo que se afirma, lo decimos en la
propuesta en lugar de omitirlo.
</p>
</div>
<div className="mt-7 flex flex-wrap gap-3">
<Link href="#solicitar" className="btn btn-signal btn-sm">
Solicitar documentación
</Link>
<Link href="/catalogo" className="btn btn-ghost btn-sm">
Ver el catálogo
</Link>
</div>
</div>
</div>
</section>
{/* ---------- qué documentación corresponde a cada compra ---------- */}
<section className="grid-bg-lite bg-paper py-14 sm:py-20" aria-labelledby="documentos-titulo">
<div className="shell">
<Revelar>
<p className="eyebrow mb-4">El acervo</p>
<h2 id="documentos-titulo" className="font-display text-2xl font-extrabold text-deep sm:text-3xl">
Los {DOCUMENTOS.length} documentos que corresponden a una compra
</h2>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-deep-muted sm:text-base">
No todos aplican a todos los productos. Los dos últimos aparecen solo cuando hay
instalación o personal que operar.
</p>
</Revelar>
<ol className="mt-10 grid gap-px bg-rule-lite sm:grid-cols-2">
{DOCUMENTOS.map((documento, i) => (
<li key={documento.id} className="bg-white p-6 sm:p-7">
<div className="flex items-baseline gap-3">
<span
aria-hidden="true"
className="font-mono text-[0.625rem] font-semibold tracking-[0.1em] text-signal"
>
{String(i + 1).padStart(2, "0")}
</span>
<h3 className="font-display text-base font-extrabold leading-tight text-deep">
{documento.nombre}
</h3>
</div>
<p className="mt-3 text-sm leading-relaxed text-deep-muted">{documento.queEs}</p>
</li>
))}
</ol>
</div>
</section>
{/* ---------- índice por categoría ---------- */}
<section className="border-t border-rule bg-ink py-14 sm:py-20" aria-labelledby="indice-titulo">
<div className="shell-wide">
<Revelar>
<p className="eyebrow mb-4">Índice</p>
<h2 id="indice-titulo" className="font-display text-2xl font-extrabold sm:text-3xl">
Qué aplica según la categoría de producto
</h2>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted sm:text-base">
Es la lista de lo que conviene exigir en cada compra, no una afirmación de qué está ya
publicado.
</p>
</Revelar>
<p className="mt-6 font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-faint sm:hidden">
Desliza la tabla para ver todas las columnas
</p>
<div
className="mt-4 overflow-x-auto rounded-[6px] border border-rule bg-ink-2 [-webkit-overflow-scrolling:touch]"
tabIndex={0}
role="region"
aria-label="Índice de documentación por categoría, tabla desplazable en horizontal"
>
<table className="w-full min-w-[52rem] border-collapse text-left">
<caption className="sr-only">
Documentos que corresponden a cada categoría de producto.
</caption>
<thead>
<tr className="border-b border-rule bg-ink-3">
<th
scope="col"
className="whitespace-nowrap px-4 py-3 font-mono text-[0.625rem] font-medium uppercase tracking-[0.14em] text-faint"
>
Categoría
</th>
{DOCUMENTOS.map((d) => (
<th
key={d.id}
scope="col"
className="whitespace-nowrap px-3 py-3 font-mono text-[0.625rem] font-medium uppercase tracking-[0.12em] text-faint"
>
{d.corto}
</th>
))}
</tr>
</thead>
<tbody>
{CATEGORIAS.map((categoria) => {
const aplica = APLICA_POR_CATEGORIA[categoria.slug] ?? [];
return (
<tr key={categoria.slug} className="border-b border-rule last:border-b-0">
<th scope="row" className="px-4 py-4 text-left align-middle">
<Link
href={`/catalogo/${categoria.slug}`}
className="link font-sans text-sm font-semibold text-bright [-webkit-tap-highlight-color:transparent]"
>
{categoria.nombre}
</Link>
</th>
{DOCUMENTOS.map((d) => {
const incluido = aplica.includes(d.id);
return (
<td key={d.id} className="px-3 py-4 align-middle">
{incluido ? (
<span className="flex h-5 w-5 items-center justify-center rounded-[2px] bg-signal">
<span className="sr-only">
{d.nombre} aplica a {categoria.nombre}
</span>
<svg width="11" height="9" viewBox="0 0 11 9" aria-hidden="true">
<path
d="M1 4.4l3 3L10 1"
stroke="#fff"
strokeWidth="1.7"
fill="none"
strokeLinecap="round"
/>
</svg>
</span>
) : (
<span className="flex h-5 w-5 items-center justify-center rounded-[2px] border border-rule-2">
<span className="sr-only">
{d.nombre} no aplica a {categoria.nombre}
</span>
<span
aria-hidden="true"
className="h-[1px] w-2 bg-rule-2"
/>
</span>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</section>
{/* ---------- consolidado de claims por verificar ---------- */}
<section className="grid-bg-lite bg-paper py-14 sm:py-20" aria-labelledby="claims-titulo">
<div className="shell">
<p className="font-mono text-[0.6875rem] font-medium uppercase tracking-[0.16em] text-ember-dim">
Compromiso de documentación
</p>
<h2 id="claims-titulo" className="mt-4 font-display text-2xl font-extrabold text-deep sm:text-3xl">
Los {totalClaims} puntos que documentamos antes de cotizar
</h2>
<div className="mt-4 max-w-3xl space-y-4 text-sm leading-relaxed text-deep-muted sm:text-base">
<p>
El sitio anterior afirmaba varias cosas como características del producto:{" "}
<span className="text-deep">no caduca</span>,{" "}
<span className="text-deep">20 años de garantía</span>,{" "}
<span className="text-deep">no requiere recarga</span>, cobertura de todas las clases
de fuego. El estudio de marca concluyó que no pueden presentarse como universales.
</p>
<p>
Aquí no se repiten. Se convierten en una lista de trabajo: cada punto es un documento
que entregamos por número de modelo, con su alcance y sus condiciones, antes de que
firmes. Esa es la diferencia entre prometer y documentar.
</p>
</div>
<div className="mt-10 space-y-4">
{porCategoria.map(({ categoria, productos, claims }) => (
<details
key={categoria.slug}
className="group rounded-[6px] border border-rule-lite bg-white [-webkit-tap-highlight-color:transparent]"
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 sm:px-6">
<span>
<span className="block font-display text-base font-extrabold text-deep">
{categoria.nombre}
</span>
<span className="mt-1 block font-mono text-[0.625rem] uppercase tracking-[0.12em] text-deep-muted">
{productos.length} {productos.length === 1 ? "equipo" : "equipos"} ·{" "}
{claims.length} {claims.length === 1 ? "punto" : "puntos"} por documentar
</span>
</span>
<span
aria-hidden="true"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[2px] border border-rule-lite font-mono text-sm text-deep-muted transition-transform duration-200 group-open:rotate-45"
>
+
</span>
</summary>
<div className="border-t border-rule-lite px-5 pb-6 pt-5 sm:px-6">
{claims.length > 0 ? (
<ul className="space-y-3.5">
{claims.map((claim) => (
<li key={claim} className="flex gap-3">
<span
aria-hidden="true"
className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-ember-dim"
/>
<p className="text-sm leading-relaxed text-deep-muted">{claim}</p>
</li>
))}
</ul>
) : (
<p className="text-sm leading-relaxed text-deep-muted">
Sin puntos pendientes registrados para esta categoría.
</p>
)}
<Link
href={`/catalogo/${categoria.slug}`}
className="mt-6 inline-block font-mono text-[0.625rem] uppercase tracking-[0.14em] text-signal hover:text-signal-hi"
>
Ver los equipos de la categoría &rarr;
</Link>
</div>
</details>
))}
</div>
</div>
</section>
{/* ---------- referencias normativas y certificaciones enumeradas ---------- */}
<section className="border-t border-rule bg-ink py-14 sm:py-20" aria-labelledby="normas-titulo">
<div className="shell">
<Revelar>
<p className="eyebrow mb-4">Referencias normativas</p>
<h2 id="normas-titulo" className="font-display text-2xl font-extrabold sm:text-3xl">
Las normas contra las que se revisa un expediente
</h2>
</Revelar>
<ul className="mt-9 grid gap-4 lg:grid-cols-2">
{NORMAS.map((norma) => (
<li key={norma.clave} className="panel p-5 sm:p-6">
<p className="font-mono text-xs font-semibold uppercase tracking-[0.12em] text-signal">
{norma.clave}
</p>
<h3 className="mt-3 font-display text-base font-extrabold leading-tight text-bright">
{norma.titulo}
</h3>
<p className="mt-3 text-sm leading-relaxed text-muted">{norma.aplica}</p>
<a
href={norma.fuente}
target="_blank"
rel="noopener noreferrer"
className="mt-4 inline-block font-mono text-[0.625rem] uppercase tracking-[0.14em] text-bright underline decoration-rule-2 underline-offset-4 hover:decoration-signal"
>
Ver la fuente oficial
</a>
</li>
))}
</ul>
<div className="mt-10 rounded-[6px] border border-rule border-l-2 border-l-ember bg-ink-2 p-6 sm:p-7">
<p className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-ember">
Enumerado por la fuente, pendiente de verificar
</p>
<h3 className="mt-4 font-display text-lg font-extrabold sm:text-xl">
Lo que el sitio anterior lista bajo &laquo;certificaciones&raquo;
</h3>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-muted">
Estas referencias aparecen enumeradas en la información pública de FSPM. Las
reproducimos como inventario de trabajo, no como certificaciones vigentes: una
referencia solo se convierte en evidencia cuando existe el documento con laboratorio,
expediente, fecha, alcance y el modelo al que corresponde.
</p>
<ul className="mt-6 flex flex-wrap gap-2">
{CERTIFICACIONES_ENUMERADAS.map((certificacion) => (
<li key={certificacion} className="badge">
{certificacion}
</li>
))}
</ul>
<p className="mt-6 max-w-3xl text-xs leading-relaxed text-faint">
Ninguna norma sustituye a otra. Un listado UL no equivale a una NOM, y una prueba
ambiental no acredita desempeño de extinción. Construir ese mapa de equivalencias, con
el documento en la mano, es parte del trabajo previo a una propuesta.
</p>
</div>
</div>
</section>
{/* ---------- solicitud ---------- */}
<section id="solicitar" className="grid-bg-lite border-t border-rule bg-paper py-14 sm:py-20">
<div className="shell">
<div className="grid gap-10 lg:grid-cols-[1fr_1.05fr] lg:items-start lg:gap-16">
<div>
<Revelar>
<p className="eyebrow mb-4">Solicitar documentación</p>
<h2 className="font-display text-2xl font-extrabold text-deep sm:text-3xl">
Pide los documentos por número de modelo
</h2>
<p className="mt-4 text-sm leading-relaxed text-deep-muted sm:text-base">
Dinos qué equipo te interesa o qué te está pidiendo tu auditoría. Respondemos con lo
que existe, con su alcance, y con lo que no podemos sostener por escrito.
</p>
</Revelar>
<ul className="mt-8 space-y-3.5">
{[
"Si un certificado no cubre tu caso de uso, te decimos hasta dónde llega.",
"Si un plazo de garantía tiene condiciones, van en la propuesta, no en la letra chica.",
"Si no tenemos un documento, lo decimos. Es más barato que descubrirlo en la auditoría.",
].map((punto) => (
<li key={punto} className="flex gap-3 text-sm leading-relaxed text-deep-muted">
<span aria-hidden="true" className="mt-2 h-1 w-1 shrink-0 rounded-full bg-signal" />
{punto}
</li>
))}
</ul>
</div>
<FormularioContacto
canal="cumplimiento"
variante="claro"
titulo="Solicitar documentación técnica"
descripcion="Indica el equipo o la instalación y qué documento te están pidiendo. Respondemos con el índice aplicable a tu caso."
/>
</div>
</div>
</section>
</>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { Encabezado } from "@/components/site/Encabezado";
import { Pie } from "@/components/site/Pie";
import { WidgetWhatsApp } from "@/components/formulario/WidgetWhatsApp";
export default function LayoutSitio({ children }: { children: React.ReactNode }) {
return (
<>
<a href="#contenido" className="skip">
Ir al contenido
</a>
<Encabezado />
<main id="contenido">{children}</main>
<Pie />
<WidgetWhatsApp />
</>
);
}
+320
View File
@@ -0,0 +1,320 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { Revelar } from "@/components/site/Revelar";
import { CATEGORIAS, PRODUCTOS } from "@/data/catalogo";
import { EMPRESA, PRENSA, SITIO_URL } from "@/data/empresa";
import { INDUSTRIAS } from "@/data/industrias";
/**
* /nosotros — origen de la tecnología, posicionamiento y postura editorial.
*
* La parte más importante de esta página es la última sección: por qué la
* empresa documenta en lugar de prometer. Es la traducción del hallazgo central
* del estudio de marca y la razón de ser del resto del sitio.
*/
const TITULO = "Quiénes somos";
const DESCRIPCION = `${EMPRESA.nombre}: origen de la tecnología FireAde, posicionamiento en operaciones críticas y por qué documentamos en lugar de prometer.`;
export const metadata: Metadata = {
title: TITULO,
description: DESCRIPCION,
alternates: { canonical: `${SITIO_URL}/nosotros` },
openGraph: {
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
description: DESCRIPCION,
url: `${SITIO_URL}/nosotros`,
},
};
const POSTURA = [
{
n: "01",
titulo: "Una afirmación sin documento es publicidad",
texto:
"El sitio anterior presentaba vida útil ilimitada, veinte años de garantía y ausencia de recarga como características del producto. Eran, en el mejor de los casos, afirmaciones con condiciones que nadie escribió. Ahora viven en un bloque aparte de cada ficha, junto con lo que hay que entregar para poder sostenerlas.",
},
{
n: "02",
titulo: "Los campos vacíos se quedan vacíos",
texto:
"Cuando no hay dato de alcance, de capacidad o de caudal, la ficha lo dice. No ponemos un guion ni una estimación redondeada: un número inventado en una ficha técnica reaparece en una auditoría con el nombre de quien lo firmó.",
},
{
n: "03",
titulo: "No hay botones de descarga que no descarguen nada",
texto:
"El centro de evidencia explica qué documentación corresponde a cada compra y cómo se solicita por número de modelo. Lo que no hace es simular un acervo. Un botón falso destruye la credibilidad de todo lo demás.",
},
{
n: "04",
titulo: "Los casos van rotulados",
texto:
"Los escenarios de aplicación están construidos con fines de demostración y lo dicen arriba, no en una nota al pie. Un caso con nombre de cliente requiere autorización por escrito sobre qué se puede contar y qué cifras se pueden publicar.",
},
{
n: "05",
titulo: "Preferimos la respuesta incómoda",
texto:
"Si un certificado no cubre tu caso de uso, si un plazo trae condiciones o si simplemente no tenemos un documento, va en la propuesta. Sale más barato ahí que en la verificación.",
},
];
export default function PaginaNosotros() {
return (
<>
{/* ---------- encabezado ---------- */}
<section className="grid-bg relative overflow-hidden bg-ink pt-12 pb-14 sm:pt-16 sm:pb-20">
<div className="shell">
<nav aria-label="Ruta de navegación" className="mb-8">
<ol className="flex flex-wrap items-center gap-2 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
<li>
<Link href="/" className="hover:text-bright">
Inicio
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-bright">Nosotros</li>
</ol>
</nav>
<div className="grid gap-12 lg:grid-cols-[1.15fr_1fr] lg:items-center">
<div className="anim-rise">
<p className="eyebrow mb-5">Quiénes somos</p>
<h1 className="font-display text-[2rem] font-black uppercase leading-[1.03] tracking-tight sm:text-[2.75rem] lg:text-5xl">
{EMPRESA.promesa}
</h1>
<p className="mt-6 max-w-xl text-base leading-relaxed text-muted sm:text-lg">
{EMPRESA.descripcion}
</p>
<dl className="spec mt-9 sm:grid-cols-3">
<div className="spec-cell">
<dt className="spec-key">Origen de la tecnología</dt>
<dd className="spec-val">{EMPRESA.fundacionOrigen}</dd>
</div>
<div className="spec-cell">
<dt className="spec-key">Categorías</dt>
<dd className="spec-val">{CATEGORIAS.length}</dd>
</div>
<div className="spec-cell">
<dt className="spec-key">Equipos y agentes</dt>
<dd className="spec-val">{PRODUCTOS.length}</dd>
</div>
</dl>
</div>
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-[6px] border border-rule">
<Image
src="/media/foto/bombero-plataforma.jpg"
alt="Personal de respuesta a incendios trabajando en una plataforma industrial"
fill
priority
sizes="(max-width: 1024px) 92vw, 34rem"
className="object-cover"
/>
<div
aria-hidden="true"
className="absolute inset-0 bg-gradient-to-t from-ink/70 via-transparent to-transparent"
/>
</div>
</div>
</div>
</section>
{/* ---------- origen de la tecnología ---------- */}
<section className="grid-bg-lite bg-paper py-14 sm:py-20" aria-labelledby="origen-titulo">
<div className="shell">
<div className="grid gap-10 lg:grid-cols-[1fr_1.15fr] lg:gap-16">
<div>
<Revelar>
<p className="eyebrow mb-4">Origen</p>
<h2 id="origen-titulo" className="font-display text-2xl font-extrabold text-deep sm:text-3xl">
La tecnología viene de {EMPRESA.empresaOrigen}
</h2>
</Revelar>
<div className="mt-5 space-y-4 text-sm leading-relaxed text-deep-muted sm:text-base">
<p>
{EMPRESA.empresaOrigen} se presenta como fundada en {EMPRESA.fundacionOrigen} por{" "}
{EMPRESA.fundadorOrigen}. {EMPRESA.nombreCorto} opera en México como{" "}
{EMPRESA.razonSocial} y usa esa herencia técnica como respaldo de desarrollo.
</p>
<p>
El activo de diferenciación es el agente FireAde 2000 y la familia de equipos
Enforcer, que permiten competir en un terreno distinto al del extintor
convencional: no por precio unitario, sino por tiempo de control, costo de
limpieza y continuidad de la operación.
</p>
<p>
Ese es también el límite honesto de lo que podemos afirmar aquí. Los datos de
origen provienen de la comunicación pública de la empresa; el desempeño de cada
formulación y de cada modelo se demuestra con su documentación, no con su historia.
</p>
</div>
</div>
<div className="panel-lite p-6 sm:p-8">
<h3 className="font-display text-lg font-extrabold text-deep sm:text-xl">
Dónde decidimos jugar
</h3>
<p className="mt-4 text-sm leading-relaxed text-deep-muted">
El estudio de marca fue explícito: {EMPRESA.nombreCorto} no debería competir por
catálogo más grande ni por precio unitario. El espacio defendible está entre
tecnología especializada, respuesta rápida, evidencia técnica y costo total de
operación.
</p>
<hr className="hair-lite my-6" />
<h4 className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-deep-muted">
Los cinco segmentos donde eso aplica
</h4>
<ul className="mt-4 space-y-3">
{INDUSTRIAS.map((industria) => (
<li key={industria.slug} className="flex gap-3">
<span className="mt-0.5 font-mono text-[0.625rem] font-semibold tracking-[0.08em] text-signal">
{String(industria.prioridad).padStart(2, "0")}
</span>
<p className="text-sm leading-relaxed text-deep-muted">
<Link href={`/soluciones/${industria.slug}`} className="link font-semibold text-deep">
{industria.nombre}
</Link>
{" — "}
{industria.dolor}
</p>
</li>
))}
</ul>
</div>
</div>
</div>
</section>
{/* ---------- postura editorial ---------- */}
<section className="border-t border-rule bg-ink py-14 sm:py-20" aria-labelledby="postura-titulo">
<div className="shell">
<div className="max-w-3xl">
<p className="font-mono text-[0.6875rem] font-medium uppercase tracking-[0.16em] text-ember">
Postura editorial
</p>
<h2 id="postura-titulo" className="mt-4 font-display text-2xl font-extrabold sm:text-3xl">
Por qué documentamos en lugar de prometer
</h2>
<p className="mt-5 text-base leading-relaxed text-muted">
La competencia promete. En protección contra incendios, una promesa sin documento no
es solo una debilidad comercial: es una exposición legal para quien la compra y para
quien la firma. La auditoría de marca lo señaló como el mayor riesgo del sitio
anterior, y estas cinco reglas son la respuesta.
</p>
</div>
<ol className="mt-12 grid gap-px bg-rule sm:grid-cols-2 lg:grid-cols-3">
{POSTURA.map((regla) => (
<li key={regla.n} className="bg-ink-2 p-6 sm:p-7">
<p className="font-mono text-[0.625rem] font-semibold tracking-[0.1em] text-ember">
{regla.n}
</p>
<h3 className="mt-3 font-display text-base font-extrabold leading-tight text-bright">
{regla.titulo}
</h3>
<p className="mt-3 text-sm leading-relaxed text-muted">{regla.texto}</p>
</li>
))}
</ol>
</div>
</section>
{/* ---------- Christmas Hero, sin exagerarlo ---------- */}
<section className="grid-bg-lite border-t border-rule bg-paper py-14 sm:py-20" aria-labelledby="prensa-titulo">
<div className="shell">
<div className="grid gap-10 lg:grid-cols-[1fr_1fr] lg:gap-16">
<div>
<Revelar>
<p className="eyebrow mb-4">Antecedente de comunicación</p>
<h2 id="prensa-titulo" className="font-display text-2xl font-extrabold text-deep sm:text-3xl">
&laquo;Christmas Hero&raquo;
</h2>
</Revelar>
<div className="mt-5 space-y-4 text-sm leading-relaxed text-deep-muted sm:text-base">
<p>
En 2019, {EMPRESA.nombreCorto} desarrolló una pieza de comunicación llamada
Christmas Hero: un adorno navideño que funcionaba como dispositivo de extinción
para árboles de Navidad. Fue publicada por LatinSpots en 2019 y obtuvo una mención
como segundo finalista de El Ojo Local México.
</p>
<p>
Lo decimos con esa medida, sin convertirlo en más de lo que fue. Importa por dos
razones concretas: demuestra que la empresa sabe traducir un problema técnico en
algo que una persona entiende de inmediato, y demuestra que puede sostener
atención de prensa cuando tiene algo que decir.
</p>
<p>
Ese es exactamente el trabajo pendiente en el resto de la comunicación: la
ingeniería está; la traducción es lo que faltaba.
</p>
</div>
</div>
<div>
<h3 className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-deep-muted">
Publicaciones que han cubierto a FSPM
</h3>
<ul className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3">
{PRENSA.map((medio) => (
<li key={medio.medio}>
<a
href={medio.url}
target="_blank"
rel="noopener noreferrer"
className="flex h-20 items-center justify-center rounded-[3px] border border-rule-lite bg-white px-4 transition-colors duration-200 hover:border-signal/60 [-webkit-tap-highlight-color:transparent]"
>
<Image
src={medio.logo}
alt={`Nota en ${medio.medio}`}
width={110}
height={36}
sizes="110px"
className="max-h-9 w-auto object-contain opacity-75 transition-opacity duration-200 hover:opacity-100"
/>
</a>
</li>
))}
</ul>
<p className="mt-5 text-xs leading-relaxed text-deep-muted">
Los enlaces llevan a la nota original de cada medio. Varias corresponden a la cobertura
de Christmas Hero; aparecen como cobertura periodística, no como respaldo del
desempeño de un producto.
</p>
</div>
</div>
</div>
</section>
{/* ---------- salidas ---------- */}
<section className="border-t border-rule bg-ink-2 py-14 sm:py-16">
<div className="shell">
<div className="max-w-3xl">
<Revelar>
<p className="eyebrow mb-4">Siguiente paso</p>
<h2 className="font-display text-2xl font-extrabold sm:text-3xl">
Lo que sigue depende de qué tan urgente sea tu caso
</h2>
</Revelar>
<div className="mt-8 flex flex-wrap gap-3">
<Link href="/contacto" className="btn btn-signal">
Hablar con nosotros
</Link>
<Link href="/catalogo" className="btn btn-ghost">
Ver el catálogo
</Link>
<Link href="/evidencia" className="btn btn-ghost">
Centro de evidencia
</Link>
</div>
</div>
</div>
</section>
</>
);
}
+132
View File
@@ -0,0 +1,132 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CATEGORIAS } from "@/data/catalogo";
import { EMPRESA } from "@/data/empresa";
import { INDUSTRIAS } from "@/data/industrias";
/**
* 404 con la voz del sitio: dice qué pasó y ofrece salidas reales.
* Sin humor, sin disculpas y sin ilustración de astronauta perdido.
*/
export const metadata: Metadata = {
title: "Página no encontrada",
description:
"La dirección solicitada no existe en este sitio. Rutas disponibles: catálogo, soluciones por industria, cumplimiento y contacto.",
robots: { index: false, follow: true },
};
export default function NoEncontrado() {
return (
<section
className="grid-bg flex min-h-[70vh] flex-col justify-center bg-ink py-16 sm:py-24"
// 100dvh-style respaldo: si el navegador no entiende dvh, descarta esta
// declaración y se queda con el 70vh de la clase.
style={{ minHeight: "70dvh" }}
>
<div className="shell">
<div className="grid gap-12 lg:grid-cols-[1fr_1fr] lg:gap-16">
<div>
<p className="font-mono text-[0.6875rem] font-medium uppercase tracking-[0.18em] text-signal">
Error 404
</p>
<h1 className="mt-5 font-display text-[2rem] font-black uppercase leading-[1.03] tracking-tight sm:text-[2.75rem] lg:text-5xl">
Esta dirección no existe en el sitio
</h1>
<p className="mt-6 max-w-xl text-base leading-relaxed text-muted sm:text-lg">
Puede que la página se haya movido, que el enlace venga incompleto o que la dirección
nunca haya existido. Estas son las rutas que sí funcionan.
</p>
<div className="mt-9 flex flex-wrap gap-3">
<Link href="/catalogo" className="btn btn-signal">
Ir al catálogo
</Link>
<Link href="/soluciones" className="btn btn-ghost">
Ver soluciones
</Link>
<Link href="/contacto" className="btn btn-ghost">
Contacto
</Link>
</div>
<hr className="hair my-9" />
<p className="text-sm leading-relaxed text-muted">
Si necesitas una respuesta ahora, marca al{" "}
<a href={`tel:${EMPRESA.telefonoTel}`} className="link">
{EMPRESA.telefono}
</a>
.
</p>
</div>
<div className="panel p-6 sm:p-7">
<p className="eyebrow eyebrow-muted mb-5">Índice rápido</p>
<h2 className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-faint">
Categorías del catálogo
</h2>
<ul className="mt-4 grid gap-2 sm:grid-cols-2">
{CATEGORIAS.map((categoria) => (
<li key={categoria.slug}>
<Link
href={`/catalogo/${categoria.slug}`}
className="block text-sm leading-snug text-bright underline decoration-rule-2 underline-offset-4 transition-colors hover:decoration-signal [-webkit-tap-highlight-color:transparent]"
>
{categoria.nombre}
</Link>
</li>
))}
</ul>
<hr className="hair my-6" />
<h2 className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-faint">
Soluciones por operación
</h2>
<ul className="mt-4 grid gap-2 sm:grid-cols-2">
{INDUSTRIAS.map((industria) => (
<li key={industria.slug}>
<Link
href={`/soluciones/${industria.slug}`}
className="block text-sm leading-snug text-bright underline decoration-rule-2 underline-offset-4 transition-colors hover:decoration-signal [-webkit-tap-highlight-color:transparent]"
>
{industria.nombre}
</Link>
</li>
))}
</ul>
<hr className="hair my-6" />
<h2 className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-faint">
Recursos
</h2>
<ul className="mt-4 grid gap-2 sm:grid-cols-2">
{[
{ href: "/cumplimiento-nom-002", label: "Cumplimiento NOM-002" },
{ href: "/evidencia", label: "Centro de evidencia" },
{ href: "/preguntas-frecuentes", label: "Preguntas frecuentes" },
{ href: "/casos", label: "Casos de aplicación" },
{ href: "/nosotros", label: "Quiénes somos" },
{ href: "/aviso-de-privacidad", label: "Aviso de privacidad" },
].map((enlace) => (
<li key={enlace.href}>
<Link
href={enlace.href}
className="block text-sm leading-snug text-bright underline decoration-rule-2 underline-offset-4 transition-colors hover:decoration-signal [-webkit-tap-highlight-color:transparent]"
>
{enlace.label}
</Link>
</li>
))}
</ul>
</div>
</div>
</div>
</section>
);
}
+119
View File
@@ -0,0 +1,119 @@
import type { Metadata } from "next";
import { BloqueDiagnostico } from "@/components/site/home/BloqueDiagnostico";
import { BloqueNorma } from "@/components/site/home/BloqueNorma";
import { CatalogoDestacado } from "@/components/site/home/CatalogoDestacado";
import { ClasesFuego } from "@/components/site/home/ClasesFuego";
import { Clientes } from "@/components/site/home/Clientes";
import { Heroe } from "@/components/site/home/Heroe";
import { Prensa } from "@/components/site/home/Prensa";
import { RutasIndustria } from "@/components/site/home/RutasIndustria";
import { TresRutas } from "@/components/site/home/TresRutas";
import { EMPRESA, SITIO_URL } from "@/data/empresa";
/**
* PÁGINA DE INICIO.
*
* Es la implementación de la recomendación 10.1 del estudio de marca: rehacer
* el inicio con tres rutas — industria, tipo de riesgo y cumplimiento — en
* lugar de un escaparate de ocho categorías que obliga al visitante a inferir
* qué solución le corresponde.
*
* El orden de las secciones no es estético, es el recorrido de un comprador:
* clasificar el riesgo → elegir la ruta → entender el criterio técnico → ver
* el equipo → entender la obligación normativa → pedir el diagnóstico → juzgar
* a quién le está hablando.
*/
const DESCRIPCION =
"Extintores, agentes espumógenos y sistemas fijos, móviles y automáticos para industria, energía, logística, gasolineras, cocinas y PyMEs. Clasificamos el riesgo, seleccionamos el agente y entregamos la evidencia que exige la NOM-002-STPS-2010.";
export const metadata: Metadata = {
title: `${EMPRESA.promesa} | ${EMPRESA.nombreCorto}`,
description: DESCRIPCION,
alternates: { canonical: "/" },
keywords: [
"protección contra incendios",
"extintores industriales",
"NOM-002-STPS-2010",
"agente espumógeno",
"sistema contra incendio",
"clases de fuego",
],
openGraph: {
type: "website",
locale: "es_MX",
url: SITIO_URL,
siteName: EMPRESA.nombre,
title: `${EMPRESA.promesa} | ${EMPRESA.nombreCorto}`,
description: DESCRIPCION,
images: [
{
url: "/media/foto/bombero-plataforma.jpg",
width: 1200,
height: 630,
alt: "Respuesta contra incendio en una plataforma industrial",
},
],
},
twitter: {
card: "summary_large_image",
title: `${EMPRESA.promesa} | ${EMPRESA.nombreCorto}`,
description: DESCRIPCION,
images: ["/media/foto/bombero-plataforma.jpg"],
},
};
/**
* JSON-LD de organización. Solo campos verificables en `src/data/empresa.ts`:
* no se declara domicilio exacto, ni número de empleados, ni año de fundación
* de la operación mexicana, porque esos datos no están confirmados.
*/
const datosEstructurados = {
"@context": "https://schema.org",
"@type": "Organization",
name: EMPRESA.nombre,
legalName: EMPRESA.razonSocial,
alternateName: EMPRESA.nombreCorto,
url: SITIO_URL,
logo: `${SITIO_URL}/media/marca/fspm-logo.png`,
description: EMPRESA.descripcion,
telephone: EMPRESA.telefonoTel,
email: EMPRESA.correo,
address: {
"@type": "PostalAddress",
addressLocality: EMPRESA.ciudad,
addressCountry: "MX",
},
contactPoint: [
{
"@type": "ContactPoint",
contactType: "sales",
telephone: EMPRESA.telefonoTel,
email: EMPRESA.correo,
areaServed: "MX",
availableLanguage: ["es-MX"],
},
],
sameAs: [EMPRESA.redes.facebook, EMPRESA.redes.youtube, EMPRESA.redes.linkedin],
};
export default function PaginaInicio() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(datosEstructurados) }}
/>
<Heroe />
<TresRutas />
<RutasIndustria />
<ClasesFuego />
<CatalogoDestacado />
<BloqueNorma />
<BloqueDiagnostico />
<Clientes />
<Prensa />
</>
);
}
@@ -0,0 +1,425 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Revelar } from "@/components/site/Revelar";
import { EMPRESA, NORMAS, SITIO_URL } from "@/data/empresa";
/**
* /preguntas-frecuentes
*
* Las preguntas vienen de la página real de FSPM. Las respuestas no se copian:
* donde la fuente afirma "no caduca", "20 años de garantía" o "no requiere
* recarga", la respuesta explica qué documento respalda eso y bajo qué
* condiciones. Es el mismo criterio del resto del sitio.
*
* El JSON-LD FAQPage se construye desde el mismo arreglo que se renderiza, así
* que no puede desincronizarse del contenido visible.
*/
const TITULO = "Preguntas frecuentes";
const DESCRIPCION =
"Respuestas verificables sobre FireAde 2000, vida útil, garantía, recarga, clases de fuego, certificaciones y documentación. Qué documento respalda cada afirmación y bajo qué condiciones.";
export const metadata: Metadata = {
title: TITULO,
description: DESCRIPCION,
alternates: { canonical: `${SITIO_URL}/preguntas-frecuentes` },
openGraph: {
title: `${TITULO} | ${EMPRESA.nombreCorto}`,
description: DESCRIPCION,
url: `${SITIO_URL}/preguntas-frecuentes`,
},
};
type Pregunta = {
id: string;
pregunta: string;
/** Párrafos en texto plano: alimentan la página y el JSON-LD. */
respuesta: string[];
};
type Grupo = {
id: string;
titulo: string;
descripcion: string;
preguntas: Pregunta[];
};
const GRUPOS: Grupo[] = [
{
id: "vida-util",
titulo: "Vida útil, garantía y recarga",
descripcion:
"Las tres afirmaciones que el estudio de marca señaló como el mayor riesgo comercial del sitio anterior. Aquí no se repiten: se explican.",
preguntas: [
{
id: "caduca",
pregunta: "¿Es cierto que el equipo no caduca?",
respuesta: [
"La información pública de FSPM afirma que FireAde 2000 tiene vida de anaquel ilimitada y que conserva sus propiedades incluso mezclado con agua. No repetimos esa afirmación como característica del producto porque una vida útil se demuestra con un documento, no con una frase.",
"Lo que respalda una respuesta seria es la carta técnica del fabricante referida al número de modelo, con las condiciones de almacenamiento y las circunstancias que la invalidan. Eso lo entregamos antes de cotizar.",
"Además, conviene separar dos cosas que suelen mezclarse: la vida útil del agente y el mantenimiento del equipo que lo contiene. Aunque el agente conservara sus propiedades, el recipiente, la válvula y el manómetro siguen sujetos a revisión.",
],
},
{
id: "recarga",
pregunta: "¿Entonces no requiere recarga?",
respuesta: [
"La fuente lo presenta así, pero no especifica si aplica al agente, al extintor completo, ni bajo qué condiciones de mantenimiento. Esa distinción es justamente la que decide si tu expediente aguanta una verificación.",
"Nuestra respuesta es documental: confirmamos por escrito a qué se refiere exactamente la ausencia de recarga en tu modelo y qué obligaciones de mantenimiento subsisten.",
"En todo caso, la revisión periódica del equipo no desaparece. La NOM-154-SCFI-2005, referida por la NOM-002, establece que la revisión de los extintores debe hacerse cada mes.",
],
},
{
id: "garantia",
pregunta: "¿La garantía es de 20 años o de 15?",
respuesta: [
"Las dos cifras aparecen en la información pública de FSPM: las páginas de producto mencionan veinte años y la sección de preguntas frecuentes menciona quince para los dispositivos de aplicación. Esa inconsistencia es la razón por la que no publicamos ninguna de las dos como dato.",
"Lo que sí entregamos es el documento de garantía del modelo, con plazo, cobertura, exclusiones y las obligaciones de mantenimiento que la conservan vigente. Un plazo sin condiciones es una cifra publicitaria, no una garantía.",
],
},
],
},
{
id: "agente",
titulo: "El agente FireAde 2000",
descripcion:
"Qué es, qué clases cubre y qué afirmaciones ambientales requieren documento antes de poder repetirse.",
preguntas: [
{
id: "afff",
pregunta: "¿FireAde 2000 es un espumógeno AFFF?",
respuesta: [
"La fuente responde \"sí y no\": lo describe como clasificado en la familia AFFF y, al mismo tiempo, como una evolución tecnológica frente al AFFF tradicional basado en flúor. En el catálogo aparece como FireAde 2000 AR-AFFF, con una solución al 6% para los equipos Enforcer.",
"Para una decisión de compra la categoría importa menos que la formulación concreta. Lo que resuelve la pregunta es la ficha técnica de la variante que se te ofrece y su hoja de datos de seguridad vigente, con la composición declarada.",
],
},
{
id: "clases",
pregunta: "¿Sirve para las cinco clases de fuego, A, B, C, D y K?",
respuesta: [
"La fuente lo afirma de forma general, pero ella misma pone una condición: para fuegos de clase C la eficacia se declara con agua desmineralizada. Cuando una afirmación trae condición, la condición es parte de la afirmación.",
"En cada ficha de producto mostramos el riel de clases con lo que la fuente declara, no con lo que convendría declarar, y anotamos que las condiciones por clase se confirman en la ficha técnica del modelo: concentración, equipo y tipo de agua.",
"Hay productos del catálogo a los que deliberadamente no les atribuimos clases de fuego, como TrakAde y VaporAde Plus, porque la fuente no los presenta como agentes de extinción.",
],
},
{
id: "ambiental",
pregunta: "¿Es cierto que no es tóxico ni contaminante?",
respuesta: [
"La fuente afirma que no contiene materiales tóxicos, que no es contaminante, que superó pruebas con organismos acuáticos y que su biodegradabilidad es prácticamente total en veinticinco días. Son afirmaciones fuertes y verificables, y precisamente por eso pedimos verlas antes de repetirlas.",
"Lo que las sostiene es la hoja de datos de seguridad vigente y los reportes de laboratorio con método, alcance y fecha, referidos a la formulación exacta. Un resultado obtenido sobre una variante no se traslada automáticamente a otra.",
"Lo que no vamos a hacer es prometer que una formulación seguirá conforme a la regulación ambiental futura. Nadie puede sostener eso, y el estudio de marca lo señala como un riesgo reputacional.",
],
},
{
id: "enfriamiento",
pregunta: "¿Se puede aplicar de forma preventiva para enfriar equipo que no está ardiendo?",
respuesta: [
"La fuente describe ese uso, por ejemplo en transformadores y en maquinaria con carga térmica de riesgo, y menciona también instalaciones para las que no existe documentación en la extracción pública que revisamos.",
"Por eso limitamos la respuesta a lo que puede sostenerse: la aplicación preventiva de enfriamiento se evalúa caso por caso, con la ficha técnica del agente y con el criterio del responsable del equipo que se va a rociar. No la ofrecemos como una capacidad general.",
],
},
{
id: "compatibilidad",
pregunta: "¿Es compatible con mi equipo instalado y con agua salada?",
respuesta: [
"La fuente afirma compatibilidad con cualquier equipamiento, incluidos sistemas de espuma de aire comprimido, y con todo tipo de aguas.",
"En la práctica, la pregunta que hay que responder es más estrecha: si es compatible con tu equipo, del fabricante que sea, en la concentración que tu sistema dosifica. Eso se confirma cruzando la ficha técnica del agente con el manual de tu equipo, y es parte del trabajo previo a una propuesta.",
],
},
{
id: "mezcla",
pregunta: "¿Puedo mezclarlo con el espumógeno que ya tengo?",
respuesta: [
"No. La propia fuente lo desaconseja, y sin un dato de compatibilidad documentado la mezcla compromete el desempeño y muy probablemente la garantía.",
"Si tienes concentrado de otra marca en el tanque, lo correcto es planear la sustitución y el manejo del residuo, no la mezcla. La disposición del concentrado que sale es un tema técnico y de costo que conviene resolver antes de la compra, no después.",
],
},
{
id: "limpieza",
pregunta: "¿Qué implica la limpieza después de usarlo?",
respuesta: [
"La fuente indica que el agente no es corrosivo, que su pH es prácticamente neutro y que la limpieza se resuelve con agua.",
"El documento que respalda eso es la hoja de datos de seguridad, y es también el que dice cómo manejar el residuo según el material sobre el que se aplicó. Después de un incendio, el residuo no es solo agente: es agente más lo que se estaba quemando.",
],
},
],
},
{
id: "equipos",
titulo: "Los equipos",
descripcion: "Alcance declarado, certificaciones y cómo elegir sin sobredimensionar la compra.",
preguntas: [
{
id: "alcance",
pregunta: "¿Cuál es el alcance real de un extintor Enforcer?",
respuesta: [
"En el catálogo aparece como \"más de 10 m\", que es el dato declarado por la fuente. Lo publicamos con esa etiqueta y no lo convertimos en una cifra exacta.",
"Un alcance solo significa algo junto con las condiciones de la medición: presión, boquilla, ángulo y agente. Eso viene en la ficha técnica del modelo, y es lo que pedimos antes de proponer una distribución de equipo.",
],
},
{
id: "certificaciones",
pregunta: "¿Los equipos tienen certificación UL o cumplen NFPA?",
respuesta: [
"La información pública de FSPM enumera referencias como UL 162, UL 711, NFPA 11, NFPA 16, NFPA 18, DIN EN 1568-3 y LASTFIRE, entre otras.",
"Una referencia no es un certificado. Un listado aplica a una formulación o a un modelo concreto, con expediente, alcance y fecha, y no se traslada al resto del catálogo. Lo que entregamos es el certificado que corresponde al modelo que se te está ofreciendo.",
"También conviene decirlo con claridad: ninguna norma sustituye a otra. Un listado UL no equivale a una NOM mexicana, y una prueba ambiental no acredita desempeño de extinción.",
],
},
{
id: "elegir",
pregunta: "¿Cómo sé qué equipo me corresponde?",
respuesta: [
"Por tres respuestas, en este orden: qué se quema en cada área, quién va a operar el equipo y en cuántos segundos, y qué documentación te van a pedir.",
"La primera define la clase de fuego y por tanto el agente. La segunda define si el equipo debe ser portátil, móvil, fijo o de activación automática. La tercera define el expediente.",
"El catálogo está construido para responder eso: se filtra por clase de fuego y por aplicación, no por nombre de producto.",
],
},
{
id: "precio",
pregunta: "¿Cuánto cuesta?",
respuesta: [
"No publicamos precios porque dependen de la configuración, la cantidad, la instalación y el servicio asociado. Preferimos decirlo antes de que llenes un formulario, no después.",
"Lo que sí podemos anticipar es el criterio: el error caro casi nunca es el precio unitario, es comprar equipo que no corresponde a la clase de fuego del área o distribuirlo por metros cuadrados en lugar de por distancia de recorrido.",
],
},
],
},
{
id: "normativa",
titulo: "Normativa y documentación",
descripcion: "Lo que una verificación pide y lo que debe acompañar a una compra.",
preguntas: [
{
id: "nom-002",
pregunta: "¿Qué me exige la NOM-002-STPS-2010?",
respuesta: [
NORMAS[0].aplica,
"En términos prácticos: la clasificación del riesgo va primero y condiciona todo lo demás. Los umbrales de esa clasificación dependen del material y de la superficie de cada centro de trabajo, así que no los reproducimos aquí.",
"Es una referencia orientativa. La normativa puede complementarse por estado y municipio, y esta página no sustituye un dictamen técnico ni jurídico.",
],
},
{
id: "revision",
pregunta: "¿Cada cuánto hay que revisar los extintores?",
respuesta: [
NORMAS[1].aplica,
"Es la obligación recurrente que más seguido se pierde, y casi nunca por desconocerla: se pierde porque nadie quedó a cargo de la bitácora. Por eso, en una entrega, el formato de bitácora y el responsable nombrado son parte del alcance.",
],
},
{
id: "documentos",
pregunta: "¿Qué documentos debo exigir en una compra?",
respuesta: [
"Ficha técnica del modelo, hoja de datos de seguridad del agente, certificado o listado por modelo, garantía con condiciones por escrito, manual de operación y mantenimiento, y bitácora de revisión. Cuando hay instalación, además la constancia de puesta en marcha; cuando hay personal que operar, la constancia de capacitación.",
"No todos aplican a todos los productos. El centro de evidencia tiene el índice de qué corresponde según la categoría.",
],
},
{
id: "descargas",
pregunta: "¿Por qué no hay documentos para descargar en este sitio?",
respuesta: [
"Porque en la extracción pública del sitio de FSPM no apareció un índice verificable de documentos por número de modelo, y poner botones de descarga que no llevan a ningún archivo sería peor que no tenerlos.",
"Los documentos se entregan por número de modelo, a solicitud, junto con la propuesta técnica. Y cuando un documento no exista o no cubra lo que se afirma, lo decimos en la propuesta.",
],
},
],
},
];
export default function PaginaPreguntas() {
const todas = GRUPOS.flatMap((g) => g.preguntas);
const jsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: todas.map((p) => ({
"@type": "Question",
name: p.pregunta,
acceptedAnswer: {
"@type": "Answer",
text: p.respuesta.join(" "),
},
})),
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* ---------- encabezado ---------- */}
<section className="grid-bg bg-ink pt-12 pb-14 sm:pt-16 sm:pb-20">
<div className="shell">
<nav aria-label="Ruta de navegación" className="mb-8">
<ol className="flex flex-wrap items-center gap-2 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
<li>
<Link href="/" className="hover:text-bright">
Inicio
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-bright">Preguntas frecuentes</li>
</ol>
</nav>
<div className="max-w-3xl anim-rise">
<p className="eyebrow mb-5">Preguntas frecuentes</p>
<h1 className="font-display text-[2rem] font-black uppercase leading-[1.03] tracking-tight sm:text-[2.75rem] lg:text-5xl">
Las mismas preguntas, respondidas con lo que se puede documentar
</h1>
<p className="mt-6 text-base leading-relaxed text-muted sm:text-lg">
Estas preguntas vienen de la página real de FSPM. Las respuestas no. Donde la fuente
afirmaba algo sin condiciones, aquí se explica qué documento lo respalda y qué queda
pendiente de confirmar por número de modelo. Es menos vistoso y sirve mucho más
cuando llega la auditoría.
</p>
<dl className="spec mt-9 sm:grid-cols-2">
<div className="spec-cell">
<dt className="spec-key">Preguntas</dt>
<dd className="spec-val">{todas.length}</dd>
</div>
<div className="spec-cell">
<dt className="spec-key">Temas</dt>
<dd className="spec-val">{GRUPOS.length}</dd>
</div>
</dl>
</div>
</div>
</section>
{/* ---------- los grupos ---------- */}
{GRUPOS.map((grupo, indice) => {
const claro = indice % 2 === 0;
return (
<section
key={grupo.id}
id={grupo.id}
aria-labelledby={`grupo-${grupo.id}`}
className={[
"border-t border-rule py-14 sm:py-20",
claro ? "grid-bg-lite bg-paper" : "bg-ink",
].join(" ")}
>
<div className="shell">
<div className="grid gap-10 lg:grid-cols-[1fr_1.6fr] lg:gap-14">
<Revelar className="lg:sticky lg:top-24 lg:self-start">
<p
className={[
"font-mono text-[0.625rem] uppercase tracking-[0.16em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
Tema {String(indice + 1).padStart(2, "0")}
</p>
<h2
id={`grupo-${grupo.id}`}
className={[
"mt-4 font-display text-2xl font-extrabold leading-tight sm:text-3xl",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
{grupo.titulo}
</h2>
<p
className={[
"mt-4 text-sm leading-relaxed",
claro ? "text-deep-muted" : "text-muted",
].join(" ")}
>
{grupo.descripcion}
</p>
</Revelar>
<div className="space-y-3">
{grupo.preguntas.map((pregunta) => (
<details
key={pregunta.id}
id={pregunta.id}
className={[
"group rounded-[6px] border [-webkit-tap-highlight-color:transparent]",
claro ? "border-rule-lite bg-white" : "border-rule bg-ink-2",
].join(" ")}
>
<summary className="flex cursor-pointer list-none items-start justify-between gap-4 px-5 py-4 sm:px-6 sm:py-5">
<span
className={[
"font-display text-base font-bold leading-snug sm:text-lg",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
{pregunta.pregunta}
</span>
<span
aria-hidden="true"
className={[
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-[2px] border font-mono text-sm transition-transform duration-200 group-open:rotate-45",
claro
? "border-rule-lite text-deep-muted"
: "border-rule-2 text-muted",
].join(" ")}
>
+
</span>
</summary>
<div
className={[
"space-y-4 border-t px-5 pb-6 pt-5 sm:px-6",
claro ? "border-rule-lite" : "border-rule",
].join(" ")}
>
{pregunta.respuesta.map((parrafo) => (
<p
key={parrafo.slice(0, 48)}
className={[
"text-sm leading-relaxed",
claro ? "text-deep-muted" : "text-muted",
].join(" ")}
>
{parrafo}
</p>
))}
</div>
</details>
))}
</div>
</div>
</div>
</section>
);
})}
{/* ---------- salidas ---------- */}
<section className="border-t border-rule bg-ink-2 py-14 sm:py-16">
<div className="shell">
<div className="max-w-3xl">
<p className="eyebrow mb-4">Si tu pregunta no está aquí</p>
<h2 className="font-display text-2xl font-extrabold sm:text-3xl">
Pregúntala directo, con el escenario incluido
</h2>
<p className="mt-4 text-sm leading-relaxed text-muted sm:text-base">
Si la respuesta depende de un documento que no tenemos, te lo decimos. Es más útil que
una respuesta cómoda.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link href="/contacto" className="btn btn-signal">
Escribirnos
</Link>
<Link href="/evidencia" className="btn btn-ghost">
Centro de evidencia
</Link>
<Link href="/cumplimiento-nom-002" className="btn btn-ghost">
Cumplimiento NOM-002
</Link>
</div>
</div>
</div>
</section>
</>
);
}
+433
View File
@@ -0,0 +1,433 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { FormularioContacto } from "@/components/formulario/FormularioContacto";
import { RielClases } from "@/components/marca/RielClases";
import { Revelar } from "@/components/site/Revelar";
import { BloqueDocumentacion } from "@/components/site/catalogo/BloqueDocumentacion";
import { TarjetaProducto } from "@/components/site/catalogo/TarjetaProducto";
import { iconoDeAplicacion } from "@/components/site/catalogo/iconos";
import { PRODUCTOS, getCategoria, getProducto, productosDeCategoria } from "@/data/catalogo";
import { CLASES_INFO } from "@/data/clases-fuego";
import { EMPRESA, SITIO_URL } from "@/data/empresa";
/**
* /producto/[slug] — la página que decide la venta.
*
* Lleva tres cosas que el sitio original no tenía juntas: la especificación sin
* huecos rellenados a mano, el compromiso de documentación por número de modelo
* y un formulario que ya sabe qué producto se estaba viendo.
*
* El JSON-LD declara Product sin offers, price ni aggregateRating: no hay
* precios públicos ni reseñas, e inventarlos en datos estructurados es una
* violación de las políticas de Google además de una mentira.
*/
type Props = { params: Promise<{ slug: string }> };
export function generateStaticParams(): Array<{ slug: string }> {
return PRODUCTOS.map((p) => ({ slug: p.slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const producto = getProducto(slug);
if (!producto) {
return { title: "Producto no encontrado" };
}
const url = `${SITIO_URL}/producto/${producto.slug}`;
const partes = [
producto.capacidad ? `Capacidad ${producto.capacidad}.` : null,
producto.agente ? `Agente ${producto.agente}.` : null,
producto.clases.length > 0 ? `Clases ${producto.clases.join(", ")}.` : null,
].filter(Boolean);
return {
title: producto.nombre,
description: `${producto.resumen} ${partes.join(" ")}`.trim().slice(0, 300),
alternates: { canonical: url },
openGraph: {
type: "website",
title: `${producto.nombre} | ${EMPRESA.nombreCorto}`,
description: producto.resumen,
url,
images: [{ url: producto.imagen, alt: producto.nombre }],
},
};
}
export default async function PaginaProducto({ params }: Props) {
const { slug } = await params;
const producto = getProducto(slug);
if (!producto) notFound();
const categoria = getCategoria(producto.categoria);
const relacionados = productosDeCategoria(producto.categoria).filter(
(p) => p.slug !== producto.slug,
);
// la tira de especificación: solo lo que la fuente declara
const especificacion = [
{ clave: "Capacidad", valor: producto.capacidad },
{ clave: "Agente", valor: producto.agente },
{ clave: "Alcance declarado", valor: producto.alcance },
{ clave: "Descarga", valor: producto.descarga },
{ clave: "Modelo", valor: producto.modelo },
].filter((campo): campo is { clave: string; valor: string } => Boolean(campo.valor));
const rutaBase = categoria ? `/catalogo/${categoria.slug}` : "/catalogo";
const migas = [
{ nombre: "Catálogo", url: `${SITIO_URL}/catalogo` },
...(categoria ? [{ nombre: categoria.nombre, url: `${SITIO_URL}${rutaBase}` }] : []),
{ nombre: producto.nombre, url: `${SITIO_URL}/producto/${producto.slug}` },
];
const jsonLdMigas = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: migas.map((miga, i) => ({
"@type": "ListItem",
position: i + 1,
name: miga.nombre,
item: miga.url,
})),
};
const jsonLdProducto = {
"@context": "https://schema.org",
"@type": "Product",
name: producto.nombre,
description: producto.resumen,
image: `${SITIO_URL}${producto.imagen}`,
sku: producto.modelo ?? undefined,
category: categoria?.nombre,
brand: { "@type": "Brand", name: EMPRESA.nombreCorto },
manufacturer: { "@type": "Organization", name: EMPRESA.empresaOrigen },
url: `${SITIO_URL}/producto/${producto.slug}`,
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLdMigas) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLdProducto) }}
/>
{/* ---------- 1 y 2. migas, imagen e identificación ---------- */}
<section className="grid-bg bg-ink pt-12 pb-14 sm:pt-16 sm:pb-20">
<div className="shell">
<nav aria-label="Ruta de navegación" className="mb-8">
<ol className="flex flex-wrap items-center gap-2 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
<li>
<Link href="/catalogo" className="hover:text-bright">
Catálogo
</Link>
</li>
{categoria ? (
<>
<li aria-hidden="true">/</li>
<li>
<Link href={rutaBase} className="hover:text-bright">
{categoria.nombre}
</Link>
</li>
</>
) : null}
<li aria-hidden="true">/</li>
<li className="text-bright">{producto.nombre}</li>
</ol>
</nav>
<div className="grid gap-10 lg:grid-cols-[1fr_1.1fr] lg:gap-14">
<div className="relative aspect-square w-full overflow-hidden rounded-[6px] border border-rule bg-ink-2">
<Image
src={producto.imagen}
alt={producto.nombre}
fill
priority
sizes="(max-width: 1024px) 92vw, 32rem"
className="object-contain p-8 sm:p-12"
/>
</div>
<div className="anim-rise">
{categoria ? (
<p className="eyebrow mb-5">{categoria.nombre}</p>
) : (
<p className="eyebrow mb-5">Catálogo</p>
)}
<h1 className="font-display text-[1.875rem] font-black uppercase leading-[1.05] tracking-tight sm:text-[2.5rem] lg:text-[2.75rem]">
{producto.nombre}
</h1>
{producto.modelo ? (
<p className="mt-4 font-mono text-xs uppercase tracking-[0.14em] text-faint">
Modelo <span className="text-bright">{producto.modelo}</span>
</p>
) : null}
<p className="mt-6 max-w-xl text-base leading-relaxed text-muted sm:text-lg">
{producto.resumen}
</p>
<div className="mt-8">
{producto.clases.length > 0 ? (
<>
<p className="field-label">Clases de fuego declaradas</p>
<RielClases activas={producto.clases} tamano="lg" />
<p className="mt-3.5 max-w-lg text-xs leading-relaxed text-faint">
Las condiciones de aplicación por clase se confirman en la ficha técnica del
modelo. Para clase C, la fuente condiciona el desempeño al uso de agua
desmineralizada.
</p>
</>
) : (
<div className="rounded-[3px] border border-rule-2 bg-ink-2 px-4 py-3.5">
<p className="font-mono text-[0.625rem] uppercase tracking-[0.14em] text-ember">
Sin clase de fuego declarada
</p>
<p className="mt-2 max-w-lg text-xs leading-relaxed text-muted">
Este producto no se presenta como agente extintor, así que no le atribuimos
clases de fuego. Consulta su ficha técnica para la aplicación correcta.
</p>
</div>
)}
</div>
{/* 3. tira de especificación, sin campos vacíos */}
{especificacion.length > 0 ? (
<dl className="spec mt-9 sm:grid-cols-2">
{especificacion.map((campo) => (
<div key={campo.clave} className="spec-cell">
<dt className="spec-key">{campo.clave}</dt>
<dd className="spec-val whitespace-normal leading-relaxed">{campo.valor}</dd>
</div>
))}
</dl>
) : null}
<div className="mt-9 flex flex-wrap gap-3">
<Link href="#cotizar" className="btn btn-signal">
Cotizar este equipo
</Link>
<Link href="/evidencia" className="btn btn-ghost">
Qué documentos incluye
</Link>
</div>
</div>
</div>
</div>
</section>
{/* ---------- 4 y 5. características y compromiso de documentación ---------- */}
<section className="grid-bg-lite bg-paper py-14 sm:py-20">
<div className="shell">
<div className="grid gap-10 lg:grid-cols-[1fr_1.15fr] lg:gap-14">
<div>
<Revelar>
<p className="eyebrow mb-4">Características</p>
<h2 className="font-display text-2xl font-extrabold text-deep sm:text-3xl">
Lo que la fuente declara del equipo
</h2>
</Revelar>
<ul className="mt-8 space-y-4">
{producto.caracteristicas.map((caracteristica) => (
<li key={caracteristica} className="flex gap-3.5">
<span
aria-hidden="true"
className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-signal"
/>
<p className="text-sm leading-relaxed text-deep-muted">{caracteristica}</p>
</li>
))}
</ul>
{producto.clases.length > 0 ? (
<div className="panel-lite mt-10 p-5">
<h3 className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-deep-muted">
Qué significa cada clase encendida
</h3>
<dl className="mt-4 space-y-3">
{producto.clases.map((clase) => (
<div key={clase} className="flex gap-3">
<dt className="flex h-5 w-5 shrink-0 items-center justify-center rounded-[2px] bg-signal font-mono text-[0.625rem] font-semibold text-white">
{clase}
</dt>
<dd className="text-xs leading-relaxed text-deep-muted">
<span className="font-semibold text-deep">
{CLASES_INFO[clase].nombre}.
</span>{" "}
{CLASES_INFO[clase].combustible}.
</dd>
</div>
))}
</dl>
</div>
) : null}
</div>
<div className="lg:pt-2">
<BloqueDocumentacion claims={producto.claimsPorVerificar} claro />
{producto.claimsPorVerificar.length === 0 ? (
<div className="panel-lite p-6">
<p className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-deep-muted">
Documentación
</p>
<p className="mt-3 text-sm leading-relaxed text-deep-muted">
Este producto no tiene afirmaciones pendientes de verificación registradas. La
entrega sigue incluyendo ficha técnica, hoja de datos de seguridad cuando
aplica, garantía con condiciones y manual.{" "}
<Link href="/evidencia" className="link">
Ver el índice de documentación
</Link>
.
</p>
</div>
) : null}
</div>
</div>
</div>
</section>
{/* ---------- 6. aplicaciones ---------- */}
<section className="border-t border-rule bg-ink py-14 sm:py-16" aria-labelledby="aplicaciones-titulo">
<div className="shell">
<Revelar>
<p className="eyebrow mb-4">Aplicaciones</p>
<h2 id="aplicaciones-titulo" className="font-display text-2xl font-extrabold sm:text-3xl">
Dónde lo ubica la fuente
</h2>
</Revelar>
<ul className="mt-9 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{producto.aplicaciones.map((aplicacion) => {
const icono = iconoDeAplicacion(aplicacion);
return (
<li
key={aplicacion}
className="flex flex-col items-center gap-3 rounded-[6px] border border-rule bg-ink-2 px-3 py-5 text-center"
>
{icono ? (
<Image
src={icono}
alt=""
width={40}
height={40}
sizes="40px"
className="h-10 w-10 object-contain opacity-85"
/>
) : (
<span
aria-hidden="true"
className="flex h-10 w-10 items-center justify-center rounded-[3px] border border-rule-2 font-mono text-sm text-faint"
>
{aplicacion.slice(0, 2).toUpperCase()}
</span>
)}
<span className="font-mono text-[0.625rem] uppercase leading-tight tracking-[0.1em] text-bright">
{aplicacion}
</span>
</li>
);
})}
</ul>
</div>
</section>
{/* ---------- 7. formulario con el producto resuelto ---------- */}
<section id="cotizar" className="border-t border-rule bg-ink-2 py-14 sm:py-20">
<div className="shell">
<div className="grid gap-10 lg:grid-cols-[1fr_1.05fr] lg:items-start lg:gap-16">
<div>
<Revelar>
<p className="eyebrow mb-4">Cotizar</p>
<h2 className="font-display text-2xl font-extrabold sm:text-3xl">
Cotizamos con la instalación a la vista, no por pieza
</h2>
<p className="mt-4 text-sm leading-relaxed text-muted sm:text-base">
Tu solicitud llega con el equipo que estabas viendo ya registrado, así que la
primera respuesta puede ser técnica en lugar de una pregunta de vuelta.
</p>
</Revelar>
<dl className="spec mt-8">
<div className="spec-cell">
<dt className="spec-key">Equipo de interés</dt>
<dd className="spec-val whitespace-normal">{producto.nombre}</dd>
</div>
{categoria ? (
<div className="spec-cell">
<dt className="spec-key">Categoría</dt>
<dd className="spec-val whitespace-normal">{categoria.nombre}</dd>
</div>
) : null}
</dl>
<p className="mt-6 text-xs leading-relaxed text-faint">
No publicamos precios: dependen de la configuración, la cantidad y la instalación.
Preferimos decirlo antes de que llenes el formulario.
</p>
</div>
<FormularioContacto
canal="producto"
producto={{
slug: producto.slug,
nombre: producto.nombre,
categoriaSlug: producto.categoria,
}}
titulo="Solicitar cotización"
descripcion="Cuéntanos dónde va instalado y qué material se maneja. Respondemos con configuración, cantidad y los documentos que acompañan la entrega."
/>
</div>
</div>
</section>
{/* ---------- 8. productos relacionados ---------- */}
{relacionados.length > 0 && categoria ? (
<section
className="grid-bg-lite border-t border-rule bg-paper py-14 sm:py-20"
aria-labelledby="relacionados-titulo"
>
<div className="shell">
<Revelar>
<p className="eyebrow mb-4">Misma categoría</p>
<h2
id="relacionados-titulo"
className="font-display text-2xl font-extrabold text-deep sm:text-3xl"
>
Otros equipos de {categoria.nombre.toLowerCase()}
</h2>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-deep-muted">
Comparten agente y clases declaradas; cambian la capacidad y el formato de respuesta.
</p>
</Revelar>
<div className="mt-9 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{relacionados.slice(0, 6).map((p) => (
<TarjetaProducto key={p.slug} producto={p} claro sinCategoria />
))}
</div>
<div className="mt-10">
<Link href={rutaBase} className="btn btn-deep">
Ver toda la categoría
</Link>
</div>
</div>
</section>
) : null}
</>
);
}
@@ -0,0 +1,470 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { notFound } from "next/navigation";
import { FormularioContacto } from "@/components/formulario/FormularioContacto";
import { RielClases } from "@/components/marca/RielClases";
import { Revelar } from "@/components/site/Revelar";
import { TarjetaProducto } from "@/components/site/soluciones/TarjetaProducto";
import { getCategoria, productosDeCategoria } from "@/data/catalogo";
import { CLASES_INFO, parseClases, type ClaseFuego } from "@/data/clases-fuego";
import { EMPRESA } from "@/data/empresa";
import { getIndustria, INDUSTRIAS } from "@/data/industrias";
/**
* PÁGINA DE SOLUCIÓN POR INDUSTRIA.
*
* Es una de las cinco landing pages que pide la recomendación 6 del estudio.
* Usa todos los campos del segmento: el dolor como encabezado, el contexto, las
* preguntas del diagnóstico presentadas como la secuencia real de una visita
* técnica, el paquete —la traducción de "no cotices solo un extintor"—, el
* cumplimiento aplicable sobre fondo claro, y el equipo de las categorías que
* corresponden a esa ruta.
*
* El riesgo declarado viaja por query string desde el clasificador del inicio:
* `?clases=B,C` gana sobre las clases dominantes del segmento y llega
* preseleccionado al formulario.
*/
type Params = { industria: string };
type Busqueda = { [clave: string]: string | string[] | undefined };
export function generateStaticParams(): Params[] {
return INDUSTRIAS.map((i) => ({ industria: i.slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<Params>;
}): Promise<Metadata> {
const { industria: slug } = await params;
const industria = getIndustria(slug);
if (!industria) {
return { title: "Solución no encontrada", robots: { index: false, follow: false } };
}
const titulo = `Protección contra incendios para ${industria.nombreLargo.toLowerCase()}`;
const descripcion = `${industria.dolor} ${industria.contexto.split(". ")[0]}. Clases de fuego ${industria.clases.join(", ")}, equipo, capacitación y la evidencia que exige la NOM-002-STPS-2010.`;
return {
title: titulo,
description: descripcion,
alternates: { canonical: `/soluciones/${industria.slug}` },
openGraph: {
type: "article",
locale: "es_MX",
siteName: EMPRESA.nombre,
url: `/soluciones/${industria.slug}`,
title: `${titulo} | ${EMPRESA.nombreCorto}`,
description: descripcion,
images: [{ url: industria.imagen, width: 1200, height: 630, alt: industria.nombreLargo }],
},
twitter: {
card: "summary_large_image",
title: `${titulo} | ${EMPRESA.nombreCorto}`,
description: descripcion,
images: [industria.imagen],
},
};
}
/** Primer valor útil de un parámetro que puede llegar repetido. */
function primerValor(valor: string | string[] | undefined): string | undefined {
if (Array.isArray(valor)) return valor[0];
return valor;
}
export default async function PaginaSolucion({
params,
searchParams,
}: {
params: Promise<Params>;
searchParams: Promise<Busqueda>;
}) {
const { industria: slug } = await params;
const industria = getIndustria(slug);
if (!industria) notFound();
const consulta = await searchParams;
const declaradas = parseClases(primerValor(consulta.clases));
// Lo que el visitante declaró en el clasificador gana sobre el valor por
// omisión del segmento: es su operación, no nuestro promedio.
const clasesActivas: ClaseFuego[] =
declaradas.length > 0 ? declaradas : (industria.clases as ClaseFuego[]);
const vieneDelClasificador = declaradas.length > 0;
const bloquesCategoria = industria.categorias
.map((slugCategoria) => {
const categoria = getCategoria(slugCategoria);
if (!categoria) return null;
const productos = productosDeCategoria(slugCategoria);
if (productos.length === 0) return null;
return { categoria, productos };
})
.filter((b): b is NonNullable<typeof b> => b !== null);
return (
<>
{/* ---------- 1. Encabezado ---------- */}
<section className="relative isolate overflow-hidden bg-ink" aria-labelledby="solucion-titulo">
<div className="absolute inset-0 -z-20">
<Image
src={industria.imagen}
alt=""
aria-hidden="true"
fill
priority
sizes="100vw"
className="object-cover object-center"
/>
</div>
<div
className="absolute inset-0 -z-10"
aria-hidden="true"
style={{
background:
"linear-gradient(100deg, var(--color-ink) 0%, rgba(12,17,22,0.97) 40%, rgba(12,17,22,0.82) 66%, rgba(12,17,22,0.52) 100%)",
}}
/>
<div
className="absolute inset-0 -z-10"
aria-hidden="true"
style={{
background:
"linear-gradient(to top, var(--color-ink) 0%, rgba(12,17,22,0.3) 34%, rgba(12,17,22,0) 70%)",
}}
/>
<div className="grid-bg absolute inset-0 -z-10" aria-hidden="true" />
<div className="shell relative py-14 sm:py-20">
<nav aria-label="Ruta de navegación" className="mb-7">
<ol className="flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
<li>
<Link href="/" className="transition-colors hover:text-bright">
Inicio
</Link>
</li>
<li aria-hidden="true">/</li>
<li>
<Link href="/soluciones" className="transition-colors hover:text-bright">
Soluciones
</Link>
</li>
<li aria-hidden="true">/</li>
<li className="text-muted">{industria.nombre}</li>
</ol>
</nav>
<div className="max-w-[44rem]">
<p className="eyebrow">
{/* prioridad del estudio de mercado, no índice de lista */}
Prioridad {String(industria.prioridad).padStart(2, "0")} ·{" "}
{industria.nombreLargo}
</p>
<h1
id="solucion-titulo"
className="anim-rise mt-5 font-display font-black uppercase leading-[0.96] tracking-[-0.03em] text-bright"
style={{ fontSize: "clamp(1.85rem, 5.4vw, 3.5rem)" }}
>
{industria.dolor}
</h1>
<p className="mt-5 font-mono text-xs uppercase tracking-[0.12em] text-signal">
Protección contra incendios para {industria.nombreLargo.toLowerCase()}
</p>
<p className="mt-6 text-base leading-relaxed text-muted sm:text-lg">
{industria.contexto}
</p>
<div className="panel mt-8 flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
{vieneDelClasificador
? "Riesgo que declaraste"
: "Clases habituales del segmento"}
</p>
<RielClases activas={clasesActivas} tamano="md" className="mt-2.5" />
</div>
<a href="#diagnostico" className="btn btn-signal btn-sm shrink-0">
Solicitar diagnóstico
</a>
</div>
<ul className="mt-4 flex flex-wrap gap-1.5">
{clasesActivas.map((clase) => (
<li key={clase} className="badge badge-signal">
Clase {clase} · {CLASES_INFO[clase].nombre}
</li>
))}
</ul>
</div>
</div>
</section>
{/* ---------- 2. Las preguntas del diagnóstico ---------- */}
<section className="border-y border-rule bg-ink-2" aria-labelledby="visita-titulo">
<div className="shell py-16 sm:py-20">
<Revelar className="max-w-[44rem]">
<p className="eyebrow eyebrow-muted">La visita técnica</p>
<h2
id="visita-titulo"
className="mt-4 font-display uppercase tracking-[-0.02em] text-bright"
style={{ fontSize: "clamp(1.5rem, 3.4vw, 2.2rem)" }}
>
Lo que preguntamos antes de proponer equipo
</h2>
<p className="mt-4 text-muted">
Es el orden real de una visita en este tipo de operación. Si puedes responder estas
preguntas por adelantado, la primera propuesta llega mucho más cerca de lo que
necesitas.
</p>
</Revelar>
{/* Numeradas porque se presentan como secuencia de visita, no como
lista de viñetas sin orden. */}
<ol className="mt-9 grid gap-px border border-rule bg-rule sm:grid-cols-2">
{industria.preguntasDiagnostico.map((pregunta, i) => (
<li key={pregunta} className="flex gap-4 bg-ink-2 p-5 sm:p-6">
<span className="font-mono text-sm tracking-[0.1em] text-signal" data-numeric>
{String(i + 1).padStart(2, "0")}
</span>
<p className="text-sm leading-relaxed text-bright sm:text-base">{pregunta}</p>
</li>
))}
</ol>
</div>
</section>
{/* ---------- 3. Qué se empaqueta ---------- */}
<section className="bg-ink" aria-labelledby="paquete-titulo">
<div className="shell py-16 sm:py-20">
<div className="grid gap-10 lg:grid-cols-12 lg:gap-12">
<Revelar className="lg:col-span-5">
<p className="eyebrow">El alcance</p>
<h2
id="paquete-titulo"
className="mt-4 font-display uppercase tracking-[-0.02em] text-bright"
style={{ fontSize: "clamp(1.5rem, 3.4vw, 2.2rem)" }}
>
No cotices solo un extintor
</h2>
<p className="mt-4 text-muted">
Un equipo suelto resuelve una compra, no un riesgo. Para {" "}
{industria.nombreLargo.toLowerCase()} esto es lo que conviene dejar cerrado en el
mismo proyecto.
</p>
<Link href="/evidencia" className="btn btn-ghost btn-sm mt-6">
Ver qué documentamos
</Link>
</Revelar>
<div className="lg:col-span-7">
<Revelar>
<ul className="panel divide-y divide-rule">
{industria.paquete.map((item) => (
<li key={item} className="flex items-start gap-3.5 px-5 py-4">
<span
aria-hidden="true"
className="mt-2 block h-1.5 w-1.5 shrink-0 rounded-[1px] bg-signal"
/>
<span className="text-sm leading-relaxed text-bright sm:text-base">
{item}
</span>
</li>
))}
</ul>
</Revelar>
</div>
</div>
</div>
</section>
{/* ---------- 4. Cumplimiento aplicable ---------- */}
<section
className="border-y border-rule-lite bg-paper text-deep"
aria-labelledby="cumplimiento-titulo"
>
<div className="grid-bg-lite">
<div className="shell py-16 sm:py-20">
<Revelar className="max-w-[44rem]">
<p className="eyebrow">Cumplimiento</p>
<h2
id="cumplimiento-titulo"
className="mt-4 font-display uppercase tracking-[-0.02em] text-deep"
style={{ fontSize: "clamp(1.5rem, 3.4vw, 2.2rem)" }}
>
Qué te van a revisar en esta operación
</h2>
</Revelar>
<Revelar className="mt-8">
<ul className="grid gap-px border border-rule-lite bg-rule-lite lg:grid-cols-3">
{industria.cumplimiento.map((item) => (
<li key={item} className="bg-paper p-5">
<p className="text-sm leading-relaxed text-deep">{item}</p>
</li>
))}
</ul>
</Revelar>
<Revelar className="mt-6 flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
<p className="max-w-[40rem] font-mono text-[0.6875rem] leading-relaxed text-deep-muted">
Referencia de obligaciones aplicables, no dictamen técnico ni jurídico. El alcance
definitivo depende de la clasificación de riesgo de tu centro de trabajo y de la
autoridad estatal o municipal que te verifique.
</p>
<Link href="/cumplimiento-nom-002" className="btn btn-deep btn-sm shrink-0">
Cómo funciona la NOM-002
</Link>
</Revelar>
</div>
</div>
</section>
{/* ---------- 5. Productos relevantes ---------- */}
<section className="bg-ink" aria-labelledby="equipo-titulo">
<div className="shell py-16 sm:py-20">
<Revelar className="max-w-[44rem]">
<p className="eyebrow">Equipo de esta ruta</p>
<h2
id="equipo-titulo"
className="mt-4 font-display uppercase tracking-[-0.02em] text-bright"
style={{ fontSize: "clamp(1.5rem, 3.4vw, 2.2rem)" }}
>
Categorías que se usan en {industria.nombre.toLowerCase()}
</h2>
</Revelar>
<div className="mt-10 space-y-12">
{bloquesCategoria.map(({ categoria, productos }) => {
const mostrados = productos.slice(0, 4);
return (
<Revelar key={categoria.slug}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-[40rem]">
<h3 className="font-display text-lg uppercase tracking-[-0.01em] text-bright">
{categoria.nombre}
</h3>
<p className="mt-2 text-sm leading-relaxed text-muted">
{categoria.tagline}
</p>
</div>
<Link
href={`/catalogo/${categoria.slug}`}
className="link shrink-0 font-mono text-[0.6875rem] uppercase tracking-[0.1em] text-muted"
>
{productos.length > mostrados.length
? `Ver los ${productos.length} equipos`
: "Ver la categoría"}
</Link>
</div>
<hr className="hair my-5" />
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{mostrados.map((producto) => (
<li key={producto.slug}>
<TarjetaProducto producto={producto} />
</li>
))}
</ul>
</Revelar>
);
})}
</div>
</div>
</section>
{/* ---------- 6. Formulario ---------- */}
<section
id="diagnostico"
className="scroll-mt-20 border-t border-rule bg-ink-2"
aria-labelledby="diagnostico-titulo"
>
<div className="shell py-16 sm:py-20">
<div className="grid gap-10 lg:grid-cols-12 lg:gap-14">
<Revelar className="lg:col-span-5">
<p className="eyebrow">Siguiente paso</p>
<h2
id="diagnostico-titulo"
className="mt-4 font-display uppercase tracking-[-0.02em] text-bright"
style={{ fontSize: "clamp(1.5rem, 3.4vw, 2.2rem)" }}
>
Diagnóstico para {industria.nombre.toLowerCase()}
</h2>
<p className="mt-4 text-muted">
Tu segmento y tus clases de fuego ya vienen cargados en el formulario. Solo
necesitamos saber dónde estás y qué tienes instalado hoy.
</p>
<hr className="hair my-7" />
<p className="font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
Contexto que viaja con tu solicitud
</p>
<dl className="spec mt-3 grid-cols-1 sm:grid-cols-2">
<div className="spec-cell">
<dt className="spec-key">Segmento</dt>
<dd className="spec-val">{industria.nombre}</dd>
</div>
<div className="spec-cell">
<dt className="spec-key">Clases declaradas</dt>
<dd className="spec-val">{clasesActivas.join(" · ")}</dd>
</div>
</dl>
<p className="mt-5 text-sm text-muted">
O márcanos:{" "}
<a
href={`tel:${EMPRESA.telefonoTel}`}
className="link font-mono text-sm text-bright"
>
{EMPRESA.telefono}
</a>
</p>
</Revelar>
<Revelar className="lg:col-span-7" delay={90}>
<FormularioContacto
canal="solucion"
titulo={`Diagnóstico · ${industria.nombre}`}
descripcion="Con el escenario descrito podemos responder qué agente aplica, en qué formato y qué te toca documentar, sin una segunda vuelta de correos."
industriaPreseleccionada={industria.slug}
clasesPreseleccionadas={clasesActivas}
/>
</Revelar>
</div>
</div>
</section>
{/* ---------- Otras rutas ---------- */}
<section className="border-t border-rule bg-ink" aria-labelledby="otras-titulo">
<div className="shell py-12 sm:py-14">
<h2
id="otras-titulo"
className="font-display text-base uppercase tracking-[0.01em] text-bright"
>
Otras rutas
</h2>
<ul className="mt-5 flex flex-wrap gap-2">
{INDUSTRIAS.filter((i) => i.slug !== industria.slug).map((otra) => (
<li key={otra.slug}>
<Link
href={`/soluciones/${otra.slug}`}
className="badge transition-colors hover:border-muted hover:text-bright"
>
{otra.nombre}
</Link>
</li>
))}
</ul>
</div>
</section>
</>
);
}
+97
View File
@@ -0,0 +1,97 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Revelar } from "@/components/site/Revelar";
import { FilaRuta } from "@/components/site/soluciones/FilaRuta";
import { CLASES } from "@/data/clases-fuego";
import { EMPRESA } from "@/data/empresa";
import { INDUSTRIAS } from "@/data/industrias";
/**
* ÍNDICE DE SOLUCIONES.
*
* Página breve y comparativa: los cinco segmentos prioritarios del estudio con
* el contexto suficiente para decidir por dónde entrar. No repite el catálogo,
* lo enruta.
*/
const DESCRIPCION =
"Cinco rutas de protección contra incendios por tipo de operación: industria y energía, logística y bodegas, gasolineras y flotas, cocinas y hoteles, PyMEs y oficinas. Cada una con su escenario, sus clases de fuego y lo que hay que documentar.";
export const metadata: Metadata = {
title: "Soluciones por industria",
description: DESCRIPCION,
alternates: { canonical: "/soluciones" },
openGraph: {
type: "website",
locale: "es_MX",
siteName: EMPRESA.nombre,
url: "/soluciones",
title: `Soluciones por industria | ${EMPRESA.nombreCorto}`,
description: DESCRIPCION,
},
};
export default function PaginaSoluciones() {
return (
<>
<section className="grid-bg border-b border-rule bg-ink" aria-labelledby="soluciones-titulo">
<div className="shell py-14 sm:py-18">
<Revelar className="max-w-[46rem]">
<p className="eyebrow">Rutas por industria</p>
<h1
id="soluciones-titulo"
className="mt-5 font-display font-black uppercase leading-[0.96] tracking-[-0.03em] text-bright"
style={{ fontSize: "clamp(1.9rem, 5.2vw, 3.4rem)" }}
>
El riesgo no es igual en una bodega que en una cocina
</h1>
<p className="mt-6 text-base leading-relaxed text-muted sm:text-lg">
Cada operación quema cosas distintas, responde con personas distintas y le rinde
cuentas a autoridades distintas. Estas son las {INDUSTRIAS.length} rutas por las que
entra la mayoría de nuestros proyectos, ordenadas por prioridad del estudio de
mercado.
</p>
<div className="mt-7 flex flex-wrap gap-3">
<Link href="/#clases-de-fuego" className="btn btn-ghost btn-sm">
Prefiero entrar por clase de fuego
</Link>
<Link href="/cumplimiento-nom-002" className="btn btn-ghost btn-sm">
Prefiero entrar por cumplimiento
</Link>
</div>
</Revelar>
</div>
</section>
<section className="bg-ink" aria-label="Listado de rutas por industria">
<div className="shell py-14 sm:py-16">
<ul>
{INDUSTRIAS.map((industria) => (
<FilaRuta key={industria.slug} industria={industria} />
))}
</ul>
</div>
</section>
<section className="border-t border-rule-lite bg-paper text-deep">
<div className="shell py-12 sm:py-14">
<Revelar className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="max-w-[42rem]">
<h2 className="font-display text-lg uppercase tracking-[-0.01em] text-deep sm:text-xl">
¿Tu operación no encaja limpio en ninguna?
</h2>
<p className="mt-3 text-sm leading-relaxed text-deep-muted">
Pasa seguido: un sitio mixto puede tener clase {CLASES.join(", ")} en el mismo
predio. Descríbenos el escenario y lo clasificamos contigo antes de proponer equipo.
</p>
</div>
<Link href="/contacto" className="btn btn-deep shrink-0">
Describir mi escenario
</Link>
</Revelar>
</div>
</section>
</>
);
}
+269
View File
@@ -0,0 +1,269 @@
import type { Metadata } from "next";
import { alcanceDe, exigirSesion } from "@/lib/crm/auth";
import { construirTablero, listarOportunidades } from "@/lib/crm/consultas";
import { ORIGEN_ETIQUETA } from "@/lib/crm/constantes";
import { INDUSTRIAS, getIndustria } from "@/data/industrias";
import { getProducto } from "@/data/catalogo";
import {
ContenedorTabla,
EncabezadoPagina,
EstadoVacio,
Nota,
Panel,
Td,
Th,
} from "@/components/admin/ui";
import { SelectorPeriodo, leerDias } from "@/components/admin/SelectorPeriodo";
import { TablaCampanas } from "@/components/admin/TablaCampanas";
import {
BarrasMagnitud,
Medidor,
type FilaBarra,
} from "@/components/admin/graficas/BarrasMagnitud";
import { Embudo } from "@/components/admin/graficas/Embudo";
import { SerieTiempo } from "@/components/admin/graficas/SerieTiempo";
import { entero, porcentajeDeFraccion } from "@/components/admin/formato";
import type { Consulta } from "@/components/admin/enlaces";
export const metadata: Metadata = {
title: "Analítica",
robots: { index: false, follow: false },
};
const RUTA = "/admin/analitica";
/** Tope de filas que se traen para el cruce por industria. */
const TOPE_MUESTRA = 1000;
type FilaIndustria = {
slug: string;
nombre: string;
oportunidades: number;
ganadas: number;
perdidas: number;
tasaCierre: number;
};
export default async function PaginaAnalitica({
searchParams,
}: {
searchParams: Promise<Consulta>;
}) {
const sp = await searchParams;
const dias = leerDias(sp);
const sesion = await exigirSesion();
const alcance = alcanceDe(sesion);
const [tablero, muestra] = await Promise.all([
construirTablero(alcance, dias),
// `Tablero` no trae desglose por industria, así que el cruce se arma aquí
// sobre el propio listado. Es una lectura acotada y suficiente para la
// escala de la demostración.
listarOportunidades(alcance, { pagina: 1, porPagina: TOPE_MUESTRA }),
]);
// ---------- cruce industria × tasa de cierre ----------
const acumulado = new Map<string, { oportunidades: number; ganadas: number; perdidas: number }>();
for (const o of muestra.filas) {
const clave = o.industria ?? "sin-industria";
const actual = acumulado.get(clave) ?? { oportunidades: 0, ganadas: 0, perdidas: 0 };
actual.oportunidades += 1;
if (o.etapa === "GANADA") actual.ganadas += 1;
if (o.etapa === "PERDIDA") actual.perdidas += 1;
acumulado.set(clave, actual);
}
const filasIndustria: FilaIndustria[] = [...acumulado.entries()]
.map(([slug, v]) => ({
slug,
nombre:
slug === "sin-industria"
? "Sin industria declarada"
: (getIndustria(slug)?.nombre ?? slug),
oportunidades: v.oportunidades,
ganadas: v.ganadas,
perdidas: v.perdidas,
tasaCierre: v.oportunidades > 0 ? v.ganadas / v.oportunidades : 0,
}))
.sort((a, b) => b.oportunidades - a.oportunidades);
const industriasSinLead = INDUSTRIAS.filter((i) => !acumulado.has(i.slug));
// ---------- productos más solicitados ----------
const filasProducto: FilaBarra[] = [...tablero.productos]
.sort((a, b) => b.cantidad - a.cantidad)
.slice(0, 12)
.map((p) => ({
clave: p.productoSlug ?? p.productoNombre,
etiqueta: p.productoNombre,
valor: p.cantidad,
destacado: p.ganadas,
detalle: p.productoSlug
? (getProducto(p.productoSlug)?.capacidad ?? undefined)
: undefined,
}));
// ---------- origen: formulario contra widget ----------
const filasOrigen: FilaBarra[] = tablero.origenes.map((o) => ({
clave: o.origen,
etiqueta: ORIGEN_ETIQUETA[o.origen],
valor: o.cantidad,
destacado: o.ganadas,
}));
const totalOrigen = tablero.origenes.reduce((s, o) => s + o.cantidad, 0);
return (
<div className="min-w-0">
<EncabezadoPagina
eyebrow="Panel comercial"
titulo="Analítica"
descripcion="La vista extendida del tablero: campañas con todo el detalle de atribución, cruce por industria, productos más solicitados y la comparación entre el formulario del sitio y el widget de WhatsApp."
acciones={<SelectorPeriodo ruta={RUTA} sp={sp} dias={dias} />}
/>
<div className="grid min-w-0 gap-4 sm:gap-5">
<div className="grid min-w-0 gap-4 sm:gap-5 xl:grid-cols-2">
<Panel titulo="Entradas y cierres" nota={`Por día, últimos ${dias} días`}>
<SerieTiempo serie={tablero.serie} dias={dias} />
</Panel>
<Panel titulo="Embudo" nota="Oportunidades por etapa y conversión entre tramos">
<Embudo embudo={tablero.embudo} />
</Panel>
</div>
<Panel
id="campanas"
titulo="Campañas con detalle de atribución"
nota="Fuente, medio y campaña por separado; ordena por cualquier columna"
>
<TablaCampanas campanas={tablero.campanas} ruta={RUTA} sp={sp} detallada />
</Panel>
<Panel
titulo="Industria contra tasa de cierre"
nota={`Sobre las últimas ${entero(Math.min(muestra.total, TOPE_MUESTRA))} oportunidades del alcance, sin recorte por periodo`}
>
{filasIndustria.length === 0 ? (
<EstadoVacio
titulo="Sin oportunidades que cruzar"
quehacer="Cuando entren envíos con industria declarada, esta tabla dirá en qué segmento se cierra mejor. Es el dato que decide dónde invertir el siguiente peso de publicidad."
/>
) : (
<>
<ContenedorTabla etiqueta="Industria contra tasa de cierre">
<table className="w-full min-w-[40rem] border-collapse text-left">
<caption className="sr-only">
Oportunidades, ganadas, perdidas y tasa de cierre por industria
</caption>
<thead>
<tr>
<Th>Industria</Th>
<Th numerico>Oportunidades</Th>
<Th numerico>Ganadas</Th>
<Th numerico>Perdidas</Th>
<Th numerico>Tasa de cierre</Th>
</tr>
</thead>
<tbody>
{filasIndustria.map((f) => (
<tr key={f.slug} className="transition-colors hover:bg-ink-3/50">
<Th scope="row" className="normal-case tracking-normal">
<span className="text-sm text-bright">{f.nombre}</span>
</Th>
<Td numerico>{entero(f.oportunidades)}</Td>
<Td numerico className="text-ok">
{entero(f.ganadas)}
</Td>
<Td numerico className="text-faint">
{entero(f.perdidas)}
</Td>
<Td className="text-right">
<Medidor
fraccion={f.tasaCierre}
etiqueta={`Tasa de cierre de ${f.nombre}`}
/>
</Td>
</tr>
))}
</tbody>
</table>
</ContenedorTabla>
{industriasSinLead.length > 0 ? (
<div className="px-4 py-3 sm:px-5">
<Nota>
Sin una sola oportunidad todavía:{" "}
{industriasSinLead.map((i) => i.nombre).join(", ")}. Son segmentos
prioritarios del estudio, así que un cero aquí es una pregunta de
distribución, no un dato de mercado.
</Nota>
</div>
) : null}
</>
)}
</Panel>
<div className="grid min-w-0 gap-4 sm:gap-5 xl:grid-cols-2">
<Panel
titulo="Formulario contra widget"
nota="Qué canal de captura trae más y qué canal cierra mejor"
>
<BarrasMagnitud
filas={filasOrigen}
unidad="Oportunidades"
etiquetaTotal="Oportunidades"
etiquetaDestacado="Ganadas"
vacio={{
titulo: "Sin envíos en el periodo",
quehacer:
"Ni el formulario ni el widget registraron envíos. Amplía el periodo a 90 días.",
}}
/>
{totalOrigen > 0 ? (
<div className="border-t border-rule px-4 py-3 sm:px-5">
<ul className="grid gap-1.5">
{tablero.origenes.map((o) => (
<li
key={o.origen}
className="flex flex-wrap items-baseline justify-between gap-x-3 font-mono text-[0.625rem] uppercase tracking-[0.1em] text-faint"
>
<span>{ORIGEN_ETIQUETA[o.origen]}</span>
<span>
{porcentajeDeFraccion(o.cantidad / totalOrigen)} del volumen · cierra{" "}
<span className="text-bright">
{o.cantidad > 0
? porcentajeDeFraccion(o.ganadas / o.cantidad)
: "—"}
</span>
</span>
</li>
))}
</ul>
</div>
) : null}
</Panel>
<Panel
titulo="Productos más solicitados"
nota="Lo que el lead estaba viendo al enviar, con la porción que terminó en venta"
>
<BarrasMagnitud
filas={filasProducto}
unidad="Oportunidades"
etiquetaTotal="Oportunidades"
etiquetaDestacado="Ganadas"
vacio={{
titulo: "Sin producto asociado",
quehacer:
"Los envíos del periodo no traen producto. Revisa que las fichas del catálogo pasen el slug al formulario: sin eso no se sabe qué se pide.",
}}
/>
</Panel>
</div>
</div>
</div>
);
}
@@ -0,0 +1,339 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { alcanceDe, exigirSesion } from "@/lib/crm/auth";
import { obtenerContacto } from "@/lib/crm/consultas";
import {
CANAL_ETIQUETA,
ORIGEN_ETIQUETA,
type Canal,
} from "@/lib/crm/constantes";
import { getIndustria } from "@/data/industrias";
import { parseClases } from "@/data/clases-fuego";
import { RielClases } from "@/components/marca/RielClases";
import {
ContenedorTabla,
EncabezadoPagina,
EnlaceFolio,
EstadoVacio,
InsigniaEstadoPedido,
InsigniaEtapa,
Nota,
Panel,
Spec,
Td,
Th,
} from "@/components/admin/ui";
import { NotasContacto } from "@/components/admin/contacto/NotasContacto";
import { entero, fecha, fechaHora, moneda, oRaya } from "@/components/admin/formato";
export const metadata: Metadata = {
title: "Contacto",
robots: { index: false, follow: false },
};
function etiquetaCanal(canal: Canal | null): string {
return canal ? (CANAL_ETIQUETA[canal] ?? canal) : "—";
}
export default async function PaginaContacto({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const sesion = await exigirSesion();
const alcance = alcanceDe(sesion);
const c = await obtenerContacto(alcance, id);
if (!c) notFound();
// Orden cronológico ascendente: el primer envío arriba, para que se lea la
// secuencia de la relación y no una pila invertida.
const oportunidades = [...c.oportunidades].sort(
(a, b) => new Date(a.creadoEn).getTime() - new Date(b.creadoEn).getTime(),
);
const primerToque = c.primerUtmCampaign;
const campanasPosteriores = oportunidades
.slice(1)
.map((o) => o.utmCampaign)
.filter((v): v is string => typeof v === "string" && v.length > 0);
const cambioDeCampana = campanasPosteriores.some((v) => v !== primerToque);
return (
<div className="min-w-0">
<p className="mb-4">
<Link href="/admin/contactos" className="link font-mono text-xs uppercase tracking-[0.1em]">
← Contactos
</Link>
</p>
<EncabezadoPagina
eyebrow="Contacto"
titulo={c.nombre}
descripcion={[
oRaya(c.empresa),
c.puesto ?? null,
c.industria ? (getIndustria(c.industria)?.nombre ?? c.industria) : null,
]
.filter((v): v is string => Boolean(v) && v !== "—")
.join(" · ")}
acciones={
<span className="flex flex-wrap items-center gap-2">
<span className={c.totalOportunidades > 1 ? "badge badge-signal" : "badge"}>
{entero(c.totalOportunidades)}{" "}
{c.totalOportunidades === 1 ? "envío" : "envíos"}
</span>
<span className="badge">Alta {fecha(c.creadoEn)}</span>
</span>
}
/>
<div className="grid min-w-0 gap-4 sm:gap-5 xl:grid-cols-[1fr_20rem]">
<div className="grid min-w-0 gap-4 sm:gap-5">
{/* atribución de primer toque contra cada envío */}
<Panel
titulo="Atribución de primer toque"
nota="Lo que trajo a esta persona por primera vez; no se sobreescribe nunca"
>
<div className="grid gap-3 px-4 py-4 sm:px-5">
<Spec
columnas={2}
datos={[
{ clave: "primer utm_source", valor: oRaya(c.primerUtmSource) },
{ clave: "primer utm_medium", valor: oRaya(c.primerUtmMedium) },
{ clave: "primer utm_campaign", valor: oRaya(c.primerUtmCampaign) },
{ clave: "primer landing", valor: oRaya(c.primerLandingPath) },
{ clave: "primer referrer", valor: oRaya(c.primerReferrer) },
]}
/>
{cambioDeCampana ? (
<Nota tono="aviso">
Los envíos posteriores de este contacto traen una campaña distinta a la del
primer toque. Es exactamente el caso que justifica guardar las dos capas: la
campaña que lo trajo no es la que lo hizo volver.
</Nota>
) : (
<Nota>
Los envíos posteriores repiten la campaña del primer toque, o llegaron sin
campaña. Con más historial esta comparación separa adquisición de reactivación.
</Nota>
)}
</div>
</Panel>
{/* historial de envíos */}
<Panel
titulo="Historial de envíos"
nota="Del más viejo al más reciente, con el canal, el producto y la campaña de cada uno"
>
{oportunidades.length === 0 ? (
<EstadoVacio
titulo="Sin envíos registrados"
quehacer="Este contacto existe pero no tiene oportunidades. Revisa si se creó a mano o si su envío falló al escribirse."
/>
) : (
<>
<div className="hidden lg:block">
<ContenedorTabla etiqueta="Historial de envíos del contacto">
<table className="w-full min-w-[58rem] border-collapse text-left">
<caption className="sr-only">
Oportunidades de este contacto en orden cronológico
</caption>
<thead>
<tr>
<Th numerico>#</Th>
<Th>Folio</Th>
<Th numerico>Fecha</Th>
<Th>Etapa</Th>
<Th>Origen y canal</Th>
<Th>Producto</Th>
<Th>Clases</Th>
<Th>Campaña del envío</Th>
</tr>
</thead>
<tbody>
{oportunidades.map((o, i) => {
const distinta =
i > 0 &&
o.utmCampaign != null &&
o.utmCampaign !== primerToque;
return (
<tr key={o.id} className="transition-colors hover:bg-ink-3/50">
<Td numerico className="text-faint">
{i + 1}
</Td>
<Td>
<EnlaceFolio
href={`/admin/oportunidades/${o.id}`}
folio={o.folio}
/>
</Td>
<Td numerico>{fecha(o.creadoEn)}</Td>
<Td>
<InsigniaEtapa etapa={o.etapa} sm />
</Td>
<Td mono>
<span className="block">{ORIGEN_ETIQUETA[o.origen]}</span>
<span className="block text-faint">
{etiquetaCanal(o.canal)}
</span>
</Td>
<Td className="max-w-[14rem]">
<span className="block truncate text-[0.8125rem]">
{oRaya(o.productoNombre)}
</span>
</Td>
<Td>
<RielClases
activas={parseClases(o.clases.join(","))}
tamano="sm"
/>
</Td>
<Td mono className="max-w-[12rem]">
<span
className={[
"block truncate",
distinta ? "text-ember" : "",
].join(" ")}
>
{oRaya(o.utmCampaign)}
</span>
{distinta ? (
<span className="block text-[0.5625rem] uppercase tracking-[0.08em] text-faint">
distinta al primer toque
</span>
) : null}
</Td>
</tr>
);
})}
</tbody>
</table>
</ContenedorTabla>
</div>
<ol className="divide-y divide-rule/60 lg:hidden">
{oportunidades.map((o, i) => (
<li key={o.id} className="min-w-0 px-4 py-3.5">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<span className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-faint">
Envío {i + 1}
</span>
<EnlaceFolio href={`/admin/oportunidades/${o.id}`} folio={o.folio} />
<InsigniaEtapa etapa={o.etapa} sm />
</div>
<p className="mt-1 font-mono text-[0.625rem] uppercase tracking-[0.08em] text-faint">
{fecha(o.creadoEn)} · {ORIGEN_ETIQUETA[o.origen]} ·{" "}
{etiquetaCanal(o.canal)}
</p>
<p className="mt-1 truncate text-[0.8125rem] text-bright">
{oRaya(o.productoNombre)}
</p>
<p className="mt-1 truncate font-mono text-[0.625rem] text-muted">
Campaña: {oRaya(o.utmCampaign)}
</p>
<div className="mt-2">
<RielClases activas={parseClases(o.clases.join(","))} tamano="sm" />
</div>
</li>
))}
</ol>
</>
)}
</Panel>
{/* pedidos */}
<Panel titulo="Pedidos" nota="Lo que este contacto ya compró">
{c.pedidos.length === 0 ? (
<EstadoVacio
titulo="Sin pedidos"
quehacer="Cuando una de sus oportunidades pase a ganada, levanta el pedido desde la propia ficha de la oportunidad."
/>
) : (
<ContenedorTabla etiqueta="Pedidos del contacto">
<table className="w-full min-w-[38rem] border-collapse text-left">
<caption className="sr-only">Pedidos de este contacto</caption>
<thead>
<tr>
<Th>Folio</Th>
<Th>Estado</Th>
<Th numerico>Partidas</Th>
<Th numerico>Total</Th>
<Th numerico>Alta</Th>
</tr>
</thead>
<tbody>
{c.pedidos.map((p) => (
<tr key={p.id} className="transition-colors hover:bg-ink-3/50">
<Td>
<EnlaceFolio href={`/admin/pedidos/${p.id}`} folio={p.folio} />
</Td>
<Td>
<InsigniaEstadoPedido estado={p.estado} />
</Td>
<Td numerico>{entero(p.totalPartidas)}</Td>
<Td numerico>{moneda(p.total)}</Td>
<Td numerico>{fecha(p.creadoEn)}</Td>
</tr>
))}
</tbody>
</table>
</ContenedorTabla>
)}
</Panel>
</div>
{/* ---------- columna lateral ---------- */}
<div className="grid min-w-0 gap-4 self-start sm:gap-5">
<Panel titulo="Datos de contacto">
<div className="px-4 py-4 sm:px-5">
<Spec
columnas={1}
datos={[
{ clave: "Empresa", valor: oRaya(c.empresa) },
{ clave: "Puesto", valor: oRaya(c.puesto) },
{
clave: "Correo",
valor: c.email ? (
<a href={`mailto:${c.email}`} className="link break-all">
{c.email}
</a>
) : (
"—"
),
},
{
clave: "Teléfono",
valor: c.telefono ? (
<a href={`tel:${c.telefono}`} className="link">
{c.telefono}
</a>
) : (
"—"
),
},
{ clave: "Ciudad", valor: oRaya(c.ciudad) },
{
clave: "Industria",
valor: c.industria
? (getIndustria(c.industria)?.nombre ?? c.industria)
: "—",
},
{ clave: "Propietario", valor: c.propietario?.nombre ?? "Sin asignar" },
{ clave: "Alta", valor: fechaHora(c.creadoEn) },
]}
/>
</div>
</Panel>
<Panel titulo="Notas">
<NotasContacto id={c.id} notas={c.notas} />
</Panel>
</div>
</div>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
import type { Metadata } from "next";
import Link from "next/link";
import { alcanceDe, exigirSesion } from "@/lib/crm/auth";
import { listarContactos } from "@/lib/crm/consultas";
import { getIndustria } from "@/data/industrias";
import {
ContenedorTabla,
EncabezadoPagina,
EstadoVacio,
Panel,
Td,
Th,
} from "@/components/admin/ui";
import { BarraFiltros } from "@/components/admin/BarraFiltros";
import { Paginacion } from "@/components/admin/Paginacion";
import { entero, fecha, oRaya } from "@/components/admin/formato";
import { param, paramEntero, type Consulta } from "@/components/admin/enlaces";
export const metadata: Metadata = {
title: "Contactos",
robots: { index: false, follow: false },
};
const RUTA = "/admin/contactos";
const POR_PAGINA = 25;
export default async function PaginaContactos({
searchParams,
}: {
searchParams: Promise<Consulta>;
}) {
const sp = await searchParams;
const sesion = await exigirSesion();
const alcance = alcanceDe(sesion);
const esAdmin = sesion.rol === "ADMIN";
const pagina = await listarContactos(alcance, {
q: param(sp, "q") || undefined,
pagina: paramEntero(sp, "pagina", 1),
porPagina: POR_PAGINA,
});
return (
<div className="min-w-0">
<EncabezadoPagina
eyebrow="Panel comercial"
titulo="Contactos"
descripcion="Una persona, un registro. Los envíos posteriores se acumulan sobre el mismo contacto en lugar de crear duplicados, así que aquí se ve el historial completo de cada cuenta."
/>
<Panel>
<BarraFiltros marcador="Nombre, empresa, correo o teléfono" />
{pagina.filas.length === 0 ? (
<EstadoVacio
titulo="Sin contactos con esta búsqueda"
quehacer="Prueba con el apellido o el dominio del correo. Si el listado está vacío del todo, todavía no ha entrado ningún envío del sitio."
/>
) : (
<>
{/* tabla en escritorio */}
<div className="hidden md:block">
<ContenedorTabla etiqueta="Contactos">
<table className="w-full min-w-[52rem] border-collapse text-left">
<caption className="sr-only">Contactos con su industria y envíos acumulados</caption>
<thead>
<tr>
<Th>Contacto</Th>
<Th>Empresa</Th>
<Th>Industria</Th>
<Th>Contacto directo</Th>
{esAdmin ? <Th>Propietario</Th> : null}
<Th numerico>Envíos</Th>
<Th>Primer toque</Th>
<Th numerico>Alta</Th>
</tr>
</thead>
<tbody>
{pagina.filas.map((c) => (
<tr key={c.id} className="transition-colors hover:bg-ink-3/50">
<Th scope="row" className="normal-case tracking-normal">
<Link
href={`/admin/contactos/${c.id}`}
className="text-sm font-medium text-bright underline decoration-rule-2 underline-offset-[3px] hover:decoration-signal"
style={{ WebkitTapHighlightColor: "transparent" }}
>
{c.nombre}
</Link>
</Th>
<Td>{oRaya(c.empresa)}</Td>
<Td>{c.industria ? (getIndustria(c.industria)?.nombre ?? c.industria) : "—"}</Td>
<Td mono className="max-w-[14rem]">
<span className="block truncate">{oRaya(c.email)}</span>
<span className="block truncate text-faint">{oRaya(c.telefono)}</span>
</Td>
{esAdmin ? <Td>{c.propietario?.nombre ?? "—"}</Td> : null}
<Td numerico>
<span
className={
c.totalOportunidades > 1 ? "font-semibold text-signal-hi" : ""
}
>
{entero(c.totalOportunidades)}
</span>
</Td>
<Td mono className="max-w-[11rem]">
<span className="block truncate">{oRaya(c.primerUtmCampaign)}</span>
</Td>
<Td numerico>{fecha(c.creadoEn)}</Td>
</tr>
))}
</tbody>
</table>
</ContenedorTabla>
</div>
{/* tarjetas en móvil */}
<ul className="divide-y divide-rule/60 md:hidden">
{pagina.filas.map((c) => (
<li key={c.id} className="min-w-0">
<Link
href={`/admin/contactos/${c.id}`}
className="block min-w-0 px-4 py-3.5 transition-colors hover:bg-ink-3/50"
style={{ WebkitTapHighlightColor: "transparent" }}
>
<span className="flex min-w-0 items-baseline justify-between gap-3">
<span className="min-w-0 truncate text-sm font-medium text-bright">
{c.nombre}
</span>
<span
className={[
"shrink-0 font-mono text-[0.625rem] uppercase tracking-[0.08em]",
c.totalOportunidades > 1 ? "text-signal-hi" : "text-faint",
].join(" ")}
>
{entero(c.totalOportunidades)}{" "}
{c.totalOportunidades === 1 ? "envío" : "envíos"}
</span>
</span>
<span className="mt-0.5 block truncate text-xs text-muted">
{oRaya(c.empresa)}
{c.industria
? ` · ${getIndustria(c.industria)?.nombre ?? c.industria}`
: ""}
</span>
<span className="mt-1 block truncate font-mono text-[0.625rem] text-faint">
{oRaya(c.email)}
</span>
</Link>
</li>
))}
</ul>
</>
)}
<Paginacion
ruta={RUTA}
sp={sp}
pagina={pagina.pagina}
paginas={pagina.paginas}
total={pagina.total}
porPagina={pagina.porPagina}
etiqueta="contactos"
/>
</Panel>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { exigirAdmin, exigirSesion } from "@/lib/crm/auth";
import { listarEquipo } from "@/lib/crm/consultas";
import type { UsuarioResumen } from "@/lib/crm/tipos";
import { EncabezadoPagina, Nota, Panel } from "@/components/admin/ui";
import { TablaEquipo } from "@/components/admin/TablaEquipo";
import {
AlternarUsuario,
FormularioUsuario,
} from "@/components/admin/equipo/FormulariosEquipo";
import { conParametros, param, type Consulta } from "@/components/admin/enlaces";
export const metadata: Metadata = {
title: "Equipo",
robots: { index: false, follow: false },
};
const RUTA = "/admin/equipo";
export default async function PaginaEquipo({
searchParams,
}: {
searchParams: Promise<Consulta>;
}) {
const sp = await searchParams;
// Página exclusiva de administración: un vendedor no la ve en la navegación
// y aquí se le niega el paso de todos modos. Para él la ruta simplemente no
// existe —un 404 no confirma que exista un panel de administración—, y así se
// evita además la pantalla de error cruda de `exigirAdmin`.
const sesion = await exigirSesion();
if (sesion.rol !== "ADMIN") notFound();
await exigirAdmin();
const equipo = await listarEquipo();
const idEditar = param(sp, "editar");
const nuevo = param(sp, "nuevo") === "1";
const enEdicion = idEditar ? equipo.find((u) => u.id === idEditar) : undefined;
const resumen: UsuarioResumen | undefined = enEdicion
? {
id: enEdicion.id,
nombre: enEdicion.nombre,
email: enEdicion.email,
rol: enEdicion.rol,
puesto: enEdicion.puesto,
activo: enEdicion.activo,
}
: undefined;
const activos = equipo.filter((u) => u.activo).length;
const vendedoresActivos = equipo.filter((u) => u.activo && u.rol === "VENDEDOR").length;
return (
<div className="min-w-0">
<EncabezadoPagina
eyebrow="Administración"
titulo="Equipo"
descripcion="Quién entra al panel, con qué rol y con qué carga. Desactivar una cuenta le quita el acceso sin borrar su historial ni reasignar sus oportunidades."
acciones={
!nuevo && !resumen ? (
<Link
href={conParametros(RUTA, sp, { nuevo: 1, editar: undefined })}
className="btn btn-signal btn-sm"
style={{ WebkitTapHighlightColor: "transparent" }}
>
Dar de alta
</Link>
) : undefined
}
/>
<div className="grid min-w-0 gap-4 sm:gap-5">
{resumen ? (
<Panel
titulo={`Editar a ${resumen.nombre}`}
nota="La contraseña actual no se muestra; se puede reemplazar, no consultar"
>
<FormularioUsuario usuario={resumen} onListo={RUTA} />
</Panel>
) : nuevo ? (
<Panel titulo="Alta de usuario" nota="El rol define el alcance de lo que va a ver">
<FormularioUsuario onListo={RUTA} />
</Panel>
) : null}
<Panel
titulo="Carga por integrante"
nota={`${activos} de ${equipo.length} cuentas activas · ${vendedoresActivos} vendedores reciben asignación automática`}
>
<TablaEquipo
equipo={equipo}
mostrarEstado
acciones={(u) => (
<span className="flex flex-wrap items-start gap-2">
<Link
href={conParametros(RUTA, sp, { editar: u.id, nuevo: undefined })}
className="btn btn-ghost btn-sm whitespace-nowrap"
style={{ WebkitTapHighlightColor: "transparent" }}
>
Editar
<span className="sr-only"> a {u.nombre}</span>
</Link>
<AlternarUsuario id={u.id} activo={u.activo} nombre={u.nombre} />
</span>
)}
/>
</Panel>
{vendedoresActivos === 0 ? (
<Nota tono="aviso">
No hay vendedores activos. Con la tabla de reglas vacía o sin coincidencias, el
reparto por turno no tiene a quién asignarle y las oportunidades nuevas van a
quedar sin propietario.
</Nota>
) : null}
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { exigirSesion } from "@/lib/crm/auth";
import { Cascaron } from "@/components/admin/Cascaron";
/**
* Cascarón autenticado del panel: barra lateral fija en escritorio, cajón
* deslizable en móvil, franja de demostración y salida de sesión.
*
* Toda ruta bajo este layout exige sesión. La navegación se recorta por rol:
* un vendedor no ve —ni siquiera como enlace— Equipo ni Reglas, y las páginas
* correspondientes vuelven a exigir `ADMIN` por su cuenta.
*/
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
export default async function LayoutPanel({ children }: { children: React.ReactNode }) {
const sesion = await exigirSesion();
return (
<Cascaron nombre={sesion.nombre} rol={sesion.rol}>
{children}
</Cascaron>
);
}
@@ -0,0 +1,324 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { alcanceDe, exigirSesion } from "@/lib/crm/auth";
import { listarEquipo, obtenerOportunidad } from "@/lib/crm/consultas";
import {
CANAL_ETIQUETA,
ORIGEN_ETIQUETA,
type Canal,
} from "@/lib/crm/constantes";
import { parseClases } from "@/data/clases-fuego";
import { getIndustria } from "@/data/industrias";
import { getProducto } from "@/data/catalogo";
import { RielClases } from "@/components/marca/RielClases";
import {
EncabezadoPagina,
InsigniaEtapa,
InsigniaPrioridad,
Nota,
Panel,
Spec,
} from "@/components/admin/ui";
import { antiguedad, fechaHora, moneda, oRaya } from "@/components/admin/formato";
import {
AsignarPropietario,
CambiarEtapa,
CambiarPrioridad,
EditarValor,
MarcarPrimeraRespuesta,
RegistrarActividad,
} from "@/components/admin/oportunidad/Controles";
import { Bitacora } from "@/components/admin/oportunidad/Bitacora";
import { CrearPedido } from "@/components/admin/oportunidad/CrearPedido";
export const metadata: Metadata = {
title: "Oportunidad",
robots: { index: false, follow: false },
};
function etiquetaCanal(canal: Canal | null): string {
return canal ? (CANAL_ETIQUETA[canal] ?? canal) : "—";
}
export default async function PaginaOportunidad({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const sesion = await exigirSesion();
const alcance = alcanceDe(sesion);
const esAdmin = sesion.rol === "ADMIN";
// La consulta ya filtró por alcance: un vendedor pidiendo una oportunidad
// ajena recibe null y aquí se convierte en 404. No hay fuga por URL.
const o = await obtenerOportunidad(alcance, id);
if (!o) notFound();
const equipo = esAdmin ? await listarEquipo() : [];
const clases = parseClases(o.clases.join(","));
const producto = o.productoSlug ? getProducto(o.productoSlug) : undefined;
const industria = o.industria ? getIndustria(o.industria) : undefined;
const a = o.atribucion;
return (
<div className="min-w-0">
<p className="mb-4">
<Link href="/admin/oportunidades" className="link font-mono text-xs uppercase tracking-[0.1em]">
← Oportunidades
</Link>
</p>
<EncabezadoPagina
eyebrow={`Oportunidad · ${ORIGEN_ETIQUETA[o.origen]}`}
titulo={o.folio}
descripcion={`${o.contacto.nombre}${o.contacto.empresa ? ` · ${o.contacto.empresa}` : ""} · entró hace ${antiguedad(o.creadoEn)}`}
acciones={
<span className="flex flex-wrap items-center gap-2">
<InsigniaEtapa etapa={o.etapa} />
<InsigniaPrioridad prioridad={o.prioridad} />
</span>
}
/>
{o.etapa === "PERDIDA" ? (
<div className="mb-5">
<Nota tono="aviso">
Oportunidad perdida{o.motivoPerdida ? `: ${o.motivoPerdida}` : ""}.
{o.cerradoEn ? ` Cerrada el ${fechaHora(o.cerradoEn)}.` : ""}
</Nota>
</div>
) : null}
<div className="grid min-w-0 gap-4 sm:gap-5 xl:grid-cols-[1fr_20rem]">
{/* ---------- columna principal ---------- */}
<div className="grid min-w-0 gap-4 sm:gap-5">
{/* riesgo declarado */}
<Panel titulo="Riesgo declarado" nota="Las clases de fuego que el propio lead marcó en el sitio">
<div className="grid gap-4 px-4 py-4 sm:px-5">
<RielClases activas={clases} tamano="md" etiqueta="Clases" />
<Spec
columnas={3}
datos={[
{ clave: "Industria", valor: industria?.nombre ?? oRaya(o.industria) },
{ clave: "Superficie", valor: oRaya(o.superficie) },
{ clave: "Urgencia", valor: oRaya(o.urgencia) },
]}
/>
{clases.length === 0 ? (
<Nota>
El lead no declaró clases de fuego. Es la primera pregunta del diagnóstico:
sin clase no se puede recomendar agente.
</Nota>
) : null}
</div>
</Panel>
{/* producto de interés */}
<Panel titulo="Producto de interés" nota="Lo que el lead estaba viendo cuando envió">
<div className="grid gap-3 px-4 py-4 sm:px-5">
{o.productoNombre ? (
<>
<p className="text-base font-medium text-bright">{o.productoNombre}</p>
{producto ? (
<Spec
columnas={3}
datos={[
{ clave: "Modelo", valor: oRaya(producto.modelo) },
{ clave: "Capacidad", valor: oRaya(producto.capacidad) },
{ clave: "Agente", valor: oRaya(producto.agente) },
]}
/>
) : null}
{o.productoUrl ? (
<p>
<a
href={o.productoUrl}
target="_blank"
rel="noopener noreferrer"
className="link break-all font-mono text-xs"
>
{o.productoUrl}
<span className="sr-only"> (abre en una pestaña nueva)</span>
</a>
</p>
) : null}
{producto ? (
<p>
<Link href={`/producto/${producto.slug}`} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
Ver ficha en el sitio
<span className="sr-only"> de {producto.nombre} (abre en una pestaña nueva)</span>
</Link>
</p>
) : null}
{producto && producto.claimsPorVerificar.length > 0 ? (
<Nota tono="aviso">
Este producto tiene {producto.claimsPorVerificar.length} afirmaciones por
verificar con el fabricante. No las uses como argumento de venta sin la
carta técnica: es lo que separa a FSPM de la competencia.
</Nota>
) : null}
</>
) : (
<p className="text-sm text-muted">
El envío no traía producto asociado. Empieza por la clase de fuego y la
superficie para acotar la recomendación.
</p>
)}
</div>
</Panel>
{/* mensaje del lead */}
{o.mensaje ? (
<Panel titulo="Mensaje del lead">
<p className="px-4 py-4 text-sm leading-relaxed text-bright sm:px-5">{o.mensaje}</p>
</Panel>
) : null}
{/* atribución completa: la capa de datos */}
<Panel
titulo="Atribución"
nota="Toda la cadena de campaña de este envío, tal como se capturó"
>
<div className="px-4 py-4 sm:px-5">
<Spec
columnas={3}
datos={[
{ clave: "utm_source", valor: oRaya(a.utmSource) },
{ clave: "utm_medium", valor: oRaya(a.utmMedium) },
{ clave: "utm_campaign", valor: oRaya(a.utmCampaign) },
{ clave: "utm_term", valor: oRaya(a.utmTerm) },
{ clave: "utm_content", valor: oRaya(a.utmContent) },
{ clave: "gclid", valor: oRaya(a.gclid) },
{ clave: "fbclid", valor: oRaya(a.fbclid) },
{ clave: "referrer", valor: oRaya(a.referrer) },
{ clave: "landing", valor: oRaya(a.landingPath) },
{ clave: "página del envío", valor: oRaya(a.paginaUrl) },
{ clave: "dispositivo", valor: oRaya(a.dispositivo) },
{ clave: "canal", valor: etiquetaCanal(o.canal) },
]}
/>
</div>
</Panel>
{/* bitácora */}
<Panel titulo="Bitácora" nota="En orden cronológico, de lo más viejo a lo más reciente">
<Bitacora actividades={o.actividades} />
<RegistrarActividad id={o.id} />
</Panel>
{/* pedido, solo desde ganada */}
{o.etapa === "GANADA" ? (
<Panel titulo="Pedido" nota="Partidas del catálogo, con subtotal, IVA y total en vivo">
<CrearPedido oportunidadId={o.id} productoSlugSugerido={o.productoSlug} />
</Panel>
) : null}
</div>
{/* ---------- columna lateral ---------- */}
<div className="grid min-w-0 gap-4 self-start sm:gap-5">
<Panel titulo="Gestión">
<div className="grid gap-4 px-4 py-4 sm:px-5">
<CambiarEtapa id={o.id} etapa={o.etapa} />
<hr className="hair" />
<CambiarPrioridad id={o.id} prioridad={o.prioridad} />
<hr className="hair" />
{esAdmin ? (
<>
<AsignarPropietario
id={o.id}
propietarioId={o.propietario?.id ?? null}
equipo={equipo.map((u) => ({
id: u.id,
nombre: u.nombre,
activo: u.activo,
}))}
/>
<hr className="hair" />
</>
) : (
<div>
<p className="field-label">Propietario</p>
<p className="font-mono text-sm text-bright">
{o.propietario?.nombre ?? "Sin asignar"}
</p>
</div>
)}
<EditarValor id={o.id} valor={o.valorEstimado} />
<hr className="hair" />
<MarcarPrimeraRespuesta id={o.id} horasSinRespuesta={o.horasSinRespuesta} />
</div>
</Panel>
<Panel titulo="Contacto">
<div className="grid gap-3 px-4 py-4 sm:px-5">
<p className="text-base font-medium text-bright">{o.contactoCompleto.nombre}</p>
<Spec
columnas={1}
datos={[
{ clave: "Empresa", valor: oRaya(o.contactoCompleto.empresa) },
{
clave: "Correo",
valor: o.contactoCompleto.email ? (
<a href={`mailto:${o.contactoCompleto.email}`} className="link break-all">
{o.contactoCompleto.email}
</a>
) : (
"—"
),
},
{
clave: "Teléfono",
valor: o.contactoCompleto.telefono ? (
<a href={`tel:${o.contactoCompleto.telefono}`} className="link">
{o.contactoCompleto.telefono}
</a>
) : (
"—"
),
},
{ clave: "Ciudad", valor: oRaya(o.contactoCompleto.ciudad) },
{
clave: "Envíos acumulados",
valor: String(o.contactoCompleto.totalOportunidades),
},
{
clave: "Primer toque",
valor: oRaya(o.contactoCompleto.primerUtmCampaign),
},
]}
/>
<Link
href={`/admin/contactos/${o.contactoCompleto.id}`}
className="btn btn-ghost btn-sm"
style={{ WebkitTapHighlightColor: "transparent" }}
>
Ver historial completo
</Link>
</div>
</Panel>
<Panel titulo="Trazabilidad">
<div className="px-4 py-4 sm:px-5">
<Spec
columnas={1}
datos={[
{ clave: "Creada", valor: fechaHora(o.creadoEn) },
{ clave: "Actualizada", valor: fechaHora(o.actualizadoEn) },
{
clave: "Primera respuesta",
valor: o.primeraRespuestaEn ? fechaHora(o.primeraRespuestaEn) : "Pendiente",
},
{ clave: "Cerrada", valor: o.cerradoEn ? fechaHora(o.cerradoEn) : "—" },
{ clave: "Valor estimado", valor: moneda(o.valorEstimado) },
]}
/>
</div>
</Panel>
</div>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
import type { Metadata } from "next";
import { alcanceDe, exigirSesion } from "@/lib/crm/auth";
import {
construirTablero,
listarEquipo,
listarOportunidades,
} from "@/lib/crm/consultas";
import { ETAPAS, ORIGENES } from "@/lib/crm/constantes";
import type { FiltroOportunidades } from "@/lib/crm/tipos";
import { INDUSTRIAS } from "@/data/industrias";
import { EncabezadoPagina, Panel } from "@/components/admin/ui";
import { FiltrosOportunidades, type OpcionSimple } from "@/components/admin/FiltrosOportunidades";
import { TablaOportunidades } from "@/components/admin/TablaOportunidades";
import { Paginacion } from "@/components/admin/Paginacion";
import {
param,
paramEntero,
paramEnum,
type Consulta,
} from "@/components/admin/enlaces";
export const metadata: Metadata = {
title: "Oportunidades",
robots: { index: false, follow: false },
};
const RUTA = "/admin/oportunidades";
const POR_PAGINA = 25;
export default async function PaginaOportunidades({
searchParams,
}: {
searchParams: Promise<Consulta>;
}) {
const sp = await searchParams;
const sesion = await exigirSesion();
const alcance = alcanceDe(sesion);
const esAdmin = sesion.rol === "ADMIN";
const filtro: FiltroOportunidades = {
etapa: paramEnum(sp, "etapa", ETAPAS),
origen: paramEnum(sp, "origen", ORIGENES),
industria: param(sp, "industria") || undefined,
utmCampaign: param(sp, "campana") || undefined,
// Un vendedor no puede filtrar por propietario: su alcance ya lo hace.
propietarioId: esAdmin ? param(sp, "propietario") || undefined : undefined,
q: param(sp, "q") || undefined,
soloAbiertas: param(sp, "abiertas") === "1" ? true : undefined,
pagina: paramEntero(sp, "pagina", 1),
porPagina: POR_PAGINA,
};
const [pagina, tableroAmplio, equipo] = await Promise.all([
listarOportunidades(alcance, filtro),
// Las campañas del selector salen de una ventana amplia: filtrar por una
// campaña que ya no aparece en 30 días seguiría siendo útil.
construirTablero(alcance, 90),
esAdmin ? listarEquipo() : Promise.resolve([]),
]);
const industrias: OpcionSimple[] = INDUSTRIAS.map((i) => ({
valor: i.slug,
etiqueta: i.nombre,
}));
const campanas: OpcionSimple[] = Array.from(
new Set(
tableroAmplio.campanas
.map((c) => c.utmCampaign)
.filter((c): c is string => typeof c === "string" && c.length > 0),
),
)
.sort((a, b) => a.localeCompare(b, "es-MX"))
.map((c) => ({ valor: c, etiqueta: c }));
const propietarios: OpcionSimple[] | undefined = esAdmin
? equipo.map((u) => ({ valor: u.id, etiqueta: u.nombre }))
: undefined;
return (
<div className="min-w-0">
<EncabezadoPagina
eyebrow="Panel comercial"
titulo="Oportunidades"
descripcion={
esAdmin
? "Todos los envíos del sitio convertidos en oportunidad. Los filtros viajan en la URL, así que esta vista se puede compartir tal cual."
: "Los envíos asignados a tu nombre. Los filtros viajan en la URL, así que esta vista se puede compartir tal cual."
}
/>
<Panel>
<FiltrosOportunidades
industrias={industrias}
campanas={campanas}
propietarios={propietarios}
/>
<TablaOportunidades filas={pagina.filas} mostrarPropietario={esAdmin} />
<Paginacion
ruta={RUTA}
sp={sp}
pagina={pagina.pagina}
paginas={pagina.paginas}
total={pagina.total}
porPagina={pagina.porPagina}
etiqueta="oportunidades"
/>
</Panel>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
import type { Metadata } from "next";
import { alcanceDe, exigirSesion } from "@/lib/crm/auth";
import { construirTablero } from "@/lib/crm/consultas";
import { CANAL_ETIQUETA, ORIGEN_ETIQUETA, type Canal } from "@/lib/crm/constantes";
import { EncabezadoPagina, Panel } from "@/components/admin/ui";
import { FilaKpis } from "@/components/admin/graficas/TarjetaKpiVista";
import { SerieTiempo } from "@/components/admin/graficas/SerieTiempo";
import { Embudo } from "@/components/admin/graficas/Embudo";
import {
BarrasMagnitud,
type FilaBarra,
} from "@/components/admin/graficas/BarrasMagnitud";
import { PanelSinAtender } from "@/components/admin/PanelSinAtender";
import { TablaCampanas } from "@/components/admin/TablaCampanas";
import { TablaEquipo } from "@/components/admin/TablaEquipo";
import { SelectorPeriodo, leerDias } from "@/components/admin/SelectorPeriodo";
import type { Consulta } from "@/components/admin/enlaces";
export const metadata: Metadata = {
title: "Tablero",
robots: { index: false, follow: false },
};
const RUTA = "/admin";
function etiquetaCanal(canal: string): string {
return CANAL_ETIQUETA[canal as Canal] ?? canal;
}
export default async function PaginaTablero({
searchParams,
}: {
searchParams: Promise<Consulta>;
}) {
const sp = await searchParams;
const dias = leerDias(sp);
const sesion = await exigirSesion();
const alcance = alcanceDe(sesion);
const tablero = await construirTablero(alcance, dias);
const esAdmin = sesion.rol === "ADMIN";
const filasOrigen: FilaBarra[] = tablero.origenes.map((o) => ({
clave: o.origen,
etiqueta: ORIGEN_ETIQUETA[o.origen],
valor: o.cantidad,
destacado: o.ganadas,
}));
const filasCanal: FilaBarra[] = [...tablero.canales]
.sort((a, b) => b.cantidad - a.cantidad)
.map((c) => ({
clave: c.canal,
etiqueta: etiquetaCanal(c.canal),
valor: c.cantidad,
}));
return (
<div className="min-w-0">
<EncabezadoPagina
eyebrow="Panel comercial"
titulo="Tablero"
descripcion={
esAdmin
? "Todo el embudo, todas las campañas y la carga del equipo. Los indicadores se comparan contra el periodo inmediatamente anterior."
: "Tu embudo y tus campañas. Solo aparece lo que está asignado a tu nombre."
}
acciones={<SelectorPeriodo ruta={RUTA} sp={sp} dias={dias} />}
/>
<div className="grid min-w-0 gap-4 sm:gap-5">
{/* 1 · KPIs */}
<FilaKpis kpis={tablero.kpis} dias={dias} />
{/* 4 · sin atender, arriba porque es lo que hay que hacer hoy */}
<Panel
titulo="Sin atender"
nota="Oportunidades sin primera respuesta, las más viejas primero"
>
<PanelSinAtender filas={tablero.sinAtender} />
</Panel>
{/* 2 y 3 · serie y embudo */}
<div className="grid min-w-0 gap-4 sm:gap-5 xl:grid-cols-2">
<Panel titulo="Entradas y cierres" nota={`Por día, últimos ${dias} días`}>
<SerieTiempo serie={tablero.serie} dias={dias} />
</Panel>
<Panel titulo="Embudo" nota="Oportunidades por etapa y conversión entre tramos">
<Embudo embudo={tablero.embudo} />
</Panel>
</div>
{/* 5 · campañas */}
<Panel
id="campanas"
titulo="Efectividad por campaña"
nota="Ordena por cualquier columna; el orden viaja en la URL"
>
<TablaCampanas campanas={tablero.campanas} ruta={RUTA} sp={sp} limite={8} />
</Panel>
{/* 6 · origen y canal */}
<div className="grid min-w-0 gap-4 sm:gap-5 xl:grid-cols-2">
<Panel titulo="Origen" nota="Formulario del sitio contra widget de WhatsApp">
<BarrasMagnitud
filas={filasOrigen}
unidad="Oportunidades"
etiquetaTotal="Oportunidades"
etiquetaDestacado="Ganadas"
vacio={{
titulo: "Sin envíos en el periodo",
quehacer:
"Ni el formulario ni el widget registraron envíos. Amplía el periodo o revisa que la captura del sitio esté escribiendo en el CRM.",
}}
/>
</Panel>
<Panel titulo="Canal" nota="Página desde la que se envió">
<BarrasMagnitud
filas={filasCanal}
unidad="Oportunidades"
vacio={{
titulo: "Sin canal registrado",
quehacer:
"Los envíos del periodo no traen canal. Revisa que cada formulario del sitio declare desde qué página se envía.",
}}
/>
</Panel>
</div>
{/* 7 · equipo, solo ADMIN */}
{esAdmin ? (
<Panel titulo="Equipo" nota="Carga abierta, cierres y tiempo de primera respuesta">
<TablaEquipo equipo={tablero.equipo} />
</Panel>
) : null}
</div>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { alcanceDe, exigirSesion } from "@/lib/crm/auth";
import { obtenerPedido } from "@/lib/crm/consultas";
import { IVA } from "@/lib/crm/constantes";
import { getProducto } from "@/data/catalogo";
import {
ContenedorTabla,
EncabezadoPagina,
EstadoVacio,
InsigniaEstadoPedido,
Panel,
Spec,
Td,
Th,
} from "@/components/admin/ui";
import { CambiarEstadoPedido } from "@/components/admin/pedido/CambiarEstadoPedido";
import { entero, fecha, fechaHora, moneda, oRaya } from "@/components/admin/formato";
export const metadata: Metadata = {
title: "Pedido",
robots: { index: false, follow: false },
};
export default async function PaginaPedido({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const sesion = await exigirSesion();
const alcance = alcanceDe(sesion);
const p = await obtenerPedido(alcance, id);
if (!p) notFound();
return (
<div className="min-w-0">
<p className="mb-4">
<Link href="/admin/pedidos" className="link font-mono text-xs uppercase tracking-[0.1em]">
← Pedidos
</Link>
</p>
<EncabezadoPagina
eyebrow={`Pedido · ${p.moneda}`}
titulo={p.folio}
descripcion={`${p.contacto.nombre}${p.contacto.empresa ? ` · ${p.contacto.empresa}` : ""} · alta ${fecha(p.creadoEn)}`}
acciones={<InsigniaEstadoPedido estado={p.estado} />}
/>
<div className="grid min-w-0 gap-4 sm:gap-5 xl:grid-cols-[1fr_20rem]">
<div className="grid min-w-0 gap-4 sm:gap-5">
<Panel titulo="Partidas" nota="Productos del catálogo, cantidad y precio unitario acordado">
{p.items.length === 0 ? (
<EstadoVacio
titulo="Pedido sin partidas"
quehacer="Este pedido se guardó vacío. Cancélalo y levanta uno nuevo desde la oportunidad de origen."
/>
) : (
<ContenedorTabla etiqueta="Partidas del pedido">
<table className="w-full min-w-[38rem] border-collapse text-left">
<caption className="sr-only">Partidas del pedido con su importe</caption>
<thead>
<tr>
<Th>Producto</Th>
<Th numerico>Cantidad</Th>
<Th numerico>Precio unitario</Th>
<Th numerico>Importe</Th>
</tr>
</thead>
<tbody>
{p.items.map((it, i) => {
const producto = it.productoSlug ? getProducto(it.productoSlug) : undefined;
return (
<tr key={`${it.productoSlug ?? "libre"}-${i}`}>
<Th scope="row" className="normal-case tracking-normal">
<span className="block text-sm text-bright">{it.nombre}</span>
{producto ? (
<span className="mt-0.5 block font-mono text-[0.625rem] normal-case tracking-[0.04em] text-faint">
{oRaya(producto.modelo)} · {oRaya(producto.capacidad)}
</span>
) : null}
</Th>
<Td numerico>{entero(it.cantidad)}</Td>
<Td numerico>{moneda(it.precioUnitario)}</Td>
<Td numerico className="font-semibold text-bright">
{moneda(it.cantidad * it.precioUnitario)}
</Td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<Th scope="row" className="text-right normal-case tracking-normal" >
Subtotal
</Th>
<Td numerico />
<Td numerico />
<Td numerico>{moneda(p.subtotal)}</Td>
</tr>
<tr>
<Th scope="row" className="text-right normal-case tracking-normal">
IVA {Math.round(IVA * 100)} %
</Th>
<Td numerico />
<Td numerico />
<Td numerico>{moneda(p.iva)}</Td>
</tr>
<tr>
<Th scope="row" className="text-right normal-case tracking-normal">
Total
</Th>
<Td numerico />
<Td numerico />
<Td numerico className="text-base font-semibold text-signal-hi">
{moneda(p.total)}
</Td>
</tr>
</tfoot>
</table>
</ContenedorTabla>
)}
</Panel>
{p.notas ? (
<Panel titulo="Notas del pedido">
<p className="px-4 py-4 text-sm leading-relaxed text-bright sm:px-5">{p.notas}</p>
</Panel>
) : null}
<Panel titulo="Origen" nota="De dónde salió esta venta">
<div className="grid gap-3 px-4 py-4 sm:px-5">
<Spec
columnas={2}
datos={[
{ clave: "Oportunidad", valor: oRaya(p.oportunidadFolio) },
{ clave: "Contacto", valor: p.contacto.nombre },
{ clave: "Empresa", valor: oRaya(p.contacto.empresa) },
{ clave: "Propietario", valor: p.propietario?.nombre ?? "Sin asignar" },
]}
/>
<div className="flex flex-wrap gap-2">
{p.oportunidadFolio ? (
<Link
href={`/admin/oportunidades?q=${encodeURIComponent(p.oportunidadFolio)}`}
className="btn btn-ghost btn-sm"
style={{ WebkitTapHighlightColor: "transparent" }}
>
Ver la oportunidad {p.oportunidadFolio}
</Link>
) : null}
<Link
href={`/admin/contactos/${p.contacto.id}`}
className="btn btn-ghost btn-sm"
style={{ WebkitTapHighlightColor: "transparent" }}
>
Ver el contacto
</Link>
</div>
</div>
</Panel>
</div>
<div className="grid min-w-0 gap-4 self-start sm:gap-5">
<Panel titulo="Gestión">
<div className="px-4 py-4 sm:px-5">
<CambiarEstadoPedido id={p.id} estado={p.estado} />
</div>
</Panel>
<Panel titulo="Resumen">
<div className="px-4 py-4 sm:px-5">
<Spec
columnas={1}
datos={[
{ clave: "Moneda", valor: p.moneda },
{ clave: "Partidas", valor: entero(p.totalPartidas) },
{ clave: "Subtotal", valor: moneda(p.subtotal) },
{ clave: `IVA ${Math.round(IVA * 100)} %`, valor: moneda(p.iva) },
{ clave: "Total", valor: moneda(p.total) },
{
clave: "Entrega estimada",
valor: p.entregaEstimada ? fecha(p.entregaEstimada) : "Sin fecha",
},
{ clave: "Alta", valor: fechaHora(p.creadoEn) },
{ clave: "Actualizado", valor: fechaHora(p.actualizadoEn) },
]}
/>
</div>
</Panel>
</div>
</div>
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
import type { Metadata } from "next";
import { alcanceDe, exigirSesion } from "@/lib/crm/auth";
import { listarPedidos } from "@/lib/crm/consultas";
import {
ESTADOS_PEDIDO,
ESTADO_PEDIDO_ETIQUETA,
} from "@/lib/crm/constantes";
import {
ContenedorTabla,
EncabezadoPagina,
EnlaceFolio,
EstadoVacio,
InsigniaEstadoPedido,
Panel,
Td,
Th,
} from "@/components/admin/ui";
import { BarraFiltros } from "@/components/admin/BarraFiltros";
import { Paginacion } from "@/components/admin/Paginacion";
import { entero, fecha, moneda, oRaya } from "@/components/admin/formato";
import {
param,
paramEntero,
paramEnum,
type Consulta,
} from "@/components/admin/enlaces";
export const metadata: Metadata = {
title: "Pedidos",
robots: { index: false, follow: false },
};
const RUTA = "/admin/pedidos";
const POR_PAGINA = 25;
export default async function PaginaPedidos({
searchParams,
}: {
searchParams: Promise<Consulta>;
}) {
const sp = await searchParams;
const sesion = await exigirSesion();
const alcance = alcanceDe(sesion);
const esAdmin = sesion.rol === "ADMIN";
const pagina = await listarPedidos(alcance, {
estado: paramEnum(sp, "estado", ESTADOS_PEDIDO),
q: param(sp, "q") || undefined,
pagina: paramEntero(sp, "pagina", 1),
porPagina: POR_PAGINA,
});
const montoPagina = pagina.filas.reduce((s, p) => s + p.total, 0);
return (
<div className="min-w-0">
<EncabezadoPagina
eyebrow="Panel comercial"
titulo="Pedidos"
descripcion="Cada pedido nace de una oportunidad ganada y conserva el enlace a su origen: se puede rastrear qué campaña terminó en esta venta."
/>
<Panel>
<BarraFiltros
marcador="Folio, contacto o empresa"
selectores={[
{
clave: "estado",
etiqueta: "Estado",
todos: "Todos los estados",
opciones: ESTADOS_PEDIDO.map((e) => ({
valor: e,
etiqueta: ESTADO_PEDIDO_ETIQUETA[e],
})),
},
]}
/>
{pagina.filas.length === 0 ? (
<EstadoVacio
titulo="Sin pedidos con estos filtros"
quehacer="Quita el filtro de estado o busca por folio. Para levantar uno nuevo, abre una oportunidad ganada y crea el pedido desde ahí."
/>
) : (
<>
<div className="hidden md:block">
<ContenedorTabla etiqueta="Pedidos">
<table className="w-full min-w-[56rem] border-collapse text-left">
<caption className="sr-only">Pedidos con su estado y total</caption>
<thead>
<tr>
<Th>Folio</Th>
<Th>Contacto</Th>
<Th>Estado</Th>
<Th>Oportunidad</Th>
{esAdmin ? <Th>Propietario</Th> : null}
<Th numerico>Partidas</Th>
<Th numerico>Total</Th>
<Th numerico>Alta</Th>
<Th numerico>Entrega</Th>
</tr>
</thead>
<tbody>
{pagina.filas.map((p) => (
<tr key={p.id} className="transition-colors hover:bg-ink-3/50">
<Td>
<EnlaceFolio href={`/admin/pedidos/${p.id}`} folio={p.folio} />
</Td>
<Td>
<span className="block text-sm text-bright">{p.contacto.nombre}</span>
<span className="mt-0.5 block font-mono text-[0.625rem] uppercase tracking-[0.06em] text-faint">
{oRaya(p.contacto.empresa)}
</span>
</Td>
<Td>
<InsigniaEstadoPedido estado={p.estado} />
</Td>
<Td mono>{oRaya(p.oportunidadFolio)}</Td>
{esAdmin ? <Td>{p.propietario?.nombre ?? "—"}</Td> : null}
<Td numerico>{entero(p.totalPartidas)}</Td>
<Td numerico className="font-semibold text-bright">
{moneda(p.total)}
</Td>
<Td numerico>{fecha(p.creadoEn)}</Td>
<Td numerico>{p.entregaEstimada ? fecha(p.entregaEstimada) : "—"}</Td>
</tr>
))}
</tbody>
</table>
</ContenedorTabla>
</div>
<ul className="divide-y divide-rule/60 md:hidden">
{pagina.filas.map((p) => (
<li key={p.id} className="min-w-0 px-4 py-3.5">
<div className="flex flex-wrap items-center justify-between gap-x-2.5 gap-y-1">
<EnlaceFolio href={`/admin/pedidos/${p.id}`} folio={p.folio} />
<InsigniaEstadoPedido estado={p.estado} />
</div>
<p className="mt-1.5 truncate text-sm text-bright">
{p.contacto.nombre}
{p.contacto.empresa ? (
<span className="text-muted"> · {p.contacto.empresa}</span>
) : null}
</p>
<p className="mt-1 flex flex-wrap items-baseline gap-x-3 font-mono text-[0.625rem] uppercase tracking-[0.08em] text-faint">
<span>{entero(p.totalPartidas)} partidas</span>
<span>{fecha(p.creadoEn)}</span>
<span className="text-sm font-semibold normal-case tracking-normal text-bright">
{moneda(p.total)}
</span>
</p>
</li>
))}
</ul>
<p className="border-t border-rule px-4 py-3 font-mono text-[0.625rem] uppercase tracking-[0.1em] text-faint sm:px-5">
Suma de esta página:{" "}
<span className="font-semibold text-bright">{moneda(montoPagina)}</span>
</p>
</>
)}
<Paginacion
ruta={RUTA}
sp={sp}
pagina={pagina.pagina}
paginas={pagina.paginas}
total={pagina.total}
porPagina={pagina.porPagina}
etiqueta="pedidos"
/>
</Panel>
</div>
);
}
+252
View File
@@ -0,0 +1,252 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { exigirAdmin, exigirSesion } from "@/lib/crm/auth";
import { listarEquipo, listarReglas } from "@/lib/crm/consultas";
import {
ORIGEN_ETIQUETA,
TIPO_REGLA_ETIQUETA,
type Origen,
type TipoRegla,
} from "@/lib/crm/constantes";
import { getIndustria } from "@/data/industrias";
import { getCategoria } from "@/data/catalogo";
import { CLASES_INFO } from "@/data/clases-fuego";
import {
ContenedorTabla,
EncabezadoPagina,
EstadoVacio,
Panel,
Td,
Th,
} from "@/components/admin/ui";
import {
AccionesRegla,
FormularioRegla,
} from "@/components/admin/reglas/FormulariosReglas";
import { conParametros, param, type Consulta } from "@/components/admin/enlaces";
export const metadata: Metadata = {
title: "Reglas de asignación",
robots: { index: false, follow: false },
};
const RUTA = "/admin/reglas";
/** Traduce el valor crudo de la regla al nombre que se lee en el sitio. */
function valorLegible(tipo: TipoRegla, valor: string | null): string {
if (tipo === "CUALQUIERA") return "Cualquier valor";
if (!valor) return "—";
switch (tipo) {
case "INDUSTRIA":
return getIndustria(valor)?.nombre ?? valor;
case "CATEGORIA":
return getCategoria(valor)?.nombre ?? valor;
case "ORIGEN":
return ORIGEN_ETIQUETA[valor as Origen] ?? valor;
case "CLASE":
return `Clase ${valor} — ${CLASES_INFO[valor as keyof typeof CLASES_INFO]?.nombre ?? valor}`;
default:
return valor;
}
}
export default async function PaginaReglas({
searchParams,
}: {
searchParams: Promise<Consulta>;
}) {
const sp = await searchParams;
// Igual que Equipo: para un vendedor esta ruta no existe.
const sesion = await exigirSesion();
if (sesion.rol !== "ADMIN") notFound();
await exigirAdmin();
const [reglas, equipo] = await Promise.all([listarReglas(), listarEquipo()]);
const nuevo = param(sp, "nuevo") === "1";
const ordenadas = [...reglas].sort((a, b) => a.prioridad - b.prioridad);
const activas = ordenadas.filter((r) => r.activa);
const vendedoresActivos = equipo.filter((u) => u.activo && u.rol === "VENDEDOR");
const prioridadSugerida =
ordenadas.length === 0 ? 10 : ordenadas[ordenadas.length - 1].prioridad + 10;
return (
<div className="min-w-0">
<EncabezadoPagina
eyebrow="Administración"
titulo="Reglas de asignación"
descripcion="Quién recibe cada oportunidad nueva y por qué. Cambiar el orden cambia el resultado, así que la tabla se lee de arriba hacia abajo."
acciones={
!nuevo ? (
<Link
href={conParametros(RUTA, sp, { nuevo: 1 })}
className="btn btn-signal btn-sm"
style={{ WebkitTapHighlightColor: "transparent" }}
>
Nueva regla
</Link>
) : undefined
}
/>
{/* La explicación va arriba y completa: un panel de reglas sin ella es una
caja negra para quien lo opera. */}
<section
aria-labelledby="como-se-resuelve"
className="panel mb-4 px-4 py-4 sm:mb-5 sm:px-5"
>
<h2
id="como-se-resuelve"
className="mb-3 font-mono text-[0.6875rem] font-semibold uppercase tracking-[0.14em] text-bright"
>
Cómo se resuelve la asignación
</h2>
<ol className="grid gap-2.5 text-sm leading-relaxed text-muted">
<li className="flex gap-3">
<span className="font-mono text-xs font-semibold text-signal">1</span>
<span>
Las reglas <strong className="font-semibold text-bright">activas</strong> se
evalúan por prioridad ascendente: primero la de número más bajo.
</span>
</li>
<li className="flex gap-3">
<span className="font-mono text-xs font-semibold text-signal">2</span>
<span>
Gana <strong className="font-semibold text-bright">la primera que coincide</strong>{" "}
con la oportunidad. Las siguientes ya no se revisan, aunque también coincidan.
</span>
</li>
<li className="flex gap-3">
<span className="font-mono text-xs font-semibold text-signal">3</span>
<span>
Si ninguna coincide, la oportunidad se reparte{" "}
<strong className="font-semibold text-bright">por turno</strong> entre los
vendedores activos, de forma que la carga se equilibre sola.
</span>
</li>
</ol>
<p className="mt-3 border-l-2 border-rule-2 py-1 pl-3 text-xs leading-relaxed text-faint">
El motivo de cada asignación se escribe en la bitácora de la oportunidad, así que
siempre se puede auditar por qué le tocó a quién. Hay{" "}
<span className="font-mono text-bright">{activas.length}</span>{" "}
{activas.length === 1 ? "regla activa" : "reglas activas"} y{" "}
<span className="font-mono text-bright">{vendedoresActivos.length}</span>{" "}
{vendedoresActivos.length === 1
? "vendedor activo en el reparto por turno"
: "vendedores activos en el reparto por turno"}
.
</p>
</section>
<div className="grid min-w-0 gap-4 sm:gap-5">
{nuevo ? (
<Panel
titulo="Nueva regla"
nota="El valor a comparar se elige del catálogo real, no se escribe a mano"
>
<FormularioRegla
equipo={equipo.map((u) => ({ id: u.id, nombre: u.nombre, activo: u.activo }))}
prioridadSugerida={prioridadSugerida}
onListo={RUTA}
/>
</Panel>
) : null}
<Panel titulo="Orden de evaluación" nota="De la prioridad más baja a la más alta">
{ordenadas.length === 0 ? (
<EstadoVacio
titulo="Sin reglas"
quehacer="Sin reglas, todas las oportunidades se reparten por turno entre los vendedores activos. Crea una regla cuando quieras que un segmento concreto caiga siempre en la misma persona."
/>
) : (
<>
<div className="hidden md:block">
<ContenedorTabla etiqueta="Reglas de asignación en orden de evaluación">
<table className="w-full min-w-[52rem] border-collapse text-left">
<caption className="sr-only">
Reglas de asignación, de la prioridad más baja a la más alta
</caption>
<thead>
<tr>
<Th numerico>Prioridad</Th>
<Th>Regla</Th>
<Th>Condición</Th>
<Th>Valor</Th>
<Th>Se asigna a</Th>
<Th>Estado</Th>
<Th>Acciones</Th>
</tr>
</thead>
<tbody>
{ordenadas.map((r) => (
<tr
key={r.id}
className={[
"transition-colors hover:bg-ink-3/50",
r.activa ? "" : "opacity-60",
].join(" ")}
>
<Td numerico className="font-semibold text-bright">
{r.prioridad}
</Td>
<Th scope="row" className="normal-case tracking-normal">
<span className="text-sm text-bright">{r.nombre}</span>
</Th>
<Td>
<span className="font-mono text-[0.625rem] uppercase tracking-[0.08em] text-muted">
{TIPO_REGLA_ETIQUETA[r.tipo]}
</span>
</Td>
<Td>{valorLegible(r.tipo, r.valor)}</Td>
<Td>{r.usuario.nombre}</Td>
<Td>
<span className={r.activa ? "badge badge-ok" : "badge"}>
{r.activa ? "Activa" : "Inactiva"}
</span>
</Td>
<Td>
<AccionesRegla id={r.id} activa={r.activa} nombre={r.nombre} />
</Td>
</tr>
))}
</tbody>
</table>
</ContenedorTabla>
</div>
<ol className="divide-y divide-rule/60 md:hidden">
{ordenadas.map((r) => (
<li
key={r.id}
className={["min-w-0 px-4 py-3.5", r.activa ? "" : "opacity-60"].join(" ")}
>
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<span className="font-mono text-[0.8125rem] font-semibold text-bright">
{r.prioridad}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-bright">
{r.nombre}
</span>
<span className={r.activa ? "badge badge-ok" : "badge"}>
{r.activa ? "Activa" : "Inactiva"}
</span>
</div>
<p className="mt-1 font-mono text-[0.625rem] uppercase tracking-[0.08em] text-faint">
{TIPO_REGLA_ETIQUETA[r.tipo]} · {valorLegible(r.tipo, r.valor)}
</p>
<p className="mt-1 text-xs text-muted">Se asigna a {r.usuario.nombre}</p>
<div className="mt-2">
<AccionesRegla id={r.id} activa={r.activa} nombre={r.nombre} />
</div>
</li>
))}
</ol>
</>
)}
</Panel>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
/**
* Raíz del panel. Solo fija lo que comparten TODAS las rutas de `/admin`,
* incluida la de acceso: título y `noindex, nofollow`.
*
* El cascarón autenticado no vive aquí sino en `(panel)/layout.tsx`. La razón
* es concreta: ese layout llama `exigirSesion()`, y si envolviera también a
* `/admin/login` un visitante sin sesión entraría en un bucle de redirección
* hacia la propia pantalla de acceso. El grupo `(panel)` no aparece en la URL,
* así que las rutas siguen siendo `/admin`, `/admin/oportunidades`, etc.
*/
export const metadata: Metadata = {
title: "Panel comercial",
robots: { index: false, follow: false },
};
export default function LayoutAdmin({ children }: { children: React.ReactNode }) {
return children;
}
+73
View File
@@ -0,0 +1,73 @@
import type { Metadata } from "next";
import Image from "next/image";
import { redirect } from "next/navigation";
import { leerSesion, perfilesDemo } from "@/lib/crm/auth";
import { AccesoDemo, FormularioAcceso } from "@/components/admin/login/Acceso";
import { EMPRESA } from "@/data/empresa";
export const metadata: Metadata = {
title: "Acceso al panel",
robots: { index: false, follow: false },
};
/** Solo se admiten destinos internos del panel: nada de redirecciones abiertas. */
function destinoSeguro(v: string | string[] | undefined): string {
const bruto = Array.isArray(v) ? v[0] : v;
if (!bruto) return "/admin";
if (!bruto.startsWith("/admin")) return "/admin";
if (bruto.startsWith("//")) return "/admin";
return bruto;
}
export default async function PaginaAcceso({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const sp = await searchParams;
const destino = destinoSeguro(sp.destino);
// Con sesión viva no hay nada que pedir: se va directo a donde quería ir.
const sesion = await leerSesion();
if (sesion) redirect(destino);
const perfiles = await perfilesDemo();
return (
<div
className="grid-bg flex min-h-screen items-center justify-center bg-ink px-4 py-10"
style={{ minHeight: "100dvh" }}
>
<div className="w-full max-w-sm">
<div className="mb-7 flex flex-col items-center text-center">
<Image
src="/media/marca/fspm-logo-blanco.png"
alt={EMPRESA.nombre}
width={200}
height={54}
priority
className="h-10 w-auto"
/>
<p className="mt-4 font-mono text-[0.625rem] uppercase tracking-[0.16em] text-faint">
Panel comercial
</p>
</div>
<div className="panel px-5 py-6 sm:px-6">
<h1 className="mb-1 text-lg uppercase tracking-tight">Acceso</h1>
<p className="mb-5 text-xs leading-relaxed text-muted">
Entra con las credenciales que te asignaron. El panel no se enlaza desde el
sitio público.
</p>
<FormularioAcceso destino={destino} />
<AccesoDemo perfiles={perfiles} />
</div>
<p className="mt-5 text-center font-mono text-[0.5625rem] uppercase leading-relaxed tracking-[0.12em] text-faint">
Los datos del panel son ficticios
</p>
</div>
</div>
);
}
+361
View File
@@ -0,0 +1,361 @@
import { NextResponse, type NextRequest } from "next/server";
import { serializeClases } from "@/data/clases-fuego";
import { WHATSAPP } from "@/data/empresa";
import { db } from "@/lib/db";
import { asignar } from "@/lib/crm/asignacion";
import { siguienteFolioEn } from "@/lib/crm/folios";
import type { Prioridad } from "@/lib/crm/constantes";
import {
erroresPorCampo,
esquemaLead,
normalizarEmail,
normalizarTelefono,
type EntradaLead,
type RespuestaLead,
} from "@/lib/leads/esquema";
/**
* Captura de leads. Es el único punto de entrada del sitio público al CRM y lo
* consumen el formulario embebido y el widget de WhatsApp.
*
* El orden importa: primero se registra el contacto y la oportunidad, y solo
* después se devuelve el enlace de WhatsApp. Así el lead existe aunque la
* conversación nunca arranque.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// ---------- limitación de tasa ----------
/**
* Ventana deslizante en memoria del proceso. Alcanza para una demo de una sola
* instancia; en un despliegue real esto viviría en Redis con una clave por IP,
* para que el límite valga entre todas las réplicas y sobreviva a un reinicio.
*/
const MAX_ENVIOS = 5;
const VENTANA_MS = 10 * 60 * 1000;
const registro = new Map<string, number[]>();
function ipDe(peticion: NextRequest): string {
const adelante = peticion.headers.get("x-forwarded-for");
if (adelante) {
const primera = adelante.split(",")[0]?.trim();
if (primera) return primera;
}
return peticion.headers.get("x-real-ip")?.trim() || "desconocida";
}
function excedeLimite(ip: string): boolean {
const ahora = Date.now();
const corte = ahora - VENTANA_MS;
// limpieza oportunista: sin esto el Map crecería sin techo
for (const [clave, marcas] of registro) {
const vivas = marcas.filter((m) => m > corte);
if (vivas.length === 0) registro.delete(clave);
else registro.set(clave, vivas);
}
const propias = registro.get(ip) ?? [];
if (propias.length >= MAX_ENVIOS) return true;
propias.push(ahora);
registro.set(ip, propias);
return false;
}
// ---------- utilidades ----------
function limpio(v: string | undefined | null): string | null {
if (typeof v !== "string") return null;
const t = v.trim();
return t.length ? t : null;
}
function primerNombre(nombre: string | null): string | undefined {
if (!nombre) return undefined;
const parte = nombre.trim().split(/\s+/)[0];
return parte && parte.length ? parte : undefined;
}
function prioridadDe(datos: EntradaLead): Prioridad {
if (datos.urgencia === "inmediata" || datos.origen === "WHATSAPP") return "ALTA";
if (datos.urgencia === "explorando") return "BAJA";
return "MEDIA";
}
/**
* Texto del primer mensaje de WhatsApp. El prefijo [DEMO] es requisito del
* cliente: identifica las pruebas de la demostración para que no se confundan
* con un lead real.
*/
function mensajeWhatsApp(datos: EntradaLead, folio: string): string {
const empresa = limpio(datos.empresa);
const motivoCrudo =
limpio(datos.mensaje) ?? "Quiero información sobre protección contra incendios";
const motivo = /[.!?]$/.test(motivoCrudo) ? motivoCrudo : `${motivoCrudo}.`;
const presentacion = empresa ? `soy ${datos.nombre} de ${empresa}` : `soy ${datos.nombre}`;
return `[DEMO] Hola FSPM, ${presentacion}. ${motivo} Folio ${folio}.`;
}
function enlaceWhatsApp(texto: string): string {
return `https://api.whatsapp.com/send?phone=${WHATSAPP}&text=${encodeURIComponent(texto)}`;
}
/** Línea de bitácora que describe el envío: canal, producto y campaña. */
function describeEnvio(datos: EntradaLead): string {
const partes: string[] = [];
partes.push(
datos.origen === "WHATSAPP"
? "Contacto registrado desde el widget de WhatsApp"
: "Formulario enviado desde el sitio",
);
partes.push(`canal: ${datos.canal}`);
const producto = limpio(datos.productoNombre) ?? limpio(datos.productoSlug);
partes.push(`producto: ${producto ?? "sin producto declarado"}`);
const campana = limpio(datos.utmCampaign);
const fuente = limpio(datos.utmSource);
const medio = limpio(datos.utmMedium);
partes.push(
campana || fuente
? `campaña: ${campana ?? "sin campaña"} (${fuente ?? "sin fuente"}/${medio ?? "sin medio"})`
: "campaña: tráfico directo u orgánico",
);
const urgencia = limpio(datos.urgencia);
if (urgencia) partes.push(`urgencia declarada: ${urgencia}`);
return `${partes.join(" · ")}.`;
}
type CamposContacto =
| "nombre"
| "empresa"
| "ciudad"
| "industria"
| "puesto"
| "telefono"
| "email";
// ---------- POST ----------
export async function POST(peticion: NextRequest): Promise<NextResponse<RespuestaLead>> {
let cuerpo: unknown;
try {
cuerpo = await peticion.json();
} catch {
return NextResponse.json(
{ ok: false, error: "No pudimos leer la solicitud. Inténtalo otra vez." },
{ status: 400 },
);
}
// 1. señuelo antispam. Se responde con un acuse creíble para no delatar que
// lo detectamos, y no se escribe nada en la base.
const senuelo =
typeof cuerpo === "object" && cuerpo !== null
? (cuerpo as Record<string, unknown>)._sitio
: undefined;
if (typeof senuelo === "string" && senuelo.trim().length > 0) {
return NextResponse.json({ ok: true, folio: "OPP-0000-0000" }, { status: 200 });
}
// 2. limitación de tasa por IP
if (excedeLimite(ipDe(peticion))) {
return NextResponse.json(
{
ok: false,
error: "Recibimos varias solicitudes desde tu conexión. Espera unos minutos.",
},
{ status: 429 },
);
}
// 3. validación
const analisis = esquemaLead.safeParse(cuerpo);
if (!analisis.success) {
const campos = erroresPorCampo(analisis.error);
const primero = Object.values(campos)[0];
return NextResponse.json(
{ ok: false, error: primero ?? "Revisa los datos del formulario.", campos },
{ status: 400 },
);
}
const datos = analisis.data;
const userAgent = peticion.headers.get("user-agent")?.slice(0, 400) ?? null;
const emailNorm = normalizarEmail(datos.email);
const telefonoNorm = normalizarTelefono(datos.telefono);
const clasesCsv = datos.clases?.length ? serializeClases(datos.clases) : null;
// 4. deduplicación: primero por correo normalizado, en su defecto por teléfono
const existente = await db.contact.findFirst({
where: emailNorm
? telefonoNorm
? { OR: [{ emailNorm }, { telefonoNorm }] }
: { emailNorm }
: telefonoNorm
? { telefonoNorm }
: { id: "__sin_clave_de_deduplicacion__" },
orderBy: [{ creadoEn: "asc" }],
});
// 6. prioridad automática
const prioridad = prioridadDe(datos);
// 7. asignación
const asignacion = await asignar({
industria: limpio(datos.industria),
categoriaSlug: limpio(datos.categoriaSlug),
origen: datos.origen,
clases: datos.clases ? [...datos.clases] : null,
});
const resultado = await db.$transaction(async (tx) => {
let contactoId: string;
if (existente) {
// Solo se tocan campos que llegaron con contenido y que estaban vacíos o
// traen un dato distinto. La atribución de primer toque (primerUtm*,
// primerLandingPath, primerReferrer) nunca se sobreescribe.
const cambios: Partial<Record<CamposContacto, string>> & {
emailNorm?: string;
telefonoNorm?: string;
propietarioId?: string;
} = {};
const proponer = (campo: CamposContacto, valor: string | null) => {
if (!valor) return;
if (existente[campo] !== valor) cambios[campo] = valor;
};
proponer("nombre", limpio(datos.nombre));
proponer("empresa", limpio(datos.empresa));
proponer("ciudad", limpio(datos.ciudad));
proponer("industria", limpio(datos.industria));
proponer("puesto", limpio(datos.puesto));
proponer("telefono", limpio(datos.telefono));
proponer("email", limpio(datos.email));
if (emailNorm && existente.emailNorm !== emailNorm) cambios.emailNorm = emailNorm;
if (telefonoNorm && existente.telefonoNorm !== telefonoNorm) {
cambios.telefonoNorm = telefonoNorm;
}
// el contacto hereda al propietario solo si aún no tenía uno
if (!existente.propietarioId && asignacion.usuarioId) {
cambios.propietarioId = asignacion.usuarioId;
}
if (Object.keys(cambios).length > 0) {
await tx.contact.update({ where: { id: existente.id }, data: cambios });
}
contactoId = existente.id;
} else {
const creado = await tx.contact.create({
data: {
nombre: datos.nombre,
email: limpio(datos.email),
telefono: limpio(datos.telefono),
emailNorm,
telefonoNorm,
empresa: limpio(datos.empresa),
puesto: limpio(datos.puesto),
ciudad: limpio(datos.ciudad),
industria: limpio(datos.industria),
propietarioId: asignacion.usuarioId,
// atribución de primer toque
primerUtmSource: limpio(datos.utmSource),
primerUtmMedium: limpio(datos.utmMedium),
primerUtmCampaign: limpio(datos.utmCampaign),
primerLandingPath: limpio(datos.landingPath),
primerReferrer: limpio(datos.referrer),
},
select: { id: true },
});
contactoId = creado.id;
}
// 5. siempre una oportunidad nueva: tres envíos del mismo contacto son
// tres oportunidades sobre una sola persona.
const folio = await siguienteFolioEn(tx, "oportunidad");
const oportunidad = await tx.opportunity.create({
data: {
folio,
contactoId,
propietarioId: asignacion.usuarioId,
origen: datos.origen,
canal: datos.canal,
etapa: "NUEVO",
prioridad,
productoSlug: limpio(datos.productoSlug),
productoNombre: limpio(datos.productoNombre),
productoUrl: limpio(datos.productoUrl),
categoriaSlug: limpio(datos.categoriaSlug),
industria: limpio(datos.industria),
clasesFuego: clasesCsv,
superficie: limpio(datos.superficie),
urgencia: limpio(datos.urgencia),
mensaje: limpio(datos.mensaje),
utmSource: limpio(datos.utmSource),
utmMedium: limpio(datos.utmMedium),
utmCampaign: limpio(datos.utmCampaign),
utmTerm: limpio(datos.utmTerm),
utmContent: limpio(datos.utmContent),
gclid: limpio(datos.gclid),
fbclid: limpio(datos.fbclid),
referrer: limpio(datos.referrer),
landingPath: limpio(datos.landingPath),
paginaUrl: limpio(datos.paginaUrl),
dispositivo: limpio(datos.dispositivo),
userAgent,
},
select: { id: true, folio: true },
});
// 8. bitácora: qué llegó y a quién le tocó
await tx.activity.create({
data: {
oportunidadId: oportunidad.id,
tipo: "SISTEMA",
texto: describeEnvio(datos),
},
});
await tx.activity.create({
data: {
oportunidadId: oportunidad.id,
usuarioId: asignacion.usuarioId,
tipo: "ASIGNACION",
texto: asignacion.usuarioId
? `Asignada a ${asignacion.nombre}. Criterio: ${asignacion.motivo}.`
: `Sin asignar. ${asignacion.motivo}.`,
},
});
return oportunidad;
});
// 9. respuesta
const respuesta: RespuestaLead = {
ok: true,
folio: resultado.folio,
atiende: primerNombre(asignacion.nombre),
};
if (datos.origen === "WHATSAPP") {
respuesta.whatsappUrl = enlaceWhatsApp(mensajeWhatsApp(datos, resultado.folio));
}
return NextResponse.json(respuesta, { status: 200 });
}
export function GET(): NextResponse<RespuestaLead> {
return NextResponse.json(
{ ok: false, error: "Método no permitido. Esta ruta solo acepta POST." },
{ status: 405, headers: { Allow: "POST" } },
);
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
/**
* Sonda de salud del contenedor. No basta con que Next responda: si SQLite no
* está montado o el volumen no es escribible, la aplicación se ve viva y no lo
* está. Por eso la sonda toca la base.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
await db.$queryRaw`SELECT 1`;
return NextResponse.json(
{ ok: true, hora: new Date().toISOString() },
{ status: 200, headers: { "Cache-Control": "no-store" } },
);
} catch {
return NextResponse.json(
{ ok: false, hora: new Date().toISOString(), error: "La base de datos no responde." },
{ status: 503, headers: { "Cache-Control": "no-store" } },
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import { ImageResponse } from "next/og";
/**
* Icono de 180×180 para iOS y iPadOS.
*
* Dos diferencias deliberadas contra el favicon de 32 px:
*
* 1. **Sin transparencia.** iOS no respeta el canal alfa en el icono de la
* pantalla de inicio: lo compone sobre negro y deja un borde sucio. El
* fondo va pintado en acero-humo, opaco, de borde a borde.
* 2. **Más aire y el anillo de la marca.** A 180 px sí cabe el anillo rojo que
* rodea la llama en el logotipo original, así que el icono se parece al
* logotipo real en vez de ser solo una silueta.
*/
const INK = "#0c1116";
const SIGNAL = "#db042b";
/** Mismo trazo de llama que el favicon. Retícula de 100×100. */
const TRAZO_LLAMA =
"M55 3 C 55 25 47 41 41 49 C 36 43 38 33 37 24 " +
"C 27 36 17 45 17 60 C 17 80 32 96 50 96 " +
"C 68 96 83 80 83 60 C 83 36 68 18 55 3 Z";
/** Caja envolvente del trazo, para poder centrarlo y escalarlo con exactitud. */
const CAJA_LLAMA = { x: 17, y: 3, ancho: 66, alto: 93 } as const;
export const size = { width: 180, height: 180 };
export const contentType = "image/png";
/**
* Coloca la llama con una altura dada y su centro geométrico en (cx, cy).
* Devuelve el `transform` del grupo SVG.
*/
function llamaCentrada(alto: number, cx: number, cy: number): string {
const escala = alto / CAJA_LLAMA.alto;
const dx = cx - CAJA_LLAMA.x * escala - (CAJA_LLAMA.ancho * escala) / 2;
const dy = cy - CAJA_LLAMA.y * escala - alto / 2;
return `translate(${dx.toFixed(2)} ${dy.toFixed(2)}) scale(${escala.toFixed(4)})`;
}
/** Icono completo: fondo opaco, anillo de marca y llama centrada. */
function svgAppleIcon(lado: number): string {
const radioAnillo = 36;
const grosorAnillo = 6;
return [
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="${lado}" height="${lado}">`,
`<rect width="100" height="100" fill="${INK}"/>`,
`<circle cx="50" cy="50" r="${radioAnillo}" fill="none" stroke="${SIGNAL}" stroke-width="${grosorAnillo}"/>`,
`<g transform="${llamaCentrada(46, 50, 50)}">`,
`<path d="${TRAZO_LLAMA}" fill="${SIGNAL}"/>`,
`</g>`,
`</svg>`,
].join("");
}
/** Data URI listo para `<img src>`: `#` y `<` tienen que ir codificados. */
function dataUriSvg(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
export default function AppleIcon() {
return new ImageResponse(
(
<div
style={{
display: "flex",
width: "100%",
height: "100%",
background: INK,
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
width={size.width}
height={size.height}
alt=""
src={dataUriSvg(svgAppleIcon(size.width))}
/>
</div>
),
{ ...size },
);
}
+515
View File
@@ -0,0 +1,515 @@
@import "tailwindcss";
/* ============================================================
FSPM — sistema de diseño
Acero-humo + rojo de marca verificado (#DB042B) + amarillo de
traje de bombero tomado de la fotografía propia de la empresa.
Dos mundos: oscuro = el riesgo y la operación.
claro = la evidencia y el documento.
============================================================ */
@theme {
/* --- superficie oscura: el riesgo --- */
--color-ink: #0c1116;
--color-ink-2: #141b22;
--color-ink-3: #1d262e;
--color-rule: #26313a;
--color-rule-2: #313e49;
/* --- superficie clara: la evidencia --- */
--color-paper: #eef1f3;
--color-paper-2: #e2e8eb;
--color-paper-3: #d3dbe0;
--color-rule-lite: #c3ced5;
/* --- señal --- */
--color-signal: #db042b;
--color-signal-hi: #ff1f45;
--color-signal-dim: #8e0620;
--color-ember: #f2b705;
--color-ember-dim: #8a6a06;
/* --- texto --- */
--color-bright: #e9eff3;
--color-muted: #8b9aa7;
--color-faint: #5d6b77;
--color-deep: #0c1116;
--color-deep-muted: #4c5b66;
/* --- estado --- */
--color-ok: #21a179;
--color-warn: #f2b705;
--color-bad: #db042b;
/* --- tipografía --- */
--font-display: var(--f-display), "Archivo", system-ui, sans-serif;
--font-sans: var(--f-sans), "IBM Plex Sans", system-ui, sans-serif;
--font-mono: var(--f-mono), "IBM Plex Mono", ui-monospace, monospace;
/* --- escala tipográfica --- */
--text-eyebrow: 0.6875rem;
--text-eyebrow--line-height: 1;
--text-eyebrow--letter-spacing: 0.18em;
/* --- ritmo --- */
--spacing-section: 6.5rem;
--spacing-gutter: 1.25rem;
/* --- radios: casi rectos, herramienta industrial --- */
--radius-tool: 3px;
--radius-panel: 6px;
}
/* ============================================================
Base
============================================================ */
@layer base {
html {
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
}
body {
background-color: var(--color-ink);
color: var(--color-bright);
font-family: var(--font-sans);
font-size: 1rem;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
::selection {
background-color: var(--color-signal);
color: #fff;
}
:focus-visible {
outline: 2px solid var(--color-ember);
outline-offset: 2px;
}
/* números tabulares en todo dato numérico */
.font-mono,
table,
[data-numeric] {
font-variant-numeric: tabular-nums;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-display);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.05;
text-wrap: balance;
}
p {
text-wrap: pretty;
}
img {
max-width: 100%;
height: auto;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
}
/* ============================================================
Utilidades compartidas — un solo nivel de clase, sin
selectores de elemento, para que no se cancelen entre sí.
============================================================ */
@layer components {
/* --- contenedor --- */
.shell {
width: 100%;
max-width: 76rem;
margin-inline: auto;
padding-inline: var(--spacing-gutter);
}
.shell-wide {
width: 100%;
max-width: 92rem;
margin-inline: auto;
padding-inline: var(--spacing-gutter);
}
/* --- eyebrow: etiqueta de sección en mono --- */
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-family: var(--font-mono);
font-size: var(--text-eyebrow);
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--color-signal);
}
.eyebrow::before {
content: "";
display: block;
width: 1.75rem;
height: 1px;
background-color: var(--color-signal);
}
.eyebrow-muted {
color: var(--color-muted);
}
.eyebrow-muted::before {
background-color: var(--color-rule-2);
}
/* --- botones --- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: 1px solid transparent;
border-radius: var(--radius-tool);
font-family: var(--font-mono);
font-size: 0.8125rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1;
padding: 0.9375rem 1.5rem;
cursor: pointer;
transition:
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease,
transform 160ms ease;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-signal {
background-color: var(--color-signal);
color: #fff;
}
.btn-signal:hover:not(:disabled) {
background-color: var(--color-signal-hi);
}
.btn-ghost {
background-color: transparent;
border-color: var(--color-rule-2);
color: var(--color-bright);
}
.btn-ghost:hover:not(:disabled) {
border-color: var(--color-bright);
background-color: rgb(255 255 255 / 0.04);
}
.btn-deep {
background-color: var(--color-deep);
color: var(--color-bright);
}
.btn-deep:hover:not(:disabled) {
background-color: #1d262e;
}
.btn-sm {
padding: 0.625rem 1rem;
font-size: 0.75rem;
}
/* --- campos de formulario --- */
.field-label {
display: block;
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-muted);
margin-bottom: 0.4375rem;
}
.field {
display: block;
width: 100%;
background-color: var(--color-ink);
border: 1px solid var(--color-rule-2);
border-radius: var(--radius-tool);
color: var(--color-bright);
font-family: var(--font-sans);
font-size: 0.9375rem;
padding: 0.6875rem 0.875rem;
transition:
border-color 140ms ease,
background-color 140ms ease;
}
.field::placeholder {
color: var(--color-faint);
}
.field:focus {
outline: none;
border-color: var(--color-signal);
background-color: #0a0e12;
}
.field:user-invalid {
border-color: var(--color-signal);
}
.field-lite {
background-color: #fff;
border-color: var(--color-rule-lite);
color: var(--color-deep);
}
.field-lite:focus {
background-color: #fff;
border-color: var(--color-signal);
}
.field-error {
display: block;
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.04em;
color: var(--color-signal-hi);
margin-top: 0.375rem;
}
/* --- paneles --- */
.panel {
background-color: var(--color-ink-2);
border: 1px solid var(--color-rule);
border-radius: var(--radius-panel);
}
.panel-lite {
background-color: #fff;
border: 1px solid var(--color-rule-lite);
border-radius: var(--radius-panel);
}
/* --- tira de especificación: la capa de datos --- */
.spec {
display: grid;
gap: 1px;
background-color: var(--color-rule);
border: 1px solid var(--color-rule);
border-radius: var(--radius-tool);
overflow: hidden;
}
.spec-cell {
background-color: var(--color-ink-2);
padding: 0.625rem 0.75rem;
}
.spec-key {
display: block;
font-family: var(--font-mono);
font-size: 0.625rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-faint);
}
.spec-val {
display: block;
font-family: var(--font-mono);
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-bright);
margin-top: 0.1875rem;
}
/* --- hairline entre bloques --- */
.hair {
height: 1px;
background-color: var(--color-rule);
border: 0;
}
.hair-lite {
height: 1px;
background-color: var(--color-rule-lite);
border: 0;
}
/* --- retícula técnica de fondo --- */
.grid-bg {
background-image:
linear-gradient(to right, rgb(255 255 255 / 0.028) 1px, transparent 1px),
linear-gradient(to bottom, rgb(255 255 255 / 0.028) 1px, transparent 1px);
background-size: 56px 56px;
}
.grid-bg-lite {
background-image:
linear-gradient(to right, rgb(12 17 22 / 0.045) 1px, transparent 1px),
linear-gradient(to bottom, rgb(12 17 22 / 0.045) 1px, transparent 1px);
background-size: 56px 56px;
}
/* --- cinta de advertencia, para bloques de riesgo --- */
.hazard {
background-image: repeating-linear-gradient(
-45deg,
var(--color-ember) 0 10px,
#0c1116 10px 20px
);
}
/* --- badge --- */
.badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
border: 1px solid var(--color-rule-2);
border-radius: 2px;
font-family: var(--font-mono);
font-size: 0.625rem;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 0.25rem 0.4375rem;
color: var(--color-muted);
}
.badge-signal {
border-color: var(--color-signal);
color: var(--color-signal-hi);
}
.badge-ember {
border-color: var(--color-ember-dim);
color: var(--color-ember);
}
.badge-ok {
border-color: color-mix(in srgb, var(--color-ok) 50%, transparent);
color: var(--color-ok);
}
/* --- enlace de texto --- */
.link {
color: var(--color-bright);
text-decoration: underline;
text-decoration-color: var(--color-rule-2);
text-underline-offset: 3px;
transition: text-decoration-color 140ms ease;
}
.link:hover {
text-decoration-color: var(--color-signal);
}
/* --- salto de accesibilidad --- */
.skip {
position: absolute;
left: -9999px;
top: 0;
z-index: 200;
}
.skip:focus {
left: 1rem;
top: 1rem;
background-color: var(--color-signal);
color: #fff;
padding: 0.75rem 1rem;
border-radius: var(--radius-tool);
font-family: var(--font-mono);
font-size: 0.8125rem;
}
}
/* ============================================================
Movimiento — una sola secuencia orquestada, nada disperso
============================================================ */
@keyframes rise {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes ignite {
0% {
opacity: 0;
transform: scale(0.82);
}
60% {
opacity: 1;
transform: scale(1.06);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes sweep {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
@layer components {
.anim-rise {
animation: rise 620ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.anim-ignite {
animation: ignite 420ms cubic-bezier(0.34, 1.4, 0.64, 1) both;
}
/* revelado al hacer scroll, activado por IntersectionObserver */
.reveal {
opacity: 0;
transform: translateY(18px);
transition:
opacity 620ms cubic-bezier(0.22, 1, 0.36, 1),
transform 620ms cubic-bezier(0.22, 1, 0.36, 1);
}
.reveal[data-shown="true"] {
opacity: 1;
transform: none;
}
}
+89
View File
@@ -0,0 +1,89 @@
import { ImageResponse } from "next/og";
/**
* Favicon de 32×32 generado en tiempo de compilación.
*
* El logotipo de FSPM es una llama roja dentro de un anillo, junto al texto
* "FSPM". A 32 píxeles el texto es ilegible y el anillo se cierra sobre la
* llama hasta volverse una mancha, así que aquí queda **solo la marca
* gráfica**: la silueta de la llama en rojo de señal sobre acero-humo,
* ocupando casi todo el cuadro. Es lo que hace que la pestaña se distinga
* entre veinte.
*
* Se dibuja como SVG incrustado en un data URI porque es la vía que Satori
* —el motor de `ImageResponse`— resuelve de forma más predecible que el SVG
* en línea, y porque el mismo trazo escala sin pérdida al icono de iOS.
*/
const INK = "#0c1116";
const SIGNAL = "#db042b";
/**
* Silueta de la llama, en una retícula de 100×100.
*
* Una lengua principal con la punta desplazada a la derecha y una lamida
* secundaria hacia la izquierda: es lo que se lee como fuego a tamaño mínimo,
* cuando cualquier detalle interior se fusionaría en una sola mancha.
*/
const TRAZO_LLAMA =
"M55 3 C 55 25 47 41 41 49 C 36 43 38 33 37 24 " +
"C 27 36 17 45 17 60 C 17 80 32 96 50 96 " +
"C 68 96 83 80 83 60 C 83 36 68 18 55 3 Z";
/** Caja envolvente del trazo, para centrarlo y escalarlo con exactitud. */
const CAJA_LLAMA = { x: 17, y: 3, ancho: 66, alto: 93 } as const;
export const size = { width: 32, height: 32 };
export const contentType = "image/png";
/**
* Coloca la llama con una altura dada y su centro geométrico en (cx, cy).
* Devuelve el `transform` del grupo SVG.
*/
function llamaCentrada(alto: number, cx: number, cy: number): string {
const escala = alto / CAJA_LLAMA.alto;
const dx = cx - CAJA_LLAMA.x * escala - (CAJA_LLAMA.ancho * escala) / 2;
const dy = cy - CAJA_LLAMA.y * escala - alto / 2;
return `translate(${dx.toFixed(2)} ${dy.toFixed(2)}) scale(${escala.toFixed(4)})`;
}
/** Icono completo: fondo opaco de borde a borde y llama centrada. */
function svgIcono(lado: number): string {
return [
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="${lado}" height="${lado}">`,
`<rect width="100" height="100" fill="${INK}"/>`,
`<g transform="${llamaCentrada(88, 50, 50)}">`,
`<path d="${TRAZO_LLAMA}" fill="${SIGNAL}"/>`,
`</g>`,
`</svg>`,
].join("");
}
/** Data URI listo para `<img src>`: `#` y `<` tienen que ir codificados. */
function dataUriSvg(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
export default function Icon() {
return new ImageResponse(
(
<div
style={{
display: "flex",
width: "100%",
height: "100%",
background: INK,
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
width={size.width}
height={size.height}
alt=""
src={dataUriSvg(svgIcono(size.width))}
/>
</div>
),
{ ...size },
);
}
+82
View File
@@ -0,0 +1,82 @@
import type { Metadata, Viewport } from "next";
import { Archivo, IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google";
import { EMPRESA, SITIO_URL } from "@/data/empresa";
import "./globals.css";
/** Rotulación: grotesca industrial, como la etiqueta de un equipo. */
const display = Archivo({
subsets: ["latin"],
weight: ["600", "700", "800", "900"],
variable: "--f-display",
display: "swap",
});
/** Cuerpo: tipografía nacida para documentación técnica. */
const sans = IBM_Plex_Sans({
subsets: ["latin"],
weight: ["400", "500", "600"],
variable: "--f-sans",
display: "swap",
});
/** Capa de datos: especificaciones, folios, cifras del panel. */
const mono = IBM_Plex_Mono({
subsets: ["latin"],
weight: ["400", "500", "600"],
variable: "--f-mono",
display: "swap",
});
export const metadata: Metadata = {
metadataBase: new URL(SITIO_URL),
title: {
default: `${EMPRESA.promesa} | ${EMPRESA.nombreCorto}`,
template: `%s | ${EMPRESA.nombreCorto}`,
},
description: EMPRESA.descripcion,
applicationName: EMPRESA.nombre,
authors: [{ name: EMPRESA.nombre }],
generator: "Next.js",
referrer: "strict-origin-when-cross-origin",
formatDetection: { telephone: true, address: false, email: true },
openGraph: {
type: "website",
locale: "es_MX",
siteName: EMPRESA.nombre,
url: SITIO_URL,
title: `${EMPRESA.promesa} | ${EMPRESA.nombreCorto}`,
description: EMPRESA.descripcion,
},
twitter: {
card: "summary_large_image",
title: `${EMPRESA.promesa} | ${EMPRESA.nombreCorto}`,
description: EMPRESA.descripcion,
},
robots: {
index: true,
follow: true,
googleBot: { index: true, follow: true, "max-image-preview": "large" },
},
};
export const viewport: Viewport = {
themeColor: "#0c1116",
colorScheme: "dark",
width: "device-width",
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es-MX" className={`${display.variable} ${sans.variable} ${mono.variable}`}>
<head>
{/* Sin JavaScript, el contenido revelado al hacer scroll no puede
quedarse en opacidad 0. Se declara aquí una vez, no por instancia. */}
<noscript>
<style>{".reveal{opacity:1!important;transform:none!important}"}</style>
</noscript>
</head>
<body className="bg-ink text-bright antialiased">{children}</body>
</html>
);
}
+50
View File
@@ -0,0 +1,50 @@
import type { MetadataRoute } from "next";
import { EMPRESA } from "@/data/empresa";
/**
* Manifiesto de aplicación web.
*
* No pretende convertir el sitio en una app instalable con funciones offline:
* sirve para que el navegador tenga nombre, color de tema e iconos correctos
* cuando alguien lo agrega a la pantalla de inicio, y para que la barra del
* navegador en Android tome el acero-humo en lugar de un gris genérico.
*
* Nombre, descripción e idioma salen de `src/data/empresa.ts`.
*/
export default function manifest(): MetadataRoute.Manifest {
return {
name: `${EMPRESA.nombre} — ${EMPRESA.promesa}`,
short_name: EMPRESA.nombreCorto,
description: EMPRESA.descripcion,
lang: "es-MX",
dir: "ltr",
start_url: "/",
scope: "/",
display: "standalone",
orientation: "portrait-primary",
theme_color: "#0c1116",
background_color: "#0c1116",
categories: ["business", "utilities"],
icons: [
{
// generado por src/app/icon.tsx
src: "/icon",
sizes: "32x32",
type: "image/png",
},
{
// generado por src/app/apple-icon.tsx; sirve igual como icono maskable
src: "/apple-icon",
sizes: "180x180",
type: "image/png",
purpose: "any",
},
{
src: "/apple-icon",
sizes: "180x180",
type: "image/png",
purpose: "maskable",
},
],
};
}
+340
View File
@@ -0,0 +1,340 @@
import { ImageResponse } from "next/og";
import { CLASES, type ClaseFuego } from "@/data/clases-fuego";
import { PRODUCTOS } from "@/data/catalogo";
import { EMPRESA, SITIO_URL } from "@/data/empresa";
/**
* Tarjeta social de 1200×630.
*
* La auditoría encontró que el sitio original no declaraba Open Graph: un
* enlace compartido se veía como un rectángulo gris con la URL. Esta tarjeta
* es la corrección, y está compuesta para que se reconozca como FSPM sin leer
* el texto:
*
* - Fondo acero-humo con retícula técnica, el mundo "oscuro" del sistema.
* - La marca gráfica y el nombre completo arriba.
* - La promesa de posicionamiento en tipografía pesada, con la segunda mitad
* en rojo de señal.
* - Al pie, el **riel de clases de fuego A B C D K**, el elemento firma del
* sitio. Las celdas encendidas se calculan desde `src/data/catalogo.ts`,
* no se escriben a mano: la tarjeta no puede afirmar una cobertura que el
* catálogo no respalde.
*/
export const alt = `${EMPRESA.promesa} — ${EMPRESA.nombre}`;
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
const INK = "#0c1116";
const INK_3 = "#1d262e";
const RULE = "#26313a";
const SIGNAL = "#db042b";
const BRIGHT = "#e9eff3";
const MUTED = "#8b9aa7";
const FAINT = "#5d6b77";
/** Mismo trazo de llama que el favicon y el icono de iOS. Retícula de 100×100. */
const TRAZO_LLAMA =
"M55 3 C 55 25 47 41 41 49 C 36 43 38 33 37 24 " +
"C 27 36 17 45 17 60 C 17 80 32 96 50 96 " +
"C 68 96 83 80 83 60 C 83 36 68 18 55 3 Z";
const CAJA_LLAMA = { x: 17, y: 3, ancho: 66, alto: 93 } as const;
/** Data URI listo para `<img src>`: `#` y `<` tienen que ir codificados. */
function dataUriSvg(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
/** La llama sola, sin fondo, para componerla sobre la retícula. */
function svgLlama(alto: number): string {
const escala = alto / CAJA_LLAMA.alto;
const ancho = Math.round(CAJA_LLAMA.ancho * escala);
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${ancho}" height="${Math.round(alto)}"`,
` viewBox="${CAJA_LLAMA.x} ${CAJA_LLAMA.y} ${CAJA_LLAMA.ancho} ${CAJA_LLAMA.alto}">`,
`<path d="${TRAZO_LLAMA}" fill="${SIGNAL}"/>`,
`</svg>`,
].join("");
}
/**
* Fondo de la tarjeta: acero-humo, retícula de 48 px dibujada línea por línea
* y un filete rojo de señal cruzando el borde superior.
*
* Las líneas se generan en un bucle en vez de con `repeating-linear-gradient`
* porque el CSS repetido no es de fiar en Satori; un `<line>` explícito sí.
*/
function svgFondo(ancho: number, alto: number): string {
const paso = 48;
const lineas: string[] = [];
for (let x = paso; x < ancho; x += paso) {
lineas.push(`<line x1="${x}" y1="0" x2="${x}" y2="${alto}"/>`);
}
for (let y = paso; y < alto; y += paso) {
lineas.push(`<line x1="0" y1="${y}" x2="${ancho}" y2="${y}"/>`);
}
return [
`<svg xmlns="http://www.w3.org/2000/svg" width="${ancho}" height="${alto}" viewBox="0 0 ${ancho} ${alto}">`,
`<defs>`,
// el halo va con gradiente radial, no con un círculo plano: un círculo de
// borde duro se nota como una mancha y ensucia la retícula
`<radialGradient id="halo">`,
`<stop offset="0" stop-color="${SIGNAL}" stop-opacity="0.16"/>`,
`<stop offset="1" stop-color="${SIGNAL}" stop-opacity="0"/>`,
`</radialGradient>`,
`</defs>`,
`<rect width="${ancho}" height="${alto}" fill="${INK}"/>`,
`<g stroke="${RULE}" stroke-width="1" opacity="0.55">${lineas.join("")}</g>`,
`<circle cx="${Math.round(ancho * 0.06)}" cy="${Math.round(alto * 0.1)}" r="420" fill="url(#halo)"/>`,
`<rect x="0" y="0" width="${ancho}" height="8" fill="${SIGNAL}"/>`,
`</svg>`,
].join("");
}
/** Clases de fuego que el catálogo declara cubrir, en el orden fijo del riel. */
function clasesDelCatalogo(): ClaseFuego[] {
const cubiertas = new Set<ClaseFuego>();
for (const producto of PRODUCTOS) {
for (const clase of producto.clases) cubiertas.add(clase);
}
return CLASES.filter((clase) => cubiertas.has(clase));
}
/**
* Tipografía.
*
* `ImageResponse` no puede usar `next/font`, así que Archivo e IBM Plex Mono se
* bajan de Google Fonts en tiempo de compilación, en `woff` o `truetype`
* (Satori no lee woff2). Todo va envuelto en `try`/`catch` con tiempo límite y con
* `cache: "force-cache"`: si la máquina de construcción no tiene red, la
* función devuelve `null`, `ImageResponse` cae a su tipografía por omisión y
* **la construcción no se rompe**. Una tarjeta con otra tipografía es un
* defecto cosmético; una tarjeta que truena tumba el despliegue.
*/
type FuenteOG = { name: string; data: ArrayBuffer; weight: 400 | 500 | 800; style: "normal" };
/**
* UA antiguo: obliga a la API de Google Fonts a servir `woff` o `truetype` en
* vez de `woff2`, que Satori no sabe leer.
*/
const UA_TRUETYPE =
"Mozilla/5.0 (Windows NT 6.1; WOW64; rv:27.0) Gecko/20100101 Firefox/27.0";
/** Primera URL del CSS en un formato que Satori acepte. */
const FORMATO_UTIL = /src:\s*url\((https:[^)]+)\)\s*format\('(?:truetype|woff)'\)/;
async function cargarFuente(
familia: string,
peso: 400 | 500 | 800,
): Promise<FuenteOG | null> {
try {
const consulta = `family=${familia.replace(/ /g, "+")}:wght@${peso}`;
const respuestaCss = await fetch(`https://fonts.googleapis.com/css2?${consulta}`, {
headers: { "User-Agent": UA_TRUETYPE },
cache: "force-cache",
signal: AbortSignal.timeout(4000),
});
if (!respuestaCss.ok) return null;
const css = await respuestaCss.text();
const url = FORMATO_UTIL.exec(css)?.[1];
if (!url) return null;
const respuestaFuente = await fetch(url, {
cache: "force-cache",
signal: AbortSignal.timeout(6000),
});
if (!respuestaFuente.ok) return null;
return {
name: familia,
data: await respuestaFuente.arrayBuffer(),
weight: peso,
style: "normal",
};
} catch {
return null;
}
}
async function cargarFuentes(): Promise<FuenteOG[]> {
const resultados = await Promise.all([
cargarFuente("Archivo", 800),
cargarFuente("IBM Plex Mono", 500),
]);
return resultados.filter((fuente): fuente is FuenteOG => fuente !== null);
}
/** Pila tipográfica: si la fuente no se pudo bajar, Satori usa la suya. */
const PILA_DISPLAY = "Archivo";
const PILA_MONO = "IBM Plex Mono";
export default async function OpenGraphImage() {
const fuentes = await cargarFuentes();
const activas = clasesDelCatalogo();
const host = SITIO_URL.replace(/^https?:\/\//, "");
return new ImageResponse(
(
<div
style={{
position: "relative",
display: "flex",
width: "100%",
height: "100%",
backgroundColor: INK,
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
width={size.width}
height={size.height}
alt=""
src={dataUriSvg(svgFondo(size.width, size.height))}
style={{ position: "absolute", top: 0, left: 0 }}
/>
<div
style={{
position: "relative",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
width: "100%",
height: "100%",
padding: "62px 68px 58px 68px",
}}
>
{/* --- marca --- */}
<div style={{ display: "flex", alignItems: "center", gap: 22 }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img alt="" src={dataUriSvg(svgLlama(74))} height={74} />
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div
style={{
display: "flex",
fontFamily: PILA_DISPLAY,
fontWeight: 800,
fontSize: 44,
lineHeight: 1,
letterSpacing: "0.04em",
color: BRIGHT,
}}
>
{EMPRESA.nombreCorto}
</div>
<div
style={{
display: "flex",
fontFamily: PILA_MONO,
fontSize: 17,
lineHeight: 1,
letterSpacing: "0.2em",
color: MUTED,
}}
>
{EMPRESA.nombre.toUpperCase()}
</div>
</div>
</div>
{/* --- promesa --- */}
<div style={{ display: "flex", flexDirection: "column" }}>
<div
style={{
display: "flex",
width: 84,
height: 5,
backgroundColor: SIGNAL,
marginBottom: 30,
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
fontFamily: PILA_DISPLAY,
fontWeight: 800,
fontSize: 70,
lineHeight: 1.06,
letterSpacing: "-0.02em",
}}
>
<div style={{ display: "flex", color: BRIGHT }}>
Protección contra incendios
</div>
<div style={{ display: "flex", color: SIGNAL }}>
para operaciones críticas
</div>
</div>
</div>
{/* --- riel de clases de fuego: el elemento firma --- */}
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div
style={{
display: "flex",
fontFamily: PILA_MONO,
fontSize: 15,
lineHeight: 1,
letterSpacing: "0.2em",
color: FAINT,
}}
>
CLASES DE FUEGO
</div>
<div style={{ display: "flex", gap: 8 }}>
{CLASES.map((clase) => {
const encendida = activas.includes(clase);
return (
<div
key={clase}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 58,
height: 58,
borderRadius: 2,
border: `2px solid ${encendida ? SIGNAL : RULE}`,
backgroundColor: encendida ? SIGNAL : INK_3,
color: encendida ? "#ffffff" : FAINT,
fontFamily: PILA_MONO,
fontWeight: 500,
fontSize: 26,
lineHeight: 1,
}}
>
{clase}
</div>
);
})}
</div>
</div>
<div
style={{
display: "flex",
fontFamily: PILA_MONO,
fontSize: 19,
lineHeight: 1,
letterSpacing: "0.06em",
color: MUTED,
}}
>
{host}
</div>
</div>
</div>
</div>
),
{ ...size, fonts: fuentes.length > 0 ? fuentes : undefined },
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { MetadataRoute } from "next";
import { urlAbsoluta } from "@/lib/seo";
/**
* `robots.txt`.
*
* Todo el sitio público es rastreable. Se prohíben `/admin/` y `/api/`.
*
* Que quede claro qué hace y qué no hace esto: un `Disallow` no protege el
* panel —cualquiera puede pedir la URL— y no es un control de acceso. Eso lo
* hace `src/middleware.ts` con la cookie de sesión firmada. Lo que `robots.txt`
* evita es que el panel y los endpoints aparezcan en resultados de búsqueda, y
* que un rastreador gaste presupuesto en rutas que siempre van a redirigir al
* inicio de sesión.
*/
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/admin/", "/admin", "/api/"],
},
],
sitemap: urlAbsoluta("/sitemap.xml"),
host: urlAbsoluta("/"),
};
}
+67
View File
@@ -0,0 +1,67 @@
import type { MetadataRoute } from "next";
import { CATEGORIAS, PRODUCTOS } from "@/data/catalogo";
import { INDUSTRIAS } from "@/data/industrias";
import { urlAbsoluta } from "@/lib/seo";
/**
* Mapa del sitio.
*
* Las rutas se derivan de `src/data/`, no se escriben a mano: si mañana entra
* una industria o un producto nuevo, el sitemap lo recoge sin que nadie tenga
* que acordarse de venir aquí.
*
* **El panel queda fuera a propósito.** Ni `/admin` ni nada que cuelgue de él
* aparece: es requisito del cliente que el panel no se exponga en el frente.
* Tampoco van las rutas de `/api`.
*
* Las prioridades siguen el hallazgo del estudio de marca: el visitante debería
* llegar por su industria, no por el catálogo. Por eso `/soluciones` y sus cinco
* rutas de segmento están al mismo nivel que el inicio, y el catálogo va
* después. El aviso de privacidad va al fondo.
*/
/** Una sola marca de tiempo por construcción: el contenido es estático. */
const AHORA = new Date();
type Entrada = MetadataRoute.Sitemap[number];
function entrada(
ruta: string,
priority: number,
changeFrequency: NonNullable<Entrada["changeFrequency"]>,
): Entrada {
return { url: urlAbsoluta(ruta), lastModified: AHORA, changeFrequency, priority };
}
export default function sitemap(): MetadataRoute.Sitemap {
return [
// --- puerta de entrada ---
entrada("/", 1, "weekly"),
// --- ruta por industria: la que el estudio pide privilegiar ---
entrada("/soluciones", 0.9, "monthly"),
...INDUSTRIAS.map((industria) =>
entrada(`/soluciones/${industria.slug}`, 0.9, "monthly"),
),
// --- ruta por obligación normativa y por evidencia ---
entrada("/cumplimiento-nom-002", 0.8, "monthly"),
entrada("/evidencia", 0.7, "monthly"),
// --- conversión ---
entrada("/contacto", 0.8, "monthly"),
// --- catálogo: profundidad, no puerta de entrada ---
entrada("/catalogo", 0.8, "monthly"),
...CATEGORIAS.map((categoria) => entrada(`/catalogo/${categoria.slug}`, 0.7, "monthly")),
...PRODUCTOS.map((producto) => entrada(`/producto/${producto.slug}`, 0.6, "monthly")),
// --- apoyo a la decisión ---
entrada("/preguntas-frecuentes", 0.6, "monthly"),
entrada("/casos", 0.6, "monthly"),
entrada("/nosotros", 0.5, "yearly"),
// --- obligatorio, pero no es lo que se busca ---
entrada("/aviso-de-privacidad", 0.2, "yearly"),
];
}
+26
View File
@@ -0,0 +1,26 @@
import ImagenOpenGraph, {
alt as altOpenGraph,
contentType as contentTypeOpenGraph,
size as sizeOpenGraph,
} from "./opengraph-image";
/**
* Tarjeta para X / Twitter, 1200×630.
*
* Es exactamente la misma composición que Open Graph: misma proporción, mismo
* `summary_large_image`. Se reutiliza en vez de duplicarse para que la marca no
* pueda quedar desalineada entre las dos redes cuando alguien ajuste una y
* olvide la otra.
*
* Las constantes se reasignan a mano en lugar de con `export { ... } from`:
* el cargador de rutas de metadatos de Next no reconoce un reexport puro y deja
* el módulo sin exportaciones.
*/
export const alt = altOpenGraph;
export const size = sizeOpenGraph;
export const contentType = contentTypeOpenGraph;
export default function TwitterImage() {
return ImagenOpenGraph();
}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
/**
* Barra de filtros genérica: una fila de selectores más una búsqueda, arriba
* del contenido que alcanza. Todo va a la query string.
*
* La usan los listados de contactos y de pedidos; el de oportunidades tiene su
* propia versión porque su dominio de filtros es más largo.
*/
export type SelectorFiltro = {
clave: string;
etiqueta: string;
/** Texto de la opción neutra. */
todos: string;
opciones: { valor: string; etiqueta: string }[];
};
export function BarraFiltros({
selectores = [],
marcador,
}: {
selectores?: SelectorFiltro[];
/** Placeholder de la búsqueda; dice por qué campos busca. */
marcador: string;
}) {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
const [q, setQ] = useState(sp.get("q") ?? "");
useEffect(() => {
setQ(sp.get("q") ?? "");
}, [sp]);
function navegar(cambios: Record<string, string>) {
const siguiente = new URLSearchParams(sp.toString());
for (const [k, v] of Object.entries(cambios)) {
if (v === "") siguiente.delete(k);
else siguiente.set(k, v);
}
siguiente.delete("pagina");
const cadena = siguiente.toString();
router.push(cadena ? `${pathname}?${cadena}` : pathname);
}
const hayFiltro =
(sp.get("q") ?? "") !== "" || selectores.some((s) => (sp.get(s.clave) ?? "") !== "");
return (
<div className="flex flex-wrap items-end gap-3 border-b border-rule px-4 py-3 sm:px-5">
{selectores.map((s) => (
<div key={s.clave} className="min-w-0">
<label htmlFor={`bf-${s.clave}`} className="field-label">
{s.etiqueta}
</label>
<select
id={`bf-${s.clave}`}
value={sp.get(s.clave) ?? ""}
onChange={(e) => navegar({ [s.clave]: e.target.value })}
className="field w-auto min-w-[9rem] max-w-[14rem] py-2 pr-8 text-sm"
style={{ appearance: "none", WebkitAppearance: "none" }}
>
<option value="">{s.todos}</option>
{s.opciones.map((o) => (
<option key={o.valor} value={o.valor}>
{o.etiqueta}
</option>
))}
</select>
</div>
))}
<form
className="flex min-w-0 flex-1 items-end gap-2"
onSubmit={(e) => {
e.preventDefault();
navegar({ q: q.trim() });
}}
>
<div className="min-w-0 flex-1 sm:min-w-[14rem]">
<label htmlFor="bf-q" className="field-label">
Buscar
</label>
<input
id="bf-q"
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={marcador}
className="field py-2 text-sm"
style={{ appearance: "none", WebkitAppearance: "none" }}
/>
</div>
<button
type="submit"
className="btn btn-ghost btn-sm"
style={{ appearance: "none", WebkitTapHighlightColor: "transparent" }}
>
Buscar
</button>
</form>
{hayFiltro ? (
<button
type="button"
onClick={() => router.push(pathname)}
className="pb-2 font-mono text-[0.625rem] uppercase tracking-[0.1em] text-signal-hi underline decoration-rule-2 underline-offset-[3px] hover:decoration-signal"
style={{ appearance: "none", WebkitTapHighlightColor: "transparent" }}
>
Limpiar
</button>
) : null}
</div>
);
}
+270
View File
@@ -0,0 +1,270 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { ROL_ETIQUETA, type Rol } from "@/lib/crm/constantes";
import { AVISO_DEMO } from "@/data/empresa";
import { accionCerrarSesion } from "@/lib/crm/acciones";
/**
* Cascarón del panel: barra lateral fija en escritorio, cajón deslizable en
* móvil. Es lo único de cliente del layout —necesita `usePathname` para marcar
* la sección activa y estado para el cajón—; el contenido llega ya renderizado
* desde el servidor.
*
* El panel no monta el widget de WhatsApp del sitio público: aquí adentro el
* usuario ya es de la casa.
*/
type Enlace = { href: string; label: string; soloAdmin?: boolean };
const ENLACES: Enlace[] = [
{ href: "/admin", label: "Tablero" },
{ href: "/admin/oportunidades", label: "Oportunidades" },
{ href: "/admin/contactos", label: "Contactos" },
{ href: "/admin/pedidos", label: "Pedidos" },
{ href: "/admin/analitica", label: "Analítica" },
{ href: "/admin/equipo", label: "Equipo", soloAdmin: true },
{ href: "/admin/reglas", label: "Reglas", soloAdmin: true },
];
function esActivo(pathname: string, href: string): boolean {
if (href === "/admin") return pathname === "/admin";
return pathname === href || pathname.startsWith(`${href}/`);
}
export function Cascaron({
nombre,
rol,
children,
}: {
nombre: string;
rol: Rol;
children: ReactNode;
}) {
const pathname = usePathname();
const [abierto, setAbierto] = useState(false);
const cajonId = useId();
const botonRef = useRef<HTMLButtonElement>(null);
const enlaces = ENLACES.filter((e) => !e.soloAdmin || rol === "ADMIN");
// El cajón se cierra al navegar: en móvil nadie quiere cerrarlo a mano.
useEffect(() => {
setAbierto(false);
}, [pathname]);
useEffect(() => {
if (!abierto) return;
function alTecla(e: KeyboardEvent) {
if (e.key === "Escape") {
setAbierto(false);
botonRef.current?.focus();
}
}
document.addEventListener("keydown", alTecla);
return () => document.removeEventListener("keydown", alTecla);
}, [abierto]);
return (
<div className="min-h-screen bg-ink text-bright" style={{ minHeight: "100dvh" }}>
<a href="#panel" className="skip">
Ir al contenido del panel
</a>
{/* ---------- barra lateral, escritorio ---------- */}
<aside className="fixed inset-y-0 left-0 z-30 hidden w-60 flex-col border-r border-rule bg-ink-2 lg:flex">
<Marca />
<Navegacion enlaces={enlaces} pathname={pathname} />
<PieLateral rol={rol} />
</aside>
{/* ---------- cajón, móvil ---------- */}
<div
aria-hidden={!abierto}
className={[
"fixed inset-0 z-40 lg:hidden",
abierto ? "pointer-events-auto" : "pointer-events-none",
].join(" ")}
>
<button
type="button"
tabIndex={abierto ? 0 : -1}
aria-label="Cerrar la navegación"
onClick={() => setAbierto(false)}
className={[
"absolute inset-0 bg-ink/80 transition-opacity duration-200",
abierto ? "opacity-100" : "opacity-0",
].join(" ")}
style={{
backdropFilter: "blur(3px)",
WebkitBackdropFilter: "blur(3px)",
WebkitTapHighlightColor: "transparent",
appearance: "none",
}}
/>
<nav
id={cajonId}
aria-label="Navegación del panel"
className={[
"absolute inset-y-0 left-0 flex h-screen w-[17rem] max-w-[85vw] flex-col border-r border-rule bg-ink-2 transition-transform duration-200 ease-out",
abierto ? "translate-x-0" : "-translate-x-full",
].join(" ")}
style={{ height: "100dvh" }}
>
<Marca />
<Navegacion enlaces={enlaces} pathname={pathname} tabbable={abierto} />
<PieLateral rol={rol} />
</nav>
</div>
{/* ---------- columna de contenido ---------- */}
<div className="flex min-w-0 flex-col lg:pl-60">
<FranjaDemo />
<header
className="sticky top-0 z-20 flex min-w-0 items-center gap-3 border-b border-rule bg-ink-2/95 px-4 py-2.5 sm:px-6"
style={{ backdropFilter: "blur(6px)", WebkitBackdropFilter: "blur(6px)" }}
>
<button
ref={botonRef}
type="button"
onClick={() => setAbierto((v) => !v)}
aria-expanded={abierto}
aria-controls={cajonId}
className="btn btn-ghost btn-sm shrink-0 lg:hidden"
style={{ WebkitTapHighlightColor: "transparent", appearance: "none" }}
>
<span aria-hidden="true" className="block">
<svg width="14" height="12" viewBox="0 0 14 12" fill="none" aria-hidden="true">
<path d="M0 1h14M0 6h14M0 11h14" stroke="currentColor" strokeWidth="1.5" />
</svg>
</span>
Menú
</button>
<p className="min-w-0 truncate font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-faint">
Panel comercial
</p>
<div className="ml-auto flex min-w-0 items-center gap-2 sm:gap-3">
<span className="hidden min-w-0 truncate text-sm text-bright sm:block">{nombre}</span>
<span className={rol === "ADMIN" ? "badge badge-signal" : "badge"}>
{ROL_ETIQUETA[rol]}
</span>
<BotonSalir />
</div>
</header>
<main id="panel" className="min-w-0 flex-1 px-4 py-6 sm:px-6 sm:py-8">
{children}
</main>
</div>
</div>
);
}
function Marca() {
return (
<div className="flex items-center gap-3 border-b border-rule px-4 py-4">
<Image
src="/media/marca/fspm-logo-blanco.png"
alt="Fire Service Plus México"
width={120}
height={32}
priority
className="h-7 w-auto"
/>
<span className="font-mono text-[0.5625rem] uppercase tracking-[0.16em] text-faint">
CRM
</span>
</div>
);
}
function Navegacion({
enlaces,
pathname,
tabbable = true,
}: {
enlaces: Enlace[];
pathname: string;
tabbable?: boolean;
}) {
return (
<ul className="flex-1 overflow-y-auto py-3" style={{ WebkitOverflowScrolling: "touch" }}>
{enlaces.map((e) => {
const activo = esActivo(pathname, e.href);
return (
<li key={e.href}>
<Link
href={e.href}
tabIndex={tabbable ? undefined : -1}
aria-current={activo ? "page" : undefined}
className={[
"flex items-center gap-3 border-l-2 px-4 py-2.5 font-mono text-[0.75rem] uppercase tracking-[0.1em] transition-colors",
activo
? "border-signal bg-ink-3 text-bright"
: "border-transparent text-muted hover:bg-ink-3/60 hover:text-bright",
].join(" ")}
style={{ WebkitTapHighlightColor: "transparent" }}
>
{e.label}
</Link>
</li>
);
})}
</ul>
);
}
function PieLateral({ rol }: { rol: Rol }) {
return (
<div className="border-t border-rule px-4 py-3">
<p className="font-mono text-[0.5625rem] uppercase leading-relaxed tracking-[0.12em] text-faint">
{rol === "ADMIN"
? "Alcance: toda la operación"
: "Alcance: solo lo asignado a ti"}
</p>
</div>
);
}
function FranjaDemo() {
return (
<div className="flex items-start gap-2.5 border-b border-rule bg-ink-3 px-4 py-2 sm:px-6">
<span aria-hidden="true" className="mt-1 block h-2.5 w-2.5 shrink-0 hazard rounded-[1px]" />
<p className="min-w-0 font-mono text-[0.625rem] uppercase leading-relaxed tracking-[0.1em] text-muted">
Entorno de demostración · {AVISO_DEMO}
</p>
</div>
);
}
function BotonSalir() {
const [saliendo, setSaliendo] = useState(false);
async function salir() {
setSaliendo(true);
try {
await accionCerrarSesion();
} finally {
setSaliendo(false);
}
}
return (
<form action={salir}>
<button
type="submit"
disabled={saliendo}
className="btn btn-ghost btn-sm"
style={{ WebkitTapHighlightColor: "transparent", appearance: "none" }}
>
{saliendo ? "Saliendo…" : "Salir"}
</button>
</form>
);
}
@@ -0,0 +1,203 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import {
ETAPAS,
ETAPA_ETIQUETA,
ORIGENES,
ORIGEN_ETIQUETA,
} from "@/lib/crm/constantes";
/**
* Fila única de filtros, arriba de la tabla. Todo va a la query string: la
* URL es la vista, y por eso se puede pegar en un chat.
*
* Los `select` cambian la vista al soltar; la búsqueda espera el Enter o el
* botón, porque nadie quiere una navegación por cada tecla.
*/
export type OpcionSimple = { valor: string; etiqueta: string };
export function FiltrosOportunidades({
industrias,
campanas,
propietarios,
}: {
industrias: OpcionSimple[];
campanas: OpcionSimple[];
/** Solo se pasa para ADMIN. Un vendedor no filtra por propietario. */
propietarios?: OpcionSimple[];
}) {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
const [q, setQ] = useState(sp.get("q") ?? "");
useEffect(() => {
setQ(sp.get("q") ?? "");
}, [sp]);
function navegar(cambios: Record<string, string>) {
const siguiente = new URLSearchParams(sp.toString());
for (const [k, v] of Object.entries(cambios)) {
if (v === "") siguiente.delete(k);
else siguiente.set(k, v);
}
// Cualquier cambio de filtro vuelve a la primera página.
siguiente.delete("pagina");
const cadena = siguiente.toString();
router.push(cadena ? `${pathname}?${cadena}` : pathname);
}
const hayFiltro = [
"etapa",
"origen",
"industria",
"campana",
"propietario",
"q",
"abiertas",
].some((k) => (sp.get(k) ?? "") !== "");
return (
<div className="border-b border-rule px-4 py-3 sm:px-5">
<div className="flex flex-wrap items-end gap-3">
<Selector
id="filtro-etapa"
etiqueta="Etapa"
valor={sp.get("etapa") ?? ""}
opciones={ETAPAS.map((e) => ({ valor: e, etiqueta: ETAPA_ETIQUETA[e] }))}
onCambio={(v) => navegar({ etapa: v })}
/>
<Selector
id="filtro-origen"
etiqueta="Origen"
valor={sp.get("origen") ?? ""}
opciones={ORIGENES.map((o) => ({ valor: o, etiqueta: ORIGEN_ETIQUETA[o] }))}
onCambio={(v) => navegar({ origen: v })}
/>
<Selector
id="filtro-industria"
etiqueta="Industria"
valor={sp.get("industria") ?? ""}
opciones={industrias}
onCambio={(v) => navegar({ industria: v })}
/>
<Selector
id="filtro-campana"
etiqueta="Campaña"
valor={sp.get("campana") ?? ""}
opciones={campanas}
onCambio={(v) => navegar({ campana: v })}
/>
{propietarios ? (
<Selector
id="filtro-propietario"
etiqueta="Propietario"
valor={sp.get("propietario") ?? ""}
opciones={propietarios}
onCambio={(v) => navegar({ propietario: v })}
/>
) : null}
<form
className="flex min-w-0 flex-1 items-end gap-2"
onSubmit={(e) => {
e.preventDefault();
navegar({ q: q.trim() });
}}
>
<div className="min-w-0 flex-1 sm:min-w-[13rem]">
<label htmlFor="filtro-q" className="field-label">
Buscar
</label>
<input
id="filtro-q"
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Folio, nombre, empresa o correo"
className="field py-2 text-sm"
style={{ appearance: "none", WebkitAppearance: "none" }}
/>
</div>
<button
type="submit"
className="btn btn-ghost btn-sm"
style={{ appearance: "none", WebkitTapHighlightColor: "transparent" }}
>
Buscar
</button>
</form>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3">
<label className="flex cursor-pointer items-center gap-2 font-mono text-[0.625rem] uppercase tracking-[0.1em] text-muted">
<input
type="checkbox"
checked={(sp.get("abiertas") ?? "") === "1"}
onChange={(e) => navegar({ abiertas: e.target.checked ? "1" : "" })}
className="h-3.5 w-3.5 rounded-[2px] border border-rule-2 bg-ink accent-signal"
style={{ appearance: "auto", WebkitAppearance: "checkbox" }}
/>
Solo abiertas
</label>
{hayFiltro ? (
<button
type="button"
onClick={() => router.push(pathname)}
className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-signal-hi underline decoration-rule-2 underline-offset-[3px] hover:decoration-signal"
style={{ appearance: "none", WebkitTapHighlightColor: "transparent" }}
>
Limpiar filtros
</button>
) : null}
</div>
</div>
);
}
function Selector({
id,
etiqueta,
valor,
opciones,
onCambio,
}: {
id: string;
etiqueta: string;
valor: string;
opciones: OpcionSimple[];
onCambio: (v: string) => void;
}) {
if (opciones.length === 0) return null;
return (
<div className="min-w-0">
<label htmlFor={id} className="field-label">
{etiqueta}
</label>
<select
id={id}
value={valor}
onChange={(e) => onCambio(e.target.value)}
className="field w-auto min-w-[8.5rem] max-w-[13rem] py-2 pr-8 text-sm"
style={{ appearance: "none", WebkitAppearance: "none" }}
>
<option value="">Todas</option>
{opciones.map((o) => (
<option key={o.valor} value={o.valor}>
{o.etiqueta}
</option>
))}
</select>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import Link from "next/link";
import { conParametros, type Consulta } from "@/components/admin/enlaces";
import { entero } from "@/components/admin/formato";
/**
* Paginación de un `Pagina<T>`. Enlaces reales, no botones: la página vive en
* la URL, así que el navegador atrás funciona y la vista se puede compartir.
*/
export function Paginacion({
ruta,
sp,
pagina,
paginas,
total,
porPagina,
etiqueta,
}: {
ruta: string;
sp: Consulta;
pagina: number;
paginas: number;
total: number;
porPagina: number;
/** Cómo se llama lo que se pagina, en plural. */
etiqueta: string;
}) {
const desde = total === 0 ? 0 : (pagina - 1) * porPagina + 1;
const hasta = Math.min(total, pagina * porPagina);
const hayPrevia = pagina > 1;
const haySiguiente = pagina < paginas;
return (
<nav
aria-label={`Paginación de ${etiqueta}`}
className="flex flex-wrap items-center justify-between gap-3 border-t border-rule px-4 py-3 sm:px-5"
>
<p className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-faint">
{total === 0 ? (
<>Sin resultados</>
) : (
<>
{entero(desde)}–{entero(hasta)} de {entero(total)} {etiqueta}
</>
)}
</p>
<div className="flex items-center gap-2">
{hayPrevia ? (
<Link
href={conParametros(ruta, sp, { pagina: pagina - 1 })}
rel="prev"
className="btn btn-ghost btn-sm"
style={{ WebkitTapHighlightColor: "transparent" }}
>
Anterior
</Link>
) : (
<span className="btn btn-ghost btn-sm pointer-events-none opacity-40" aria-hidden="true">
Anterior
</span>
)}
<span className="font-mono text-[0.6875rem] tabular-nums text-muted">
{pagina} / {Math.max(1, paginas)}
</span>
{haySiguiente ? (
<Link
href={conParametros(ruta, sp, { pagina: pagina + 1 })}
rel="next"
className="btn btn-ghost btn-sm"
style={{ WebkitTapHighlightColor: "transparent" }}
>
Siguiente
</Link>
) : (
<span className="btn btn-ghost btn-sm pointer-events-none opacity-40" aria-hidden="true">
Siguiente
</span>
)}
</div>
</nav>
);
}
+101
View File
@@ -0,0 +1,101 @@
import Link from "next/link";
import type { OportunidadResumen } from "@/lib/crm/tipos";
import { ORIGEN_ETIQUETA } from "@/lib/crm/constantes";
import { antiguedad, horas, oRaya } from "@/components/admin/formato";
import { EnlaceFolio, EstadoVacio } from "@/components/admin/ui";
/**
* Oportunidades sin primera respuesta, las más viejas primero.
*
* Es lo más accionable del tablero y por eso tiene el peso visual más alto de
* la página: cinta de advertencia, cifras grandes en ámbar y rojo. Un lead que
* lleva horas esperando es dinero enfriándose.
*/
export function PanelSinAtender({ filas }: { filas: OportunidadResumen[] }) {
if (filas.length === 0) {
return (
<EstadoVacio
titulo="Nada esperando"
quehacer="Todas las oportunidades del periodo tienen primera respuesta registrada. Mantén el ritmo: la ventana de las primeras horas es la que cierra."
/>
);
}
const criticas = filas.filter((f) => (f.horasSinRespuesta ?? 0) >= 24).length;
return (
<div>
<div className="flex items-start gap-3 border-b border-rule bg-ink-3 px-4 py-3 sm:px-5">
<span aria-hidden="true" className="hazard mt-0.5 block h-4 w-4 shrink-0 rounded-[1px]" />
<p className="min-w-0 text-xs leading-relaxed text-muted">
<strong className="font-semibold text-bright">{filas.length}</strong>{" "}
{filas.length === 1 ? "oportunidad" : "oportunidades"} sin primera respuesta
{criticas > 0 ? (
<>
, de las cuales{" "}
<strong className="font-semibold text-signal-hi">{criticas}</strong> rebasan las
24 horas
</>
) : null}
. Atiende de arriba hacia abajo.
</p>
</div>
<ul className="divide-y divide-rule/60">
{filas.map((o) => {
const h = o.horasSinRespuesta ?? 0;
const critica = h >= 24;
return (
<li
key={o.id}
className="flex min-w-0 flex-col gap-2 px-4 py-3 transition-colors hover:bg-ink-3/50 sm:flex-row sm:items-center sm:gap-4 sm:px-5"
>
<span
aria-hidden="true"
className="hidden h-8 w-[3px] shrink-0 rounded-[1px] sm:block"
style={{
backgroundColor: critica ? "var(--color-signal)" : "var(--color-ember)",
}}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<EnlaceFolio href={`/admin/oportunidades/${o.id}`} folio={o.folio} />
<span className="min-w-0 truncate text-sm text-bright">
{o.contacto.nombre}
</span>
{o.contacto.empresa ? (
<span className="min-w-0 truncate text-xs text-muted">
{o.contacto.empresa}
</span>
) : null}
</div>
<p className="mt-0.5 font-mono text-[0.625rem] uppercase tracking-[0.08em] text-faint">
{ORIGEN_ETIQUETA[o.origen]} · {oRaya(o.productoNombre)} · entró hace{" "}
{antiguedad(o.creadoEn)}
</p>
</div>
<div className="flex shrink-0 items-center justify-between gap-3 sm:justify-end">
<span
className="font-mono text-lg font-semibold tabular-nums leading-none"
style={{ color: critica ? "var(--color-signal-hi)" : "var(--color-ember)" }}
>
{horas(h)}
</span>
<Link
href={`/admin/oportunidades/${o.id}`}
className="btn btn-ghost btn-sm"
style={{ WebkitTapHighlightColor: "transparent" }}
>
Atender
<span className="sr-only"> la oportunidad {o.folio}</span>
</Link>
</div>
</li>
);
})}
</ul>
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import Link from "next/link";
import { conParametros, type Consulta } from "@/components/admin/enlaces";
export const PERIODOS = [7, 30, 90] as const;
export const PERIODO_POR_OMISION = 30;
/** Lee `?dias=` y lo limita al dominio permitido. */
export function leerDias(sp: Consulta): number {
const v = sp.dias;
const bruto = Array.isArray(v) ? v[0] : v;
const n = Number.parseInt(bruto ?? "", 10);
return (PERIODOS as readonly number[]).includes(n) ? n : PERIODO_POR_OMISION;
}
/**
* Rango de fechas del tablero. Va arriba de todo el contenido que alcanza —una
* sola fila de filtros— y viaja en la query string: la vista es compartible.
*/
export function SelectorPeriodo({
ruta,
sp,
dias,
}: {
ruta: string;
sp: Consulta;
dias: number;
}) {
return (
<div
role="group"
aria-label="Periodo del reporte"
className="inline-flex overflow-hidden rounded-[3px] border border-rule-2"
>
{PERIODOS.map((d, i) => {
const activo = d === dias;
return (
<Link
key={d}
href={conParametros(ruta, sp, { dias: d, pagina: undefined })}
aria-current={activo ? "true" : undefined}
className={[
"px-3 py-2 font-mono text-[0.6875rem] font-semibold uppercase tracking-[0.08em] transition-colors",
i > 0 ? "border-l border-rule-2" : "",
activo
? "bg-signal text-white"
: "bg-ink text-muted hover:bg-ink-3 hover:text-bright",
].join(" ")}
style={{ WebkitTapHighlightColor: "transparent" }}
>
{d} días
</Link>
);
})}
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
import Link from "next/link";
import type { FilaCampana } from "@/lib/crm/tipos";
import { conParametros, param, type Consulta } from "@/components/admin/enlaces";
import { entero, monedaCorta, oRaya } from "@/components/admin/formato";
import { ContenedorTabla, EstadoVacio, Td, Th } from "@/components/admin/ui";
import { Medidor } from "@/components/admin/graficas/BarrasMagnitud";
/**
* Efectividad por campaña. Es la razón por la que el sitio captura UTMs: sin
* esta tabla la atribución sería un campo de base de datos que nadie mira.
*
* El orden viaja en la query string, así que la vista ordenada se comparte
* igual que cualquier otra.
*/
const CLAVES = ["oportunidades", "contactos", "cierre", "monto"] as const;
type ClaveOrden = (typeof CLAVES)[number];
const ETIQUETA: Record<ClaveOrden, string> = {
oportunidades: "Oportunidades",
contactos: "Contactos",
cierre: "Tasa de cierre",
monto: "Monto ganado",
};
function leerOrden(sp: Consulta): { clave: ClaveOrden; desc: boolean } {
const bruto = param(sp, "campOrden");
const clave = (CLAVES as readonly string[]).includes(bruto)
? (bruto as ClaveOrden)
: "oportunidades";
return { clave, desc: param(sp, "campDir") !== "asc" };
}
function ordenar(filas: FilaCampana[], clave: ClaveOrden, desc: boolean): FilaCampana[] {
const valor = (f: FilaCampana): number => {
switch (clave) {
case "contactos":
return f.contactos;
case "cierre":
return f.tasaCierre;
case "monto":
return f.montoGanado;
default:
return f.oportunidades;
}
};
return [...filas].sort((a, b) => (desc ? valor(b) - valor(a) : valor(a) - valor(b)));
}
function nombreCampana(f: FilaCampana): string {
return f.utmCampaign ?? "Sin campaña";
}
function EncabezadoOrdenable({
clave,
ruta,
sp,
actual,
ancla,
}: {
clave: ClaveOrden;
ruta: string;
sp: Consulta;
actual: { clave: ClaveOrden; desc: boolean };
ancla: string;
}) {
const activo = actual.clave === clave;
// Un clic en la columna activa invierte; en otra columna empieza por lo más alto.
const siguienteDir = activo && actual.desc ? "asc" : "desc";
const href = `${conParametros(ruta, sp, { campOrden: clave, campDir: siguienteDir })}#${ancla}`;
return (
<th
scope="col"
aria-sort={activo ? (actual.desc ? "descending" : "ascending") : "none"}
className="whitespace-nowrap border-b border-rule px-3 py-2.5 text-right font-mono text-[0.625rem] font-semibold uppercase tracking-[0.12em] text-faint"
>
<Link
href={href}
className="inline-flex items-center gap-1 transition-colors hover:text-bright"
style={{ WebkitTapHighlightColor: "transparent" }}
>
{ETIQUETA[clave]}
<span aria-hidden="true" className={activo ? "text-signal" : "text-rule-2"}>
{activo ? (actual.desc ? "▼" : "▲") : "↕"}
</span>
<span className="sr-only">
{activo
? actual.desc
? ", ordenado de mayor a menor. Activa para invertir."
: ", ordenado de menor a mayor. Activa para invertir."
: ". Activa para ordenar por esta columna."}
</span>
</Link>
</th>
);
}
export function TablaCampanas({
campanas,
ruta,
sp,
ancla = "campanas",
limite,
detallada = false,
}: {
campanas: FilaCampana[];
ruta: string;
sp: Consulta;
ancla?: string;
/** Recorta la tabla en el tablero; en analítica va completa. */
limite?: number;
/** Agrega source y medium en columnas propias. */
detallada?: boolean;
}) {
if (campanas.length === 0) {
return (
<EstadoVacio
titulo="Sin campañas atribuidas"
quehacer="Todavía no llega tráfico etiquetado. Agrega utm_source, utm_medium y utm_campaign a los enlaces de anuncios y correos, y esta tabla se llena sola."
/>
);
}
const orden = leerOrden(sp);
const ordenadas = ordenar(campanas, orden.clave, orden.desc);
const filas = limite ? ordenadas.slice(0, limite) : ordenadas;
return (
<>
<ContenedorTabla etiqueta="Efectividad por campaña">
<table className="w-full min-w-[42rem] border-collapse text-left">
<caption className="sr-only">
Efectividad por campaña, ordenada por {ETIQUETA[orden.clave].toLowerCase()}{" "}
{orden.desc ? "de mayor a menor" : "de menor a mayor"}
</caption>
<thead>
<tr>
<Th>Campaña</Th>
{detallada ? <Th>Fuente</Th> : null}
{detallada ? <Th>Medio</Th> : null}
<EncabezadoOrdenable clave="oportunidades" ruta={ruta} sp={sp} actual={orden} ancla={ancla} />
<EncabezadoOrdenable clave="contactos" ruta={ruta} sp={sp} actual={orden} ancla={ancla} />
<EncabezadoOrdenable clave="cierre" ruta={ruta} sp={sp} actual={orden} ancla={ancla} />
<EncabezadoOrdenable clave="monto" ruta={ruta} sp={sp} actual={orden} ancla={ancla} />
</tr>
</thead>
<tbody>
{filas.map((f) => (
<tr
key={`${f.utmSource ?? "-"}|${f.utmMedium ?? "-"}|${f.utmCampaign ?? "-"}`}
className="transition-colors hover:bg-ink-3/50"
>
<Th scope="row" className="font-mono text-[0.75rem] normal-case tracking-normal text-bright">
{nombreCampana(f)}
{!detallada ? (
<span className="mt-0.5 block text-[0.625rem] uppercase tracking-[0.08em] text-faint">
{oRaya(f.utmSource)} / {oRaya(f.utmMedium)}
</span>
) : null}
</Th>
{detallada ? <Td mono>{oRaya(f.utmSource)}</Td> : null}
{detallada ? <Td mono>{oRaya(f.utmMedium)}</Td> : null}
<Td numerico>{entero(f.oportunidades)}</Td>
<Td numerico>{entero(f.contactos)}</Td>
<Td className="text-right">
<Medidor fraccion={f.tasaCierre} etiqueta={`Tasa de cierre de ${nombreCampana(f)}`} />
</Td>
<Td numerico>{monedaCorta(f.montoGanado)}</Td>
</tr>
))}
</tbody>
</table>
</ContenedorTabla>
{limite && ordenadas.length > limite ? (
<p className="border-t border-rule px-4 py-3 font-mono text-[0.625rem] uppercase tracking-[0.1em] text-faint sm:px-5">
{ordenadas.length - limite} campañas más en{" "}
<Link href="/admin/analitica" className="link">
Analítica
</Link>
</p>
) : null}
</>
);
}
+99
View File
@@ -0,0 +1,99 @@
import type { UsuarioConCarga } from "@/lib/crm/tipos";
import { entero, minutosLegibles, monedaCorta, oRaya } from "@/components/admin/formato";
import {
ContenedorTabla,
EstadoVacio,
InsigniaRol,
Td,
Th,
} from "@/components/admin/ui";
/**
* Carga por vendedor. Abiertas, ganadas, perdidas, monto y tiempo de primera
* respuesta.
*
* El tiempo de respuesta se lee al revés que las demás columnas: menos es
* mejor. Por eso lleva la nota al pie y no un color que insinúe lo contrario.
*/
export function TablaEquipo({
equipo,
mostrarEstado = false,
acciones,
}: {
equipo: UsuarioConCarga[];
/** En `/admin/equipo` interesa además si la cuenta está activa. */
mostrarEstado?: boolean;
/** Controles por fila, solo en la página de equipo. */
acciones?: (u: UsuarioConCarga) => React.ReactNode;
}) {
if (equipo.length === 0) {
return (
<EstadoVacio
titulo="Sin vendedores"
quehacer="Da de alta al menos un vendedor activo para que la asignación automática tenga a quién repartirle."
/>
);
}
return (
<>
<ContenedorTabla etiqueta="Carga por vendedor">
<table className="w-full min-w-[46rem] border-collapse text-left">
<caption className="sr-only">
Carga de trabajo y resultados por integrante del equipo
</caption>
<thead>
<tr>
<Th>Integrante</Th>
<Th>Rol</Th>
{mostrarEstado ? <Th>Estado</Th> : null}
<Th numerico>Abiertas</Th>
<Th numerico>Ganadas</Th>
<Th numerico>Perdidas</Th>
<Th numerico>Monto ganado</Th>
<Th numerico>1.ª respuesta</Th>
{acciones ? <Th>Acciones</Th> : null}
</tr>
</thead>
<tbody>
{equipo.map((u) => (
<tr key={u.id} className="transition-colors hover:bg-ink-3/50">
<Th scope="row" className="normal-case tracking-normal">
<span className="block text-sm font-medium text-bright">{u.nombre}</span>
<span className="mt-0.5 block font-mono text-[0.625rem] normal-case tracking-[0.04em] text-faint">
{oRaya(u.puesto)} · {u.email}
</span>
</Th>
<Td>
<InsigniaRol rol={u.rol} />
</Td>
{mostrarEstado ? (
<Td>
<span className={u.activo ? "badge badge-ok" : "badge badge-signal"}>
{u.activo ? "Activo" : "Inactivo"}
</span>
</Td>
) : null}
<Td numerico>{entero(u.abiertas)}</Td>
<Td numerico className="text-ok">
{entero(u.ganadas)}
</Td>
<Td numerico className="text-faint">
{entero(u.perdidas)}
</Td>
<Td numerico>{monedaCorta(u.montoGanado)}</Td>
<Td numerico>{minutosLegibles(u.minutosPrimeraRespuesta)}</Td>
{acciones ? <Td>{acciones(u)}</Td> : null}
</tr>
))}
</tbody>
</table>
</ContenedorTabla>
<p className="border-t border-rule px-4 py-3 font-mono text-[0.5625rem] uppercase leading-relaxed tracking-[0.1em] text-faint sm:px-5">
En la columna de primera respuesta, menos es mejor. Una raya significa que ese
integrante todavía no ha registrado ninguna.
</p>
</>
);
}
+214
View File
@@ -0,0 +1,214 @@
import Link from "next/link";
import type { OportunidadResumen } from "@/lib/crm/tipos";
import { ORIGEN_ETIQUETA } from "@/lib/crm/constantes";
import { parseClases } from "@/data/clases-fuego";
import { getIndustria } from "@/data/industrias";
import { RielClases } from "@/components/marca/RielClases";
import { antiguedad, monedaCorta, oRaya } from "@/components/admin/formato";
import {
ContenedorTabla,
EnlaceFolio,
EstadoVacio,
InsigniaEtapa,
InsigniaPrioridad,
Td,
Th,
} from "@/components/admin/ui";
/**
* Listado de oportunidades. Dos presentaciones del mismo dato:
*
* - Tabla densa a partir de `lg`, donde caben las once columnas.
* - Tarjetas apiladas por debajo. Una tabla de once columnas en un teléfono de
* 320 px es ilegible aunque se desplace; apilada se lee.
*
* Las filas sin primera respuesta llevan filete ámbar a la izquierda —o rojo si
* ya rebasaron las 24 horas— porque es la información que cambia lo que hace
* el vendedor a continuación.
*/
function colorEspera(h: number | null): string | undefined {
if (h == null) return undefined;
if (h >= 24) return "var(--color-signal)";
if (h >= 4) return "var(--color-ember)";
return undefined;
}
function nombreIndustria(slug: string | null): string {
if (!slug) return "—";
return getIndustria(slug)?.nombre ?? slug;
}
export function TablaOportunidades({
filas,
mostrarPropietario,
}: {
filas: OportunidadResumen[];
/** El vendedor no necesita una columna que siempre dice su propio nombre. */
mostrarPropietario: boolean;
}) {
if (filas.length === 0) {
return (
<EstadoVacio
titulo="Nada con estos filtros"
quehacer="Quita algún filtro o amplía la búsqueda. Si esperabas oportunidades nuevas, revisa que la captura del sitio esté publicando en el CRM."
/>
);
}
return (
<>
{/* ---------- tabla, escritorio ---------- */}
<div className="hidden lg:block">
<ContenedorTabla etiqueta="Oportunidades">
<table className="w-full min-w-[70rem] border-collapse text-left">
<caption className="sr-only">Oportunidades del periodo, con etapa, origen y antigüedad</caption>
<thead>
<tr>
<Th>Folio</Th>
<Th>Contacto</Th>
<Th>Etapa</Th>
<Th>Prioridad</Th>
<Th>Origen</Th>
<Th>Producto</Th>
<Th>Clases</Th>
{mostrarPropietario ? <Th>Propietario</Th> : null}
<Th>Campaña</Th>
<Th numerico>Valor</Th>
<Th numerico>Antigüedad</Th>
</tr>
</thead>
<tbody>
{filas.map((o) => {
const borde = colorEspera(o.horasSinRespuesta);
return (
<tr key={o.id} className="transition-colors hover:bg-ink-3/50">
<Td>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
className="block h-6 w-[3px] shrink-0 rounded-[1px]"
style={{ backgroundColor: borde ?? "transparent" }}
/>
<EnlaceFolio href={`/admin/oportunidades/${o.id}`} folio={o.folio} />
</span>
{o.horasSinRespuesta != null ? (
<span className="mt-1 block pl-[11px] font-mono text-[0.5625rem] uppercase tracking-[0.08em] text-ember">
{Math.round(o.horasSinRespuesta)} h sin atender
</span>
) : null}
</Td>
<Td>
<span className="block text-sm text-bright">{o.contacto.nombre}</span>
<span className="mt-0.5 block font-mono text-[0.625rem] uppercase tracking-[0.06em] text-faint">
{oRaya(o.contacto.empresa)} · {nombreIndustria(o.industria)}
</span>
</Td>
<Td>
<InsigniaEtapa etapa={o.etapa} sm />
</Td>
<Td>
<InsigniaPrioridad prioridad={o.prioridad} />
</Td>
<Td>
<span className="font-mono text-[0.625rem] uppercase tracking-[0.06em] text-muted">
{ORIGEN_ETIQUETA[o.origen]}
</span>
</Td>
<Td className="max-w-[13rem]">
<span className="block truncate text-[0.8125rem] text-bright">
{oRaya(o.productoNombre)}
</span>
</Td>
<Td>
<RielClases activas={parseClases(o.clases.join(","))} tamano="sm" />
</Td>
{mostrarPropietario ? (
<Td>
<span className="text-[0.8125rem] text-bright">
{o.propietario?.nombre ?? (
<span className="badge badge-ember">Sin asignar</span>
)}
</span>
</Td>
) : null}
<Td mono className="max-w-[11rem]">
<span className="block truncate">{oRaya(o.utmCampaign)}</span>
</Td>
<Td numerico>{monedaCorta(o.valorEstimado)}</Td>
<Td numerico>{antiguedad(o.creadoEn)}</Td>
</tr>
);
})}
</tbody>
</table>
</ContenedorTabla>
</div>
{/* ---------- tarjetas, móvil y tableta ---------- */}
<ul className="divide-y divide-rule/60 lg:hidden">
{filas.map((o) => {
const borde = colorEspera(o.horasSinRespuesta);
return (
<li key={o.id} className="min-w-0">
<Link
href={`/admin/oportunidades/${o.id}`}
className="flex min-w-0 gap-3 px-4 py-3.5 transition-colors hover:bg-ink-3/50"
style={{ WebkitTapHighlightColor: "transparent" }}
>
<span
aria-hidden="true"
className="block w-[3px] shrink-0 rounded-[1px]"
style={{ backgroundColor: borde ?? "var(--color-rule)" }}
/>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-mono text-[0.8125rem] font-semibold text-bright">
{o.folio}
</span>
<InsigniaEtapa etapa={o.etapa} sm />
<InsigniaPrioridad prioridad={o.prioridad} />
</span>
<span className="mt-1.5 block truncate text-sm text-bright">
{o.contacto.nombre}
{o.contacto.empresa ? (
<span className="text-muted"> · {o.contacto.empresa}</span>
) : null}
</span>
<span className="mt-0.5 block truncate font-mono text-[0.625rem] uppercase tracking-[0.06em] text-faint">
{ORIGEN_ETIQUETA[o.origen]} · {oRaya(o.productoNombre)}
</span>
<span className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-2">
<RielClases activas={parseClases(o.clases.join(","))} tamano="sm" />
<span className="font-mono text-[0.625rem] uppercase tracking-[0.08em] text-faint">
{antiguedad(o.creadoEn)}
{o.valorEstimado != null ? ` · ${monedaCorta(o.valorEstimado)}` : ""}
</span>
{o.horasSinRespuesta != null ? (
<span
className="font-mono text-[0.625rem] font-semibold uppercase tracking-[0.08em]"
style={{ color: borde ?? "var(--color-ember)" }}
>
{Math.round(o.horasSinRespuesta)} h sin atender
</span>
) : null}
</span>
{mostrarPropietario ? (
<span className="mt-1.5 block font-mono text-[0.625rem] uppercase tracking-[0.08em] text-muted">
{o.propietario?.nombre ?? "Sin asignar"}
</span>
) : null}
</span>
</Link>
</li>
);
})}
</ul>
</>
);
}
@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";
import { accionActualizarNotasContacto } from "@/lib/crm/acciones";
import { AvisoAccion, usarAccion } from "@/components/admin/usarAccion";
/**
* Notas del contacto. Es el único campo libre de la ficha, y por eso importa
* que se guarde con un gesto explícito: nadie quiere descubrir que una nota se
* perdió porque el foco cambió.
*/
export function NotasContacto({ id, notas }: { id: string; notas: string | null }) {
const { enviando, aviso, ejecutar } = usarAccion();
const [texto, setTexto] = useState(notas ?? "");
const [guardado, setGuardado] = useState(notas ?? "");
const sucio = texto !== guardado;
return (
<form
className="grid gap-3 px-4 py-4 sm:px-5"
onSubmit={(e) => {
e.preventDefault();
void ejecutar(async () => {
const r = await accionActualizarNotasContacto(id, texto);
if (r.ok) setGuardado(texto);
return r;
}, "Notas guardadas.");
}}
>
<div>
<label htmlFor="notas-contacto" className="field-label">
Notas internas
</label>
<textarea
id="notas-contacto"
rows={5}
value={texto}
onChange={(e) => setTexto(e.target.value)}
placeholder="Compra por licitación anual; el técnico decide, compras solo autoriza. Pide ficha técnica en PDF antes de cotizar."
className="field w-full py-2 text-sm"
style={{ appearance: "none", WebkitAppearance: "none" }}
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="submit"
disabled={enviando || !sucio}
className="btn btn-ghost btn-sm"
style={{ appearance: "none", WebkitTapHighlightColor: "transparent" }}
>
{enviando ? "Guardando…" : "Guardar notas"}
</button>
{sucio && !enviando ? (
<button
type="button"
onClick={() => setTexto(guardado)}
className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-muted underline decoration-rule-2 underline-offset-[3px] hover:decoration-signal"
style={{ appearance: "none" }}
>
Descartar cambios
</button>
) : null}
<AvisoAccion aviso={aviso} />
</div>
</form>
);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Construcción de URLs del panel.
*
* Todo el estado de un listado —filtros, orden, página— vive en la query
* string. No es un detalle: significa que un gerente puede pegar en un chat la
* vista exacta que está viendo y el vendedor abre lo mismo.
*/
export type Consulta = Record<string, string | string[] | undefined>;
/** Primer valor de un parámetro, ya recortado. Cadena vacía si no viene. */
export function param(sp: Consulta, clave: string): string {
const v = sp[clave];
const bruto = Array.isArray(v) ? v[0] : v;
return (bruto ?? "").trim();
}
/** Entero positivo del parámetro, o el respaldo. */
export function paramEntero(sp: Consulta, clave: string, respaldo: number): number {
const n = Number.parseInt(param(sp, clave), 10);
return Number.isFinite(n) && n > 0 ? n : respaldo;
}
/** Valor solo si pertenece al dominio permitido. */
export function paramEnum<T extends string>(
sp: Consulta,
clave: string,
permitidos: readonly T[],
): T | undefined {
const v = param(sp, clave);
return (permitidos as readonly string[]).includes(v) ? (v as T) : undefined;
}
/**
* Toma la consulta actual y devuelve una URL con los cambios aplicados.
* Un valor `undefined` o vacío borra el parámetro.
*/
export function conParametros(
ruta: string,
sp: Consulta,
cambios: Record<string, string | number | undefined | null>,
): string {
const q = new URLSearchParams();
for (const [k, v] of Object.entries(sp)) {
if (v == null) continue;
const valor = Array.isArray(v) ? v[0] : v;
if (valor != null && valor !== "") q.set(k, valor);
}
for (const [k, v] of Object.entries(cambios)) {
if (v == null || v === "") q.delete(k);
else q.set(k, String(v));
}
const cadena = q.toString();
return cadena ? `${ruta}?${cadena}` : ruta;
}
@@ -0,0 +1,224 @@
"use client";
import { useActionState } from "react";
import { useRouter } from "next/navigation";
import { ROLES, ROL_ETIQUETA, type Rol } from "@/lib/crm/constantes";
import type { UsuarioResumen } from "@/lib/crm/tipos";
import {
accionActualizarUsuario,
accionAlternarUsuario,
accionCrearUsuario,
} from "@/lib/crm/acciones";
import { AvisoAccion, usarAccion, type Resultado } from "@/components/admin/usarAccion";
/**
* Alta y edición de usuarios del panel.
*
* La contraseña se captura, nunca se muestra: el hash no aparece en ninguna
* pantalla ni viaja al cliente. Al editar, dejar el campo vacío conserva la
* contraseña actual.
*/
const ESTILO = { appearance: "none", WebkitAppearance: "none" } as const;
export function FormularioUsuario({
usuario,
onListo,
}: {
/** Sin usuario es alta; con usuario es edición. */
usuario?: UsuarioResumen;
/** Ruta a la que volver cuando la operación salió bien. */
onListo: string;
}) {
const router = useRouter();
const editando = usuario != null;
const [estado, enviar, pendiente] = useActionState<Resultado | null, FormData>(
async (_previo, datos) => {
const r = editando
? await accionActualizarUsuario(usuario.id, datos)
: await accionCrearUsuario(datos);
if (r.ok) router.push(onListo);
return r;
},
null,
);
return (
<form action={enviar} className="grid gap-4 px-4 py-4 sm:px-5">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="us-nombre" className="field-label">
Nombre completo
</label>
<input
id="us-nombre"
name="nombre"
type="text"
required
defaultValue={usuario?.nombre ?? ""}
className="field py-2 text-sm"
style={ESTILO}
/>
</div>
<div>
<label htmlFor="us-email" className="field-label">
Correo
</label>
<input
id="us-email"
name="email"
type="email"
required
inputMode="email"
defaultValue={usuario?.email ?? ""}
className="field py-2 text-sm"
style={ESTILO}
/>
</div>
<div>
<label htmlFor="us-rol" className="field-label">
Rol
</label>
<select
id="us-rol"
name="rol"
defaultValue={usuario?.rol ?? ("VENDEDOR" satisfies Rol)}
className="field py-2 text-sm"
style={ESTILO}
>
{ROLES.map((r) => (
<option key={r} value={r}>
{ROL_ETIQUETA[r]}
</option>
))}
</select>
<p className="mt-1.5 text-[0.6875rem] leading-snug text-faint">
Un administrador ve toda la operación. Un vendedor solo lo asignado a su nombre.
</p>
</div>
<div>
<label htmlFor="us-puesto" className="field-label">
Puesto (opcional)
</label>
<input
id="us-puesto"
name="puesto"
type="text"
defaultValue={usuario?.puesto ?? ""}
placeholder="Ejecutivo de cuenta industrial"
className="field py-2 text-sm"
style={ESTILO}
/>
</div>
<div>
<label htmlFor="us-telefono" className="field-label">
Teléfono (opcional)
</label>
<input
id="us-telefono"
name="telefono"
type="tel"
inputMode="tel"
className="field py-2 text-sm"
style={ESTILO}
/>
</div>
<div>
<label htmlFor="us-password" className="field-label">
{editando ? "Nueva contraseña (opcional)" : "Contraseña"}
</label>
<input
id="us-password"
name="password"
type="password"
autoComplete="new-password"
required={!editando}
minLength={8}
className="field py-2 text-sm"
style={ESTILO}
/>
<p className="mt-1.5 text-[0.6875rem] leading-snug text-faint">
{editando
? "Déjala vacía para conservar la actual. Mínimo 8 caracteres si la cambias."
: "Mínimo 8 caracteres. No se muestra después de guardarla."}
</p>
</div>
</div>
{editando ? (
<label className="flex cursor-pointer items-center gap-2.5 font-mono text-[0.6875rem] uppercase tracking-[0.1em] text-muted">
<input
type="checkbox"
name="activo"
value="1"
defaultChecked={usuario.activo}
className="h-4 w-4 rounded-[2px] border border-rule-2 bg-ink accent-signal"
/>
Cuenta activa
</label>
) : null}
<p aria-live="polite" role="status" className="min-h-[1.125rem]">
{estado && !estado.ok ? <span className="field-error">{estado.error}</span> : null}
</p>
<div className="flex flex-wrap gap-2">
<button
type="submit"
disabled={pendiente}
className="btn btn-signal btn-sm"
style={ESTILO}
>
{pendiente ? "Guardando…" : editando ? "Guardar cambios" : "Dar de alta"}
</button>
<button
type="button"
onClick={() => router.push(onListo)}
className="btn btn-ghost btn-sm"
style={ESTILO}
>
Cancelar
</button>
</div>
</form>
);
}
export function AlternarUsuario({
id,
activo,
nombre,
}: {
id: string;
activo: boolean;
nombre: string;
}) {
const { enviando, aviso, ejecutar } = usarAccion();
return (
<span className="inline-flex min-w-0 flex-col gap-1">
<button
type="button"
disabled={enviando}
onClick={() =>
void ejecutar(
() => accionAlternarUsuario(id),
activo ? `${nombre} quedó inactivo.` : `${nombre} quedó activo.`,
)
}
className="btn btn-ghost btn-sm whitespace-nowrap"
style={ESTILO}
>
{enviando ? "…" : activo ? "Desactivar" : "Activar"}
<span className="sr-only"> a {nombre}</span>
</button>
<AvisoAccion aviso={aviso} />
</span>
);
}
+148
View File
@@ -0,0 +1,148 @@
/**
* Formateo del panel. Un solo lugar para que un monto se vea igual en el
* tablero, en el listado de pedidos y en la partida de una cotización.
*
* Todo en es-MX. Las cifras se pintan siempre dentro de `font-mono`, que ya
* lleva `font-variant-numeric: tabular-nums` desde `globals.css`.
*/
const MONEDA = new Intl.NumberFormat("es-MX", {
style: "currency",
currency: "MXN",
maximumFractionDigits: 2,
});
const MONEDA_CORTA = new Intl.NumberFormat("es-MX", {
style: "currency",
currency: "MXN",
maximumFractionDigits: 0,
});
const ENTERO = new Intl.NumberFormat("es-MX", { maximumFractionDigits: 0 });
const DECIMAL_1 = new Intl.NumberFormat("es-MX", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
const FECHA = new Intl.DateTimeFormat("es-MX", {
day: "2-digit",
month: "short",
year: "numeric",
});
const FECHA_CORTA = new Intl.DateTimeFormat("es-MX", {
day: "2-digit",
month: "short",
});
const FECHA_HORA = new Intl.DateTimeFormat("es-MX", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
/** $12,340.00 */
export function moneda(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—";
return MONEDA.format(v);
}
/** $12,340 — para ejes y celdas densas. */
export function monedaCorta(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—";
return MONEDA_CORTA.format(v);
}
/** 1,284 */
export function entero(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—";
return ENTERO.format(Math.round(v));
}
/** Recibe una fracción 0-1 y devuelve `34.5 %`. */
export function porcentajeDeFraccion(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—";
return `${DECIMAL_1.format(v * 100)} %`;
}
/** Recibe ya-puntos-porcentuales y devuelve `34.5 %`. */
export function porcentaje(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—";
return `${DECIMAL_1.format(v)} %`;
}
/** 3.4 h · 42 h — la unidad va pegada porque es una especificación. */
export function horas(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—";
if (v < 10) return `${DECIMAL_1.format(v)} h`;
return `${ENTERO.format(Math.round(v))} h`;
}
/** Minutos crudos convertidos a la unidad legible más cercana. */
export function minutosLegibles(v: number | null | undefined): string {
if (v == null || !Number.isFinite(v)) return "—";
if (v < 60) return `${ENTERO.format(Math.round(v))} min`;
return horas(v / 60);
}
export function fecha(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return FECHA.format(d);
}
export function fechaCorta(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return FECHA_CORTA.format(d);
}
export function fechaHora(iso: string | null | undefined): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return FECHA_HORA.format(d);
}
/** Antigüedad en la unidad más gruesa que siga siendo informativa. */
export function antiguedad(iso: string | null | undefined, ahora = Date.now()): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
const min = Math.max(0, Math.round((ahora - d.getTime()) / 60000));
if (min < 60) return `${min} min`;
const h = Math.round(min / 60);
if (h < 48) return `${h} h`;
const dias = Math.round(h / 24);
if (dias < 45) return `${dias} d`;
return `${Math.round(dias / 30)} m`;
}
/** Formatea un valor de KPI según el campo `formato` del contrato. */
export function valorKpi(
v: number,
f: "entero" | "moneda" | "porcentaje" | "horas",
): string {
switch (f) {
case "moneda":
return monedaCorta(v);
case "porcentaje":
return porcentaje(v);
case "horas":
return horas(v);
default:
return entero(v);
}
}
/** Un texto vacío o nulo se pinta como raya, nunca como hueco. */
export function oRaya(v: string | null | undefined): string {
const t = (v ?? "").trim();
return t.length ? t : "—";
}
@@ -0,0 +1,195 @@
import { entero, porcentajeDeFraccion } from "@/components/admin/formato";
import { EstadoVacio } from "@/components/admin/ui";
import {
COLOR_GANADAS,
COLOR_MAGNITUD,
COLOR_SUPERFICIE,
} from "@/components/admin/graficas/paleta";
/**
* Barras horizontales de magnitud para categorías nominales —origen, canal,
* producto, industria—. Un solo tono: el largo de la barra ya dice cuánto, así
* que gastar el canal de color en repetirlo sería tirarlo.
*
* Cuando la fila trae `destacado` (las ganadas dentro del total) la barra pasa
* a dos tramos con un hueco de 2 px de superficie entre ellos, y entonces sí
* aparece leyenda: son dos series distintas. El par #db042b / #21a179 pasa
* completo el validador de `dataviz` sobre #141b22.
*/
export type FilaBarra = {
clave: string;
etiqueta: string;
valor: number;
/** Porción del total que se resalta, p. ej. las ganadas. */
destacado?: number;
/** Texto a la derecha, ya formateado. */
detalle?: string;
};
export function BarrasMagnitud({
filas,
unidad,
etiquetaTotal = "Total",
etiquetaDestacado = "Ganadas",
vacio,
}: {
filas: FilaBarra[];
/** Cómo se llama lo que se cuenta, para el resumen accesible. */
unidad: string;
etiquetaTotal?: string;
etiquetaDestacado?: string;
vacio: { titulo: string; quehacer: string };
}) {
if (filas.length === 0) {
return <EstadoVacio titulo={vacio.titulo} quehacer={vacio.quehacer} />;
}
const max = filas.reduce((m, f) => Math.max(m, f.valor), 0) || 1;
const hayDestacado = filas.some((f) => f.destacado != null);
const total = filas.reduce((s, f) => s + f.valor, 0);
return (
<figure className="m-0 px-4 py-4 sm:px-5">
{hayDestacado ? (
<ul className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1.5">
<ItemLeyenda color={COLOR_MAGNITUD} etiqueta={etiquetaTotal} />
<ItemLeyenda color={COLOR_GANADAS} etiqueta={etiquetaDestacado} />
</ul>
) : null}
<ul
role="img"
aria-label={`${unidad} por categoría. ${filas
.map((f) => `${f.etiqueta}: ${entero(f.valor)}`)
.join(". ")}.`}
className="grid gap-2.5"
>
{filas.map((f) => {
const anchoTotal = Math.max(f.valor > 0 ? 1.5 : 0, (f.valor / max) * 100);
const dest = f.destacado ?? 0;
const fraccionDest = f.valor > 0 ? Math.min(1, dest / f.valor) : 0;
return (
<li key={f.clave} className="min-w-0">
<div className="mb-1 flex min-w-0 items-baseline justify-between gap-3">
<span className="min-w-0 truncate text-[0.8125rem] text-bright">
{f.etiqueta}
</span>
<span className="shrink-0 font-mono text-[0.8125rem] font-semibold tabular-nums text-bright">
{entero(f.valor)}
{f.detalle ? (
<span className="ml-2 font-normal text-faint">{f.detalle}</span>
) : null}
</span>
</div>
<div className="h-3 w-full min-w-0 rounded-[2px] bg-ink-3">
<div
className="flex h-3 max-w-full items-stretch overflow-hidden rounded-r-[4px]"
style={{ width: `${anchoTotal}%` }}
title={
f.destacado != null
? `${f.etiqueta}: ${entero(f.valor)} ${unidad.toLowerCase()}, ${entero(dest)} ${etiquetaDestacado.toLowerCase()}`
: `${f.etiqueta}: ${entero(f.valor)} ${unidad.toLowerCase()}`
}
>
{f.destacado != null && fraccionDest > 0 ? (
<>
<span
className="block h-full"
style={{
width: `${fraccionDest * 100}%`,
backgroundColor: COLOR_GANADAS,
}}
/>
<span
aria-hidden="true"
className="block h-full w-[2px] shrink-0"
style={{ backgroundColor: COLOR_SUPERFICIE }}
/>
</>
) : null}
<span
className="block h-full flex-1"
style={{ backgroundColor: COLOR_MAGNITUD }}
/>
</div>
</div>
</li>
);
})}
</ul>
<figcaption className="mt-3 text-xs leading-relaxed text-muted">
{entero(total)} {unidad.toLowerCase()} repartidas en {filas.length}{" "}
{filas.length === 1 ? "categoría" : "categorías"}.
</figcaption>
<table className="sr-only">
<caption>{unidad} por categoría</caption>
<thead>
<tr>
<th scope="col">Categoría</th>
<th scope="col">{etiquetaTotal}</th>
{hayDestacado ? <th scope="col">{etiquetaDestacado}</th> : null}
</tr>
</thead>
<tbody>
{filas.map((f) => (
<tr key={f.clave}>
<th scope="row">{f.etiqueta}</th>
<td>{entero(f.valor)}</td>
{hayDestacado ? <td>{entero(f.destacado ?? 0)}</td> : null}
</tr>
))}
</tbody>
</table>
</figure>
);
}
function ItemLeyenda({ color, etiqueta }: { color: string; etiqueta: string }) {
return (
<li className="flex items-center gap-2">
<span
aria-hidden="true"
className="block h-2.5 w-2.5 shrink-0 rounded-[1px]"
style={{ backgroundColor: color }}
/>
<span className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-muted">
{etiqueta}
</span>
</li>
);
}
/**
* Medidor de una razón contra su límite. La pista es un paso más claro de la
* misma superficie, no un segundo color: el estado se lee en todo el ancho.
*/
export function Medidor({
fraccion,
etiqueta,
}: {
fraccion: number;
etiqueta?: string;
}) {
const pct = Math.min(1, Math.max(0, fraccion));
return (
<span className="inline-flex min-w-0 items-center gap-2 align-middle">
<span
className="block h-1.5 w-14 shrink-0 rounded-[1px] bg-ink-3"
role="img"
aria-label={`${etiqueta ? `${etiqueta}: ` : ""}${porcentajeDeFraccion(pct)}`}
>
<span
className="block h-1.5 rounded-[1px]"
style={{ width: `${pct * 100}%`, backgroundColor: COLOR_MAGNITUD }}
/>
</span>
<span className="font-mono text-[0.75rem] tabular-nums text-bright">
{porcentajeDeFraccion(pct)}
</span>
</span>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { ETAPA_ETIQUETA } from "@/lib/crm/constantes";
import type { TramoEmbudo } from "@/lib/crm/tipos";
import { entero, porcentajeDeFraccion } from "@/components/admin/formato";
import { EstadoVacio } from "@/components/admin/ui";
import { colorEtapa, COLOR_SUPERFICIE } from "@/components/admin/graficas/paleta";
/**
* Embudo por etapa, con la conversión entre tramos.
*
* Barras horizontales en lugar de un trapecio: los nombres de etapa son largos
* y en un trapecio no caben sin recortarse. Cada tramo lleva su nombre escrito,
* su cifra y su conversión, así que el color de `ETAPA_COLOR` nunca es el único
* canal —requisito de la skill `dataviz` cuando el par de etapas cae en la
* banda del piso de CVD, que es exactamente lo que ocurre aquí.
*/
export function Embudo({ embudo }: { embudo: TramoEmbudo[] }) {
const conDato = embudo.filter((t) => t.cantidad > 0);
if (embudo.length === 0 || conDato.length === 0) {
return (
<EstadoVacio
titulo="Embudo vacío"
quehacer="Sin oportunidades en el periodo no hay etapas que medir. Amplía el rango o revisa la captura de leads del sitio."
/>
);
}
const max = embudo.reduce((m, t) => Math.max(m, t.cantidad), 0) || 1;
const primero = embudo[0];
const ultimo = embudo[embudo.length - 1];
const conversionTotal = primero.cantidad > 0 ? ultimo.cantidad / primero.cantidad : null;
return (
<figure className="m-0 px-4 py-4 sm:px-5">
<ol
role="img"
aria-label={`Embudo por etapa. ${embudo
.map((t) => `${ETAPA_ETIQUETA[t.etapa]}: ${entero(t.cantidad)}`)
.join(". ")}.`}
className="grid gap-[2px]"
style={{ backgroundColor: COLOR_SUPERFICIE }}
>
{embudo.map((t) => {
const ancho = Math.max(t.cantidad > 0 ? 2 : 0, (t.cantidad / max) * 100);
return (
<li key={t.etapa} className="min-w-0">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
<span className="w-[5.5rem] shrink-0 font-mono text-[0.625rem] uppercase leading-tight tracking-[0.08em] text-muted sm:w-28">
{ETAPA_ETIQUETA[t.etapa]}
</span>
<div className="relative min-w-0 flex-1">
<div
className="h-5 rounded-r-[4px]"
title={`${ETAPA_ETIQUETA[t.etapa]}: ${entero(t.cantidad)} oportunidades`}
style={{
width: `${ancho}%`,
backgroundColor: colorEtapa(t.etapa),
maxWidth: "100%",
}}
/>
</div>
<span className="w-12 shrink-0 text-right font-mono text-[0.8125rem] font-semibold tabular-nums text-bright sm:w-14">
{entero(t.cantidad)}
</span>
<span className="w-14 shrink-0 text-right font-mono text-[0.625rem] tabular-nums text-faint sm:w-16">
{t.conversion == null ? "—" : porcentajeDeFraccion(t.conversion)}
</span>
</div>
</li>
);
})}
</ol>
<div className="mt-3 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<figcaption className="text-xs leading-relaxed text-muted">
De {entero(primero.cantidad)} en {ETAPA_ETIQUETA[primero.etapa].toLowerCase()} a{" "}
{entero(ultimo.cantidad)} en {ETAPA_ETIQUETA[ultimo.etapa].toLowerCase()}.
</figcaption>
<p className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-faint">
Conversión total{" "}
<span className="font-semibold text-bright">
{conversionTotal == null ? "—" : porcentajeDeFraccion(conversionTotal)}
</span>
</p>
</div>
<p className="mt-1.5 font-mono text-[0.5625rem] uppercase leading-relaxed tracking-[0.1em] text-faint">
La última columna es la conversión respecto a la etapa anterior
</p>
<table className="sr-only">
<caption>Oportunidades y conversión por etapa del embudo</caption>
<thead>
<tr>
<th scope="col">Etapa</th>
<th scope="col">Oportunidades</th>
<th scope="col">Conversión desde la etapa anterior</th>
</tr>
</thead>
<tbody>
{embudo.map((t) => (
<tr key={t.etapa}>
<th scope="row">{ETAPA_ETIQUETA[t.etapa]}</th>
<td>{entero(t.cantidad)}</td>
<td>{t.conversion == null ? "No aplica" : porcentajeDeFraccion(t.conversion)}</td>
</tr>
))}
</tbody>
</table>
</figure>
);
}
@@ -0,0 +1,358 @@
"use client";
import { useMemo, useRef, useState } from "react";
import type { PuntoSerie } from "@/lib/crm/tipos";
import { entero, fecha, fechaCorta } from "@/components/admin/formato";
import { EstadoVacio } from "@/components/admin/ui";
import {
COLOR_GANADAS,
COLOR_OPORTUNIDADES,
COLOR_RETICULA,
COLOR_SUPERFICIE,
} from "@/components/admin/graficas/paleta";
/**
* Serie de tiempo de oportunidades y ganadas. SVG escrito a mano, sin librerías.
*
* La geometría vive en un `viewBox` de 0-100 en los dos ejes con
* `preserveAspectRatio="none"`: así el trazo se estira con el contenedor sin
* romperse —los trazos llevan `vector-effect="non-scaling-stroke"`, de modo que
* 2 px siguen siendo 2 px— y todo el texto es HTML real, que no se encoge a
* tamaños ilegibles en un teléfono de 320 px.
*
* Accesible: `role="img"` con un resumen del dato, `<figcaption>` con las
* cifras clave y una tabla equivalente para lector de pantalla. La capa de
* cruceta y globo enriquece; nunca es el único camino al número.
*/
const ALTO_TRAZO = 2;
function maximoLimpio(v: number): number {
if (v <= 4) return 4;
const magnitud = Math.pow(10, Math.floor(Math.log10(v)));
for (const paso of [1, 2, 2.5, 5, 10]) {
const candidato = paso * magnitud;
if (candidato >= v) return candidato;
}
return 10 * magnitud;
}
export function SerieTiempo({ serie, dias }: { serie: PuntoSerie[]; dias: number }) {
const cajaRef = useRef<HTMLDivElement>(null);
const [activo, setActivo] = useState<number | null>(null);
const modelo = useMemo(() => {
const maxDato = serie.reduce((m, p) => Math.max(m, p.oportunidades, p.ganadas), 0);
const max = maximoLimpio(maxDato);
const n = serie.length;
const x = (i: number) => (n <= 1 ? 50 : (i / (n - 1)) * 100);
const y = (v: number) => 100 - (v / max) * 100;
return {
max,
x,
y,
totalOportunidades: serie.reduce((s, p) => s + p.oportunidades, 0),
totalGanadas: serie.reduce((s, p) => s + p.ganadas, 0),
pico: serie.reduce(
(mejor, p) => (p.oportunidades > mejor.oportunidades ? p : mejor),
serie[0] ?? { fecha: "", oportunidades: 0, ganadas: 0 },
),
};
}, [serie]);
if (serie.length === 0) {
return (
<EstadoVacio
titulo="Serie sin datos"
quehacer={`No hubo envíos en los últimos ${dias} días. Amplía el periodo a 90 días o revisa que la captura del sitio esté publicando en el CRM.`}
/>
);
}
const { max, x, y, totalOportunidades, totalGanadas, pico } = modelo;
const lineaOportunidades = serie
.map((p, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(3)},${y(p.oportunidades).toFixed(3)}`)
.join(" ");
const areaOportunidades = `${lineaOportunidades} L${x(serie.length - 1).toFixed(3)},100 L${x(0).toFixed(3)},100 Z`;
const lineaGanadas = serie
.map((p, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(3)},${y(p.ganadas).toFixed(3)}`)
.join(" ");
const resumen = `Serie de ${dias} días: ${entero(totalOportunidades)} oportunidades y ${entero(
totalGanadas,
)} ganadas. El día más alto fue ${fecha(pico.fecha)} con ${entero(pico.oportunidades)} oportunidades.`;
const marcas = [0, 0.25, 0.5, 0.75, 1];
const indicesX =
serie.length <= 4
? serie.map((_, i) => i)
: [0, Math.round((serie.length - 1) / 2), serie.length - 1];
function alMover(e: React.PointerEvent<HTMLDivElement>) {
const caja = cajaRef.current;
if (!caja || serie.length === 0) return;
const r = caja.getBoundingClientRect();
if (r.width === 0) return;
const frac = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
setActivo(Math.round(frac * (serie.length - 1)));
}
function alTecla(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
e.preventDefault();
const base = activo ?? (e.key === "ArrowRight" ? -1 : serie.length);
const siguiente = Math.min(
serie.length - 1,
Math.max(0, base + (e.key === "ArrowRight" ? 1 : -1)),
);
setActivo(siguiente);
}
if (e.key === "Escape") setActivo(null);
}
const punto = activo != null ? serie[activo] : null;
return (
<figure className="m-0 px-4 py-4 sm:px-5">
<Leyenda />
<div className="relative min-w-0 pl-11 pr-2">
<div
ref={cajaRef}
tabIndex={0}
role="img"
aria-label={resumen}
onPointerMove={alMover}
onPointerLeave={() => setActivo(null)}
onKeyDown={alTecla}
onBlur={() => setActivo(null)}
className="relative h-44 w-full min-w-0 cursor-crosshair rounded-[2px] sm:h-56"
style={{ WebkitTapHighlightColor: "transparent", touchAction: "pan-y" }}
>
{/* rótulos del eje Y, en el canal reservado por el padre */}
{marcas.map((m) => (
<span
key={m}
aria-hidden="true"
className="absolute -left-11 w-10 -translate-y-1/2 text-right font-mono text-[0.5625rem] tabular-nums text-faint"
style={{ top: `${(1 - m) * 100}%` }}
>
{entero(max * m)}
</span>
))}
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden="true"
focusable="false"
className="absolute inset-0 block h-full w-full overflow-visible"
>
{/* retícula: filete sólido de 1 px, un paso fuera de la superficie */}
{marcas.map((m) => (
<line
key={m}
x1="0"
x2="100"
y1={(1 - m) * 100}
y2={(1 - m) * 100}
stroke={COLOR_RETICULA}
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
))}
{/* oportunidades: lavado del 10 % más trazo de 2 px */}
<path d={areaOportunidades} fill={COLOR_OPORTUNIDADES} fillOpacity="0.1" />
<path
d={lineaOportunidades}
fill="none"
stroke={COLOR_OPORTUNIDADES}
strokeWidth={ALTO_TRAZO}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{/* ganadas */}
<path
d={lineaGanadas}
fill="none"
stroke={COLOR_GANADAS}
strokeWidth={ALTO_TRAZO}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{activo != null ? (
<line
x1={x(activo)}
x2={x(activo)}
y1="0"
y2="100"
stroke="var(--color-rule-2)"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
) : null}
</svg>
{/* puntas y punto activo: HTML, para que el círculo no se deforme */}
<Punto
izquierda={x(serie.length - 1)}
arriba={y(serie[serie.length - 1].oportunidades)}
color={COLOR_OPORTUNIDADES}
/>
<Punto
izquierda={x(serie.length - 1)}
arriba={y(serie[serie.length - 1].ganadas)}
color={COLOR_GANADAS}
/>
{activo != null && punto ? (
<>
<Punto izquierda={x(activo)} arriba={y(punto.oportunidades)} color={COLOR_OPORTUNIDADES} />
<Punto izquierda={x(activo)} arriba={y(punto.ganadas)} color={COLOR_GANADAS} />
</>
) : null}
</div>
{/* eje X */}
<div className="relative mt-2 h-4">
{indicesX.map((i) => (
<span
key={i}
aria-hidden="true"
className="absolute font-mono text-[0.5625rem] uppercase tracking-[0.06em] text-faint"
style={{
left: `${x(i)}%`,
transform:
i === 0
? "translateX(0)"
: i === serie.length - 1
? "translateX(-100%)"
: "translateX(-50%)",
}}
>
{fechaCorta(serie[i].fecha)}
</span>
))}
</div>
{/* globo: el valor manda, el nombre de la serie acompaña */}
{punto ? (
<div
aria-hidden="true"
className="pointer-events-none absolute top-1 z-10 min-w-[9rem] rounded-[3px] border border-rule-2 bg-ink px-2.5 py-2 shadow-lg"
style={{
left: `${Math.min(78, Math.max(0, x(activo ?? 0)))}%`,
}}
>
<p className="mb-1.5 font-mono text-[0.5625rem] uppercase tracking-[0.1em] text-faint">
{fecha(punto.fecha)}
</p>
<FilaGlobo color={COLOR_OPORTUNIDADES} etiqueta="Oportunidades" valor={punto.oportunidades} />
<FilaGlobo color={COLOR_GANADAS} etiqueta="Ganadas" valor={punto.ganadas} />
</div>
) : null}
</div>
<figcaption className="mt-3 text-xs leading-relaxed text-muted">
{entero(totalOportunidades)} oportunidades y {entero(totalGanadas)} ganadas en{" "}
{dias} días. Día más alto: {fecha(pico.fecha)} con {entero(pico.oportunidades)}.
</figcaption>
<table className="sr-only">
<caption>Oportunidades y ganadas por día</caption>
<thead>
<tr>
<th scope="col">Día</th>
<th scope="col">Oportunidades</th>
<th scope="col">Ganadas</th>
</tr>
</thead>
<tbody>
{serie.map((p) => (
<tr key={p.fecha}>
<th scope="row">{fecha(p.fecha)}</th>
<td>{entero(p.oportunidades)}</td>
<td>{entero(p.ganadas)}</td>
</tr>
))}
</tbody>
</table>
</figure>
);
}
function Punto({
izquierda,
arriba,
color,
}: {
izquierda: number;
arriba: number;
color: string;
}) {
return (
<span
aria-hidden="true"
className="pointer-events-none absolute block h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full"
style={{
left: `${izquierda}%`,
top: `${arriba}%`,
backgroundColor: color,
boxShadow: `0 0 0 2px ${COLOR_SUPERFICIE}`,
}}
/>
);
}
function FilaGlobo({
color,
etiqueta,
valor,
}: {
color: string;
etiqueta: string;
valor: number;
}) {
return (
<p className="flex items-baseline gap-2">
<span
aria-hidden="true"
className="mb-[0.2em] block h-0.5 w-3 shrink-0 self-center rounded-full"
style={{ backgroundColor: color }}
/>
<span className="font-mono text-sm font-semibold text-bright">{entero(valor)}</span>
<span className="font-mono text-[0.5625rem] uppercase tracking-[0.08em] text-muted">
{etiqueta}
</span>
</p>
);
}
function Leyenda() {
return (
<ul className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1.5">
<ItemLeyenda color={COLOR_OPORTUNIDADES} etiqueta="Oportunidades" />
<ItemLeyenda color={COLOR_GANADAS} etiqueta="Ganadas" />
</ul>
);
}
function ItemLeyenda({ color, etiqueta }: { color: string; etiqueta: string }) {
return (
<li className="flex items-center gap-2">
<span
aria-hidden="true"
className="block h-0.5 w-4 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-muted">
{etiqueta}
</span>
</li>
);
}
@@ -0,0 +1,78 @@
import type { TarjetaKpi } from "@/lib/crm/tipos";
import { valorKpi } from "@/components/admin/formato";
import { claseVariacion, subirEsMejor } from "@/components/admin/graficas/paleta";
/**
* Ficha de KPI. Rótulo, cifra y variación contra el periodo anterior.
*
* La dirección de mejora se resuelve por KPI, no por signo: en horas hasta la
* primera respuesta bajar es mejor. Si `variacion` es null no se pinta flecha
* —no hay base de comparación y no se inventa una.
*/
export function TarjetaKpiVista({ kpi, dias }: { kpi: TarjetaKpi; dias: number }) {
const mejorArriba = subirEsMejor(kpi.clave, kpi.formato);
const v = kpi.variacion;
return (
<div className="panel flex min-w-0 flex-col justify-between gap-3 px-4 py-3.5">
<p className="font-mono text-[0.625rem] uppercase leading-snug tracking-[0.12em] text-faint">
{kpi.etiqueta}
</p>
<p className="font-mono text-2xl font-semibold leading-none text-bright sm:text-[1.75rem]">
{valorKpi(kpi.valor, kpi.formato)}
</p>
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
{v == null ? (
<span className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-faint">
Sin base de comparación
</span>
) : (
<>
<span
className={`font-mono text-xs font-semibold ${claseVariacion(v, mejorArriba)}`}
>
<span aria-hidden="true">{v > 0 ? "▲" : v < 0 ? "▼" : "■"}</span>{" "}
{v > 0 ? "+" : ""}
{v.toFixed(1)} pp
</span>
<span className="font-mono text-[0.5625rem] uppercase tracking-[0.1em] text-faint">
vs. {dias} días previos
</span>
<span className="sr-only">
{v > 0 ? "sube" : v < 0 ? "baja" : "sin cambio"} {Math.abs(v).toFixed(1)} puntos
porcentuales contra los {dias} días previos, lo que{" "}
{v === 0 ? "no cambia el indicador" : (v > 0) === mejorArriba ? "es una mejora" : "es un retroceso"}.
</span>
</>
)}
</div>
{kpi.nota ? (
<p className="text-[0.6875rem] leading-snug text-muted">{kpi.nota}</p>
) : null}
</div>
);
}
export function FilaKpis({ kpis, dias }: { kpis: TarjetaKpi[]; dias: number }) {
if (kpis.length === 0) {
return (
<div className="panel px-4 py-6">
<p className="text-sm text-muted">
Todavía no hay indicadores para este periodo. Amplía el rango a 90 días o espera
los primeros envíos del sitio.
</p>
</div>
);
}
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{kpis.map((k) => (
<TarjetaKpiVista key={k.clave} kpi={k} dias={dias} />
))}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { ETAPA_COLOR, type Etapa } from "@/lib/crm/constantes";
/**
* Paleta de gráficas del panel. No es una paleta nueva: son los tokens que ya
* existen en `globals.css` y en `ETAPA_COLOR`, elegidos y verificados con el
* validador de la skill `dataviz` contra la superficie real de los paneles
* (`--color-ink-2`, #141b22).
*
* Registro literal de la verificación —para que se pueda repetir:
*
* node scripts/validate_palette.js "#db042b,#21a179" --mode dark --surface "#141b22"
* → TODAS LAS COMPROBACIONES PASAN
* banda de luminosidad OK · croma OK · CVD peor par ΔE 10.1 (deutan)
* piso de visión normal ΔE 33.9 · contraste ≥ 3:1
*
* node scripts/validate_palette.js "#db042b,#f2b705,#4a9bd1,#8b6ad6,#21a179" --mode dark --surface "#141b22"
* → FALLA: #f2b705 fuera de banda (L 0.811); par #8b6ad6↔#4a9bd1 ΔE 7.1 (deutan)
* y 14.3 en visión normal.
*
* La segunda es `ETAPA_COLOR`, que vive en un archivo del orquestador y no se
* toca. Se usa igual —el contrato lo exige y la coherencia con el sitio lo
* pide— pero **nunca como único canal**: cada etapa lleva siempre su nombre
* escrito, su cifra, un hueco de 2 px de superficie entre tramos y una tabla
* equivalente. Eso es exactamente la codificación secundaria que la propia
* skill exige cuando el par de CVD cae en la banda del piso.
*/
/** Serie principal: oportunidades entradas. */
export const COLOR_OPORTUNIDADES = "var(--color-signal)";
/** Serie de cierre: oportunidades ganadas. */
export const COLOR_GANADAS = "var(--color-ok)";
/** Marca de magnitud para categorías nominales: un solo tono, el largo dice todo. */
export const COLOR_MAGNITUD = "var(--color-signal)";
/** Retícula y ejes: un paso fuera de la superficie, filete de 1 px, siempre sólido. */
export const COLOR_RETICULA = "var(--color-rule)";
/** Marca de atención. Reservada a riesgo y espera, nunca decorativa. */
export const COLOR_ATENCION = "var(--color-ember)";
/** Superficie de los paneles: la que separa marcas que se tocan. */
export const COLOR_SUPERFICIE = "var(--color-ink-2)";
export function colorEtapa(etapa: Etapa): string {
return ETAPA_COLOR[etapa];
}
/**
* Dirección de mejora de un KPI. Subir no siempre es bueno: en horas hasta la
* primera respuesta, bajar es mejor, y en oportunidades perdidas también.
*
* Se resuelve por KPI y no por signo. Si el lote de datos agrega una clave
* nueva cuyo nombre no cae en ninguna de estas reglas, se asume que más es
* mejor —que es el caso general de un embudo comercial.
*/
export function subirEsMejor(clave: string, formato: string): boolean {
const c = clave.toLowerCase();
if (formato === "horas") return false;
if (/(perdid|caida|abandono|sin_atender|sinatender|espera|demora|fuga)/.test(c)) return false;
return true;
}
/** Clase de color para una variación ya resuelta contra su dirección. */
export function claseVariacion(variacion: number, mejorArriba: boolean): string {
if (variacion === 0) return "text-muted";
const mejora = variacion > 0 ? mejorArriba : !mejorArriba;
return mejora ? "text-ok" : "text-signal-hi";
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useActionState, useState, useTransition } from "react";
import { ROL_ETIQUETA, type Rol } from "@/lib/crm/constantes";
import { accionAccesoDemo, accionIniciarSesion } from "@/lib/crm/acciones";
import type { Resultado } from "@/components/admin/usarAccion";
/**
* Acceso al panel. Dos caminos deliberadamente distintos:
*
* 1. El formulario real de correo y contraseña.
* 2. El acceso de demostración, que el cliente pidió para poder mostrar el
* panel desde los dos roles en una junta sin teclear credenciales. Va
* separado por un filete y rotulado como lo que es: un atajo que en
* producción se desactiva. No se disimula.
*/
export type PerfilDemo = {
id: string;
nombre: string;
email: string;
rol: Rol;
puesto: string | null;
};
export function FormularioAcceso({ destino }: { destino: string }) {
const [estado, enviar, pendiente] = useActionState<Resultado | null, FormData>(
async (_previo, datos) => accionIniciarSesion(datos),
null,
);
return (
<form action={enviar} className="grid gap-4" noValidate={false}>
<input type="hidden" name="destino" value={destino} />
<div>
<label htmlFor="acceso-email" className="field-label">
Correo
</label>
<input
id="acceso-email"
name="email"
type="email"
autoComplete="username"
required
inputMode="email"
placeholder="[email protected]"
className="field"
style={{ appearance: "none", WebkitAppearance: "none" }}
/>
</div>
<div>
<label htmlFor="acceso-password" className="field-label">
Contraseña
</label>
<input
id="acceso-password"
name="password"
type="password"
autoComplete="current-password"
required
minLength={6}
className="field"
style={{ appearance: "none", WebkitAppearance: "none" }}
/>
</div>
<p aria-live="polite" role="status" className="min-h-[1.125rem]">
{estado && !estado.ok ? (
<span className="field-error">{estado.error}</span>
) : null}
</p>
<button
type="submit"
disabled={pendiente}
className="btn btn-signal w-full"
style={{ appearance: "none", WebkitTapHighlightColor: "transparent" }}
>
{pendiente ? "Verificando…" : "Entrar"}
</button>
</form>
);
}
export function AccesoDemo({ perfiles }: { perfiles: PerfilDemo[] }) {
// Sin perfiles no se pinta la sección: no se anuncia una demostración vacía.
if (perfiles.length === 0) return null;
const ordenados = [...perfiles].sort((a, b) => {
if (a.rol !== b.rol) return a.rol === "ADMIN" ? -1 : 1;
return a.nombre.localeCompare(b.nombre, "es-MX");
});
return (
<section
aria-labelledby="titulo-demo"
className="mt-7 border-t border-dashed border-ember-dim pt-6"
>
<div className="mb-4 flex items-start gap-2.5">
<span
aria-hidden="true"
className="hazard mt-0.5 block h-3 w-3 shrink-0 rounded-[1px]"
/>
<div className="min-w-0">
<h2
id="titulo-demo"
className="font-mono text-[0.6875rem] font-semibold uppercase tracking-[0.14em] text-ember"
>
Acceso de demostración
</h2>
<p className="mt-1.5 text-xs leading-relaxed text-muted">
Entra de un clic con un perfil de ejemplo, sin contraseña, para ver el panel
desde cada rol. Este bloque existe solo en la demostración y{" "}
<strong className="font-semibold text-bright">se desactiva en producción</strong>.
</p>
</div>
</div>
<ul className="grid gap-2">
{ordenados.map((p) => (
<li key={p.id}>
<BotonPerfil perfil={p} />
</li>
))}
</ul>
</section>
);
}
function BotonPerfil({ perfil }: { perfil: PerfilDemo }) {
const [pendiente, iniciar] = useTransition();
const [error, setError] = useState<string | null>(null);
function entrar() {
setError(null);
iniciar(async () => {
const r = await accionAccesoDemo(perfil.id);
if (!r.ok) setError(r.error);
});
}
return (
<>
<button
type="button"
onClick={entrar}
disabled={pendiente}
className="flex w-full items-center gap-3 rounded-[3px] border border-rule-2 bg-ink px-3 py-2.5 text-left transition-colors hover:border-ember hover:bg-ink-3 disabled:cursor-not-allowed disabled:opacity-60"
style={{ appearance: "none", WebkitTapHighlightColor: "transparent" }}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-bright">
{perfil.nombre}
</span>
<span className="block truncate font-mono text-[0.625rem] uppercase tracking-[0.1em] text-faint">
{perfil.puesto ?? perfil.email}
</span>
</span>
<span className={perfil.rol === "ADMIN" ? "badge badge-signal" : "badge"}>
{ROL_ETIQUETA[perfil.rol]}
</span>
<span aria-hidden="true" className="font-mono text-xs text-faint">
{pendiente ? "…" : "→"}
</span>
</button>
<p aria-live="polite" role="status">
{error ? <span className="field-error">{error}</span> : null}
</p>
</>
);
}
@@ -0,0 +1,85 @@
import type { ActividadResumen } from "@/lib/crm/tipos";
import { TIPO_ACTIVIDAD_ETIQUETA, type TipoActividad } from "@/lib/crm/constantes";
import { fechaHora } from "@/components/admin/formato";
import { EstadoVacio } from "@/components/admin/ui";
/**
* Bitácora de la oportunidad, en orden cronológico.
*
* Las entradas del sistema —cambio de etapa, asignación— se pintan más
* discretas que las que escribió una persona: lo que un humano anotó es lo que
* el siguiente humano necesita leer.
*/
const AUTOMATICAS: TipoActividad[] = ["CAMBIO_ETAPA", "ASIGNACION", "SISTEMA"];
export function Bitacora({ actividades }: { actividades: ActividadResumen[] }) {
if (actividades.length === 0) {
return (
<EstadoVacio
titulo="Bitácora vacía"
quehacer="Registra la primera llamada o correo abajo. La bitácora es lo que permite que otro vendedor retome la cuenta sin volver a preguntar todo."
/>
);
}
const ordenadas = [...actividades].sort(
(a, b) => new Date(a.creadoEn).getTime() - new Date(b.creadoEn).getTime(),
);
return (
<ol className="grid gap-0 px-4 py-2 sm:px-5">
{ordenadas.map((a, i) => {
const automatica = AUTOMATICAS.includes(a.tipo);
return (
<li key={a.id} className="relative flex min-w-0 gap-3 py-2.5">
{/* riel vertical de la línea de tiempo */}
<span aria-hidden="true" className="relative flex w-2 shrink-0 justify-center">
<span
className="absolute top-0 bottom-0 w-px"
style={{
backgroundColor: "var(--color-rule)",
top: i === 0 ? "0.55rem" : 0,
bottom: i === ordenadas.length - 1 ? "auto" : 0,
height: i === ordenadas.length - 1 ? "0.55rem" : undefined,
}}
/>
<span
className="relative mt-[0.4rem] block h-2 w-2 shrink-0 rounded-full"
style={{
backgroundColor: automatica ? "var(--color-rule-2)" : "var(--color-signal)",
boxShadow: "0 0 0 2px var(--color-ink-2)",
}}
/>
</span>
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span
className={[
"font-mono text-[0.5625rem] font-semibold uppercase tracking-[0.12em]",
automatica ? "text-faint" : "text-signal-hi",
].join(" ")}
>
{TIPO_ACTIVIDAD_ETIQUETA[a.tipo]}
</span>
<span className="font-mono text-[0.5625rem] uppercase tracking-[0.08em] text-faint">
{fechaHora(a.creadoEn)}
{a.usuario ? ` · ${a.usuario.nombre}` : " · sistema"}
</span>
</p>
<p
className={[
"mt-0.5 text-sm leading-relaxed",
automatica ? "text-muted" : "text-bright",
].join(" ")}
>
{a.texto}
</p>
</div>
</li>
);
})}
</ol>
);
}
@@ -0,0 +1,397 @@
"use client";
import { useState } from "react";
import {
ETAPAS,
ETAPA_ETIQUETA,
MOTIVOS_PERDIDA,
PRIORIDADES,
PRIORIDAD_ETIQUETA,
TIPOS_ACTIVIDAD_MANUAL,
TIPO_ACTIVIDAD_ETIQUETA,
type Etapa,
type Prioridad,
type TipoActividad,
} from "@/lib/crm/constantes";
import {
accionActualizarValor,
accionAsignar,
accionCambiarEtapa,
accionCambiarPrioridad,
accionMarcarPrimeraRespuesta,
accionRegistrarActividad,
} from "@/lib/crm/acciones";
import { AvisoAccion, usarAccion } from "@/components/admin/usarAccion";
/**
* Controles en línea de la ficha de oportunidad.
*
* Cada uno anuncia su resultado en una región viva: quien opera el panel con
* lector de pantalla se entera de que la etapa cambió sin tener que buscar el
* dato de nuevo.
*/
const CLASE_CAMPO = "field w-full py-2 text-sm";
const ESTILO_CAMPO = { appearance: "none", WebkitAppearance: "none" } as const;
// ---------- etapa ----------
export function CambiarEtapa({ id, etapa }: { id: string; etapa: Etapa }) {
const { enviando, aviso, ejecutar } = usarAccion();
const [elegida, setElegida] = useState<Etapa>(etapa);
const [motivo, setMotivo] = useState<string>("");
const pidiendoMotivo = elegida === "PERDIDA" && etapa !== "PERDIDA";
function alCambiar(v: string) {
const siguiente = v as Etapa;
setElegida(siguiente);
setMotivo("");
// Marcar perdida obliga a decir por qué antes de guardar: sin el motivo,
// el reporte de pérdidas no sirve para nada.
if (siguiente === "PERDIDA") return;
if (siguiente === etapa) return;
void ejecutar(
() => accionCambiarEtapa(id, siguiente),
`Etapa actualizada a ${ETAPA_ETIQUETA[siguiente]}.`,
);
}
return (
<div className="grid gap-2">
<label htmlFor="ctl-etapa" className="field-label">
Etapa
</label>
<select
id="ctl-etapa"
value={elegida}
disabled={enviando}
onChange={(e) => alCambiar(e.target.value)}
className={CLASE_CAMPO}
style={ESTILO_CAMPO}
>
{ETAPAS.map((e) => (
<option key={e} value={e}>
{ETAPA_ETIQUETA[e]}
</option>
))}
</select>
{pidiendoMotivo ? (
<div className="grid gap-2 rounded-[3px] border border-ember-dim bg-ink px-3 py-3">
<label htmlFor="ctl-motivo" className="field-label">
Motivo de la pérdida (obligatorio)
</label>
<select
id="ctl-motivo"
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
className={CLASE_CAMPO}
style={ESTILO_CAMPO}
>
<option value="">Elige un motivo</option>
{MOTIVOS_PERDIDA.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={enviando || motivo === ""}
onClick={() =>
void ejecutar(
() => accionCambiarEtapa(id, "PERDIDA", motivo),
"Oportunidad marcada como perdida.",
)
}
className="btn btn-signal btn-sm"
style={ESTILO_CAMPO}
>
{enviando ? "Guardando…" : "Marcar perdida"}
</button>
<button
type="button"
disabled={enviando}
onClick={() => {
setElegida(etapa);
setMotivo("");
}}
className="btn btn-ghost btn-sm"
style={ESTILO_CAMPO}
>
Cancelar
</button>
</div>
</div>
) : null}
<AvisoAccion aviso={aviso} />
</div>
);
}
// ---------- prioridad ----------
export function CambiarPrioridad({
id,
prioridad,
}: {
id: string;
prioridad: Prioridad;
}) {
const { enviando, aviso, ejecutar } = usarAccion();
const [elegida, setElegida] = useState<Prioridad>(prioridad);
return (
<div className="grid gap-2">
<label htmlFor="ctl-prioridad" className="field-label">
Prioridad
</label>
<select
id="ctl-prioridad"
value={elegida}
disabled={enviando}
onChange={(e) => {
const v = e.target.value as Prioridad;
setElegida(v);
void ejecutar(
() => accionCambiarPrioridad(id, v),
`Prioridad actualizada a ${PRIORIDAD_ETIQUETA[v].toLowerCase()}.`,
);
}}
className={CLASE_CAMPO}
style={ESTILO_CAMPO}
>
{PRIORIDADES.map((p) => (
<option key={p} value={p}>
{PRIORIDAD_ETIQUETA[p]}
</option>
))}
</select>
<AvisoAccion aviso={aviso} />
</div>
);
}
// ---------- asignación, solo ADMIN ----------
export function AsignarPropietario({
id,
propietarioId,
equipo,
}: {
id: string;
propietarioId: string | null;
equipo: { id: string; nombre: string; activo: boolean }[];
}) {
const { enviando, aviso, ejecutar } = usarAccion();
const [elegido, setElegido] = useState<string>(propietarioId ?? "");
return (
<div className="grid gap-2">
<label htmlFor="ctl-propietario" className="field-label">
Propietario
</label>
<select
id="ctl-propietario"
value={elegido}
disabled={enviando}
onChange={(e) => {
const v = e.target.value;
setElegido(v);
const nombre = equipo.find((u) => u.id === v)?.nombre;
void ejecutar(
() => accionAsignar(id, v === "" ? null : v),
v === "" ? "Oportunidad sin asignar." : `Asignada a ${nombre ?? "el vendedor"}.`,
);
}}
className={CLASE_CAMPO}
style={ESTILO_CAMPO}
>
<option value="">Sin asignar</option>
{equipo.map((u) => (
<option key={u.id} value={u.id}>
{u.nombre}
{u.activo ? "" : " (inactivo)"}
</option>
))}
</select>
<AvisoAccion aviso={aviso} />
</div>
);
}
// ---------- valor estimado ----------
export function EditarValor({ id, valor }: { id: string; valor: number | null }) {
const { enviando, aviso, ejecutar } = usarAccion();
const [texto, setTexto] = useState(valor == null ? "" : String(valor));
function guardar() {
const limpio = texto.trim();
if (limpio === "") {
void ejecutar(() => accionActualizarValor(id, null), "Valor estimado borrado.");
return;
}
const n = Number(limpio.replace(/[^0-9.]/g, ""));
if (!Number.isFinite(n) || n < 0) return;
void ejecutar(() => accionActualizarValor(id, n), "Valor estimado actualizado.");
}
return (
<form
className="grid gap-2"
onSubmit={(e) => {
e.preventDefault();
guardar();
}}
>
<label htmlFor="ctl-valor" className="field-label">
Valor estimado (MXN)
</label>
<div className="flex gap-2">
<input
id="ctl-valor"
type="text"
inputMode="decimal"
value={texto}
onChange={(e) => setTexto(e.target.value)}
placeholder="Sin estimar"
className="field w-full py-2 font-mono text-sm"
style={ESTILO_CAMPO}
/>
<button
type="submit"
disabled={enviando}
className="btn btn-ghost btn-sm shrink-0"
style={ESTILO_CAMPO}
>
Guardar
</button>
</div>
<AvisoAccion aviso={aviso} />
</form>
);
}
// ---------- primera respuesta ----------
export function MarcarPrimeraRespuesta({
id,
horasSinRespuesta,
}: {
id: string;
horasSinRespuesta: number | null;
}) {
const { enviando, aviso, ejecutar } = usarAccion();
if (horasSinRespuesta == null) {
return (
<p className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-ok">
Primera respuesta registrada
</p>
);
}
return (
<div className="grid gap-2">
<button
type="button"
disabled={enviando}
onClick={() =>
void ejecutar(
() => accionMarcarPrimeraRespuesta(id),
"Primera respuesta registrada.",
)
}
className="btn btn-signal btn-sm w-full"
style={ESTILO_CAMPO}
>
{enviando ? "Registrando…" : "Marcar primera respuesta"}
</button>
<p className="font-mono text-[0.5625rem] uppercase leading-relaxed tracking-[0.1em] text-ember">
Lleva {Math.round(horasSinRespuesta)} h esperando
</p>
<AvisoAccion aviso={aviso} />
</div>
);
}
// ---------- bitácora ----------
export function RegistrarActividad({ id }: { id: string }) {
const { enviando, aviso, ejecutar } = usarAccion();
const [tipo, setTipo] = useState<TipoActividad>(TIPOS_ACTIVIDAD_MANUAL[0]);
const [texto, setTexto] = useState("");
function enviar() {
const limpio = texto.trim();
if (limpio.length === 0) return;
void ejecutar(async () => {
const r = await accionRegistrarActividad(id, tipo, limpio);
if (r.ok) setTexto("");
return r;
}, "Actividad registrada en la bitácora.");
}
return (
<form
className="grid gap-3 border-t border-rule px-4 py-4 sm:px-5"
onSubmit={(e) => {
e.preventDefault();
enviar();
}}
>
<div className="grid gap-3 sm:grid-cols-[10rem_1fr] sm:items-start">
<div>
<label htmlFor="act-tipo" className="field-label">
Tipo
</label>
<select
id="act-tipo"
value={tipo}
onChange={(e) => setTipo(e.target.value as TipoActividad)}
className={CLASE_CAMPO}
style={ESTILO_CAMPO}
>
{TIPOS_ACTIVIDAD_MANUAL.map((t) => (
<option key={t} value={t}>
{TIPO_ACTIVIDAD_ETIQUETA[t]}
</option>
))}
</select>
</div>
<div>
<label htmlFor="act-texto" className="field-label">
Qué pasó
</label>
<textarea
id="act-texto"
rows={2}
required
value={texto}
onChange={(e) => setTexto(e.target.value)}
placeholder="Llamada al responsable de mantenimiento; pide propuesta por escrito."
className="field w-full py-2 text-sm"
style={ESTILO_CAMPO}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="submit"
disabled={enviando || texto.trim().length === 0}
className="btn btn-ghost btn-sm"
style={ESTILO_CAMPO}
>
{enviando ? "Registrando…" : "Registrar en bitácora"}
</button>
<AvisoAccion aviso={aviso} />
</div>
</form>
);
}
@@ -0,0 +1,300 @@
"use client";
import { useMemo, useState } from "react";
import { IVA } from "@/lib/crm/constantes";
import type { ItemPedido } from "@/lib/crm/tipos";
import { CATEGORIAS, PRODUCTOS } from "@/data/catalogo";
import { accionCrearPedido } from "@/lib/crm/acciones";
import { moneda } from "@/components/admin/formato";
import { AvisoAccion, usarAccion } from "@/components/admin/usarAccion";
/**
* Alta de pedido desde una oportunidad ganada.
*
* Las partidas salen del catálogo real: no se puede teclear un producto que no
* existe. El precio unitario sí se captura a mano y a propósito —el catálogo de
* FSPM no publica precios, así que inventarlos aquí sería fabricar un dato.
*
* Subtotal, IVA y total se calculan en vivo, antes de enviar: quien cotiza ve
* la cifra final mientras arma la partida, no después.
*/
type Partida = {
/** Clave local de la fila, no viaja al servidor. */
llave: string;
productoSlug: string;
cantidad: string;
precioUnitario: string;
};
const ESTILO_CAMPO = { appearance: "none", WebkitAppearance: "none" } as const;
const PRODUCTOS_POR_CATEGORIA = CATEGORIAS.map((c) => ({
categoria: c,
productos: PRODUCTOS.filter((p) => p.categoria === c.slug).sort((a, b) => a.orden - b.orden),
})).filter((g) => g.productos.length > 0);
let contador = 0;
function nuevaPartida(productoSlug = ""): Partida {
contador += 1;
return { llave: `p${contador}`, productoSlug, cantidad: "1", precioUnitario: "" };
}
function numero(v: string): number {
const n = Number(v.replace(/[^0-9.]/g, ""));
return Number.isFinite(n) && n >= 0 ? n : 0;
}
export function CrearPedido({
oportunidadId,
productoSlugSugerido,
}: {
oportunidadId: string;
/** El producto que declaró el lead, para no empezar de cero. */
productoSlugSugerido: string | null;
}) {
const { enviando, aviso, ejecutar } = usarAccion();
const [abierto, setAbierto] = useState(false);
const [partidas, setPartidas] = useState<Partida[]>([
nuevaPartida(
productoSlugSugerido && PRODUCTOS.some((p) => p.slug === productoSlugSugerido)
? productoSlugSugerido
: "",
),
]);
const [notas, setNotas] = useState("");
const totales = useMemo(() => {
const subtotal = partidas.reduce(
(s, p) => s + numero(p.cantidad) * numero(p.precioUnitario),
0,
);
const iva = subtotal * IVA;
return { subtotal, iva, total: subtotal + iva };
}, [partidas]);
const validas = partidas.filter(
(p) => p.productoSlug !== "" && numero(p.cantidad) > 0 && numero(p.precioUnitario) > 0,
);
function actualizar(llave: string, campo: keyof Omit<Partida, "llave">, valor: string) {
setPartidas((prev) =>
prev.map((p) => (p.llave === llave ? { ...p, [campo]: valor } : p)),
);
}
function enviar() {
if (validas.length === 0) return;
const items: ItemPedido[] = validas.map((p) => {
const producto = PRODUCTOS.find((x) => x.slug === p.productoSlug);
return {
productoSlug: p.productoSlug,
nombre: producto?.nombre ?? p.productoSlug,
cantidad: numero(p.cantidad),
precioUnitario: numero(p.precioUnitario),
};
});
void ejecutar(async () => {
const r = await accionCrearPedido(oportunidadId, items, notas.trim() || undefined);
if (r.ok) {
setPartidas([nuevaPartida()]);
setNotas("");
setAbierto(false);
}
return r;
}, "Pedido creado. Búscalo en el listado de pedidos.");
}
if (!abierto) {
return (
<div className="px-4 py-4 sm:px-5">
<p className="mb-3 text-xs leading-relaxed text-muted">
Esta oportunidad está ganada: ya se le puede levantar el pedido con las partidas
acordadas.
</p>
<button
type="button"
onClick={() => setAbierto(true)}
className="btn btn-signal btn-sm"
style={ESTILO_CAMPO}
>
Crear pedido
</button>
<AvisoAccion aviso={aviso} className="mt-2" />
</div>
);
}
return (
<form
className="grid gap-4 px-4 py-4 sm:px-5"
onSubmit={(e) => {
e.preventDefault();
enviar();
}}
>
<ul className="grid gap-3">
{partidas.map((p, i) => {
const producto = PRODUCTOS.find((x) => x.slug === p.productoSlug);
const importe = numero(p.cantidad) * numero(p.precioUnitario);
return (
<li
key={p.llave}
className="grid gap-2 rounded-[3px] border border-rule bg-ink px-3 py-3"
>
<div className="grid gap-2 sm:grid-cols-[1fr_5rem_8rem]">
<div className="min-w-0">
<label htmlFor={`pd-prod-${p.llave}`} className="field-label">
Partida {i + 1}
</label>
<select
id={`pd-prod-${p.llave}`}
value={p.productoSlug}
onChange={(e) => actualizar(p.llave, "productoSlug", e.target.value)}
className="field w-full py-2 text-sm"
style={ESTILO_CAMPO}
>
<option value="">Elige un producto del catálogo</option>
{PRODUCTOS_POR_CATEGORIA.map((g) => (
<optgroup key={g.categoria.slug} label={g.categoria.nombre}>
{g.productos.map((prod) => (
<option key={prod.slug} value={prod.slug}>
{prod.nombre}
</option>
))}
</optgroup>
))}
</select>
</div>
<div>
<label htmlFor={`pd-cant-${p.llave}`} className="field-label">
Cantidad
</label>
<input
id={`pd-cant-${p.llave}`}
type="text"
inputMode="numeric"
value={p.cantidad}
onChange={(e) => actualizar(p.llave, "cantidad", e.target.value)}
className="field w-full py-2 text-right font-mono text-sm"
style={ESTILO_CAMPO}
/>
</div>
<div>
<label htmlFor={`pd-precio-${p.llave}`} className="field-label">
Precio unitario
</label>
<input
id={`pd-precio-${p.llave}`}
type="text"
inputMode="decimal"
value={p.precioUnitario}
onChange={(e) => actualizar(p.llave, "precioUnitario", e.target.value)}
placeholder="0.00"
className="field w-full py-2 text-right font-mono text-sm"
style={ESTILO_CAMPO}
/>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="min-w-0 font-mono text-[0.5625rem] uppercase tracking-[0.08em] text-faint">
{producto
? `${producto.modelo ?? "sin modelo"} · ${producto.capacidad ?? "capacidad no declarada"}`
: "Sin producto elegido"}
</p>
<div className="flex items-center gap-3">
<p className="font-mono text-[0.8125rem] font-semibold tabular-nums text-bright">
{moneda(importe)}
</p>
{partidas.length > 1 ? (
<button
type="button"
onClick={() =>
setPartidas((prev) => prev.filter((x) => x.llave !== p.llave))
}
className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-signal-hi underline decoration-rule-2 underline-offset-[3px] hover:decoration-signal"
style={ESTILO_CAMPO}
>
Quitar
<span className="sr-only"> la partida {i + 1}</span>
</button>
) : null}
</div>
</div>
</li>
);
})}
</ul>
<button
type="button"
onClick={() => setPartidas((prev) => [...prev, nuevaPartida()])}
className="btn btn-ghost btn-sm justify-self-start"
style={ESTILO_CAMPO}
>
Agregar partida
</button>
<div>
<label htmlFor="pd-notas" className="field-label">
Notas del pedido (opcional)
</label>
<textarea
id="pd-notas"
rows={2}
value={notas}
onChange={(e) => setNotas(e.target.value)}
placeholder="Entrega en almacén 2, previa cita con seguridad patrimonial."
className="field w-full py-2 text-sm"
style={ESTILO_CAMPO}
/>
</div>
{/* Totales en vivo: la cifra final se ve antes de enviar, no después. */}
<dl className="spec grid-cols-3" aria-live="polite">
<div className="spec-cell">
<dt className="spec-key">Subtotal</dt>
<dd className="spec-val">{moneda(totales.subtotal)}</dd>
</div>
<div className="spec-cell">
<dt className="spec-key">IVA {Math.round(IVA * 100)} %</dt>
<dd className="spec-val">{moneda(totales.iva)}</dd>
</div>
<div className="spec-cell">
<dt className="spec-key">Total</dt>
<dd className="spec-val text-signal-hi">{moneda(totales.total)}</dd>
</div>
</dl>
<div className="flex flex-wrap items-center gap-3">
<button
type="submit"
disabled={enviando || validas.length === 0}
className="btn btn-signal btn-sm"
style={ESTILO_CAMPO}
>
{enviando ? "Creando…" : "Crear pedido"}
</button>
<button
type="button"
disabled={enviando}
onClick={() => setAbierto(false)}
className="btn btn-ghost btn-sm"
style={ESTILO_CAMPO}
>
Cancelar
</button>
<AvisoAccion aviso={aviso} />
</div>
{validas.length === 0 ? (
<p className="font-mono text-[0.5625rem] uppercase leading-relaxed tracking-[0.1em] text-faint">
Falta al menos una partida con producto, cantidad y precio unitario
</p>
) : null}
</form>
);
}
@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import {
ESTADOS_PEDIDO,
ESTADO_PEDIDO_ETIQUETA,
type EstadoPedido,
} from "@/lib/crm/constantes";
import { accionCambiarEstadoPedido } from "@/lib/crm/acciones";
import { AvisoAccion, usarAccion } from "@/components/admin/usarAccion";
/**
* Cambio de estado del pedido. Cancelar pide confirmación explícita: es el
* único estado del que no se sale sin explicar por qué.
*/
export function CambiarEstadoPedido({
id,
estado,
}: {
id: string;
estado: EstadoPedido;
}) {
const { enviando, aviso, ejecutar } = usarAccion();
const [elegido, setElegido] = useState<EstadoPedido>(estado);
const confirmandoCancelacion = elegido === "CANCELADO" && estado !== "CANCELADO";
function alCambiar(v: string) {
const siguiente = v as EstadoPedido;
setElegido(siguiente);
if (siguiente === "CANCELADO") return;
if (siguiente === estado) return;
void ejecutar(
() => accionCambiarEstadoPedido(id, siguiente),
`Pedido en ${ESTADO_PEDIDO_ETIQUETA[siguiente].toLowerCase()}.`,
);
}
return (
<div className="grid gap-2">
<label htmlFor="pedido-estado" className="field-label">
Estado del pedido
</label>
<select
id="pedido-estado"
value={elegido}
disabled={enviando}
onChange={(e) => alCambiar(e.target.value)}
className="field w-full py-2 text-sm"
style={{ appearance: "none", WebkitAppearance: "none" }}
>
{ESTADOS_PEDIDO.map((e) => (
<option key={e} value={e}>
{ESTADO_PEDIDO_ETIQUETA[e]}
</option>
))}
</select>
{confirmandoCancelacion ? (
<div className="grid gap-2 rounded-[3px] border border-signal-dim bg-ink px-3 py-3">
<p className="text-xs leading-relaxed text-muted">
Cancelar el pedido lo saca del flujo de surtido. Confírmalo si es lo que quieres.
</p>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={enviando}
onClick={() =>
void ejecutar(
() => accionCambiarEstadoPedido(id, "CANCELADO"),
"Pedido cancelado.",
)
}
className="btn btn-signal btn-sm"
style={{ appearance: "none" }}
>
{enviando ? "Cancelando…" : "Cancelar pedido"}
</button>
<button
type="button"
disabled={enviando}
onClick={() => setElegido(estado)}
className="btn btn-ghost btn-sm"
style={{ appearance: "none" }}
>
No cancelar
</button>
</div>
</div>
) : null}
<AvisoAccion aviso={aviso} />
</div>
);
}
@@ -0,0 +1,295 @@
"use client";
import { useActionState, useState } from "react";
import { useRouter } from "next/navigation";
import {
ORIGENES,
ORIGEN_ETIQUETA,
TIPOS_REGLA,
TIPO_REGLA_ETIQUETA,
type TipoRegla,
} from "@/lib/crm/constantes";
import { INDUSTRIAS } from "@/data/industrias";
import { CATEGORIAS } from "@/data/catalogo";
import { CLASES, CLASES_INFO } from "@/data/clases-fuego";
import {
accionAlternarRegla,
accionCrearRegla,
accionEliminarRegla,
} from "@/lib/crm/acciones";
import { AvisoAccion, usarAccion, type Resultado } from "@/components/admin/usarAccion";
/**
* Alta y control de reglas de asignación.
*
* El campo `valor` no es libre: se ofrece el dominio que corresponde al tipo
* elegido —industrias, categorías, orígenes o clases de fuego—. Una regla que
* compara contra un valor inexistente nunca coincide, y depurar eso desde la
* bitácora es un desperdicio de tiempo evitable.
*/
const ESTILO = { appearance: "none", WebkitAppearance: "none" } as const;
type Opcion = { valor: string; etiqueta: string };
function opcionesDe(tipo: TipoRegla): Opcion[] {
switch (tipo) {
case "INDUSTRIA":
return INDUSTRIAS.map((i) => ({ valor: i.slug, etiqueta: i.nombre }));
case "CATEGORIA":
return [...CATEGORIAS]
.sort((a, b) => a.orden - b.orden)
.map((c) => ({ valor: c.slug, etiqueta: c.nombre }));
case "ORIGEN":
return ORIGENES.map((o) => ({ valor: o, etiqueta: ORIGEN_ETIQUETA[o] }));
case "CLASE":
return CLASES.map((c) => ({
valor: c,
etiqueta: `Clase ${c} — ${CLASES_INFO[c].nombre}`,
}));
default:
return [];
}
}
export function FormularioRegla({
equipo,
prioridadSugerida,
onListo,
}: {
equipo: { id: string; nombre: string; activo: boolean }[];
/** Se coloca al final de la tabla por omisión, para no alterar lo que ya funciona. */
prioridadSugerida: number;
onListo: string;
}) {
const router = useRouter();
const [tipo, setTipo] = useState<TipoRegla>("INDUSTRIA");
const [estado, enviar, pendiente] = useActionState<Resultado | null, FormData>(
async (_previo, datos) => {
const r = await accionCrearRegla(datos);
if (r.ok) router.push(onListo);
return r;
},
null,
);
const opciones = opcionesDe(tipo);
const pideValor = tipo !== "CUALQUIERA";
return (
<form action={enviar} className="grid gap-4 px-4 py-4 sm:px-5">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="rg-nombre" className="field-label">
Nombre de la regla
</label>
<input
id="rg-nombre"
name="nombre"
type="text"
required
placeholder="Industria y energía a cuenta clave"
className="field py-2 text-sm"
style={ESTILO}
/>
<p className="mt-1.5 text-[0.6875rem] leading-snug text-faint">
Este texto aparece en la bitácora de cada oportunidad que la regla asigna.
</p>
</div>
<div>
<label htmlFor="rg-prioridad" className="field-label">
Prioridad de evaluación
</label>
<input
id="rg-prioridad"
name="prioridad"
type="number"
min={1}
max={9999}
step={1}
required
defaultValue={prioridadSugerida}
className="field py-2 font-mono text-sm"
style={ESTILO}
/>
<p className="mt-1.5 text-[0.6875rem] leading-snug text-faint">
Número menor, se evalúa antes. Gana la primera que coincide.
</p>
</div>
<div>
<label htmlFor="rg-tipo" className="field-label">
Tipo de condición
</label>
<select
id="rg-tipo"
name="tipo"
value={tipo}
onChange={(e) => setTipo(e.target.value as TipoRegla)}
className="field py-2 text-sm"
style={ESTILO}
>
{TIPOS_REGLA.map((t) => (
<option key={t} value={t}>
{TIPO_REGLA_ETIQUETA[t]}
</option>
))}
</select>
</div>
<div>
<label htmlFor="rg-valor" className="field-label">
Valor a comparar
</label>
{pideValor ? (
<select
id="rg-valor"
name="valor"
required
className="field py-2 text-sm"
style={ESTILO}
>
<option value="">Elige un valor</option>
{opciones.map((o) => (
<option key={o.valor} value={o.valor}>
{o.etiqueta}
</option>
))}
</select>
) : (
<>
<input type="hidden" name="valor" value="" />
<p
id="rg-valor"
className="rounded-[3px] border border-rule-2 bg-ink px-3 py-2 font-mono text-xs text-faint"
>
Sin valor: esta regla atrapa cualquier oportunidad
</p>
</>
)}
</div>
<div className="sm:col-span-2">
<label htmlFor="rg-usuario" className="field-label">
Se asigna a
</label>
<select
id="rg-usuario"
name="usuarioId"
required
className="field py-2 text-sm sm:max-w-sm"
style={ESTILO}
>
<option value="">Elige un integrante</option>
{equipo.map((u) => (
<option key={u.id} value={u.id}>
{u.nombre}
{u.activo ? "" : " (inactivo)"}
</option>
))}
</select>
</div>
</div>
<p aria-live="polite" role="status" className="min-h-[1.125rem]">
{estado && !estado.ok ? <span className="field-error">{estado.error}</span> : null}
</p>
<div className="flex flex-wrap gap-2">
<button
type="submit"
disabled={pendiente}
className="btn btn-signal btn-sm"
style={ESTILO}
>
{pendiente ? "Creando…" : "Crear regla"}
</button>
<button
type="button"
onClick={() => router.push(onListo)}
className="btn btn-ghost btn-sm"
style={ESTILO}
>
Cancelar
</button>
</div>
</form>
);
}
export function AccionesRegla({
id,
activa,
nombre,
}: {
id: string;
activa: boolean;
nombre: string;
}) {
const { enviando, aviso, ejecutar } = usarAccion();
const [confirmando, setConfirmando] = useState(false);
return (
<span className="inline-flex min-w-0 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={enviando}
onClick={() =>
void ejecutar(
() => accionAlternarRegla(id),
activa ? `Regla «${nombre}» desactivada.` : `Regla «${nombre}» activada.`,
)
}
className="btn btn-ghost btn-sm whitespace-nowrap"
style={ESTILO}
>
{activa ? "Desactivar" : "Activar"}
<span className="sr-only"> la regla {nombre}</span>
</button>
{confirmando ? (
<>
<button
type="button"
disabled={enviando}
onClick={() =>
void ejecutar(
() => accionEliminarRegla(id),
`Regla «${nombre}» eliminada.`,
)
}
className="btn btn-signal btn-sm whitespace-nowrap"
style={ESTILO}
>
{enviando ? "Eliminando…" : "Confirmar"}
</button>
<button
type="button"
disabled={enviando}
onClick={() => setConfirmando(false)}
className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-muted underline decoration-rule-2 underline-offset-[3px]"
style={ESTILO}
>
No
</button>
</>
) : (
<button
type="button"
disabled={enviando}
onClick={() => setConfirmando(true)}
className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-signal-hi underline decoration-rule-2 underline-offset-[3px] hover:decoration-signal"
style={ESTILO}
>
Eliminar
<span className="sr-only"> la regla {nombre}</span>
</button>
)}
</span>
<AvisoAccion aviso={aviso} />
</span>
);
}
+323
View File
@@ -0,0 +1,323 @@
import Link from "next/link";
import type { ReactNode } from "react";
import {
ESTADO_PEDIDO_ETIQUETA,
ETAPA_COLOR,
ETAPA_ETIQUETA,
ORIGEN_ETIQUETA,
PRIORIDAD_ETIQUETA,
ROL_ETIQUETA,
type EstadoPedido,
type Etapa,
type Origen,
type Prioridad,
type Rol,
} from "@/lib/crm/constantes";
/**
* Piezas compartidas del panel. Todas son de servidor: no traen estado, solo
* traducen dominio a superficie. El sistema de diseño es el mismo del sitio
* público — mismos tokens, mismos radios casi rectos, misma capa de datos en
* `font-mono`.
*/
// ---------- encabezado de página ----------
export function EncabezadoPagina({
titulo,
descripcion,
eyebrow,
acciones,
}: {
titulo: string;
descripcion?: string;
eyebrow?: string;
acciones?: ReactNode;
}) {
return (
<header className="mb-6 flex flex-col gap-4 sm:mb-8 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{eyebrow ? <p className="eyebrow mb-3">{eyebrow}</p> : null}
<h1 className="text-2xl uppercase tracking-tight sm:text-3xl">{titulo}</h1>
{descripcion ? (
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-muted">{descripcion}</p>
) : null}
</div>
{acciones ? <div className="flex shrink-0 flex-wrap gap-2">{acciones}</div> : null}
</header>
);
}
// ---------- panel con título ----------
export function Panel({
titulo,
nota,
acciones,
children,
className = "",
id,
}: {
titulo?: string;
nota?: string;
acciones?: ReactNode;
children: ReactNode;
className?: string;
id?: string;
}) {
return (
<section id={id} className={`panel overflow-hidden ${className}`}>
{titulo ? (
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2 border-b border-rule px-4 py-3 sm:px-5">
<div className="min-w-0">
<h2 className="font-mono text-[0.6875rem] font-semibold uppercase tracking-[0.14em] text-bright">
{titulo}
</h2>
{nota ? <p className="mt-1 text-xs leading-snug text-faint">{nota}</p> : null}
</div>
{acciones ? <div className="flex shrink-0 flex-wrap gap-2">{acciones}</div> : null}
</div>
) : null}
{children}
</section>
);
}
// ---------- contenedor de tabla ----------
/**
* El único lugar donde se permite desplazamiento horizontal. El cuerpo de la
* página nunca se mueve en X: por eso el `min-w-0` del padre y el `overflow`
* aquí, con el impulso táctil de WebKit.
*/
export function ContenedorTabla({
children,
etiqueta,
}: {
children: ReactNode;
etiqueta?: string;
}) {
return (
<div
className="w-full min-w-0 overflow-x-auto"
style={{ WebkitOverflowScrolling: "touch" }}
tabIndex={0}
role="region"
aria-label={etiqueta ?? "Tabla con desplazamiento horizontal"}
>
{children}
</div>
);
}
export function Th({
children,
className = "",
scope = "col",
numerico = false,
}: {
children: ReactNode;
className?: string;
scope?: "col" | "row";
numerico?: boolean;
}) {
return (
<th
scope={scope}
className={[
"whitespace-nowrap border-b border-rule px-3 py-2.5 font-mono text-[0.625rem] font-semibold uppercase tracking-[0.12em] text-faint",
numerico ? "text-right" : "text-left",
className,
].join(" ")}
>
{children}
</th>
);
}
export function Td({
children,
className = "",
numerico = false,
mono = false,
}: {
/** Opcional: una celda vacía de relleno en un `tfoot` es legítima. */
children?: ReactNode;
className?: string;
numerico?: boolean;
mono?: boolean;
}) {
return (
<td
className={[
"border-b border-rule/60 px-3 py-2.5 align-middle text-sm",
numerico ? "text-right" : "text-left",
mono || numerico ? "font-mono text-[0.8125rem]" : "",
className,
].join(" ")}
>
{children}
</td>
);
}
// ---------- estado vacío ----------
/** No se lamenta: dice qué hacer. */
export function EstadoVacio({
titulo,
quehacer,
accion,
}: {
titulo: string;
quehacer: string;
accion?: ReactNode;
}) {
return (
<div className="flex flex-col items-start gap-3 px-4 py-10 sm:items-center sm:px-6 sm:text-center">
<p className="font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-faint">
{titulo}
</p>
<p className="max-w-md text-sm leading-relaxed text-muted">{quehacer}</p>
{accion}
</div>
);
}
// ---------- insignias de dominio ----------
export function InsigniaEtapa({ etapa, sm = false }: { etapa: Etapa; sm?: boolean }) {
return (
<span
className={[
"inline-flex items-center gap-1.5 rounded-[2px] border border-rule-2 bg-ink font-mono font-medium uppercase tracking-[0.08em] text-bright",
sm ? "px-1.5 py-0.5 text-[0.5625rem]" : "px-2 py-1 text-[0.625rem]",
].join(" ")}
>
<span
aria-hidden="true"
className="block h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: ETAPA_COLOR[etapa] }}
/>
{ETAPA_ETIQUETA[etapa]}
</span>
);
}
export function InsigniaPrioridad({ prioridad }: { prioridad: Prioridad }) {
const clase =
prioridad === "ALTA"
? "badge badge-signal"
: prioridad === "MEDIA"
? "badge badge-ember"
: "badge";
return <span className={clase}>{PRIORIDAD_ETIQUETA[prioridad]}</span>;
}
export function InsigniaRol({ rol }: { rol: Rol }) {
return (
<span className={rol === "ADMIN" ? "badge badge-signal" : "badge"}>
{ROL_ETIQUETA[rol]}
</span>
);
}
export function InsigniaOrigen({ origen }: { origen: Origen }) {
return (
<span className="font-mono text-[0.6875rem] uppercase tracking-[0.08em] text-muted">
{ORIGEN_ETIQUETA[origen]}
</span>
);
}
const CLASE_ESTADO_PEDIDO: Record<EstadoPedido, string> = {
BORRADOR: "badge",
CONFIRMADO: "badge badge-ember",
EN_PROCESO: "badge badge-ember",
ENVIADO: "badge",
ENTREGADO: "badge badge-ok",
CANCELADO: "badge badge-signal",
};
export function InsigniaEstadoPedido({ estado }: { estado: EstadoPedido }) {
return (
<span className={CLASE_ESTADO_PEDIDO[estado]}>{ESTADO_PEDIDO_ETIQUETA[estado]}</span>
);
}
/**
* Aviso de espera. Es lo más accionable del panel: una oportunidad sin primera
* respuesta es dinero enfriándose, así que sube de tono con las horas.
*/
export function InsigniaEspera({ horasSinRespuesta }: { horasSinRespuesta: number | null }) {
if (horasSinRespuesta == null) {
return <span className="badge badge-ok">Atendida</span>;
}
const critica = horasSinRespuesta >= 24;
const alta = horasSinRespuesta >= 4;
return (
<span className={critica ? "badge badge-signal" : alta ? "badge badge-ember" : "badge"}>
{horasSinRespuesta < 10
? `${horasSinRespuesta.toFixed(1)} h sin atender`
: `${Math.round(horasSinRespuesta)} h sin atender`}
</span>
);
}
// ---------- tira de especificación ----------
export function Spec({
datos,
columnas = 2,
}: {
datos: { clave: string; valor: ReactNode }[];
columnas?: 1 | 2 | 3 | 4;
}) {
const cols =
columnas === 1
? "grid-cols-1"
: columnas === 2
? "grid-cols-1 sm:grid-cols-2"
: columnas === 3
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4";
return (
<dl className={`spec ${cols}`}>
{datos.map((d) => (
<div key={d.clave} className="spec-cell min-w-0">
<dt className="spec-key">{d.clave}</dt>
<dd className="spec-val break-words">{d.valor}</dd>
</div>
))}
</dl>
);
}
// ---------- enlace de tabla ----------
export function EnlaceFolio({ href, folio }: { href: string; folio: string }) {
return (
<Link
href={href}
className="font-mono text-[0.8125rem] font-semibold text-bright underline decoration-rule-2 underline-offset-[3px] transition-colors hover:decoration-signal"
style={{ WebkitTapHighlightColor: "transparent" }}
>
{folio}
</Link>
);
}
// ---------- nota explicativa ----------
export function Nota({ children, tono = "neutro" }: { children: ReactNode; tono?: "neutro" | "aviso" }) {
return (
<p
className={[
"border-l-2 py-2 pl-3 text-xs leading-relaxed",
tono === "aviso" ? "border-ember text-muted" : "border-rule-2 text-muted",
].join(" ")}
>
{children}
</p>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useCallback, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
/**
* Puente entre los controles del panel y las server actions del lote de datos.
*
* Todas las acciones devuelven `{ ok: true } | { ok: false, error }`, así que
* el resultado se puede anunciar sin adivinar: un `aria-live` lo lee en voz
* alta y el mismo estado apaga el control mientras la operación viaja.
*/
export type Resultado = { ok: true } | { ok: false; error: string };
export type Aviso = { tipo: "ok" | "error"; texto: string } | null;
export function usarAccion() {
const router = useRouter();
const [enviando, setEnviando] = useState(false);
const [aviso, setAviso] = useState<Aviso>(null);
const [, iniciarTransicion] = useTransition();
const ejecutar = useCallback(
async (accion: () => Promise<Resultado>, textoExito: string) => {
setEnviando(true);
setAviso(null);
try {
const r = await accion();
if (r.ok) {
setAviso({ tipo: "ok", texto: textoExito });
iniciarTransicion(() => router.refresh());
} else {
setAviso({ tipo: "error", texto: r.error });
}
} catch {
setAviso({
tipo: "error",
texto: "No se pudo completar la operación. Vuelve a intentarlo.",
});
} finally {
setEnviando(false);
}
},
[router],
);
const limpiar = useCallback(() => setAviso(null), []);
return { enviando, aviso, ejecutar, limpiar };
}
/** Región viva: cualquier resultado de acción se anuncia aquí. */
export function AvisoAccion({ aviso, className = "" }: { aviso: Aviso; className?: string }) {
return (
<p
aria-live="polite"
role="status"
className={[
"min-h-[1.125rem] font-mono text-[0.6875rem] leading-snug tracking-[0.04em]",
aviso == null ? "text-transparent" : aviso.tipo === "ok" ? "text-ok" : "text-signal-hi",
className,
].join(" ")}
>
{aviso?.texto ?? " "}
</p>
);
}
@@ -0,0 +1,500 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { RielClasesSelector } from "@/components/marca/RielClases";
import type { ClaseFuego } from "@/data/clases-fuego";
import { OPCIONES_INDUSTRIA } from "@/data/industrias";
import type { Canal } from "@/lib/crm/constantes";
import type { RespuestaLead } from "@/lib/leads/esquema";
import { capturarAtribucion, type Atribucion } from "@/lib/utm";
/**
* Formulario de captación embebido. Va en el inicio, en el catálogo y en cada
* ficha de producto, y siempre viaja con su contexto: qué producto se estaba
* viendo, en qué URL y con qué campaña llegó el visitante.
*
* No pide "solicita información": pide los datos que un ingeniero necesita para
* cotizar sin una segunda vuelta de correos.
*/
export type ProductoInteres = {
slug: string;
nombre: string;
categoriaSlug?: string;
};
type Props = {
canal: Canal;
titulo?: string;
descripcion?: string;
/** Cuando el formulario vive en una ficha, el producto ya viene resuelto. */
producto?: ProductoInteres;
industriaPreseleccionada?: string;
clasesPreseleccionadas?: ClaseFuego[];
/** "claro" para las secciones sobre papel. */
variante?: "oscuro" | "claro";
/** Solo nombre, correo, teléfono y mensaje. Para espacios reducidos. */
compacto?: boolean;
className?: string;
};
type Estado = "listo" | "enviando" | "hecho" | "error";
const URGENCIAS = [
{ value: "inmediata", label: "Inmediata — tengo un hallazgo abierto" },
{ value: "trimestre", label: "Este trimestre" },
{ value: "presupuesto", label: "Estoy armando presupuesto" },
{ value: "explorando", label: "Explorando opciones" },
];
export function FormularioContacto({
canal,
titulo = "Solicitar diagnóstico",
descripcion = "Cuéntanos qué operación hay que proteger. Te respondemos con el escenario, el agente adecuado y qué documentación te corresponde.",
producto,
industriaPreseleccionada,
clasesPreseleccionadas = [],
variante = "oscuro",
compacto = false,
className = "",
}: Props) {
const claro = variante === "claro";
const [estado, setEstado] = useState<Estado>("listo");
const [errores, setErrores] = useState<Record<string, string>>({});
const [errorGeneral, setErrorGeneral] = useState<string | null>(null);
const [folio, setFolio] = useState<string | null>(null);
const [atiende, setAtiende] = useState<string | null>(null);
const [clases, setClases] = useState<ClaseFuego[]>(clasesPreseleccionadas);
const atribucion = useRef<Atribucion>({});
const regionEstado = useRef<HTMLDivElement>(null);
useEffect(() => {
atribucion.current = capturarAtribucion();
}, []);
// llevar el foco al aviso de resultado, para lector de pantalla
useEffect(() => {
if (estado === "hecho" || estado === "error") regionEstado.current?.focus();
}, [estado]);
async function enviar(evento: React.FormEvent<HTMLFormElement>) {
evento.preventDefault();
if (estado === "enviando") return;
const datos = new FormData(evento.currentTarget);
setEstado("enviando");
setErrores({});
setErrorGeneral(null);
const cuerpo = {
nombre: String(datos.get("nombre") ?? ""),
email: String(datos.get("email") ?? ""),
telefono: String(datos.get("telefono") ?? ""),
empresa: String(datos.get("empresa") ?? "") || undefined,
puesto: String(datos.get("puesto") ?? "") || undefined,
ciudad: String(datos.get("ciudad") ?? "") || undefined,
industria: String(datos.get("industria") ?? "") || undefined,
superficie: String(datos.get("superficie") ?? "") || undefined,
urgencia: String(datos.get("urgencia") ?? "") || undefined,
mensaje: String(datos.get("mensaje") ?? "") || undefined,
clases: clases.length ? clases : undefined,
origen: "FORMULARIO" as const,
canal,
productoSlug: producto?.slug,
productoNombre: producto?.nombre,
categoriaSlug: producto?.categoriaSlug,
productoUrl: typeof window !== "undefined" ? window.location.href.slice(0, 400) : undefined,
privacidad: datos.get("privacidad") === "on",
_sitio: String(datos.get("_sitio") ?? ""),
...atribucion.current,
};
try {
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(cuerpo),
});
const json = (await res.json()) as RespuestaLead;
if (json.ok) {
setFolio(json.folio);
setAtiende(json.atiende ?? null);
setEstado("hecho");
return;
}
setErrores(json.campos ?? {});
setErrorGeneral(json.error);
setEstado("error");
} catch {
setErrorGeneral("No pudimos enviar la solicitud. Revisa tu conexión e inténtalo otra vez.");
setEstado("error");
}
}
// ---------- acuse de recibo ----------
if (estado === "hecho") {
return (
<div
ref={regionEstado}
tabIndex={-1}
role="status"
className={[
claro ? "panel-lite" : "panel",
"p-7 outline-none sm:p-9",
className,
].join(" ")}
>
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-ok/15">
<svg width="13" height="10" viewBox="0 0 13 10" aria-hidden="true">
<path
d="M1 5l3.6 3.6L12 1.2"
stroke="var(--color-ok)"
strokeWidth="1.8"
fill="none"
strokeLinecap="round"
/>
</svg>
</span>
<p className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-ok">
Solicitud registrada
</p>
</div>
<h3
className={[
"mt-5 font-display text-2xl font-extrabold",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
Recibimos tu solicitud
</h3>
<p className={["mt-3 text-sm leading-relaxed", claro ? "text-deep-muted" : "text-muted"].join(" ")}>
{atiende
? `${atiende} tiene tu caso asignado y da seguimiento.`
: "Un especialista tiene tu caso asignado y da seguimiento."}{" "}
Si tu situación no puede esperar, marca directo al{" "}
<a href="tel:+525540348395" className="link">
55 4034 8395
</a>
.
</p>
<dl className={["spec mt-6", claro ? "" : ""].join(" ")} style={{ gridTemplateColumns: "1fr 1fr" }}>
<div className="spec-cell">
<dt className="spec-key">Folio</dt>
<dd className="spec-val">{folio}</dd>
</div>
<div className="spec-cell">
<dt className="spec-key">Registrado</dt>
<dd className="spec-val">
{new Date().toLocaleString("es-MX", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
})}
</dd>
</div>
</dl>
</div>
);
}
// ---------- formulario ----------
const claseCampo = claro ? "field field-lite" : "field";
const claseEtiqueta = claro ? "field-label !text-deep-muted" : "field-label";
return (
<div className={[claro ? "panel-lite" : "panel", "p-6 sm:p-8", className].join(" ")}>
<div className="mb-6">
<p className="eyebrow mb-3">{producto ? "Cotizar este equipo" : "Diagnóstico sin costo"}</p>
<h3
className={[
"font-display text-2xl font-extrabold leading-tight sm:text-[1.75rem]",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
{titulo}
</h3>
<p className={["mt-2.5 text-sm leading-relaxed", claro ? "text-deep-muted" : "text-muted"].join(" ")}>
{descripcion}
</p>
{producto ? (
<p className="mt-4 inline-flex items-center gap-2 rounded-[3px] border border-signal/40 bg-signal/10 px-2.5 py-1.5">
<span className="font-mono text-[0.625rem] uppercase tracking-[0.12em] text-signal-hi">
Interés
</span>
<span className={["font-mono text-xs", claro ? "text-deep" : "text-bright"].join(" ")}>
{producto.nombre}
</span>
</p>
) : null}
</div>
<form onSubmit={enviar} noValidate>
{/* señuelo antispam: invisible y fuera del orden de tabulación */}
<div aria-hidden="true" className="absolute h-0 w-0 overflow-hidden opacity-0">
<label htmlFor={`sitio-${canal}`}>No llenar</label>
<input id={`sitio-${canal}`} name="_sitio" type="text" tabIndex={-1} autoComplete="off" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor={`nombre-${canal}`} className={claseEtiqueta}>
Nombre
</label>
<input
id={`nombre-${canal}`}
name="nombre"
type="text"
required
autoComplete="name"
placeholder="Nombre y apellido"
className={claseCampo}
aria-invalid={Boolean(errores.nombre)}
aria-describedby={errores.nombre ? `err-nombre-${canal}` : undefined}
/>
{errores.nombre ? (
<span id={`err-nombre-${canal}`} className="field-error">
{errores.nombre}
</span>
) : null}
</div>
<div>
<label htmlFor={`email-${canal}`} className={claseEtiqueta}>
Correo
</label>
<input
id={`email-${canal}`}
name="email"
type="email"
required
autoComplete="email"
inputMode="email"
placeholder="[email protected]"
className={claseCampo}
aria-invalid={Boolean(errores.email)}
aria-describedby={errores.email ? `err-email-${canal}` : undefined}
/>
{errores.email ? (
<span id={`err-email-${canal}`} className="field-error">
{errores.email}
</span>
) : null}
</div>
<div>
<label htmlFor={`telefono-${canal}`} className={claseEtiqueta}>
Teléfono
</label>
<input
id={`telefono-${canal}`}
name="telefono"
type="tel"
required
autoComplete="tel"
inputMode="tel"
placeholder="55 1234 5678"
className={claseCampo}
aria-invalid={Boolean(errores.telefono)}
aria-describedby={errores.telefono ? `err-telefono-${canal}` : undefined}
/>
{errores.telefono ? (
<span id={`err-telefono-${canal}`} className="field-error">
{errores.telefono}
</span>
) : null}
</div>
{!compacto ? (
<>
<div>
<label htmlFor={`empresa-${canal}`} className={claseEtiqueta}>
Empresa
</label>
<input
id={`empresa-${canal}`}
name="empresa"
type="text"
autoComplete="organization"
placeholder="Razón social o nombre comercial"
className={claseCampo}
/>
</div>
<div>
<label htmlFor={`ciudad-${canal}`} className={claseEtiqueta}>
Ciudad o estado
</label>
<input
id={`ciudad-${canal}`}
name="ciudad"
type="text"
autoComplete="address-level2"
placeholder="Dónde está la instalación"
className={claseCampo}
/>
</div>
<div>
<label htmlFor={`industria-${canal}`} className={claseEtiqueta}>
Tipo de operación
</label>
<select
id={`industria-${canal}`}
name="industria"
defaultValue={industriaPreseleccionada ?? ""}
className={`${claseCampo} appearance-none [-webkit-appearance:none]`}
>
<option value="">Selecciona una</option>
{OPCIONES_INDUSTRIA.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor={`urgencia-${canal}`} className={claseEtiqueta}>
Cuándo lo necesitas
</label>
<select
id={`urgencia-${canal}`}
name="urgencia"
defaultValue=""
className={`${claseCampo} appearance-none [-webkit-appearance:none]`}
>
<option value="">Selecciona una</option>
{URGENCIAS.map((u) => (
<option key={u.value} value={u.value}>
{u.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor={`superficie-${canal}`} className={claseEtiqueta}>
Superficie a proteger
</label>
<input
id={`superficie-${canal}`}
name="superficie"
type="text"
placeholder="Ej. 4 500 m² en dos naves"
className={claseCampo}
/>
</div>
<div>
<label htmlFor={`puesto-${canal}`} className={claseEtiqueta}>
Tu puesto
</label>
<input
id={`puesto-${canal}`}
name="puesto"
type="text"
autoComplete="organization-title"
placeholder="Ej. Jefe de seguridad industrial"
className={claseCampo}
/>
</div>
{/* el riel: el visitante declara su propio riesgo */}
<div className="sm:col-span-2">
<span className={claseEtiqueta}>Clases de fuego presentes</span>
<div
className={[
"flex flex-wrap items-center gap-4 rounded-[3px] border px-3.5 py-3",
claro ? "border-rule-lite bg-paper-2/60" : "border-rule-2 bg-ink/60",
].join(" ")}
>
<RielClasesSelector valor={clases} onChange={setClases} tamano="md" />
<p
className={[
"font-mono text-[0.625rem] leading-relaxed tracking-[0.04em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
{clases.length === 0
? "Si no las conoces, déjalo vacío y lo clasificamos nosotros"
: `Declaraste ${clases.length} ${clases.length === 1 ? "clase" : "clases"}`}
</p>
</div>
</div>
</>
) : null}
<div className="sm:col-span-2">
<label htmlFor={`mensaje-${canal}`} className={claseEtiqueta}>
Qué hay que proteger
</label>
<textarea
id={`mensaje-${canal}`}
name="mensaje"
rows={compacto ? 3 : 4}
placeholder="Describe la instalación, el material que se maneja y qué te pidieron cumplir."
className={`${claseCampo} resize-y`}
/>
</div>
</div>
<div className="mt-5">
<label className="flex cursor-pointer items-start gap-2.5">
<input
name="privacidad"
type="checkbox"
required
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer accent-[var(--color-signal)] [-webkit-appearance:checkbox]"
aria-invalid={Boolean(errores.privacidad)}
/>
<span
className={[
"text-xs leading-relaxed",
claro ? "text-deep-muted" : "text-muted",
].join(" ")}
>
Acepto que FSPM use mis datos para responder esta solicitud, conforme al{" "}
<a href="/aviso-de-privacidad" className="link">
aviso de privacidad
</a>
.
</span>
</label>
{errores.privacidad ? <span className="field-error">{errores.privacidad}</span> : null}
</div>
{errorGeneral ? (
<div
ref={regionEstado}
tabIndex={-1}
role="alert"
className="mt-5 border-l-2 border-signal bg-signal/10 px-4 py-3 outline-none"
>
<p className="font-mono text-xs leading-relaxed text-signal-hi">{errorGeneral}</p>
</div>
) : null}
<button type="submit" disabled={estado === "enviando"} className="btn btn-signal mt-6 w-full">
{estado === "enviando" ? "Enviando…" : producto ? "Solicitar cotización" : "Solicitar diagnóstico"}
</button>
<p
className={[
"mt-3.5 text-center font-mono text-[0.625rem] leading-relaxed tracking-[0.06em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
Respuesta en horario hábil. Sin costo y sin compromiso de compra.
</p>
</form>
</div>
);
}
@@ -0,0 +1,379 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import type { RespuestaLead } from "@/lib/leads/esquema";
import { capturarAtribucion, type Atribucion } from "@/lib/utm";
/**
* Widget flotante de WhatsApp con formulario previo.
*
* La diferencia con un botón de WhatsApp normal está en el orden: primero se
* registra el contacto en el CRM con su atribución de campaña, y solo entonces
* se abre la conversación. Así el lead existe aunque la persona nunca envíe el
* mensaje, y la campaña que lo trajo queda medida.
*/
const MOTIVOS = [
{ value: "diagnostico", label: "Quiero un diagnóstico de mi instalación" },
{ value: "cotizacion", label: "Necesito cotizar equipo" },
{ value: "cumplimiento", label: "Tengo una verificación o auditoría encima" },
{ value: "urgencia", label: "Es urgente, tuve un incidente" },
{ value: "otro", label: "Otro tema" },
];
type Estado = "listo" | "enviando" | "hecho" | "error";
export function WidgetWhatsApp() {
const ruta = usePathname();
const [abierto, setAbierto] = useState(false);
const [insinuar, setInsinuar] = useState(false);
const [estado, setEstado] = useState<Estado>("listo");
const [error, setError] = useState<string | null>(null);
const [folio, setFolio] = useState<string | null>(null);
const [enlace, setEnlace] = useState<string | null>(null);
const atribucion = useRef<Atribucion>({});
const panel = useRef<HTMLDivElement>(null);
const primerCampo = useRef<HTMLInputElement>(null);
const disparador = useRef<HTMLButtonElement>(null);
// el panel no se monta en el panel de administración
const oculto = ruta.startsWith("/admin");
useEffect(() => {
atribucion.current = capturarAtribucion();
}, []);
// insinuar el widget una sola vez, a los 6 segundos, si nadie lo abrió
useEffect(() => {
if (oculto) return;
let visto = false;
try {
visto = window.sessionStorage.getItem("fspm.wa.visto") === "1";
} catch {
// sin almacenamiento: insinuamos igual
}
if (visto) return;
const t = window.setTimeout(() => setInsinuar(true), 6000);
return () => window.clearTimeout(t);
}, [oculto]);
// foco al abrir, devolución del foco al cerrar, cierre con Escape
useEffect(() => {
if (!abierto) return;
primerCampo.current?.focus();
try {
window.sessionStorage.setItem("fspm.wa.visto", "1");
} catch {
// ídem
}
setInsinuar(false);
function alTeclear(e: KeyboardEvent) {
if (e.key === "Escape") {
setAbierto(false);
disparador.current?.focus();
}
}
document.addEventListener("keydown", alTeclear);
return () => document.removeEventListener("keydown", alTeclear);
}, [abierto]);
// cerrar al hacer clic fuera
useEffect(() => {
if (!abierto) return;
function alClic(e: MouseEvent) {
const t = e.target as Node;
if (panel.current?.contains(t) || disparador.current?.contains(t)) return;
setAbierto(false);
}
document.addEventListener("mousedown", alClic);
return () => document.removeEventListener("mousedown", alClic);
}, [abierto]);
if (oculto) return null;
async function enviar(evento: React.FormEvent<HTMLFormElement>) {
evento.preventDefault();
if (estado === "enviando") return;
const datos = new FormData(evento.currentTarget);
const motivo = String(datos.get("motivo") ?? "");
const etiquetaMotivo = MOTIVOS.find((m) => m.value === motivo)?.label ?? motivo;
setEstado("enviando");
setError(null);
try {
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nombre: String(datos.get("nombre") ?? ""),
email: String(datos.get("email") ?? ""),
telefono: String(datos.get("telefono") ?? ""),
empresa: String(datos.get("empresa") ?? "") || undefined,
mensaje: etiquetaMotivo,
urgencia: motivo === "urgencia" ? "inmediata" : undefined,
origen: "WHATSAPP" as const,
canal: "widget" as const,
productoUrl:
typeof window !== "undefined" ? window.location.href.slice(0, 400) : undefined,
privacidad: datos.get("privacidad") === "on",
_sitio: String(datos.get("_sitio") ?? ""),
...atribucion.current,
}),
});
const json = (await res.json()) as RespuestaLead;
if (json.ok) {
setFolio(json.folio);
setEstado("hecho");
if (json.whatsappUrl) {
setEnlace(json.whatsappUrl);
// apertura inmediata: seguimos dentro del gesto del usuario
window.open(json.whatsappUrl, "_blank", "noopener,noreferrer");
}
return;
}
setError(json.error);
setEstado("error");
} catch {
setError("No pudimos registrar tu contacto. Revisa tu conexión.");
setEstado("error");
}
}
return (
<>
{/* disparador flotante */}
<div
className="fixed bottom-0 right-0 z-[60] p-4 sm:p-6"
style={{
paddingBottom: "max(1rem, env(safe-area-inset-bottom))",
paddingRight: "max(1rem, env(safe-area-inset-right))",
}}
>
<div className="flex items-center justify-end gap-2.5">
{insinuar && !abierto ? (
<p className="anim-rise hidden max-w-[13rem] rounded-[3px] border border-rule bg-ink-2 px-3 py-2 font-mono text-[0.6875rem] leading-snug text-muted shadow-xl shadow-black/40 sm:block">
¿Tienes una verificación encima? Escríbenos.
</p>
) : null}
<button
ref={disparador}
type="button"
onClick={() => setAbierto((v) => !v)}
aria-expanded={abierto}
aria-controls="panel-whatsapp"
aria-label={abierto ? "Cerrar contacto por WhatsApp" : "Contactar por WhatsApp"}
className="relative flex h-14 w-14 items-center justify-center rounded-full bg-signal text-white shadow-2xl shadow-signal/30 transition-transform duration-200 hover:scale-105 active:scale-95"
style={{ WebkitTapHighlightColor: "transparent" }}
>
{insinuar && !abierto ? (
<span
aria-hidden="true"
className="absolute inset-0 animate-ping rounded-full bg-signal opacity-40"
/>
) : null}
{abierto ? (
<svg width="17" height="17" viewBox="0 0 17 17" aria-hidden="true">
<path
d="M1.5 1.5l14 14M15.5 1.5l-14 14"
stroke="currentColor"
strokeWidth="1.9"
strokeLinecap="round"
/>
</svg>
) : (
<svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M17.47 14.38c-.3-.15-1.75-.86-2.02-.96-.27-.1-.47-.15-.67.15-.2.3-.77.97-.92 1.17-.15.2-.3.22-.6.07-.3-.15-1.26-.46-2.4-1.48-.89-.79-1.48-1.77-1.63-2.07-.15-.3-.02-.46.13-.61.13-.13.3-.35.45-.52.15-.18.2-.3.3-.5.1-.2.05-.38-.02-.53-.08-.15-.68-1.63-.93-2.22-.24-.58-.49-.5-.67-.51h-.57c-.2 0-.52.07-.79.37-.27.3-1.04 1.02-1.04 2.48 0 1.47 1.07 2.89 1.22 3.09.15.2 2.1 3.34 5.1 4.56.71.31 1.27.49 1.71.63.72.23 1.37.2 1.89.12.58-.09 1.75-.72 2-1.41.25-.7.25-1.29.17-1.42-.07-.12-.27-.2-.57-.35zM12.04 21.8h-.01a9.7 9.7 0 01-4.94-1.35l-.35-.21-3.67.96.98-3.58-.23-.37a9.67 9.67 0 01-1.48-5.16c0-5.35 4.36-9.7 9.71-9.7 2.6 0 5.03 1.01 6.86 2.85a9.63 9.63 0 012.84 6.86c0 5.35-4.36 9.7-9.71 9.7zm8.26-17.96A11.6 11.6 0 0012.04.44C5.6.44.36 5.68.36 12.12c0 2.06.54 4.07 1.56 5.85L.28 24l6.16-1.62a11.66 11.66 0 005.6 1.43h.01c6.43 0 11.67-5.24 11.67-11.68 0-3.12-1.21-6.05-3.42-8.26z" />
</svg>
)}
</button>
</div>
</div>
{/* panel */}
{abierto ? (
<div
ref={panel}
id="panel-whatsapp"
role="dialog"
aria-modal="false"
aria-labelledby="titulo-whatsapp"
className="anim-rise fixed inset-x-3 bottom-24 z-[59] max-h-[calc(100dvh-8rem)] overflow-y-auto rounded-[6px] border border-rule bg-ink-2 shadow-2xl shadow-black/60 sm:inset-x-auto sm:right-6 sm:w-[24rem]"
style={{
WebkitOverflowScrolling: "touch",
marginBottom: "env(safe-area-inset-bottom)",
}}
>
{/* franja de la marca */}
<div className="flex items-start gap-3 border-b border-rule bg-ink px-5 py-4">
<span className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-signal/15">
<svg width="15" height="15" viewBox="0 0 24 24" fill="var(--color-signal-hi)" aria-hidden="true">
<path d="M12.04 21.8h-.01a9.7 9.7 0 01-4.94-1.35l-.35-.21-3.67.96.98-3.58-.23-.37a9.67 9.67 0 01-1.48-5.16c0-5.35 4.36-9.7 9.71-9.7 2.6 0 5.03 1.01 6.86 2.85a9.63 9.63 0 012.84 6.86c0 5.35-4.36 9.7-9.71 9.7z" />
</svg>
</span>
<div className="min-w-0">
<h2 id="titulo-whatsapp" className="font-display text-base font-extrabold text-bright">
Hablar por WhatsApp
</h2>
<p className="mt-0.5 font-mono text-[0.625rem] leading-snug tracking-[0.06em] text-faint">
Te dejamos con un especialista, no con un bot
</p>
</div>
</div>
{estado === "hecho" ? (
<div className="px-5 py-6" role="status">
<p className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-ok">
Contacto registrado
</p>
<p className="mt-3 text-sm leading-relaxed text-muted">
Abrimos WhatsApp en otra pestaña. Si tu navegador la bloqueó, usa este enlace.
</p>
{enlace ? (
<a
href={enlace}
target="_blank"
rel="noopener noreferrer"
className="btn btn-signal btn-sm mt-4 w-full"
>
Abrir WhatsApp
</a>
) : null}
<p className="mt-4 font-mono text-[0.625rem] tracking-[0.08em] text-faint">
Folio {folio}
</p>
</div>
) : (
<form onSubmit={enviar} noValidate className="px-5 py-5">
<div aria-hidden="true" className="absolute h-0 w-0 overflow-hidden opacity-0">
<label htmlFor="sitio-wa">No llenar</label>
<input id="sitio-wa" name="_sitio" type="text" tabIndex={-1} autoComplete="off" />
</div>
<div className="space-y-3.5">
<div>
<label htmlFor="wa-nombre" className="field-label">
Nombre
</label>
<input
ref={primerCampo}
id="wa-nombre"
name="nombre"
type="text"
required
autoComplete="name"
placeholder="Nombre y apellido"
className="field"
/>
</div>
<div>
<label htmlFor="wa-telefono" className="field-label">
Teléfono
</label>
<input
id="wa-telefono"
name="telefono"
type="tel"
required
autoComplete="tel"
inputMode="tel"
placeholder="55 1234 5678"
className="field"
/>
</div>
<div>
<label htmlFor="wa-email" className="field-label">
Correo
</label>
<input
id="wa-email"
name="email"
type="email"
required
autoComplete="email"
inputMode="email"
placeholder="[email protected]"
className="field"
/>
</div>
<div>
<label htmlFor="wa-empresa" className="field-label">
Empresa
</label>
<input
id="wa-empresa"
name="empresa"
type="text"
autoComplete="organization"
placeholder="Opcional"
className="field"
/>
</div>
<div>
<label htmlFor="wa-motivo" className="field-label">
Motivo
</label>
<select
id="wa-motivo"
name="motivo"
required
defaultValue="diagnostico"
className="field appearance-none [-webkit-appearance:none]"
>
{MOTIVOS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
</div>
<label className="mt-4 flex cursor-pointer items-start gap-2.5">
<input
name="privacidad"
type="checkbox"
required
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer accent-[var(--color-signal)] [-webkit-appearance:checkbox]"
/>
<span className="text-[0.6875rem] leading-relaxed text-muted">
Acepto el{" "}
<a href="/aviso-de-privacidad" className="link">
aviso de privacidad
</a>
.
</span>
</label>
{error ? (
<p role="alert" className="field-error mt-3">
{error}
</p>
) : null}
<button
type="submit"
disabled={estado === "enviando"}
className="btn btn-signal mt-4 w-full"
>
{estado === "enviando" ? "Registrando…" : "Abrir WhatsApp"}
</button>
</form>
)}
</div>
) : null}
</>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { CLASES, type ClaseFuego } from "@/data/clases-fuego";
/**
* ELEMENTO FIRMA DEL SITIO — el riel de clases de fuego.
*
* Cinco celdas fijas, A B C D K. Se encienden solo las clases que ese producto,
* solución o lead realmente cubre; el resto queda apagado. No es decoración:
* la posición es constante, así que dos productos se comparan de un vistazo.
*
* Aparece igual en el sitio público y en el panel comercial, donde clasifica
* el riesgo declarado por el lead. Es lo que une las dos mitades del producto.
*/
type Props = {
activas: ClaseFuego[];
/** md por omisión. sm para tablas y tarjetas densas, lg para héroes. */
tamano?: "sm" | "md" | "lg";
/** Sobre superficie clara invierte los apagados. */
claro?: boolean;
/** Enciende las celdas en secuencia al montar. Solo para el héroe. */
animar?: boolean;
/** Etiqueta a la izquierda del riel. */
etiqueta?: string;
className?: string;
};
const DIM = {
sm: { celda: "h-5 w-5", texto: "text-[0.625rem]" },
md: { celda: "h-7 w-7", texto: "text-xs" },
lg: { celda: "h-10 w-10", texto: "text-base" },
} as const;
export function RielClases({
activas,
tamano = "md",
claro = false,
animar = false,
etiqueta,
className = "",
}: Props) {
const d = DIM[tamano];
const encendidas = CLASES.filter((c) => activas.includes(c));
const resumen =
encendidas.length > 0
? `Clases de fuego que cubre: ${encendidas.join(", ")}`
: "Sin clase de fuego declarada";
return (
<div className={`flex items-center gap-2.5 ${className}`}>
{etiqueta ? (
<span className="font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
{etiqueta}
</span>
) : null}
<ul
className="flex items-center gap-1"
role="img"
aria-label={resumen}
style={{ ["--riel-total" as string]: CLASES.length }}
>
{CLASES.map((clase, i) => {
const on = activas.includes(clase);
return (
<li
key={clase}
aria-hidden="true"
className={[
d.celda,
d.texto,
"flex items-center justify-center rounded-[2px] border font-mono font-semibold leading-none",
on
? "border-signal bg-signal text-white"
: claro
? "border-rule-lite bg-paper-2 text-paper-3"
: "border-rule bg-ink-3 text-faint",
animar && on ? "anim-ignite" : "",
].join(" ")}
style={animar && on ? { animationDelay: `${240 + i * 90}ms` } : undefined}
>
{clase}
</li>
);
})}
</ul>
</div>
);
}
/**
* Variante interactiva: el visitante enciende las clases que tiene en su
* operación. Es la mitad izquierda del clasificador del héroe y viaja al CRM
* como el riesgo que el propio lead declaró.
*/
export function RielClasesSelector({
valor,
onChange,
tamano = "lg",
disabled = false,
}: {
valor: ClaseFuego[];
onChange: (clases: ClaseFuego[]) => void;
tamano?: "sm" | "md" | "lg";
disabled?: boolean;
}) {
const d = DIM[tamano];
function alternar(clase: ClaseFuego) {
onChange(valor.includes(clase) ? valor.filter((c) => c !== clase) : [...valor, clase]);
}
return (
<div className="flex items-center gap-1.5" role="group" aria-label="Clases de fuego presentes en tu operación">
{CLASES.map((clase) => {
const on = valor.includes(clase);
return (
<button
key={clase}
type="button"
disabled={disabled}
onClick={() => alternar(clase)}
aria-pressed={on}
title={`Clase ${clase}`}
className={[
d.celda,
d.texto,
"flex items-center justify-center rounded-[2px] border font-mono font-semibold leading-none transition-colors duration-150",
on
? "border-signal bg-signal text-white"
: "border-rule-2 bg-ink text-muted hover:border-muted hover:text-bright",
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
].join(" ")}
>
{clase}
<span className="sr-only">Clase {clase}</span>
</button>
);
})}
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { EMPRESA, NAV_PRINCIPAL } from "@/data/empresa";
import { INDUSTRIAS } from "@/data/industrias";
export function Encabezado() {
const ruta = usePathname();
const [abierto, setAbierto] = useState(false);
const [desplazado, setDesplazado] = useState(false);
const [solucionesAbierto, setSolucionesAbierto] = useState(false);
useEffect(() => {
const alScroll = () => setDesplazado(window.scrollY > 12);
alScroll();
window.addEventListener("scroll", alScroll, { passive: true });
return () => window.removeEventListener("scroll", alScroll);
}, []);
// cerrar el menú al navegar
useEffect(() => {
setAbierto(false);
setSolucionesAbierto(false);
}, [ruta]);
// bloquear el scroll del cuerpo con el menú móvil abierto
useEffect(() => {
document.body.style.overflow = abierto ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [abierto]);
const activa = (href: string) => ruta === href || ruta.startsWith(`${href}/`);
return (
<header
className={[
"sticky top-0 z-50 border-b transition-colors duration-200",
desplazado ? "border-rule bg-ink/95 backdrop-blur-md" : "border-transparent bg-ink",
].join(" ")}
>
{/* barra de contacto: el teléfono de un proveedor de emergencia siempre visible */}
<div className="hidden border-b border-rule/60 lg:block">
<div className="shell flex h-9 items-center justify-between">
<p className="font-mono text-[0.625rem] uppercase tracking-[0.16em] text-faint">
Atención a operaciones críticas · Ciudad de México y a distancia
</p>
<div className="flex items-center gap-5">
<a
href={`tel:${EMPRESA.telefonoTel}`}
className="font-mono text-[0.6875rem] tracking-[0.08em] text-muted transition-colors hover:text-bright"
>
{EMPRESA.telefono}
</a>
<a
href={`mailto:${EMPRESA.correo}`}
className="font-mono text-[0.6875rem] tracking-[0.08em] text-muted transition-colors hover:text-bright"
>
{EMPRESA.correo}
</a>
</div>
</div>
</div>
<div className="shell flex h-[4.5rem] items-center justify-between gap-6">
<Link href="/" className="flex shrink-0 items-center gap-3" aria-label={`${EMPRESA.nombreCorto} — inicio`}>
<Image
src="/media/marca/fspm-logo-blanco.png"
alt={EMPRESA.nombre}
width={148}
height={40}
priority
className="h-8 w-auto"
/>
</Link>
{/* navegación de escritorio */}
<nav className="hidden items-center gap-1 lg:flex" aria-label="Navegación principal">
{/* Soluciones abre las cinco rutas por industria */}
<div
className="relative"
onMouseEnter={() => setSolucionesAbierto(true)}
onMouseLeave={() => setSolucionesAbierto(false)}
>
<Link
href="/soluciones"
aria-expanded={solucionesAbierto}
className={[
"flex items-center gap-1.5 px-3 py-2 font-mono text-[0.75rem] uppercase tracking-[0.1em] transition-colors",
activa("/soluciones") ? "text-signal-hi" : "text-muted hover:text-bright",
].join(" ")}
>
Soluciones
<svg width="9" height="6" viewBox="0 0 9 6" aria-hidden="true" className="opacity-60">
<path d="M1 1l3.5 3.5L8 1" stroke="currentColor" strokeWidth="1.4" fill="none" />
</svg>
</Link>
{solucionesAbierto ? (
<div className="absolute left-0 top-full w-[22rem] pt-2">
<div className="panel overflow-hidden p-1.5 shadow-2xl shadow-black/50">
{INDUSTRIAS.map((ind) => (
<Link
key={ind.slug}
href={`/soluciones/${ind.slug}`}
className="group flex items-start gap-3 rounded-[3px] px-3 py-2.5 transition-colors hover:bg-ink-3"
>
<span className="mt-0.5 font-mono text-[0.625rem] text-signal">
{String(ind.prioridad).padStart(2, "0")}
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold text-bright">{ind.nombre}</span>
<span className="mt-0.5 block truncate text-xs text-faint">{ind.dolor}</span>
</span>
</Link>
))}
</div>
</div>
) : null}
</div>
{NAV_PRINCIPAL.filter((n) => n.href !== "/soluciones").map((n) => (
<Link
key={n.href}
href={n.href}
className={[
"px-3 py-2 font-mono text-[0.75rem] uppercase tracking-[0.1em] transition-colors",
activa(n.href) ? "text-signal-hi" : "text-muted hover:text-bright",
].join(" ")}
>
{n.label}
</Link>
))}
</nav>
<div className="hidden shrink-0 items-center gap-2.5 lg:flex">
<Link href="/contacto" className="btn btn-signal btn-sm">
Solicitar diagnóstico
</Link>
</div>
{/* disparador móvil */}
<button
type="button"
onClick={() => setAbierto((v) => !v)}
aria-expanded={abierto}
aria-controls="menu-movil"
aria-label={abierto ? "Cerrar menú" : "Abrir menú"}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[3px] border border-rule-2 text-bright lg:hidden"
>
<span className="relative block h-3 w-4">
<span
className={[
"absolute left-0 block h-[1.5px] w-4 bg-current transition-transform duration-200",
abierto ? "top-[5px] rotate-45" : "top-0",
].join(" ")}
/>
<span
className={[
"absolute left-0 top-[5px] block h-[1.5px] w-4 bg-current transition-opacity duration-200",
abierto ? "opacity-0" : "opacity-100",
].join(" ")}
/>
<span
className={[
"absolute left-0 block h-[1.5px] w-4 bg-current transition-transform duration-200",
abierto ? "top-[5px] -rotate-45" : "top-[10px]",
].join(" ")}
/>
</span>
</button>
</div>
{/* panel móvil */}
{abierto ? (
<div
id="menu-movil"
className="fixed inset-x-0 bottom-0 top-[4.5rem] z-40 overflow-y-auto border-t border-rule bg-ink lg:hidden"
>
<div className="shell py-6">
<p className="eyebrow eyebrow-muted mb-3">Rutas por industria</p>
<ul className="mb-7 divide-y divide-rule/70">
{INDUSTRIAS.map((ind) => (
<li key={ind.slug}>
<Link
href={`/soluciones/${ind.slug}`}
className="flex items-baseline gap-3 py-3.5"
>
<span className="font-mono text-[0.625rem] text-signal">
{String(ind.prioridad).padStart(2, "0")}
</span>
<span>
<span className="block font-display text-lg font-bold text-bright">
{ind.nombre}
</span>
<span className="block text-xs text-faint">{ind.dolor}</span>
</span>
</Link>
</li>
))}
</ul>
<p className="eyebrow eyebrow-muted mb-3">Secciones</p>
<ul className="mb-8 divide-y divide-rule/70">
{NAV_PRINCIPAL.filter((n) => n.href !== "/soluciones").map((n) => (
<li key={n.href}>
<Link
href={n.href}
className="block py-3.5 font-mono text-sm uppercase tracking-[0.1em] text-bright"
>
{n.label}
</Link>
</li>
))}
</ul>
<Link href="/contacto" className="btn btn-signal w-full">
Solicitar diagnóstico
</Link>
<div className="mt-6 space-y-2">
<a
href={`tel:${EMPRESA.telefonoTel}`}
className="block font-mono text-sm text-muted"
>
{EMPRESA.telefono}
</a>
<a href={`mailto:${EMPRESA.correo}`} className="block font-mono text-sm text-muted">
{EMPRESA.correo}
</a>
</div>
</div>
</div>
) : null}
</header>
);
}
+134
View File
@@ -0,0 +1,134 @@
import Image from "next/image";
import Link from "next/link";
import { AVISO_DEMO, EMPRESA, NAV_PIE } from "@/data/empresa";
const COLUMNAS = [
{ titulo: "Soluciones", enlaces: NAV_PIE.soluciones },
{ titulo: "Recursos", enlaces: NAV_PIE.recursos },
{ titulo: "Empresa", enlaces: NAV_PIE.empresa },
] as const;
export function Pie() {
const anio = new Date().getFullYear();
return (
<footer className="border-t border-rule bg-ink">
<div className="shell py-14 lg:py-16">
<div className="grid gap-10 lg:grid-cols-[1.4fr_repeat(3,1fr)] lg:gap-12">
{/* identidad y contacto */}
<div>
<Image
src="/media/marca/fspm-logo-blanco.png"
alt={EMPRESA.nombre}
width={140}
height={38}
className="h-8 w-auto"
/>
<p className="mt-5 max-w-sm text-sm leading-relaxed text-muted">
{EMPRESA.promesa}. {EMPRESA.descripcion}
</p>
<dl className="mt-6 space-y-2.5">
<div className="flex gap-3">
<dt className="w-16 shrink-0 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
Tel
</dt>
<dd>
<a
href={`tel:${EMPRESA.telefonoTel}`}
className="font-mono text-sm text-bright transition-colors hover:text-signal-hi"
>
{EMPRESA.telefono}
</a>
</dd>
</div>
<div className="flex gap-3">
<dt className="w-16 shrink-0 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
Correo
</dt>
<dd>
<a
href={`mailto:${EMPRESA.correo}`}
className="font-mono text-sm text-bright transition-colors hover:text-signal-hi"
>
{EMPRESA.correo}
</a>
</dd>
</div>
<div className="flex gap-3">
<dt className="w-16 shrink-0 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-faint">
Sede
</dt>
<dd className="text-sm text-muted">
{EMPRESA.colonia}, {EMPRESA.ciudad}
</dd>
</div>
</dl>
<ul className="mt-6 flex items-center gap-2">
{[
{ url: EMPRESA.redes.facebook, label: "Facebook", sigla: "Fb" },
{ url: EMPRESA.redes.youtube, label: "YouTube", sigla: "Yt" },
{ url: EMPRESA.redes.linkedin, label: "LinkedIn", sigla: "In" },
].map((r) => (
<li key={r.label}>
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
aria-label={`${EMPRESA.nombreCorto} en ${r.label}`}
className="flex h-9 w-9 items-center justify-center rounded-[3px] border border-rule-2 font-mono text-[0.6875rem] text-muted transition-colors hover:border-signal hover:text-signal-hi"
>
{r.sigla}
</a>
</li>
))}
</ul>
</div>
{/* columnas de navegación */}
{COLUMNAS.map((col) => (
<nav key={col.titulo} aria-labelledby={`pie-${col.titulo}`}>
<h2
id={`pie-${col.titulo}`}
className="font-mono text-[0.625rem] font-medium uppercase tracking-[0.16em] text-faint"
>
{col.titulo}
</h2>
<ul className="mt-4 space-y-2.5">
{col.enlaces.map((e) => (
<li key={e.href}>
<Link
href={e.href}
className="text-sm text-muted transition-colors hover:text-bright"
>
{e.label}
</Link>
</li>
))}
</ul>
</nav>
))}
</div>
{/* nota de alcance: la norma no se sustituye con una página web */}
<div className="mt-12 border-l-2 border-ember/60 bg-ink-2 px-5 py-4">
<p className="font-mono text-[0.6875rem] leading-relaxed tracking-[0.02em] text-muted">
<span className="text-ember">Alcance.</span> La información normativa de este sitio es
de referencia. La NOM-002-STPS-2010 puede complementarse por estado, municipio y tipo de
instalación, y nada aquí sustituye un dictamen técnico o jurídico.
</p>
</div>
</div>
<div className="border-t border-rule">
<div className="shell flex flex-col gap-3 py-5 sm:flex-row sm:items-center sm:justify-between">
<p className="font-mono text-[0.625rem] tracking-[0.08em] text-faint">
© {anio} {EMPRESA.razonSocial}
</p>
<p className="font-mono text-[0.625rem] tracking-[0.08em] text-faint">{AVISO_DEMO}</p>
</div>
</div>
</footer>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
/**
* REVELADO AL HACER SCROLL — utilidad compartida del sitio.
*
* Envuelve a sus hijos y les aplica la utilidad `.reveal` de globals.css.
* Cuando el bloque entra en el viewport (umbral 0.15) marca `data-shown="true"`
* y deja de observar: el revelado ocurre una sola vez, nunca se revierte al
* volver a subir. Eso es deliberado — un elemento que aparece y desaparece
* delata animación automática.
*
* Comportamiento en los casos borde:
* - `prefers-reduced-motion: reduce` → el contenido se muestra de inmediato,
* sin observador y sin transición.
* - Navegador sin `IntersectionObserver` → se muestra de inmediato.
* - Sin JavaScript → el `<noscript>` fuerza `.reveal` a estado visible, para
* que la página siga siendo legible y rastreable.
*
* Uso:
* ```tsx
* <Revelar> …bloque… </Revelar>
* <Revelar as="li" delay={90}> …tarjeta… </Revelar>
* ```
*
* Nota: `as` acepta cualquier etiqueta que admita contenido de flujo — `div`,
* `section`, `article`, `li`. No la uses con `ul` ni `ol`, porque el respaldo
* `<noscript>` viaja como primer hijo y esos elementos solo admiten `li`.
* Para una lista, envuelve el `<ul>` dentro de un `<Revelar>`.
*/
type Props = {
children: ReactNode;
/** Etiqueta a renderizar. `div` por omisión. */
as?: ElementType;
/** Retardo en milisegundos, para escalonar hermanos. Úsalo con medida. */
delay?: number;
/** Fracción visible que dispara el revelado. 0.15 por omisión. */
umbral?: number;
className?: string;
};
export function Revelar({
children,
as: Etiqueta = "div",
delay = 0,
umbral = 0.15,
className = "",
}: Props) {
const nodo = useRef<HTMLElement | null>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (visible) return;
const sinMovimiento =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (sinMovimiento || typeof IntersectionObserver === "undefined") {
setVisible(true);
return;
}
const el = nodo.current;
if (!el) {
setVisible(true);
return;
}
const observador = new IntersectionObserver(
(entradas) => {
for (const entrada of entradas) {
if (entrada.isIntersecting) {
setVisible(true);
observador.disconnect();
return;
}
}
},
{ threshold: umbral, rootMargin: "0px 0px -8% 0px" },
);
observador.observe(el);
return () => observador.disconnect();
}, [umbral, visible]);
return (
<Etiqueta
ref={nodo}
data-shown={visible ? "true" : undefined}
className={`reveal ${className}`.trim()}
style={delay > 0 ? { transitionDelay: `${delay}ms` } : undefined}
>
{/* El respaldo sin JavaScript se declara una sola vez en src/app/layout.tsx.
Emitirlo por instancia significaba treinta y cuatro <style> repetidos
en el inicio, y además impedía usar `as="ul"`. */}
{children}
</Etiqueta>
);
}
@@ -0,0 +1,104 @@
/**
* El bloque más importante de una ficha de producto.
*
* El sitio anterior de FSPM presentaba "no caduca", "20 años de garantía" y
* "no requiere recarga" como características. El estudio de marca concluyó que
* no pueden sostenerse como universales. Aquí no se repiten: se convierten en
* compromisos de documentación por número de modelo.
*
* Cada punto se lee como "esto te lo entregamos por escrito", nunca como
* "esto hace el producto".
*/
type Props = {
claims: string[];
/** Sobre superficie clara. */
claro?: boolean;
titulo?: string;
/** El texto que explica por qué existe este bloque. */
intro?: string;
};
export function BloqueDocumentacion({
claims,
claro = false,
titulo = "Qué documentamos antes de cotizar",
intro = "La información pública de FSPM afirma varias de estas cosas. No las repetimos como características: las entregamos por escrito, por número de modelo, antes de que firmes. Si un punto no puede documentarse, te lo decimos en la propuesta.",
}: Props) {
if (claims.length === 0) return null;
return (
<section
aria-labelledby="documentacion-titulo"
className={[
"rounded-[6px] border border-l-2 border-l-ember p-6 sm:p-8",
claro ? "border-rule-lite bg-white" : "border-rule bg-ink-2",
].join(" ")}
>
<p
className={[
"font-mono text-[0.6875rem] font-medium uppercase tracking-[0.16em]",
// #F2B705 no alcanza contraste AA sobre papel: en claro va el token
// ember-dim, que es el mismo amarillo llevado a superficie clara.
claro ? "text-ember-dim" : "text-ember",
].join(" ")}
>
Compromiso de documentación
</p>
<h2
id="documentacion-titulo"
className={[
"mt-4 font-display text-xl font-extrabold sm:text-2xl",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
{titulo}
</h2>
<p
className={[
"mt-3 max-w-2xl text-sm leading-relaxed",
claro ? "text-deep-muted" : "text-muted",
].join(" ")}
>
{intro}
</p>
<ol
className={[
"mt-7 grid gap-px sm:grid-cols-2",
claro ? "bg-rule-lite" : "bg-rule",
].join(" ")}
>
{claims.map((claim, i) => (
<li
key={claim}
className={[
"flex gap-3.5 p-4 sm:p-5",
claro ? "bg-white" : "bg-ink-2",
].join(" ")}
>
<span
className={[
"mt-0.5 shrink-0 font-mono text-[0.625rem] font-semibold tracking-[0.08em]",
claro ? "text-ember-dim" : "text-ember",
].join(" ")}
aria-hidden="true"
>
{String(i + 1).padStart(2, "0")}
</span>
<p
className={[
"text-sm leading-relaxed",
claro ? "text-deep-muted" : "text-muted",
].join(" ")}
>
{claim}
</p>
</li>
))}
</ol>
</section>
);
}
@@ -0,0 +1,290 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { RielClases, RielClasesSelector } from "@/components/marca/RielClases";
import { Revelar } from "@/components/site/Revelar";
import { TarjetaProducto } from "@/components/site/catalogo/TarjetaProducto";
import { CATEGORIAS, PRODUCTOS, type Producto } from "@/data/catalogo";
import type { ClaseFuego } from "@/data/clases-fuego";
/**
* El catálogo como comparador, no como galería.
*
* Tres controles: clase de fuego (el riel, que es el vocabulario del oficio),
* categoría y aplicación. Filtran en el cliente, sin recarga. El resultado se
* ve dos veces a propósito: como tarjetas para reconocer el equipo y como hoja
* de comparación para descartarlo. Es la recomendación 10.2 del estudio.
*/
const CATEGORIA_NOMBRE = new Map(CATEGORIAS.map((c) => [c.slug, c.nombre]));
const APLICACIONES: string[] = Array.from(
new Set(PRODUCTOS.flatMap((p) => p.aplicaciones)),
).sort((a, b) => a.localeCompare(b, "es-MX"));
const COLUMNAS = [
"Producto",
"Categoría",
"Clases",
"Capacidad",
"Agente",
"Alcance declarado",
] as const;
function cubreClases(producto: Producto, clases: ClaseFuego[]): boolean {
return clases.every((c) => producto.clases.includes(c));
}
export function ExploradorCatalogo() {
const [clases, setClases] = useState<ClaseFuego[]>([]);
const [categoria, setCategoria] = useState("");
const [aplicacion, setAplicacion] = useState("");
const resultados = useMemo(
() =>
PRODUCTOS.filter(
(p) =>
cubreClases(p, clases) &&
(categoria === "" || p.categoria === categoria) &&
(aplicacion === "" || p.aplicaciones.includes(aplicacion)),
),
[clases, categoria, aplicacion],
);
const hayFiltro = clases.length > 0 || categoria !== "" || aplicacion !== "";
function limpiar() {
setClases([]);
setCategoria("");
setAplicacion("");
}
const conteo =
resultados.length === 1 ? "1 equipo coincide" : `${resultados.length} equipos coinciden`;
return (
<>
{/* ---------- filtros y resultado: el mundo oscuro, la operación ---------- */}
<section
className="grid-bg border-t border-rule bg-ink py-14 sm:py-20"
aria-labelledby="filtros-titulo"
>
<div className="shell">
<Revelar>
<p className="eyebrow mb-4">Selector técnico</p>
<h2 id="filtros-titulo" className="font-display text-2xl font-extrabold sm:text-3xl">
Filtra por lo que tienes, no por el nombre del equipo
</h2>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted sm:text-base">
Enciende las clases de fuego presentes en tu operación. El filtro deja únicamente los
equipos que cubren todas las que marcaste, según lo declarado en la información pública
de FSPM.
</p>
</Revelar>
<div className="panel mt-8 p-5 sm:p-7">
<div className="grid gap-6 lg:grid-cols-[auto_1fr_1fr] lg:items-end">
<div>
<span className="field-label">Clases de fuego</span>
<div className="rounded-[3px] border border-rule-2 bg-ink/60 px-3 py-2.5">
<RielClasesSelector valor={clases} onChange={setClases} tamano="md" />
</div>
</div>
<div>
<label htmlFor="filtro-categoria" className="field-label">
Categoría
</label>
<select
id="filtro-categoria"
value={categoria}
onChange={(e) => setCategoria(e.target.value)}
className="field appearance-none [-webkit-appearance:none]"
>
<option value="">Todas las categorías</option>
{CATEGORIAS.map((c) => (
<option key={c.slug} value={c.slug}>
{c.nombre}
</option>
))}
</select>
</div>
<div>
<label htmlFor="filtro-aplicacion" className="field-label">
Aplicación
</label>
<select
id="filtro-aplicacion"
value={aplicacion}
onChange={(e) => setAplicacion(e.target.value)}
className="field appearance-none [-webkit-appearance:none]"
>
<option value="">Cualquier aplicación</option>
{APLICACIONES.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 border-t border-rule pt-5">
<p
aria-live="polite"
className="font-mono text-xs uppercase tracking-[0.12em] text-bright"
>
{conteo}
<span className="text-faint"> · de {PRODUCTOS.length} en el catálogo</span>
</p>
{hayFiltro ? (
<button type="button" onClick={limpiar} className="btn btn-ghost btn-sm">
Limpiar filtros
</button>
) : null}
</div>
</div>
{/* ---------- resultados en tarjetas ---------- */}
{resultados.length > 0 ? (
<div className="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{resultados.map((producto) => (
<TarjetaProducto key={producto.slug} producto={producto} />
))}
</div>
) : (
<div className="panel mt-10 border-l-2 border-l-ember p-6 sm:p-8">
<p className="font-mono text-[0.6875rem] uppercase tracking-[0.16em] text-ember">
Sin coincidencias
</p>
<h3 className="mt-4 font-display text-xl font-extrabold sm:text-2xl">
Ningún equipo del catálogo cubre esa combinación
</h3>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-muted">
Eso no significa que tu escenario no tenga solución: significa que probablemente
necesitas más de un equipo, o un sistema diseñado para la instalación. Pide un
diagnóstico y lo resolvemos con el escenario a la vista.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link href="#cotizar" className="btn btn-signal btn-sm">
Pedir un diagnóstico
</Link>
<button type="button" onClick={limpiar} className="btn btn-ghost btn-sm">
Quitar los filtros
</button>
</div>
</div>
)}
</div>
</section>
{/* ---------- hoja de comparación: el mundo claro, el documento ---------- */}
<section
className="grid-bg-lite bg-paper py-14 text-deep sm:py-20"
aria-labelledby="tabla-titulo"
>
<div className="shell-wide">
<Revelar>
<p className="eyebrow mb-4">Hoja de comparación</p>
<h2
id="tabla-titulo"
className="font-display text-2xl font-extrabold text-deep sm:text-3xl"
>
Los {PRODUCTOS.length} equipos, lado a lado
</h2>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-deep-muted sm:text-base">
La misma selección de arriba, en formato de tabla. Donde la fuente no declara un dato,
la celda lo dice: no lo rellenamos con una estimación.
</p>
</Revelar>
<p className="mt-6 font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-deep-muted sm:hidden">
Desliza la tabla para ver todas las columnas
</p>
<div
className="mt-4 overflow-x-auto rounded-[6px] border border-rule-lite bg-white [-webkit-overflow-scrolling:touch]"
tabIndex={0}
role="region"
aria-label="Tabla comparativa de productos, desplazable en horizontal"
>
<table className="w-full min-w-[54rem] border-collapse text-left">
<caption className="sr-only">
Comparación de productos por categoría, clases de fuego, capacidad, agente y alcance
declarado.
</caption>
<thead>
<tr className="border-b border-rule-lite bg-paper-2">
{COLUMNAS.map((encabezado) => (
<th
key={encabezado}
scope="col"
className="whitespace-nowrap px-4 py-3 font-mono text-[0.625rem] font-medium uppercase tracking-[0.14em] text-deep-muted"
>
{encabezado}
</th>
))}
</tr>
</thead>
<tbody>
{resultados.map((p) => (
<tr key={p.slug} className="border-b border-rule-lite last:border-b-0">
<th scope="row" className="px-4 py-4 text-left align-top">
<Link
href={`/producto/${p.slug}`}
className="link font-sans text-sm font-semibold text-deep [-webkit-tap-highlight-color:transparent]"
>
{p.nombre}
</Link>
{p.modelo ? (
<span className="mt-1 block font-mono text-[0.625rem] uppercase tracking-[0.1em] text-deep-muted">
{p.modelo}
</span>
) : null}
</th>
<td className="px-4 py-4 align-top font-mono text-xs text-deep-muted">
{CATEGORIA_NOMBRE.get(p.categoria) ?? p.categoria}
</td>
<td className="px-4 py-4 align-top">
{p.clases.length > 0 ? (
<RielClases activas={p.clases} tamano="sm" claro />
) : (
<span className="font-mono text-[0.625rem] uppercase tracking-[0.1em] text-deep-muted">
No declarada
</span>
)}
</td>
<td className="px-4 py-4 align-top font-mono text-xs text-deep">
{p.capacidad ?? <span className="text-deep-muted">Sin dato en la fuente</span>}
</td>
<td className="px-4 py-4 align-top font-mono text-xs text-deep">
{p.agente ?? <span className="text-deep-muted">No aplica</span>}
</td>
<td className="max-w-[18rem] px-4 py-4 align-top font-mono text-xs leading-relaxed text-deep">
{p.alcance ?? <span className="text-deep-muted">Sin dato en la fuente</span>}
</td>
</tr>
))}
</tbody>
</table>
{resultados.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-deep-muted">
Ningún equipo coincide con el filtro actual.
</p>
) : null}
</div>
<p className="mt-5 max-w-3xl text-xs leading-relaxed text-deep-muted">
Alcance, capacidad y clases provienen de la información pública de FSPM. Antes de
cotizar entregamos la ficha técnica y el certificado por número de modelo, que es lo que
realmente sostiene cada celda de esta tabla.
</p>
</div>
</section>
</>
);
}
@@ -0,0 +1,130 @@
import Image from "next/image";
import Link from "next/link";
import { RielClases } from "@/components/marca/RielClases";
import { getCategoria, type Producto } from "@/data/catalogo";
/**
* Tarjeta de producto. Se usa en el catálogo, en la página de categoría y en
* los productos relacionados de una ficha.
*
* La tarjeta no vende: identifica. Nombre, riel de clases y los dos datos que
* permiten descartar un equipo de un vistazo. Los campos nulos no se pintan.
*/
type Props = {
producto: Producto;
/** Sobre superficie clara. */
claro?: boolean;
/** Oculta el nombre de la categoría, cuando ya se está dentro de ella. */
sinCategoria?: boolean;
};
export function TarjetaProducto({ producto, claro = false, sinCategoria = false }: Props) {
const categoria = getCategoria(producto.categoria);
return (
<article
className={[
"group relative flex h-full flex-col overflow-hidden rounded-[6px] border transition-colors duration-200",
claro
? "border-rule-lite bg-white hover:border-signal/60"
: "border-rule bg-ink-2 hover:border-rule-2",
"[-webkit-tap-highlight-color:transparent]",
].join(" ")}
>
<div
className={[
"relative aspect-[4/3] w-full overflow-hidden border-b",
claro ? "border-rule-lite bg-paper-2" : "border-rule bg-ink-3",
].join(" ")}
>
<Image
src={producto.imagen}
alt={producto.nombre}
fill
sizes="(max-width: 640px) 92vw, (max-width: 1024px) 44vw, 22rem"
className="object-contain p-5 transition-transform duration-300 group-hover:scale-[1.03]"
/>
</div>
<div className="flex flex-1 flex-col p-5">
{!sinCategoria && categoria ? (
<p
className={[
"font-mono text-[0.625rem] uppercase tracking-[0.14em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
{categoria.nombre}
</p>
) : null}
<h3
className={[
"mt-1.5 font-display text-lg font-extrabold leading-tight",
claro ? "text-deep" : "text-bright",
].join(" ")}
>
<Link href={`/producto/${producto.slug}`} className="[-webkit-tap-highlight-color:transparent]">
<span className="absolute inset-0" aria-hidden="true" />
{producto.nombre}
</Link>
</h3>
<p
className={[
"mt-2.5 text-sm leading-relaxed",
claro ? "text-deep-muted" : "text-muted",
].join(" ")}
>
{producto.resumen}
</p>
<div className="mt-auto pt-5">
{producto.clases.length > 0 ? (
<RielClases activas={producto.clases} tamano="sm" claro={claro} etiqueta="Clases" />
) : (
<p
className={[
"font-mono text-[0.625rem] uppercase tracking-[0.12em]",
claro ? "text-deep-muted" : "text-faint",
].join(" ")}
>
Sin clase de fuego declarada
</p>
)}
{producto.capacidad || producto.alcance ? (
<dl
className={[
"mt-4 flex flex-wrap gap-x-6 gap-y-2 border-t pt-4",
claro ? "border-rule-lite" : "border-rule",
].join(" ")}
>
{producto.capacidad ? (
<div>
<dt className={["spec-key", claro ? "!text-deep-muted" : ""].join(" ")}>
Capacidad
</dt>
<dd className={["spec-val", claro ? "!text-deep" : ""].join(" ")}>
{producto.capacidad}
</dd>
</div>
) : null}
{producto.alcance ? (
<div>
<dt className={["spec-key", claro ? "!text-deep-muted" : ""].join(" ")}>
Alcance declarado
</dt>
<dd className={["spec-val", claro ? "!text-deep" : ""].join(" ")}>
{producto.alcance}
</dd>
</div>
) : null}
</dl>
) : null}
</div>
</div>
</article>
);
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Correspondencia entre una aplicación declarada en el catálogo y el icono que
* existe realmente en /public/media/iconos.
*
* Solo se mapea lo que tiene una coincidencia razonable. Si una aplicación no
* está aquí, se rotula con texto: es mejor no pintar icono que pintar el
* equivocado.
*/
const ICONOS: Record<string, string> = {
Industria: "/media/iconos/icono-industria.png",
Oficina: "/media/iconos/icono-oficina.png",
Hogar: "/media/iconos/icono-hogar.png",
Viviendas: "/media/iconos/icono-hogar.png",
Exterior: "/media/iconos/icono-exterior.png",
Gasolineras: "/media/iconos/icono-gasolineras.png",
Museos: "/media/iconos/icono-museos.png",
Auditorios: "/media/iconos/icono-auditorios.png",
Bosques: "/media/iconos/icono-bosques.png",
Minas: "/media/iconos/iconos-minas.png",
Aeropuertos: "/media/iconos/iconos-aeropuertos.png",
"Central de bomberos": "/media/iconos/icono-central-bomberos.png",
"Brigadas contraincendio": "/media/iconos/icono-central-bomberos.png",
Agricultura: "/media/iconos/icono-agricultura.png",
};
export function iconoDeAplicacion(aplicacion: string): string | null {
return ICONOS[aplicacion] ?? null;
}
@@ -0,0 +1,93 @@
import { FormularioContacto } from "@/components/formulario/FormularioContacto";
import { Revelar } from "@/components/site/Revelar";
import { EMPRESA } from "@/data/empresa";
/**
* DIAGNÓSTICO — el formulario embebido en el inicio, requisito del cliente.
*
* Dos columnas: a la izquierda el argumento de por qué conviene pedirlo, a la
* derecha la captura. El estudio señaló que el formulario original pedía datos
* sin ofrecer nada a cambio; aquí lo que se ofrece está escrito.
*/
const QUE_RECIBES = [
{
paso: "01",
titulo: "Clasificación del escenario",
texto:
"Qué clase de fuego tienes, dónde está el punto de origen probable y qué tan rápido puede crecer.",
},
{
paso: "02",
titulo: "Agente y equipo que corresponden",
texto:
"Qué agente aplica a ese combustible, en qué capacidad y en qué formato: portátil, móvil, fijo o automático.",
},
{
paso: "03",
titulo: "Qué te toca documentar",
texto:
"La lista de lo que debe existir en tu expediente para operación, verificación y renovación de póliza.",
},
];
export function BloqueDiagnostico() {
return (
<section className="bg-ink" aria-labelledby="diagnostico-titulo">
<div className="shell py-16 sm:py-20">
<div className="grid gap-10 lg:grid-cols-12 lg:gap-14">
<Revelar className="lg:col-span-5">
<p className="eyebrow">Siguiente paso</p>
<h2
id="diagnostico-titulo"
className="mt-4 font-display uppercase tracking-[-0.02em] text-bright"
style={{ fontSize: "clamp(1.6rem, 3.6vw, 2.5rem)" }}
>
Pide el diagnóstico, no la lista de precios
</h2>
<p className="mt-4 text-muted">
Una cotización sin escenario es una adivinanza cara de corregir. Cuéntanos qué opera
tu instalación y qué tienes montado hoy; con eso podemos responder algo útil desde el
primer correo.
</p>
<ol className="mt-8 space-y-5">
{QUE_RECIBES.map((item) => (
<li key={item.paso} className="flex gap-4">
<span className="mt-0.5 font-mono text-[0.6875rem] tracking-[0.14em] text-signal">
{item.paso}
</span>
<span className="min-w-0">
<span className="block font-display text-sm uppercase tracking-[0.01em] text-bright">
{item.titulo}
</span>
<span className="mt-1 block text-sm leading-relaxed text-muted">
{item.texto}
</span>
</span>
</li>
))}
</ol>
<hr className="hair my-8" />
<p className="text-sm text-muted">
Si prefieres hablar antes de escribir:{" "}
<a href={`tel:${EMPRESA.telefonoTel}`} className="link font-mono text-sm text-bright">
{EMPRESA.telefono}
</a>
</p>
</Revelar>
<Revelar className="lg:col-span-7" delay={90}>
<FormularioContacto
canal="home"
titulo="Solicitar diagnóstico"
descripcion="Entre más preciso el escenario, más útil la primera respuesta. Los campos de riesgo son opcionales, pero cambian por completo lo que podemos decirte."
/>
</Revelar>
</div>
</div>
</section>
);
}
+114
View File
@@ -0,0 +1,114 @@
import Link from "next/link";
import { Revelar } from "@/components/site/Revelar";
import { NORMAS } from "@/data/empresa";
/**
* CUMPLIMIENTO — ruta C del inicio.
*
* Aquí sí caben `text-ember` y `.hazard`, con medida: la NOM-002 aplica a todo
* centro de trabajo en México y el incumplimiento es riesgo real, no un recurso
* gráfico. El texto viene de `NORMAS`; no se afirma nada que la norma no diga.
*/
const LO_QUE_EXIGE = [
"Clasificar el riesgo de incendio del centro de trabajo",
"Contar con el equipo y los sistemas que correspondan a ese nivel de riesgo",
"Tener un plan de atención a emergencias por escrito",
"Integrar brigadas y capacitarlas",
"Ejecutar simulacros y dejar registro",
"Revisar los extintores cada mes y documentarlo",
];
export function BloqueNorma() {
return (
<section
id="cumplimiento"
className="scroll-mt-24 border-y border-rule bg-ink-2"
aria-labelledby="norma-titulo"
>
<div className="shell py-16 sm:py-20">
<div className="grid gap-10 lg:grid-cols-12 lg:gap-12">
<Revelar className="lg:col-span-5">
<p className="eyebrow">Ruta C · Por cumplimiento</p>
<h2
id="norma-titulo"
className="mt-4 font-display uppercase tracking-[-0.02em] text-bright"
style={{ fontSize: "clamp(1.6rem, 3.6vw, 2.5rem)" }}
>
Comprar extintores no es cumplir
</h2>
<div className="hazard mt-6 h-1.5 w-24 rounded-[1px]" aria-hidden="true" />
<p className="mt-6 text-muted">
La NOM-002-STPS-2010 aplica a{" "}
<strong className="font-semibold text-ember">todo centro de trabajo en México</strong>
, sin importar el giro ni el tamaño. Y no se satisface con una compra: exige
clasificar el riesgo, sostener un plan, capacitar personal y conservar el registro de
todo eso.
</p>
<p className="mt-4 text-sm leading-relaxed text-faint">
La mayoría de los hallazgos que vemos no son por falta de equipo, sino por falta de
expediente. Es la parte que un proveedor de catálogo no te entrega.
</p>
<div className="mt-7 flex flex-wrap gap-3">
<Link href="/cumplimiento-nom-002" className="btn btn-signal">
Validar cumplimiento NOM-002
</Link>
<Link href="/evidencia" className="btn btn-ghost">
Centro de evidencia
</Link>
</div>
</Revelar>
<div className="lg:col-span-7">
<Revelar>
<ul className="panel divide-y divide-rule">
{LO_QUE_EXIGE.map((item) => (
<li key={item} className="flex items-start gap-3 px-5 py-3.5">
<span
aria-hidden="true"
className="mt-1.5 block h-1.5 w-1.5 shrink-0 rounded-[1px] bg-ember"
/>
<span className="text-sm leading-relaxed text-bright">{item}</span>
</li>
))}
</ul>
</Revelar>
<ul className="mt-4 grid gap-4 sm:grid-cols-2">
{NORMAS.map((norma, i) => (
<Revelar as="li" key={norma.clave} delay={i * 80}>
<article className="panel h-full p-5">
<h3 className="font-mono text-xs uppercase tracking-[0.1em] text-signal">
{norma.clave}
</h3>
<p className="mt-2.5 text-sm font-semibold leading-snug text-bright">
{norma.titulo}
</p>
<p className="mt-2.5 text-xs leading-relaxed text-muted">{norma.aplica}</p>
<a
href={norma.fuente}
target="_blank"
rel="noopener noreferrer"
className="link mt-3 inline-block font-mono text-[0.6875rem] uppercase tracking-[0.1em] text-muted"
>
Texto oficial
</a>
</article>
</Revelar>
))}
</ul>
<p className="mt-4 font-mono text-[0.6875rem] leading-relaxed text-faint">
Referencia normativa, no dictamen. La autoridad estatal o municipal puede pedir
requisitos adicionales según el tipo de instalación.
</p>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,141 @@
import Image from "next/image";
import Link from "next/link";
import { RielClases } from "@/components/marca/RielClases";
import { Revelar } from "@/components/site/Revelar";
import { getProducto, type Producto } from "@/data/catalogo";
/**
* CATÁLOGO DESTACADO — seis equipos que cubren el ancho real de la oferta:
* portátil, móvil, fijo, automático, agente y robótico. No es un escaparate de
* los más vendidos (no tenemos ese dato); es una muestra de amplitud.
*
* La tira `.spec` solo imprime los campos que existen: los `null` del catálogo
* se omiten en lugar de rellenarse con guiones o con datos inventados.
*/
const DESTACADOS = [
"enforcer-9-litros",
"carretilla-70-litros",
"enforcer-10",
"esfera-rociadora-automatica",
"fireade-2000",
"enforcer-robot",
];
const PRODUCTOS_DESTACADOS: Producto[] = DESTACADOS.map(getProducto).filter(
(p): p is Producto => Boolean(p),
);
function especificaciones(p: Producto): { clave: string; valor: string }[] {
return [
{ clave: "Capacidad", valor: p.capacidad },
{ clave: "Agente", valor: p.agente },
{ clave: "Alcance", valor: p.alcance },
].filter((c): c is { clave: string; valor: string } => Boolean(c.valor));
}
export function CatalogoDestacado() {
return (
<section className="bg-ink" aria-labelledby="catalogo-titulo">
<div className="shell py-16 sm:py-20">
<Revelar className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<div className="max-w-[34rem]">
<p className="eyebrow">Equipo</p>
<h2
id="catalogo-titulo"
className="mt-4 font-display uppercase tracking-[-0.02em] text-bright"
style={{ fontSize: "clamp(1.6rem, 3.6vw, 2.5rem)" }}
>
Del extintor de un litro al cañón automático
</h2>
</div>
<Link href="/catalogo" className="btn btn-ghost btn-sm shrink-0">
Catálogo completo
</Link>
</Revelar>
{/* En móvil es un riel con arrastre; desde md se acomoda en retícula. */}
<ul
className="mt-10 flex snap-x snap-mandatory gap-4 overflow-x-auto overscroll-x-contain pb-3 md:grid md:grid-cols-2 md:overflow-visible md:pb-0 lg:grid-cols-3"
style={{ WebkitOverflowScrolling: "touch" }}
>
{PRODUCTOS_DESTACADOS.map((p, i) => {
const specs = especificaciones(p);
return (
<Revelar
as="li"
key={p.slug}
delay={(i % 3) * 80}
className="w-[78%] shrink-0 snap-start sm:w-[52%] md:w-auto"
>
<Link
href={`/producto/${p.slug}`}
className="panel group flex h-full flex-col overflow-hidden transition-colors duration-200 hover:border-rule-2"
style={{ WebkitTapHighlightColor: "transparent" }}
>
<div className="relative aspect-[4/3] w-full bg-ink-3">
<Image
src={p.imagen}
alt={p.nombre}
fill
sizes="(min-width: 1024px) 21rem, (min-width: 768px) 45vw, 78vw"
className="object-contain p-6 transition-transform duration-500 group-hover:scale-[1.04]"
/>
</div>
<div className="flex flex-1 flex-col p-5">
{p.modelo ? (
<p className="font-mono text-[0.625rem] uppercase tracking-[0.16em] text-faint">
{p.modelo}
</p>
) : null}
<h3 className="mt-2 font-display text-base uppercase tracking-[-0.01em] text-bright">
{p.nombre}
</h3>
<RielClases activas={p.clases} tamano="sm" className="mt-4" />
{/* auto-fit: una columna en tarjeta angosta, hasta tres cuando
hay ancho. Sin media queries y sin desbordes. */}
{specs.length > 0 ? (
<dl
className="spec mt-4"
style={{ gridTemplateColumns: "repeat(auto-fit, minmax(6.5rem, 1fr))" }}
>
{specs.map((s) => (
<div key={s.clave} className="spec-cell">
<dt className="spec-key">{s.clave}</dt>
<dd className="spec-val break-words">{s.valor}</dd>
</div>
))}
</dl>
) : null}
<span className="mt-5 flex items-center gap-2 font-mono text-[0.6875rem] uppercase tracking-[0.1em] text-muted transition-colors group-hover:text-bright">
Ver ficha
<svg
width="13"
height="9"
viewBox="0 0 13 9"
aria-hidden="true"
className="transition-transform duration-200 group-hover:translate-x-1"
>
<path
d="M0 4.5h10.5M7.5 1.5l3 3-3 3"
stroke="currentColor"
strokeWidth="1.4"
fill="none"
/>
</svg>
</span>
</div>
</Link>
</Revelar>
);
})}
</ul>
</div>
</section>
);
}
+119
View File
@@ -0,0 +1,119 @@
import Link from "next/link";
import { RielClases } from "@/components/marca/RielClases";
import { Revelar } from "@/components/site/Revelar";
import { CLASES_ORDENADAS } from "@/data/clases-fuego";
/**
* CLASIFICACIÓN POR CLASE DE FUEGO — ruta B del inicio.
*
* Va sobre `bg-paper`: es evidencia técnica, pertenece al mundo del documento.
* El campo que carga el argumento es `riesgoAgenteIncorrecto`: decir qué pasa
* cuando el agente está mal elegido es exactamente lo que ningún competidor
* del mapa está diciendo, y es lo que convierte el catálogo en criterio.
*/
export function ClasesFuego() {
return (
<section
id="clases-de-fuego"
className="scroll-mt-24 border-y border-rule-lite bg-paper text-deep"
aria-labelledby="clases-titulo"
>
<div className="grid-bg-lite">
<div className="shell py-16 sm:py-20">
<Revelar className="max-w-[44rem]">
<p className="eyebrow">Ruta B · Por tipo de riesgo</p>
<h2
id="clases-titulo"
className="mt-4 font-display uppercase tracking-[-0.02em] text-deep"
style={{ fontSize: "clamp(1.6rem, 3.6vw, 2.5rem)" }}
>
El agente equivocado empeora el incendio
</h2>
<p className="mt-4 text-deep-muted">
La clase de fuego no es una etiqueta administrativa: define qué agente puede tocar ese
combustible. Esto es lo que cambia según lo que se esté quemando en tu operación.
</p>
</Revelar>
<ul className="mt-10 divide-y divide-rule-lite border-y border-rule-lite">
{CLASES_ORDENADAS.map((c, i) => (
<Revelar
as="li"
key={c.clase}
delay={Math.min(i, 3) * 70}
className="grid gap-5 py-7 lg:grid-cols-12 lg:gap-6"
>
<div className="lg:col-span-3">
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[3px] bg-signal font-mono text-xl font-semibold leading-none text-white"
>
{c.clase}
</span>
<div className="min-w-0">
<p className="font-mono text-[0.625rem] uppercase tracking-[0.16em] text-deep-muted">
Clase {c.clase}
</p>
<h3 className="font-display text-base uppercase tracking-[-0.01em] text-deep">
{c.nombre}
</h3>
</div>
</div>
<RielClases activas={[c.clase]} tamano="sm" claro className="mt-4" />
</div>
<div className="lg:col-span-4">
<p className="font-mono text-[0.625rem] uppercase tracking-[0.14em] text-deep-muted">
Combustible
</p>
<p className="mt-1.5 text-sm leading-relaxed text-deep">{c.combustible}</p>
<p className="mt-4 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-deep-muted">
Dónde aparece
</p>
<ul className="mt-2 flex flex-wrap gap-1.5">
{c.dondeAparece.map((d) => (
<li
key={d}
className="rounded-[2px] border border-rule-lite bg-paper-2 px-2 py-1 text-[0.6875rem] leading-tight text-deep-muted"
>
{d}
</li>
))}
</ul>
</div>
<div className="lg:col-span-5">
<div className="panel-lite h-full p-4">
<p className="flex items-center gap-2 font-mono text-[0.625rem] uppercase tracking-[0.14em] text-deep-muted">
<span
aria-hidden="true"
className="hazard block h-2.5 w-6 rounded-[1px]"
/>
Si se usa el agente incorrecto
</p>
<p className="mt-2.5 text-sm leading-relaxed text-deep">
{c.riesgoAgenteIncorrecto}
</p>
</div>
</div>
</Revelar>
))}
</ul>
<Revelar className="mt-9 flex flex-wrap items-center gap-x-6 gap-y-3">
<Link href="/catalogo" className="btn btn-deep">
Ver equipo por clase de fuego
</Link>
<p className="max-w-[30rem] text-xs leading-relaxed text-deep-muted">
Referencia técnica general. La selección definitiva del agente se hace sobre el
escenario real, con ficha técnica y hoja de seguridad del producto.
</p>
</Revelar>
</div>
</div>
</section>
);
}
+144
View File
@@ -0,0 +1,144 @@
"use client";
import Link from "next/link";
import { useId, useMemo, useState } from "react";
import { RielClasesSelector } from "@/components/marca/RielClases";
import { serializeClases, type ClaseFuego } from "@/data/clases-fuego";
import { INDUSTRIAS } from "@/data/industrias";
/**
* EL CLASIFICADOR DE RIESGO — el elemento firma en acción.
*
* El hallazgo central del estudio de marca es que el visitante tenía que
* adivinar qué solución le correspondía. Esto lo resuelve en dos pasos:
* elige su tipo de operación, enciende las clases de fuego que tiene, y el
* botón lo deja en la ruta correcta con su riesgo ya declarado
* (`/soluciones/<slug>?clases=B,C`), que la página de destino y el formulario
* leen como preselección.
*
* Degradación sin JavaScript: el `<noscript>` oculta la interfaz interactiva y
* publica las cinco rutas como enlaces directos, cada una con las clases
* dominantes de su segmento en la query string.
*/
export function Clasificador() {
const [slug, setSlug] = useState<string>(INDUSTRIAS[0].slug);
const [clases, setClases] = useState<ClaseFuego[]>([]);
const idPaso1 = useId();
const idPaso2 = useId();
const industria = useMemo(
() => INDUSTRIAS.find((i) => i.slug === slug) ?? INDUSTRIAS[0],
[slug],
);
// Sin clases encendidas viajan las dominantes del segmento: el destino
// nunca queda sin contexto de riesgo.
const clasesEfectivas = clases.length > 0 ? clases : industria.clases;
const destino = `/soluciones/${industria.slug}?clases=${serializeClases(clasesEfectivas)}`;
return (
<div className="panel bg-ink-2/85 p-4 sm:p-5" style={{ WebkitBackdropFilter: "blur(6px)", backdropFilter: "blur(6px)" }}>
<noscript>
<style>{".clasif-js{display:none!important}.clasif-nojs{display:block!important}"}</style>
</noscript>
<div className="clasif-js">
{/* Paso 01 — tipo de operación */}
<p
id={idPaso1}
className="font-mono text-[0.625rem] uppercase tracking-[0.16em] text-faint"
>
<span className="text-signal">01</span> Tu tipo de operación
</p>
<div
role="radiogroup"
aria-labelledby={idPaso1}
className="mt-2.5 flex flex-wrap gap-1.5"
>
{INDUSTRIAS.map((ind) => {
const activa = ind.slug === industria.slug;
return (
<button
key={ind.slug}
type="button"
role="radio"
aria-checked={activa}
onClick={() => setSlug(ind.slug)}
className={[
"rounded-[3px] border px-2.5 py-2 text-left font-mono text-[0.6875rem] uppercase tracking-[0.06em] transition-colors duration-150",
activa
? "border-signal bg-signal/12 text-bright"
: "border-rule-2 text-muted hover:border-muted hover:text-bright",
].join(" ")}
style={{
WebkitAppearance: "none",
appearance: "none",
WebkitTapHighlightColor: "transparent",
}}
>
{ind.nombre}
</button>
);
})}
</div>
<hr className="hair my-4" />
{/* Paso 02 — clases de fuego presentes */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p
id={idPaso2}
className="font-mono text-[0.625rem] uppercase tracking-[0.16em] text-faint"
>
<span className="text-signal">02</span> Clases de fuego que tienes
</p>
{/* El riel se enciende una sola vez al montar: es el elemento firma
presentándose, no un efecto que se repite. */}
<div className="anim-ignite mt-2.5" aria-describedby={idPaso2}>
<RielClasesSelector valor={clases} onChange={setClases} tamano="lg" />
</div>
</div>
<p className="max-w-[16rem] text-xs leading-relaxed text-faint">
{clases.length === 0
? `Si no lo sabes, seguimos con las clases habituales del segmento: ${industria.clases.join(", ")}.`
: `Riesgo declarado: ${clases.join(", ")}. Viaja contigo a la siguiente pantalla.`}
</p>
</div>
<Link href={destino} className="btn btn-signal mt-4 w-full sm:w-auto">
Ver mi ruta
<svg width="14" height="10" viewBox="0 0 14 10" aria-hidden="true">
<path d="M1 5h11M8.5 1.5L12 5l-3.5 3.5" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
</Link>
</div>
{/* Respaldo sin JavaScript: las cinco rutas como enlaces. */}
<div className="clasif-nojs hidden">
<p className="font-mono text-[0.625rem] uppercase tracking-[0.16em] text-faint">
Elige tu tipo de operación
</p>
<ul className="mt-2.5 divide-y divide-rule">
{INDUSTRIAS.map((ind) => (
<li key={ind.slug}>
<Link
href={`/soluciones/${ind.slug}?clases=${serializeClases(ind.clases)}`}
className="flex items-baseline justify-between gap-3 py-2.5"
>
<span className="text-sm font-semibold text-bright">{ind.nombre}</span>
<span className="font-mono text-[0.6875rem] text-signal">
{ind.clases.join("·")}
</span>
</Link>
</li>
))}
</ul>
</div>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import Image from "next/image";
import { Revelar } from "@/components/site/Revelar";
import { CLIENTES } from "@/data/empresa";
/**
* MARCAS EN CARTERA — solo logotipos.
*
* El estudio de marca pide confirmar con la empresa qué relación existió con
* cada marca y en qué periodo antes de usarlas como caso comercial. Por eso
* aquí no hay testimonios, ni cifras atribuidas, ni la expresión "casos de
* éxito": el encabezado dice exactamente lo que se puede sostener hoy.
*/
export function Clientes() {
return (
<section
className="border-t border-rule-lite bg-paper text-deep"
aria-labelledby="clientes-titulo"
>
<div className="shell py-14 sm:py-16">
<Revelar>
<p className="eyebrow eyebrow-muted">Cartera</p>
<h2
id="clientes-titulo"
className="mt-4 font-display text-xl uppercase tracking-[-0.01em] text-deep sm:text-2xl"
>
Marcas que aparecen en nuestra cartera
</h2>
<p className="mt-3 max-w-[46rem] text-sm leading-relaxed text-deep-muted">
Se muestran como referencia del sector atendido. El alcance y el periodo de cada
relación se confirman caso por caso antes de presentarlos como proyecto.
</p>
</Revelar>
<Revelar className="mt-9">
<ul className="grid grid-cols-2 gap-px border border-rule-lite bg-rule-lite sm:grid-cols-3 lg:grid-cols-6">
{CLIENTES.map((cliente) => (
<li
key={cliente.nombre}
className="flex items-center justify-center bg-paper px-4 py-6"
>
<Image
src={cliente.logo}
alt={cliente.nombre}
width={140}
height={48}
sizes="(min-width: 1024px) 10rem, (min-width: 640px) 22vw, 40vw"
className="h-9 w-auto max-w-full object-contain opacity-70 grayscale transition duration-300 hover:opacity-100 hover:grayscale-0"
/>
</li>
))}
</ul>
</Revelar>
</div>
</section>
);
}
+133
View File
@@ -0,0 +1,133 @@
import Image from "next/image";
import Link from "next/link";
import { Clasificador } from "@/components/site/home/Clasificador";
import { CATEGORIAS, PRODUCTOS } from "@/data/catalogo";
import { CLASES } from "@/data/clases-fuego";
import { EMPRESA } from "@/data/empresa";
import { INDUSTRIAS } from "@/data/industrias";
/**
* HÉROE — no es un banner con una cifra grande.
*
* Es el hallazgo del estudio convertido en interfaz: en lugar de anunciar,
* clasifica. La fotografía va tratada con un degradado direccional desde
* `--color-ink` para que el texto tenga contraste real, no un lavado gris.
*
* La tira de datos de abajo se deriva de `src/data/`: número de categorías,
* equipos en catálogo, clases cubiertas por la taxonomía y año de origen de la
* tecnología. Ninguna cifra está escrita a mano.
*/
const DATOS = [
{ clave: "Categorías de solución", valor: String(CATEGORIAS.length).padStart(2, "0") },
{ clave: "Equipos en catálogo", valor: String(PRODUCTOS.length) },
{ clave: "Clases de fuego", valor: CLASES.join("·") },
{ clave: "Tecnología de origen", valor: String(EMPRESA.fundacionOrigen) },
];
const HEROE_ALTO = `
@media (min-width: 1024px) {
.heroe-alto {
min-height: calc(100vh - 7.5rem);
min-height: calc(100dvh - 7.5rem);
}
}`;
export function Heroe() {
return (
<section
className="relative isolate overflow-hidden bg-ink"
aria-labelledby="heroe-titulo"
>
{/* Fotografía propia de la empresa, al fondo. */}
<div className="absolute inset-0 -z-20">
<Image
src="/media/foto/bombero-plataforma.jpg"
alt=""
aria-hidden="true"
fill
priority
sizes="100vw"
className="object-cover object-[68%_center]"
/>
</div>
{/* Oscurecimiento direccional: opaco donde vive el texto, translúcido
sobre la figura. No es un velo plano. */}
<div
className="absolute inset-0 -z-10"
aria-hidden="true"
style={{
background:
"linear-gradient(97deg, var(--color-ink) 0%, rgba(12,17,22,0.985) 34%, rgba(12,17,22,0.88) 56%, rgba(12,17,22,0.55) 78%, rgba(12,17,22,0.42) 100%)",
}}
/>
<div
className="absolute inset-0 -z-10"
aria-hidden="true"
style={{
background:
"linear-gradient(to top, var(--color-ink) 0%, rgba(12,17,22,0.35) 26%, rgba(12,17,22,0) 62%)",
}}
/>
<div className="grid-bg absolute inset-0 -z-10" aria-hidden="true" />
{/* Alto de primera pantalla con respaldo: WebKit antiguo ignora dvh y se
queda con vh, no con nada. Va en <style> porque una utilidad de
Tailwind no puede declarar dos veces la misma propiedad. */}
<style>{HEROE_ALTO}</style>
<div className="heroe-alto shell relative flex flex-col justify-center py-14 sm:py-20 lg:py-24">
<div className="max-w-[46rem]">
<p className="eyebrow anim-rise">Clasificación de riesgo · Agente · Evidencia</p>
<h1
id="heroe-titulo"
className="anim-rise mt-5 font-display font-black uppercase leading-[0.94] tracking-[-0.03em] text-bright"
style={{ animationDelay: "80ms", fontSize: "clamp(2.05rem, 6.4vw, 4.4rem)" }}
>
{EMPRESA.promesa}
</h1>
<p
className="anim-rise mt-6 max-w-[38rem] text-base leading-relaxed text-muted sm:text-lg"
style={{ animationDelay: "160ms" }}
>
No vendemos catálogo. Clasificamos el riesgo de tu operación, seleccionamos el agente
y el equipo que corresponden a ese escenario, y entregamos la documentación que te van
a pedir en auditoría, ante tu aseguradora y en Protección Civil.
</p>
</div>
<div
className="anim-rise mt-9 max-w-[46rem]"
style={{ animationDelay: "240ms" }}
>
<Clasificador />
</div>
<div
className="anim-rise mt-8 flex flex-wrap items-center gap-x-6 gap-y-3"
style={{ animationDelay: "320ms" }}
>
<Link href="/cumplimiento-nom-002" className="btn btn-ghost">
Validar cumplimiento NOM-002
</Link>
<Link href="/soluciones" className="link text-sm text-muted">
Ver las {INDUSTRIAS.length} rutas por industria
</Link>
</div>
{/* Tira de datos: todo derivado de src/data, cero cifras inventadas. */}
<dl className="spec mt-10 grid-cols-2 sm:grid-cols-4 sm:mt-12">
{DATOS.map((d) => (
<div key={d.clave} className="spec-cell">
<dt className="spec-key">{d.clave}</dt>
<dd className="spec-val">{d.valor}</dd>
</div>
))}
</dl>
</div>
</section>
);
}

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