Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -54,7 +57,7 @@ export const QrisPaymentView = ({
} | null>(null);
const [secondsLeft, setSecondsLeft] = useState(() => secondsUntil(expiredAt));
const hasElapsed = useRef(false);
const [fallbackImageBase64, setFallbackImageBase64] = useState<string | null>(
const [fallbackImage, setFallbackImage] = useState<ComposedQrImage | null>(
null
);

Expand All @@ -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);
}
});
};
Expand Down Expand Up @@ -114,15 +122,15 @@ export const QrisPaymentView = ({
</Paragraph>

<Sheet
isOpen={fallbackImageBase64 !== null}
onOpenChange={(isOpen) => !isOpen && setFallbackImageBase64(null)}
isOpen={fallbackImage !== null}
onOpenChange={(isOpen) => !isOpen && setFallbackImage(null)}
>
<YStack padding="$4" gap="$3" alignItems="center">
{fallbackImageBase64 ? (
{fallbackImage ? (
<Image
src={qrImageDataUrl(fallbackImageBase64)}
src={qrImageDataUrl(fallbackImage.base64)}
width={QR_SIZE}
height={QR_SIZE}
height={(fallbackImage.height / fallbackImage.width) * QR_SIZE}
/>
) : null}
<Paragraph textAlign="center">
Expand Down
64 changes: 64 additions & 0 deletions libs/ui/src/utils/qrDownload.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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();
Expand Down
76 changes: 76 additions & 0 deletions libs/ui/src/utils/qrDownload.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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<ComposedQrImage> {
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);
Expand Down
Loading