From e580d228e5f6b1d312d1cead3bed84c3dc6394f3 Mon Sep 17 00:00:00 2001 From: Artem Sushchev Date: Sun, 26 Apr 2026 00:38:31 +0200 Subject: [PATCH] Add payment URL QR support to invoices - Persist payment URL in form state and shared links - Include a QR code in generated PDFs and downloads - Update tests for defaults, reload, sharing, and download --- bun.lock | 63 +++++++++++++++++++++++++++ e2e/form-defaults.spec.ts | 6 +++ e2e/share-download.spec.ts | 44 +++++++++++++++++++ package.json | 4 +- public/index.html | 7 +++ public/js/app.ts | 9 ++-- public/js/form.ts | 2 + public/js/pdf-generator.ts | 87 +++++++++++++++++++++++++++++--------- public/js/url-sharing.ts | 2 +- public/key-map.json | 1 + src/types.ts | 1 + 11 files changed, 202 insertions(+), 24 deletions(-) diff --git a/bun.lock b/bun.lock index fd98ad4..a67b2e4 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "easypdf-lite", @@ -7,10 +8,12 @@ "jspdf": "^2.5.2", "jspdf-autotable": "^3.8.4", "lz-string": "^1.5.0", + "qrcode": "1.5.4", }, "devDependencies": { "@playwright/test": "^1.49.0", "@types/lz-string": "^1.5.0", + "@types/qrcode": "1.5.6", "bun-types": "^1.3.9", "typescript": "^5.9.3", }, @@ -25,8 +28,14 @@ "@types/node": ["@types/node@25.2.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-BkmoP5/FhRYek5izySdkOneRyXYN35I860MFAGupTdebyE66uZaR+bXLHq8k4DirE5DwQi3NuhvRU1jqTVwUrQ=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "atob": ["atob@2.1.2", "", { "bin": { "atob": "bin/atob.js" } }, "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg=="], "base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="], @@ -35,40 +44,84 @@ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="], + "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "core-js": ["core-js@3.48.0", "", {}, "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ=="], "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dompurify": ["dompurify@2.5.8", "", {}, "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "jspdf": ["jspdf@2.5.2", "", { "dependencies": { "@babel/runtime": "^7.23.2", "atob": "^2.1.2", "btoa": "^1.2.1", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.6", "core-js": "^3.6.0", "dompurify": "^2.5.4", "html2canvas": "^1.0.0-rc.5" } }, "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ=="], "jspdf-autotable": ["jspdf-autotable@3.8.4", "", { "peerDependencies": { "jspdf": "^2.5.1" } }, "sha512-rSffGoBsJYX83iTRv8Ft7FhqfgEL2nLpGAIiqruEQQ3e4r0qdLFbPUB7N9HAle0I3XgpisvyW751VHCqKUVOgQ=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="], "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "svg-pathdata": ["svg-pathdata@6.0.3", "", {}, "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw=="], "text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="], @@ -78,5 +131,15 @@ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="], + + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + + "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], } } diff --git a/e2e/form-defaults.spec.ts b/e2e/form-defaults.spec.ts index 8a52eca..a28ec7c 100644 --- a/e2e/form-defaults.spec.ts +++ b/e2e/form-defaults.spec.ts @@ -109,6 +109,12 @@ test.describe('Form loads with correct defaults', () => { await expect(paymentMethod).toHaveValue('Bank Transfer'); }); + test('payment URL defaults to empty', async ({ page }) => { + const paymentUrl = page.locator('[data-testid="payment-url-input"]'); + await expect(paymentUrl).toBeVisible(); + await expect(paymentUrl).toHaveValue(''); + }); + test('one default invoice item exists', async ({ page }) => { const items = page.locator('[data-testid="invoice-item"]'); await expect(items).toHaveCount(1); diff --git a/e2e/share-download.spec.ts b/e2e/share-download.spec.ts index e61983d..ccd1fb3 100644 --- a/e2e/share-download.spec.ts +++ b/e2e/share-download.spec.ts @@ -80,6 +80,34 @@ test.describe('Share link and download', () => { await expect(page.locator('[data-testid="item-net-price-input"]').first()).toHaveValue('150'); }); + test('payment URL persists in shared URL', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + + await page.locator('[data-testid="payment-url-input"]').fill('https://example.com/pay/invoice-123'); + await page.locator('[data-testid="share-invoice-link-button"]').click(); + + const toast = page.locator('[data-testid="toast"]'); + await expect(toast).toContainText('Invoice link copied to clipboard!'); + await expect(toast).toHaveClass(/success/); + await expect(toast).not.toContainText('QR code not included'); + + const sharedUrl = page.url(); + await page.evaluate(() => localStorage.clear()); + await page.goto(sharedUrl); + await page.waitForSelector('[data-testid="items-container"]'); + + await expect(page.locator('[data-testid="payment-url-input"]')).toHaveValue('https://example.com/pay/invoice-123'); + }); + + test('payment URL persists after reload', async ({ page }) => { + await page.locator('[data-testid="payment-url-input"]').fill('https://example.com/pay/reload-test'); + await page.waitForTimeout(700); + await page.reload(); + await page.waitForSelector('[data-testid="items-container"]'); + + await expect(page.locator('[data-testid="payment-url-input"]')).toHaveValue('https://example.com/pay/reload-test'); + }); + test('share shows success toast', async ({ page, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']); @@ -112,6 +140,22 @@ test.describe('Share link and download', () => { expect(filename).toMatch(/\.pdf$/); }); + test('download with payment URL triggers PDF generation', async ({ page }) => { + await page.locator('[data-testid="seller-name-input"]').fill('Test Seller'); + await page.locator('[data-testid="seller-email-input"]').fill('seller@test.com'); + await page.locator('[data-testid="seller-address-input"]').fill('123 Test St'); + await page.locator('[data-testid="buyer-name-input"]').fill('Test Buyer'); + await page.locator('[data-testid="buyer-email-input"]').fill('buyer@test.com'); + await page.locator('[data-testid="buyer-address-input"]').fill('456 Test Ave'); + await page.locator('[data-testid="payment-url-input"]').fill('https://example.com/pay/invoice-123'); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('[data-testid="download-invoice-button"]').click(); + + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/\.pdf$/); + }); + test('share with logo shows error toast', async ({ page, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']); diff --git a/package.json b/package.json index 63e157c..26ae04f 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,13 @@ "dependencies": { "jspdf": "^2.5.2", "jspdf-autotable": "^3.8.4", - "lz-string": "^1.5.0" + "lz-string": "^1.5.0", + "qrcode": "1.5.4" }, "devDependencies": { "@playwright/test": "^1.49.0", "@types/lz-string": "^1.5.0", + "@types/qrcode": "1.5.6", "bun-types": "^1.3.9", "typescript": "^5.9.3" } diff --git a/public/index.html b/public/index.html index 4ed3356..bed62ae 100644 --- a/public/index.html +++ b/public/index.html @@ -368,6 +368,13 @@

EasyPDF Lite

+
+
+ + +
+
+
diff --git a/public/js/app.ts b/public/js/app.ts index 2f60f60..53ec3ca 100644 --- a/public/js/app.ts +++ b/public/js/app.ts @@ -32,7 +32,9 @@ function showToast(message: string, type: "success" | "error" = "success") { function refreshPreview() { try { const data = extractFormData(); - updatePdfPreview(data); + void updatePdfPreview(data).catch((err) => { + console.error("PDF generation error:", err); + }); } catch (err) { console.error("PDF generation error:", err); } @@ -233,10 +235,10 @@ function setupShareAndDownload() { } if (downloadBtn) { - downloadBtn.addEventListener("click", () => { + downloadBtn.addEventListener("click", async () => { try { const data = extractFormData(); - downloadPdf(data); + await downloadPdf(data); showToast("PDF downloaded!", "success"); } catch (err) { console.error("Download error:", err); @@ -407,6 +409,7 @@ function getDefaultData(): InvoiceData { vatTableSummaryIsVisible: true, paymentMethod: "Bank Transfer", paymentMethodFieldIsVisible: true, + paymentUrl: "", notes: "", notesFieldIsVisible: true, personAuthorizedToReceiveFieldIsVisible: true, diff --git a/public/js/form.ts b/public/js/form.ts index 39a0fb8..a38d5dc 100644 --- a/public/js/form.ts +++ b/public/js/form.ts @@ -104,6 +104,7 @@ export function populateForm(data: InvoiceData): void { // Payment & notes setInputValue("paymentMethod", data.paymentMethod ?? ""); + setInputValue("paymentUrl", data.paymentUrl ?? ""); setTextareaValue("invoiceNotes", data.notes ?? ""); // Visibility checkboxes @@ -226,6 +227,7 @@ export function extractFormData(): InvoiceData { vatTableSummaryIsVisible: getCheckbox("vatTableSummaryIsVisible"), paymentMethod: getInputValue("paymentMethod"), paymentMethodFieldIsVisible: getCheckbox("paymentMethodFieldIsVisible"), + paymentUrl: getInputValue("paymentUrl") || undefined, notes: getTextareaValue("invoiceNotes"), notesFieldIsVisible: getCheckbox("notesFieldIsVisible"), personAuthorizedToReceiveFieldIsVisible: getCheckbox("personAuthorizedToReceiveFieldIsVisible"), diff --git a/public/js/pdf-generator.ts b/public/js/pdf-generator.ts index 5e899e4..a0e24e8 100644 --- a/public/js/pdf-generator.ts +++ b/public/js/pdf-generator.ts @@ -2,6 +2,7 @@ import { INVOICE_PDF_TRANSLATIONS } from './i18n'; import type { TranslationSet as TranslationStrings } from './i18n'; import { formatNumber, formatDate, formatCurrency } from './utils'; import type { InvoiceData, InvoiceItem } from '../../src/types'; +import QRCode from 'qrcode'; // jsPDF and jspdf-autotable are loaded as UMD globals via script tags declare const jspdf: { jsPDF: any }; @@ -68,6 +69,26 @@ function drawHLine(doc: any, y: number, color: string = LIGHT_GRAY): void { doc.line(MARGIN, y, PAGE_WIDTH - MARGIN, y); } +async function generatePaymentQrDataUrl(paymentUrl?: string): Promise { + const trimmed = paymentUrl?.trim(); + if (!trimmed) return undefined; + + try { + return await QRCode.toDataURL(trimmed, { + errorCorrectionLevel: 'M', + margin: 1, + width: 256, + color: { + dark: '#000000', + light: '#FFFFFF', + }, + }); + } catch (e) { + console.warn('Failed to generate payment QR code:', e); + return undefined; + } +} + // ───────────────────────────────────────────────────────────────────────────── // Section renderers // ───────────────────────────────────────────────────────────────────────────── @@ -671,10 +692,11 @@ function drawPaymentTotals( doc: any, data: InvoiceData, t: TranslationStrings, - startY: number + startY: number, + paymentQrDataUrl?: string ): number { let y = startY; - y = checkPageBreak(doc, y, 80); + y = checkPageBreak(doc, y, paymentQrDataUrl ? 150 : 80); const formattedTotal = formatNumber(data.total); const currencyCode = data.currency; @@ -684,28 +706,52 @@ function drawPaymentTotals( doc.setFontSize(12); doc.setTextColor(BLACK); const toPayText = `${t.paymentTotals.toPay}: ${formattedTotal} ${currencyCode}`; - doc.text(toPayText, MARGIN, y); + const toPayY = y; + doc.text(toPayText, MARGIN, toPayY); // Underline beneath the "To Pay" text const toPayWidth = doc.getTextWidth(toPayText); doc.setDrawColor(BLACK); doc.setLineWidth(1); - doc.line(MARGIN, y + 2, MARGIN + toPayWidth, y + 2); + doc.line(MARGIN, toPayY + 2, MARGIN + toPayWidth, toPayY + 2); - y += 20; + let leftY = toPayY + 20; + let rightY = toPayY; // "Paid: 0.00 {currency}" doc.setFont('OpenSans', 'normal'); doc.setFontSize(9); doc.setTextColor(DARK_GRAY); - doc.text(`${t.paymentTotals.paid}: ${formatNumber(0)} ${currencyCode}`, MARGIN, y); - y += 14; + doc.text(`${t.paymentTotals.paid}: ${formatNumber(0)} ${currencyCode}`, MARGIN, leftY); + leftY += 14; // "Left to Pay: {total} {currency}" - doc.text(`${t.paymentTotals.leftToPay}: ${formattedTotal} ${currencyCode}`, MARGIN, y); - y += 20; + doc.text(`${t.paymentTotals.leftToPay}: ${formattedTotal} ${currencyCode}`, MARGIN, leftY); + leftY += 20; - return y; + if (paymentQrDataUrl) { + try { + const qrWidth = 90; + const qrX = PAGE_WIDTH - MARGIN - qrWidth; + const qrY = toPayY - 10; + doc.addImage(paymentQrDataUrl, 'PNG', qrX, qrY, qrWidth, qrWidth, undefined, 'FAST'); + rightY = qrY + qrWidth + 10; + + if (data.notesFieldIsVisible && data.notes) { + doc.setFont('OpenSans', 'normal'); + doc.setFontSize(7); + doc.setTextColor(MEDIUM_GRAY); + const noteWidth = 170; + const noteLines = doc.splitTextToSize(data.notes, noteWidth); + doc.text(noteLines, PAGE_WIDTH - MARGIN, rightY, { align: 'right' }); + rightY += noteLines.length * 9 + 10; + } + } catch (e) { + console.warn('Failed to add payment QR code to PDF:', e); + } + } + + return Math.max(leftY, rightY); } function drawSignatureSection( @@ -839,13 +885,14 @@ function drawFooter(doc: any, data: InvoiceData, t: TranslationStrings): void { * Generate and return the jsPDF document object. * Does NOT save or create blob — just returns doc. */ -export function generatePdf(data: InvoiceData): any { +export async function generatePdf(data: InvoiceData): Promise { const { jsPDF } = jspdf; const doc = new jsPDF({ orientation: 'portrait', unit: 'pt', format: 'a4' }); registerFonts(doc); const t: TranslationStrings = INVOICE_PDF_TRANSLATIONS[data.language]; const taxLabel = data.taxLabelText || 'VAT'; + const paymentQrDataUrl = await generatePaymentQrDataUrl(data.paymentUrl); let y = MARGIN; @@ -862,13 +909,15 @@ export function generatePdf(data: InvoiceData): any { y = drawPaymentAndVatSummary(doc, data, t, taxLabel, y); // (e) Payment Totals - y = drawPaymentTotals(doc, data, t, y); + y = drawPaymentTotals(doc, data, t, y, paymentQrDataUrl); // (f) Signature Section y = drawSignatureSection(doc, data, t, y); // (g) Notes - y = drawNotes(doc, data, y); + if (!paymentQrDataUrl) { + y = drawNotes(doc, data, y); + } // (h) Footer (on every page) drawFooter(doc, data, t); @@ -879,8 +928,8 @@ export function generatePdf(data: InvoiceData): any { /** * Generate the PDF and return it as a Blob. */ -export function generatePdfBlob(data: InvoiceData): Blob { - const doc = generatePdf(data); +export async function generatePdfBlob(data: InvoiceData): Promise { + const doc = await generatePdf(data); return doc.output('blob') as Blob; } @@ -888,8 +937,8 @@ export function generatePdfBlob(data: InvoiceData): Blob { * Generate the PDF, create a blob URL, and trigger a download. * Filename: invoice-{language}-{invoiceNumberValue}.pdf */ -export function downloadPdf(data: InvoiceData): void { - const blob = generatePdfBlob(data); +export async function downloadPdf(data: InvoiceData): Promise { + const blob = await generatePdfBlob(data); const url = URL.createObjectURL(blob); const invoiceNumber = data.invoiceNumberObject?.value || 'draft'; const filename = `invoice-${data.language}-${invoiceNumber}.pdf`; @@ -908,8 +957,8 @@ export function downloadPdf(data: InvoiceData): void { * using a data URL. This avoids the blob URL approach which causes * Firefox/Waterfox to download the PDF instead of displaying it inline. */ -export function updatePreview(data: InvoiceData): void { - const doc = generatePdf(data); +export async function updatePreview(data: InvoiceData): Promise { + const doc = await generatePdf(data); const dataUrl = doc.output('dataurlstring') as string; const iframe = document.getElementById('pdf-preview') as HTMLIFrameElement | null; diff --git a/public/js/url-sharing.ts b/public/js/url-sharing.ts index 0755a91..b6f7dd6 100644 --- a/public/js/url-sharing.ts +++ b/public/js/url-sharing.ts @@ -12,7 +12,7 @@ export const INVOICE_KEY_COMPRESSION_MAP: Record = { vatTableSummaryIsVisible: "o", paymentMethod: "p", paymentMethodFieldIsVisible: "q", paymentDue: "r", stripePayOnlineUrl: "s", notes: "t", notesFieldIsVisible: "u", personAuthorizedToReceiveFieldIsVisible: "v", personAuthorizedToIssueFieldIsVisible: "w", - taxLabelText: "1", + taxLabelText: "1", paymentUrl: "9", label: "x", value: "y", id: "z", name: "A", address: "B", vatNo: "C", vatNoFieldIsVisible: "D", email: "E", accountNumber: "F", accountNumberFieldIsVisible: "G", diff --git a/public/key-map.json b/public/key-map.json index 2f1a3df..aa68c00 100644 --- a/public/key-map.json +++ b/public/key-map.json @@ -23,6 +23,7 @@ "personAuthorizedToReceiveFieldIsVisible": "v", "personAuthorizedToIssueFieldIsVisible": "w", "taxLabelText": "1", + "paymentUrl": "9", "label": "x", "value": "y", "id": "z", diff --git a/src/types.ts b/src/types.ts index 1e53c0f..ba446e9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -145,6 +145,7 @@ export interface InvoiceData { paymentMethod?: string; paymentMethodFieldIsVisible: boolean; paymentDue: string; + paymentUrl?: string; stripePayOnlineUrl?: string; notes?: string; notesFieldIsVisible: boolean;