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:
13
mvp/b2c/drizzle/0007_pale_chat.sql
Normal file
13
mvp/b2c/drizzle/0007_pale_chat.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "galeria_fotos" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"tenant_id" uuid NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"titulo" text,
|
||||||
|
"orden" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "theme_preset" text DEFAULT 'pizarra' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tenants" ADD COLUMN "theme_color" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "galeria_fotos" ADD CONSTRAINT "galeria_fotos_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "galeria_tenant_idx" ON "galeria_fotos" USING btree ("tenant_id");
|
||||||
1583
mvp/b2c/drizzle/meta/0007_snapshot.json
Normal file
1583
mvp/b2c/drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,13 @@
|
|||||||
"when": 1780308810691,
|
"when": 1780308810691,
|
||||||
"tag": "0006_aspiring_susan_delgado",
|
"tag": "0006_aspiring_susan_delgado",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780313493522,
|
||||||
|
"tag": "0007_pale_chat",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,11 @@ import ReformaSlider from '@/components/ReformaSlider/ReformaSlider';
|
|||||||
import Features from '@/components/Features/Features';
|
import Features from '@/components/Features/Features';
|
||||||
import QuienesSomos from '@/components/funnel/QuienesSomos';
|
import QuienesSomos from '@/components/funnel/QuienesSomos';
|
||||||
import TestimoniosCliente from '@/components/funnel/TestimoniosCliente';
|
import TestimoniosCliente from '@/components/funnel/TestimoniosCliente';
|
||||||
|
import GaleriaTrabajos from '@/components/funnel/GaleriaTrabajos';
|
||||||
import Footer from '@/components/Footer/Footer';
|
import Footer from '@/components/Footer/Footer';
|
||||||
import TenantBrand from '@/components/funnel/TenantBrand';
|
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';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -32,11 +34,19 @@ export default async function FunnelPage({ params }: { params: Promise<{ slug: s
|
|||||||
const tenant = await getTenantBySlug(slug);
|
const tenant = await getTenantBySlug(slug);
|
||||||
if (!tenant) notFound();
|
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 (
|
return (
|
||||||
<>
|
<div
|
||||||
<TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />
|
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">
|
<main id="main-content">
|
||||||
<Hero slug={tenant.slug} />
|
<Hero slug={tenant.slug} />
|
||||||
<ReformaSlider />
|
<ReformaSlider />
|
||||||
@@ -49,9 +59,10 @@ export default async function FunnelPage({ params }: { params: Promise<{ slug: s
|
|||||||
aniosExperiencia={tenant.aniosExperiencia}
|
aniosExperiencia={tenant.aniosExperiencia}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<GaleriaTrabajos fotos={galeria} nombreEmpresa={tenant.nombreEmpresa} />
|
||||||
{testimonios.length > 0 && <TestimoniosCliente testimonios={testimonios} />}
|
{testimonios.length > 0 && <TestimoniosCliente testimonios={testimonios} />}
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
@import "tailwindcss";
|
@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 {
|
@theme {
|
||||||
/* Colors */
|
/* Colors */
|
||||||
--color-black: #0a0a0a;
|
--color-black: #0a0a0a;
|
||||||
@@ -111,4 +130,23 @@
|
|||||||
.badge-accent {
|
.badge-accent {
|
||||||
@apply bg-accent-light text-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';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
import { login } from './actions';
|
import { login } from './actions';
|
||||||
|
import AuthShell from '@/components/auth/AuthShell';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [error, formAction, pending] = useActionState(login, null);
|
const [error, formAction, pending] = useActionState(login, null);
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
|
<AuthShell
|
||||||
<form action={formAction} className="w-full max-w-sm bg-white border border-gray-200 rounded-xl p-8 flex flex-col gap-4">
|
photo="/despues.webp"
|
||||||
<h1 className="text-xl font-black tracking-tight text-black">Entra en tu panel</h1>
|
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">
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-gray-700">Email</span>
|
<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>
|
||||||
<label className="flex flex-col gap-1 text-sm">
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
<span className="font-medium text-gray-700">Contraseña</span>
|
<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>
|
</label>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{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'}
|
{pending ? 'Entrando…' : 'Entrar'}
|
||||||
</button>
|
</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>
|
</form>
|
||||||
</main>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { db } from '@/db';
|
|||||||
import { tenants } from '@/db/schema';
|
import { tenants } from '@/db/schema';
|
||||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||||
import { validarSlug } from '@/lib/validation/signup';
|
import { validarSlug } from '@/lib/validation/signup';
|
||||||
|
import { THEME_PRESETS, isHexColor, type ThemePresetId } from '@/lib/funnel/themes';
|
||||||
|
|
||||||
const LOGO_MAX_BYTES = 500_000;
|
const LOGO_MAX_BYTES = 500_000;
|
||||||
const LOGO_TIPOS = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
|
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));
|
await db.update(tenants).set({ aboutFotoUrl: null }).where(eq(tenants.id, tenantId));
|
||||||
revalidatePath('/panel/empresa');
|
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 { actualizarEmpresa } from './actions';
|
||||||
import LogoUploader from '@/components/panel/LogoUploader';
|
import LogoUploader from '@/components/panel/LogoUploader';
|
||||||
import AboutFotoUploader from '@/components/panel/AboutFotoUploader';
|
import AboutFotoUploader from '@/components/panel/AboutFotoUploader';
|
||||||
|
import ThemePicker from '@/components/panel/ThemePicker';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ export default async function EmpresaPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-10 max-w-2xl">
|
<div className="space-y-10 max-w-2xl">
|
||||||
<div>
|
<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">
|
<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
|
Estos datos y el logo aparecen en la cabecera de los presupuestos en PDF que recibe el
|
||||||
cliente. Manténlos al día.
|
cliente. Manténlos al día.
|
||||||
@@ -184,7 +185,7 @@ export default async function EmpresaPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</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
|
Guardar datos
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -197,6 +198,15 @@ export default async function EmpresaPage() {
|
|||||||
</p>
|
</p>
|
||||||
<AboutFotoUploader fotoUrl={perfil.aboutFotoUrl} />
|
<AboutFotoUploader fotoUrl={perfil.aboutFotoUrl} />
|
||||||
</section>
|
</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>
|
</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 = [
|
const PANEL_LINKS = [
|
||||||
{ href: '/panel', label: 'Leads', icon: 'leads' },
|
{ href: '/panel', label: 'Leads', icon: 'leads' },
|
||||||
{ href: '/panel/precios', label: 'Precios', icon: 'precios' },
|
{ href: '/panel/precios', label: 'Precios', icon: 'precios' },
|
||||||
|
{ href: '/panel/galeria', label: 'Galería', icon: 'galeria' },
|
||||||
{ href: '/panel/opiniones', label: 'Opiniones', icon: 'opiniones' },
|
{ href: '/panel/opiniones', label: 'Opiniones', icon: 'opiniones' },
|
||||||
{ href: '/panel/empresa', label: 'Empresa', icon: 'empresa' },
|
{ href: '/panel/empresa', label: 'Empresa', icon: 'empresa' },
|
||||||
] as const;
|
] 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">
|
<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
|
R
|
||||||
</span>
|
</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-gray-300">/</span>
|
||||||
<span className="hidden sm:inline text-sm font-medium text-gray-600 truncate">
|
<span className="hidden sm:inline text-sm font-medium text-gray-600 truncate">
|
||||||
{nombreEmpresa}
|
{nombreEmpresa}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default async function OpinionesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-3xl">
|
<div className="space-y-8 max-w-3xl">
|
||||||
<div>
|
<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">
|
<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 opiniones que te dejan tus clientes. Aprueba las que quieras mostrar en tu funnel; solo
|
||||||
las publicadas aparecen en tu página.
|
las publicadas aparecen en tu página.
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getLeads, getResumen, type LeadFiltro } from '@/db/queries';
|
import { getLeads, getResumen, type LeadFiltro } from '@/db/queries';
|
||||||
import {
|
import { ESTADOS, ESTADO_LABEL } from '@/lib/funnel';
|
||||||
ESTADOS,
|
import LeadsView, { type PanelLead } from '@/components/panel/LeadsView';
|
||||||
ESTADO_BADGE,
|
|
||||||
ESTADO_LABEL,
|
|
||||||
PIPELINE_LABEL,
|
|
||||||
PIPELINE_NEXT,
|
|
||||||
formatEuros,
|
|
||||||
formatFecha,
|
|
||||||
} from '@/lib/funnel';
|
|
||||||
import { getCurrentTenantId } from '@/lib/auth/current-user';
|
import { getCurrentTenantId } from '@/lib/auth/current-user';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { tenants, plans } from '@/db/schema';
|
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 filtro: LeadFiltro = (FILTROS.find((f) => f.value === estado)?.value ?? 'todos') as LeadFiltro;
|
||||||
|
|
||||||
const [leads, resumen] = await Promise.all([getLeads(filtro), getResumen()]);
|
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 tenantId = await getCurrentTenantId();
|
||||||
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1);
|
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1);
|
||||||
@@ -82,96 +89,7 @@ export default async function PanelPage({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabla (desktop) */}
|
<LeadsView leads={leadsView} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default async function PreciosPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<div>
|
<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">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
Define los precios unitarios y la mano de obra. El motor calcula el presupuesto a
|
Define los precios unitarios y la mano de obra. El motor calcula el presupuesto a
|
||||||
partir de estos valores y las medidas del lead.
|
partir de estos valores y las medidas del lead.
|
||||||
|
|||||||
@@ -1,29 +1,73 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
import { useActionState } from 'react';
|
import { useActionState } from 'react';
|
||||||
import { signup } from './actions';
|
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() {
|
export default function SignupPage() {
|
||||||
const [error, formAction, pending] = useActionState(signup, null);
|
const [error, formAction, pending] = useActionState(signup, null);
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center bg-gray-50 px-6 py-12">
|
<AuthShell
|
||||||
<form action={formAction} className="w-full max-w-md bg-white border border-gray-200 rounded-xl p-8 flex flex-col gap-4">
|
photo="/despues-bano.webp"
|
||||||
<h1 className="text-xl font-black tracking-tight text-black">Empieza gratis 14 días</h1>
|
photoAlt="Baño reformado"
|
||||||
<p className="text-sm text-gray-500">Sin tarjeta. Configura tu catálogo y recibe leads.</p>
|
caption="Empieza a recibir leads en minutos."
|
||||||
<input name="nombre" placeholder="Tu nombre" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
|
captionSub="14 días gratis, sin tarjeta. Configura tu catálogo y comparte tu funnel."
|
||||||
<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" />
|
<form action={formAction} className="flex flex-col gap-4">
|
||||||
<input name="provincia" placeholder="Provincia" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
|
<div>
|
||||||
<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" />
|
<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">
|
<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>
|
</label>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{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'}
|
{pending ? 'Creando cuenta…' : 'Crear cuenta'}
|
||||||
</button>
|
</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>
|
</form>
|
||||||
</main>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { usePathname } from 'next/navigation';
|
|||||||
export type AppNavIcon =
|
export type AppNavIcon =
|
||||||
| 'leads'
|
| 'leads'
|
||||||
| 'precios'
|
| 'precios'
|
||||||
|
| 'galeria'
|
||||||
| 'empresa'
|
| 'empresa'
|
||||||
| 'opiniones'
|
| 'opiniones'
|
||||||
| 'resumen'
|
| 'resumen'
|
||||||
@@ -38,6 +39,13 @@ const ICON_PATHS: Record<AppNavIcon | 'salir', React.ReactNode> = {
|
|||||||
<circle cx="8.5" cy="8.5" r="1.2" />
|
<circle cx="8.5" cy="8.5" r="1.2" />
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
|
galeria: (
|
||||||
|
<>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<path d="m21 15-5-5L5 21" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
empresa: (
|
empresa: (
|
||||||
<>
|
<>
|
||||||
<path d="M4 21V6l8-3 8 3v15" />
|
<path d="M4 21V6l8-3 8 3v15" />
|
||||||
|
|||||||
@@ -109,7 +109,10 @@ function LeadForm({ slug }: { slug: string }) {
|
|||||||
role="alert"
|
role="alert"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<div className="w-16 h-16 bg-black text-white rounded-full flex items-center justify-center mb-2">
|
<div
|
||||||
|
className="w-16 h-16 rounded-full flex items-center justify-center mb-2"
|
||||||
|
style={{ backgroundColor: 'var(--brand, #0a0a0a)', color: 'var(--brand-contrast, #fff)' }}
|
||||||
|
>
|
||||||
<svg width="28" height="28" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
<svg width="28" height="28" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M6 16l7 7L26 9"
|
d="M6 16l7 7L26 9"
|
||||||
@@ -294,7 +297,7 @@ function LeadForm({ slug }: { slug: string }) {
|
|||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-primary w-full justify-center mt-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
className="btn btn-brand w-full justify-center mt-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||||
disabled={status === 'loading' || !consentsGranted}
|
disabled={status === 'loading' || !consentsGranted}
|
||||||
aria-busy={status === 'loading'}
|
aria-busy={status === 'loading'}
|
||||||
aria-disabled={status === 'loading' || !consentsGranted}
|
aria-disabled={status === 'loading' || !consentsGranted}
|
||||||
@@ -370,7 +373,7 @@ export default function Hero({ slug }: { slug: string }) {
|
|||||||
{/* CTAs */}
|
{/* CTAs */}
|
||||||
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-200 flex flex-col sm:flex-row gap-3">
|
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-200 flex flex-col sm:flex-row gap-3">
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary btn-lg w-full sm:w-auto"
|
className="btn btn-brand btn-lg w-full sm:w-auto"
|
||||||
onClick={() => document.querySelector('#contact')?.scrollIntoView({ behavior: 'smooth' })}
|
onClick={() => document.querySelector('#contact')?.scrollIntoView({ behavior: 'smooth' })}
|
||||||
>
|
>
|
||||||
Calcular mi reforma gratis
|
Calcular mi reforma gratis
|
||||||
@@ -433,7 +436,10 @@ export default function Hero({ slug }: { slug: string }) {
|
|||||||
},
|
},
|
||||||
].map(({ icon, title, description }) => (
|
].map(({ icon, title, description }) => (
|
||||||
<div key={title} className="flex flex-col gap-4 items-center text-center">
|
<div key={title} className="flex flex-col gap-4 items-center text-center">
|
||||||
<div className="w-12 h-12 rounded-full bg-black flex items-center justify-center text-white">
|
<div
|
||||||
|
className="w-12 h-12 rounded-full flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: 'var(--brand, #0a0a0a)', color: 'var(--brand-contrast, #fff)' }}
|
||||||
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-black tracking-tight text-black">{title}</h3>
|
<h3 className="text-lg font-black tracking-tight text-black">{title}</h3>
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
|
|
||||||
const navLinks = [
|
|
||||||
{ label: 'Características', href: '#features' },
|
|
||||||
{ label: 'Precios', href: '#pricing' },
|
|
||||||
{ label: 'Contacto', href: '#contact' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Navbar() {
|
|
||||||
const [scrolled, setScrolled] = useState(false);
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => setScrolled(window.scrollY > 24);
|
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNavClick = (href: string) => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
const el = document.querySelector(href);
|
|
||||||
if (el) el.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header
|
|
||||||
className={`fixed top-0 left-0 right-0 z-[1000] bg-white/90 backdrop-blur-md border-b transition-all duration-250 ease-out ${
|
|
||||||
scrolled ? 'border-gray-200 shadow-sm' : 'border-transparent'
|
|
||||||
}`}
|
|
||||||
role="banner"
|
|
||||||
>
|
|
||||||
<nav
|
|
||||||
className="container flex items-center justify-between h-[72px] gap-8"
|
|
||||||
aria-label="Navegación principal"
|
|
||||||
>
|
|
||||||
{/* Logo */}
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="flex items-center gap-2 no-underline shrink-0"
|
|
||||||
aria-label="FlowSync - inicio"
|
|
||||||
>
|
|
||||||
<span className="w-9 h-9 bg-black text-white rounded-md flex items-center justify-center text-xs font-extrabold tracking-[-0.02em]">
|
|
||||||
FS
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-extrabold text-black tracking-[-0.04em]">
|
|
||||||
FlowSync
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{/* Desktop links */}
|
|
||||||
<ul
|
|
||||||
className="hidden md:flex items-center justify-center gap-1 list-none flex-1"
|
|
||||||
role="list"
|
|
||||||
>
|
|
||||||
{navLinks.map((link) => (
|
|
||||||
<li key={link.href}>
|
|
||||||
<button
|
|
||||||
className="px-3 py-2 text-sm font-medium text-gray-600 rounded-md transition-colors duration-150 ease-out bg-transparent border-none cursor-pointer hover:text-black hover:bg-gray-100"
|
|
||||||
onClick={() => handleNavClick(link.href)}
|
|
||||||
aria-label={`Ir a sección ${link.label}`}
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{/* Desktop CTA */}
|
|
||||||
<div className="hidden md:flex items-center gap-3 shrink-0">
|
|
||||||
<button className="btn btn-secondary" id="nav-login-btn">
|
|
||||||
Iniciar sesión
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
id="nav-cta-btn"
|
|
||||||
onClick={() => handleNavClick('#contact')}
|
|
||||||
>
|
|
||||||
Prueba gratis
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile toggle */}
|
|
||||||
<button
|
|
||||||
className="md:hidden flex flex-col gap-[5px] p-2 rounded-md bg-transparent border-none cursor-pointer"
|
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
|
||||||
aria-label={menuOpen ? 'Cerrar menú' : 'Abrir menú'}
|
|
||||||
aria-expanded={menuOpen}
|
|
||||||
id="nav-menu-toggle"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`block w-[22px] h-[2px] bg-black rounded-sm transition-transform duration-150 ease-out origin-center ${
|
|
||||||
menuOpen ? 'translate-y-[7px] rotate-45' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`block w-[22px] h-[2px] bg-black rounded-sm transition-all duration-150 ease-out ${
|
|
||||||
menuOpen ? 'opacity-0 scale-x-0' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`block w-[22px] h-[2px] bg-black rounded-sm transition-transform duration-150 ease-out origin-center ${
|
|
||||||
menuOpen ? '-translate-y-[7px] -rotate-45' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Mobile menu */}
|
|
||||||
{menuOpen && (
|
|
||||||
<div
|
|
||||||
className="md:hidden bg-white border-t border-gray-200 px-6 py-4 animate-fadeInUp"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Menú móvil"
|
|
||||||
>
|
|
||||||
<ul className="list-none flex flex-col gap-1 mb-4" role="list">
|
|
||||||
{navLinks.map((link) => (
|
|
||||||
<li key={link.href}>
|
|
||||||
<button
|
|
||||||
className="block w-full text-left px-4 py-3 text-base font-medium text-dark rounded-md bg-transparent border-none cursor-pointer transition-colors duration-150 ease-out hover:bg-gray-100"
|
|
||||||
onClick={() => handleNavClick(link.href)}
|
|
||||||
>
|
|
||||||
{link.label}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<button className="btn btn-secondary w-full">Iniciar sesión</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary w-full"
|
|
||||||
onClick={() => handleNavClick('#contact')}
|
|
||||||
>
|
|
||||||
Prueba gratis
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
46
mvp/b2c/src/components/auth/AuthShell.tsx
Normal file
46
mvp/b2c/src/components/auth/AuthShell.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function AuthShell({
|
||||||
|
photo,
|
||||||
|
photoAlt,
|
||||||
|
caption,
|
||||||
|
captionSub,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
photo: string;
|
||||||
|
photoAlt: string;
|
||||||
|
caption: string;
|
||||||
|
captionSub: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen grid lg:grid-cols-2">
|
||||||
|
{/* Panel del formulario */}
|
||||||
|
<div className="flex flex-col px-6 py-8 sm:px-10 lg:px-16">
|
||||||
|
<Link href="/" className="inline-flex items-center gap-2 self-start">
|
||||||
|
<span className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-black text-lg font-black italic leading-none text-white">
|
||||||
|
R
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-extrabold tracking-tight text-black">Reformix</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center justify-center py-10">
|
||||||
|
<div className="w-full max-w-sm">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Panel de la foto (oculto en móvil) */}
|
||||||
|
<div className="relative hidden lg:block">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={photo} alt={photoAlt} className="absolute inset-0 h-full w-full object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||||
|
<div className="absolute inset-x-0 bottom-0 p-12">
|
||||||
|
<p className="max-w-md text-3xl font-black leading-tight tracking-tight text-white">
|
||||||
|
{caption}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 max-w-md text-sm text-white/80">{captionSub}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
mvp/b2c/src/components/funnel/GaleriaTrabajos.tsx
Normal file
54
mvp/b2c/src/components/funnel/GaleriaTrabajos.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { PublicGaleriaFoto } from '@/lib/funnel/public-queries';
|
||||||
|
|
||||||
|
type GaleriaTrabajosProps = {
|
||||||
|
fotos: PublicGaleriaFoto[];
|
||||||
|
nombreEmpresa: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Galería de trabajos del reformista en su landing pública. Solo se muestra si
|
||||||
|
// el reformista ha subido fotos desde su panel.
|
||||||
|
export default function GaleriaTrabajos({ fotos, nombreEmpresa }: GaleriaTrabajosProps) {
|
||||||
|
if (fotos.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="galeria" className="bg-gray-50 section" aria-label="Galería de trabajos">
|
||||||
|
<div className="container">
|
||||||
|
<div className="max-w-2xl mb-10 md:mb-14">
|
||||||
|
<span
|
||||||
|
className="badge mb-4"
|
||||||
|
style={{ backgroundColor: 'var(--brand)', color: 'var(--brand-contrast)' }}
|
||||||
|
>
|
||||||
|
Nuestros trabajos
|
||||||
|
</span>
|
||||||
|
<h2 className="text-[clamp(1.75rem,4vw,2.75rem)] font-black tracking-tight text-black leading-tight">
|
||||||
|
Reformas que ya hemos hecho
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500 mt-3 leading-relaxed">
|
||||||
|
Una muestra real del trabajo de {nombreEmpresa}. Calidad de acabados, plazos cumplidos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
|
||||||
|
{fotos.map((f) => (
|
||||||
|
<figure
|
||||||
|
key={f.id}
|
||||||
|
className="group relative aspect-[4/3] overflow-hidden rounded-xl bg-gray-100 border border-gray-200"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={f.url}
|
||||||
|
alt={f.titulo ?? `Reforma de ${nombreEmpresa}`}
|
||||||
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
{f.titulo && (
|
||||||
|
<figcaption className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-3 text-white text-sm font-semibold opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
|
{f.titulo}
|
||||||
|
</figcaption>
|
||||||
|
)}
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
type TenantBrandProps = {
|
type TenantBrandProps = {
|
||||||
nombreEmpresa: string;
|
nombreEmpresa: string;
|
||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
showLogin?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function iniciales(nombre: string): string {
|
function iniciales(nombre: string): string {
|
||||||
@@ -14,7 +17,12 @@ function iniciales(nombre: string): string {
|
|||||||
|
|
||||||
// Cabecera de marca del reformista para el funnel público y las páginas de
|
// Cabecera de marca del reformista para el funnel público y las páginas de
|
||||||
// solicitud. El cliente final ve el branding del reformista, no el de Reformix.
|
// solicitud. El cliente final ve el branding del reformista, no el de Reformix.
|
||||||
export default function TenantBrand({ nombreEmpresa, logoUrl, subtitle }: TenantBrandProps) {
|
export default function TenantBrand({
|
||||||
|
nombreEmpresa,
|
||||||
|
logoUrl,
|
||||||
|
subtitle,
|
||||||
|
showLogin = false,
|
||||||
|
}: TenantBrandProps) {
|
||||||
return (
|
return (
|
||||||
<header className="bg-white border-b border-gray-200">
|
<header className="bg-white border-b border-gray-200">
|
||||||
<div className="container py-4 flex items-center justify-between gap-4">
|
<div className="container py-4 flex items-center justify-between gap-4">
|
||||||
@@ -27,7 +35,10 @@ export default function TenantBrand({ nombreEmpresa, logoUrl, subtitle }: Tenant
|
|||||||
className="h-9 w-auto max-w-[160px] object-contain"
|
className="h-9 w-auto max-w-[160px] object-contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="h-9 w-9 rounded-lg bg-black text-white text-sm font-black flex items-center justify-center shrink-0">
|
<span
|
||||||
|
className="h-9 w-9 rounded-lg text-sm font-black flex items-center justify-center shrink-0"
|
||||||
|
style={{ backgroundColor: 'var(--brand, #0a0a0a)', color: 'var(--brand-contrast, #fff)' }}
|
||||||
|
>
|
||||||
{iniciales(nombreEmpresa)}
|
{iniciales(nombreEmpresa)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -35,9 +46,27 @@ export default function TenantBrand({ nombreEmpresa, logoUrl, subtitle }: Tenant
|
|||||||
{nombreEmpresa}
|
{nombreEmpresa}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{showLogin ? (
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-gray-300 px-3.5 py-2 text-sm font-semibold text-gray-700 transition-colors hover:border-gray-900 hover:text-black"
|
||||||
|
>
|
||||||
|
Entrar
|
||||||
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2 8h12M10 4l4 4-4 4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400 shrink-0">
|
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400 shrink-0">
|
||||||
{subtitle ?? 'Presupuesto de reforma'}
|
{subtitle ?? 'Presupuesto de reforma'}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
69
mvp/b2c/src/components/panel/GaleriaUploader.tsx
Normal file
69
mvp/b2c/src/components/panel/GaleriaUploader.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useActionState, useEffect, useRef } from 'react';
|
||||||
|
import { subirFotoGaleria, type GaleriaResult } from '@/app/panel/galeria/actions';
|
||||||
|
|
||||||
|
export default function GaleriaUploader({
|
||||||
|
total,
|
||||||
|
max,
|
||||||
|
}: {
|
||||||
|
total: number;
|
||||||
|
max: number;
|
||||||
|
}) {
|
||||||
|
const [state, formAction, pending] = useActionState<GaleriaResult | null, FormData>(
|
||||||
|
subirFotoGaleria,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state?.ok) formRef.current?.reset();
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const lleno = total >= max;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-xl border border-gray-200 bg-white p-5">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900">Añadir foto</h2>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{total}/{max} fotos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form ref={formRef} action={formAction} className="flex flex-col gap-3">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="foto"
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
disabled={lleno || pending}
|
||||||
|
className="text-sm file:mr-2 file:rounded-lg file:border-0 file:bg-black file:text-white file:px-3 file:py-1.5 file:text-sm file:font-medium disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="titulo"
|
||||||
|
maxLength={80}
|
||||||
|
placeholder="Título opcional (ej. Cocina en Chamberí)"
|
||||||
|
disabled={lleno || pending}
|
||||||
|
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:border-gray-400 focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={lleno || pending}
|
||||||
|
className="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 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? 'Subiendo…' : 'Añadir foto'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{lleno && (
|
||||||
|
<p className="text-sm text-amber-600">Has alcanzado el máximo de {max} fotos.</p>
|
||||||
|
)}
|
||||||
|
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||||
|
{state?.ok && <p className="text-sm text-green-600">Foto añadida ✓</p>}
|
||||||
|
<p className="text-xs text-gray-400">PNG, JPG o WEBP · máx. 2 MB.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
mvp/b2c/src/components/panel/ThemePicker.tsx
Normal file
152
mvp/b2c/src/components/panel/ThemePicker.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useActionState, useState } from 'react';
|
||||||
|
import { guardarTema, type LogoResult } from '@/app/panel/empresa/actions';
|
||||||
|
import {
|
||||||
|
THEME_LIST,
|
||||||
|
isHexColor,
|
||||||
|
type ThemePresetId,
|
||||||
|
} from '@/lib/funnel/themes';
|
||||||
|
|
||||||
|
export default function ThemePicker({
|
||||||
|
themePreset,
|
||||||
|
themeColor,
|
||||||
|
}: {
|
||||||
|
themePreset: string;
|
||||||
|
themeColor: string | null;
|
||||||
|
}) {
|
||||||
|
const [state, formAction, pending] = useActionState<LogoResult | null, FormData>(
|
||||||
|
guardarTema,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const [preset, setPreset] = useState<ThemePresetId>(
|
||||||
|
(THEME_LIST.find((t) => t.id === themePreset)?.id ?? 'pizarra') as ThemePresetId
|
||||||
|
);
|
||||||
|
const [usarColor, setUsarColor] = useState<boolean>(isHexColor(themeColor));
|
||||||
|
const [color, setColor] = useState<string>(isHexColor(themeColor) ? themeColor : '#0066ff');
|
||||||
|
|
||||||
|
const presetActual = THEME_LIST.find((t) => t.id === preset) ?? THEME_LIST[0];
|
||||||
|
const colorEfectivo = usarColor && isHexColor(color) ? color : presetActual.primary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="flex flex-col gap-5">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
{THEME_LIST.map((t) => {
|
||||||
|
const activo = preset === t.id;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={t.id}
|
||||||
|
className={
|
||||||
|
'cursor-pointer rounded-xl border p-3 transition ' +
|
||||||
|
(activo
|
||||||
|
? 'border-gray-900 ring-1 ring-gray-900'
|
||||||
|
: 'border-gray-200 hover:border-gray-400')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="themePreset"
|
||||||
|
value={t.id}
|
||||||
|
checked={activo}
|
||||||
|
onChange={() => setPreset(t.id)}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="h-6 w-6 shrink-0 rounded-full border border-black/10"
|
||||||
|
style={{ backgroundColor: t.primary }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-gray-900">{t.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-1.5 block text-xs leading-snug text-gray-500">
|
||||||
|
{t.descripcion}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'mt-1.5 inline-block text-[11px] font-medium ' +
|
||||||
|
(t.heading === 'serif' ? 'text-gray-400' : 'text-transparent')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Titulares en serif
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-200 p-4">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="usarColor"
|
||||||
|
checked={usarColor}
|
||||||
|
onChange={(e) => setUsarColor(e.target.checked)}
|
||||||
|
className="h-4 w-4 accent-black"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
Usar un color de marca personalizado
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{usarColor && (
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
name="themeColor"
|
||||||
|
value={color}
|
||||||
|
onChange={(e) => setColor(e.target.value)}
|
||||||
|
className="h-10 w-14 cursor-pointer rounded-lg border border-gray-200 bg-white p-1"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-sm text-gray-600">{color}</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
Sustituye el color del preset por el tuyo.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-gray-200 bg-gray-50 p-4">
|
||||||
|
<p className="mb-3 text-xs font-medium uppercase tracking-wide text-gray-400">
|
||||||
|
Vista previa
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
style={{ backgroundColor: colorEfectivo, color: '#fff' }}
|
||||||
|
>
|
||||||
|
Presupuesto en 2 minutos
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full px-4 py-2 text-sm font-semibold"
|
||||||
|
style={{ backgroundColor: colorEfectivo, color: '#fff' }}
|
||||||
|
>
|
||||||
|
Pedir presupuesto
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'text-lg font-black tracking-tight text-gray-900 ' +
|
||||||
|
(presetActual.heading === 'serif' ? 'font-serif' : '')
|
||||||
|
}
|
||||||
|
style={presetActual.heading === 'serif' ? { fontFamily: 'Georgia, serif' } : undefined}
|
||||||
|
>
|
||||||
|
Tu cocina, reformada
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="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 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? 'Guardando…' : 'Guardar tema'}
|
||||||
|
</button>
|
||||||
|
{state?.error && <span className="text-sm text-red-600">{state.error}</span>}
|
||||||
|
{state?.ok && <span className="text-sm text-green-600">Tema guardado ✓</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -92,6 +92,10 @@ export const tenants = pgTable('tenants', {
|
|||||||
aboutFotoUrl: text('about_foto_url'), // data URI base64 de la foto del reformista
|
aboutFotoUrl: text('about_foto_url'), // data URI base64 de la foto del reformista
|
||||||
aboutTexto: text('about_texto'),
|
aboutTexto: text('about_texto'),
|
||||||
aniosExperiencia: integer('anios_experiencia'),
|
aniosExperiencia: integer('anios_experiencia'),
|
||||||
|
// Tema visual de la landing del reformista (personalización del funnel público).
|
||||||
|
// themePreset = id de THEME_PRESETS; themeColor = override hex opcional del color primario.
|
||||||
|
themePreset: text('theme_preset').notNull().default('pizarra'),
|
||||||
|
themeColor: text('theme_color'),
|
||||||
// Datos de empresa para la cabecera del presupuesto (RF-D-07).
|
// Datos de empresa para la cabecera del presupuesto (RF-D-07).
|
||||||
cif: text('cif'),
|
cif: text('cif'),
|
||||||
direccion: text('direccion'),
|
direccion: text('direccion'),
|
||||||
@@ -255,6 +259,22 @@ export const testimonioFotos = pgTable('testimonio_fotos', {
|
|||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Galería de trabajos del reformista (fotos de reformas hechas), visible en su landing.
|
||||||
|
export const galeriaFotos = pgTable(
|
||||||
|
'galeria_fotos',
|
||||||
|
{
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
tenantId: uuid('tenant_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => tenants.id, { onDelete: 'cascade' }),
|
||||||
|
url: text('url').notNull(), // data URI base64 (no hay storage externo aún)
|
||||||
|
titulo: text('titulo'),
|
||||||
|
orden: integer('orden').notNull().default(0),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(table) => [index('galeria_tenant_idx').on(table.tenantId)]
|
||||||
|
);
|
||||||
|
|
||||||
// Histórico de cambios de estado comercial (RF-D-03: persistir y reflejar)
|
// Histórico de cambios de estado comercial (RF-D-03: persistir y reflejar)
|
||||||
export const leadEstadoHistory = pgTable('lead_estado_history', {
|
export const leadEstadoHistory = pgTable('lead_estado_history', {
|
||||||
id: uuid('id').primaryKey().defaultRandom(),
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
@@ -333,6 +353,7 @@ export type LeadFoto = typeof leadFotos.$inferSelect;
|
|||||||
export type Testimonio = typeof testimonios.$inferSelect;
|
export type Testimonio = typeof testimonios.$inferSelect;
|
||||||
export type NewTestimonio = typeof testimonios.$inferInsert;
|
export type NewTestimonio = typeof testimonios.$inferInsert;
|
||||||
export type TestimonioFoto = typeof testimonioFotos.$inferSelect;
|
export type TestimonioFoto = typeof testimonioFotos.$inferSelect;
|
||||||
|
export type GaleriaFoto = typeof galeriaFotos.$inferSelect;
|
||||||
export type LeadEstadoHistory = typeof leadEstadoHistory.$inferSelect;
|
export type LeadEstadoHistory = typeof leadEstadoHistory.$inferSelect;
|
||||||
export type LeadPipelineEvento = typeof leadPipelineEventos.$inferSelect;
|
export type LeadPipelineEvento = typeof leadPipelineEventos.$inferSelect;
|
||||||
export type PrecisionHistory = typeof precisionHistory.$inferSelect;
|
export type PrecisionHistory = typeof precisionHistory.$inferSelect;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { desc, eq } from 'drizzle-orm';
|
import { asc, desc, eq } from 'drizzle-orm';
|
||||||
import { db } from './index';
|
import { db } from './index';
|
||||||
import { tenants, testimonios, testimonioFotos } from './schema';
|
import { tenants, testimonios, testimonioFotos, galeriaFotos, type GaleriaFoto } from './schema';
|
||||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||||
|
|
||||||
export type TenantPerfil = {
|
export type TenantPerfil = {
|
||||||
@@ -19,6 +19,8 @@ export type TenantPerfil = {
|
|||||||
aboutFotoUrl: string | null;
|
aboutFotoUrl: string | null;
|
||||||
aboutTexto: string | null;
|
aboutTexto: string | null;
|
||||||
aniosExperiencia: number | null;
|
aniosExperiencia: number | null;
|
||||||
|
themePreset: string;
|
||||||
|
themeColor: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function getTenantPerfil(): Promise<TenantPerfil> {
|
export async function getTenantPerfil(): Promise<TenantPerfil> {
|
||||||
@@ -40,6 +42,8 @@ export async function getTenantPerfil(): Promise<TenantPerfil> {
|
|||||||
aboutFotoUrl: tenants.aboutFotoUrl,
|
aboutFotoUrl: tenants.aboutFotoUrl,
|
||||||
aboutTexto: tenants.aboutTexto,
|
aboutTexto: tenants.aboutTexto,
|
||||||
aniosExperiencia: tenants.aniosExperiencia,
|
aniosExperiencia: tenants.aniosExperiencia,
|
||||||
|
themePreset: tenants.themePreset,
|
||||||
|
themeColor: tenants.themeColor,
|
||||||
})
|
})
|
||||||
.from(tenants)
|
.from(tenants)
|
||||||
.where(eq(tenants.id, tenantId))
|
.where(eq(tenants.id, tenantId))
|
||||||
@@ -62,10 +66,22 @@ export async function getTenantPerfil(): Promise<TenantPerfil> {
|
|||||||
aboutFotoUrl: null,
|
aboutFotoUrl: null,
|
||||||
aboutTexto: null,
|
aboutTexto: null,
|
||||||
aniosExperiencia: null,
|
aniosExperiencia: null,
|
||||||
|
themePreset: 'pizarra',
|
||||||
|
themeColor: null,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Galería de trabajos del reformista, para gestionarla desde el panel.
|
||||||
|
export async function getGaleriaPanel(): Promise<GaleriaFoto[]> {
|
||||||
|
const tenantId = await getTenantId();
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(galeriaFotos)
|
||||||
|
.where(eq(galeriaFotos.tenantId, tenantId))
|
||||||
|
.orderBy(asc(galeriaFotos.orden), asc(galeriaFotos.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
export type TestimonioPanel = {
|
export type TestimonioPanel = {
|
||||||
id: string;
|
id: string;
|
||||||
nombre: string;
|
nombre: string;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { leadEstado, pipelineStage, tipoReforma } from '@/db/schema';
|
import type { calidad, leadEstado, pipelineStage, tipoReforma } from '@/db/schema';
|
||||||
|
|
||||||
export const TENANT_SLUG = 'reformas-ejemplo';
|
export const TENANT_SLUG = 'reformas-ejemplo';
|
||||||
|
|
||||||
type Estado = (typeof leadEstado.enumValues)[number];
|
type Estado = (typeof leadEstado.enumValues)[number];
|
||||||
type Stage = (typeof pipelineStage.enumValues)[number];
|
type Stage = (typeof pipelineStage.enumValues)[number];
|
||||||
type Tipo = (typeof tipoReforma.enumValues)[number];
|
type Tipo = (typeof tipoReforma.enumValues)[number];
|
||||||
|
type Calidad = (typeof calidad.enumValues)[number];
|
||||||
|
|
||||||
export const ESTADOS: Estado[] = [
|
export const ESTADOS: Estado[] = [
|
||||||
'nuevo',
|
'nuevo',
|
||||||
@@ -74,6 +75,12 @@ export const TIPO_LABEL: Record<Tipo, string> = {
|
|||||||
otro: 'Otro',
|
otro: 'Otro',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CALIDAD_LABEL: Record<Calidad, string> = {
|
||||||
|
basica: 'Calidad básica',
|
||||||
|
media: 'Calidad media',
|
||||||
|
premium: 'Calidad premium',
|
||||||
|
};
|
||||||
|
|
||||||
export function formatEuros(cents: number | null): string {
|
export function formatEuros(cents: number | null): string {
|
||||||
if (cents == null) return '—';
|
if (cents == null) return '—';
|
||||||
return new Intl.NumberFormat('es-ES', {
|
return new Intl.NumberFormat('es-ES', {
|
||||||
@@ -91,3 +98,16 @@ export function formatFecha(date: Date): string {
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tiempo transcurrido en formato corto: "Hace 4 min", "Hace 2 h", "Hace 3 d".
|
||||||
|
export function formatRelativo(date: Date): string {
|
||||||
|
const segundos = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000));
|
||||||
|
if (segundos < 60) return 'Justo ahora';
|
||||||
|
const minutos = Math.round(segundos / 60);
|
||||||
|
if (minutos < 60) return `Hace ${minutos} min`;
|
||||||
|
const horas = Math.round(minutos / 60);
|
||||||
|
if (horas < 24) return `Hace ${horas} h`;
|
||||||
|
const dias = Math.round(horas / 24);
|
||||||
|
if (dias < 30) return `Hace ${dias} d`;
|
||||||
|
return formatFecha(date);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
leadPipelineEventos,
|
leadPipelineEventos,
|
||||||
testimonios,
|
testimonios,
|
||||||
testimonioFotos,
|
testimonioFotos,
|
||||||
|
galeriaFotos,
|
||||||
} from '@/db/schema';
|
} from '@/db/schema';
|
||||||
|
|
||||||
export type PublicTenant = {
|
export type PublicTenant = {
|
||||||
@@ -20,6 +21,8 @@ export type PublicTenant = {
|
|||||||
aboutFotoUrl: string | null;
|
aboutFotoUrl: string | null;
|
||||||
aboutTexto: string | null;
|
aboutTexto: string | null;
|
||||||
aniosExperiencia: number | null;
|
aniosExperiencia: number | null;
|
||||||
|
themePreset: string;
|
||||||
|
themeColor: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TENANT_PUBLIC_COLUMNS = {
|
const TENANT_PUBLIC_COLUMNS = {
|
||||||
@@ -33,8 +36,26 @@ const TENANT_PUBLIC_COLUMNS = {
|
|||||||
aboutFotoUrl: tenants.aboutFotoUrl,
|
aboutFotoUrl: tenants.aboutFotoUrl,
|
||||||
aboutTexto: tenants.aboutTexto,
|
aboutTexto: tenants.aboutTexto,
|
||||||
aniosExperiencia: tenants.aniosExperiencia,
|
aniosExperiencia: tenants.aniosExperiencia,
|
||||||
|
themePreset: tenants.themePreset,
|
||||||
|
themeColor: tenants.themeColor,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type PublicGaleriaFoto = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
titulo: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Galería de trabajos publicada por el reformista, para mostrar en su landing.
|
||||||
|
export async function getGaleria(tenantId: string): Promise<PublicGaleriaFoto[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ id: galeriaFotos.id, url: galeriaFotos.url, titulo: galeriaFotos.titulo })
|
||||||
|
.from(galeriaFotos)
|
||||||
|
.where(eq(galeriaFotos.tenantId, tenantId))
|
||||||
|
.orderBy(asc(galeriaFotos.orden), asc(galeriaFotos.createdAt));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
export type PublicTestimonio = {
|
export type PublicTestimonio = {
|
||||||
id: string;
|
id: string;
|
||||||
nombre: string;
|
nombre: string;
|
||||||
|
|||||||
124
mvp/b2c/src/lib/funnel/themes.ts
Normal file
124
mvp/b2c/src/lib/funnel/themes.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
export type ThemePresetId = 'pizarra' | 'azul' | 'verde' | 'terracota' | 'arena';
|
||||||
|
|
||||||
|
export type ThemePreset = {
|
||||||
|
id: ThemePresetId;
|
||||||
|
label: string;
|
||||||
|
descripcion: string;
|
||||||
|
primary: string; // color de marca
|
||||||
|
primaryDark: string; // hover / énfasis
|
||||||
|
contrast: string; // texto sobre el color de marca
|
||||||
|
heading: 'sans' | 'serif'; // tipografía de los titulares de la landing
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_THEME: ThemePresetId = 'pizarra';
|
||||||
|
|
||||||
|
export const THEME_PRESETS: Record<ThemePresetId, ThemePreset> = {
|
||||||
|
pizarra: {
|
||||||
|
id: 'pizarra',
|
||||||
|
label: 'Pizarra',
|
||||||
|
descripcion: 'Negro elegante. El look por defecto de Reformix.',
|
||||||
|
primary: '#0a0a0a',
|
||||||
|
primaryDark: '#1a1a1a',
|
||||||
|
contrast: '#ffffff',
|
||||||
|
heading: 'sans',
|
||||||
|
},
|
||||||
|
azul: {
|
||||||
|
id: 'azul',
|
||||||
|
label: 'Azul confianza',
|
||||||
|
descripcion: 'Azul corporativo que transmite seguridad.',
|
||||||
|
primary: '#0066ff',
|
||||||
|
primaryDark: '#0052cc',
|
||||||
|
contrast: '#ffffff',
|
||||||
|
heading: 'sans',
|
||||||
|
},
|
||||||
|
verde: {
|
||||||
|
id: 'verde',
|
||||||
|
label: 'Verde natural',
|
||||||
|
descripcion: 'Verde sereno para un estilo sostenible.',
|
||||||
|
primary: '#0f7a52',
|
||||||
|
primaryDark: '#0b5e3f',
|
||||||
|
contrast: '#ffffff',
|
||||||
|
heading: 'sans',
|
||||||
|
},
|
||||||
|
terracota: {
|
||||||
|
id: 'terracota',
|
||||||
|
label: 'Terracota',
|
||||||
|
descripcion: 'Cálido y artesanal, con titulares en serif.',
|
||||||
|
primary: '#b4502e',
|
||||||
|
primaryDark: '#8f3d22',
|
||||||
|
contrast: '#ffffff',
|
||||||
|
heading: 'serif',
|
||||||
|
},
|
||||||
|
arena: {
|
||||||
|
id: 'arena',
|
||||||
|
label: 'Arena',
|
||||||
|
descripcion: 'Neutro cálido, sobrio y premium. Titulares en serif.',
|
||||||
|
primary: '#8a6d3b',
|
||||||
|
primaryDark: '#6e562f',
|
||||||
|
contrast: '#ffffff',
|
||||||
|
heading: 'serif',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const THEME_LIST: ThemePreset[] = Object.values(THEME_PRESETS);
|
||||||
|
|
||||||
|
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
|
||||||
|
|
||||||
|
export function isHexColor(value: string | null | undefined): value is string {
|
||||||
|
return !!value && HEX_RE.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function darken(hex: string, amount = 0.16): string {
|
||||||
|
const n = parseInt(hex.slice(1), 16);
|
||||||
|
const r = Math.max(0, Math.round(((n >> 16) & 255) * (1 - amount)));
|
||||||
|
const g = Math.max(0, Math.round(((n >> 8) & 255) * (1 - amount)));
|
||||||
|
const b = Math.max(0, Math.round((n & 255) * (1 - amount)));
|
||||||
|
return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResolvedTheme = {
|
||||||
|
preset: ThemePreset;
|
||||||
|
primary: string;
|
||||||
|
primaryDark: string;
|
||||||
|
contrast: string;
|
||||||
|
heading: 'sans' | 'serif';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Combina el preset elegido con el color personalizado opcional del reformista.
|
||||||
|
export function resolveTheme(
|
||||||
|
presetId: string | null | undefined,
|
||||||
|
customColor: string | null | undefined
|
||||||
|
): ResolvedTheme {
|
||||||
|
const preset = THEME_PRESETS[presetId as ThemePresetId] ?? THEME_PRESETS[DEFAULT_THEME];
|
||||||
|
if (isHexColor(customColor)) {
|
||||||
|
return {
|
||||||
|
preset,
|
||||||
|
primary: customColor,
|
||||||
|
primaryDark: darken(customColor),
|
||||||
|
contrast: '#ffffff',
|
||||||
|
heading: preset.heading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
preset,
|
||||||
|
primary: preset.primary,
|
||||||
|
primaryDark: preset.primaryDark,
|
||||||
|
contrast: preset.contrast,
|
||||||
|
heading: preset.heading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variables CSS que cuelgan del contenedor de la landing y consumen los componentes.
|
||||||
|
export function themeStyle(
|
||||||
|
presetId: string | null | undefined,
|
||||||
|
customColor: string | null | undefined
|
||||||
|
): CSSProperties {
|
||||||
|
const t = resolveTheme(presetId, customColor);
|
||||||
|
return {
|
||||||
|
'--brand': t.primary,
|
||||||
|
'--brand-dark': t.primaryDark,
|
||||||
|
'--brand-contrast': t.contrast,
|
||||||
|
} as CSSProperties;
|
||||||
|
}
|
||||||
3
mvp/b2c/src/lib/galeria.ts
Normal file
3
mvp/b2c/src/lib/galeria.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Tope de fotos en la galería del reformista. Vive fuera del módulo 'use server'
|
||||||
|
// porque esos solo pueden exportar funciones async.
|
||||||
|
export const GALERIA_MAX_FOTOS = 24;
|
||||||
Reference in New Issue
Block a user