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:
125
mvp/b2c/src/app/panel/opiniones/page.tsx
Normal file
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="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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user