@mmpay/browser 0.0.0-stage → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui.ts ADDED
@@ -0,0 +1,715 @@
1
+ import {normalizeLanguage, rememberLanguage, resolveInitialLanguage, translate} from './i18n';
2
+ import {MMPAY_LOGO_DATA_URI} from './logo';
3
+ import {MMPAY_STYLES} from './styles';
4
+ import {IDesignOptions, MMPayLanguage, MMPayTerminalStatus} from './types';
5
+
6
+ export interface MMPayUIHandlers {
7
+ /** The shopper pressed close while a payment is still pending. */
8
+ onCloseRequest: () => void;
9
+ /** The shopper confirmed they want to stop the pending payment. */
10
+ onCancelConfirm: () => void;
11
+ /** The shopper closed a finished (success, failed, expired, cancelled) screen. */
12
+ onDismiss: () => void;
13
+ }
14
+
15
+ export interface MMPayPaymentView {
16
+ merchantName: string;
17
+ orderId: string;
18
+ amount?: number;
19
+ vendorQrRefId?: string;
20
+ qr?: string;
21
+ }
22
+
23
+ export interface MMPayResultView {
24
+ orderId: string;
25
+ messageKey: string;
26
+ ref?: string;
27
+ detail?: string;
28
+ }
29
+
30
+ interface Binding {
31
+ node: HTMLElement;
32
+ key: string;
33
+ params?: Record<string, string>;
34
+ attribute?: string;
35
+ }
36
+
37
+ type Child = Node | string | null | undefined | false;
38
+ type ScreenMode = 'closed' | 'loading' | 'qr' | 'result';
39
+
40
+ const HOST_ID = 'mmpay-full-modal';
41
+ const FONT_LINK_ID = 'mmpay-font-padauk';
42
+ const LOGO_URL = 'https://motephoe.com/images/mmqr.webp';
43
+ const QR_RENDER_SIZE = 480;
44
+
45
+ // Pinned builds of node-qrcode 1.5.1. The integrity hashes make the browser
46
+ // refuse a file that was altered on the CDN, which matters because the QR
47
+ // decides where the shopper's money goes.
48
+ const QR_LIBRARY_SOURCES = [
49
+ {
50
+ url: 'https://cdnjs.cloudflare.com/ajax/libs/qrcode/1.5.1/qrcode.min.js',
51
+ integrity: 'sha512-PEhlWBZBrQL7flpJPY8lXx8tIN7HWX912GzGhFTDqA3iWFrakVH3lVHomCoU9BhfKzgxfEk6EG2C3xej+9srOQ=='
52
+ },
53
+ {
54
+ url: 'https://cdn.jsdelivr.net/npm/qrcode@1.5.1/build/qrcode.js',
55
+ integrity: 'sha512-hAz8pxm1kva4chZWPXQhGehvvlIj1Loka653sQXoYHNj4sgSukoR3ZKyHEOT7kFGVW17wUqD9CbKeviIJ2XFKQ=='
56
+ }
57
+ ];
58
+
59
+ const ICONS: Record<string, string> = {
60
+ close: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>',
61
+ clock: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/><path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/></svg>',
62
+ download: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v11m0 0l-4-4m4 4l4-4M5 19h14"/></svg>',
63
+ success: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 13l4 4L19 7"/></svg>',
64
+ failed: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>',
65
+ expired: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 7v5l3 2M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
66
+ cancelled: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M5.6 5.6l12.8 12.8M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
67
+ warning: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 9v4m0 4h.01M10.3 4.3L2.6 17.5A2 2 0 004.3 20.5h15.4a2 2 0 001.7-3L13.7 4.3a2 2 0 00-3.4 0z"/></svg>'
68
+ };
69
+
70
+ function h<K extends keyof HTMLElementTagNameMap>(
71
+ tag: K,
72
+ attributes?: Record<string, string> | null,
73
+ ...children: Child[]
74
+ ): HTMLElementTagNameMap[K] {
75
+ const element = document.createElement(tag);
76
+ if (attributes) {
77
+ Object.keys(attributes).forEach(name => element.setAttribute(name, attributes[name]));
78
+ }
79
+ children.forEach(child => {
80
+ if (child === null || child === undefined || child === false) return;
81
+ element.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
82
+ });
83
+ return element;
84
+ }
85
+
86
+ /** Static, trusted SVG markup only. Never pass merchant or server data here. */
87
+ function icon(name: string, className?: string): HTMLSpanElement {
88
+ const wrapper = h('span', className ? {class: className} : null);
89
+ wrapper.style.display = 'inline-flex';
90
+ wrapper.innerHTML = ICONS[name];
91
+ return wrapper;
92
+ }
93
+
94
+ function isQrLibrary(candidate: any): boolean {
95
+ return !!candidate && typeof candidate.toCanvas === 'function';
96
+ }
97
+
98
+ let qrLibraryPromise: Promise<any> | null = null;
99
+
100
+ function loadQrLibrary(): Promise<any> {
101
+ if (qrLibraryPromise) return qrLibraryPromise;
102
+ const pending = new Promise<any>((resolve, reject) => {
103
+ const globalScope = window as any;
104
+ if (isQrLibrary(globalScope.QRCode)) {
105
+ resolve(globalScope.QRCode);
106
+ return;
107
+ }
108
+ // The merchant page may have its own, different `QRCode` global. Put it
109
+ // back after loading ours instead of deleting it.
110
+ const hadGlobal = 'QRCode' in globalScope;
111
+ const previousGlobal = globalScope.QRCode;
112
+ let index = 0;
113
+ const loadNext = () => {
114
+ if (index >= QR_LIBRARY_SOURCES.length) {
115
+ reject(new Error('QR library could not be loaded.'));
116
+ return;
117
+ }
118
+ const source = QR_LIBRARY_SOURCES[index++];
119
+ const script = document.createElement('script');
120
+ script.src = source.url;
121
+ script.integrity = source.integrity;
122
+ script.crossOrigin = 'anonymous';
123
+ script.async = true;
124
+ script.onload = () => {
125
+ const library = globalScope.QRCode;
126
+ if (hadGlobal) globalScope.QRCode = previousGlobal;
127
+ if (isQrLibrary(library)) {
128
+ resolve(library);
129
+ } else {
130
+ loadNext();
131
+ }
132
+ };
133
+ script.onerror = () => {
134
+ if (script.parentNode) script.parentNode.removeChild(script);
135
+ loadNext();
136
+ };
137
+ document.head.appendChild(script);
138
+ };
139
+ loadNext();
140
+ });
141
+ qrLibraryPromise = pending;
142
+ pending.catch(() => {
143
+ if (qrLibraryPromise === pending) qrLibraryPromise = null;
144
+ });
145
+ return pending;
146
+ }
147
+
148
+ function resolveAccent(color?: string): {background: string; text: string} | null {
149
+ if (!color) return null;
150
+ const value = color.trim();
151
+ const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value);
152
+ if (hex) {
153
+ let digits = hex[1];
154
+ if (digits.length === 3) digits = digits[0] + digits[0] + digits[1] + digits[1] + digits[2] + digits[2];
155
+ const red = parseInt(digits.substr(0, 2), 16);
156
+ const green = parseInt(digits.substr(2, 2), 16);
157
+ const blue = parseInt(digits.substr(4, 2), 16);
158
+ const brightness = (red * 299 + green * 587 + blue * 114) / 1000;
159
+ return {background: value, text: brightness > 165 ? '#000000' : '#ffffff'};
160
+ }
161
+ // Anything else must look like a plain CSS colour, so a configured value
162
+ // cannot smuggle extra CSS into the modal.
163
+ if (/^[a-z]{3,30}$/i.test(value) || /^(rgb|hsl)a?\([0-9.,%\s/]+\)$/i.test(value)) {
164
+ return {background: value, text: '#ffffff'};
165
+ }
166
+ return null;
167
+ }
168
+
169
+ export class MMPayUI {
170
+ private design: IDesignOptions;
171
+ private language: MMPayLanguage;
172
+ private handlers: MMPayUIHandlers | null = null;
173
+ private mode: ScreenMode = 'closed';
174
+ private host: HTMLDivElement | null = null;
175
+ private root: HTMLDivElement | null = null;
176
+ private card: HTMLDivElement | null = null;
177
+ private screen: HTMLElement | null = null;
178
+ private sheet: HTMLElement | null = null;
179
+ private closeButton: HTMLButtonElement | null = null;
180
+ private frameBindings: Binding[] = [];
181
+ private screenBindings: Binding[] = [];
182
+ private sheetBindings: Binding[] = [];
183
+ private payment: MMPayPaymentView | null = null;
184
+ private paymentParts: {
185
+ amount: HTMLElement;
186
+ qrBox: HTMLElement;
187
+ caption: HTMLElement;
188
+ statusRow: HTMLElement;
189
+ refValue: HTMLElement;
190
+ download: HTMLButtonElement;
191
+ } | null = null;
192
+ private countdownText: HTMLElement | null = null;
193
+ private timerBadge: HTMLElement | null = null;
194
+ private waitingBadge: HTMLElement | null = null;
195
+ private waitingBinding: Binding | null = null;
196
+ private qrCanvas: HTMLCanvasElement | null = null;
197
+ private previousBodyOverflow: string | null = null;
198
+ private previousFocus: Element | null = null;
199
+ private readonly onKeyDown = (event: KeyboardEvent) => this.handleKeyDown(event);
200
+
201
+ constructor(design: IDesignOptions, preferredLanguage?: string) {
202
+ this.design = design || {};
203
+ this.language = typeof window !== 'undefined' ? resolveInitialLanguage(preferredLanguage) : 'en';
204
+ }
205
+
206
+ public getLanguage(): MMPayLanguage {
207
+ return this.language;
208
+ }
209
+
210
+ /**
211
+ * The language is state of the UI, not of a screen, so it survives every
212
+ * screen change and is applied to whatever is rendered next.
213
+ */
214
+ public setLanguage(language: string, remember: boolean = true): void {
215
+ const next = normalizeLanguage(language);
216
+ if (!next) return;
217
+ this.language = next;
218
+ if (remember) rememberLanguage(next);
219
+ this.applyLanguage();
220
+ }
221
+
222
+ public isOpen(): boolean {
223
+ return this.mode !== 'closed';
224
+ }
225
+
226
+ public showLoading(view: MMPayPaymentView, handlers: MMPayUIHandlers): void {
227
+ this.handlers = handlers;
228
+ this.mount();
229
+ this.mode = 'loading';
230
+ this.renderPaymentScreen(view);
231
+ this.setCloseVisible(false);
232
+ }
233
+
234
+ public showQr(view: MMPayPaymentView, handlers: MMPayUIHandlers): void {
235
+ this.handlers = handlers;
236
+ this.mount();
237
+ // Coming from the loading state of the same order, only the placeholders
238
+ // are filled in, so the card does not jump or flash.
239
+ if (this.mode !== 'loading' || !this.payment || this.payment.orderId !== view.orderId || !this.paymentParts) {
240
+ this.renderPaymentScreen(view);
241
+ }
242
+ this.mode = 'qr';
243
+ this.payment = view;
244
+ this.fillPayment(view);
245
+ this.setCloseVisible(true);
246
+ }
247
+
248
+ public showResult(status: MMPayTerminalStatus, view: MMPayResultView, handlers: MMPayUIHandlers): void {
249
+ this.handlers = handlers;
250
+ this.mount();
251
+ this.mode = 'result';
252
+ this.payment = null;
253
+ this.paymentParts = null;
254
+ this.removeSheet();
255
+
256
+ const tone = status === 'SUCCESS' ? 'success' : (status === 'CANCELLED' ? 'warn' : 'danger');
257
+ const iconName = status === 'SUCCESS' ? 'success' : (status === 'CANCELLED' ? 'cancelled' : (status === 'EXPIRED' ? 'expired' : 'failed'));
258
+ const bindings: Binding[] = [];
259
+ const badge = icon(iconName, 'mmp-icon');
260
+ badge.setAttribute('data-tone', tone);
261
+ badge.style.display = '';
262
+ const screen = h('div', {class: 'mmp-screen', 'aria-live': 'polite'},
263
+ badge,
264
+ h('h2', {class: 'mmp-title', id: 'mmp-dialog-title'}, this.text(bindings, 'status.' + status)),
265
+ h('div', {class: 'mmp-pill'}, 'ID: ' + view.orderId),
266
+ h('p', {class: 'mmp-text'}, this.text(bindings, view.messageKey)),
267
+ view.ref ? h('p', {class: 'mmp-text'}, this.text(bindings, 'message.ref', {ref: view.ref})) : null,
268
+ view.detail ? h('p', {class: 'mmp-detail-text'}, view.detail) : null,
269
+ h('div', {class: 'mmp-actions'},
270
+ h('button', {class: 'mmp-btn', type: 'button', 'data-action': 'dismiss'}, this.text(bindings, 'action.close'))
271
+ )
272
+ );
273
+ this.swapScreen(screen, bindings);
274
+ this.setCloseVisible(true);
275
+ this.focusFirst(screen);
276
+ }
277
+
278
+ public showCancelConfirmation(): void {
279
+ if (!this.card || this.sheet || this.mode !== 'qr') return;
280
+ const bindings: Binding[] = [];
281
+ const badge = icon('warning', 'mmp-icon');
282
+ badge.setAttribute('data-tone', 'warn');
283
+ badge.style.display = '';
284
+ const sheet = h('div', {class: 'mmp-sheet', role: 'alertdialog', 'aria-modal': 'true'},
285
+ badge,
286
+ h('h2', {class: 'mmp-title'}, this.text(bindings, 'cancel.title')),
287
+ h('p', {class: 'mmp-text'}, this.text(bindings, 'cancel.body')),
288
+ h('div', {class: 'mmp-actions'},
289
+ h('button', {class: 'mmp-btn mmp-btn-danger', type: 'button', 'data-action': 'cancel-confirm'}, this.text(bindings, 'cancel.confirm')),
290
+ h('button', {class: 'mmp-btn mmp-btn-secondary', type: 'button', 'data-action': 'cancel-back'}, this.text(bindings, 'cancel.back'))
291
+ )
292
+ );
293
+ this.sheet = sheet;
294
+ this.sheetBindings = bindings;
295
+ this.card.appendChild(sheet);
296
+ this.setCloseVisible(false);
297
+ const back = sheet.querySelector('[data-action="cancel-back"]') as HTMLElement | null;
298
+ if (back) back.focus();
299
+ }
300
+
301
+ public hideCancelConfirmation(): void {
302
+ this.removeSheet();
303
+ }
304
+
305
+ public setCancelBusy(busy: boolean): void {
306
+ if (!this.sheet) return;
307
+ const buttons = this.sheet.querySelectorAll('button');
308
+ for (let i = 0; i < buttons.length; i++) {
309
+ buttons[i].disabled = busy;
310
+ if (buttons[i].getAttribute('data-action') === 'cancel-confirm') {
311
+ buttons[i].setAttribute('data-busy', busy ? 'true' : 'false');
312
+ }
313
+ }
314
+ }
315
+
316
+ public setCountdown(remainingSeconds: number): void {
317
+ if (!this.countdownText || !this.timerBadge) return;
318
+ const minutes = Math.floor(remainingSeconds / 60);
319
+ const seconds = remainingSeconds % 60;
320
+ this.countdownText.textContent = (minutes < 10 ? '0' : '') + minutes + ':' + (seconds < 10 ? '0' : '') + seconds;
321
+ this.timerBadge.setAttribute('data-urgent', remainingSeconds <= 60 ? 'true' : 'false');
322
+ }
323
+
324
+ public setConnectionState(state: 'live' | 'reconnecting'): void {
325
+ if (!this.waitingBadge || !this.waitingBinding) return;
326
+ if (this.waitingBadge.getAttribute('data-state') === state) return;
327
+ this.waitingBadge.setAttribute('data-state', state);
328
+ this.waitingBinding.key = state === 'live' ? 'qr.waiting' : 'qr.reconnecting';
329
+ this.applyBinding(this.waitingBinding);
330
+ }
331
+
332
+ public close(): void {
333
+ if (this.host && this.host.parentNode) {
334
+ this.host.parentNode.removeChild(this.host);
335
+ }
336
+ if (this.mode !== 'closed') {
337
+ document.removeEventListener('keydown', this.onKeyDown, true);
338
+ if (this.previousBodyOverflow !== null) {
339
+ document.body.style.overflow = this.previousBodyOverflow;
340
+ this.previousBodyOverflow = null;
341
+ }
342
+ const previous = this.previousFocus as HTMLElement | null;
343
+ if (previous && typeof previous.focus === 'function' && document.contains(previous)) {
344
+ try {
345
+ previous.focus();
346
+ } catch (e) { }
347
+ }
348
+ }
349
+ this.mode = 'closed';
350
+ this.host = this.root = this.card = null;
351
+ this.screen = this.sheet = null;
352
+ this.closeButton = null;
353
+ this.payment = null;
354
+ this.paymentParts = null;
355
+ this.countdownText = this.timerBadge = this.waitingBadge = null;
356
+ this.waitingBinding = null;
357
+ this.qrCanvas = null;
358
+ this.previousFocus = null;
359
+ this.frameBindings = [];
360
+ this.screenBindings = [];
361
+ this.sheetBindings = [];
362
+ this.handlers = null;
363
+ }
364
+
365
+ private mount(): void {
366
+ if (this.host && this.host.parentNode) return;
367
+ const stale = document.getElementById(HOST_ID);
368
+ if (stale && stale.parentNode) stale.parentNode.removeChild(stale);
369
+
370
+ const host = document.createElement('div');
371
+ host.id = HOST_ID;
372
+ host.style.cssText = 'all: initial; position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 2147483000; display: block;';
373
+ const container: ShadowRoot | HTMLElement = typeof host.attachShadow === 'function' ? host.attachShadow({mode: 'open'}) : host;
374
+
375
+ const style = document.createElement('style');
376
+ style.textContent = MMPAY_STYLES;
377
+ container.appendChild(style);
378
+
379
+ const mode = this.design.mode || 'light';
380
+ const prefersDark = typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: dark)').matches;
381
+ const isDark = mode === 'auto' ? prefersDark : mode.indexOf('dark') !== -1;
382
+ const root = h('div', {class: 'mmp-root' + (isDark ? ' mmp-dark' : '') + (mode.indexOf('translucent') !== -1 ? ' mmp-translucent' : '')});
383
+ const accent = resolveAccent(this.design.color);
384
+ if (accent) {
385
+ root.style.setProperty('--mmp-accent', accent.background);
386
+ root.style.setProperty('--mmp-accent-text', accent.text);
387
+ }
388
+
389
+ this.frameBindings = [];
390
+ const languageGroup = h('div', {class: 'mmp-lang', role: 'group'},
391
+ h('button', {type: 'button', 'data-action': 'language', 'data-language': 'en', lang: 'en'}, 'EN'),
392
+ h('button', {type: 'button', 'data-action': 'language', 'data-language': 'mm', lang: 'my'}, 'မြန်မာ')
393
+ );
394
+ this.frameBindings.push({node: languageGroup, key: 'action.language', attribute: 'aria-label'});
395
+ const closeButton = h('button', {class: 'mmp-close', type: 'button', 'data-action': 'close'});
396
+ closeButton.appendChild(icon('close'));
397
+ this.frameBindings.push({node: closeButton, key: 'action.close', attribute: 'aria-label'});
398
+
399
+ const card = h('div', {class: 'mmp-card', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'mmp-dialog-title', tabindex: '-1'},
400
+ h('div', {class: 'mmp-topbar'}, languageGroup, closeButton)
401
+ );
402
+ root.appendChild(card);
403
+ container.appendChild(root);
404
+ root.addEventListener('click', event => this.handleClick(event));
405
+
406
+ this.host = host;
407
+ this.root = root;
408
+ this.card = card;
409
+ this.closeButton = closeButton;
410
+ this.screen = null;
411
+
412
+ this.previousFocus = document.activeElement;
413
+ this.previousBodyOverflow = document.body.style.overflow;
414
+ document.body.style.overflow = 'hidden';
415
+ document.body.appendChild(host);
416
+ document.addEventListener('keydown', this.onKeyDown, true);
417
+ this.ensureFont();
418
+ this.applyLanguage();
419
+ card.focus();
420
+ }
421
+
422
+ private renderPaymentScreen(view: MMPayPaymentView): void {
423
+ this.removeSheet();
424
+ const bindings: Binding[] = [];
425
+ const logo = h('img', {src: LOGO_URL, alt: 'MMQR', decoding: 'async'});
426
+ logo.onerror = () => {
427
+ logo.style.display = 'none';
428
+ };
429
+
430
+ const amount = h('div', {class: 'mmp-amount'});
431
+ const qrBox = h('div', {class: 'mmp-qr'});
432
+ const caption = h('div', {class: 'mmp-caption', 'aria-live': 'polite'});
433
+ const statusRow = h('div', {class: 'mmp-status-row'});
434
+ const refValue = h('strong');
435
+ const download = h('button', {class: 'mmp-btn', type: 'button', 'data-action': 'download'},
436
+ icon('download'),
437
+ this.text(bindings, 'qr.download')
438
+ );
439
+ download.disabled = true;
440
+
441
+ const screen = h('div', {class: 'mmp-screen'},
442
+ h('div', {class: 'mmp-brand'}, logo),
443
+ h('div', {class: 'mmp-merchant', id: 'mmp-dialog-title'}, view.merchantName),
444
+ amount,
445
+ qrBox,
446
+ caption,
447
+ statusRow,
448
+ h('div', {class: 'mmp-details'},
449
+ h('div', {class: 'mmp-detail'}, h('span', null, this.text(bindings, 'qr.orderId')), h('strong', null, view.orderId)),
450
+ h('div', {class: 'mmp-detail'}, h('span', null, this.text(bindings, 'qr.refNo')), refValue)
451
+ ),
452
+ download,
453
+ h('div', {class: 'mmp-powered'}, this.text(bindings, 'qr.poweredBy'))
454
+ );
455
+
456
+ this.payment = view;
457
+ this.paymentParts = {amount, qrBox, caption, statusRow, refValue, download};
458
+ this.countdownText = this.timerBadge = this.waitingBadge = null;
459
+ this.waitingBinding = null;
460
+ this.qrCanvas = null;
461
+ this.swapScreen(screen, bindings);
462
+
463
+ this.setAmount(view.amount);
464
+ this.showQrPlaceholder();
465
+ refValue.appendChild(h('span', {class: 'mmp-skeleton', style: 'width: 110px;'}));
466
+ caption.appendChild(h('div', {class: 'mmp-caption-title'}, this.text(this.screenBindings, 'loading.title')));
467
+ caption.appendChild(h('div', {class: 'mmp-caption-sub'}, this.text(this.screenBindings, 'loading.subtitle')));
468
+ statusRow.style.visibility = 'hidden';
469
+ statusRow.appendChild(h('span', {class: 'mmp-timer'}, '00:00'));
470
+ }
471
+
472
+ private fillPayment(view: MMPayPaymentView): void {
473
+ const parts = this.paymentParts;
474
+ if (!parts) return;
475
+ this.setAmount(view.amount);
476
+
477
+ parts.refValue.textContent = view.vendorQrRefId || '-';
478
+
479
+ this.clear(parts.caption);
480
+ parts.caption.appendChild(h('div', {class: 'mmp-hint'}, this.text(this.screenBindings, 'qr.scanHint')));
481
+
482
+ this.clear(parts.statusRow);
483
+ parts.statusRow.style.visibility = '';
484
+ const waitingText = this.text(this.screenBindings, 'qr.waiting');
485
+ this.waitingBinding = this.screenBindings[this.screenBindings.length - 1];
486
+ this.waitingBadge = h('span', {class: 'mmp-waiting', 'data-state': 'live', role: 'status'}, h('span', {class: 'mmp-dot'}), waitingText);
487
+ this.countdownText = h('span', null, '--:--');
488
+ this.timerBadge = h('span', {class: 'mmp-timer', 'data-urgent': 'false'}, icon('clock'), this.countdownText);
489
+ parts.statusRow.appendChild(this.waitingBadge);
490
+ parts.statusRow.appendChild(this.timerBadge);
491
+
492
+ this.renderQr();
493
+ }
494
+
495
+ private setAmount(amount?: number): void {
496
+ const parts = this.paymentParts;
497
+ if (!parts) return;
498
+ this.clear(parts.amount);
499
+ if (typeof amount === 'number' && isFinite(amount)) {
500
+ const formatted = amount.toLocaleString('en-US', {minimumFractionDigits: 0, maximumFractionDigits: 0});
501
+ parts.amount.appendChild(h('span', {class: 'mmp-amount-value'}, formatted));
502
+ parts.amount.appendChild(h('span', {class: 'mmp-amount-currency'}, 'MMK'));
503
+ } else {
504
+ parts.amount.appendChild(h('span', {class: 'mmp-skeleton', style: 'width: 150px; height: 26px; border-radius: 9px;'}));
505
+ }
506
+ }
507
+
508
+ private showQrPlaceholder(): void {
509
+ const parts = this.paymentParts;
510
+ if (!parts) return;
511
+ this.clear(parts.qrBox);
512
+ const logo = h('img', {class: 'mmp-loader-logo', src: MMPAY_LOGO_DATA_URI, alt: 'MyanMyanPay'});
513
+ parts.qrBox.appendChild(h('div', {class: 'mmp-qr-placeholder'},
514
+ logo,
515
+ h('div', {class: 'mmp-loader-track', 'aria-hidden': 'true'}, h('div', {class: 'mmp-loader-bar'}))
516
+ ));
517
+ }
518
+
519
+ private renderQr(): void {
520
+ const parts = this.paymentParts;
521
+ const view = this.payment;
522
+ if (!parts || !view || !view.qr) return;
523
+ const qrData = view.qr;
524
+ const isCurrent = () => this.paymentParts === parts && this.payment === view;
525
+ const fail = () => {
526
+ if (!isCurrent()) return;
527
+ this.qrCanvas = null;
528
+ parts.download.disabled = true;
529
+ this.clear(parts.qrBox);
530
+ // The bindings of an earlier failed attempt point at removed nodes.
531
+ this.screenBindings = this.screenBindings.filter(binding => binding.key !== 'qr.loadFailed' && binding.key !== 'qr.retry');
532
+ parts.qrBox.appendChild(h('div', {class: 'mmp-qr-error', role: 'alert'},
533
+ this.text(this.screenBindings, 'qr.loadFailed'),
534
+ h('button', {type: 'button', 'data-action': 'retry-qr'}, this.text(this.screenBindings, 'qr.retry'))
535
+ ));
536
+ };
537
+ loadQrLibrary().then(library => {
538
+ if (!isCurrent()) return;
539
+ const canvas = document.createElement('canvas');
540
+ library.toCanvas(canvas, qrData, {
541
+ width: QR_RENDER_SIZE,
542
+ margin: 1,
543
+ color: {dark: '#000000', light: '#ffffff'},
544
+ errorCorrectionLevel: 'M'
545
+ }, (error: unknown) => {
546
+ if (error) {
547
+ fail();
548
+ return;
549
+ }
550
+ if (!isCurrent()) return;
551
+ canvas.setAttribute('role', 'img');
552
+ canvas.setAttribute('aria-label', 'MMQR');
553
+ this.clear(parts.qrBox);
554
+ parts.qrBox.appendChild(canvas);
555
+ this.qrCanvas = canvas;
556
+ parts.download.disabled = false;
557
+ });
558
+ }).catch(fail);
559
+ }
560
+
561
+ private downloadQr(): void {
562
+ if (!this.qrCanvas || !this.payment) return;
563
+ try {
564
+ const link = document.createElement('a');
565
+ link.href = this.qrCanvas.toDataURL('image/png');
566
+ link.download = 'MMPay-QR-' + this.payment.orderId.replace(/[^a-zA-Z0-9_-]/g, '') + '.png';
567
+ document.body.appendChild(link);
568
+ link.click();
569
+ document.body.removeChild(link);
570
+ } catch (e) { }
571
+ }
572
+
573
+ private handleClick(event: Event): void {
574
+ let target = event.target as HTMLElement | null;
575
+ while (target && target !== this.root && !(target.getAttribute && target.getAttribute('data-action'))) {
576
+ target = target.parentNode as HTMLElement | null;
577
+ }
578
+ if (!target || target === this.root || !this.handlers) return;
579
+ if ((target as HTMLButtonElement).disabled) return;
580
+ const action = target.getAttribute('data-action');
581
+ if (action === 'language') {
582
+ this.setLanguage(target.getAttribute('data-language') || 'en');
583
+ } else if (action === 'close') {
584
+ this.requestClose();
585
+ } else if (action === 'dismiss') {
586
+ this.handlers.onDismiss();
587
+ } else if (action === 'cancel-confirm') {
588
+ this.handlers.onCancelConfirm();
589
+ } else if (action === 'cancel-back') {
590
+ this.hideCancelConfirmation();
591
+ } else if (action === 'download') {
592
+ this.downloadQr();
593
+ } else if (action === 'retry-qr') {
594
+ this.showQrPlaceholder();
595
+ this.renderQr();
596
+ }
597
+ }
598
+
599
+ private requestClose(): void {
600
+ if (!this.handlers) return;
601
+ if (this.mode === 'result') {
602
+ this.handlers.onDismiss();
603
+ } else if (this.mode === 'qr') {
604
+ this.handlers.onCloseRequest();
605
+ }
606
+ }
607
+
608
+ private handleKeyDown(event: KeyboardEvent): void {
609
+ if (this.mode === 'closed') return;
610
+ if (event.key === 'Escape' || event.key === 'Esc') {
611
+ event.stopPropagation();
612
+ if (this.sheet) {
613
+ const confirm = this.sheet.querySelector('[data-action="cancel-confirm"]') as HTMLButtonElement | null;
614
+ if (!confirm || !confirm.disabled) this.hideCancelConfirmation();
615
+ } else {
616
+ this.requestClose();
617
+ }
618
+ return;
619
+ }
620
+ if (event.key !== 'Tab' || !this.card) return;
621
+ // Keep keyboard focus inside the modal while it is open.
622
+ const scope: HTMLElement = this.sheet || this.card;
623
+ const candidates = scope.querySelectorAll('button');
624
+ const focusable: HTMLElement[] = [];
625
+ for (let i = 0; i < candidates.length; i++) {
626
+ const button = candidates[i];
627
+ if (!button.disabled && !button.hidden) focusable.push(button);
628
+ }
629
+ if (focusable.length === 0) {
630
+ event.preventDefault();
631
+ return;
632
+ }
633
+ const shadow = this.host && this.host.shadowRoot;
634
+ const active = (shadow ? shadow.activeElement : document.activeElement) as HTMLElement | null;
635
+ const index = active ? focusable.indexOf(active) : -1;
636
+ event.preventDefault();
637
+ const nextIndex = event.shiftKey
638
+ ? (index <= 0 ? focusable.length - 1 : index - 1)
639
+ : (index === -1 || index === focusable.length - 1 ? 0 : index + 1);
640
+ focusable[nextIndex].focus();
641
+ }
642
+
643
+ private swapScreen(screen: HTMLElement, bindings: Binding[]): void {
644
+ if (!this.card) return;
645
+ if (this.screen && this.screen.parentNode) this.screen.parentNode.removeChild(this.screen);
646
+ this.card.appendChild(screen);
647
+ this.screen = screen;
648
+ this.screenBindings = bindings;
649
+ }
650
+
651
+ private removeSheet(): void {
652
+ if (this.sheet && this.sheet.parentNode) this.sheet.parentNode.removeChild(this.sheet);
653
+ const hadSheet = !!this.sheet;
654
+ this.sheet = null;
655
+ this.sheetBindings = [];
656
+ if (hadSheet && this.card && this.mode !== 'closed') {
657
+ this.setCloseVisible(true);
658
+ this.card.focus();
659
+ }
660
+ }
661
+
662
+ private setCloseVisible(visible: boolean): void {
663
+ if (this.closeButton) this.closeButton.hidden = !visible;
664
+ }
665
+
666
+ private focusFirst(scope: HTMLElement): void {
667
+ const button = scope.querySelector('button') as HTMLElement | null;
668
+ if (button) button.focus();
669
+ }
670
+
671
+ private clear(element: HTMLElement): void {
672
+ while (element.firstChild) element.removeChild(element.firstChild);
673
+ }
674
+
675
+ /** Creates a text node holder that is re-translated whenever the language changes. */
676
+ private text(bindings: Binding[], key: string, params?: Record<string, string>): HTMLSpanElement {
677
+ const node = h('span');
678
+ const binding: Binding = {node, key, params};
679
+ bindings.push(binding);
680
+ this.applyBinding(binding);
681
+ return node;
682
+ }
683
+
684
+ private applyBinding(binding: Binding): void {
685
+ const value = translate(this.language, binding.key, binding.params);
686
+ if (binding.attribute) {
687
+ binding.node.setAttribute(binding.attribute, value);
688
+ } else {
689
+ binding.node.textContent = value;
690
+ }
691
+ }
692
+
693
+ private applyLanguage(): void {
694
+ if (!this.root) return;
695
+ this.root.setAttribute('data-lang', this.language);
696
+ this.root.setAttribute('lang', this.language === 'mm' ? 'my' : 'en');
697
+ const toggles = this.root.querySelectorAll('[data-action="language"]');
698
+ for (let i = 0; i < toggles.length; i++) {
699
+ toggles[i].setAttribute('aria-pressed', toggles[i].getAttribute('data-language') === this.language ? 'true' : 'false');
700
+ }
701
+ this.frameBindings.forEach(binding => this.applyBinding(binding));
702
+ this.screenBindings.forEach(binding => this.applyBinding(binding));
703
+ this.sheetBindings.forEach(binding => this.applyBinding(binding));
704
+ }
705
+
706
+ /** Web fonts must be declared in the page itself to be usable inside a shadow root. */
707
+ private ensureFont(): void {
708
+ if (document.getElementById(FONT_LINK_ID)) return;
709
+ const link = document.createElement('link');
710
+ link.id = FONT_LINK_ID;
711
+ link.rel = 'stylesheet';
712
+ link.href = 'https://fonts.googleapis.com/css2?family=Padauk:wght@400;700&display=swap';
713
+ document.head.appendChild(link);
714
+ }
715
+ }