/** * Marketing flyer "LogYou × LogShip" — 16:9 landscape PDF (720×405 pt, the * format of the Hive reference deck) built with pdfmake from FLYER_TEXTS and the * assets under server/assets/offers/flyer/ (photos + app screenshots rendered * from /flyer-demo/* with dummy data + integration logos). * * Opt-in attachment (default OFF) for quote and contract sends — see * contractPackage.ts ('flyer' key) and quote/send.post.ts. Cached per language. */ import { BRAND } from './quotePdf' import { renderPdfmake } from './agbPdf' import { FLYER_TEXTS } from './flyerTexts' const W = 720 const H = 405 const NAVY = BRAND.navy || '#1c324b' const ORANGE = BRAND.orange || '#f15924' const LIGHT = '#f3f5f8' const TEXT = '#1f2a37' const MUTED = '#6b7a8c' const GREEN = '#1f9d55' const LINE = '#e3e7ec' // ---------- assets ---------- const assetCache: Record = {} const loadAsset = async (file: string): Promise => { if (file in assetCache) return assetCache[file] try { const storage = useStorage('assets:server') const raw: any = await storage.getItemRaw(`offers/flyer/${file}`) if (!raw) { assetCache[file] = null; return null } const buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw) if (file.endsWith('.svg')) { assetCache[file] = buf.toString('utf8'); return assetCache[file] } const mime = file.endsWith('.png') ? 'image/png' : 'image/jpeg' assetCache[file] = `data:${mime};base64,${buf.toString('base64')}` } catch { assetCache[file] = null } return assetCache[file] } const LOGO_FILES: Record = { shopify: 'logos/shopify.png', amazon: 'logos/amazon_icon.png', ebay: 'logos/ebay.svg', shopware: 'logos/shopware.png', jtl: 'logos/jtl.png', plentyone: 'logos/plentyone_logo.png', xentral: 'logos/xentral_logo.png', temu: 'logos/temu.png', dhl: 'logos/dhl_logo_small.png', dhlexpress: 'logos/dhlexpress.png', dpd: 'logos/dpd_logo_small.png', gls: 'logos/gls_logo_small.svg', ups: 'logos/one_logo_small.png' } const LOGO_LABELS: Record = { shopify: 'Shopify', amazon: 'Amazon', ebay: 'eBay', shopware: 'Shopware', jtl: 'JTL', plentyone: 'PlentyOne', xentral: 'Xentral', temu: 'Temu', dhl: 'DHL', dhlexpress: 'DHL Express', dpd: 'DPD', gls: 'GLS', ups: 'UPS' } // ---------- layout helpers ---------- const abs = (x: number, y: number, node: any) => ({ ...node, absolutePosition: { x, y } }) // pdfkit has no rgba() colours — translucency goes through fillOpacity. const rect = (x: number, y: number, w: number, h: number, color: string, r = 0, opacity = 1) => abs(0, 0, { canvas: [{ type: 'rect', x, y, w, h, r, color, ...(opacity < 1 ? { fillOpacity: opacity } : {}) }] }) const line = (x1: number, y1: number, x2: number, y2: number, color = LINE, lw = 0.75) => abs(0, 0, { canvas: [{ type: 'line', x1, y1, x2, y2, lineWidth: lw, lineColor: color }] }) const circle = (x: number, y: number, r: number, color: string) => abs(0, 0, { canvas: [{ type: 'ellipse', x, y, r1: r, r2: r, color }] }) // A bare absolutely-positioned text wraps against the page edge, so the width // is enforced through a single-column container. const txt = (x: number, y: number, w: number, text: any, opts: any = {}) => abs(x, y, { columns: [{ width: w, text, ...opts }] }) const img = (data: string | null, x: number, y: number, w?: number, h?: number, fit?: [number, number]) => data ? abs(x, y, fit ? { image: data, fit } : { image: data, ...(w ? { width: w } : {}), ...(h ? { height: h } : {}) }) : { text: '' } const svg = (data: string | null, x: number, y: number, w: number, h: number) => data ? abs(x, y, { svg: data, fit: [w, h] }) : { text: '' } /** pdfmake nodes with absolutePosition never advance the flow, so every page is * explicitly opened with a break marker. */ const pageBreak = () => ({ text: '', pageBreak: 'before' as const }) export const buildFlyerDocDefinition = async (language: string) => { const lang = language === 'en' ? 'en' : 'de' const T = FLYER_TEXTS[lang] const A = async (f: string) => loadAsset(f) const logoLogyou = await A('logyou-logo.png') const logoLogyouDark = await A('logyou-logo-dark.png') const logoLogship = await A('logship-logo-dark.png') const logoLogshipLight = await A('logship-logo-light.png') const markLogship = await A('logship-mark.png') let pageNo = 0 const pages: any[][] = [] // Footer strip: section tag left, LogYou mark + page right const footer = (tag: string, dark = false) => [ txt(28, H - 22, 300, tag, { fontSize: 7, bold: true, characterSpacing: 0.8, color: dark ? '#9fb0c2' : MUTED }), txt(W - 120, H - 22, 92, `${T.footer.page} ${pageNo}`, { fontSize: 7, color: dark ? '#9fb0c2' : MUTED, alignment: 'right' }), ...(logoLogyouDark && logoLogyou ? [img(dark ? logoLogyouDark : logoLogyou, W - 26 - 44, H - 26, 44)] : []) ] const bullet = (x: number, y: number, w: number, b: { h: string; t: string }, dark = false) => [ circle(x + 7, y + 7, 7, ORANGE), txt(x + 4.2, y + 2.6, 8, '✓', { fontSize: 7.5, bold: true, color: '#fff' }), txt(x + 22, y, w - 22, [{ text: b.h + ' ', bold: true, color: dark ? '#fff' : NAVY }, { text: b.t, color: dark ? '#dbe3ec' : TEXT }], { fontSize: 9, lineHeight: 1.3 }) ] const headline = (x: number, y: number, w: number, title: string, sub: string, dark = false) => [ txt(x, y, w, title, { fontSize: 21, bold: true, color: dark ? '#fff' : NAVY, lineHeight: 1.12 }), txt(x, y + 62, w, sub, { fontSize: 9.5, color: dark ? '#c9d3df' : MUTED, lineHeight: 1.35 }) ] /** Standard feature slide: text left, image right (portrait crop), bullets. */ const featureSlide = (S: any, image: string | null, extra: any[] = [], opts: { screenshot?: boolean; phone?: string | null } = {}) => { pageNo += 1 const nodes: any[] = [rect(0, 0, W, H, '#fff')] if (opts.screenshot && image) { // screenshot in a navy "device" frame on the right const iw = 306, ih = Math.round(iw * 652 / 1207) nodes.push(rect(384, 70, iw + 12, ih + 12, NAVY, 8)) nodes.push(img(image, 390, 76, iw)) nodes.push(rect(384, 70 + ih + 20, iw + 12, 3, ORANGE)) } else if (image) { nodes.push(img(image, 400, 0, 320, H)) } if (opts.phone) { nodes.push(rect(556, 60, 132, 258, '#111', 16)) nodes.push(img(opts.phone, 561, 65, 122)) } nodes.push(...headline(30, 42, 340, S.title, S.sub)) let y = 148 for (const b of S.bullets) { nodes.push(...bullet(30, y, 340, b)); y += 62 } nodes.push(...extra) nodes.push(...footer(S.tag)) pages.push(nodes) } // ---------- 1 · Cover ---------- pageNo += 1 const dash = await A('screen-dashboard.png') pages.push([ rect(0, 0, W, H, NAVY), rect(0, H - 6, W, 6, ORANGE), img(logoLogyouDark, 30, 26, 96), txt(30, 76, 330, T.cover.kicker, { fontSize: 8, color: '#9fb0c2', characterSpacing: 0.5 }), txt(30, 96, 330, T.cover.title, { fontSize: 30, bold: true, color: '#fff', lineHeight: 1.08 }), txt(30, 222, 320, T.cover.sub, { fontSize: 9.5, color: '#dbe3ec', lineHeight: 1.4 }), line(30, 306, 330, 306, '#4a6280'), txt(30, 318, 330, T.cover.trust, { fontSize: 11, bold: true, color: ORANGE }), txt(30, 338, 330, T.cover.channels, { fontSize: 7.5, color: '#9fb0c2', lineHeight: 1.3 }), // laptop-ish frame with the dashboard rect(376, 58, 334, 226, '#0e1a29', 10), rect(382, 64, 322, 214, '#fff', 4), img(dash, 383, 65, 320), rect(356, 284, 374, 12, '#243b55', 5), rect(516, 290, 54, 4, '#0e1a29', 2), img(logoLogshipLight, 590, 336, 110), txt(W - 120, H - 24, 92, 'logyou.de', { fontSize: 7, color: '#9fb0c2', alignment: 'right' }) ]) // ---------- 2 · Why ---------- pageNo += 1 { const nodes: any[] = [rect(0, 0, W, H, '#fff'), ...headline(30, 40, 560, T.why.title, T.why.sub)] const cardW = 206 T.why.items.forEach((it: any, i: number) => { const x = 30 + i * (cardW + 21) nodes.push(rect(x, 150, cardW, 190, LIGHT, 10)) nodes.push(rect(x, 150, cardW, 5, i === 1 ? NAVY : ORANGE, 2)) nodes.push(circle(x + 30, 194, 17, NAVY)) nodes.push(txt(x + 22, 185, 16, String(i + 1), { fontSize: 13, bold: true, color: '#fff', alignment: 'center' })) nodes.push(txt(x + 18, 226, cardW - 36, it.h, { fontSize: 12, bold: true, color: NAVY, lineHeight: 1.2 })) nodes.push(txt(x + 18, 266, cardW - 36, it.t, { fontSize: 8.8, color: TEXT, lineHeight: 1.35 })) }) nodes.push(...footer('WARUM LOGYOU')) pages.push(nodes) } // ---------- 3 · Stats ---------- pageNo += 1 { const photo = await A('warehouse_scanning_p.jpg') const nodes: any[] = [rect(0, 0, W, H, '#fff'), img(photo, 0, 0, 300, H), rect(0, 0, 300, 30, NAVY, 0, 0.85), txt(14, 10, 272, T.combine.photoCaption, { fontSize: 7.5, color: '#fff' }), ...headline(330, 40, 360, T.combine.title, T.combine.sub)] T.combine.stats.forEach((s: any, i: number) => { const x = 330 + (i % 2) * 185 const y = 168 + Math.floor(i / 2) * 96 nodes.push(rect(x, y, 172, 82, LIGHT, 10)) nodes.push(rect(x, y, 4, 82, i % 3 === 0 ? ORANGE : i === 1 ? NAVY : GREEN, 2)) nodes.push(txt(x + 16, y + 12, 150, s.v, { fontSize: 22, bold: true, color: i % 3 === 0 ? ORANGE : i === 1 ? NAVY : GREEN })) nodes.push(txt(x + 16, y + 44, 150, s.l, { fontSize: 9.5, bold: true, color: NAVY })) nodes.push(txt(x + 16, y + 58, 150, s.s, { fontSize: 7.5, color: MUTED })) }) nodes.push(...footer('LOGYOU × LOGSHIP')) pages.push(nodes) } // ---------- 4 · Services overview ---------- pageNo += 1 { const nodes: any[] = [rect(0, 0, W, H, LIGHT), txt(30, 40, 660, T.services.title, { fontSize: 21, bold: true, color: NAVY })] const colW = 124 const photos = ['forklift_aisle_t.jpg', 'warehouse_shelves_t.jpg', 'barcode_scanner_t.jpg', 'delivery_truck_t.jpg', 'cardboard_boxes_t.jpg'] for (let i = 0; i < T.services.items.length; i++) { const it = T.services.items[i] const x = 30 + i * (colW + 10) const p = await A(photos[i]) nodes.push(rect(x, 96, colW, 280, '#fff', 10)) nodes.push(img(p, x, 96, colW, 96)) nodes.push(rect(x, 96, colW, 96, NAVY, 0, 0.22)) nodes.push(circle(x + 22, 206, 15, ORANGE)) nodes.push(txt(x + 14, 199, 16, String(i + 1), { fontSize: 11, bold: true, color: '#fff', alignment: 'center' })) nodes.push(txt(x + 12, 232, colW - 24, it.h, { fontSize: 11, bold: true, color: NAVY })) nodes.push(txt(x + 12, 252, colW - 24, it.t, { fontSize: 8, color: TEXT, lineHeight: 1.35 })) } nodes.push(...footer('LEISTUNGEN')) pages.push(nodes) } // ---------- 5-8 · Feature slides ---------- featureSlide(T.inbound, await A('warehouse_shelves_p.jpg')) featureSlide(T.pick, await A('barcode_scanner_p.jpg'), [], { phone: await A('screen-mobile.png') }) { // shipping + carrier logos row const extra: any[] = [txt(30, 334, 340, T.shipping.carriers, { fontSize: 7.5, bold: true, color: MUTED, characterSpacing: 0.5 })] const keys = ['dhl', 'dhlexpress', 'dpd', 'gls'] let x = 30 for (const k of keys) { const f = LOGO_FILES[k] const d = await A(f) extra.push(rect(x, 348, 60, 30, '#fff', 6)) extra.push(f.endsWith('.svg') ? svg(d, x + 8, 353, 44, 20) : img(d, x + 8, 353, undefined, undefined, [44, 20])) x += 68 } featureSlide(T.shipping, await A('delivery_truck_p.jpg'), extra) } featureSlide(T.returns, await A('screen-returns.png'), [], { screenshot: true }) // ---------- 9 · LogShip Cloud (big screenshot) ---------- pageNo += 1 { const orders = await A('screen-orders.png') const stock = await A('screen-stock.png') const nodes: any[] = [rect(0, 0, W, H, NAVY), rect(0, H - 6, W, 6, ORANGE), img(logoLogshipLight, 30, 30, 92), ...headline(30, 60, 300, T.cloud.title, T.cloud.sub, true)] let y = 176 for (const b of T.cloud.bullets) { nodes.push(...bullet(30, y, 300, b, true)); y += 60 } const h1 = Math.round(330 * 652 / 1207), h2 = Math.round(250 * 652 / 1207) nodes.push(rect(356, 40, 340, h1 + 10, '#0e1a29', 8)) nodes.push(img(orders, 361, 45, 330)) nodes.push(rect(436, 40 + h1 + 24, 260, h2 + 10, '#0e1a29', 8)) nodes.push(img(stock, 441, 45 + h1 + 24, 250)) nodes.push(...footer(T.cloud.tag, true)) pages.push(nodes) } // ---------- 10 · Customer experience (tracking) ---------- featureSlide(T.experience, await A('screen-tracking.png'), [], { screenshot: true }) // ---------- 11 · VideoShip ---------- featureSlide(T.videoship, await A('conveyor_packages_p.jpg')) // ---------- 12 · Integrations ---------- pageNo += 1 { const nodes: any[] = [rect(0, 0, W, H, '#fff'), ...headline(30, 40, 600, T.integrations.title, T.integrations.sub)] let y = 150 for (const g of T.integrations.groups) { nodes.push(txt(30, y, 300, g.h, { fontSize: 8, bold: true, color: MUTED, characterSpacing: 0.6 })) let x = 30 for (const k of g.keys) { const f = LOGO_FILES[k] const d = f ? await A(f) : null nodes.push(rect(x, y + 14, 76, 56, LIGHT, 8)) if (d) nodes.push(f.endsWith('.svg') ? svg(d, x + 12, y + 22, 52, 24) : img(d, x + 12, y + 22, undefined, undefined, [52, 24])) nodes.push(txt(x, y + 52, 76, LOGO_LABELS[k] || k, { fontSize: 7, color: MUTED, alignment: 'center' })) x += 82 } y += 92 } nodes.push(txt(30, y + 4, 600, T.integrations.more, { fontSize: 9, color: TEXT, italics: true })) nodes.push(...footer(T.integrations.tag)) pages.push(nodes) } // ---------- 13 · Special services ---------- pageNo += 1 { const nodes: any[] = [rect(0, 0, W, H, LIGHT), ...headline(30, 40, 620, T.special.title, T.special.sub)] const cw = 210, ch = 86 T.special.items.forEach((it: any, i: number) => { const x = 30 + (i % 3) * (cw + 15) const y = 146 + Math.floor(i / 3) * (ch + 12) nodes.push(rect(x, y, cw, ch, '#fff', 10)) nodes.push(rect(x, y + 12, 4, ch - 24, ORANGE, 2)) nodes.push(txt(x + 16, y + 12, cw - 28, it.h, { fontSize: 10.5, bold: true, color: NAVY })) nodes.push(txt(x + 16, y + 30, cw - 28, it.t, { fontSize: 8, color: TEXT, lineHeight: 1.35 })) }) nodes.push(...footer(T.special.tag)) pages.push(nodes) } // ---------- 14 · Pricing structure ---------- pageNo += 1 { const nodes: any[] = [rect(0, 0, W, H, '#fff'), ...headline(30, 40, 620, T.pricing.title, T.pricing.sub)] const cw = 210, ch = 66 T.pricing.items.forEach((it: any, i: number) => { const x = 30 + (i % 3) * (cw + 15) const y = 138 + Math.floor(i / 3) * (ch + 10) nodes.push(rect(x, y, cw, ch, LIGHT, 8)) nodes.push(circle(x + 18, y + 20, 9, i < 3 ? ORANGE : NAVY)) nodes.push(txt(x + 13, y + 15, 10, String(i + 1), { fontSize: 8, bold: true, color: '#fff', alignment: 'center' })) nodes.push(txt(x + 36, y + 12, cw - 46, it.h, { fontSize: 10, bold: true, color: NAVY })) nodes.push(txt(x + 36, y + 30, cw - 46, it.t, { fontSize: 7.8, color: TEXT, lineHeight: 1.3 })) }) nodes.push(rect(30, 296, 660, 56, NAVY, 8)) nodes.push(txt(46, 308, 630, T.pricing.note, { fontSize: 8.5, color: '#dbe3ec', lineHeight: 1.35 })) nodes.push(...footer(T.pricing.tag)) pages.push(nodes) } // ---------- 15 · Start / contact ---------- pageNo += 1 { const bg = await A('warehouse_scanning_w.jpg') const nodes: any[] = [rect(0, 0, W, H, NAVY), img(bg, 0, 0, W, H, [W, H]), rect(0, 0, W, H, NAVY, 0, 0.88), rect(0, H - 6, W, 6, ORANGE), txt(30, 36, 640, T.start.title, { fontSize: 21, bold: true, color: '#fff' })] T.start.steps.forEach((s: any, i: number) => { const x = 30 + i * 226 nodes.push(rect(x, 84, 206, 150, '#ffffff', 10, 0.1)) nodes.push(circle(x + 26, 110, 15, ORANGE)) nodes.push(txt(x + 18, 102, 16, String(i + 1), { fontSize: 12, bold: true, color: '#fff', alignment: 'center' })) nodes.push(txt(x + 16, 134, 178, s.h, { fontSize: 11.5, bold: true, color: '#fff' })) nodes.push(txt(x + 16, 156, 178, s.t, { fontSize: 8.5, color: '#dbe3ec', lineHeight: 1.35 })) }) nodes.push(txt(30, 252, 660, T.start.promise, { fontSize: 10, italics: true, color: '#fff', lineHeight: 1.35 })) nodes.push(rect(30, 290, 420, 78, '#fff', 10)) nodes.push(txt(46, 300, 390, T.start.contact.h, { fontSize: 11, bold: true, color: NAVY })) nodes.push(txt(46, 318, 390, T.start.contact.lines.join('\n'), { fontSize: 8.5, color: TEXT, lineHeight: 1.4 })) nodes.push(img(logoLogyouDark, 480, 300, 100)) nodes.push(img(logoLogshipLight, 480, 338, 100)) nodes.push(...footer(T.start.tag, true)) pages.push(nodes) } const content: any[] = [] pages.forEach((nodes, i) => { if (i > 0) content.push(pageBreak()) content.push(...nodes) }) return { pageSize: { width: W, height: H }, pageMargins: [0, 0, 0, 0], info: { title: T.docTitle, author: 'LogYou GmbH', subject: T.docTitle }, content, defaultStyle: { font: 'Roboto', fontSize: 9, color: TEXT } } } const pdfCache: Record = {} export const flyerFilename = (language: string) => (language === 'en' ? 'LogYou-LogShip_Overview.pdf' : 'LogYou-LogShip_Ueberblick.pdf') export const generateFlyerPdf = async (language: string, force = false): Promise<{ buffer: Buffer; filename: string; language: string }> => { const lang = language === 'en' ? 'en' : 'de' const cached = pdfCache[lang] if (!force && cached && Date.now() - cached.at < 3600_000) return { buffer: cached.buffer, filename: flyerFilename(lang), language: lang } const def = await buildFlyerDocDefinition(lang) const buffer = await renderPdfmake(def) pdfCache[lang] = { buffer, at: Date.now() } return { buffer, filename: flyerFilename(lang), language: lang } }