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:
50
mvp/b2c/src/app/opinion/[id]/page.tsx
Normal file
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
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
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user