/** * Initial-call questionnaire PDF (pdf-lib), DE/EN, multi-page. * * Two modes from ONE layout: * blank → every answer is a NATIVE AcroForm field (text fields + check boxes), * so the sheet can be printed and filled by hand OR completed in a PDF * viewer. Company/contact are prefilled from the lead. * filled → answers are drawn as text / ticked boxes, no form fields (final * document that is attached to the lead). * * Question catalog + answer helpers: app/utils/leadQuestionnaire.ts (shared * with the modal). Branding mirrors sepaPdf.ts (logo, navy/orange, legal footer). * Standard Helvetica (WinAnsi) — exotic glyphs mapped by `winAnsi()`. */ import { PDFDocument, StandardFonts, rgb, type PDFFont, type PDFPage } from 'pdf-lib' import { getQuoteLogos, LEGAL_FOOTER_LINES } from './quotePdf' import { parseDataUrl } from './pdfImages' import { QUESTIONNAIRE, qLabel, formatQuestionnaireAnswer, type QLang, type QQuestion } from '../../../app/utils/leadQuestionnaire' const A4: [number, number] = [595.28, 841.89] const M = 48 const NAVY = rgb(0.11, 0.196, 0.294) const ORANGE = rgb(0.945, 0.349, 0.141) const GRAY = rgb(0.45, 0.5, 0.55) const LINE = rgb(0.8, 0.83, 0.87) const FIELD_BG = rgb(0.955, 0.965, 0.98) const BAND = rgb(0.93, 0.95, 0.97) const TEXT = rgb(0.13, 0.16, 0.2) const WHITE = rgb(1, 1, 1) const winAnsi = (s: any) => String(s ?? '') .replace(/→/g, '->').replace(/—/g, '-').replace(/–/g, '-') .replace(/…/g, '...').replace(/[‘’]/g, "'").replace(/[“”„]/g, '"') .replace(/[^\x00-\xFF€]/g, '?') const wrap = (text: string, font: PDFFont, size: number, maxWidth: number): string[] => { const out: string[] = [] for (const para of String(text || '').split('\n')) { const words = para.split(/\s+/).filter(Boolean) if (!words.length) { out.push(''); continue } let line = '' for (const w of words) { const probe = line ? `${line} ${w}` : w if (font.widthOfTextAtSize(probe, size) <= maxWidth) { line = probe; continue } if (line) out.push(line) line = w } if (line) out.push(line) } return out } export interface QuestionnaireMeta { company?: string contactName?: string email?: string phone?: string /** Employee who conducted the call. */ interviewer?: string /** ISO date or human date of the conversation. */ date?: string /** 'phone' | 'meeting' | 'video' | '' */ channel?: string } export interface QuestionnairePdfOptions { language: QLang mode: 'blank' | 'filled' meta?: QuestionnaireMeta answers?: Record } const T = { de: { title: 'Erstgespräch – Fragebogen', subtitle: 'Fulfillment-Bedarfsanalyse für Neukunden', company: 'Firma', contact: 'Ansprechpartner', date: 'Datum', channel: 'Gesprächsart', interviewer: 'Gespräch geführt von (LogYou)', yes: 'Ja', no: 'Nein', channels: { phone: 'Telefon', meeting: 'Persönlich', video: 'Video-Call' } as Record, page: (n: number, total: number) => `Seite ${n} von ${total}`, intro: 'Bitte alle zutreffenden Felder ausfüllen bzw. ankreuzen. Dieser Bogen dient als Grundlage für Angebot und Onboarding.', fillHint: 'Ausfüllbares PDF – am Bildschirm oder handschriftlich ausfüllen.' }, en: { title: 'Initial call – Questionnaire', subtitle: 'Fulfilment needs assessment for new clients', company: 'Company', contact: 'Contact person', date: 'Date', channel: 'Type of conversation', interviewer: 'Conducted by (LogYou)', yes: 'Yes', no: 'No', channels: { phone: 'Phone', meeting: 'In person', video: 'Video call' } as Record, page: (n: number, total: number) => `Page ${n} of ${total}`, intro: 'Please complete or tick all applicable fields. This sheet is the basis for the quote and the onboarding.', fillHint: 'Fillable PDF – complete on screen or by hand.' } } export const buildQuestionnairePdf = async (opts: QuestionnairePdfOptions): Promise => { const lang: QLang = opts.language === 'en' ? 'en' : 'de' const t = T[lang] const filled = opts.mode === 'filled' const meta: QuestionnaireMeta = opts.meta || {} const answers: Record = opts.answers || {} const pdf = await PDFDocument.create() pdf.setTitle(`${t.title}${meta.company ? ` – ${meta.company}` : ''}`) pdf.setAuthor('LogYou GmbH') pdf.setLanguage(lang === 'en' ? 'en-GB' : 'de-DE') const font = await pdf.embedFont(StandardFonts.Helvetica) const bold = await pdf.embedFont(StandardFonts.HelveticaBold) const form = pdf.getForm() const W = A4[0] const contentW = W - 2 * M const bottomLimit = M + 34 // legal footer + page number live below this let logoImg: any = null try { const logos = await getQuoteLogos() const src = logos.light ? parseDataUrl(logos.light) : null if (src?.buffer) logoImg = await pdf.embedPng(src.buffer) } catch { /* logo optional */ } let page: PDFPage = pdf.addPage(A4) let y = A4[1] - M let fieldSeq = 0 const text = (s: string, x: number, yy: number, size: number, f: PDFFont = font, color = TEXT) => { page.drawText(winAnsi(s), { x, y: yy, size, font: f, color }) } const newPage = () => { page = pdf.addPage(A4) y = A4[1] - M // small running header on continuation pages text(t.title, M, y - 8, 8, bold, GRAY) if (meta.company) text(winAnsi(meta.company), W - M - font.widthOfTextAtSize(winAnsi(meta.company), 8), y - 8, 8, font, GRAY) y -= 18 page.drawLine({ start: { x: M, y }, end: { x: W - M, y }, thickness: 0.5, color: LINE }) y -= 14 } const ensure = (h: number) => { if (y - h < bottomLimit) newPage() } // ---- widgets ------------------------------------------------------------ /** Text box: blank → AcroForm text field; filled → drawn value. */ const textBox = (name: string, x: number, yy: number, w: number, h: number, value: string, multiline = false) => { page.drawRectangle({ x, y: yy - h, width: w, height: h, color: FIELD_BG, borderColor: LINE, borderWidth: 0.6 }) if (!filled) { const tf = form.createTextField(`q.${name}.${fieldSeq++}`) if (multiline) tf.enableMultiline() tf.addToPage(page, { x: x + 1, y: yy - h + 1, width: w - 2, height: h - 2, borderWidth: 0, backgroundColor: FIELD_BG, textColor: TEXT, font }) tf.setFontSize(9) // after addToPage: the widget's /DA entry must exist first tf.setText(winAnsi(value || '')) return } if (!value) return const size = 9 const lines = wrap(winAnsi(value), font, size, w - 10) const maxLines = Math.max(1, Math.floor((h - 6) / (size * 1.3))) let ty = yy - 4 - size for (const l of lines.slice(0, maxLines)) { text(l, x + 5, ty, size, font, TEXT); ty -= size * 1.3 } } /** Check box (11pt): blank → AcroForm check box; filled → drawn box + tick. */ const checkBox = (name: string, x: number, yy: number, checked: boolean) => { const s = 10.5 if (!filled) { const cb = form.createCheckBox(`q.${name}.${fieldSeq++}`) cb.addToPage(page, { x, y: yy - s, width: s, height: s, borderWidth: 0.8, borderColor: GRAY, backgroundColor: WHITE }) if (checked) cb.check() return } page.drawRectangle({ x, y: yy - s, width: s, height: s, color: WHITE, borderColor: GRAY, borderWidth: 0.8 }) if (checked) { page.drawLine({ start: { x: x + 2.2, y: yy - s + 5 }, end: { x: x + 4.4, y: yy - s + 2.4 }, thickness: 1.6, color: ORANGE }) page.drawLine({ start: { x: x + 4.4, y: yy - s + 2.4 }, end: { x: x + 8.6, y: yy - s + 8.4 }, thickness: 1.6, color: ORANGE }) } } /** Inline flow of labelled check boxes, wraps within contentW. Returns height used. */ const optionRow = (name: string, options: Array<{ key: string; label: string; checked: boolean }>, startY: number): number => { const size = 9 let x = M let yy = startY let rows = 1 for (const o of options) { const lw = font.widthOfTextAtSize(winAnsi(o.label), size) const need = 10.5 + 5 + lw + 16 if (x + need > W - M && x > M) { x = M; yy -= 16; rows++ } checkBox(`${name}.${o.key}`, x, yy, o.checked) text(o.label, x + 15, yy - 8.5, size, font, TEXT) x += need } return rows * 16 } // ---- header -------------------------------------------------------------- if (logoImg) { const h = 26 const w = (logoImg.width / logoImg.height) * h page.drawImage(logoImg, { x: W - M - w, y: y - h + 6, width: w, height: h }) } text(t.title, M, y - 14, 17, bold, NAVY) y -= 30 text(t.subtitle, M, y, 9.5, font, ORANGE) y -= 10 page.drawLine({ start: { x: M, y }, end: { x: W - M, y }, thickness: 1, color: ORANGE }) y -= 8 text(filled ? t.intro : t.fillHint, M, y - 6, 7.5, font, GRAY) y -= 16 // meta block: 2 columns × 3 rows const colW = (contentW - 16) / 2 const metaField = (label: string, name: string, x: number, yy: number, w: number, value: string) => { text(label.toUpperCase(), x, yy - 7, 6.5, bold, GRAY) textBox(name, x, yy - 10, w, 18, value) } const dateHuman = (() => { const d = meta.date ? new Date(meta.date) : new Date() return isNaN(d.getTime()) ? String(meta.date || '') : d.toLocaleDateString(lang === 'en' ? 'en-GB' : 'de-DE', { timeZone: 'Europe/Berlin' }) })() metaField(t.company, 'meta.company', M, y, colW, meta.company || '') metaField(t.contact, 'meta.contact', M + colW + 16, y, colW, meta.contactName || '') y -= 30 metaField(t.date, 'meta.date', M, y, colW, filled ? dateHuman : (meta.date ? dateHuman : '')) metaField(t.interviewer, 'meta.interviewer', M + colW + 16, y, colW, meta.interviewer || '') y -= 30 // channel as check boxes text(t.channel.toUpperCase(), M, y - 7, 6.5, bold, GRAY) y -= 12 y -= optionRow('meta.channel', Object.entries(t.channels).map(([key, label]) => ({ key, label, checked: filled && meta.channel === key })), y) page.drawLine({ start: { x: M, y }, end: { x: W - M, y }, thickness: 0.5, color: LINE }) y -= 10 // ---- categories & questions ---------------------------------------------- const labelSize = 9 const questionBlockHeight = (q: QQuestion): number => { // rough estimate used for page-break decisions (label + control) const labelLines = wrap(winAnsi(qLabel(q, lang)), bold, labelSize, contentW - (q.type === 'bool' ? 120 : 0)).length let h = labelLines * 12 + (q.hintDe ? 10 : 0) + 4 if (q.type === 'text' || q.type === 'number') h += 22 if (q.type === 'textarea') h += 46 if (q.type === 'choice' || q.type === 'multi') h += 16 * Math.ceil((q.options || []).length / 4) + 4 if (q.detail) h += 24 return h + 5 } for (const cat of QUESTIONNAIRE) { ensure(24 + Math.min(questionBlockHeight(cat.questions[0]), 48)) // category band page.drawRectangle({ x: M, y: y - 16, width: contentW, height: 18, color: BAND }) page.drawRectangle({ x: M, y: y - 16, width: 3, height: 18, color: ORANGE }) text(qLabel(cat, lang), M + 10, y - 11, 9.5, bold, NAVY) y -= 24 for (const q of cat.questions) { ensure(questionBlockHeight(q)) const label = qLabel(q, lang) const hint = lang === 'en' ? q.hintEn : q.hintDe const value = filled ? formatQuestionnaireAnswer({ ...q, detail: undefined }, answers, lang) : '' const detailVal = q.detail ? String(answers?.[`${q.id}_detail`] ?? '').trim() : '' if (q.type === 'bool') { // label left, Ja/Nein boxes right-aligned on the first line const lines = wrap(winAnsi(label), bold, labelSize, contentW - 125) const v = answers?.[q.id] const bx = W - M - 110 checkBox(`${q.id}.yes`, bx, y, filled && v === true) text(t.yes, bx + 15, y - 8.5, 9, font, TEXT) checkBox(`${q.id}.no`, bx + 58, y, filled && v === false) text(t.no, bx + 73, y - 8.5, 9, font, TEXT) let ly = y - 8.5 for (const l of lines) { text(l, M, ly, labelSize, bold, TEXT); ly -= 12 } y = ly + 12 - 12 if (hint) { text(hint, M, y - 2, 7.5, font, GRAY); y -= 10 } if (q.detail) { y -= 4 text(qLabel(q.detail, lang).toUpperCase(), M, y - 7, 6.5, bold, GRAY) textBox(`${q.id}.detail`, M, y - 10, contentW, 18, detailVal) y -= 30 } y -= 5 continue } // label (+ hint) const lines = wrap(winAnsi(label), bold, labelSize, contentW) let ly = y - 8.5 for (const l of lines) { text(l, M, ly, labelSize, bold, TEXT); ly -= 12 } y = ly + 12 - 12 if (hint) { text(hint, M, y - 2, 7.5, font, GRAY); y -= 10 } y -= 3 if (q.type === 'text' || q.type === 'number') { const w = q.type === 'number' ? Math.min(contentW, 200) : contentW textBox(q.id, M, y, w, 18, value) if (q.type === 'number' && !filled && q.unit && !q.unitChoice) text(q.unit.replace('Monat', lang === 'en' ? 'month' : 'Monat'), M + w + 6, y - 12.5, 8.5, font, GRAY) if (q.type === 'number' && q.unitChoice) { // unit picker next to the number box let x = M + w + 12 for (const o of q.unitChoice) { const l = qLabel(o, lang) checkBox(`${q.id}.unit.${o.key}`, x, y - 3.5, filled ? answers?.[`${q.id}_unit`] === o.key : false) text(l, x + 15, y - 12, 8.5, font, TEXT) x += 15 + font.widthOfTextAtSize(winAnsi(l), 8.5) + 14 } } y -= 20 } else if (q.type === 'textarea') { // The free-notes box (last question) grows to the rest of its page — room for // hand-written notes on the printed form, and no near-empty trailing page. const h = q.id === 'notes' ? Math.max(42, Math.min(y - bottomLimit - 6, 420)) : 42 textBox(q.id, M, y, contentW, h, value, true) y -= h + 4 } else if (q.type === 'choice' || q.type === 'multi') { const v = answers?.[q.id] const opts = (q.options || []).map((o) => ({ key: o.key, label: qLabel(o, lang), checked: filled && (q.type === 'multi' ? Array.isArray(v) && v.includes(o.key) : v === o.key) })) y -= optionRow(q.id, opts, y) - 2 } if (q.detail) { ensure(30) text(qLabel(q.detail, lang).toUpperCase(), M, y - 7, 6.5, bold, GRAY) textBox(`${q.id}.detail`, M, y - 10, contentW, 18, detailVal) y -= 30 } y -= 5 } y -= 2 } // ---- footer on every page (legal lines + page number) ---------------------- const pages = pdf.getPages() pages.forEach((p, i) => { let fyy = M - 8 for (const line of [...LEGAL_FOOTER_LINES].reverse()) { p.drawText(winAnsi(line), { x: M, y: fyy, size: 6.5, font, color: GRAY }) fyy += 9 } p.drawLine({ start: { x: M, y: fyy + 2 }, end: { x: W - M, y: fyy + 2 }, thickness: 0.5, color: LINE }) const pn = t.page(i + 1, pages.length) p.drawText(winAnsi(pn), { x: W - M - font.widthOfTextAtSize(pn, 6.5), y: M - 8, size: 6.5, font, color: GRAY }) }) if (!filled) form.updateFieldAppearances(font) return Buffer.from(await pdf.save()) }