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;