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:
co-authored by
Claude Opus 5
parent
122e06a4bb
commit
01b817d098
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user