Añade chooser de canal y formulario por zonas al funnel B2C
- Paso intermedio /solicitud/[id]: el cliente elige llamada, WhatsApp o formulario (crearLead ahora redirige aquí, no a /fotos). - /formulario: FormularioZonas permite añadir varias zonas, cada una con tipo, m², acabado, notas y fotos; /fotos queda como redirect. - guardarDetallesYFotos: guarda fotos (antes, por zona) y notas (por zona), agrega los campos del lead (m² suma, tipo único o 'integral', calidad más alta, tasteText concatenado) para el presupuesto orientativo inmediato, y señala perfilCompleto al flujo externo. - Elimina FotosUploader (sustituido por FormularioZonas). Verificado en navegador: 2 zonas → presupuesto al instante + notas por zona + evento de perfil en DB. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
39
mvp/b2c/src/app/solicitud/[id]/formulario/page.tsx
Normal file
39
mvp/b2c/src/app/solicitud/[id]/formulario/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { getPublicLead } from '@/lib/funnel/public-queries';
|
||||||
|
import { guardarDetallesYFotos } from '../../actions';
|
||||||
|
import FormularioZonas from '@/components/funnel/FormularioZonas';
|
||||||
|
import TenantBrand from '@/components/funnel/TenantBrand';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export default async function FormularioPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await getPublicLead(id);
|
||||||
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const { lead, tenant } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{tenant && <TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />}
|
||||||
|
<div className="container py-10 max-w-2xl flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
|
||||||
|
Cuéntanos tu reforma
|
||||||
|
</span>
|
||||||
|
<h1 className="text-2xl font-black tracking-tight text-black">
|
||||||
|
Hola {lead.nombre.split(' ')[0]}, cuéntanos sobre tu reforma
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 leading-relaxed">
|
||||||
|
Añade cada zona que quieras reformar con sus fotos y detalles. Con eso preparamos tu
|
||||||
|
render y un presupuesto orientativo en menos de un minuto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-6 md:p-8 shadow-sm">
|
||||||
|
<FormularioZonas action={guardarDetallesYFotos.bind(null, id)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,39 +1,10 @@
|
|||||||
import { notFound } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { getPublicLead } from '@/lib/funnel/public-queries';
|
|
||||||
import { guardarDetallesYFotos } from '../../actions';
|
|
||||||
import FotosUploader from '@/components/funnel/FotosUploader';
|
|
||||||
import TenantBrand from '@/components/funnel/TenantBrand';
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default async function FotosPage({ params }: { params: Promise<{ id: string }> }) {
|
// La subida de fotos vive ahora en /formulario (formulario por zonas). Mantenemos /fotos como
|
||||||
|
// redirect por compatibilidad con enlaces antiguos.
|
||||||
|
export default async function FotosRedirect({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const data = await getPublicLead(id);
|
redirect(`/solicitud/${id}/formulario`);
|
||||||
if (!data) notFound();
|
|
||||||
|
|
||||||
const { lead, tenant } = data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{tenant && <TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />}
|
|
||||||
<div className="container py-10 max-w-2xl flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
|
|
||||||
Paso 2 de 2
|
|
||||||
</span>
|
|
||||||
<h1 className="text-2xl font-black tracking-tight text-black">
|
|
||||||
Hola {lead.nombre.split(' ')[0]}, cuéntanos sobre tu reforma
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-gray-500 leading-relaxed">
|
|
||||||
Sube unas fotos del espacio y dinos qué tienes en mente. Con eso preparamos tu render y un
|
|
||||||
presupuesto orientativo en menos de un minuto.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 md:p-8 shadow-sm">
|
|
||||||
<FotosUploader action={guardarDetallesYFotos.bind(null, id)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
82
mvp/b2c/src/app/solicitud/[id]/page.tsx
Normal file
82
mvp/b2c/src/app/solicitud/[id]/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { getPublicLead } from '@/lib/funnel/public-queries';
|
||||||
|
import TenantBrand from '@/components/funnel/TenantBrand';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const CANALES = [
|
||||||
|
{
|
||||||
|
slug: 'llamada',
|
||||||
|
icon: '📞',
|
||||||
|
titulo: 'Que te llamemos',
|
||||||
|
descripcion:
|
||||||
|
'Un asistente te llama y te hace unas preguntas rápidas. Lo más cómodo: no escribes nada.',
|
||||||
|
cta: 'Quiero que me llamen',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'whatsapp',
|
||||||
|
icon: '💬',
|
||||||
|
titulo: 'Por WhatsApp',
|
||||||
|
descripcion:
|
||||||
|
'Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.',
|
||||||
|
cta: 'Seguir por WhatsApp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'formulario',
|
||||||
|
icon: '📝',
|
||||||
|
titulo: 'Rellenar un formulario',
|
||||||
|
descripcion:
|
||||||
|
'Tú lo cuentas zona por zona y subes las fotos. Recibes el presupuesto al instante.',
|
||||||
|
cta: 'Rellenar el formulario',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default async function ChooserPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const data = await getPublicLead(id);
|
||||||
|
if (!data) notFound();
|
||||||
|
|
||||||
|
const { lead, tenant } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{tenant && <TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />}
|
||||||
|
<div className="container py-10 max-w-2xl flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
|
||||||
|
Elige cómo seguir
|
||||||
|
</span>
|
||||||
|
<h1 className="text-2xl font-black tracking-tight text-black">
|
||||||
|
¿Cómo prefieres contarnos tu reforma, {lead.nombre.split(' ')[0]}?
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 leading-relaxed">
|
||||||
|
Tú eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu render
|
||||||
|
y tu presupuesto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{CANALES.map((c) => (
|
||||||
|
<Link
|
||||||
|
key={c.slug}
|
||||||
|
href={`/solicitud/${id}/${c.slug}`}
|
||||||
|
className="group bg-white border border-gray-200 rounded-xl p-5 shadow-sm flex items-center gap-4 transition-all hover:border-black hover:shadow-md"
|
||||||
|
>
|
||||||
|
<span className="text-3xl shrink-0" aria-hidden="true">
|
||||||
|
{c.icon}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
|
<span className="text-base font-bold text-black">{c.titulo}</span>
|
||||||
|
<span className="text-sm text-gray-500 leading-snug">{c.descripcion}</span>
|
||||||
|
<span className="text-sm font-semibold text-[color:var(--brand,#0a0a0a)] mt-1">
|
||||||
|
{c.cta} →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,11 +5,14 @@ import { and, eq } from 'drizzle-orm';
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
import { db } from '@/db';
|
import { db } from '@/db';
|
||||||
import { leads, leadFotos, leadPipelineEventos } from '@/db/schema';
|
import { leads, leadFotos, leadNotas, leadPipelineEventos } from '@/db/schema';
|
||||||
|
import type { NewLeadFoto, NewLeadNota } from '@/db/schema';
|
||||||
import { getTenantBySlug } from '@/lib/funnel/public-queries';
|
import { getTenantBySlug } from '@/lib/funnel/public-queries';
|
||||||
import { procesarLead } from '@/lib/funnel/orchestrator';
|
import { procesarLead } from '@/lib/funnel/orchestrator';
|
||||||
|
import { señalarPerfilCompleto } from '@/lib/funnel/perfil';
|
||||||
|
|
||||||
const MAX_FOTOS = 4;
|
const MAX_ZONAS = 6;
|
||||||
|
const MAX_FOTOS_ZONA = 6;
|
||||||
const MAX_FOTO_BYTES = 6 * 1024 * 1024; // 6 MB por foto
|
const MAX_FOTO_BYTES = 6 * 1024 * 1024; // 6 MB por foto
|
||||||
|
|
||||||
const crearLeadSchema = z.object({
|
const crearLeadSchema = z.object({
|
||||||
@@ -79,27 +82,57 @@ async function fileToDataUri(file: File): Promise<string | null> {
|
|||||||
return `data:${file.type};base64,${buffer.toString('base64')}`;
|
return `data:${file.type};base64,${buffer.toString('base64')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paso 3 del funnel: el cliente sube fotos y confirma los datos clave de la reforma.
|
const CALIDAD_RANK: Record<(typeof CALIDADES)[number], number> = { basica: 0, media: 1, premium: 2 };
|
||||||
// Guardamos las fotos como data URI (no hay storage externo en esta fase) y disparamos
|
|
||||||
// el orquestador que simula la llamada/render y calcula el presupuesto real.
|
type ZonaParseada = {
|
||||||
|
tipo: (typeof TIPOS)[number];
|
||||||
|
m2: number | null;
|
||||||
|
calidad: (typeof CALIDADES)[number];
|
||||||
|
notas: string | null;
|
||||||
|
fotos: string[]; // data URIs
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lee las zonas del FormData (campos zona-<i>-tipo / -m2 / -calidad / -notas / -fotos).
|
||||||
|
async function parsearZonas(formData: FormData): Promise<ZonaParseada[]> {
|
||||||
|
const count = Math.min(Number(formData.get('zonasCount')) || 0, MAX_ZONAS);
|
||||||
|
const zonas: ZonaParseada[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const tipoRaw = String(formData.get(`zona-${i}-tipo`) ?? '');
|
||||||
|
const calidadRaw = String(formData.get(`zona-${i}-calidad`) ?? '');
|
||||||
|
const m2Raw = Number(formData.get(`zona-${i}-m2`));
|
||||||
|
const tipo = (TIPOS as readonly string[]).includes(tipoRaw)
|
||||||
|
? (tipoRaw as (typeof TIPOS)[number])
|
||||||
|
: 'otro';
|
||||||
|
const calidad = (CALIDADES as readonly string[]).includes(calidadRaw)
|
||||||
|
? (calidadRaw as (typeof CALIDADES)[number])
|
||||||
|
: 'media';
|
||||||
|
const m2 = Number.isFinite(m2Raw) && m2Raw > 0 ? m2Raw : null;
|
||||||
|
const notas = String(formData.get(`zona-${i}-notas`) ?? '').trim() || null;
|
||||||
|
|
||||||
|
const archivos = formData
|
||||||
|
.getAll(`zona-${i}-fotos`)
|
||||||
|
.filter((f): f is File => f instanceof File)
|
||||||
|
.slice(0, MAX_FOTOS_ZONA);
|
||||||
|
const fotos: string[] = [];
|
||||||
|
for (const file of archivos) {
|
||||||
|
const uri = await fileToDataUri(file);
|
||||||
|
if (uri) fotos.push(uri);
|
||||||
|
}
|
||||||
|
zonas.push({ tipo, m2, calidad, notas, fotos });
|
||||||
|
}
|
||||||
|
return zonas;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paso 2 (canal formulario): el cliente describe la reforma zona por zona y sube fotos.
|
||||||
|
// Guardamos fotos (momento 'antes', etiquetadas por zona) y notas como data en lead_notas;
|
||||||
|
// agregamos los campos del lead para calcular el presupuesto orientativo al instante con el motor
|
||||||
|
// actual, y señalamos "perfil completo" al flujo externo para que genere los renders "después".
|
||||||
export async function guardarDetallesYFotos(leadId: string, formData: FormData): Promise<void> {
|
export async function guardarDetallesYFotos(leadId: string, formData: FormData): Promise<void> {
|
||||||
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
||||||
if (!lead) throw new Error('Solicitud no encontrada.');
|
if (!lead) throw new Error('Solicitud no encontrada.');
|
||||||
const tenantId = lead.tenantId;
|
const tenantId = lead.tenantId;
|
||||||
|
|
||||||
const tipoRaw = String(formData.get('tipoReforma') ?? '');
|
|
||||||
const calidadRaw = String(formData.get('calidad') ?? '');
|
|
||||||
const m2Raw = Number(formData.get('m2'));
|
|
||||||
const provincia = String(formData.get('provincia') ?? '').trim() || null;
|
const provincia = String(formData.get('provincia') ?? '').trim() || null;
|
||||||
|
|
||||||
const tipoReforma = (TIPOS as readonly string[]).includes(tipoRaw)
|
|
||||||
? (tipoRaw as (typeof TIPOS)[number])
|
|
||||||
: 'otro';
|
|
||||||
const calidadGlobal = (CALIDADES as readonly string[]).includes(calidadRaw)
|
|
||||||
? (calidadRaw as (typeof CALIDADES)[number])
|
|
||||||
: 'media';
|
|
||||||
const m2Suelo = Number.isFinite(m2Raw) && m2Raw > 0 ? m2Raw : null;
|
|
||||||
|
|
||||||
const urgenciaRaw = String(formData.get('urgencia') ?? '');
|
const urgenciaRaw = String(formData.get('urgencia') ?? '');
|
||||||
const urgencia = (['alta', 'media', 'baja'] as const).includes(urgenciaRaw as 'alta')
|
const urgencia = (['alta', 'media', 'baja'] as const).includes(urgenciaRaw as 'alta')
|
||||||
? (urgenciaRaw as 'alta' | 'media' | 'baja')
|
? (urgenciaRaw as 'alta' | 'media' | 'baja')
|
||||||
@@ -108,20 +141,40 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
|
|||||||
const presupuestoTarget =
|
const presupuestoTarget =
|
||||||
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
|
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
|
||||||
const estructural = formData.get('estructural') === 'on';
|
const estructural = formData.get('estructural') === 'on';
|
||||||
const tasteText = String(formData.get('tasteText') ?? '').trim() || null;
|
|
||||||
|
|
||||||
const archivos = formData.getAll('fotos').filter((f): f is File => f instanceof File);
|
let zonas = await parsearZonas(formData);
|
||||||
const dataUris: string[] = [];
|
if (zonas.length === 0) {
|
||||||
for (const file of archivos.slice(0, MAX_FOTOS)) {
|
zonas = [{ tipo: 'otro', m2: null, calidad: 'media', notas: null, fotos: [] }];
|
||||||
const uri = await fileToDataUri(file);
|
|
||||||
if (uri) dataUris.push(uri);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dataUris.length > 0) {
|
// Inserta fotos (antes, por zona) y notas (por zona) en la estructura del lead.
|
||||||
await db.insert(leadFotos).values(
|
const fotoRows: NewLeadFoto[] = [];
|
||||||
dataUris.map((url, orden) => ({ leadId, url, orden }))
|
const notaRows: NewLeadNota[] = [];
|
||||||
|
let orden = 0;
|
||||||
|
for (const z of zonas) {
|
||||||
|
for (const url of z.fotos) {
|
||||||
|
fotoRows.push({ leadId, url, momento: 'antes', zona: z.tipo, orden: orden++ });
|
||||||
|
}
|
||||||
|
if (z.notas) notaRows.push({ leadId, texto: z.notas, zona: z.tipo, origen: 'funnel' });
|
||||||
|
}
|
||||||
|
if (fotoRows.length > 0) await db.insert(leadFotos).values(fotoRows);
|
||||||
|
if (notaRows.length > 0) await db.insert(leadNotas).values(notaRows);
|
||||||
|
|
||||||
|
// Agregado para el motor de presupuesto (multi-zona "de verdad" = F1.5): m² suma, tipo único
|
||||||
|
// o 'integral' si hay varias zonas, calidad la más alta, y tasteText con las notas concatenadas.
|
||||||
|
const tiposUnicos = Array.from(new Set(zonas.map((z) => z.tipo)));
|
||||||
|
const tipoReforma = tiposUnicos.length === 1 ? tiposUnicos[0] : 'integral';
|
||||||
|
const m2Total = zonas.reduce((s, z) => s + (z.m2 ?? 0), 0);
|
||||||
|
const m2Suelo = m2Total > 0 ? m2Total : null;
|
||||||
|
const calidadGlobal = zonas.reduce<(typeof CALIDADES)[number]>(
|
||||||
|
(best, z) => (CALIDAD_RANK[z.calidad] > CALIDAD_RANK[best] ? z.calidad : best),
|
||||||
|
'basica',
|
||||||
);
|
);
|
||||||
}
|
const tasteText =
|
||||||
|
zonas
|
||||||
|
.filter((z) => z.notas)
|
||||||
|
.map((z) => `${z.tipo}: ${z.notas}`)
|
||||||
|
.join('\n') || null;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(leads)
|
.update(leads)
|
||||||
@@ -142,12 +195,15 @@ export async function guardarDetallesYFotos(leadId: string, formData: FormData):
|
|||||||
await db.insert(leadPipelineEventos).values({
|
await db.insert(leadPipelineEventos).values({
|
||||||
leadId,
|
leadId,
|
||||||
stage: 'fotos_subidas',
|
stage: 'fotos_subidas',
|
||||||
metadata: { fotos: dataUris.length },
|
metadata: { fotos: fotoRows.length, notas: notaRows.length, zonas: zonas.length },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dispara el resto del pipeline (llamada simulada → render → presupuesto real → WhatsApp).
|
// Presupuesto orientativo inmediato (motor actual). La rama de WhatsApp queda simulada.
|
||||||
await procesarLead(leadId);
|
await procesarLead(leadId);
|
||||||
|
|
||||||
|
// Señala al flujo externo que el perfil está listo para generar los renders "después".
|
||||||
|
await señalarPerfilCompleto(leadId);
|
||||||
|
|
||||||
revalidatePath('/panel');
|
revalidatePath('/panel');
|
||||||
redirect(`/solicitud/${leadId}/estado`);
|
redirect(`/solicitud/${leadId}/estado`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export default function ContactForm({ slug }: { slug: string }) {
|
|||||||
setSubmitError(result.error);
|
setSubmitError(result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/solicitud/${result.leadId}/fotos`);
|
router.push(`/solicitud/${result.leadId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ function LeadForm({ slug }: { slug: string }) {
|
|||||||
setSubmitError(result.error);
|
setSubmitError(result.error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(`/solicitud/${result.leadId}/fotos`);
|
router.push(`/solicitud/${result.leadId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
|
|||||||
286
mvp/b2c/src/components/funnel/FormularioZonas.tsx
Normal file
286
mvp/b2c/src/components/funnel/FormularioZonas.tsx
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useFormStatus } from 'react-dom';
|
||||||
|
import { TIPO_LABEL } from '@/lib/funnel';
|
||||||
|
|
||||||
|
const TIPOS = ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'] as const;
|
||||||
|
const CALIDADES = [
|
||||||
|
{ value: 'basica', label: 'Básica' },
|
||||||
|
{ value: 'media', label: 'Media' },
|
||||||
|
{ value: 'premium', label: 'Premium' },
|
||||||
|
] as const;
|
||||||
|
const URGENCIAS = [
|
||||||
|
{ value: 'alta', label: 'Cuanto antes' },
|
||||||
|
{ value: 'media', label: 'En unos meses' },
|
||||||
|
{ value: 'baja', label: 'Sin prisa' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const MAX_ZONAS = 6;
|
||||||
|
const MAX_FOTOS_ZONA = 6;
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
'w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] border-gray-200 rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)]';
|
||||||
|
|
||||||
|
type Zona = { key: number; tipo: string };
|
||||||
|
|
||||||
|
function SubmitButton() {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-lg w-full justify-center mt-1 disabled:opacity-60 disabled:cursor-not-allowed disabled:transform-none"
|
||||||
|
disabled={pending}
|
||||||
|
aria-busy={pending}
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="w-[18px] h-[18px] border-2 border-white/30 border-t-white rounded-full animate-[spin_0.7s_linear_infinite] shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Generando tu presupuesto...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Pedir mi presupuesto'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZonaCard({
|
||||||
|
index,
|
||||||
|
zona,
|
||||||
|
onTipoChange,
|
||||||
|
onRemove,
|
||||||
|
removable,
|
||||||
|
}: {
|
||||||
|
index: number;
|
||||||
|
zona: Zona;
|
||||||
|
onTipoChange: (tipo: string) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
removable: boolean;
|
||||||
|
}) {
|
||||||
|
const [previews, setPreviews] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const handleFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files ?? []).slice(0, MAX_FOTOS_ZONA);
|
||||||
|
previews.forEach((url) => URL.revokeObjectURL(url));
|
||||||
|
setPreviews(files.map((f) => URL.createObjectURL(f)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-xl p-5 flex flex-col gap-4 bg-gray-50/50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-bold text-black">Zona {index + 1}</span>
|
||||||
|
{removable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRemove}
|
||||||
|
className="text-xs text-gray-400 hover:text-red-600 font-medium"
|
||||||
|
>
|
||||||
|
Quitar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor={`zona-${index}-tipo`} className="text-sm font-semibold text-dark">
|
||||||
|
¿Qué zona es?
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`zona-${index}-tipo`}
|
||||||
|
name={`zona-${index}-tipo`}
|
||||||
|
value={zona.tipo}
|
||||||
|
onChange={(e) => onTipoChange(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{TIPOS.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{TIPO_LABEL[t]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor={`zona-${index}-m2`} className="text-sm font-semibold text-dark">
|
||||||
|
Metros cuadrados <span className="text-gray-400 font-normal">(aprox.)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`zona-${index}-m2`}
|
||||||
|
name={`zona-${index}-m2`}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="12"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor={`zona-${index}-calidad`} className="text-sm font-semibold text-dark">
|
||||||
|
Nivel de acabado
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`zona-${index}-calidad`}
|
||||||
|
name={`zona-${index}-calidad`}
|
||||||
|
defaultValue="media"
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{CALIDADES.map((c) => (
|
||||||
|
<option key={c.value} value={c.value}>
|
||||||
|
{c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor={`zona-${index}-notas`} className="text-sm font-semibold text-dark">
|
||||||
|
Detalles de esta zona <span className="text-gray-400 font-normal">(opcional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id={`zona-${index}-notas`}
|
||||||
|
name={`zona-${index}-notas`}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Materiales, estilo, caprichos… (ej. suelo porcelánico, encimera de cuarzo, ducha de obra)."
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor={`zona-${index}-fotos`} className="text-sm font-semibold text-dark">
|
||||||
|
Fotos de la zona{' '}
|
||||||
|
<span className="text-gray-400 font-normal">(hasta {MAX_FOTOS_ZONA})</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`zona-${index}-fotos`}
|
||||||
|
name={`zona-${index}-fotos`}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={handleFiles}
|
||||||
|
className="block w-full text-sm text-gray-600 file:mr-4 file:py-2.5 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-black file:text-white hover:file:bg-gray-800 file:cursor-pointer cursor-pointer"
|
||||||
|
/>
|
||||||
|
{previews.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-3 mt-2">
|
||||||
|
{previews.map((url, i) => (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
className="w-20 h-20 object-cover rounded-lg border border-gray-200"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormularioZonas({
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
action: (formData: FormData) => void | Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [zonas, setZonas] = useState<Zona[]>([{ key: 0, tipo: 'cocina' }]);
|
||||||
|
const [nextKey, setNextKey] = useState(1);
|
||||||
|
|
||||||
|
const addZona = () => {
|
||||||
|
if (zonas.length >= MAX_ZONAS) return;
|
||||||
|
setZonas((z) => [...z, { key: nextKey, tipo: 'bano' }]);
|
||||||
|
setNextKey((k) => k + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={action} className="flex flex-col gap-6">
|
||||||
|
<input type="hidden" name="zonasCount" value={zonas.length} />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{zonas.map((z, i) => (
|
||||||
|
<ZonaCard
|
||||||
|
key={z.key}
|
||||||
|
index={i}
|
||||||
|
zona={z}
|
||||||
|
removable={zonas.length > 1}
|
||||||
|
onTipoChange={(tipo) =>
|
||||||
|
setZonas((prev) => prev.map((p) => (p.key === z.key ? { ...p, tipo } : p)))
|
||||||
|
}
|
||||||
|
onRemove={() => setZonas((prev) => prev.filter((p) => p.key !== z.key))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{zonas.length < MAX_ZONAS && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addZona}
|
||||||
|
className="text-sm font-semibold text-[color:var(--brand,#0a0a0a)] self-start hover:underline"
|
||||||
|
>
|
||||||
|
+ Añadir otra zona
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-gray-100 pt-5 flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="provincia" className="text-sm font-semibold text-dark">
|
||||||
|
Provincia
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="provincia"
|
||||||
|
name="provincia"
|
||||||
|
type="text"
|
||||||
|
placeholder="Madrid"
|
||||||
|
autoComplete="address-level1"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="urgencia" className="text-sm font-semibold text-dark">
|
||||||
|
¿Para cuándo?
|
||||||
|
</label>
|
||||||
|
<select id="urgencia" name="urgencia" defaultValue="media" className={inputClass}>
|
||||||
|
{URGENCIAS.map((u) => (
|
||||||
|
<option key={u.value} value={u.value}>
|
||||||
|
{u.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label htmlFor="presupuestoTarget" className="text-sm font-semibold text-dark">
|
||||||
|
Presupuesto objetivo <span className="text-gray-400 font-normal">(opcional, €)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="presupuestoTarget"
|
||||||
|
name="presupuestoTarget"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="100"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="8000"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
|
||||||
|
<input type="checkbox" name="estructural" className="w-4 h-4 accent-black" />
|
||||||
|
Hay que mover sanitarios, tirar algún muro o cambiar la distribución
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SubmitButton />
|
||||||
|
<p className="text-xs text-gray-400 text-center">
|
||||||
|
Calculamos un presupuesto orientativo con tus datos. Sin compromiso.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useFormStatus } from 'react-dom';
|
|
||||||
import { TIPO_LABEL } from '@/lib/funnel';
|
|
||||||
|
|
||||||
const TIPOS = ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'] as const;
|
|
||||||
const CALIDADES = [
|
|
||||||
{ value: 'basica', label: 'Básica' },
|
|
||||||
{ value: 'media', label: 'Media' },
|
|
||||||
{ value: 'premium', label: 'Premium' },
|
|
||||||
] as const;
|
|
||||||
const URGENCIAS = [
|
|
||||||
{ value: 'alta', label: 'Cuanto antes' },
|
|
||||||
{ value: 'media', label: 'En unos meses' },
|
|
||||||
{ value: 'baja', label: 'Sin prisa' },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const MAX_FOTOS = 4;
|
|
||||||
|
|
||||||
const inputClass =
|
|
||||||
'w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] border-gray-200 rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)]';
|
|
||||||
|
|
||||||
function SubmitButton({ disabled }: { disabled: boolean }) {
|
|
||||||
const { pending } = useFormStatus();
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-primary btn-lg w-full justify-center mt-1 disabled:opacity-60 disabled:cursor-not-allowed disabled:transform-none"
|
|
||||||
disabled={pending || disabled}
|
|
||||||
aria-busy={pending}
|
|
||||||
>
|
|
||||||
{pending ? (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className="w-[18px] h-[18px] border-2 border-white/30 border-t-white rounded-full animate-[spin_0.7s_linear_infinite] shrink-0"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
Generando tu presupuesto...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Generar mi presupuesto'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FotosUploader({
|
|
||||||
action,
|
|
||||||
}: {
|
|
||||||
action: (formData: FormData) => void | Promise<void>;
|
|
||||||
}) {
|
|
||||||
const [previews, setPreviews] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const handleFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const files = Array.from(e.target.files ?? []).slice(0, MAX_FOTOS);
|
|
||||||
previews.forEach((url) => URL.revokeObjectURL(url));
|
|
||||||
setPreviews(files.map((f) => URL.createObjectURL(f)));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form action={action} className="flex flex-col gap-5">
|
|
||||||
{/* Fotos */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="fotos" className="text-sm font-semibold text-dark">
|
|
||||||
Sube fotos del espacio <span className="text-gray-400 font-normal">(hasta {MAX_FOTOS})</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="fotos"
|
|
||||||
name="fotos"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
onChange={handleFiles}
|
|
||||||
className="block w-full text-sm text-gray-600 file:mr-4 file:py-2.5 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-black file:text-white hover:file:bg-gray-800 file:cursor-pointer cursor-pointer"
|
|
||||||
/>
|
|
||||||
{previews.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-3 mt-2">
|
|
||||||
{previews.map((url, i) => (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img
|
|
||||||
key={i}
|
|
||||||
src={url}
|
|
||||||
alt=""
|
|
||||||
className="w-20 h-20 object-cover rounded-lg border border-gray-200"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tipo de reforma */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="tipoReforma" className="text-sm font-semibold text-dark">
|
|
||||||
¿Qué quieres reformar?
|
|
||||||
</label>
|
|
||||||
<select id="tipoReforma" name="tipoReforma" defaultValue="cocina" className={inputClass}>
|
|
||||||
{TIPOS.map((t) => (
|
|
||||||
<option key={t} value={t}>
|
|
||||||
{TIPO_LABEL[t]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* m2 + calidad */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="m2" className="text-sm font-semibold text-dark">
|
|
||||||
Metros cuadrados <span className="text-gray-400 font-normal">(aprox.)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="m2"
|
|
||||||
name="m2"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder="12"
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="calidad" className="text-sm font-semibold text-dark">
|
|
||||||
Nivel de acabado
|
|
||||||
</label>
|
|
||||||
<select id="calidad" name="calidad" defaultValue="media" className={inputClass}>
|
|
||||||
{CALIDADES.map((c) => (
|
|
||||||
<option key={c.value} value={c.value}>
|
|
||||||
{c.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Provincia */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="provincia" className="text-sm font-semibold text-dark">
|
|
||||||
Provincia
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="provincia"
|
|
||||||
name="provincia"
|
|
||||||
type="text"
|
|
||||||
placeholder="Madrid"
|
|
||||||
autoComplete="address-level1"
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Urgencia + presupuesto objetivo */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="urgencia" className="text-sm font-semibold text-dark">
|
|
||||||
¿Para cuándo?
|
|
||||||
</label>
|
|
||||||
<select id="urgencia" name="urgencia" defaultValue="media" className={inputClass}>
|
|
||||||
{URGENCIAS.map((u) => (
|
|
||||||
<option key={u.value} value={u.value}>
|
|
||||||
{u.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="presupuestoTarget" className="text-sm font-semibold text-dark">
|
|
||||||
Presupuesto objetivo <span className="text-gray-400 font-normal">(opcional, €)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="presupuestoTarget"
|
|
||||||
name="presupuestoTarget"
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="100"
|
|
||||||
inputMode="numeric"
|
|
||||||
placeholder="8000"
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cambios estructurales */}
|
|
||||||
<label className="flex items-center gap-3 text-sm font-medium text-dark cursor-pointer">
|
|
||||||
<input type="checkbox" name="estructural" className="w-4 h-4 accent-black" />
|
|
||||||
Hay que mover sanitarios, tirar algún muro o cambiar la distribución
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{/* Bloque abierto de gustos */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label htmlFor="tasteText" className="text-sm font-semibold text-dark">
|
|
||||||
Cuéntanos cómo lo imaginas
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
id="tasteText"
|
|
||||||
name="tasteText"
|
|
||||||
rows={4}
|
|
||||||
placeholder="Estilo, colores, materiales que te gusten… y cualquier capricho que no quieras que falte (una isla, ducha de obra, encimera de cuarzo…)."
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SubmitButton disabled={false} />
|
|
||||||
<p className="text-xs text-gray-400 text-center">
|
|
||||||
Calculamos un presupuesto orientativo con tus datos. Sin compromiso.
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user