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:
Carlos Narro
2026-06-01 13:51:00 +02:00
parent a91fe5ce2c
commit 1ea5d70675
30 changed files with 2797 additions and 283 deletions

View File

@@ -6,6 +6,7 @@ import { db } from '@/db';
import { tenants } from '@/db/schema';
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
import { validarSlug } from '@/lib/validation/signup';
import { THEME_PRESETS, isHexColor, type ThemePresetId } from '@/lib/funnel/themes';
const LOGO_MAX_BYTES = 500_000;
const LOGO_TIPOS = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
@@ -126,3 +127,24 @@ export async function quitarAboutFoto() {
await db.update(tenants).set({ aboutFotoUrl: null }).where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
}
// Guarda el tema de la landing del reformista: preset + color personalizado opcional.
export async function guardarTema(
_prev: LogoResult | null,
formData: FormData
): Promise<LogoResult> {
const tenantId = await getTenantId();
const presetRaw = String(formData.get('themePreset') ?? '');
const themePreset: ThemePresetId = presetRaw in THEME_PRESETS ? (presetRaw as ThemePresetId) : 'pizarra';
const usarColor = formData.get('usarColor') === 'on';
const colorRaw = String(formData.get('themeColor') ?? '').trim();
const themeColor = usarColor && isHexColor(colorRaw) ? colorRaw : null;
await db
.update(tenants)
.set({ themePreset, themeColor })
.where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
return { ok: true };
}

View File

@@ -3,6 +3,7 @@ import { getTenantPerfil } from '@/db/tenant-queries';
import { actualizarEmpresa } from './actions';
import LogoUploader from '@/components/panel/LogoUploader';
import AboutFotoUploader from '@/components/panel/AboutFotoUploader';
import ThemePicker from '@/components/panel/ThemePicker';
export const dynamic = 'force-dynamic';
@@ -17,7 +18,7 @@ export default async function EmpresaPage() {
return (
<div className="space-y-10 max-w-2xl">
<div>
<h1 className="text-2xl font-extrabold tracking-tight text-black">Datos de empresa</h1>
<h1 className="text-2xl font-black tracking-tight text-black">Datos de empresa</h1>
<p className="text-sm text-gray-500 mt-1">
Estos datos y el logo aparecen en la cabecera de los presupuestos en PDF que recibe el
cliente. Manténlos al día.
@@ -184,7 +185,7 @@ export default async function EmpresaPage() {
/>
</label>
<button className="md:col-span-2 justify-self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
<button className="md:col-span-2 justify-self-start inline-flex items-center justify-center rounded-lg bg-black px-4 py-2 text-sm font-semibold text-white transition hover:bg-gray-900">
Guardar datos
</button>
</form>
@@ -197,6 +198,15 @@ export default async function EmpresaPage() {
</p>
<AboutFotoUploader fotoUrl={perfil.aboutFotoUrl} />
</section>
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-1">Tema de tu funnel</h2>
<p className="text-sm text-gray-500 mb-4">
Elige los colores y la tipografía con los que tus clientes ven tu landing. Puedes partir
de un preset y, si quieres, fijar tu propio color de marca.
</p>
<ThemePicker themePreset={perfil.themePreset} themeColor={perfil.themeColor} />
</section>
</div>
);
}

View File

@@ -0,0 +1,56 @@
'use server';
import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { galeriaFotos } from '@/db/schema';
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
import { GALERIA_MAX_FOTOS } from '@/lib/galeria';
const GALERIA_MAX_BYTES = 2_000_000;
const GALERIA_TIPOS = ['image/png', 'image/jpeg', 'image/webp'];
export type GaleriaResult = { ok: boolean; error?: string };
export async function subirFotoGaleria(
_prev: GaleriaResult | null,
formData: FormData
): Promise<GaleriaResult> {
const tenantId = await getTenantId();
const file = formData.get('foto');
if (!(file instanceof File) || file.size === 0) {
return { ok: false, error: 'Selecciona una imagen.' };
}
if (!GALERIA_TIPOS.includes(file.type)) {
return { ok: false, error: 'Formato no válido. Usa PNG, JPG o WEBP.' };
}
if (file.size > GALERIA_MAX_BYTES) {
return { ok: false, error: 'La imagen no puede superar los 2 MB.' };
}
const existentes = await db
.select({ id: galeriaFotos.id })
.from(galeriaFotos)
.where(eq(galeriaFotos.tenantId, tenantId));
if (existentes.length >= GALERIA_MAX_FOTOS) {
return { ok: false, error: `Has alcanzado el máximo de ${GALERIA_MAX_FOTOS} fotos.` };
}
const titulo = String(formData.get('titulo') ?? '').trim() || null;
const base64 = Buffer.from(await file.arrayBuffer()).toString('base64');
const dataUri = `data:${file.type};base64,${base64}`;
await db
.insert(galeriaFotos)
.values({ tenantId, url: dataUri, titulo, orden: existentes.length });
revalidatePath('/panel/galeria');
return { ok: true };
}
export async function eliminarFotoGaleria(id: string) {
const tenantId = await getTenantId();
await db
.delete(galeriaFotos)
.where(and(eq(galeriaFotos.id, id), eq(galeriaFotos.tenantId, tenantId)));
revalidatePath('/panel/galeria');
}

View File

@@ -0,0 +1,73 @@
import { getGaleriaPanel } from '@/db/tenant-queries';
import { eliminarFotoGaleria } from './actions';
import { GALERIA_MAX_FOTOS } from '@/lib/galeria';
import GaleriaUploader from '@/components/panel/GaleriaUploader';
export const dynamic = 'force-dynamic';
export default async function GaleriaPage() {
const fotos = await getGaleriaPanel();
return (
<div className="space-y-8 max-w-3xl">
<div>
<h1 className="text-2xl font-black tracking-tight text-black">Galería de trabajos</h1>
<p className="text-sm text-gray-500 mt-1">
Sube fotos de reformas que ya has hecho. Aparecen en tu funnel para dar confianza al
cliente antes de pedir presupuesto.
</p>
</div>
<GaleriaUploader total={fotos.length} max={GALERIA_MAX_FOTOS} />
{fotos.length === 0 ? (
<p className="text-sm text-gray-400">
Aún no has subido ninguna foto. La galería no se mostrará en tu funnel hasta que añadas la
primera.
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{fotos.map((foto) => (
<figure
key={foto.id}
className="group relative overflow-hidden rounded-xl border border-gray-200 bg-white"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={foto.url}
alt={foto.titulo ?? 'Reforma'}
className="aspect-[4/3] w-full object-cover"
/>
{foto.titulo && (
<figcaption className="px-3 py-2 text-xs font-medium text-gray-700 truncate">
{foto.titulo}
</figcaption>
)}
<form action={eliminarFotoGaleria.bind(null, foto.id)} className="absolute top-2 right-2">
<button
type="submit"
aria-label="Eliminar foto"
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/90 text-gray-600 shadow-sm transition hover:bg-red-500 hover:text-white"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6M10 11v6M14 11v6" />
</svg>
</button>
</form>
</figure>
))}
</div>
)}
</div>
);
}

View File

@@ -9,6 +9,7 @@ import AppNav from '@/components/AppNav';
const PANEL_LINKS = [
{ href: '/panel', label: 'Leads', icon: 'leads' },
{ href: '/panel/precios', label: 'Precios', icon: 'precios' },
{ href: '/panel/galeria', label: 'Galería', icon: 'galeria' },
{ href: '/panel/opiniones', label: 'Opiniones', icon: 'opiniones' },
{ href: '/panel/empresa', label: 'Empresa', icon: 'empresa' },
] as const;
@@ -33,7 +34,7 @@ export default async function PanelLayout({ children }: { children: React.ReactN
<span className="inline-flex shrink-0 items-center justify-center w-8 h-8 rounded-lg bg-black text-white font-black italic text-lg leading-none">
R
</span>
<span className="font-extrabold tracking-tight text-black">Reformix</span>
<span className="font-black tracking-tight text-black">Reformix</span>
<span className="hidden sm:inline text-gray-300">/</span>
<span className="hidden sm:inline text-sm font-medium text-gray-600 truncate">
{nombreEmpresa}

View File

@@ -33,7 +33,7 @@ export default async function OpinionesPage() {
return (
<div className="space-y-8 max-w-3xl">
<div>
<h1 className="text-2xl font-extrabold tracking-tight text-black">Opiniones</h1>
<h1 className="text-2xl font-black tracking-tight text-black">Opiniones</h1>
<p className="text-sm text-gray-500 mt-1">
Las opiniones que te dejan tus clientes. Aprueba las que quieras mostrar en tu funnel; solo
las publicadas aparecen en tu página.

View File

@@ -1,14 +1,7 @@
import Link from 'next/link';
import { getLeads, getResumen, type LeadFiltro } from '@/db/queries';
import {
ESTADOS,
ESTADO_BADGE,
ESTADO_LABEL,
PIPELINE_LABEL,
PIPELINE_NEXT,
formatEuros,
formatFecha,
} from '@/lib/funnel';
import { ESTADOS, ESTADO_LABEL } from '@/lib/funnel';
import LeadsView, { type PanelLead } from '@/components/panel/LeadsView';
import { getCurrentTenantId } from '@/lib/auth/current-user';
import { db } from '@/db';
import { tenants, plans } from '@/db/schema';
@@ -32,6 +25,20 @@ export default async function PanelPage({
const filtro: LeadFiltro = (FILTROS.find((f) => f.value === estado)?.value ?? 'todos') as LeadFiltro;
const [leads, resumen] = await Promise.all([getLeads(filtro), getResumen()]);
const leadsView: PanelLead[] = leads.map((l) => ({
id: l.id,
nombre: l.nombre,
telefono: l.telefono,
provincia: l.provincia,
tipoReforma: l.tipoReforma,
estado: l.estado,
pipelineStage: l.pipelineStage,
presupuestoEstimado: l.presupuestoEstimado,
renderUrl: l.renderUrl,
createdAtMs: l.createdAt.getTime(),
m2Suelo: l.m2Suelo,
calidadGlobal: l.calidadGlobal,
}));
const tenantId = await getCurrentTenantId();
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1);
@@ -82,96 +89,7 @@ export default async function PanelPage({
})}
</div>
{/* Tabla (desktop) */}
<div className="hidden md:block bg-white border border-gray-200 rounded-xl overflow-hidden">
<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 w-16 h-12 rounded-md overflow-hidden bg-gray-100">
{l.renderUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={l.renderUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="flex w-full h-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">{l.telefono}</div>
</td>
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{formatFecha(l.createdAt)}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2.5 py-1 rounded-full text-xs font-semibold ${ESTADO_BADGE[l.estado]}`}>
{ESTADO_LABEL[l.estado]}
</span>
</td>
<td className="px-4 py-3 font-semibold text-black whitespace-nowrap">
{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>
{leads.length === 0 && (
<div className="px-4 py-12 text-center text-gray-400">No hay leads con este estado.</div>
)}
</div>
{/* Cards (mobile) */}
<div className="md:hidden flex flex-col gap-3">
{leads.map((l) => (
<Link
key={l.id}
href={`/panel/${l.id}`}
className="bg-white border border-gray-200 rounded-xl p-4 flex gap-3"
>
<div className="w-16 h-16 rounded-md overflow-hidden bg-gray-100 shrink-0">
{l.renderUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={l.renderUrl} alt="" className="w-full h-full object-cover" />
) : null}
</div>
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-black truncate">{l.nombre}</span>
<span className={`shrink-0 px-2 py-0.5 rounded-full text-[11px] font-semibold ${ESTADO_BADGE[l.estado]}`}>
{ESTADO_LABEL[l.estado]}
</span>
</div>
<div className="text-sm text-gray-500">{l.telefono}</div>
<div className="flex items-center justify-between text-sm">
<span className="font-semibold text-black">{formatEuros(l.presupuestoEstimado)}</span>
<span className="text-gray-400">{formatFecha(l.createdAt)}</span>
</div>
<div className="text-xs text-gray-400">{PIPELINE_NEXT[l.pipelineStage]}</div>
</div>
</Link>
))}
{leads.length === 0 && (
<div className="px-4 py-12 text-center text-gray-400">No hay leads con este estado.</div>
)}
</div>
<LeadsView leads={leadsView} />
</div>
);
}

View File

@@ -28,7 +28,7 @@ export default async function PreciosPage() {
return (
<div className="space-y-10">
<div>
<h1 className="text-2xl font-extrabold tracking-tight text-black">Tabla de precios</h1>
<h1 className="text-2xl font-black tracking-tight text-black">Tabla de precios</h1>
<p className="text-sm text-gray-500 mt-1">
Define los precios unitarios y la mano de obra. El motor calcula el presupuesto a
partir de estos valores y las medidas del lead.