diff --git a/copy/COPY-GUIDE.md b/copy/COPY-GUIDE.md
index e9ae178..71f4107 100644
--- a/copy/COPY-GUIDE.md
+++ b/copy/COPY-GUIDE.md
@@ -478,6 +478,17 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
> 👉 *¿Te gustaría que [Reformista] vaya a verlo gratis?*
> [Botón: Sí, pídeme la visita] [Botón: Tengo dudas, contestadme]
+### PDF del presupuesto (documento adjunto)
+
+Documento que se adjunta en la entrega por WhatsApp y se descarga desde el panel. Mantiene el tono orientativo y honesto: deja claro que es una aproximación, sin restarle credibilidad.
+
+- **Título:** *PRESUPUESTO ORIENTATIVO DE REFORMA*
+- **Disclaimer (bajo el título):** *El precio final se determinará tras la visita gratuita de [Reformista] en tu casa. Esta aproximación se basa en datos estadísticos de reformas similares y la ajustamos para que se acerque lo máximo posible al importe definitivo.*
+- **Sección render — título:** *Así quedaría tu reforma*
+- **Render — descripción (se genera según la selección del cliente):** *Recreación con calidad [media] y acabados en [suelo porcelánico, paredes en tono neutro, mobiliario lacado]. Estilo [moderno y luminoso] según tus preferencias.*
+- **Render — descripción (fallback si faltan materiales o estilo):** *Recreación orientativa de cómo quedaría tu espacio reformado con la calidad [media] seleccionada.*
+- **Pie de la imagen:** *Render orientativo generado con IA. El resultado real puede variar según los materiales finales y las condiciones de la obra.*
+
### WhatsApp follow-up (24h sin respuesta)
> 👋 *Hola [Nombre], ¿pudiste mirar el presupuesto que te mandamos ayer?*
diff --git a/mvp/b2c/package-lock.json b/mvp/b2c/package-lock.json
index 568ee9b..59b385a 100644
--- a/mvp/b2c/package-lock.json
+++ b/mvp/b2c/package-lock.json
@@ -18,6 +18,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"react-easy-crop": "^5.5.7",
+ "sharp": "^0.34.5",
"tailwindcss": "^4.3.0",
"zod": "^4.4.3"
},
@@ -1437,7 +1438,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=18"
}
@@ -7980,7 +7980,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
- "optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -8024,7 +8023,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"license": "ISC",
- "optional": true,
"bin": {
"semver": "bin/semver.js"
},
diff --git a/mvp/b2c/package.json b/mvp/b2c/package.json
index 633a7a3..3ae9eb1 100644
--- a/mvp/b2c/package.json
+++ b/mvp/b2c/package.json
@@ -27,6 +27,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"react-easy-crop": "^5.5.7",
+ "sharp": "^0.34.5",
"tailwindcss": "^4.3.0",
"zod": "^4.4.3"
},
diff --git a/mvp/b2c/src/app/panel/[id]/presupuesto/route.ts b/mvp/b2c/src/app/panel/[id]/presupuesto/route.ts
index 5d265af..3e76f75 100644
--- a/mvp/b2c/src/app/panel/[id]/presupuesto/route.ts
+++ b/mvp/b2c/src/app/panel/[id]/presupuesto/route.ts
@@ -4,7 +4,9 @@ import { getLead } from '@/db/queries';
import { getTenantPerfil } from '@/db/tenant-queries';
import { TIPO_LABEL } from '@/lib/funnel';
import { PresupuestoDoc } from '@/lib/pdf/PresupuestoDoc';
+import { construirDescripcionRender, resolverImagenPdf } from '@/lib/pdf/render-info';
import type { BudgetResult } from '@/budget/types';
+import type { AbstractedPreferences } from '@/lib/voice/preferences';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@@ -25,6 +27,22 @@ export async function GET(
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
+ const [logoSrc, imagenSrc] = await Promise.all([
+ resolverImagenPdf(empresa.logoUrl, { formato: 'png', maxAncho: 400 }),
+ resolverImagenPdf(lead.renderUrl, { formato: 'jpeg', maxAncho: 1400 }),
+ ]);
+ const prefs = lead.preferencesSnapshot as AbstractedPreferences | null;
+ const render = imagenSrc
+ ? {
+ imagenSrc,
+ descripcion: construirDescripcionRender({
+ calidad: lead.calidadGlobal,
+ materiales: desglose?.materialesRender ?? [],
+ estilo: prefs?.estiloRender ?? [],
+ }),
+ }
+ : null;
+
const buffer = await renderToBuffer(
PresupuestoDoc({
empresa,
@@ -34,6 +52,8 @@ export async function GET(
fecha: lead.createdAt,
},
desglose,
+ logoSrc,
+ render,
})
);
diff --git a/mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx b/mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx
index 40760c4..34ab913 100644
--- a/mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx
+++ b/mvp/b2c/src/lib/pdf/PresupuestoDoc.tsx
@@ -131,6 +131,12 @@ const styles = StyleSheet.create({
textAlign: 'center',
},
empty: { marginTop: 40, fontSize: 11, color: COLOR.gray400, textAlign: 'center' },
+ disclaimer: { marginTop: 6, fontSize: 8.5, color: COLOR.gray600, lineHeight: 1.45 },
+ renderSection: { marginTop: 26 },
+ renderTitle: { fontSize: 11, fontFamily: 'Helvetica-Bold', color: COLOR.black, marginBottom: 8 },
+ renderImage: { width: '100%', height: 240, objectFit: 'cover', borderRadius: 6 },
+ renderDesc: { marginTop: 8, fontSize: 9, color: COLOR.gray600, lineHeight: 1.5 },
+ renderFootnote: { marginTop: 4, fontSize: 7, color: COLOR.gray400 },
});
const fmtEuros = (cents: number) =>
@@ -148,9 +154,18 @@ export type PresupuestoDocProps = {
cliente: { nombre: string; telefono: string; provincia: string | null };
reforma: { tipoLabel: string; fecha: Date };
desglose: BudgetResult | null;
+ logoSrc?: string | null;
+ render?: { imagenSrc: string; descripcion: string } | null;
};
-export function PresupuestoDoc({ empresa, cliente, reforma, desglose }: PresupuestoDocProps) {
+export function PresupuestoDoc({
+ empresa,
+ cliente,
+ reforma,
+ desglose,
+ logoSrc,
+ render,
+}: PresupuestoDocProps) {
const contacto = [empresa.telefono, empresa.email, empresa.web].filter(Boolean).join(' · ');
return (
@@ -166,13 +181,18 @@ export function PresupuestoDoc({ empresa, cliente, reforma, desglose }: Presupue
{empresa.direccion ? {empresa.direccion} : null}
{contacto ? {contacto} : null}
- {empresa.logoUrl ? (
+ {logoSrc ? (
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop
-
+
) : null}
PRESUPUESTO ORIENTATIVO DE REFORMA
+
+ El precio final se determinará tras la visita gratuita de {empresa.nombreEmpresa} en tu
+ casa. Esta aproximación se basa en datos estadísticos de reformas similares y la ajustamos
+ para que se acerque lo máximo posible al importe definitivo.
+
@@ -237,6 +257,19 @@ export function PresupuestoDoc({ empresa, cliente, reforma, desglose }: Presupue
Este lead aún no tiene presupuesto calculado.
)}
+ {render ? (
+
+ Así quedaría tu reforma
+ {/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop */}
+
+ {render.descripcion}
+
+ Render orientativo generado con IA. El resultado real puede variar según los
+ materiales finales y las condiciones de la obra.
+
+
+ ) : null}
+
Presupuesto orientativo. El precio final puede variar según la visita técnica.
{' · '}
diff --git a/mvp/b2c/src/lib/pdf/render-info.ts b/mvp/b2c/src/lib/pdf/render-info.ts
new file mode 100644
index 0000000..dd3f083
--- /dev/null
+++ b/mvp/b2c/src/lib/pdf/render-info.ts
@@ -0,0 +1,109 @@
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+import sharp from 'sharp';
+import type { Calidad } from '@/budget/types';
+import { CALIDAD_LABEL } from '@/lib/funnel';
+
+const MIME: Record = {
+ '.webp': 'image/webp',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.svg': 'image/svg+xml',
+};
+
+// @react-pdf solo pinta PNG y JPEG. Cualquier otro formato (WebP de nuestros uploads y
+// assets, SVG de los logos…) se rasteriza con sharp; si no, la imagen se descarta en
+// silencio y el PDF sale sin ella. El render va a JPEG (foto, sin transparencia → ligero)
+// y el logo a PNG (conserva transparencia).
+const PASSTHROUGH = new Set(['image/png', 'image/jpeg']);
+
+type OpcionesImagen = { formato?: 'png' | 'jpeg'; maxAncho?: number };
+
+async function bufferADataUri(buf: Buffer, mime: string, opts: OpcionesImagen): Promise {
+ if (PASSTHROUGH.has(mime)) {
+ return `data:${mime};base64,${buf.toString('base64')}`;
+ }
+ const pipe = sharp(buf, { density: 200 }).resize({
+ width: opts.maxAncho ?? 1400,
+ withoutEnlargement: true,
+ });
+ if (opts.formato === 'jpeg') {
+ const jpeg = await pipe.flatten({ background: '#ffffff' }).jpeg({ quality: 80 }).toBuffer();
+ return `data:image/jpeg;base64,${jpeg.toString('base64')}`;
+ }
+ const png = await pipe.png().toBuffer();
+ return `data:image/png;base64,${png.toString('base64')}`;
+}
+
+// Convierte el origen de una imagen (renderUrl, logoUrl) en un data URI PNG/JPEG que
+// @react-pdf pueda incrustar. Acepta data URIs, rutas relativas a /public y URLs http(s).
+export async function resolverImagenPdf(
+ url: string | null,
+ opts: OpcionesImagen = {}
+): Promise {
+ if (!url) return null;
+
+ if (url.startsWith('data:')) {
+ const coma = url.indexOf(',');
+ if (coma === -1) return null;
+ const cabecera = url.slice(5, coma);
+ const mime = cabecera.split(';')[0] || 'application/octet-stream';
+ const datos = url.slice(coma + 1);
+ const buf = /;base64/i.test(cabecera)
+ ? Buffer.from(datos, 'base64')
+ : Buffer.from(decodeURIComponent(datos));
+ try {
+ return await bufferADataUri(buf, mime, opts);
+ } catch {
+ return null;
+ }
+ }
+
+ // La pipeline de render entrega PNG/JPEG; las URLs remotas se pasan tal cual.
+ if (url.startsWith('http://') || url.startsWith('https://')) return url;
+
+ if (!url.startsWith('/')) return null;
+ const publicDir = path.join(process.cwd(), 'public');
+ const abs = path.normalize(path.join(publicDir, url));
+ if (abs !== publicDir && !abs.startsWith(publicDir + path.sep)) return null;
+
+ const mime = MIME[path.extname(abs).toLowerCase()];
+ if (!mime) return null;
+ try {
+ return await bufferADataUri(await readFile(abs), mime, opts);
+ } catch {
+ return null;
+ }
+}
+
+function listaNatural(items: string[]): string {
+ if (items.length <= 1) return items[0] ?? '';
+ return `${items.slice(0, -1).join(', ')} y ${items[items.length - 1]}`;
+}
+
+// Describe el render combinando los acabados (materialesRender del desglose) con el
+// estilo detectado en la llamada. Si falta cualquiera de los dos, degrada con elegancia.
+export function construirDescripcionRender(opts: {
+ calidad: Calidad | null;
+ materiales: string[];
+ estilo: string[];
+}): string {
+ const calidad = CALIDAD_LABEL[opts.calidad ?? 'media'].toLowerCase();
+ const materiales = opts.materiales.map((s) => s.trim()).filter(Boolean).slice(0, 4);
+ const estilo = opts.estilo.map((s) => s.trim()).filter(Boolean).slice(0, 3);
+
+ if (materiales.length === 0 && estilo.length === 0) {
+ return `Recreación orientativa de cómo quedaría tu espacio reformado con la ${calidad} seleccionada.`;
+ }
+
+ const partes = [
+ materiales.length > 0
+ ? `Recreación con ${calidad} y acabados en ${listaNatural(materiales)}.`
+ : `Recreación con ${calidad}.`,
+ ];
+ if (estilo.length > 0) {
+ partes.push(`Estilo ${listaNatural(estilo)} según tus preferencias.`);
+ }
+ return partes.join(' ');
+}