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:
@@ -5,9 +5,11 @@ import ReformaSlider from '@/components/ReformaSlider/ReformaSlider';
|
||||
import Features from '@/components/Features/Features';
|
||||
import QuienesSomos from '@/components/funnel/QuienesSomos';
|
||||
import TestimoniosCliente from '@/components/funnel/TestimoniosCliente';
|
||||
import GaleriaTrabajos from '@/components/funnel/GaleriaTrabajos';
|
||||
import Footer from '@/components/Footer/Footer';
|
||||
import TenantBrand from '@/components/funnel/TenantBrand';
|
||||
import { getTenantBySlug, getPublishedTestimonios } from '@/lib/funnel/public-queries';
|
||||
import { getTenantBySlug, getPublishedTestimonios, getGaleria } from '@/lib/funnel/public-queries';
|
||||
import { resolveTheme, themeStyle } from '@/lib/funnel/themes';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -32,11 +34,19 @@ export default async function FunnelPage({ params }: { params: Promise<{ slug: s
|
||||
const tenant = await getTenantBySlug(slug);
|
||||
if (!tenant) notFound();
|
||||
|
||||
const testimonios = await getPublishedTestimonios(tenant.id);
|
||||
const [testimonios, galeria] = await Promise.all([
|
||||
getPublishedTestimonios(tenant.id),
|
||||
getGaleria(tenant.id),
|
||||
]);
|
||||
|
||||
const theme = resolveTheme(tenant.themePreset, tenant.themeColor);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />
|
||||
<div
|
||||
className={theme.heading === 'serif' ? 'theme-serif' : undefined}
|
||||
style={themeStyle(tenant.themePreset, tenant.themeColor)}
|
||||
>
|
||||
<TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} showLogin />
|
||||
<main id="main-content">
|
||||
<Hero slug={tenant.slug} />
|
||||
<ReformaSlider />
|
||||
@@ -49,9 +59,10 @@ export default async function FunnelPage({ params }: { params: Promise<{ slug: s
|
||||
aniosExperiencia={tenant.aniosExperiencia}
|
||||
/>
|
||||
)}
|
||||
<GaleriaTrabajos fotos={galeria} nombreEmpresa={tenant.nombreEmpresa} />
|
||||
{testimonios.length > 0 && <TestimoniosCliente testimonios={testimonios} />}
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Instrument Serif (display) — usada por los presets de tema con titulares serif.
|
||||
Los .woff2 viven en /public/b2b-assets/fonts. */
|
||||
@font-face {
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/b2b-assets/fonts/421ba28b-7abe-4b86-a87c-fcd3e94378f7.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/b2b-assets/fonts/15d36112-e39a-4059-ae12-06c58a5747ac.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* Colors */
|
||||
--color-black: #0a0a0a;
|
||||
@@ -111,4 +130,23 @@
|
||||
.badge-accent {
|
||||
@apply bg-accent-light text-accent;
|
||||
}
|
||||
|
||||
/* Botón con el color de marca del reformista (tema de la landing). */
|
||||
.btn-brand {
|
||||
background-color: var(--brand, #0a0a0a);
|
||||
color: var(--brand-contrast, #ffffff);
|
||||
border: 2px solid var(--brand, #0a0a0a);
|
||||
}
|
||||
.btn-brand:hover {
|
||||
background-color: var(--brand-dark, #1a1a1a);
|
||||
border-color: var(--brand-dark, #1a1a1a);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Presets de tema con titulares en serif (terracota, arena). */
|
||||
.theme-serif :is(h1, h2, h3) {
|
||||
font-family: 'Instrument Serif', Georgia, 'Times New Roman', serif;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
@@ -1,27 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useActionState } from 'react';
|
||||
import { login } from './actions';
|
||||
import AuthShell from '@/components/auth/AuthShell';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [error, formAction, pending] = useActionState(login, null);
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
|
||||
<form action={formAction} className="w-full max-w-sm bg-white border border-gray-200 rounded-xl p-8 flex flex-col gap-4">
|
||||
<h1 className="text-xl font-black tracking-tight text-black">Entra en tu panel</h1>
|
||||
<AuthShell
|
||||
photo="/despues.webp"
|
||||
photoAlt="Cocina reformada"
|
||||
caption="Tus leads, ya cualificados."
|
||||
captionSub="Render IA, presupuesto orientativo y datos del cliente. Todo en un panel."
|
||||
>
|
||||
<form action={formAction} className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-tight text-black">Entra en tu panel</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">Gestiona tus leads y tu funnel.</p>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-gray-700">Email</span>
|
||||
<input name="email" type="email" required className="border border-gray-300 rounded-md px-3 py-2" />
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="rounded-lg border border-gray-300 px-3 py-2.5 focus:border-gray-900 focus:outline-none focus:ring-1 focus:ring-gray-900"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-gray-700">Contraseña</span>
|
||||
<input name="password" type="password" required className="border border-gray-300 rounded-md px-3 py-2" />
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="rounded-lg border border-gray-300 px-3 py-2.5 focus:border-gray-900 focus:outline-none focus:ring-1 focus:ring-gray-900"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button type="submit" disabled={pending} className="bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
|
||||
|
||||
<button type="submit" disabled={pending} className="btn btn-primary w-full disabled:opacity-60">
|
||||
{pending ? 'Entrando…' : 'Entrar'}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
¿No tienes cuenta?{' '}
|
||||
<Link href="/signup" className="font-semibold text-black hover:underline">
|
||||
Empieza gratis
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
56
mvp/b2c/src/app/panel/galeria/actions.ts
Normal file
56
mvp/b2c/src/app/panel/galeria/actions.ts
Normal 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');
|
||||
}
|
||||
73
mvp/b2c/src/app/panel/galeria/page.tsx
Normal file
73
mvp/b2c/src/app/panel/galeria/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,29 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useActionState } from 'react';
|
||||
import { signup } from './actions';
|
||||
import AuthShell from '@/components/auth/AuthShell';
|
||||
|
||||
const inputClass =
|
||||
'rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-gray-900 focus:outline-none focus:ring-1 focus:ring-gray-900';
|
||||
|
||||
export default function SignupPage() {
|
||||
const [error, formAction, pending] = useActionState(signup, null);
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-gray-50 px-6 py-12">
|
||||
<form action={formAction} className="w-full max-w-md bg-white border border-gray-200 rounded-xl p-8 flex flex-col gap-4">
|
||||
<h1 className="text-xl font-black tracking-tight text-black">Empieza gratis 14 días</h1>
|
||||
<p className="text-sm text-gray-500">Sin tarjeta. Configura tu catálogo y recibe leads.</p>
|
||||
<input name="nombre" placeholder="Tu nombre" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
|
||||
<input name="empresa" placeholder="Nombre de tu empresa" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
|
||||
<input name="email" type="email" placeholder="Email" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
|
||||
<input name="provincia" placeholder="Provincia" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
|
||||
<input name="password" type="password" placeholder="Contraseña (mín. 8)" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
|
||||
<AuthShell
|
||||
photo="/despues-bano.webp"
|
||||
photoAlt="Baño reformado"
|
||||
caption="Empieza a recibir leads en minutos."
|
||||
captionSub="14 días gratis, sin tarjeta. Configura tu catálogo y comparte tu funnel."
|
||||
>
|
||||
<form action={formAction} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black tracking-tight text-black">Empieza gratis 14 días</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Sin tarjeta. Configura tu catálogo y recibe leads.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input name="nombre" placeholder="Tu nombre" required autoComplete="name" className={inputClass} />
|
||||
<input
|
||||
name="empresa"
|
||||
placeholder="Nombre de tu empresa"
|
||||
required
|
||||
autoComplete="organization"
|
||||
className={inputClass}
|
||||
/>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className={inputClass}
|
||||
/>
|
||||
<input name="provincia" placeholder="Provincia" required className={inputClass} />
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Contraseña (mín. 8)"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className={inputClass}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<input name="optInMarketing" type="checkbox" /> Quiero recibir novedades de Reformix
|
||||
<input name="optInMarketing" type="checkbox" className="h-4 w-4 accent-black" /> Quiero
|
||||
recibir novedades de Reformix
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button type="submit" disabled={pending} className="bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
|
||||
|
||||
<button type="submit" disabled={pending} className="btn btn-primary w-full disabled:opacity-60">
|
||||
{pending ? 'Creando cuenta…' : 'Crear cuenta'}
|
||||
</button>
|
||||
<a href="/login" className="text-xs text-gray-400 text-center hover:text-black">Ya tengo cuenta</a>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
¿Ya tienes cuenta?{' '}
|
||||
<Link href="/login" className="font-semibold text-black hover:underline">
|
||||
Entrar
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user