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