Añade personalización SEO/Quiénes somos y testimonios gestionables por reformista

- Panel/empresa: title y meta description SEO personalizables; foto, texto y
  años de experiencia para el bloque "Quiénes somos" (toggle on/off).
- Funnel por slug: metadata SEO desde el tenant, bloque "Quiénes somos" y
  testimonios servidos desde DB (sustituye los hardcodeados).
- Flujo de opiniones: el reformista solicita la opinión desde la ficha de un
  lead ganado; el cliente la deja en un funnel dedicado /opinion/[id] con
  estrellas + texto + fotos; entra como pendiente y el reformista la modera
  (publicar/ocultar/eliminar) en /panel/opiniones antes de mostrarla.
- Schema: columnas SEO/about en tenants, testimonioSolicitadoAt en leads,
  enum testimonio_estado, tablas testimonios + testimonio_fotos (migración 0006).
- Seed: opiniones demo (2 publicadas, 1 pendiente) y contenido "Quiénes somos".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-01 12:26:13 +02:00
parent 1a1caaf0df
commit a91fe5ce2c
25 changed files with 2638 additions and 66 deletions

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1780237037524,
"tag": "0005_tearful_maverick",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1780308810691,
"tag": "0006_aspiring_susan_delgado",
"breakpoints": true
}
]
}

View File

@@ -3,10 +3,11 @@ import { notFound } from 'next/navigation';
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 QuienesSomos from '@/components/funnel/QuienesSomos';
import TestimoniosCliente from '@/components/funnel/TestimoniosCliente';
import Footer from '@/components/Footer/Footer';
import TenantBrand from '@/components/funnel/TenantBrand';
import { getTenantBySlug } from '@/lib/funnel/public-queries';
import { getTenantBySlug, getPublishedTestimonios } from '@/lib/funnel/public-queries';
export const dynamic = 'force-dynamic';
@@ -19,8 +20,10 @@ export async function generateMetadata({
const tenant = await getTenantBySlug(slug);
if (!tenant) return { title: 'Reforma no encontrada' };
return {
title: `${tenant.nombreEmpresa} · Presupuesto de reforma`,
description: `Pide tu presupuesto de reforma a ${tenant.nombreEmpresa}. Render IA y presupuesto orientativo en minutos.`,
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.`,
};
}
@@ -29,6 +32,8 @@ export default async function FunnelPage({ params }: { params: Promise<{ slug: s
const tenant = await getTenantBySlug(slug);
if (!tenant) notFound();
const testimonios = await getPublishedTestimonios(tenant.id);
return (
<>
<TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />
@@ -36,7 +41,15 @@ export default async function FunnelPage({ params }: { params: Promise<{ slug: s
<Hero slug={tenant.slug} />
<ReformaSlider />
<Features />
<Testimonials />
{tenant.aboutEnabled && tenant.aboutTexto && (
<QuienesSomos
nombreEmpresa={tenant.nombreEmpresa}
fotoUrl={tenant.aboutFotoUrl}
texto={tenant.aboutTexto}
aniosExperiencia={tenant.aniosExperiencia}
/>
)}
{testimonios.length > 0 && <TestimoniosCliente testimonios={testimonios} />}
</main>
<Footer />
</>

View 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>
</>
);
}

View 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 };
}

View 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>
);
}

View File

@@ -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,6 +40,11 @@ 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">
@@ -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-black 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-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
>
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">

View File

@@ -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

View File

@@ -9,6 +9,8 @@ import { validarSlug } from '@/lib/validation/signup';
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();
@@ -42,6 +44,10 @@ export async function actualizarEmpresa(formData: FormData) {
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({
@@ -53,6 +59,11 @@ export async function actualizarEmpresa(formData: FormData) {
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');
@@ -87,3 +98,31 @@ 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');
}

View File

@@ -2,6 +2,7 @@ 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';
export const dynamic = 'force-dynamic';
@@ -115,11 +116,87 @@ export default async function EmpresaPage() {
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">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-black"
/>
<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 bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
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>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import AppNav from '@/components/AppNav';
const PANEL_LINKS = [
{ href: '/panel', label: 'Leads', icon: 'leads' },
{ href: '/panel/precios', label: 'Precios', icon: 'precios' },
{ href: '/panel/opiniones', label: 'Opiniones', icon: 'opiniones' },
{ href: '/panel/empresa', label: 'Empresa', icon: 'empresa' },
] as const;

View 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');
}

View 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="text-2xl font-extrabold 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>
);
}

View File

@@ -7,6 +7,7 @@ export type AppNavIcon =
| 'leads'
| 'precios'
| 'empresa'
| 'opiniones'
| 'resumen'
| 'usuarios'
| 'planes';
@@ -43,6 +44,11 @@ const ICON_PATHS: Record<AppNavIcon | 'salir', React.ReactNode> = {
<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" />
@@ -120,7 +126,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 (

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -0,0 +1,57 @@
'use client';
import { useActionState } from 'react';
import {
subirAboutFoto,
quitarAboutFoto,
type LogoResult,
} from '@/app/panel/empresa/actions';
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">
<input
type="file"
name="aboutFoto"
accept="image/png,image/jpeg,image/webp"
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"
/>
<button
type="submit"
disabled={pending}
className="bg-black text-white rounded-lg px-4 py-1.5 text-sm font-medium 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 · máx. 1,5 MB.</p>
</div>
);
}

View 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>
);
}

View File

@@ -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,14 @@ 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'),
// Datos de empresa para la cabecera del presupuesto (RF-D-07).
cif: text('cif'),
direccion: text('direccion'),
@@ -172,6 +184,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 +221,40 @@ 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(),
});
// 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 +330,9 @@ 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 LeadEstadoHistory = typeof leadEstadoHistory.$inferSelect;
export type LeadPipelineEvento = typeof leadPipelineEventos.$inferSelect;
export type PrecisionHistory = typeof precisionHistory.$inferSelect;

View File

@@ -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()

View File

@@ -1,6 +1,6 @@
import { eq } from 'drizzle-orm';
import { desc, eq } from 'drizzle-orm';
import { db } from './index';
import { tenants } from './schema';
import { tenants, testimonios, testimonioFotos } from './schema';
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
export type TenantPerfil = {
@@ -13,6 +13,12 @@ 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;
};
export async function getTenantPerfil(): Promise<TenantPerfil> {
@@ -28,6 +34,12 @@ 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,
})
.from(tenants)
.where(eq(tenants.id, tenantId))
@@ -44,6 +56,54 @@ export async function getTenantPerfil(): Promise<TenantPerfil> {
telefono: null,
email: null,
web: null,
seoTitle: null,
seoDescription: null,
aboutEnabled: false,
aboutFotoUrl: null,
aboutTexto: null,
aniosExperiencia: null,
}
);
}
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) ?? [],
}));
}

View File

@@ -1,12 +1,25 @@
import { and, asc, eq } from 'drizzle-orm';
import { db } from '@/db';
import { tenants, leads, leadFotos, leadPipelineEventos } from '@/db/schema';
import {
tenants,
leads,
leadFotos,
leadPipelineEventos,
testimonios,
testimonioFotos,
} from '@/db/schema';
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;
};
const TENANT_PUBLIC_COLUMNS = {
@@ -14,8 +27,47 @@ const TENANT_PUBLIC_COLUMNS = {
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,
} as const;
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> {
@@ -47,6 +99,25 @@ export async function getPublicLead(id: string) {
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

View File

@@ -29,6 +29,7 @@ export const RESERVED_SLUGS = new Set([
'signup',
'admin',
'solicitud',
'opinion',
'api',
'b2b',
'b2b-assets',