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

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