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:
@@ -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) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user