Merge branch 'main' of https://github.com/McGregory99/reformix-hackaton
4
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
*.ps1
|
||||
zips/
|
||||
.playwright-mcp/
|
||||
.claude/
|
||||
|
||||
node_modules/
|
||||
.next/
|
||||
next-env.d.ts
|
||||
next-env.d.ts
|
||||
|
||||
@@ -1508,7 +1508,7 @@ h3, h4, h5, h6 {
|
||||
<a href="#faq">Preguntas</a>
|
||||
</nav>
|
||||
<div class="topbar-actions">
|
||||
<a href="#" class="btn btn-ghost btn-sm">Entrar</a>
|
||||
<a href="/login" class="btn btn-ghost btn-sm">Entrar</a>
|
||||
<a href="/signup" class="btn btn-primary btn-sm">Empezar gratis</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
33
mvp/b2c/drizzle/0006_aspiring_susan_delgado.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
CREATE TYPE "public"."testimonio_estado" AS ENUM('pendiente', 'publicado', 'oculto');--> statement-breakpoint
|
||||
CREATE TABLE "testimonio_fotos" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"testimonio_id" uuid NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"orden" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "testimonios" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tenant_id" uuid NOT NULL,
|
||||
"lead_id" uuid,
|
||||
"nombre" text NOT NULL,
|
||||
"contexto" text,
|
||||
"rating" integer NOT NULL,
|
||||
"texto" text NOT NULL,
|
||||
"estado" "testimonio_estado" DEFAULT 'pendiente' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "leads" ADD COLUMN "testimonio_solicitado_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "tenants" ADD COLUMN "seo_title" text;--> statement-breakpoint
|
||||
ALTER TABLE "tenants" ADD COLUMN "seo_description" text;--> statement-breakpoint
|
||||
ALTER TABLE "tenants" ADD COLUMN "about_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "tenants" ADD COLUMN "about_foto_url" text;--> statement-breakpoint
|
||||
ALTER TABLE "tenants" ADD COLUMN "about_texto" text;--> statement-breakpoint
|
||||
ALTER TABLE "tenants" ADD COLUMN "anios_experiencia" integer;--> statement-breakpoint
|
||||
ALTER TABLE "testimonio_fotos" ADD CONSTRAINT "testimonio_fotos_testimonio_id_testimonios_id_fk" FOREIGN KEY ("testimonio_id") REFERENCES "public"."testimonios"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "testimonios" ADD CONSTRAINT "testimonios_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "testimonios" ADD CONSTRAINT "testimonios_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "testimonios_tenant_estado_idx" ON "testimonios" USING btree ("tenant_id","estado");--> statement-breakpoint
|
||||
CREATE INDEX "testimonios_lead_idx" ON "testimonios" USING btree ("lead_id");
|
||||
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");
|
||||
1488
mvp/b2c/drizzle/meta/0006_snapshot.json
Normal file
1583
mvp/b2c/drizzle/meta/0007_snapshot.json
Normal file
@@ -43,6 +43,20 @@
|
||||
"when": 1780237037524,
|
||||
"tag": "0005_tearful_maverick",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1780308810691,
|
||||
"tag": "0006_aspiring_susan_delgado",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1780313493522,
|
||||
"tag": "0007_pale_chat",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,10 +4,17 @@ const nextConfig: NextConfig = {
|
||||
// @react-pdf/renderer usa módulos nativos/wasm (yoga, fontkit) que no deben bundlearse.
|
||||
serverExternalPackages: ['@react-pdf/renderer'],
|
||||
async rewrites() {
|
||||
return [
|
||||
// Landing B2B estática (mvp/b2b) servida en /b2b. El fichero vive en public/b2b.html.
|
||||
{ source: "/b2b", destination: "/b2b.html" },
|
||||
];
|
||||
// beforeFiles: estas reglas ganan a las rutas del filesystem (incluida [slug]).
|
||||
// La raíz y /b2b sirven la landing B2B estática (public/b2b.html); cada reformista
|
||||
// tiene su funnel en /{slug} vía app/[slug]/page.tsx.
|
||||
return {
|
||||
beforeFiles: [
|
||||
{ source: "/", destination: "/b2b.html" },
|
||||
{ source: "/b2b", destination: "/b2b.html" },
|
||||
],
|
||||
afterFiles: [],
|
||||
fallback: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
21
mvp/b2c/package-lock.json
generated
@@ -17,6 +17,7 @@
|
||||
"postgres": "^3.4.9",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -7209,6 +7210,12 @@
|
||||
"svg-arc-to-cubic-bezier": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -7635,6 +7642,20 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop": {
|
||||
"version": "5.5.7",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-5.5.7.tgz",
|
||||
"integrity": "sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"normalize-wheel": "^1.0.1",
|
||||
"tslib": "^2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.0",
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"postgres": "^3.4.9",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-easy-crop": "^5.5.7",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
BIN
mvp/b2c/public/b2b-assets/img/antes-bano.webp
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
mvp/b2c/public/b2b-assets/img/antes-comedor.webp
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
mvp/b2c/public/b2b-assets/img/antes.webp
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
mvp/b2c/public/b2b-assets/img/despues-bano.webp
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
mvp/b2c/public/b2b-assets/img/despues-comedor.webp
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
mvp/b2c/public/b2b-assets/img/despues.webp
Normal file
|
After Width: | Height: | Size: 153 KiB |
4
mvp/b2c/public/b2b-assets/img/icon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<rect width="64" height="64" rx="14" fill="#2F5C46"/>
|
||||
<text x="32" y="48" text-anchor="middle" font-family="Georgia, 'Instrument Serif', 'Times New Roman', serif" font-style="italic" font-size="48" fill="#F6F4EF">R</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 317 B |
68
mvp/b2c/src/app/[slug]/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Hero from '@/components/Hero/Hero';
|
||||
import ReformaSlider from '@/components/ReformaSlider/ReformaSlider';
|
||||
import Features from '@/components/Features/Features';
|
||||
import QuienesSomos from '@/components/funnel/QuienesSomos';
|
||||
import TestimoniosCliente from '@/components/funnel/TestimoniosCliente';
|
||||
import GaleriaTrabajos from '@/components/funnel/GaleriaTrabajos';
|
||||
import Footer from '@/components/Footer/Footer';
|
||||
import TenantBrand from '@/components/funnel/TenantBrand';
|
||||
import { getTenantBySlug, getPublishedTestimonios, getGaleria } from '@/lib/funnel/public-queries';
|
||||
import { resolveTheme, themeStyle } from '@/lib/funnel/themes';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const tenant = await getTenantBySlug(slug);
|
||||
if (!tenant) return { title: 'Reforma no encontrada' };
|
||||
return {
|
||||
title: tenant.seoTitle ?? `${tenant.nombreEmpresa} · Presupuesto de reforma`,
|
||||
description:
|
||||
tenant.seoDescription ??
|
||||
`Pide tu presupuesto de reforma a ${tenant.nombreEmpresa}. Render IA y presupuesto orientativo en minutos.`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function FunnelPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params;
|
||||
const tenant = await getTenantBySlug(slug);
|
||||
if (!tenant) notFound();
|
||||
|
||||
const [testimonios, galeria] = await Promise.all([
|
||||
getPublishedTestimonios(tenant.id),
|
||||
getGaleria(tenant.id),
|
||||
]);
|
||||
|
||||
const theme = resolveTheme(tenant.themePreset, tenant.themeColor);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={theme.heading === 'serif' ? 'theme-serif' : undefined}
|
||||
style={themeStyle(tenant.themePreset, tenant.themeColor)}
|
||||
>
|
||||
<TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} showLogin />
|
||||
<main id="main-content">
|
||||
<Hero slug={tenant.slug} />
|
||||
<ReformaSlider />
|
||||
<Features />
|
||||
{tenant.aboutEnabled && tenant.aboutTexto && (
|
||||
<QuienesSomos
|
||||
nombreEmpresa={tenant.nombreEmpresa}
|
||||
fotoUrl={tenant.aboutFotoUrl}
|
||||
texto={tenant.aboutTexto}
|
||||
aniosExperiencia={tenant.aniosExperiencia}
|
||||
/>
|
||||
)}
|
||||
<GaleriaTrabajos fotos={galeria} nombreEmpresa={tenant.nombreEmpresa} />
|
||||
{testimonios.length > 0 && <TestimoniosCliente testimonios={testimonios} />}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,24 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Instrument Serif (display) — usada por los presets de tema con titulares serif.
|
||||
Los .woff2 viven en /public/b2b-assets/fonts. */
|
||||
@font-face {
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/b2b-assets/fonts/421ba28b-7abe-4b86-a87c-fcd3e94378f7.woff2') format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Instrument Serif';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/b2b-assets/fonts/15d36112-e39a-4059-ae12-06c58a5747ac.woff2') format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* Colors */
|
||||
--color-black: #0a0a0a;
|
||||
@@ -25,6 +44,15 @@
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-display: 'Instrument Serif', Georgia, 'Times New Roman', serif;
|
||||
|
||||
/* Paleta de marca B2B "Architectural Warmth" — panel y área autenticada */
|
||||
--color-primary-50: #f4f8f5;
|
||||
--color-primary-100: #e8f0eb;
|
||||
--color-primary-500: #4d8a6d;
|
||||
--color-primary-700: #2f5c46;
|
||||
--color-primary-900: #1f3a2e;
|
||||
--color-stone-50: #f7f8f7;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms ease;
|
||||
@@ -111,4 +139,23 @@
|
||||
.badge-accent {
|
||||
@apply bg-accent-light text-accent;
|
||||
}
|
||||
|
||||
/* Botón con el color de marca del reformista (tema de la landing). */
|
||||
.btn-brand {
|
||||
background-color: var(--brand, #0a0a0a);
|
||||
color: var(--brand-contrast, #ffffff);
|
||||
border: 2px solid var(--brand, #0a0a0a);
|
||||
}
|
||||
.btn-brand:hover {
|
||||
background-color: var(--brand-dark, #1a1a1a);
|
||||
border-color: var(--brand-dark, #1a1a1a);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Presets de tema con titulares en serif (terracota, arena). */
|
||||
.theme-serif :is(h1, h2, h3) {
|
||||
font-family: 'Instrument Serif', Georgia, 'Times New Roman', serif;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
@@ -1,27 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useActionState } from 'react';
|
||||
import { login } from './actions';
|
||||
import AuthShell from '@/components/auth/AuthShell';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [error, formAction, pending] = useActionState(login, null);
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
|
||||
<form action={formAction} className="w-full max-w-sm bg-white border border-gray-200 rounded-xl p-8 flex flex-col gap-4">
|
||||
<h1 className="text-xl font-black tracking-tight text-black">Entra en tu panel</h1>
|
||||
<AuthShell
|
||||
photo="/despues.webp"
|
||||
photoAlt="Cocina reformada"
|
||||
caption="Tus leads, ya cualificados."
|
||||
captionSub="Render IA, presupuesto orientativo y datos del cliente. Todo en un panel."
|
||||
>
|
||||
<form action={formAction} className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl tracking-tight text-black">Entra en tu panel</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">Gestiona tus leads y tu funnel.</p>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-gray-700">Email</span>
|
||||
<input name="email" type="email" required className="border border-gray-300 rounded-md px-3 py-2" />
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className="rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary-700 focus:outline-none focus:ring-1 focus:ring-primary-700"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-sm">
|
||||
<span className="font-medium text-gray-700">Contraseña</span>
|
||||
<input name="password" type="password" required className="border border-gray-300 rounded-md px-3 py-2" />
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary-700 focus:outline-none focus:ring-1 focus:ring-primary-700"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button type="submit" disabled={pending} className="bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
|
||||
|
||||
<button type="submit" disabled={pending} className="btn bg-primary-700 text-white hover:bg-primary-900 w-full disabled:opacity-60">
|
||||
{pending ? 'Entrando…' : 'Entrar'}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
¿No tienes cuenta?{' '}
|
||||
<Link href="/signup" className="font-semibold text-primary-700 hover:underline">
|
||||
Empieza gratis
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
50
mvp/b2c/src/app/opinion/[id]/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getLeadForReview } from '@/lib/funnel/public-queries';
|
||||
import { enviarOpinion } from '../actions';
|
||||
import OpinionForm from '@/components/funnel/OpinionForm';
|
||||
import TenantBrand from '@/components/funnel/TenantBrand';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function OpinionPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const data = await getLeadForReview(id);
|
||||
if (!data || !data.tenant) notFound();
|
||||
|
||||
const { lead, tenant, yaEnviado } = data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TenantBrand
|
||||
nombreEmpresa={tenant.nombreEmpresa}
|
||||
logoUrl={tenant.logoUrl}
|
||||
subtitle="Tu opinión"
|
||||
/>
|
||||
<div className="container py-10 max-w-2xl flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-black tracking-tight text-black">
|
||||
{lead.nombre.split(' ')[0]}, ¿qué tal fue tu reforma con {tenant.nombreEmpresa}?
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 leading-relaxed">
|
||||
Tu opinión nos ayuda muchísimo. Cuéntanos cómo fue y, si quieres, sube alguna foto del
|
||||
resultado.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{yaEnviado ? (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-8 text-center flex flex-col gap-3">
|
||||
<span className="text-4xl" aria-hidden="true">
|
||||
✅
|
||||
</span>
|
||||
<h2 className="text-xl font-black text-black">Ya hemos recibido tu opinión</h2>
|
||||
<p className="text-sm text-gray-500">¡Gracias por tomarte el tiempo!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 md:p-8 shadow-sm">
|
||||
<OpinionForm action={enviarOpinion.bind(null, id)} nombreCliente={lead.nombre} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
mvp/b2c/src/app/opinion/actions.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
'use server';
|
||||
|
||||
import { db } from '@/db';
|
||||
import { testimonios, testimonioFotos } from '@/db/schema';
|
||||
import { getLeadForReview } from '@/lib/funnel/public-queries';
|
||||
|
||||
const MAX_FOTOS = 4;
|
||||
const MAX_FOTO_BYTES = 6 * 1024 * 1024; // 6 MB por foto
|
||||
|
||||
export type EnviarOpinionResult = { ok: boolean; error?: string };
|
||||
|
||||
async function fileToDataUri(file: File): Promise<string | null> {
|
||||
if (file.size === 0 || file.size > MAX_FOTO_BYTES) return null;
|
||||
if (!file.type.startsWith('image/')) return null;
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
return `data:${file.type};base64,${buffer.toString('base64')}`;
|
||||
}
|
||||
|
||||
// El cliente final deja su opinión desde el funnel de review (/opinion/[id]).
|
||||
// Entra como 'pendiente'; el reformista la aprueba antes de que salga en su landing.
|
||||
export async function enviarOpinion(
|
||||
leadId: string,
|
||||
_prev: EnviarOpinionResult | null,
|
||||
formData: FormData
|
||||
): Promise<EnviarOpinionResult> {
|
||||
const data = await getLeadForReview(leadId);
|
||||
if (!data || !data.tenant) {
|
||||
return { ok: false, error: 'No hemos podido identificar tu solicitud.' };
|
||||
}
|
||||
if (data.yaEnviado) {
|
||||
return { ok: false, error: 'Ya hemos recibido tu opinión. ¡Gracias!' };
|
||||
}
|
||||
|
||||
const rating = Number(formData.get('rating'));
|
||||
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
|
||||
return { ok: false, error: 'Selecciona una valoración de 1 a 5 estrellas.' };
|
||||
}
|
||||
|
||||
const texto = String(formData.get('texto') ?? '').trim();
|
||||
if (texto.length < 10) {
|
||||
return { ok: false, error: 'Cuéntanos un poco más sobre tu experiencia (mínimo 10 caracteres).' };
|
||||
}
|
||||
|
||||
const nombre = String(formData.get('nombre') ?? '').trim() || data.lead.nombre;
|
||||
const contexto = String(formData.get('contexto') ?? '').trim() || null;
|
||||
|
||||
const archivos = formData.getAll('fotos').filter((f): f is File => f instanceof File);
|
||||
const dataUris: string[] = [];
|
||||
for (const file of archivos.slice(0, MAX_FOTOS)) {
|
||||
const uri = await fileToDataUri(file);
|
||||
if (uri) dataUris.push(uri);
|
||||
}
|
||||
|
||||
const [testimonio] = await db
|
||||
.insert(testimonios)
|
||||
.values({
|
||||
tenantId: data.tenant.id,
|
||||
leadId,
|
||||
nombre,
|
||||
contexto,
|
||||
rating,
|
||||
texto,
|
||||
estado: 'pendiente',
|
||||
})
|
||||
.returning({ id: testimonios.id });
|
||||
|
||||
if (dataUris.length > 0) {
|
||||
await db.insert(testimonioFotos).values(
|
||||
dataUris.map((url, orden) => ({ testimonioId: testimonio.id, url, orden }))
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
12
mvp/b2c/src/app/opinion/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export default function OpinionLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
<main className="flex-1">{children}</main>
|
||||
<footer className="border-t border-gray-200 bg-white">
|
||||
<div className="container py-6 text-xs text-gray-400 text-center">
|
||||
Tu opinión ayuda a otros clientes a decidir con confianza.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import Navbar from '@/components/Navbar/Navbar';
|
||||
import Hero from '@/components/Hero/Hero';
|
||||
import ReformaSlider from '@/components/ReformaSlider/ReformaSlider';
|
||||
import Features from '@/components/Features/Features';
|
||||
import Testimonials from '@/components/Testimonials/Testimonials';
|
||||
import ContactForm from '@/components/ContactForm/ContactForm';
|
||||
import Footer from '@/components/Footer/Footer';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
{/* <Navbar /> */}
|
||||
<main id="main-content">
|
||||
<Hero />
|
||||
<ReformaSlider />
|
||||
<Features />
|
||||
<Testimonials />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import Link from 'next/link';
|
||||
import { headers } from 'next/headers';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getLead } from '@/db/queries';
|
||||
import EstadoControl from '@/components/panel/EstadoControl';
|
||||
import ConceptosEditor from '@/components/panel/ConceptosEditor';
|
||||
import OpinionLinkBox from '@/components/panel/OpinionLinkBox';
|
||||
import {
|
||||
PIPELINE_LABEL,
|
||||
PIPELINE_NEXT,
|
||||
@@ -11,7 +13,7 @@ import {
|
||||
formatEuros,
|
||||
formatFecha,
|
||||
} from '@/lib/funnel';
|
||||
import { recalcularPresupuesto, enviarPresupuesto } from '../actions';
|
||||
import { recalcularPresupuesto, enviarPresupuesto, solicitarOpinion } from '../actions';
|
||||
import type { BudgetResult } from '@/budget/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -38,9 +40,14 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
const prefs = lead.preferencesSnapshot as import('@/lib/voice/preferences').AbstractedPreferences | null;
|
||||
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
|
||||
|
||||
const h = await headers();
|
||||
const host = h.get('x-forwarded-host') ?? h.get('host') ?? 'reformix.dv3.com.es';
|
||||
const proto = h.get('x-forwarded-proto') ?? 'https';
|
||||
const opinionUrl = `${proto}://${host}/opinion/${lead.id}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Link href="/panel" className="text-sm text-gray-500 hover:text-black w-fit">
|
||||
<Link href="/panel" className="text-sm text-gray-500 hover:text-primary-700 w-fit">
|
||||
← Volver a leads
|
||||
</Link>
|
||||
|
||||
@@ -48,7 +55,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-black tracking-tight text-black">{lead.nombre}</h1>
|
||||
<h1 className="font-display text-3xl tracking-tight text-black">{lead.nombre}</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma'} ·{' '}
|
||||
{lead.provincia ?? '—'} · entró {formatFecha(lead.createdAt)}
|
||||
@@ -66,6 +73,42 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Solicitar opinión al cliente */}
|
||||
<Section title="Opinión del cliente">
|
||||
{lead.testimonioSolicitadoAt ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-gray-500">
|
||||
Solicitada el {formatFecha(lead.testimonioSolicitadoAt)}. Comparte este enlace con el
|
||||
cliente para que deje su opinión. Cuando la envíe, la verás en{' '}
|
||||
<Link href="/panel/opiniones" className="text-primary-700 underline underline-offset-2">
|
||||
Opiniones
|
||||
</Link>{' '}
|
||||
para aprobarla.
|
||||
</p>
|
||||
<OpinionLinkBox url={opinionUrl} />
|
||||
</div>
|
||||
) : lead.estado === 'ganado' ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-gray-500">
|
||||
Si la reforma ha ido bien, pídele su opinión. Generamos un enlace para que valore y
|
||||
suba fotos del resultado.
|
||||
</p>
|
||||
<form action={solicitarOpinion.bind(null, lead.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-700 text-white text-sm font-semibold w-fit hover:bg-primary-900"
|
||||
>
|
||||
Solicitar opinión al cliente
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">
|
||||
Disponible cuando marques este lead como <span className="font-medium">ganado</span>.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Timeline del funnel */}
|
||||
<Section title="Progreso en el funnel">
|
||||
<ol className="flex flex-col gap-2">
|
||||
@@ -211,7 +254,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href={`/panel/${lead.id}/presupuesto?download=1`}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-700 text-white text-sm font-semibold w-fit hover:bg-primary-900"
|
||||
>
|
||||
Descargar PDF
|
||||
</a>
|
||||
@@ -219,7 +262,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
href={`/panel/${lead.id}/presupuesto`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="text-sm font-medium text-gray-500 hover:text-black"
|
||||
className="text-sm font-medium text-gray-500 hover:text-primary-700"
|
||||
>
|
||||
Ver en el navegador
|
||||
</a>
|
||||
@@ -277,7 +320,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
<form action={recalcularPresupuesto.bind(null, lead.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary-700 text-white text-sm font-semibold w-fit hover:bg-primary-900"
|
||||
>
|
||||
Recalcular desde el catálogo
|
||||
</button>
|
||||
|
||||
@@ -129,6 +129,18 @@ export async function enviarPresupuesto(leadId: string) {
|
||||
revalidatePath(`/panel/${leadId}`);
|
||||
}
|
||||
|
||||
export async function solicitarOpinion(leadId: string) {
|
||||
const tenantId = await getTenantId();
|
||||
const [updated] = await db
|
||||
.update(leads)
|
||||
.set({ testimonioSolicitadoAt: new Date(), updatedAt: new Date() })
|
||||
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
|
||||
.returning({ id: leads.id });
|
||||
if (!updated) throw new Error('Lead no encontrado.');
|
||||
|
||||
revalidatePath(`/panel/${leadId}`);
|
||||
}
|
||||
|
||||
export async function recalcularPresupuesto(leadId: string) {
|
||||
const tenantId = await getTenantId();
|
||||
const [lead] = await db
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
'use server';
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, eq, ne } from 'drizzle-orm';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { db } from '@/db';
|
||||
import { tenants } from '@/db/schema';
|
||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||
import { validarSlug } from '@/lib/validation/signup';
|
||||
import { THEME_PRESETS, isHexColor, type ThemePresetId } from '@/lib/funnel/themes';
|
||||
|
||||
const LOGO_MAX_BYTES = 500_000;
|
||||
const LOGO_TIPOS = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
|
||||
const ABOUT_FOTO_MAX_BYTES = 1_500_000;
|
||||
const ABOUT_FOTO_TIPOS = ['image/png', 'image/jpeg', 'image/webp'];
|
||||
|
||||
function limpiar(raw: FormDataEntryValue | null): string | null {
|
||||
const s = String(raw ?? '').trim();
|
||||
@@ -20,16 +24,47 @@ export async function actualizarEmpresa(formData: FormData) {
|
||||
if (!nombreEmpresa) {
|
||||
throw new Error('El nombre de la empresa es obligatorio.');
|
||||
}
|
||||
|
||||
const slugRaw = limpiar(formData.get('slug'));
|
||||
if (!slugRaw) {
|
||||
throw new Error('El enlace de tu funnel es obligatorio.');
|
||||
}
|
||||
const validacion = validarSlug(slugRaw);
|
||||
if (!validacion.ok) {
|
||||
throw new Error(validacion.error);
|
||||
}
|
||||
const slug = validacion.slug;
|
||||
|
||||
// El slug es único en todo el sistema; comprobamos que no lo use otro reformista.
|
||||
const [colision] = await db
|
||||
.select({ id: tenants.id })
|
||||
.from(tenants)
|
||||
.where(and(eq(tenants.slug, slug), ne(tenants.id, tenantId)))
|
||||
.limit(1);
|
||||
if (colision) {
|
||||
throw new Error('Ese enlace ya está en uso. Elige otro.');
|
||||
}
|
||||
|
||||
const aniosRaw = Number(formData.get('aniosExperiencia'));
|
||||
const aniosExperiencia =
|
||||
Number.isInteger(aniosRaw) && aniosRaw > 0 && aniosRaw <= 100 ? aniosRaw : null;
|
||||
|
||||
await db
|
||||
.update(tenants)
|
||||
.set({
|
||||
nombreEmpresa,
|
||||
slug,
|
||||
cif: limpiar(formData.get('cif')),
|
||||
direccion: limpiar(formData.get('direccion')),
|
||||
provincia: limpiar(formData.get('provincia')),
|
||||
telefono: limpiar(formData.get('telefono')),
|
||||
email: limpiar(formData.get('email')),
|
||||
web: limpiar(formData.get('web')),
|
||||
seoTitle: limpiar(formData.get('seoTitle')),
|
||||
seoDescription: limpiar(formData.get('seoDescription')),
|
||||
aboutEnabled: formData.get('aboutEnabled') === 'on',
|
||||
aboutTexto: limpiar(formData.get('aboutTexto')),
|
||||
aniosExperiencia,
|
||||
})
|
||||
.where(eq(tenants.id, tenantId));
|
||||
revalidatePath('/panel/empresa');
|
||||
@@ -64,3 +99,52 @@ export async function quitarLogo() {
|
||||
revalidatePath('/panel/empresa');
|
||||
revalidatePath('/panel');
|
||||
}
|
||||
|
||||
export async function subirAboutFoto(
|
||||
_prev: LogoResult | null,
|
||||
formData: FormData
|
||||
): Promise<LogoResult> {
|
||||
const tenantId = await getTenantId();
|
||||
const file = formData.get('aboutFoto');
|
||||
if (!(file instanceof File) || file.size === 0) {
|
||||
return { ok: false, error: 'Selecciona un archivo de imagen.' };
|
||||
}
|
||||
if (!ABOUT_FOTO_TIPOS.includes(file.type)) {
|
||||
return { ok: false, error: 'Formato no válido. Usa PNG, JPG o WEBP.' };
|
||||
}
|
||||
if (file.size > ABOUT_FOTO_MAX_BYTES) {
|
||||
return { ok: false, error: 'La foto no puede superar los 1,5 MB.' };
|
||||
}
|
||||
const base64 = Buffer.from(await file.arrayBuffer()).toString('base64');
|
||||
const dataUri = `data:${file.type};base64,${base64}`;
|
||||
await db.update(tenants).set({ aboutFotoUrl: dataUri }).where(eq(tenants.id, tenantId));
|
||||
revalidatePath('/panel/empresa');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function quitarAboutFoto() {
|
||||
const tenantId = await getTenantId();
|
||||
await db.update(tenants).set({ aboutFotoUrl: null }).where(eq(tenants.id, tenantId));
|
||||
revalidatePath('/panel/empresa');
|
||||
}
|
||||
|
||||
// Guarda el tema de la landing del reformista: preset + color personalizado opcional.
|
||||
export async function guardarTema(
|
||||
_prev: LogoResult | null,
|
||||
formData: FormData
|
||||
): Promise<LogoResult> {
|
||||
const tenantId = await getTenantId();
|
||||
const presetRaw = String(formData.get('themePreset') ?? '');
|
||||
const themePreset: ThemePresetId = presetRaw in THEME_PRESETS ? (presetRaw as ThemePresetId) : 'pizarra';
|
||||
|
||||
const usarColor = formData.get('usarColor') === 'on';
|
||||
const colorRaw = String(formData.get('themeColor') ?? '').trim();
|
||||
const themeColor = usarColor && isHexColor(colorRaw) ? colorRaw : null;
|
||||
|
||||
await db
|
||||
.update(tenants)
|
||||
.set({ themePreset, themeColor })
|
||||
.where(eq(tenants.id, tenantId));
|
||||
revalidatePath('/panel/empresa');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { headers } from 'next/headers';
|
||||
import { getTenantPerfil } from '@/db/tenant-queries';
|
||||
import { actualizarEmpresa } from './actions';
|
||||
import LogoUploader from '@/components/panel/LogoUploader';
|
||||
import AboutFotoUploader from '@/components/panel/AboutFotoUploader';
|
||||
import ThemePicker from '@/components/panel/ThemePicker';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function EmpresaPage() {
|
||||
const perfil = await getTenantPerfil();
|
||||
|
||||
const h = await headers();
|
||||
const host = h.get('x-forwarded-host') ?? h.get('host') ?? 'reformix.dv3.com.es';
|
||||
const proto = h.get('x-forwarded-proto') ?? 'https';
|
||||
const funnelUrl = `${proto}://${host}/${perfil.slug}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-10 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-extrabold tracking-tight text-black">Datos de empresa</h1>
|
||||
<h1 className="font-display text-3xl tracking-tight text-black">Datos de empresa</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Estos datos y el logo aparecen en la cabecera de los presupuestos en PDF que recibe el
|
||||
cliente. Manténlos al día.
|
||||
@@ -34,6 +42,32 @@ export default async function EmpresaPage() {
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm md:col-span-2">
|
||||
<span className="block text-gray-500 mb-1">Enlace de tu funnel *</span>
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||
<span className="px-3 py-2 text-gray-400 bg-gray-50 border-r border-gray-200 select-none whitespace-nowrap">
|
||||
{host}/
|
||||
</span>
|
||||
<input
|
||||
name="slug"
|
||||
required
|
||||
defaultValue={perfil.slug}
|
||||
pattern="[a-z0-9-]+"
|
||||
className="flex-1 px-3 py-2 outline-none min-w-0"
|
||||
/>
|
||||
</div>
|
||||
<span className="block text-gray-400 mt-1.5 text-xs">
|
||||
Esta es la dirección que compartes con tus clientes:{' '}
|
||||
<a
|
||||
href={funnelUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-700 underline underline-offset-2 break-all"
|
||||
>
|
||||
{funnelUrl}
|
||||
</a>
|
||||
</span>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="block text-gray-500 mb-1">CIF / NIF</span>
|
||||
<input
|
||||
@@ -83,11 +117,96 @@ export default async function EmpresaPage() {
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<button className="md:col-span-2 justify-self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||
|
||||
<div className="md:col-span-2 border-t border-gray-100 pt-5 mt-1">
|
||||
<h3 className="font-bold text-black">SEO de tu funnel</h3>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Personaliza cómo aparece tu página en Google y al compartirla. Si lo dejas vacío,
|
||||
usamos un texto por defecto con el nombre de tu empresa.
|
||||
</p>
|
||||
</div>
|
||||
<label className="text-sm md:col-span-2">
|
||||
<span className="block text-gray-500 mb-1">Título (title)</span>
|
||||
<input
|
||||
name="seoTitle"
|
||||
defaultValue={perfil.seoTitle ?? ''}
|
||||
maxLength={70}
|
||||
placeholder={`${perfil.nombreEmpresa} · Presupuesto de reforma`}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm md:col-span-2">
|
||||
<span className="block text-gray-500 mb-1">Descripción (meta description)</span>
|
||||
<textarea
|
||||
name="seoDescription"
|
||||
defaultValue={perfil.seoDescription ?? ''}
|
||||
maxLength={160}
|
||||
rows={2}
|
||||
placeholder="Pide tu presupuesto de reforma con render IA en minutos."
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="md:col-span-2 border-t border-gray-100 pt-5 mt-1">
|
||||
<h3 className="font-bold text-black">Bloque “Quiénes somos”</h3>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Si lo activas, en tu funnel aparece un bloque con tu foto, tu historia y tus años de
|
||||
experiencia.
|
||||
</p>
|
||||
</div>
|
||||
<label className="text-sm md:col-span-2 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="aboutEnabled"
|
||||
defaultChecked={perfil.aboutEnabled}
|
||||
className="w-4 h-4 accent-[#2f5c46]"
|
||||
/>
|
||||
<span className="text-gray-700">Mostrar el bloque “Quiénes somos” en mi funnel</span>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
<span className="block text-gray-500 mb-1">Años de experiencia</span>
|
||||
<input
|
||||
name="aniosExperiencia"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
defaultValue={perfil.aniosExperiencia ?? ''}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm md:col-span-2">
|
||||
<span className="block text-gray-500 mb-1">Texto de presentación</span>
|
||||
<textarea
|
||||
name="aboutTexto"
|
||||
defaultValue={perfil.aboutTexto ?? ''}
|
||||
rows={5}
|
||||
placeholder="Cuéntale al cliente quién eres, tu trayectoria y por qué confiar en ti."
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button className="md:col-span-2 justify-self-start inline-flex items-center justify-center rounded-lg bg-primary-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-900">
|
||||
Guardar datos
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="font-bold text-black mb-1">Foto de “Quiénes somos”</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Tu foto o la de tu equipo. Aparece en el bloque “Quiénes somos” de tu funnel.
|
||||
</p>
|
||||
<AboutFotoUploader fotoUrl={perfil.aboutFotoUrl} />
|
||||
</section>
|
||||
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="font-bold text-black mb-1">Tema de tu funnel</h2>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Elige los colores y la tipografía con los que tus clientes ven tu landing. Puedes partir
|
||||
de un preset y, si quieres, fijar tu propio color de marca.
|
||||
</p>
|
||||
<ThemePicker themePreset={perfil.themePreset} themeColor={perfil.themeColor} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
56
mvp/b2c/src/app/panel/galeria/actions.ts
Normal file
@@ -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
@@ -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="font-display text-3xl 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,8 @@ import AppNav from '@/components/AppNav';
|
||||
const PANEL_LINKS = [
|
||||
{ href: '/panel', label: 'Leads', icon: 'leads' },
|
||||
{ href: '/panel/precios', label: 'Precios', icon: 'precios' },
|
||||
{ href: '/panel/galeria', label: 'Galería', icon: 'galeria' },
|
||||
{ href: '/panel/opiniones', label: 'Opiniones', icon: 'opiniones' },
|
||||
{ href: '/panel/empresa', label: 'Empresa', icon: 'empresa' },
|
||||
] as const;
|
||||
|
||||
@@ -25,14 +27,14 @@ export default async function PanelLayout({ children }: { children: React.ReactN
|
||||
const nombreEmpresa = tenant?.nombreEmpresa ?? 'Reformix';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<header className="sticky top-0 z-20 bg-white border-b border-gray-200">
|
||||
<div className="relative max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<Link href="/panel" className="flex items-center gap-2 min-w-0">
|
||||
<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-primary-700 text-white font-black italic text-lg leading-none">
|
||||
R
|
||||
</span>
|
||||
<span className="font-extrabold tracking-tight text-black">Reformix</span>
|
||||
<span className="font-bold tracking-tight text-black">Reformix</span>
|
||||
<span className="hidden sm:inline text-gray-300">/</span>
|
||||
<span className="hidden sm:inline text-sm font-medium text-gray-600 truncate">
|
||||
{nombreEmpresa}
|
||||
|
||||
36
mvp/b2c/src/app/panel/opiniones/actions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
'use server';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { db } from '@/db';
|
||||
import { testimonios } from '@/db/schema';
|
||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||
|
||||
type TestimonioEstado = (typeof testimonios.estado.enumValues)[number];
|
||||
|
||||
async function setEstado(testimonioId: string, estado: TestimonioEstado) {
|
||||
const tenantId = await getTenantId();
|
||||
const [updated] = await db
|
||||
.update(testimonios)
|
||||
.set({ estado })
|
||||
.where(and(eq(testimonios.id, testimonioId), eq(testimonios.tenantId, tenantId)))
|
||||
.returning({ id: testimonios.id });
|
||||
if (!updated) throw new Error('Opinión no encontrada.');
|
||||
revalidatePath('/panel/opiniones');
|
||||
}
|
||||
|
||||
export async function publicarTestimonio(testimonioId: string) {
|
||||
await setEstado(testimonioId, 'publicado');
|
||||
}
|
||||
|
||||
export async function ocultarTestimonio(testimonioId: string) {
|
||||
await setEstado(testimonioId, 'oculto');
|
||||
}
|
||||
|
||||
export async function eliminarTestimonio(testimonioId: string) {
|
||||
const tenantId = await getTenantId();
|
||||
await db
|
||||
.delete(testimonios)
|
||||
.where(and(eq(testimonios.id, testimonioId), eq(testimonios.tenantId, tenantId)));
|
||||
revalidatePath('/panel/opiniones');
|
||||
}
|
||||
125
mvp/b2c/src/app/panel/opiniones/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { getTestimoniosPanel, type TestimonioPanel } from '@/db/tenant-queries';
|
||||
import { formatFecha } from '@/lib/funnel';
|
||||
import { publicarTestimonio, ocultarTestimonio, eliminarTestimonio } from './actions';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const ESTADO_BADGE: Record<TestimonioPanel['estado'], { label: string; className: string }> = {
|
||||
pendiente: { label: 'Pendiente', className: 'bg-amber-100 text-amber-700' },
|
||||
publicado: { label: 'Publicado', className: 'bg-green-100 text-green-700' },
|
||||
oculto: { label: 'Oculto', className: 'bg-gray-100 text-gray-500' },
|
||||
};
|
||||
|
||||
function Estrellas({ rating }: { rating: number }) {
|
||||
return (
|
||||
<div className="flex gap-0.5" aria-label={`${rating} de 5 estrellas`}>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<svg
|
||||
key={n}
|
||||
viewBox="0 0 24 24"
|
||||
className={`w-4 h-4 ${n <= rating ? 'fill-yellow-400' : 'fill-gray-200'}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function OpinionesPage() {
|
||||
const testimonios = await getTestimoniosPanel();
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-3xl">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl tracking-tight text-black">Opiniones</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Las opiniones que te dejan tus clientes. Aprueba las que quieras mostrar en tu funnel; solo
|
||||
las publicadas aparecen en tu página.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{testimonios.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-sm text-gray-400">
|
||||
Aún no tienes opiniones. Solicítalas a tus clientes desde la ficha de un lead ganado.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-4">
|
||||
{testimonios.map((t) => {
|
||||
const badge = ESTADO_BADGE[t.estado];
|
||||
return (
|
||||
<li
|
||||
key={t.id}
|
||||
className="bg-white rounded-xl border border-gray-200 p-5 flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-black">{t.nombre}</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-semibold ${badge.className}`}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
{t.contexto && <span className="text-xs text-gray-500">{t.contexto}</span>}
|
||||
</div>
|
||||
<Estrellas rating={t.rating} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-700 leading-relaxed italic">{t.texto}</p>
|
||||
|
||||
{t.fotos.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{t.fotos.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 className="flex flex-wrap items-center gap-2 pt-2 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-400 mr-auto">{formatFecha(t.createdAt)}</span>
|
||||
{t.estado !== 'publicado' && (
|
||||
<form action={publicarTestimonio.bind(null, t.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-3 py-1.5 rounded-lg bg-green-600 text-white text-xs font-semibold hover:bg-green-700"
|
||||
>
|
||||
Publicar
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{t.estado === 'publicado' && (
|
||||
<form action={ocultarTestimonio.bind(null, t.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-3 py-1.5 rounded-lg border border-gray-300 text-gray-700 text-xs font-semibold hover:border-gray-500"
|
||||
>
|
||||
Ocultar
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<form action={eliminarTestimonio.bind(null, t.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-3 py-1.5 rounded-lg text-red-500 text-xs font-semibold hover:underline"
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
import { getLeads, getResumen, type LeadFiltro } from '@/db/queries';
|
||||
import {
|
||||
ESTADOS,
|
||||
ESTADO_BADGE,
|
||||
ESTADO_LABEL,
|
||||
PIPELINE_LABEL,
|
||||
PIPELINE_NEXT,
|
||||
formatEuros,
|
||||
formatFecha,
|
||||
} from '@/lib/funnel';
|
||||
import { ESTADOS, ESTADO_LABEL } from '@/lib/funnel';
|
||||
import LeadsView, { type PanelLead } from '@/components/panel/LeadsView';
|
||||
import { getCurrentTenantId } from '@/lib/auth/current-user';
|
||||
import { db } from '@/db';
|
||||
import { tenants, plans } from '@/db/schema';
|
||||
@@ -32,6 +25,20 @@ export default async function PanelPage({
|
||||
const filtro: LeadFiltro = (FILTROS.find((f) => f.value === estado)?.value ?? 'todos') as LeadFiltro;
|
||||
|
||||
const [leads, resumen] = await Promise.all([getLeads(filtro), getResumen()]);
|
||||
const leadsView: PanelLead[] = leads.map((l) => ({
|
||||
id: l.id,
|
||||
nombre: l.nombre,
|
||||
telefono: l.telefono,
|
||||
provincia: l.provincia,
|
||||
tipoReforma: l.tipoReforma,
|
||||
estado: l.estado,
|
||||
pipelineStage: l.pipelineStage,
|
||||
presupuestoEstimado: l.presupuestoEstimado,
|
||||
renderUrl: l.renderUrl,
|
||||
createdAtMs: l.createdAt.getTime(),
|
||||
m2Suelo: l.m2Suelo,
|
||||
calidadGlobal: l.calidadGlobal,
|
||||
}));
|
||||
|
||||
const tenantId = await getCurrentTenantId();
|
||||
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1);
|
||||
@@ -43,7 +50,7 @@ export default async function PanelPage({
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-black tracking-tight text-black">Leads</h1>
|
||||
<h1 className="font-display text-3xl tracking-tight text-black">Leads</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
{resumen.total} leads en total · {resumen.porEstado['nuevo'] ?? 0} sin contactar
|
||||
</p>
|
||||
@@ -72,7 +79,7 @@ export default async function PanelPage({
|
||||
href={f.value === 'todos' ? '/panel' : `/panel?estado=${f.value}`}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
|
||||
active
|
||||
? 'bg-black text-white border-black'
|
||||
? 'bg-primary-700 text-white border-primary-700'
|
||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
@@ -82,96 +89,7 @@ export default async function PanelPage({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tabla (desktop) */}
|
||||
<div className="hidden md:block bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-400">
|
||||
<th className="px-4 py-3 font-semibold">Render</th>
|
||||
<th className="px-4 py-3 font-semibold">Cliente</th>
|
||||
<th className="px-4 py-3 font-semibold">Fecha</th>
|
||||
<th className="px-4 py-3 font-semibold">Estado</th>
|
||||
<th className="px-4 py-3 font-semibold">Presupuesto</th>
|
||||
<th className="px-4 py-3 font-semibold">Siguiente paso</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leads.map((l) => (
|
||||
<tr key={l.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/panel/${l.id}`} className="block w-16 h-12 rounded-md overflow-hidden bg-gray-100">
|
||||
{l.renderUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={l.renderUrl} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="flex w-full h-full items-center justify-center text-[10px] text-gray-400">
|
||||
sin render
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/panel/${l.id}`} className="font-semibold text-black hover:underline">
|
||||
{l.nombre}
|
||||
</Link>
|
||||
<div className="text-gray-500">{l.telefono}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{formatFecha(l.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-block px-2.5 py-1 rounded-full text-xs font-semibold ${ESTADO_BADGE[l.estado]}`}>
|
||||
{ESTADO_LABEL[l.estado]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-semibold text-black whitespace-nowrap">
|
||||
{formatEuros(l.presupuestoEstimado)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500">
|
||||
<div className="text-xs text-gray-400">{PIPELINE_LABEL[l.pipelineStage]}</div>
|
||||
{PIPELINE_NEXT[l.pipelineStage]}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{leads.length === 0 && (
|
||||
<div className="px-4 py-12 text-center text-gray-400">No hay leads con este estado.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cards (mobile) */}
|
||||
<div className="md:hidden flex flex-col gap-3">
|
||||
{leads.map((l) => (
|
||||
<Link
|
||||
key={l.id}
|
||||
href={`/panel/${l.id}`}
|
||||
className="bg-white border border-gray-200 rounded-xl p-4 flex gap-3"
|
||||
>
|
||||
<div className="w-16 h-16 rounded-md overflow-hidden bg-gray-100 shrink-0">
|
||||
{l.renderUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={l.renderUrl} alt="" className="w-full h-full object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-semibold text-black truncate">{l.nombre}</span>
|
||||
<span className={`shrink-0 px-2 py-0.5 rounded-full text-[11px] font-semibold ${ESTADO_BADGE[l.estado]}`}>
|
||||
{ESTADO_LABEL[l.estado]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{l.telefono}</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-semibold text-black">{formatEuros(l.presupuestoEstimado)}</span>
|
||||
<span className="text-gray-400">{formatFecha(l.createdAt)}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">{PIPELINE_NEXT[l.pipelineStage]}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{leads.length === 0 && (
|
||||
<div className="px-4 py-12 text-center text-gray-400">No hay leads con este estado.</div>
|
||||
)}
|
||||
</div>
|
||||
<LeadsView leads={leadsView} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default async function PreciosPage() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div>
|
||||
<h1 className="text-2xl font-extrabold tracking-tight text-black">Tabla de precios</h1>
|
||||
<h1 className="font-display text-3xl tracking-tight text-black">Tabla de precios</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Define los precios unitarios y la mano de obra. El motor calcula el presupuesto a
|
||||
partir de estos valores y las medidas del lead.
|
||||
@@ -73,7 +73,7 @@ export default async function PreciosPage() {
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<button className="self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||
<button className="self-start bg-primary-700 text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||
Guardar preferencia
|
||||
</button>
|
||||
</form>
|
||||
@@ -112,7 +112,7 @@ export default async function PreciosPage() {
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<button className="col-span-2 md:col-span-5 justify-self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||
<button className="col-span-2 md:col-span-5 justify-self-start bg-primary-700 text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||
Guardar configuración
|
||||
</button>
|
||||
</form>
|
||||
@@ -212,7 +212,7 @@ export default async function PreciosPage() {
|
||||
<label className="col-span-2 flex items-center gap-2 text-gray-500 sm:col-auto">
|
||||
<input type="checkbox" name="esDefault" /> Marcar como default
|
||||
</label>
|
||||
<button className="col-span-2 bg-black text-white rounded-lg px-3 py-2 font-medium sm:col-auto sm:py-1.5">
|
||||
<button className="col-span-2 bg-primary-700 text-white rounded-lg px-3 py-2 font-medium sm:col-auto sm:py-1.5">
|
||||
Añadir
|
||||
</button>
|
||||
</form>
|
||||
@@ -234,7 +234,7 @@ export default async function PreciosPage() {
|
||||
placeholder="categoria,nombre,calidad,precio,unidad,descriptor_render,sku"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 font-mono text-xs"
|
||||
/>
|
||||
<button className="mt-2 bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||
<button className="mt-2 bg-primary-700 text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||
Importar
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,29 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useActionState } from 'react';
|
||||
import { signup } from './actions';
|
||||
import AuthShell from '@/components/auth/AuthShell';
|
||||
|
||||
const inputClass =
|
||||
'rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:border-primary-700 focus:outline-none focus:ring-1 focus:ring-primary-700';
|
||||
|
||||
export default function SignupPage() {
|
||||
const [error, formAction, pending] = useActionState(signup, null);
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-gray-50 px-6 py-12">
|
||||
<form action={formAction} className="w-full max-w-md bg-white border border-gray-200 rounded-xl p-8 flex flex-col gap-4">
|
||||
<h1 className="text-xl font-black tracking-tight text-black">Empieza gratis 14 días</h1>
|
||||
<p className="text-sm text-gray-500">Sin tarjeta. Configura tu catálogo y recibe leads.</p>
|
||||
<input name="nombre" placeholder="Tu nombre" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
|
||||
<input name="empresa" placeholder="Nombre de tu empresa" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
|
||||
<input name="email" type="email" placeholder="Email" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
|
||||
<input name="provincia" placeholder="Provincia" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
|
||||
<input name="password" type="password" placeholder="Contraseña (mín. 8)" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
|
||||
<AuthShell
|
||||
photo="/despues-bano.webp"
|
||||
photoAlt="Baño reformado"
|
||||
caption="Empieza a recibir leads en minutos."
|
||||
captionSub="14 días gratis, sin tarjeta. Configura tu catálogo y comparte tu funnel."
|
||||
>
|
||||
<form action={formAction} className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl tracking-tight text-black">Empieza gratis 14 días</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Sin tarjeta. Configura tu catálogo y recibe leads.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input name="nombre" placeholder="Tu nombre" required autoComplete="name" className={inputClass} />
|
||||
<input
|
||||
name="empresa"
|
||||
placeholder="Nombre de tu empresa"
|
||||
required
|
||||
autoComplete="organization"
|
||||
className={inputClass}
|
||||
/>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
required
|
||||
autoComplete="email"
|
||||
className={inputClass}
|
||||
/>
|
||||
<input name="provincia" placeholder="Provincia" required className={inputClass} />
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Contraseña (mín. 8)"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
className={inputClass}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<input name="optInMarketing" type="checkbox" /> Quiero recibir novedades de Reformix
|
||||
<input name="optInMarketing" type="checkbox" className="h-4 w-4 accent-[#2f5c46]" /> Quiero
|
||||
recibir novedades de Reformix
|
||||
</label>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
<button type="submit" disabled={pending} className="bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
|
||||
|
||||
<button type="submit" disabled={pending} className="btn bg-primary-700 text-white hover:bg-primary-900 w-full disabled:opacity-60">
|
||||
{pending ? 'Creando cuenta…' : 'Crear cuenta'}
|
||||
</button>
|
||||
<a href="/login" className="text-xs text-gray-400 text-center hover:text-black">Ya tengo cuenta</a>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
¿Ya tienes cuenta?{' '}
|
||||
<Link href="/login" className="font-semibold text-primary-700 hover:underline">
|
||||
Entrar
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notFound } from 'next/navigation';
|
||||
import { getPublicLead } from '@/lib/funnel/public-queries';
|
||||
import { PIPELINE_ORDER, PIPELINE_LABEL, TIPO_LABEL, formatEuros } from '@/lib/funnel';
|
||||
import type { BudgetResult } from '@/budget/types';
|
||||
import TenantBrand from '@/components/funnel/TenantBrand';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
@@ -10,7 +11,7 @@ export default async function EstadoPage({ params }: { params: Promise<{ id: str
|
||||
const data = await getPublicLead(id);
|
||||
if (!data) notFound();
|
||||
|
||||
const { lead, eventos } = data;
|
||||
const { lead, tenant, eventos } = data;
|
||||
const reachedStages = new Set(eventos.map((e) => e.stage));
|
||||
|
||||
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
|
||||
@@ -19,7 +20,9 @@ export default async function EstadoPage({ params }: { params: Promise<{ id: str
|
||||
const tipo = lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'tu reforma';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<>
|
||||
{tenant && <TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />}
|
||||
<div className="container py-10 max-w-2xl flex flex-col gap-6">
|
||||
{/* Cabecera de éxito */}
|
||||
<div className="flex flex-col items-center text-center gap-3">
|
||||
<div className="w-14 h-14 bg-black text-white rounded-full flex items-center justify-center">
|
||||
@@ -128,6 +131,7 @@ export default async function EstadoPage({ params }: { params: Promise<{ id: str
|
||||
Presupuesto enviado a tu WhatsApp ✓
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notFound } 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';
|
||||
|
||||
@@ -10,10 +11,12 @@ export default async function FotosPage({ params }: { params: Promise<{ id: stri
|
||||
const data = await getPublicLead(id);
|
||||
if (!data) notFound();
|
||||
|
||||
const { lead } = data;
|
||||
const { lead, tenant } = data;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<>
|
||||
{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
|
||||
@@ -31,5 +34,6 @@ export default async function FotosPage({ params }: { params: Promise<{ id: stri
|
||||
<FotosUploader action={guardarDetallesYFotos.bind(null, id)} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { redirect } from 'next/navigation';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { db } from '@/db';
|
||||
import { leads, leadFotos, leadPipelineEventos } from '@/db/schema';
|
||||
import { getDemoTenantId } from '@/lib/funnel/public-queries';
|
||||
import { getTenantBySlug } from '@/lib/funnel/public-queries';
|
||||
import { procesarLead } from '@/lib/funnel/orchestrator';
|
||||
|
||||
const MAX_FOTOS = 4;
|
||||
@@ -28,7 +28,7 @@ export type CrearLeadResult =
|
||||
| { ok: true; leadId: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export async function crearLead(input: CrearLeadInput): Promise<CrearLeadResult> {
|
||||
export async function crearLead(slug: string, input: CrearLeadInput): Promise<CrearLeadResult> {
|
||||
const parsed = crearLeadSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return { ok: false, error: parsed.error.issues[0]?.message ?? 'Datos inválidos.' };
|
||||
@@ -40,12 +40,16 @@ export async function crearLead(input: CrearLeadInput): Promise<CrearLeadResult>
|
||||
return { ok: false, error: 'Debes aceptar la política de privacidad y las condiciones.' };
|
||||
}
|
||||
|
||||
const tenantId = await getDemoTenantId();
|
||||
// El lead se atribuye al reformista dueño del funnel (slug de la URL pública).
|
||||
const tenant = await getTenantBySlug(slug);
|
||||
if (!tenant) {
|
||||
return { ok: false, error: 'No hemos podido identificar al reformista. Recarga la página.' };
|
||||
}
|
||||
|
||||
const [lead] = await db
|
||||
.insert(leads)
|
||||
.values({
|
||||
tenantId,
|
||||
tenantId: tenant.id,
|
||||
nombre: data.nombre,
|
||||
email: data.email,
|
||||
telefono: data.telefono,
|
||||
@@ -79,14 +83,9 @@ async function fileToDataUri(file: File): Promise<string | null> {
|
||||
// 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.
|
||||
export async function guardarDetallesYFotos(leadId: string, formData: FormData): Promise<void> {
|
||||
const tenantId = await getDemoTenantId();
|
||||
|
||||
const [lead] = await db
|
||||
.select()
|
||||
.from(leads)
|
||||
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
|
||||
.limit(1);
|
||||
const [lead] = await db.select().from(leads).where(eq(leads.id, leadId)).limit(1);
|
||||
if (!lead) throw new Error('Solicitud no encontrada.');
|
||||
const tenantId = lead.tenantId;
|
||||
|
||||
const tipoRaw = String(formData.get('tipoReforma') ?? '');
|
||||
const calidadRaw = String(formData.get('calidad') ?? '');
|
||||
|
||||
@@ -1,24 +1,10 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function SolicitudLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col">
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="container py-4 flex items-center justify-between">
|
||||
<Link href="/" className="text-lg font-black tracking-tight text-black">
|
||||
Reformix
|
||||
</Link>
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
|
||||
Tu presupuesto
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1">
|
||||
<div className="container py-10 max-w-2xl">{children}</div>
|
||||
</main>
|
||||
<main className="flex-1">{children}</main>
|
||||
<footer className="border-t border-gray-200 bg-white">
|
||||
<div className="container py-6 text-xs text-gray-400 text-center">
|
||||
Reformix · Presupuesto orientativo. El precio final puede variar según la visita técnica.
|
||||
Presupuesto orientativo. El precio final puede variar según la visita técnica.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,9 @@ import { usePathname } from 'next/navigation';
|
||||
export type AppNavIcon =
|
||||
| 'leads'
|
||||
| 'precios'
|
||||
| 'galeria'
|
||||
| 'empresa'
|
||||
| 'opiniones'
|
||||
| 'resumen'
|
||||
| 'usuarios'
|
||||
| 'planes';
|
||||
@@ -37,12 +39,24 @@ const ICON_PATHS: Record<AppNavIcon | 'salir', React.ReactNode> = {
|
||||
<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: (
|
||||
<>
|
||||
<path d="M4 21V6l8-3 8 3v15" />
|
||||
<path d="M4 21h16M9 21v-5h6v5M8 9h.01M12 9h.01M16 9h.01M8 13h.01M12 13h.01M16 13h.01" />
|
||||
</>
|
||||
),
|
||||
opiniones: (
|
||||
<>
|
||||
<path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||
</>
|
||||
),
|
||||
resumen: (
|
||||
<>
|
||||
<rect x="3" y="3" width="7" height="9" rx="1" />
|
||||
@@ -102,13 +116,13 @@ export default function AppNav({ links }: { links: readonly AppNavLink[] }) {
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className={active === l.href ? 'text-black font-semibold' : 'text-gray-500 hover:text-black'}
|
||||
className={active === l.href ? 'text-primary-700 font-semibold' : 'text-gray-500 hover:text-primary-700'}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
<form action="/logout" method="post">
|
||||
<button type="submit" className="text-gray-500 hover:text-black">
|
||||
<button type="submit" className="text-gray-500 hover:text-primary-700">
|
||||
Salir
|
||||
</button>
|
||||
</form>
|
||||
@@ -120,7 +134,10 @@ export default function AppNav({ links }: { links: readonly AppNavLink[] }) {
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
aria-label="Navegación principal"
|
||||
>
|
||||
<div className="grid grid-cols-4">
|
||||
<div
|
||||
className="grid"
|
||||
style={{ gridTemplateColumns: `repeat(${links.length + 1}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{links.map((l) => {
|
||||
const isActive = active === l.href;
|
||||
return (
|
||||
@@ -130,11 +147,11 @@ export default function AppNav({ links }: { links: readonly AppNavLink[] }) {
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={
|
||||
'relative flex flex-col items-center justify-center gap-1 pt-2.5 pb-2 text-[11px] font-medium transition-colors ' +
|
||||
(isActive ? 'text-black' : 'text-gray-400 hover:text-gray-600')
|
||||
(isActive ? 'text-primary-700' : 'text-gray-400 hover:text-gray-600')
|
||||
}
|
||||
>
|
||||
{isActive && (
|
||||
<span className="absolute top-0 h-0.5 w-8 rounded-full bg-black" aria-hidden="true" />
|
||||
<span className="absolute top-0 h-0.5 w-8 rounded-full bg-primary-700" aria-hidden="true" />
|
||||
)}
|
||||
<Icon name={l.icon} />
|
||||
<span className="leading-none">{l.label}</span>
|
||||
|
||||
@@ -40,7 +40,7 @@ function validateForm(data: FormData): FormErrors {
|
||||
return errors;
|
||||
}
|
||||
|
||||
export default function ContactForm() {
|
||||
export default function ContactForm({ slug }: { slug: string }) {
|
||||
const [formData, setFormData] = useState<FormData>(initialData);
|
||||
const [consents, setConsents] = useState(initialConsents);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
@@ -99,7 +99,7 @@ export default function ContactForm() {
|
||||
|
||||
setStatus('loading');
|
||||
setSubmitError(null);
|
||||
const result = await crearLead({
|
||||
const result = await crearLead(slug, {
|
||||
nombre: formData.name,
|
||||
email: formData.email,
|
||||
telefono: formData.phone,
|
||||
|
||||
@@ -40,7 +40,7 @@ function validateForm(data: FormData): FormErrors {
|
||||
return errors;
|
||||
}
|
||||
|
||||
function LeadForm() {
|
||||
function LeadForm({ slug }: { slug: string }) {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState<FormData>(initialData);
|
||||
const [consents, setConsents] = useState(initialConsents);
|
||||
@@ -79,7 +79,7 @@ function LeadForm() {
|
||||
|
||||
setStatus('loading');
|
||||
setSubmitError(null);
|
||||
const result = await crearLead({
|
||||
const result = await crearLead(slug, {
|
||||
nombre: formData.name,
|
||||
email: formData.email,
|
||||
telefono: formData.phone,
|
||||
@@ -109,7 +109,10 @@ function LeadForm() {
|
||||
role="alert"
|
||||
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">
|
||||
<path
|
||||
d="M6 16l7 7L26 9"
|
||||
@@ -294,7 +297,7 @@ function LeadForm() {
|
||||
{/* Submit */}
|
||||
<button
|
||||
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}
|
||||
aria-busy={status === 'loading'}
|
||||
aria-disabled={status === 'loading' || !consentsGranted}
|
||||
@@ -326,7 +329,7 @@ function LeadForm() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Hero() {
|
||||
export default function Hero({ slug }: { slug: string }) {
|
||||
const heroRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -370,7 +373,7 @@ export default function Hero() {
|
||||
{/* 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">
|
||||
<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' })}
|
||||
>
|
||||
Calcular mi reforma gratis
|
||||
@@ -393,7 +396,7 @@ export default function Hero() {
|
||||
<h2 className="text-xl font-black tracking-tight text-black">Pide tu presupuesto</h2>
|
||||
<p className="text-sm text-gray-400 mt-1">En menos de 2 minutos te llamamos · Render por WhatsApp</p>
|
||||
</div>
|
||||
<LeadForm />
|
||||
<LeadForm slug={slug} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -433,7 +436,10 @@ export default function Hero() {
|
||||
},
|
||||
].map(({ icon, title, description }) => (
|
||||
<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}
|
||||
</div>
|
||||
<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
@@ -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-primary-700 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
@@ -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>
|
||||
);
|
||||
}
|
||||
169
mvp/b2c/src/components/funnel/OpinionForm.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
import { useFormStatus } from 'react-dom';
|
||||
import type { EnviarOpinionResult } from '@/app/opinion/actions';
|
||||
|
||||
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 Estrella({ filled }: { filled: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`w-9 h-9 ${filled ? 'fill-yellow-400' : 'fill-gray-200'}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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={pending}
|
||||
aria-busy={pending}
|
||||
>
|
||||
{pending ? 'Enviando...' : 'Enviar mi opinión'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OpinionForm({
|
||||
action,
|
||||
nombreCliente,
|
||||
}: {
|
||||
action: (prev: EnviarOpinionResult | null, formData: FormData) => Promise<EnviarOpinionResult>;
|
||||
nombreCliente: string;
|
||||
}) {
|
||||
const [state, formAction] = useActionState(action, null);
|
||||
const [rating, setRating] = useState(0);
|
||||
const [hover, setHover] = useState(0);
|
||||
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)));
|
||||
};
|
||||
|
||||
if (state?.ok) {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-8 text-center flex flex-col gap-3">
|
||||
<span className="text-4xl" aria-hidden="true">
|
||||
🙌
|
||||
</span>
|
||||
<h2 className="text-xl font-black text-black">¡Gracias por tu opinión!</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
La hemos recibido correctamente. Aparecerá en la web del reformista una vez la revise.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-5">
|
||||
<input type="hidden" name="rating" value={rating} />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold text-dark">Tu valoración</span>
|
||||
<div className="flex gap-1" role="radiogroup" aria-label="Valoración de 1 a 5 estrellas">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setRating(n)}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
aria-label={`${n} estrella${n > 1 ? 's' : ''}`}
|
||||
aria-checked={rating === n}
|
||||
role="radio"
|
||||
className="p-0.5"
|
||||
>
|
||||
<Estrella filled={n <= (hover || rating)} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="texto" className="text-sm font-semibold text-dark">
|
||||
¿Cómo fue tu experiencia?
|
||||
</label>
|
||||
<textarea
|
||||
id="texto"
|
||||
name="texto"
|
||||
rows={5}
|
||||
placeholder="Cuéntanos cómo fue el trato, el resultado de la reforma, los plazos…"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="nombre" className="text-sm font-semibold text-dark">
|
||||
Tu nombre
|
||||
</label>
|
||||
<input
|
||||
id="nombre"
|
||||
name="nombre"
|
||||
type="text"
|
||||
defaultValue={nombreCliente}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="contexto" className="text-sm font-semibold text-dark">
|
||||
Reforma <span className="text-gray-400 font-normal">(opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="contexto"
|
||||
name="contexto"
|
||||
type="text"
|
||||
placeholder="Reforma de cocina · Madrid"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="fotos" className="text-sm font-semibold text-dark">
|
||||
Sube fotos del resultado{' '}
|
||||
<span className="text-gray-400 font-normal">(opcional, 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>
|
||||
|
||||
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
50
mvp/b2c/src/components/funnel/QuienesSomos.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
type QuienesSomosProps = {
|
||||
nombreEmpresa: string;
|
||||
fotoUrl: string | null;
|
||||
texto: string;
|
||||
aniosExperiencia: number | null;
|
||||
};
|
||||
|
||||
export default function QuienesSomos({
|
||||
nombreEmpresa,
|
||||
fotoUrl,
|
||||
texto,
|
||||
aniosExperiencia,
|
||||
}: QuienesSomosProps) {
|
||||
return (
|
||||
<section className="section" id="quienes-somos" aria-labelledby="quienes-somos-heading">
|
||||
<div className="container">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 lg:gap-16 items-center">
|
||||
{fotoUrl && (
|
||||
<div className="order-1 md:order-none">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={fotoUrl}
|
||||
alt={nombreEmpresa}
|
||||
className="w-full aspect-[4/3] object-cover rounded-2xl border border-gray-200 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-5">
|
||||
<span className="badge badge-dark self-start">Quiénes somos</span>
|
||||
<h2
|
||||
id="quienes-somos-heading"
|
||||
className="text-[clamp(1.875rem,5vw,3rem)] font-black tracking-[-0.04em] leading-[1.1] text-black"
|
||||
>
|
||||
{nombreEmpresa}
|
||||
</h2>
|
||||
{aniosExperiencia != null && aniosExperiencia > 0 && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-black text-black">{aniosExperiencia}</span>
|
||||
<span className="text-lg text-gray-600">
|
||||
año{aniosExperiencia === 1 ? '' : 's'} de experiencia
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-lg text-gray-700 leading-relaxed whitespace-pre-line">{texto}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
73
mvp/b2c/src/components/funnel/TenantBrand.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
type TenantBrandProps = {
|
||||
nombreEmpresa: string;
|
||||
logoUrl: string | null;
|
||||
subtitle?: string;
|
||||
showLogin?: boolean;
|
||||
};
|
||||
|
||||
function iniciales(nombre: string): string {
|
||||
return nombre
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
// 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.
|
||||
export default function TenantBrand({
|
||||
nombreEmpresa,
|
||||
logoUrl,
|
||||
subtitle,
|
||||
showLogin = false,
|
||||
}: TenantBrandProps) {
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="container py-4 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={nombreEmpresa}
|
||||
className="h-9 w-auto max-w-[160px] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<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)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-lg font-black tracking-tight text-black truncate">
|
||||
{nombreEmpresa}
|
||||
</span>
|
||||
</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">
|
||||
{subtitle ?? 'Presupuesto de reforma'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { PublicTestimonio } from '@/lib/funnel/public-queries';
|
||||
|
||||
type Testimonial = {
|
||||
quote: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
context: string;
|
||||
};
|
||||
function iniciales(nombre: string): string {
|
||||
return nombre
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
const testimonials: Testimonial[] = [
|
||||
{
|
||||
quote:
|
||||
'Me llamaron en 90 segundos. El presupuesto orientativo cuadró con el final a 200 € de diferencia. Me ahorré tres visitas en blanco.',
|
||||
name: 'Laura Martínez',
|
||||
initials: 'LM',
|
||||
context: 'Cocina en Madrid',
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'Subí tres fotos del baño un domingo por la noche. El lunes a las nueve ya tenía un presupuesto desglosado en el WhatsApp.',
|
||||
name: 'Sergio Bonet',
|
||||
initials: 'SB',
|
||||
context: 'Baño en Valencia',
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'El render me sirvió para decidir el suelo sin ir a la tienda. Cuando llegó el reformista, ya teníamos media reforma clara.',
|
||||
name: 'Marta Vidal',
|
||||
initials: 'MV',
|
||||
context: 'Comedor en Barcelona',
|
||||
},
|
||||
];
|
||||
function Estrellas({ rating }: { rating: number }) {
|
||||
return (
|
||||
<div className="flex gap-0.5" aria-label={`${rating} de 5 estrellas`}>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<svg
|
||||
key={n}
|
||||
viewBox="0 0 24 24"
|
||||
className={`w-4 h-4 ${n <= rating ? 'fill-yellow-400' : 'fill-gray-200'}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||
</svg>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Testimonials() {
|
||||
export default function TestimoniosCliente({ testimonios }: { testimonios: PublicTestimonio[] }) {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,7 +56,6 @@ export default function Testimonials() {
|
||||
aria-labelledby="testimonios-heading"
|
||||
>
|
||||
<div className="container">
|
||||
{/* Header */}
|
||||
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out flex flex-col items-center text-center gap-4 mb-16">
|
||||
<span className="badge badge-dark">Testimonios</span>
|
||||
<h2
|
||||
@@ -72,45 +66,42 @@ export default function Testimonials() {
|
||||
<br />
|
||||
quienes ya reformaron.
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 max-w-xl">
|
||||
Mismo método, distintos espacios. Sin filtros ni guion.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||
{testimonials.map((t, i) => (
|
||||
{testimonios.map((t, i) => (
|
||||
<article
|
||||
key={t.name}
|
||||
key={t.id}
|
||||
className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out flex flex-col gap-5 bg-white border border-gray-200 rounded-2xl p-8 shadow-sm hover:shadow-md"
|
||||
style={{ transitionDelay: `${i * 80}ms` }}
|
||||
>
|
||||
{/* Quote mark */}
|
||||
<svg
|
||||
width="32"
|
||||
height="24"
|
||||
viewBox="0 0 32 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="text-black/15"
|
||||
>
|
||||
<path
|
||||
d="M0 24V13.6C0 6.08 4.16 0.96 12.48 0L13.44 4.16C8.96 5.44 6.72 8.32 6.72 11.52H12.48V24H0ZM18.56 24V13.6C18.56 6.08 22.72 0.96 31.04 0L32 4.16C27.52 5.44 25.28 8.32 25.28 11.52H31.04V24H18.56Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<Estrellas rating={t.rating} />
|
||||
|
||||
<blockquote className="text-lg leading-relaxed text-gray-800 italic">
|
||||
{t.quote}
|
||||
{t.texto}
|
||||
</blockquote>
|
||||
|
||||
{t.fotos.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{t.fotos.map((url, idx) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={idx}
|
||||
src={url}
|
||||
alt=""
|
||||
className="w-16 h-16 object-cover rounded-lg border border-gray-200"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="flex items-center gap-3 mt-auto pt-4 border-t border-gray-100">
|
||||
<div className="w-10 h-10 bg-black text-white rounded-full flex items-center justify-center text-xs font-bold tracking-wider shrink-0">
|
||||
{t.initials}
|
||||
{iniciales(t.nombre)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-black">{t.name}</div>
|
||||
<div className="text-xs text-gray-500">{t.context}</div>
|
||||
<div className="text-sm font-bold text-black">{t.nombre}</div>
|
||||
{t.contexto && <div className="text-xs text-gray-500">{t.contexto}</div>}
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
62
mvp/b2c/src/components/panel/AboutFotoUploader.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import {
|
||||
subirAboutFoto,
|
||||
quitarAboutFoto,
|
||||
type LogoResult,
|
||||
} from '@/app/panel/empresa/actions';
|
||||
import ImageCropperField from '@/components/panel/ImageCropperField';
|
||||
|
||||
export default function AboutFotoUploader({ fotoUrl }: { fotoUrl: string | null }) {
|
||||
const [state, formAction, pending] = useActionState<LogoResult | null, FormData>(
|
||||
subirAboutFoto,
|
||||
null
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-24 h-24 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{fotoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={fotoUrl} alt="Foto" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Sin foto</span>
|
||||
)}
|
||||
</div>
|
||||
{fotoUrl && (
|
||||
<form action={quitarAboutFoto}>
|
||||
<button type="submit" className="text-xs text-red-500 hover:underline">
|
||||
Quitar foto
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
||||
<ImageCropperField
|
||||
name="aboutFoto"
|
||||
maxDimension={500}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
buttonLabel="Elegir foto"
|
||||
previewClassName="h-24 w-24 rounded-lg"
|
||||
disabled={pending}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="rounded-lg bg-primary-700 px-4 py-1.5 text-sm font-medium text-white transition hover:bg-primary-900 disabled:opacity-50"
|
||||
>
|
||||
{pending ? 'Subiendo…' : 'Subir foto'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||
{state?.ok && <p className="text-sm text-green-600">Foto actualizada ✓</p>}
|
||||
<p className="text-xs text-gray-400">
|
||||
PNG, JPG o WEBP. Se recorta y optimiza a WebP (máx. 500 px).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
mvp/b2c/src/components/panel/GaleriaUploader.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useEffect, useRef } from 'react';
|
||||
import { subirFotoGaleria, type GaleriaResult } from '@/app/panel/galeria/actions';
|
||||
import ImageCropperField from '@/components/panel/ImageCropperField';
|
||||
|
||||
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">
|
||||
<ImageCropperField
|
||||
key={total}
|
||||
name="foto"
|
||||
maxDimension={1200}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
buttonLabel="Elegir foto"
|
||||
previewClassName="h-24 w-32 rounded-lg"
|
||||
disabled={lleno || pending}
|
||||
/>
|
||||
<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-primary-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-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. Se recorta y optimiza a WebP (máx. 1200 px).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
mvp/b2c/src/components/panel/ImageCropperField.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Cropper from 'react-easy-crop';
|
||||
import { getCroppedWebp, type CropArea } from '@/lib/image/crop';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
maxDimension: number;
|
||||
accept: string;
|
||||
buttonLabel?: string;
|
||||
previewClassName?: string;
|
||||
previewImgClassName?: string;
|
||||
allowSvgPassthrough?: boolean;
|
||||
quality?: number;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
// Campo de imagen con recorte (zoom + pan) y reescalado a WebP en el navegador.
|
||||
// El archivo optimizado se inyecta en un <input type="file"> oculto con `name`,
|
||||
// de modo que el formulario padre lo envía como si fuera la selección original.
|
||||
export default function ImageCropperField({
|
||||
name,
|
||||
maxDimension,
|
||||
accept,
|
||||
buttonLabel = 'Elegir imagen',
|
||||
previewClassName = 'h-24 w-24 rounded-lg',
|
||||
previewImgClassName = 'h-full w-full object-cover',
|
||||
allowSvgPassthrough = false,
|
||||
quality = 0.82,
|
||||
disabled = false,
|
||||
}: Props) {
|
||||
const pickerRef = useRef<HTMLInputElement>(null);
|
||||
const outputRef = useRef<HTMLInputElement>(null);
|
||||
const objectUrls = useRef<Set<string>>(new Set());
|
||||
|
||||
const [modalSrc, setModalSrc] = useState<string | null>(null);
|
||||
const [aspect, setAspect] = useState(1);
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [area, setArea] = useState<CropArea | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const track = useCallback((url: string) => {
|
||||
objectUrls.current.add(url);
|
||||
return url;
|
||||
}, []);
|
||||
|
||||
const revoke = useCallback((url: string | null) => {
|
||||
if (url && objectUrls.current.has(url)) {
|
||||
URL.revokeObjectURL(url);
|
||||
objectUrls.current.delete(url);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const urls = objectUrls.current;
|
||||
return () => {
|
||||
for (const url of urls) URL.revokeObjectURL(url);
|
||||
urls.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function setOutputFile(file: File) {
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
if (outputRef.current) outputRef.current.files = dt.files;
|
||||
}
|
||||
|
||||
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setError(null);
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ''; // permite re-elegir el mismo archivo
|
||||
if (!file) return;
|
||||
|
||||
if (allowSvgPassthrough && file.type === 'image/svg+xml') {
|
||||
setOutputFile(file);
|
||||
revoke(previewUrl);
|
||||
setPreviewUrl(track(URL.createObjectURL(file)));
|
||||
return;
|
||||
}
|
||||
|
||||
const src = track(URL.createObjectURL(file));
|
||||
try {
|
||||
const dims = await loadDims(src);
|
||||
setAspect(dims.width / dims.height || 1);
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setArea(null);
|
||||
setModalSrc(src);
|
||||
} catch {
|
||||
revoke(src);
|
||||
setError('No se pudo abrir la imagen.');
|
||||
}
|
||||
}
|
||||
|
||||
function cerrarModal() {
|
||||
revoke(modalSrc);
|
||||
setModalSrc(null);
|
||||
}
|
||||
|
||||
async function aplicar() {
|
||||
if (!modalSrc || !area) return;
|
||||
setWorking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const blob = await getCroppedWebp(modalSrc, area, maxDimension, quality);
|
||||
setOutputFile(new File([blob], `${name}.webp`, { type: 'image/webp' }));
|
||||
revoke(previewUrl);
|
||||
setPreviewUrl(track(URL.createObjectURL(blob)));
|
||||
cerrarModal();
|
||||
} catch {
|
||||
setError('No se pudo procesar la imagen. Prueba con otra.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input ref={outputRef} type="file" name={name} accept="image/webp,image/svg+xml" className="hidden" />
|
||||
<input
|
||||
ref={pickerRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={onPick}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{previewUrl && (
|
||||
<span className={`overflow-hidden border border-gray-200 bg-gray-50 ${previewClassName}`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={previewUrl} alt="Previsualización" className={previewImgClassName} />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pickerRef.current?.click()}
|
||||
disabled={disabled}
|
||||
className="rounded-lg border border-gray-300 px-4 py-1.5 text-sm font-medium text-gray-700 transition hover:border-primary-700 hover:text-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{previewUrl ? 'Cambiar imagen' : buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
{modalSrc && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="flex w-full max-w-lg flex-col gap-4 rounded-2xl bg-white p-5 shadow-xl">
|
||||
<h3 className="font-display text-2xl tracking-tight text-black">Ajusta el encuadre</h3>
|
||||
<div className="relative h-[55vh] w-full overflow-hidden rounded-xl bg-gray-900">
|
||||
<Cropper
|
||||
image={modalSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={aspect}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={(_area, areaPixels) => setArea(areaPixels)}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm text-gray-600">
|
||||
Zoom
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 accent-[#2f5c46]"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cerrarModal}
|
||||
disabled={working}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition hover:border-gray-500 disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={aplicar}
|
||||
disabled={working || !area}
|
||||
className="rounded-lg bg-primary-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-900 disabled:opacity-50"
|
||||
>
|
||||
{working ? 'Procesando…' : 'Aplicar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function loadDims(src: string): Promise<{ width: number; height: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
img.onerror = () => reject(new Error('No se pudo cargar la imagen.'));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
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-primary-700 text-white' : 'text-gray-500 hover:text-primary-700')
|
||||
}
|
||||
>
|
||||
<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-primary-700 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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { subirLogo, quitarLogo, type LogoResult } from '@/app/panel/empresa/actions';
|
||||
import ImageCropperField from '@/components/panel/ImageCropperField';
|
||||
|
||||
export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) {
|
||||
const [state, formAction, pending] = useActionState<LogoResult | null, FormData>(subirLogo, null);
|
||||
@@ -27,16 +28,20 @@ export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) {
|
||||
</div>
|
||||
|
||||
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
<ImageCropperField
|
||||
name="logo"
|
||||
maxDimension={500}
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
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"
|
||||
buttonLabel="Elegir logo"
|
||||
previewClassName="h-20 w-32 rounded-lg"
|
||||
previewImgClassName="h-full w-full object-contain"
|
||||
allowSvgPassthrough
|
||||
disabled={pending}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="bg-black text-white rounded-lg px-4 py-1.5 text-sm font-medium disabled:opacity-50"
|
||||
className="rounded-lg bg-primary-700 px-4 py-1.5 text-sm font-medium text-white transition hover:bg-primary-900 disabled:opacity-50"
|
||||
>
|
||||
{pending ? 'Subiendo…' : 'Subir logo'}
|
||||
</button>
|
||||
@@ -44,7 +49,9 @@ export default function LogoUploader({ logoUrl }: { logoUrl: string | null }) {
|
||||
|
||||
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||
{state?.ok && <p className="text-sm text-green-600">Logo actualizado ✓</p>}
|
||||
<p className="text-xs text-gray-400">PNG, JPG, WEBP o SVG · máx. 500 KB.</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
PNG, JPG, WEBP o SVG. Se recorta y optimiza a WebP (máx. 500 px). El SVG se sube tal cual.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
35
mvp/b2c/src/components/panel/OpinionLinkBox.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function OpinionLinkBox({ url }: { url: string }) {
|
||||
const [copiado, setCopiado] = useState(false);
|
||||
|
||||
const copiar = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopiado(true);
|
||||
setTimeout(() => setCopiado(false), 2000);
|
||||
} catch {
|
||||
setCopiado(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||
<input
|
||||
readOnly
|
||||
value={url}
|
||||
className="flex-1 px-3 py-2 text-sm text-gray-700 outline-none min-w-0 bg-gray-50"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copiar}
|
||||
className="px-3 py-2 text-sm font-medium bg-black text-white whitespace-nowrap hover:bg-gray-800"
|
||||
>
|
||||
{copiado ? 'Copiado ✓' : 'Copiar'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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-primary-700 ring-1 ring-primary-700'
|
||||
: '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-[#2f5c46]"
|
||||
/>
|
||||
<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-primary-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-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>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,10 @@ export const subscriptionStatus = pgEnum('subscription_status', [
|
||||
// 'automatico' = el funnel lo envía solo; 'revision' = se para para que el reformista lo revise/edite antes de enviar.
|
||||
export const envioPresupuestoMode = pgEnum('envio_presupuesto_mode', ['automatico', 'revision']);
|
||||
|
||||
// Estado de moderación de un testimonio. El reformista aprueba antes de publicar.
|
||||
// 'pendiente' = recién enviado por el cliente; 'publicado' = visible en la landing; 'oculto' = retirado.
|
||||
export const testimonioEstado = pgEnum('testimonio_estado', ['pendiente', 'publicado', 'oculto']);
|
||||
|
||||
// Reformista (cliente del SaaS). MVP = "Reformas Ejemplo" hardcoded.
|
||||
// Multi-tenant real es F1.5; la tabla ya queda lista para ello.
|
||||
export const tenants = pgTable('tenants', {
|
||||
@@ -80,6 +84,18 @@ export const tenants = pgTable('tenants', {
|
||||
logoUrl: text('logo_url'), // data URI base64 del logo (no hay storage externo aún)
|
||||
provincia: text('provincia'),
|
||||
whatsappBusiness: text('whatsapp_business'),
|
||||
// SEO personalizable de la landing del reformista (RF-A / funnel público).
|
||||
seoTitle: text('seo_title'),
|
||||
seoDescription: text('seo_description'),
|
||||
// Bloque "Quiénes somos" opcional en el funnel del reformista.
|
||||
aboutEnabled: boolean('about_enabled').notNull().default(false),
|
||||
aboutFotoUrl: text('about_foto_url'), // data URI base64 de la foto del reformista
|
||||
aboutTexto: text('about_texto'),
|
||||
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).
|
||||
cif: text('cif'),
|
||||
direccion: text('direccion'),
|
||||
@@ -172,6 +188,9 @@ export const leads = pgTable(
|
||||
|
||||
notas: text('notas'),
|
||||
|
||||
// Cuándo el reformista pidió la opinión al cliente (RF: recogida de testimonios).
|
||||
testimonioSolicitadoAt: timestamp('testimonio_solicitado_at', { withTimezone: true }),
|
||||
|
||||
// Inputs del motor de presupuesto (capturados de menos a más en el funnel)
|
||||
m2Suelo: doublePrecision('m2_suelo'),
|
||||
alturaTecho: doublePrecision('altura_techo'),
|
||||
@@ -206,6 +225,56 @@ export const leadFotos = pgTable('lead_fotos', {
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// Opiniones del cliente final, recogidas en el funnel de review (/opinion/[id]).
|
||||
// El reformista las solicita desde el panel y aprueba antes de que salgan en su landing.
|
||||
export const testimonios = pgTable(
|
||||
'testimonios',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
tenantId: uuid('tenant_id')
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: 'cascade' }),
|
||||
leadId: uuid('lead_id').references(() => leads.id, { onDelete: 'set null' }),
|
||||
nombre: text('nombre').notNull(),
|
||||
contexto: text('contexto'), // p.ej. "Reforma de cocina · Madrid"
|
||||
rating: integer('rating').notNull(), // 1-5 estrellas
|
||||
texto: text('texto').notNull(),
|
||||
estado: testimonioEstado('estado').notNull().default('pendiente'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('testimonios_tenant_estado_idx').on(table.tenantId, table.estado),
|
||||
index('testimonios_lead_idx').on(table.leadId),
|
||||
]
|
||||
);
|
||||
|
||||
// Fotos adjuntas a un testimonio (el cliente sube fotos del resultado).
|
||||
export const testimonioFotos = pgTable('testimonio_fotos', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
testimonioId: uuid('testimonio_id')
|
||||
.notNull()
|
||||
.references(() => testimonios.id, { onDelete: 'cascade' }),
|
||||
url: text('url').notNull(),
|
||||
orden: integer('orden').notNull().default(0),
|
||||
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)
|
||||
export const leadEstadoHistory = pgTable('lead_estado_history', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
@@ -281,6 +350,10 @@ export type Tenant = typeof tenants.$inferSelect;
|
||||
export type Lead = typeof leads.$inferSelect;
|
||||
export type NewLead = typeof leads.$inferInsert;
|
||||
export type LeadFoto = typeof leadFotos.$inferSelect;
|
||||
export type Testimonio = typeof testimonios.$inferSelect;
|
||||
export type NewTestimonio = typeof testimonios.$inferInsert;
|
||||
export type TestimonioFoto = typeof testimonioFotos.$inferSelect;
|
||||
export type GaleriaFoto = typeof galeriaFotos.$inferSelect;
|
||||
export type LeadEstadoHistory = typeof leadEstadoHistory.$inferSelect;
|
||||
export type LeadPipelineEvento = typeof leadPipelineEventos.$inferSelect;
|
||||
export type PrecisionHistory = typeof precisionHistory.$inferSelect;
|
||||
|
||||
@@ -284,7 +284,7 @@ async function main() {
|
||||
|
||||
console.log('Limpiando datos previos...');
|
||||
await db.execute(
|
||||
sql`TRUNCATE TABLE ${schema.precisionHistory}, ${schema.leadPipelineEventos}, ${schema.leadEstadoHistory}, ${schema.leadFotos}, ${schema.leads}, ${schema.sessions}, ${schema.users}, ${schema.plans}, ${schema.tenants} RESTART IDENTITY CASCADE`
|
||||
sql`TRUNCATE TABLE ${schema.testimonioFotos}, ${schema.testimonios}, ${schema.precisionHistory}, ${schema.leadPipelineEventos}, ${schema.leadEstadoHistory}, ${schema.leadFotos}, ${schema.leads}, ${schema.sessions}, ${schema.users}, ${schema.plans}, ${schema.tenants} RESTART IDENTITY CASCADE`
|
||||
);
|
||||
|
||||
console.log('Sembrando planes...');
|
||||
@@ -341,6 +341,13 @@ async function main() {
|
||||
planId: pro.id,
|
||||
subscriptionStatus: 'trial',
|
||||
trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
|
||||
seoTitle: 'Reformas Ejemplo · Reformas integrales en Madrid',
|
||||
seoDescription:
|
||||
'Pide tu presupuesto de reforma con render IA en minutos. Reformas de cocina, baño y vivienda completa en Madrid.',
|
||||
aboutEnabled: true,
|
||||
aboutTexto:
|
||||
'Somos un equipo de Madrid especializado en reformas integrales de cocinas, baños y viviendas completas. Cuidamos cada detalle y te acompañamos desde la primera idea hasta la entrega de llaves, con presupuestos claros y sin sorpresas.',
|
||||
aniosExperiencia: 15,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -430,6 +437,66 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Opiniones demo (recogidas en el funnel de review, ya moderadas) ---
|
||||
console.log('Sembrando opiniones demo...');
|
||||
const leadsPorEmail = await db
|
||||
.select({ id: schema.leads.id, email: schema.leads.email })
|
||||
.from(schema.leads)
|
||||
.where(eq(schema.leads.tenantId, tenant.id));
|
||||
const leadIdPorEmail = new Map(leadsPorEmail.map((l) => [l.email, l.id]));
|
||||
|
||||
// Diego (ganado) ya tiene la opinión solicitada desde el panel.
|
||||
const diegoId = leadIdPorEmail.get('diego.romero@example.com');
|
||||
if (diegoId) {
|
||||
await db
|
||||
.update(schema.leads)
|
||||
.set({ testimonioSolicitadoAt: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000) })
|
||||
.where(eq(schema.leads.id, diegoId));
|
||||
}
|
||||
|
||||
const [testiDiego] = await db
|
||||
.insert(schema.testimonios)
|
||||
.values({
|
||||
tenantId: tenant.id,
|
||||
leadId: diegoId ?? null,
|
||||
nombre: 'Diego Romero',
|
||||
contexto: 'Reforma integral · Valencia',
|
||||
rating: 5,
|
||||
texto:
|
||||
'Reformaron un piso heredado de 85 metros de arriba a abajo. El presupuesto inicial cuadró casi al detalle con el final y los plazos se cumplieron. Repetiría sin dudarlo.',
|
||||
estado: 'publicado',
|
||||
})
|
||||
.returning();
|
||||
if (testiDiego) {
|
||||
await db
|
||||
.insert(schema.testimonioFotos)
|
||||
.values({ testimonioId: testiDiego.id, url: '/despues.webp', orden: 0 });
|
||||
}
|
||||
|
||||
await db.insert(schema.testimonios).values([
|
||||
{
|
||||
tenantId: tenant.id,
|
||||
leadId: leadIdPorEmail.get('carmen.ibanez@example.com') ?? null,
|
||||
nombre: 'Carmen Ibáñez',
|
||||
contexto: 'Comedor · Madrid',
|
||||
rating: 5,
|
||||
texto:
|
||||
'Abrieron el comedor al salón y pusieron tarima nueva. El render que me enseñaron al principio era casi idéntico al resultado final. Trato impecable.',
|
||||
estado: 'publicado',
|
||||
},
|
||||
{
|
||||
// Pendiente de aprobar: aparece en el panel pero aún no en la landing.
|
||||
tenantId: tenant.id,
|
||||
leadId: leadIdPorEmail.get('tomas.herrero@example.com') ?? null,
|
||||
nombre: 'Tomás Herrero',
|
||||
contexto: 'Baño · Bilbao',
|
||||
rating: 4,
|
||||
texto:
|
||||
'Cambiaron todo el alicatado y los sanitarios del baño. Buen acabado y limpios. Solo se retrasaron un par de días por los materiales.',
|
||||
estado: 'pendiente',
|
||||
},
|
||||
]);
|
||||
|
||||
// --- Precios + catálogo demo (motor de presupuesto) ---
|
||||
const [tenantRow] = await db
|
||||
.select()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { asc, desc, eq } from 'drizzle-orm';
|
||||
import { db } from './index';
|
||||
import { tenants } from './schema';
|
||||
import { tenants, testimonios, testimonioFotos, galeriaFotos, type GaleriaFoto } from './schema';
|
||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||
|
||||
export type TenantPerfil = {
|
||||
nombreEmpresa: string;
|
||||
slug: string;
|
||||
logoUrl: string | null;
|
||||
provincia: string | null;
|
||||
cif: string | null;
|
||||
@@ -12,6 +13,14 @@ export type TenantPerfil = {
|
||||
telefono: string | null;
|
||||
email: string | null;
|
||||
web: string | null;
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
aboutEnabled: boolean;
|
||||
aboutFotoUrl: string | null;
|
||||
aboutTexto: string | null;
|
||||
aniosExperiencia: number | null;
|
||||
themePreset: string;
|
||||
themeColor: string | null;
|
||||
};
|
||||
|
||||
export async function getTenantPerfil(): Promise<TenantPerfil> {
|
||||
@@ -19,6 +28,7 @@ export async function getTenantPerfil(): Promise<TenantPerfil> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
nombreEmpresa: tenants.nombreEmpresa,
|
||||
slug: tenants.slug,
|
||||
logoUrl: tenants.logoUrl,
|
||||
provincia: tenants.provincia,
|
||||
cif: tenants.cif,
|
||||
@@ -26,6 +36,14 @@ export async function getTenantPerfil(): Promise<TenantPerfil> {
|
||||
telefono: tenants.telefono,
|
||||
email: tenants.email,
|
||||
web: tenants.web,
|
||||
seoTitle: tenants.seoTitle,
|
||||
seoDescription: tenants.seoDescription,
|
||||
aboutEnabled: tenants.aboutEnabled,
|
||||
aboutFotoUrl: tenants.aboutFotoUrl,
|
||||
aboutTexto: tenants.aboutTexto,
|
||||
aniosExperiencia: tenants.aniosExperiencia,
|
||||
themePreset: tenants.themePreset,
|
||||
themeColor: tenants.themeColor,
|
||||
})
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
@@ -34,6 +52,7 @@ export async function getTenantPerfil(): Promise<TenantPerfil> {
|
||||
return (
|
||||
row ?? {
|
||||
nombreEmpresa: 'Reformix',
|
||||
slug: '',
|
||||
logoUrl: null,
|
||||
provincia: null,
|
||||
cif: null,
|
||||
@@ -41,6 +60,66 @@ export async function getTenantPerfil(): Promise<TenantPerfil> {
|
||||
telefono: null,
|
||||
email: null,
|
||||
web: null,
|
||||
seoTitle: null,
|
||||
seoDescription: null,
|
||||
aboutEnabled: false,
|
||||
aboutFotoUrl: null,
|
||||
aboutTexto: 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 = {
|
||||
id: string;
|
||||
nombre: string;
|
||||
contexto: string | null;
|
||||
rating: number;
|
||||
texto: string;
|
||||
estado: (typeof testimonios.estado.enumValues)[number];
|
||||
createdAt: Date;
|
||||
fotos: string[];
|
||||
};
|
||||
|
||||
export async function getTestimoniosPanel(): Promise<TestimonioPanel[]> {
|
||||
const tenantId = await getTenantId();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(testimonios)
|
||||
.where(eq(testimonios.tenantId, tenantId))
|
||||
.orderBy(desc(testimonios.createdAt));
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const ids = rows.map((r) => r.id);
|
||||
const fotos = await db.select().from(testimonioFotos);
|
||||
const fotosPorTestimonio = new Map<string, string[]>();
|
||||
for (const f of fotos) {
|
||||
if (!ids.includes(f.testimonioId)) continue;
|
||||
const list = fotosPorTestimonio.get(f.testimonioId) ?? [];
|
||||
list.push(f.url);
|
||||
fotosPorTestimonio.set(f.testimonioId, list);
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
nombre: r.nombre,
|
||||
contexto: r.contexto,
|
||||
rating: r.rating,
|
||||
texto: r.texto,
|
||||
estado: r.estado,
|
||||
createdAt: r.createdAt,
|
||||
fotos: fotosPorTestimonio.get(r.id) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
type Estado = (typeof leadEstado.enumValues)[number];
|
||||
type Stage = (typeof pipelineStage.enumValues)[number];
|
||||
type Tipo = (typeof tipoReforma.enumValues)[number];
|
||||
type Calidad = (typeof calidad.enumValues)[number];
|
||||
|
||||
export const ESTADOS: Estado[] = [
|
||||
'nuevo',
|
||||
@@ -74,6 +75,12 @@ export const TIPO_LABEL: Record<Tipo, string> = {
|
||||
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 {
|
||||
if (cents == null) return '—';
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
@@ -91,3 +98,16 @@ export function formatFecha(date: Date): string {
|
||||
minute: '2-digit',
|
||||
}).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);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,114 @@
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { tenants, leads, leadFotos, leadPipelineEventos } from '@/db/schema';
|
||||
import { TENANT_SLUG } from '@/lib/funnel';
|
||||
import {
|
||||
tenants,
|
||||
leads,
|
||||
leadFotos,
|
||||
leadPipelineEventos,
|
||||
testimonios,
|
||||
testimonioFotos,
|
||||
galeriaFotos,
|
||||
} from '@/db/schema';
|
||||
|
||||
// Tenant demo del MVP ("Reformas Ejemplo"). El funnel público es anónimo:
|
||||
// todos los leads se atribuyen a este reformista hasta que multi-tenant B2C (F1.5) exista.
|
||||
export async function getDemoTenantId(): Promise<string> {
|
||||
const [row] = await db
|
||||
.select({ id: tenants.id })
|
||||
.from(tenants)
|
||||
.where(eq(tenants.slug, TENANT_SLUG))
|
||||
.limit(1);
|
||||
if (!row) {
|
||||
throw new Error(`Tenant demo "${TENANT_SLUG}" no encontrado. Ejecuta el seed de la base de datos.`);
|
||||
}
|
||||
return row.id;
|
||||
export type PublicTenant = {
|
||||
id: string;
|
||||
slug: string;
|
||||
nombreEmpresa: string;
|
||||
logoUrl: string | null;
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
aboutEnabled: boolean;
|
||||
aboutFotoUrl: string | null;
|
||||
aboutTexto: string | null;
|
||||
aniosExperiencia: number | null;
|
||||
themePreset: string;
|
||||
themeColor: string | null;
|
||||
};
|
||||
|
||||
const TENANT_PUBLIC_COLUMNS = {
|
||||
id: tenants.id,
|
||||
slug: tenants.slug,
|
||||
nombreEmpresa: tenants.nombreEmpresa,
|
||||
logoUrl: tenants.logoUrl,
|
||||
seoTitle: tenants.seoTitle,
|
||||
seoDescription: tenants.seoDescription,
|
||||
aboutEnabled: tenants.aboutEnabled,
|
||||
aboutFotoUrl: tenants.aboutFotoUrl,
|
||||
aboutTexto: tenants.aboutTexto,
|
||||
aniosExperiencia: tenants.aniosExperiencia,
|
||||
themePreset: tenants.themePreset,
|
||||
themeColor: tenants.themeColor,
|
||||
} 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;
|
||||
}
|
||||
|
||||
// Lectura del lead para las páginas públicas del funnel. Scoped al tenant demo,
|
||||
// nunca expone leads de otros reformistas.
|
||||
export async function getPublicLead(id: string) {
|
||||
const tenantId = await getDemoTenantId();
|
||||
const [lead] = await db
|
||||
.select()
|
||||
.from(leads)
|
||||
.where(and(eq(leads.id, id), eq(leads.tenantId, tenantId)))
|
||||
.limit(1);
|
||||
export type PublicTestimonio = {
|
||||
id: string;
|
||||
nombre: string;
|
||||
contexto: string | null;
|
||||
rating: number;
|
||||
texto: string;
|
||||
fotos: string[];
|
||||
};
|
||||
|
||||
// Testimonios publicados de un reformista, para mostrar en su landing.
|
||||
export async function getPublishedTestimonios(tenantId: string): Promise<PublicTestimonio[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(testimonios)
|
||||
.where(and(eq(testimonios.tenantId, tenantId), eq(testimonios.estado, 'publicado')))
|
||||
.orderBy(asc(testimonios.createdAt));
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const fotos = await db
|
||||
.select()
|
||||
.from(testimonioFotos)
|
||||
.orderBy(asc(testimonioFotos.orden));
|
||||
|
||||
return rows.map((t) => ({
|
||||
id: t.id,
|
||||
nombre: t.nombre,
|
||||
contexto: t.contexto,
|
||||
rating: t.rating,
|
||||
texto: t.texto,
|
||||
fotos: fotos.filter((f) => f.testimonioId === t.id).map((f) => f.url),
|
||||
}));
|
||||
}
|
||||
|
||||
// Resuelve el reformista dueño del funnel a partir de su slug público.
|
||||
// Devuelve null si el slug no corresponde a ningún reformista.
|
||||
export async function getTenantBySlug(slug: string): Promise<PublicTenant | null> {
|
||||
const [row] = await db
|
||||
.select(TENANT_PUBLIC_COLUMNS)
|
||||
.from(tenants)
|
||||
.where(eq(tenants.slug, slug))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
// Lectura del lead para las páginas públicas del funnel (/solicitud/[id]).
|
||||
// El lead se identifica por su UUID (no enumerable); devolvemos su reformista
|
||||
// para mostrar el branding correcto sin exponer datos de otros tenants.
|
||||
export async function getPublicLead(id: string) {
|
||||
const [lead] = await db.select().from(leads).where(eq(leads.id, id)).limit(1);
|
||||
if (!lead) return null;
|
||||
|
||||
const [fotos, eventos] = await Promise.all([
|
||||
const [[tenant], fotos, eventos] = await Promise.all([
|
||||
db.select(TENANT_PUBLIC_COLUMNS).from(tenants).where(eq(tenants.id, lead.tenantId)).limit(1),
|
||||
db.select().from(leadFotos).where(eq(leadFotos.leadId, id)).orderBy(asc(leadFotos.orden)),
|
||||
db
|
||||
.select()
|
||||
@@ -38,5 +117,34 @@ export async function getPublicLead(id: string) {
|
||||
.orderBy(asc(leadPipelineEventos.occurredAt)),
|
||||
]);
|
||||
|
||||
return { lead, fotos, eventos };
|
||||
return { lead, tenant: tenant ?? null, fotos, eventos };
|
||||
}
|
||||
|
||||
// Lectura del lead para el funnel de opinión (/opinion/[id]).
|
||||
// El lead se identifica por su UUID. Devolvemos el branding del reformista y si
|
||||
// ya hay un testimonio enviado para no permitir duplicados.
|
||||
export async function getLeadForReview(id: string) {
|
||||
const [lead] = await db
|
||||
.select({ id: leads.id, nombre: leads.nombre, tenantId: leads.tenantId })
|
||||
.from(leads)
|
||||
.where(eq(leads.id, id))
|
||||
.limit(1);
|
||||
if (!lead) return null;
|
||||
|
||||
const [[tenant], [yaEnviado]] = await Promise.all([
|
||||
db.select(TENANT_PUBLIC_COLUMNS).from(tenants).where(eq(tenants.id, lead.tenantId)).limit(1),
|
||||
db.select({ id: testimonios.id }).from(testimonios).where(eq(testimonios.leadId, id)).limit(1),
|
||||
]);
|
||||
|
||||
return { lead, tenant: tenant ?? null, yaEnviado: !!yaEnviado };
|
||||
}
|
||||
|
||||
// Verifica que un lead pertenece al tenant indicado (scoping de mutaciones).
|
||||
export async function leadPerteneceATenant(leadId: string, tenantId: string): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ id: leads.id })
|
||||
.from(leads)
|
||||
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
|
||||
.limit(1);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
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
@@ -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;
|
||||
38
mvp/b2c/src/lib/image/crop.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type CropArea = { x: number; y: number; width: number; height: number };
|
||||
|
||||
// Recorta la región indicada (en píxeles del original) y reescala el lado más
|
||||
// largo a `maxDimension`, devolviendo un WebP ya optimizado listo para subir.
|
||||
export async function getCroppedWebp(
|
||||
src: string,
|
||||
area: CropArea,
|
||||
maxDimension: number,
|
||||
quality = 0.82,
|
||||
): Promise<Blob> {
|
||||
const img = await loadImage(src);
|
||||
const scale = Math.min(1, maxDimension / Math.max(area.width, area.height));
|
||||
const outW = Math.max(1, Math.round(area.width * scale));
|
||||
const outH = Math.max(1, Math.round(area.height * scale));
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = outW;
|
||||
canvas.height = outH;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('No se pudo crear el lienzo.');
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(img, area.x, area.y, area.width, area.height, 0, 0, outW, outH);
|
||||
|
||||
const blob = await new Promise<Blob | null>((resolve) =>
|
||||
canvas.toBlob(resolve, 'image/webp', quality),
|
||||
);
|
||||
if (!blob) throw new Error('No se pudo procesar la imagen.');
|
||||
return blob;
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('No se pudo cargar la imagen.'));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
@@ -19,3 +19,46 @@ export function slugify(value: string): string {
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
// Rutas de la app que NO pueden ser slug de un reformista: colisionarían con
|
||||
// páginas reales o con ficheros públicos servidos en la raíz del dominio.
|
||||
export const RESERVED_SLUGS = new Set([
|
||||
'panel',
|
||||
'login',
|
||||
'logout',
|
||||
'signup',
|
||||
'admin',
|
||||
'solicitud',
|
||||
'opinion',
|
||||
'api',
|
||||
'b2b',
|
||||
'b2b-assets',
|
||||
'assets',
|
||||
'_next',
|
||||
'favicon.ico',
|
||||
'icon.svg',
|
||||
'robots.txt',
|
||||
'sitemap.xml',
|
||||
]);
|
||||
|
||||
export function isReservedSlug(slug: string): boolean {
|
||||
return RESERVED_SLUGS.has(slug);
|
||||
}
|
||||
|
||||
export type SlugValidation = { ok: true; slug: string } | { ok: false; error: string };
|
||||
|
||||
// Normaliza y valida un slug propuesto para el funnel público de un reformista.
|
||||
// No comprueba unicidad (eso requiere BD); solo formato y palabras reservadas.
|
||||
export function validarSlug(raw: string): SlugValidation {
|
||||
const slug = slugify(raw);
|
||||
if (slug.length < 2) {
|
||||
return { ok: false, error: 'El enlace debe tener al menos 2 caracteres.' };
|
||||
}
|
||||
if (slug.length > 40) {
|
||||
return { ok: false, error: 'El enlace no puede superar los 40 caracteres.' };
|
||||
}
|
||||
if (isReservedSlug(slug)) {
|
||||
return { ok: false, error: 'Ese enlace está reservado. Elige otro.' };
|
||||
}
|
||||
return { ok: true, slug };
|
||||
}
|
||||
|
||||