diff --git a/libs/ui/src/presentation/views/components/checkout/QrisPaymentView.tsx b/libs/ui/src/presentation/views/components/checkout/QrisPaymentView.tsx index 02f8277b..c8a23e69 100644 --- a/libs/ui/src/presentation/views/components/checkout/QrisPaymentView.tsx +++ b/libs/ui/src/presentation/views/components/checkout/QrisPaymentView.tsx @@ -9,8 +9,11 @@ import { XStack, YStack, } from 'tamagui'; +import { ORDER_BRAND_NAME } from '../../../../utils/brand'; import { formatRupiah } from '../../../../utils/currency'; import { + type ComposedQrImage, + composeQrDownloadImage, downloadQrImage, isQrDownloadSupported, qrImageDataUrl, @@ -54,7 +57,7 @@ export const QrisPaymentView = ({ } | null>(null); const [secondsLeft, setSecondsLeft] = useState(() => secondsUntil(expiredAt)); const hasElapsed = useRef(false); - const [fallbackImageBase64, setFallbackImageBase64] = useState( + const [fallbackImage, setFallbackImage] = useState( null ); @@ -73,11 +76,16 @@ export const QrisPaymentView = ({ }, [secondsLeft, onCountdownElapsed]); const handleSavePress = () => { - qrRef.current?.toDataURL((base64) => { + qrRef.current?.toDataURL(async (base64) => { + const composedImage = await composeQrDownloadImage( + base64, + ORDER_BRAND_NAME, + formatRupiah(amount) + ); if (isQrDownloadSupported()) { - downloadQrImage(base64, reference); + downloadQrImage(composedImage.base64, reference); } else { - setFallbackImageBase64(base64); + setFallbackImage(composedImage); } }); }; @@ -114,15 +122,15 @@ export const QrisPaymentView = ({ !isOpen && setFallbackImageBase64(null)} + isOpen={fallbackImage !== null} + onOpenChange={(isOpen) => !isOpen && setFallbackImage(null)} > - {fallbackImageBase64 ? ( + {fallbackImage ? ( ) : null} diff --git a/libs/ui/src/utils/qrDownload.test.ts b/libs/ui/src/utils/qrDownload.test.ts index 16e34aa0..5e38f765 100644 --- a/libs/ui/src/utils/qrDownload.test.ts +++ b/libs/ui/src/utils/qrDownload.test.ts @@ -1,9 +1,27 @@ import { + composeQrDownloadImage, downloadQrImage, isQrDownloadSupported, qrImageDataUrl, } from './qrDownload'; +class FakeImage { + width = 220; + height = 220; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + private currentSrc = ''; + + set src(value: string) { + this.currentSrc = value; + this.onload?.(); + } + + get src() { + return this.currentSrc; + } +} + describe('isQrDownloadSupported', () => { afterEach(() => { jest.restoreAllMocks(); @@ -26,6 +44,52 @@ describe('qrImageDataUrl', () => { }); }); +describe('composeQrDownloadImage', () => { + const originalImage = global.Image; + + beforeEach(() => { + global.Image = FakeImage as unknown as typeof Image; + HTMLCanvasElement.prototype.getContext = jest.fn().mockReturnValue({ + fillRect: jest.fn(), + fillText: jest.fn(), + drawImage: jest.fn(), + }) as unknown as typeof HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.toDataURL = jest + .fn() + .mockReturnValue('data:image/png;base64,composed') as unknown as typeof HTMLCanvasElement.prototype.toDataURL; + }); + + afterEach(() => { + global.Image = originalImage; + jest.restoreAllMocks(); + }); + + it('draws a white canvas padded around the QR with the title and amount on top, sized to the QR plus padding', async () => { + const composed = await composeQrDownloadImage( + btoa('hello'), + 'Gatherloop Board Game Cafe', + 'Rp 36.000' + ); + + expect(composed.base64).toBe('composed'); + expect(composed.width).toBe(220 + 32 * 2); + expect(composed.height).toBe(220 + 32 * 2 + 28 + 36 + 12); + }); + + it('rejects when the QR image fails to load', async () => { + class FailingImage extends FakeImage { + set src(_value: string) { + this.onerror?.(); + } + } + global.Image = FailingImage as unknown as typeof Image; + + await expect( + composeQrDownloadImage(btoa('hello'), 'title', 'amount') + ).rejects.toThrow('Failed to load QR image'); + }); +}); + describe('downloadQrImage', () => { afterEach(() => { jest.restoreAllMocks(); diff --git a/libs/ui/src/utils/qrDownload.ts b/libs/ui/src/utils/qrDownload.ts index 88c27444..7dbf3c81 100644 --- a/libs/ui/src/utils/qrDownload.ts +++ b/libs/ui/src/utils/qrDownload.ts @@ -1,3 +1,10 @@ +const QR_CANVAS_PADDING = 32; +const QR_CANVAS_TITLE_FONT = 'bold 20px sans-serif'; +const QR_CANVAS_AMOUNT_FONT = 'bold 28px sans-serif'; +const QR_CANVAS_TITLE_HEIGHT = 28; +const QR_CANVAS_AMOUNT_HEIGHT = 36; +const QR_CANVAS_HEADER_GAP = 12; + export function isQrDownloadSupported(): boolean { return ( typeof document !== 'undefined' && 'download' in document.createElement('a') @@ -8,6 +15,75 @@ export function qrImageDataUrl(base64: string): string { return `data:image/png;base64,${base64}`; } +export type ComposedQrImage = { + base64: string; + width: number; + height: number; +}; + +export function composeQrDownloadImage( + qrBase64: string, + title: string, + amountLabel: string +): Promise { + return new Promise((resolve, reject) => { + const qrImage = new Image(); + qrImage.onload = () => { + const width = qrImage.width + QR_CANVAS_PADDING * 2; + const headerHeight = + QR_CANVAS_TITLE_HEIGHT + QR_CANVAS_AMOUNT_HEIGHT + QR_CANVAS_HEADER_GAP; + const height = QR_CANVAS_PADDING * 2 + headerHeight + qrImage.height; + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext('2d'); + if (!context) { + reject(new Error('Canvas 2D context is unavailable')); + return; + } + + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, width, height); + + context.fillStyle = '#000000'; + context.textAlign = 'center'; + context.font = QR_CANVAS_TITLE_FONT; + context.fillText( + title, + width / 2, + QR_CANVAS_PADDING + QR_CANVAS_TITLE_HEIGHT - 8 + ); + + context.font = QR_CANVAS_AMOUNT_FONT; + context.fillText( + amountLabel, + width / 2, + QR_CANVAS_PADDING + QR_CANVAS_TITLE_HEIGHT + QR_CANVAS_AMOUNT_HEIGHT - 8 + ); + + context.drawImage( + qrImage, + QR_CANVAS_PADDING, + QR_CANVAS_PADDING + headerHeight, + qrImage.width, + qrImage.height + ); + + resolve({ + base64: canvas + .toDataURL('image/png') + .replace(/^data:image\/png;base64,/, ''), + width, + height, + }); + }; + qrImage.onerror = () => reject(new Error('Failed to load QR image')); + qrImage.src = qrImageDataUrl(qrBase64); + }); +} + export function downloadQrImage(base64: string, reference: string): void { const binary = atob(base64); const bytes = new Uint8Array(binary.length);