Files
Uriel JarethandClaude Opus 5 0e0331c1fc feat: catálogo como centro del embudo y captura de baja fricción
Reestructuración pedida por el cliente tras revisar la primera versión.

Conversión:
- Formulario de 14 campos a 4: nombre, correo, teléfono y mensaje. La
  clasificación de riesgo ya no se pregunta, se deduce del contexto de la
  página y viaja oculta. Menos fricción, misma riqueza para el análisis.
- El inicio deja de bifurcar y empuja al catálogo: 36 llamados desembocan
  ahí, y las rutas por industria y escenario pasan a ser la forma de entrar
  al catálogo ya filtrado, en lugar de competir como destino.
- Catálogo rehecho como tienda: barra lateral con filtros, búsqueda que
  acepta expresiones regulares, títulos que abren la ficha, un solo botón
  primario por tarjeta y el comparador detrás de un desplegable.
- Ficha de producto orientada al cierre: bloque en lenguaje llano sobre si
  el equipo corresponde al caso, y "Cotizar este equipo" con el formulario
  precargado. Se retiraron los enlaces que sacaban del embudo a leer normas.
- Todos los llamados se unifican en "Recibir cotización".
- Widget de WhatsApp: nombre, teléfono, correo y mensaje libre. El texto que
  escribe la persona es el que viaja a la conversación.

Doble audiencia:
- 13 escenarios con la iconografía original de FSPM traducen un lugar
  reconocible a clases de fuego y productos, para quien no domina la
  terminología. La capa técnica se conserva para búsqueda.
- Héroe con fotografía en todas las landings.

Correcciones:
- El contenedor moría al arrancar porque el CLI de Prisma no podía escribir
  sus motores; el chown ahora incluye /app/prisma-cli.
- Iconos repetidos entre escenarios. El set original no tiene icono de
  cocina, así que ese escenario usa un marcador tipográfico explícito.
- FIREMIKS no aparecía en ningún escenario.
- Los ids de campo del formulario colisionaban con dos instancias por página.
- Se documentó el despliegue, que faltaba por completo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 01:30:44 -06:00

189 lines
8.3 KiB
Docker

# 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.
# /app/prisma-cli entra en el chown a propósito: al arrancar, `prisma db push`
# verifica y a veces reescribe sus motores dentro de su propio node_modules. Sin
# permiso de escritura el contenedor muere con
# "Can't write to /app/prisma-cli/node_modules/@prisma/engines" y el arranque
# nunca llega a Next. Se detectó con el contenedor ya construido, no en el build.
RUN mkdir -p /app/data \
&& chown -R nextjs:nodejs /app/data /app/semilla /app/.next /app/prisma-cli
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"]