Añade personalización SEO/Quiénes somos y testimonios gestionables por reformista

- Panel/empresa: title y meta description SEO personalizables; foto, texto y
  años de experiencia para el bloque "Quiénes somos" (toggle on/off).
- Funnel por slug: metadata SEO desde el tenant, bloque "Quiénes somos" y
  testimonios servidos desde DB (sustituye los hardcodeados).
- Flujo de opiniones: el reformista solicita la opinión desde la ficha de un
  lead ganado; el cliente la deja en un funnel dedicado /opinion/[id] con
  estrellas + texto + fotos; entra como pendiente y el reformista la modera
  (publicar/ocultar/eliminar) en /panel/opiniones antes de mostrarla.
- Schema: columnas SEO/about en tenants, testimonioSolicitadoAt en leads,
  enum testimonio_estado, tablas testimonios + testimonio_fotos (migración 0006).
- Seed: opiniones demo (2 publicadas, 1 pendiente) y contenido "Quiénes somos".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-01 12:26:13 +02:00
parent 1a1caaf0df
commit a91fe5ce2c
25 changed files with 2638 additions and 66 deletions

View File

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

View File

@@ -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()

View File

@@ -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) ?? [],
}));
}