Mejora el PDF de presupuesto: disclaimer, render y conversión de imágenes

- Añade bajo el título un párrafo orientativo: el precio final se fija tras
  la visita gratuita y la estimación se basa en datos estadísticos ajustados
  para acercarse lo máximo posible al importe definitivo.
- Añade una sección con el render del resultado y una descripción generada a
  partir de los materiales (materialesRender) y el estilo de la llamada, con
  fallback elegante cuando faltan datos.
- @react-pdf solo incrusta PNG/JPEG: convierte con sharp los WebP/SVG (render
  y logo) que antes se descartaban en silencio dejando el PDF sin imagen. El
  render va a JPEG redimensionado (PDF ~360 KB en vez de ~2,7 MB) y el logo a
  PNG para conservar transparencia.
- Fija sharp como dependencia directa (ya venía como transitiva de Next).
- Copy nuevo añadido primero a COPY-GUIDE.md (sección entrega del presupuesto).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-02 19:48:14 +02:00
parent e9637f77ff
commit 8de139f9d3
6 changed files with 178 additions and 6 deletions

View File

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

View File

@@ -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 ? <Text style={styles.empresaDato}>{empresa.direccion}</Text> : null}
{contacto ? <Text style={styles.empresaDato}>{contacto}</Text> : null}
</View>
{empresa.logoUrl ? (
{logoSrc ? (
// eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop
<Image src={empresa.logoUrl} style={styles.logo} />
<Image src={logoSrc} style={styles.logo} />
) : null}
</View>
<Text style={styles.docTitle}>PRESUPUESTO ORIENTATIVO DE REFORMA</Text>
<Text style={styles.disclaimer}>
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.
</Text>
<View style={styles.metaRow}>
<View>
@@ -237,6 +257,19 @@ export function PresupuestoDoc({ empresa, cliente, reforma, desglose }: Presupue
<Text style={styles.empty}>Este lead aún no tiene presupuesto calculado.</Text>
)}
{render ? (
<View style={styles.renderSection} wrap={false}>
<Text style={styles.renderTitle}>Así quedaría tu reforma</Text>
{/* eslint-disable-next-line jsx-a11y/alt-text -- @react-pdf Image, no alt prop */}
<Image src={render.imagenSrc} style={styles.renderImage} />
<Text style={styles.renderDesc}>{render.descripcion}</Text>
<Text style={styles.renderFootnote}>
Render orientativo generado con IA. El resultado real puede variar según los
materiales finales y las condiciones de la obra.
</Text>
</View>
) : null}
<Text style={styles.footer} fixed>
Presupuesto orientativo. El precio final puede variar según la visita técnica.
{' · '}

View File

@@ -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<string, string> = {
'.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<string> {
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<string | null> {
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(' ');
}