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:
@@ -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 />
|
||||
</>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
36
mvp/b2c/src/app/panel/opiniones/actions.ts
Normal file
36
mvp/b2c/src/app/panel/opiniones/actions.ts
Normal 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');
|
||||
}
|
||||
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