Rediseña panel y auth con la identidad de la landing B2C
- Vista de leads en tarjetas + tabla con toggle (tarjetas por defecto, preferencia persistida) - Galería de trabajos: gestión en /panel/galeria y bloque público en el funnel - Selector de tema por reformista (presets + color de marca opcional) aplicado a la landing - Login y registro rediseñados a pantalla partida 50/50 con foto de reforma - Enlace "Entrar" funcional en la cabecera del funnel; elimina Navbar muerto - Unifica tipografía y botones del panel con los tokens de la landing Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
279
mvp/b2c/src/components/panel/LeadsView.tsx
Normal file
279
mvp/b2c/src/components/panel/LeadsView.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import {
|
||||
CALIDAD_LABEL,
|
||||
ESTADO_BADGE,
|
||||
ESTADO_LABEL,
|
||||
PIPELINE_LABEL,
|
||||
PIPELINE_NEXT,
|
||||
TIPO_LABEL,
|
||||
formatEuros,
|
||||
formatFecha,
|
||||
formatRelativo,
|
||||
} from '@/lib/funnel';
|
||||
|
||||
export type PanelLead = {
|
||||
id: string;
|
||||
nombre: string;
|
||||
telefono: string;
|
||||
provincia: string | null;
|
||||
tipoReforma: keyof typeof TIPO_LABEL | null;
|
||||
estado: keyof typeof ESTADO_BADGE;
|
||||
pipelineStage: keyof typeof PIPELINE_NEXT;
|
||||
presupuestoEstimado: number | null;
|
||||
renderUrl: string | null;
|
||||
createdAtMs: number;
|
||||
m2Suelo: number | null;
|
||||
calidadGlobal: keyof typeof CALIDAD_LABEL | null;
|
||||
};
|
||||
|
||||
type Vista = 'cards' | 'tabla';
|
||||
const STORAGE_KEY = 'reformix.panel.leadsVista';
|
||||
|
||||
// La preferencia de vista vive en localStorage; useSyncExternalStore la lee sin
|
||||
// provocar desajuste de hidratación (el servidor siempre renderiza 'cards').
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribeVista(cb: () => void) {
|
||||
listeners.add(cb);
|
||||
window.addEventListener('storage', cb);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
window.removeEventListener('storage', cb);
|
||||
};
|
||||
}
|
||||
|
||||
function getVistaSnapshot(): Vista {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === 'tabla' ? 'tabla' : 'cards';
|
||||
}
|
||||
|
||||
function getVistaServerSnapshot(): Vista {
|
||||
return 'cards';
|
||||
}
|
||||
|
||||
function setVistaPersistida(v: Vista) {
|
||||
window.localStorage.setItem(STORAGE_KEY, v);
|
||||
for (const cb of listeners) cb();
|
||||
}
|
||||
|
||||
function iniciales(nombre: string): string {
|
||||
const partes = nombre.trim().split(/\s+/).slice(0, 2);
|
||||
return partes.map((p) => p[0]?.toUpperCase() ?? '').join('') || '?';
|
||||
}
|
||||
|
||||
function subtitulo(l: PanelLead): string {
|
||||
const partes = [
|
||||
l.tipoReforma ? TIPO_LABEL[l.tipoReforma] : null,
|
||||
l.provincia,
|
||||
].filter(Boolean);
|
||||
return partes.join(' · ');
|
||||
}
|
||||
|
||||
function detalle(l: PanelLead): string {
|
||||
const partes = [
|
||||
formatRelativo(new Date(l.createdAtMs)),
|
||||
l.m2Suelo ? `${l.m2Suelo} m²` : null,
|
||||
l.calidadGlobal ? CALIDAD_LABEL[l.calidadGlobal] : null,
|
||||
].filter(Boolean);
|
||||
return partes.join(' · ');
|
||||
}
|
||||
|
||||
function ToggleVista({ vista, onChange }: { vista: Vista; onChange: (v: Vista) => void }) {
|
||||
const opciones: { value: Vista; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
value: 'cards',
|
||||
label: 'Tarjetas',
|
||||
icon: (
|
||||
<>
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'tabla',
|
||||
label: 'Tabla',
|
||||
icon: (
|
||||
<>
|
||||
<path d="M3 5h18M3 12h18M3 19h18" />
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="inline-flex rounded-lg border border-gray-200 bg-white p-0.5">
|
||||
{opciones.map((o) => {
|
||||
const activo = vista === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => onChange(o.value)}
|
||||
aria-pressed={activo}
|
||||
className={
|
||||
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ' +
|
||||
(activo ? 'bg-black text-white' : 'text-gray-500 hover:text-black')
|
||||
}
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{o.icon}
|
||||
</svg>
|
||||
{o.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tarjeta({ l }: { l: PanelLead }) {
|
||||
return (
|
||||
<Link
|
||||
href={`/panel/${l.id}`}
|
||||
className="group flex flex-col gap-3 rounded-xl border border-gray-200 bg-white p-4 transition hover:border-gray-400 hover:shadow-sm"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-gray-900 text-sm font-bold text-white">
|
||||
{iniciales(l.nombre)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="truncate font-semibold text-gray-900">{l.nombre}</span>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold ${ESTADO_BADGE[l.estado]}`}
|
||||
>
|
||||
{ESTADO_LABEL[l.estado]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="truncate text-sm text-gray-500">{subtitulo(l) || l.telefono}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-gray-400">{detalle(l)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{l.renderUrl && (
|
||||
<div className="overflow-hidden rounded-lg bg-gray-100">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={l.renderUrl}
|
||||
alt=""
|
||||
className="aspect-[16/9] w-full object-cover transition group-hover:scale-[1.02]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end justify-between border-t border-gray-100 pt-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] text-gray-400">{PIPELINE_LABEL[l.pipelineStage]}</p>
|
||||
<p className="truncate text-xs text-gray-500">{PIPELINE_NEXT[l.pipelineStage]}</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="font-bold text-gray-900">{formatEuros(l.presupuestoEstimado)}</p>
|
||||
{l.presupuestoEstimado != null && (
|
||||
<p className="text-[11px] text-gray-400">orientativo</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LeadsView({ leads }: { leads: PanelLead[] }) {
|
||||
const vista = useSyncExternalStore(subscribeVista, getVistaSnapshot, getVistaServerSnapshot);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<ToggleVista vista={vista} onChange={setVistaPersistida} />
|
||||
</div>
|
||||
|
||||
{leads.length === 0 ? (
|
||||
<div className="rounded-xl border border-gray-200 bg-white px-4 py-12 text-center text-gray-400">
|
||||
No hay leads con este estado.
|
||||
</div>
|
||||
) : vista === 'cards' ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{leads.map((l) => (
|
||||
<Tarjeta key={l.id} l={l} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-400">
|
||||
<th className="px-4 py-3 font-semibold">Render</th>
|
||||
<th className="px-4 py-3 font-semibold">Cliente</th>
|
||||
<th className="px-4 py-3 font-semibold">Fecha</th>
|
||||
<th className="px-4 py-3 font-semibold">Estado</th>
|
||||
<th className="px-4 py-3 font-semibold">Presupuesto</th>
|
||||
<th className="px-4 py-3 font-semibold">Siguiente paso</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leads.map((l) => (
|
||||
<tr key={l.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/panel/${l.id}`}
|
||||
className="block h-12 w-16 overflow-hidden rounded-md bg-gray-100"
|
||||
>
|
||||
{l.renderUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={l.renderUrl} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="flex h-full w-full items-center justify-center text-[10px] text-gray-400">
|
||||
sin render
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/panel/${l.id}`}
|
||||
className="font-semibold text-black hover:underline"
|
||||
>
|
||||
{l.nombre}
|
||||
</Link>
|
||||
<div className="text-gray-500">{subtitulo(l) || l.telefono}</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-gray-600">
|
||||
{formatFecha(new Date(l.createdAtMs))}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-block rounded-full px-2.5 py-1 text-xs font-semibold ${ESTADO_BADGE[l.estado]}`}
|
||||
>
|
||||
{ESTADO_LABEL[l.estado]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 font-semibold text-black">
|
||||
{formatEuros(l.presupuestoEstimado)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">
|
||||
<div className="text-xs text-gray-400">{PIPELINE_LABEL[l.pipelineStage]}</div>
|
||||
{PIPELINE_NEXT[l.pipelineStage]}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user