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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export type AppNavIcon =
|
||||
| 'leads'
|
||||
| 'precios'
|
||||
| 'empresa'
|
||||
| 'opiniones'
|
||||
| 'resumen'
|
||||
| 'usuarios'
|
||||
| 'planes';
|
||||
@@ -43,6 +44,11 @@ const ICON_PATHS: Record<AppNavIcon | 'salir', React.ReactNode> = {
|
||||
<path d="M4 21h16M9 21v-5h6v5M8 9h.01M12 9h.01M16 9h.01M8 13h.01M12 13h.01M16 13h.01" />
|
||||
</>
|
||||
),
|
||||
opiniones: (
|
||||
<>
|
||||
<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" />
|
||||
</>
|
||||
),
|
||||
resumen: (
|
||||
<>
|
||||
<rect x="3" y="3" width="7" height="9" rx="1" />
|
||||
@@ -120,7 +126,10 @@ export default function AppNav({ links }: { links: readonly AppNavLink[] }) {
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
aria-label="Navegación principal"
|
||||
>
|
||||
<div className="grid grid-cols-4">
|
||||
<div
|
||||
className="grid"
|
||||
style={{ gridTemplateColumns: `repeat(${links.length + 1}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{links.map((l) => {
|
||||
const isActive = active === l.href;
|
||||
return (
|
||||
|
||||
169
mvp/b2c/src/components/funnel/OpinionForm.tsx
Normal file
169
mvp/b2c/src/components/funnel/OpinionForm.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useState } from 'react';
|
||||
import { useFormStatus } from 'react-dom';
|
||||
import type { EnviarOpinionResult } from '@/app/opinion/actions';
|
||||
|
||||
const MAX_FOTOS = 4;
|
||||
|
||||
const inputClass =
|
||||
'w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] border-gray-200 rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)]';
|
||||
|
||||
function Estrella({ filled }: { filled: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`w-9 h-9 ${filled ? '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>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus();
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg w-full justify-center mt-1 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
disabled={pending}
|
||||
aria-busy={pending}
|
||||
>
|
||||
{pending ? 'Enviando...' : 'Enviar mi opinión'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OpinionForm({
|
||||
action,
|
||||
nombreCliente,
|
||||
}: {
|
||||
action: (prev: EnviarOpinionResult | null, formData: FormData) => Promise<EnviarOpinionResult>;
|
||||
nombreCliente: string;
|
||||
}) {
|
||||
const [state, formAction] = useActionState(action, null);
|
||||
const [rating, setRating] = useState(0);
|
||||
const [hover, setHover] = useState(0);
|
||||
const [previews, setPreviews] = useState<string[]>([]);
|
||||
|
||||
const handleFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []).slice(0, MAX_FOTOS);
|
||||
previews.forEach((url) => URL.revokeObjectURL(url));
|
||||
setPreviews(files.map((f) => URL.createObjectURL(f)));
|
||||
};
|
||||
|
||||
if (state?.ok) {
|
||||
return (
|
||||
<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">¡Gracias por tu opinión!</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
La hemos recibido correctamente. Aparecerá en la web del reformista una vez la revise.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-5">
|
||||
<input type="hidden" name="rating" value={rating} />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-semibold text-dark">Tu valoración</span>
|
||||
<div className="flex gap-1" role="radiogroup" aria-label="Valoración de 1 a 5 estrellas">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setRating(n)}
|
||||
onMouseEnter={() => setHover(n)}
|
||||
onMouseLeave={() => setHover(0)}
|
||||
aria-label={`${n} estrella${n > 1 ? 's' : ''}`}
|
||||
aria-checked={rating === n}
|
||||
role="radio"
|
||||
className="p-0.5"
|
||||
>
|
||||
<Estrella filled={n <= (hover || rating)} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="texto" className="text-sm font-semibold text-dark">
|
||||
¿Cómo fue tu experiencia?
|
||||
</label>
|
||||
<textarea
|
||||
id="texto"
|
||||
name="texto"
|
||||
rows={5}
|
||||
placeholder="Cuéntanos cómo fue el trato, el resultado de la reforma, los plazos…"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="nombre" className="text-sm font-semibold text-dark">
|
||||
Tu nombre
|
||||
</label>
|
||||
<input
|
||||
id="nombre"
|
||||
name="nombre"
|
||||
type="text"
|
||||
defaultValue={nombreCliente}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="contexto" className="text-sm font-semibold text-dark">
|
||||
Reforma <span className="text-gray-400 font-normal">(opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="contexto"
|
||||
name="contexto"
|
||||
type="text"
|
||||
placeholder="Reforma de cocina · Madrid"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label htmlFor="fotos" className="text-sm font-semibold text-dark">
|
||||
Sube fotos del resultado{' '}
|
||||
<span className="text-gray-400 font-normal">(opcional, hasta {MAX_FOTOS})</span>
|
||||
</label>
|
||||
<input
|
||||
id="fotos"
|
||||
name="fotos"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFiles}
|
||||
className="block w-full text-sm text-gray-600 file:mr-4 file:py-2.5 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-black file:text-white hover:file:bg-gray-800 file:cursor-pointer cursor-pointer"
|
||||
/>
|
||||
{previews.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3 mt-2">
|
||||
{previews.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>
|
||||
|
||||
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||
|
||||
<SubmitButton />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
50
mvp/b2c/src/components/funnel/QuienesSomos.tsx
Normal file
50
mvp/b2c/src/components/funnel/QuienesSomos.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
type QuienesSomosProps = {
|
||||
nombreEmpresa: string;
|
||||
fotoUrl: string | null;
|
||||
texto: string;
|
||||
aniosExperiencia: number | null;
|
||||
};
|
||||
|
||||
export default function QuienesSomos({
|
||||
nombreEmpresa,
|
||||
fotoUrl,
|
||||
texto,
|
||||
aniosExperiencia,
|
||||
}: QuienesSomosProps) {
|
||||
return (
|
||||
<section className="section" id="quienes-somos" aria-labelledby="quienes-somos-heading">
|
||||
<div className="container">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 lg:gap-16 items-center">
|
||||
{fotoUrl && (
|
||||
<div className="order-1 md:order-none">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={fotoUrl}
|
||||
alt={nombreEmpresa}
|
||||
className="w-full aspect-[4/3] object-cover rounded-2xl border border-gray-200 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-5">
|
||||
<span className="badge badge-dark self-start">Quiénes somos</span>
|
||||
<h2
|
||||
id="quienes-somos-heading"
|
||||
className="text-[clamp(1.875rem,5vw,3rem)] font-black tracking-[-0.04em] leading-[1.1] text-black"
|
||||
>
|
||||
{nombreEmpresa}
|
||||
</h2>
|
||||
{aniosExperiencia != null && aniosExperiencia > 0 && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-black text-black">{aniosExperiencia}</span>
|
||||
<span className="text-lg text-gray-600">
|
||||
año{aniosExperiencia === 1 ? '' : 's'} de experiencia
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-lg text-gray-700 leading-relaxed whitespace-pre-line">{texto}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { PublicTestimonio } from '@/lib/funnel/public-queries';
|
||||
|
||||
type Testimonial = {
|
||||
quote: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
context: string;
|
||||
};
|
||||
function iniciales(nombre: string): string {
|
||||
return nombre
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
const testimonials: Testimonial[] = [
|
||||
{
|
||||
quote:
|
||||
'Me llamaron en 90 segundos. El presupuesto orientativo cuadró con el final a 200 € de diferencia. Me ahorré tres visitas en blanco.',
|
||||
name: 'Laura Martínez',
|
||||
initials: 'LM',
|
||||
context: 'Cocina en Madrid',
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'Subí tres fotos del baño un domingo por la noche. El lunes a las nueve ya tenía un presupuesto desglosado en el WhatsApp.',
|
||||
name: 'Sergio Bonet',
|
||||
initials: 'SB',
|
||||
context: 'Baño en Valencia',
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'El render me sirvió para decidir el suelo sin ir a la tienda. Cuando llegó el reformista, ya teníamos media reforma clara.',
|
||||
name: 'Marta Vidal',
|
||||
initials: 'MV',
|
||||
context: 'Comedor en Barcelona',
|
||||
},
|
||||
];
|
||||
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 function Testimonials() {
|
||||
export default function TestimoniosCliente({ testimonios }: { testimonios: PublicTestimonio[] }) {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,7 +56,6 @@ export default function Testimonials() {
|
||||
aria-labelledby="testimonios-heading"
|
||||
>
|
||||
<div className="container">
|
||||
{/* Header */}
|
||||
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out flex flex-col items-center text-center gap-4 mb-16">
|
||||
<span className="badge badge-dark">Testimonios</span>
|
||||
<h2
|
||||
@@ -72,45 +66,42 @@ export default function Testimonials() {
|
||||
<br />
|
||||
quienes ya reformaron.
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 max-w-xl">
|
||||
Mismo método, distintos espacios. Sin filtros ni guion.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8">
|
||||
{testimonials.map((t, i) => (
|
||||
{testimonios.map((t, i) => (
|
||||
<article
|
||||
key={t.name}
|
||||
key={t.id}
|
||||
className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out flex flex-col gap-5 bg-white border border-gray-200 rounded-2xl p-8 shadow-sm hover:shadow-md"
|
||||
style={{ transitionDelay: `${i * 80}ms` }}
|
||||
>
|
||||
{/* Quote mark */}
|
||||
<svg
|
||||
width="32"
|
||||
height="24"
|
||||
viewBox="0 0 32 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="text-black/15"
|
||||
>
|
||||
<path
|
||||
d="M0 24V13.6C0 6.08 4.16 0.96 12.48 0L13.44 4.16C8.96 5.44 6.72 8.32 6.72 11.52H12.48V24H0ZM18.56 24V13.6C18.56 6.08 22.72 0.96 31.04 0L32 4.16C27.52 5.44 25.28 8.32 25.28 11.52H31.04V24H18.56Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<Estrellas rating={t.rating} />
|
||||
|
||||
<blockquote className="text-lg leading-relaxed text-gray-800 italic">
|
||||
{t.quote}
|
||||
{t.texto}
|
||||
</blockquote>
|
||||
|
||||
{t.fotos.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{t.fotos.map((url, idx) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={idx}
|
||||
src={url}
|
||||
alt=""
|
||||
className="w-16 h-16 object-cover rounded-lg border border-gray-200"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer className="flex items-center gap-3 mt-auto pt-4 border-t border-gray-100">
|
||||
<div className="w-10 h-10 bg-black text-white rounded-full flex items-center justify-center text-xs font-bold tracking-wider shrink-0">
|
||||
{t.initials}
|
||||
{iniciales(t.nombre)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-black">{t.name}</div>
|
||||
<div className="text-xs text-gray-500">{t.context}</div>
|
||||
<div className="text-sm font-bold text-black">{t.nombre}</div>
|
||||
{t.contexto && <div className="text-xs text-gray-500">{t.contexto}</div>}
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
57
mvp/b2c/src/components/panel/AboutFotoUploader.tsx
Normal file
57
mvp/b2c/src/components/panel/AboutFotoUploader.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import {
|
||||
subirAboutFoto,
|
||||
quitarAboutFoto,
|
||||
type LogoResult,
|
||||
} from '@/app/panel/empresa/actions';
|
||||
|
||||
export default function AboutFotoUploader({ fotoUrl }: { fotoUrl: string | null }) {
|
||||
const [state, formAction, pending] = useActionState<LogoResult | null, FormData>(
|
||||
subirAboutFoto,
|
||||
null
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-24 h-24 rounded-lg border border-gray-200 bg-gray-50 flex items-center justify-center overflow-hidden">
|
||||
{fotoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={fotoUrl} alt="Foto" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">Sin foto</span>
|
||||
)}
|
||||
</div>
|
||||
{fotoUrl && (
|
||||
<form action={quitarAboutFoto}>
|
||||
<button type="submit" className="text-xs text-red-500 hover:underline">
|
||||
Quitar foto
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form action={formAction} className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
name="aboutFoto"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="text-sm file:mr-2 file:rounded-lg file:border-0 file:bg-black file:text-white file:px-3 file:py-1.5 file:text-sm file:font-medium"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="bg-black text-white rounded-lg px-4 py-1.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{pending ? 'Subiendo…' : 'Subir foto'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{state?.error && <p className="text-sm text-red-600">{state.error}</p>}
|
||||
{state?.ok && <p className="text-sm text-green-600">Foto actualizada ✓</p>}
|
||||
<p className="text-xs text-gray-400">PNG, JPG o WEBP · máx. 1,5 MB.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
mvp/b2c/src/components/panel/OpinionLinkBox.tsx
Normal file
35
mvp/b2c/src/components/panel/OpinionLinkBox.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function OpinionLinkBox({ url }: { url: string }) {
|
||||
const [copiado, setCopiado] = useState(false);
|
||||
|
||||
const copiar = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopiado(true);
|
||||
setTimeout(() => setCopiado(false), 2000);
|
||||
} catch {
|
||||
setCopiado(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center border border-gray-300 rounded-lg overflow-hidden">
|
||||
<input
|
||||
readOnly
|
||||
value={url}
|
||||
className="flex-1 px-3 py-2 text-sm text-gray-700 outline-none min-w-0 bg-gray-50"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copiar}
|
||||
className="px-3 py-2 text-sm font-medium bg-black text-white whitespace-nowrap hover:bg-gray-800"
|
||||
>
|
||||
{copiado ? 'Copiado ✓' : 'Copiar'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,10 @@ export const subscriptionStatus = pgEnum('subscription_status', [
|
||||
// 'automatico' = el funnel lo envía solo; 'revision' = se para para que el reformista lo revise/edite antes de enviar.
|
||||
export const envioPresupuestoMode = pgEnum('envio_presupuesto_mode', ['automatico', 'revision']);
|
||||
|
||||
// Estado de moderación de un testimonio. El reformista aprueba antes de publicar.
|
||||
// 'pendiente' = recién enviado por el cliente; 'publicado' = visible en la landing; 'oculto' = retirado.
|
||||
export const testimonioEstado = pgEnum('testimonio_estado', ['pendiente', 'publicado', 'oculto']);
|
||||
|
||||
// Reformista (cliente del SaaS). MVP = "Reformas Ejemplo" hardcoded.
|
||||
// Multi-tenant real es F1.5; la tabla ya queda lista para ello.
|
||||
export const tenants = pgTable('tenants', {
|
||||
@@ -80,6 +84,14 @@ export const tenants = pgTable('tenants', {
|
||||
logoUrl: text('logo_url'), // data URI base64 del logo (no hay storage externo aún)
|
||||
provincia: text('provincia'),
|
||||
whatsappBusiness: text('whatsapp_business'),
|
||||
// SEO personalizable de la landing del reformista (RF-A / funnel público).
|
||||
seoTitle: text('seo_title'),
|
||||
seoDescription: text('seo_description'),
|
||||
// Bloque "Quiénes somos" opcional en el funnel del reformista.
|
||||
aboutEnabled: boolean('about_enabled').notNull().default(false),
|
||||
aboutFotoUrl: text('about_foto_url'), // data URI base64 de la foto del reformista
|
||||
aboutTexto: text('about_texto'),
|
||||
aniosExperiencia: integer('anios_experiencia'),
|
||||
// Datos de empresa para la cabecera del presupuesto (RF-D-07).
|
||||
cif: text('cif'),
|
||||
direccion: text('direccion'),
|
||||
@@ -172,6 +184,9 @@ export const leads = pgTable(
|
||||
|
||||
notas: text('notas'),
|
||||
|
||||
// Cuándo el reformista pidió la opinión al cliente (RF: recogida de testimonios).
|
||||
testimonioSolicitadoAt: timestamp('testimonio_solicitado_at', { withTimezone: true }),
|
||||
|
||||
// Inputs del motor de presupuesto (capturados de menos a más en el funnel)
|
||||
m2Suelo: doublePrecision('m2_suelo'),
|
||||
alturaTecho: doublePrecision('altura_techo'),
|
||||
@@ -206,6 +221,40 @@ export const leadFotos = pgTable('lead_fotos', {
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// Opiniones del cliente final, recogidas en el funnel de review (/opinion/[id]).
|
||||
// El reformista las solicita desde el panel y aprueba antes de que salgan en su landing.
|
||||
export const testimonios = pgTable(
|
||||
'testimonios',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
tenantId: uuid('tenant_id')
|
||||
.notNull()
|
||||
.references(() => tenants.id, { onDelete: 'cascade' }),
|
||||
leadId: uuid('lead_id').references(() => leads.id, { onDelete: 'set null' }),
|
||||
nombre: text('nombre').notNull(),
|
||||
contexto: text('contexto'), // p.ej. "Reforma de cocina · Madrid"
|
||||
rating: integer('rating').notNull(), // 1-5 estrellas
|
||||
texto: text('texto').notNull(),
|
||||
estado: testimonioEstado('estado').notNull().default('pendiente'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('testimonios_tenant_estado_idx').on(table.tenantId, table.estado),
|
||||
index('testimonios_lead_idx').on(table.leadId),
|
||||
]
|
||||
);
|
||||
|
||||
// Fotos adjuntas a un testimonio (el cliente sube fotos del resultado).
|
||||
export const testimonioFotos = pgTable('testimonio_fotos', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
testimonioId: uuid('testimonio_id')
|
||||
.notNull()
|
||||
.references(() => testimonios.id, { onDelete: 'cascade' }),
|
||||
url: text('url').notNull(),
|
||||
orden: integer('orden').notNull().default(0),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// Histórico de cambios de estado comercial (RF-D-03: persistir y reflejar)
|
||||
export const leadEstadoHistory = pgTable('lead_estado_history', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
@@ -281,6 +330,9 @@ export type Tenant = typeof tenants.$inferSelect;
|
||||
export type Lead = typeof leads.$inferSelect;
|
||||
export type NewLead = typeof leads.$inferInsert;
|
||||
export type LeadFoto = typeof leadFotos.$inferSelect;
|
||||
export type Testimonio = typeof testimonios.$inferSelect;
|
||||
export type NewTestimonio = typeof testimonios.$inferInsert;
|
||||
export type TestimonioFoto = typeof testimonioFotos.$inferSelect;
|
||||
export type LeadEstadoHistory = typeof leadEstadoHistory.$inferSelect;
|
||||
export type LeadPipelineEvento = typeof leadPipelineEventos.$inferSelect;
|
||||
export type PrecisionHistory = typeof precisionHistory.$inferSelect;
|
||||
|
||||
@@ -284,7 +284,7 @@ async function main() {
|
||||
|
||||
console.log('Limpiando datos previos...');
|
||||
await db.execute(
|
||||
sql`TRUNCATE TABLE ${schema.precisionHistory}, ${schema.leadPipelineEventos}, ${schema.leadEstadoHistory}, ${schema.leadFotos}, ${schema.leads}, ${schema.sessions}, ${schema.users}, ${schema.plans}, ${schema.tenants} RESTART IDENTITY CASCADE`
|
||||
sql`TRUNCATE TABLE ${schema.testimonioFotos}, ${schema.testimonios}, ${schema.precisionHistory}, ${schema.leadPipelineEventos}, ${schema.leadEstadoHistory}, ${schema.leadFotos}, ${schema.leads}, ${schema.sessions}, ${schema.users}, ${schema.plans}, ${schema.tenants} RESTART IDENTITY CASCADE`
|
||||
);
|
||||
|
||||
console.log('Sembrando planes...');
|
||||
@@ -341,6 +341,13 @@ async function main() {
|
||||
planId: pro.id,
|
||||
subscriptionStatus: 'trial',
|
||||
trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
|
||||
seoTitle: 'Reformas Ejemplo · Reformas integrales en Madrid',
|
||||
seoDescription:
|
||||
'Pide tu presupuesto de reforma con render IA en minutos. Reformas de cocina, baño y vivienda completa en Madrid.',
|
||||
aboutEnabled: true,
|
||||
aboutTexto:
|
||||
'Somos un equipo de Madrid especializado en reformas integrales de cocinas, baños y viviendas completas. Cuidamos cada detalle y te acompañamos desde la primera idea hasta la entrega de llaves, con presupuestos claros y sin sorpresas.',
|
||||
aniosExperiencia: 15,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -430,6 +437,66 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Opiniones demo (recogidas en el funnel de review, ya moderadas) ---
|
||||
console.log('Sembrando opiniones demo...');
|
||||
const leadsPorEmail = await db
|
||||
.select({ id: schema.leads.id, email: schema.leads.email })
|
||||
.from(schema.leads)
|
||||
.where(eq(schema.leads.tenantId, tenant.id));
|
||||
const leadIdPorEmail = new Map(leadsPorEmail.map((l) => [l.email, l.id]));
|
||||
|
||||
// Diego (ganado) ya tiene la opinión solicitada desde el panel.
|
||||
const diegoId = leadIdPorEmail.get('diego.romero@example.com');
|
||||
if (diegoId) {
|
||||
await db
|
||||
.update(schema.leads)
|
||||
.set({ testimonioSolicitadoAt: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000) })
|
||||
.where(eq(schema.leads.id, diegoId));
|
||||
}
|
||||
|
||||
const [testiDiego] = await db
|
||||
.insert(schema.testimonios)
|
||||
.values({
|
||||
tenantId: tenant.id,
|
||||
leadId: diegoId ?? null,
|
||||
nombre: 'Diego Romero',
|
||||
contexto: 'Reforma integral · Valencia',
|
||||
rating: 5,
|
||||
texto:
|
||||
'Reformaron un piso heredado de 85 metros de arriba a abajo. El presupuesto inicial cuadró casi al detalle con el final y los plazos se cumplieron. Repetiría sin dudarlo.',
|
||||
estado: 'publicado',
|
||||
})
|
||||
.returning();
|
||||
if (testiDiego) {
|
||||
await db
|
||||
.insert(schema.testimonioFotos)
|
||||
.values({ testimonioId: testiDiego.id, url: '/despues.webp', orden: 0 });
|
||||
}
|
||||
|
||||
await db.insert(schema.testimonios).values([
|
||||
{
|
||||
tenantId: tenant.id,
|
||||
leadId: leadIdPorEmail.get('carmen.ibanez@example.com') ?? null,
|
||||
nombre: 'Carmen Ibáñez',
|
||||
contexto: 'Comedor · Madrid',
|
||||
rating: 5,
|
||||
texto:
|
||||
'Abrieron el comedor al salón y pusieron tarima nueva. El render que me enseñaron al principio era casi idéntico al resultado final. Trato impecable.',
|
||||
estado: 'publicado',
|
||||
},
|
||||
{
|
||||
// Pendiente de aprobar: aparece en el panel pero aún no en la landing.
|
||||
tenantId: tenant.id,
|
||||
leadId: leadIdPorEmail.get('tomas.herrero@example.com') ?? null,
|
||||
nombre: 'Tomás Herrero',
|
||||
contexto: 'Baño · Bilbao',
|
||||
rating: 4,
|
||||
texto:
|
||||
'Cambiaron todo el alicatado y los sanitarios del baño. Buen acabado y limpios. Solo se retrasaron un par de días por los materiales.',
|
||||
estado: 'pendiente',
|
||||
},
|
||||
]);
|
||||
|
||||
// --- Precios + catálogo demo (motor de presupuesto) ---
|
||||
const [tenantRow] = await db
|
||||
.select()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { desc, eq } from 'drizzle-orm';
|
||||
import { db } from './index';
|
||||
import { tenants } from './schema';
|
||||
import { tenants, testimonios, testimonioFotos } from './schema';
|
||||
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
|
||||
|
||||
export type TenantPerfil = {
|
||||
@@ -13,6 +13,12 @@ export type TenantPerfil = {
|
||||
telefono: string | null;
|
||||
email: string | null;
|
||||
web: string | null;
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
aboutEnabled: boolean;
|
||||
aboutFotoUrl: string | null;
|
||||
aboutTexto: string | null;
|
||||
aniosExperiencia: number | null;
|
||||
};
|
||||
|
||||
export async function getTenantPerfil(): Promise<TenantPerfil> {
|
||||
@@ -28,6 +34,12 @@ export async function getTenantPerfil(): Promise<TenantPerfil> {
|
||||
telefono: tenants.telefono,
|
||||
email: tenants.email,
|
||||
web: tenants.web,
|
||||
seoTitle: tenants.seoTitle,
|
||||
seoDescription: tenants.seoDescription,
|
||||
aboutEnabled: tenants.aboutEnabled,
|
||||
aboutFotoUrl: tenants.aboutFotoUrl,
|
||||
aboutTexto: tenants.aboutTexto,
|
||||
aniosExperiencia: tenants.aniosExperiencia,
|
||||
})
|
||||
.from(tenants)
|
||||
.where(eq(tenants.id, tenantId))
|
||||
@@ -44,6 +56,54 @@ export async function getTenantPerfil(): Promise<TenantPerfil> {
|
||||
telefono: null,
|
||||
email: null,
|
||||
web: null,
|
||||
seoTitle: null,
|
||||
seoDescription: null,
|
||||
aboutEnabled: false,
|
||||
aboutFotoUrl: null,
|
||||
aboutTexto: null,
|
||||
aniosExperiencia: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export type TestimonioPanel = {
|
||||
id: string;
|
||||
nombre: string;
|
||||
contexto: string | null;
|
||||
rating: number;
|
||||
texto: string;
|
||||
estado: (typeof testimonios.estado.enumValues)[number];
|
||||
createdAt: Date;
|
||||
fotos: string[];
|
||||
};
|
||||
|
||||
export async function getTestimoniosPanel(): Promise<TestimonioPanel[]> {
|
||||
const tenantId = await getTenantId();
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(testimonios)
|
||||
.where(eq(testimonios.tenantId, tenantId))
|
||||
.orderBy(desc(testimonios.createdAt));
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const ids = rows.map((r) => r.id);
|
||||
const fotos = await db.select().from(testimonioFotos);
|
||||
const fotosPorTestimonio = new Map<string, string[]>();
|
||||
for (const f of fotos) {
|
||||
if (!ids.includes(f.testimonioId)) continue;
|
||||
const list = fotosPorTestimonio.get(f.testimonioId) ?? [];
|
||||
list.push(f.url);
|
||||
fotosPorTestimonio.set(f.testimonioId, list);
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
nombre: r.nombre,
|
||||
contexto: r.contexto,
|
||||
rating: r.rating,
|
||||
texto: r.texto,
|
||||
estado: r.estado,
|
||||
createdAt: r.createdAt,
|
||||
fotos: fotosPorTestimonio.get(r.id) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { tenants, leads, leadFotos, leadPipelineEventos } from '@/db/schema';
|
||||
import {
|
||||
tenants,
|
||||
leads,
|
||||
leadFotos,
|
||||
leadPipelineEventos,
|
||||
testimonios,
|
||||
testimonioFotos,
|
||||
} from '@/db/schema';
|
||||
|
||||
export type PublicTenant = {
|
||||
id: string;
|
||||
slug: string;
|
||||
nombreEmpresa: string;
|
||||
logoUrl: string | null;
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
aboutEnabled: boolean;
|
||||
aboutFotoUrl: string | null;
|
||||
aboutTexto: string | null;
|
||||
aniosExperiencia: number | null;
|
||||
};
|
||||
|
||||
const TENANT_PUBLIC_COLUMNS = {
|
||||
@@ -14,8 +27,47 @@ const TENANT_PUBLIC_COLUMNS = {
|
||||
slug: tenants.slug,
|
||||
nombreEmpresa: tenants.nombreEmpresa,
|
||||
logoUrl: tenants.logoUrl,
|
||||
seoTitle: tenants.seoTitle,
|
||||
seoDescription: tenants.seoDescription,
|
||||
aboutEnabled: tenants.aboutEnabled,
|
||||
aboutFotoUrl: tenants.aboutFotoUrl,
|
||||
aboutTexto: tenants.aboutTexto,
|
||||
aniosExperiencia: tenants.aniosExperiencia,
|
||||
} as const;
|
||||
|
||||
export type PublicTestimonio = {
|
||||
id: string;
|
||||
nombre: string;
|
||||
contexto: string | null;
|
||||
rating: number;
|
||||
texto: string;
|
||||
fotos: string[];
|
||||
};
|
||||
|
||||
// Testimonios publicados de un reformista, para mostrar en su landing.
|
||||
export async function getPublishedTestimonios(tenantId: string): Promise<PublicTestimonio[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(testimonios)
|
||||
.where(and(eq(testimonios.tenantId, tenantId), eq(testimonios.estado, 'publicado')))
|
||||
.orderBy(asc(testimonios.createdAt));
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const fotos = await db
|
||||
.select()
|
||||
.from(testimonioFotos)
|
||||
.orderBy(asc(testimonioFotos.orden));
|
||||
|
||||
return rows.map((t) => ({
|
||||
id: t.id,
|
||||
nombre: t.nombre,
|
||||
contexto: t.contexto,
|
||||
rating: t.rating,
|
||||
texto: t.texto,
|
||||
fotos: fotos.filter((f) => f.testimonioId === t.id).map((f) => f.url),
|
||||
}));
|
||||
}
|
||||
|
||||
// Resuelve el reformista dueño del funnel a partir de su slug público.
|
||||
// Devuelve null si el slug no corresponde a ningún reformista.
|
||||
export async function getTenantBySlug(slug: string): Promise<PublicTenant | null> {
|
||||
@@ -47,6 +99,25 @@ export async function getPublicLead(id: string) {
|
||||
return { lead, tenant: tenant ?? null, fotos, eventos };
|
||||
}
|
||||
|
||||
// Lectura del lead para el funnel de opinión (/opinion/[id]).
|
||||
// El lead se identifica por su UUID. Devolvemos el branding del reformista y si
|
||||
// ya hay un testimonio enviado para no permitir duplicados.
|
||||
export async function getLeadForReview(id: string) {
|
||||
const [lead] = await db
|
||||
.select({ id: leads.id, nombre: leads.nombre, tenantId: leads.tenantId })
|
||||
.from(leads)
|
||||
.where(eq(leads.id, id))
|
||||
.limit(1);
|
||||
if (!lead) return null;
|
||||
|
||||
const [[tenant], [yaEnviado]] = await Promise.all([
|
||||
db.select(TENANT_PUBLIC_COLUMNS).from(tenants).where(eq(tenants.id, lead.tenantId)).limit(1),
|
||||
db.select({ id: testimonios.id }).from(testimonios).where(eq(testimonios.leadId, id)).limit(1),
|
||||
]);
|
||||
|
||||
return { lead, tenant: tenant ?? null, yaEnviado: !!yaEnviado };
|
||||
}
|
||||
|
||||
// Verifica que un lead pertenece al tenant indicado (scoping de mutaciones).
|
||||
export async function leadPerteneceATenant(leadId: string, tenantId: string): Promise<boolean> {
|
||||
const [row] = await db
|
||||
|
||||
@@ -29,6 +29,7 @@ export const RESERVED_SLUGS = new Set([
|
||||
'signup',
|
||||
'admin',
|
||||
'solicitud',
|
||||
'opinion',
|
||||
'api',
|
||||
'b2b',
|
||||
'b2b-assets',
|
||||
|
||||
Reference in New Issue
Block a user