@mmpay/browser 0.0.0-stage → 1.3.1

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/index.ts ADDED
@@ -0,0 +1,490 @@
1
+ import {MMPayAPI} from './api';
2
+ import {PaymentStatusWatcher} from './stream';
3
+ import {
4
+ ICreatePaymentRequestParams,
5
+ ICreatePaymentResponse,
6
+ IModalEventResult,
7
+ IPaymentShowRequestParams,
8
+ IPollingRequest,
9
+ IPollingResponse,
10
+ MMPayLanguage,
11
+ MMPayTerminalStatus,
12
+ MMPayTransport,
13
+ SDKOptions
14
+ } from './types';
15
+ import {MMPayUI, MMPayUIHandlers} from './ui';
16
+
17
+ const MIN_LOADING_MS = 400;
18
+
19
+ export class MMPaySDK {
20
+ private POLL_INTERVAL_MS: number;
21
+ private TIMEOUT_SECONDS: number = 900;
22
+ private readonly CACHE_KEY: string = 'mmpay_pending_tx';
23
+ private environment: 'sandbox' | 'production';
24
+ private transport: MMPayTransport;
25
+ protected merchantName: string;
26
+ private onCompleteCallback: ((result: IModalEventResult) => void) | null = null;
27
+ private statusWatcher: PaymentStatusWatcher | null = null;
28
+ private countdownIntervalId: number | undefined = undefined;
29
+ private pendingApiResponse: any | null = null;
30
+ private pendingPaymentPayload: any | null = null;
31
+ private flowId: number = 0;
32
+ private settled: boolean = true;
33
+ private cancelling: boolean = false;
34
+ private creating: boolean = false;
35
+ protected api: MMPayAPI;
36
+ protected ui: MMPayUI;
37
+
38
+ constructor(publishableKey: string, options: SDKOptions = {}) {
39
+ if (!publishableKey) {
40
+ throw new Error("A Publishable Key is required to initialize [MMPaySDK].");
41
+ }
42
+ if (publishableKey.includes('pk_test')) {
43
+ this.environment = 'sandbox';
44
+ } else if (publishableKey.includes('pk_live')) {
45
+ this.environment = 'production';
46
+ } else {
47
+ this.environment = options.environment || 'production';
48
+ }
49
+ const baseUrl = options.baseUrl || 'https://bbapi.myanmyanpay.com';
50
+ this.merchantName = options.merchantName || 'MyanMyanPay';
51
+ this.POLL_INTERVAL_MS = options.pollInterval || 5000;
52
+ this.transport = options.transport || 'auto';
53
+ if (typeof options.timeoutSeconds === 'number' && isFinite(options.timeoutSeconds)) {
54
+ // The server expires an unpaid order after 15 minutes, so the countdown
55
+ // can be shortened but not extended.
56
+ this.TIMEOUT_SECONDS = Math.min(900, Math.max(60, Math.floor(options.timeoutSeconds)));
57
+ }
58
+ this.api = new MMPayAPI(baseUrl, this.environment, publishableKey);
59
+ this.ui = new MMPayUI({
60
+ mode: options.design?.mode || 'light',
61
+ color: options.design?.color
62
+ }, options.language);
63
+ if (typeof window !== 'undefined') {
64
+ this._checkAndAutoResume();
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Switches the modal language. Works at any time, including while a payment
70
+ * is on screen, and is remembered for the shopper's next visit.
71
+ */
72
+ public setLanguage(language: MMPayLanguage | 'my'): void {
73
+ this.ui.setLanguage(language);
74
+ }
75
+
76
+ public getLanguage(): MMPayLanguage {
77
+ return this.ui.getLanguage();
78
+ }
79
+
80
+ public async showPaymentModal(
81
+ params: ICreatePaymentRequestParams,
82
+ onComplete: (result: IModalEventResult) => void
83
+ ): Promise<void> {
84
+ // A second call while an order is still being created (double click) must
85
+ // not create a second order.
86
+ if (this.creating) {
87
+ this.onCompleteCallback = onComplete;
88
+ return;
89
+ }
90
+ const flowId = this._beginFlow(onComplete);
91
+ this.creating = true;
92
+ this.ui.showLoading({
93
+ merchantName: this.merchantName,
94
+ orderId: params.orderId,
95
+ amount: params.amount
96
+ }, this._getUiHandlers());
97
+ try {
98
+ const startTime = Date.now();
99
+ if (await this._showUnfinishedOrder(flowId, startTime)) return;
100
+ if (flowId !== this.flowId) return;
101
+ const tokenNonce = new Date().getTime().toString() + '_create';
102
+ const tokenResponse = await this.api.createToken({
103
+ amount: params.amount,
104
+ orderId: params.orderId,
105
+ nonce: tokenNonce
106
+ });
107
+ if (flowId !== this.flowId) return;
108
+ this.api.setToken(tokenResponse.token);
109
+ const apiResponse: any = await this.api.createPayment({...params, nonce: tokenNonce});
110
+ await this._holdLoading(startTime);
111
+ if (flowId !== this.flowId) return;
112
+ const actualRefId = apiResponse?.vendorQrRefId || apiResponse?.transactionRefId;
113
+ if (apiResponse && apiResponse.qr && actualRefId) {
114
+ apiResponse.vendorQrRefId = actualRefId;
115
+ this.pendingPaymentPayload = {...params, tokenNonce};
116
+ this.pendingApiResponse = apiResponse;
117
+ const expireAt = Date.now() + (this.TIMEOUT_SECONDS * 1000);
118
+ this._writeCache({
119
+ payload: {amount: apiResponse.amount, orderId: params.orderId, nonce: tokenNonce + '_poll'},
120
+ apiResponse: apiResponse,
121
+ expireAt: expireAt,
122
+ token: this.api.getToken(),
123
+ environment: this.environment
124
+ });
125
+ this._showQr(apiResponse, params.orderId);
126
+ this._startStatusWatch({orderId: params.orderId, nonce: tokenNonce + '_poll'});
127
+ this._startCountdown(params.orderId, expireAt);
128
+ this._triggerEvent({
129
+ created: true,
130
+ orderId: params.orderId,
131
+ vendorQrRefId: actualRefId,
132
+ amount: params.amount
133
+ });
134
+ } else {
135
+ throw new Error("Invalid API Response: Missing QR Data.");
136
+ }
137
+ } catch (error: any) {
138
+ if (flowId !== this.flowId) return;
139
+ this._failFlow(params.orderId, error, params.amount);
140
+ } finally {
141
+ if (flowId === this.flowId) this.creating = false;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Spam guard: while the shopper has an order that is not paid, cancelled or
147
+ * expired, a new one is not created. The unfinished order is shown again
148
+ * instead. The server is asked first, so an order that finished in the
149
+ * meantime does not block a new one.
150
+ */
151
+ private async _showUnfinishedOrder(flowId: number, startTime: number): Promise<boolean> {
152
+ const cached = this._readCache();
153
+ if (!cached) return false;
154
+ if (cached.environment !== this.environment || Date.now() >= cached.expireAt) {
155
+ this._clearCache();
156
+ return false;
157
+ }
158
+ this.api.setToken(cached.token);
159
+ try {
160
+ const response = await this.api.pollPayment({orderId: cached.payload.orderId, nonce: cached.payload.nonce});
161
+ await this._holdLoading(startTime);
162
+ if (flowId !== this.flowId) return true;
163
+ if ((response.status || '').toUpperCase() === 'PENDING') {
164
+ this._resumePaymentState(cached.apiResponse, cached.payload, cached.expireAt);
165
+ return true;
166
+ }
167
+ } catch (error) {
168
+ if (flowId !== this.flowId) return true;
169
+ }
170
+ this.api.setToken(null);
171
+ this._clearCache();
172
+ return false;
173
+ }
174
+
175
+ public async pay(orderId: string, onComplete: (result: IModalEventResult) => void): Promise<void> {
176
+ const flowId = this._beginFlow(onComplete);
177
+ const cached = this._readCache();
178
+ if (cached) {
179
+ if (cached.environment === this.environment && Date.now() < cached.expireAt && cached.payload?.orderId === orderId) {
180
+ this.api.setToken(cached.token);
181
+ this._resumePaymentState(cached.apiResponse, cached.payload, cached.expireAt);
182
+ return;
183
+ }
184
+ this._clearCache();
185
+ }
186
+ this.ui.showLoading({merchantName: this.merchantName, orderId}, this._getUiHandlers());
187
+ const showPayload: IPaymentShowRequestParams = {orderId, nonce: new Date().getTime().toString() + '_show'};
188
+ const expireAt = Date.now() + (this.TIMEOUT_SECONDS * 1000);
189
+ try {
190
+ const startTime = Date.now();
191
+ const tokenNonce = new Date().getTime().toString() + '_read';
192
+ const tokenResponse = await this.api.createToken({orderId, nonce: tokenNonce});
193
+ if (flowId !== this.flowId) return;
194
+ this.api.setToken(tokenResponse.token);
195
+ const apiResponse: any = await this.api.showPayment(showPayload);
196
+ await this._holdLoading(startTime);
197
+ if (flowId !== this.flowId) return;
198
+ if (apiResponse) {
199
+ const status = (apiResponse.status || '').toUpperCase();
200
+ const actualRefId = apiResponse.vendorQrRefId || apiResponse.transactionRefId;
201
+ if (status !== 'PENDING') {
202
+ const terminalStatus: MMPayTerminalStatus =
203
+ status === 'SUCCESS' || status === 'CANCELLED' || status === 'EXPIRED' ? status : 'FAILED';
204
+ this._finish(terminalStatus, {
205
+ orderId: apiResponse.orderId || orderId,
206
+ amount: apiResponse.amount,
207
+ vendorQrRefId: actualRefId
208
+ }, {
209
+ // Kept as before: a non-pending status the modal has no screen for
210
+ // (REFUNDED) shows the failed screen without reporting `failed`.
211
+ failed: status === 'FAILED'
212
+ });
213
+ return;
214
+ }
215
+ if (apiResponse.qr && actualRefId) {
216
+ const mappedPaymentResponse: ICreatePaymentResponse = {...apiResponse, vendorQrRefId: actualRefId};
217
+ const mappedPaymentPayload = {amount: apiResponse.amount, orderId: apiResponse.orderId, nonce: showPayload.nonce};
218
+ this._writeCache({
219
+ payload: mappedPaymentPayload,
220
+ apiResponse: mappedPaymentResponse,
221
+ expireAt: expireAt,
222
+ token: this.api.getToken(),
223
+ environment: this.environment
224
+ });
225
+ this._resumePaymentState(mappedPaymentResponse, mappedPaymentPayload, expireAt);
226
+ return;
227
+ }
228
+ }
229
+ throw new Error("Invalid API Response: Missing QR Data.");
230
+ } catch (error: any) {
231
+ if (flowId !== this.flowId) return;
232
+ this._failFlow(orderId || 'N/A', error);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Starts a fresh flow. Anything still running from an earlier call is
238
+ * stopped, and its late responses are ignored through `flowId`.
239
+ */
240
+ private _beginFlow(onComplete: (result: IModalEventResult) => void): number {
241
+ this._stopTimers();
242
+ this.onCompleteCallback = onComplete;
243
+ this.pendingApiResponse = null;
244
+ this.pendingPaymentPayload = null;
245
+ this.settled = false;
246
+ this.cancelling = false;
247
+ this.creating = false;
248
+ return ++this.flowId;
249
+ }
250
+
251
+ /** Keeps the loading state up briefly so a fast response does not flicker. */
252
+ private async _holdLoading(startTime: number): Promise<void> {
253
+ const elapsed = Date.now() - startTime;
254
+ if (elapsed < MIN_LOADING_MS) {
255
+ await new Promise(resolve => setTimeout(resolve, MIN_LOADING_MS - elapsed));
256
+ }
257
+ }
258
+
259
+ private _showQr(apiResponse: any, orderId: string): void {
260
+ this.ui.showQr({
261
+ merchantName: this.merchantName,
262
+ orderId,
263
+ amount: apiResponse.amount,
264
+ vendorQrRefId: apiResponse.vendorQrRefId,
265
+ qr: apiResponse.qr
266
+ }, this._getUiHandlers());
267
+ }
268
+
269
+ private _failFlow(orderId: string, error: any, amount?: number): void {
270
+ this._stopTimers();
271
+ this.settled = true;
272
+ this.api.setToken(null);
273
+ this.ui.showResult('FAILED', {
274
+ orderId,
275
+ messageKey: 'message.ERROR',
276
+ detail: error?.message || 'Error occurred.'
277
+ }, this._getUiHandlers());
278
+ this._triggerEvent(amount === undefined ? {failed: true, orderId} : {failed: true, orderId, amount});
279
+ }
280
+
281
+ /**
282
+ * Single exit for every finished payment, whichever source reported it
283
+ * first (socket, polling or the countdown), so the result screen and the
284
+ * merchant callback fire exactly once.
285
+ */
286
+ private _finish(
287
+ status: MMPayTerminalStatus,
288
+ info: {orderId: string; amount?: number; vendorQrRefId?: string},
289
+ overrides: {failed?: boolean; messageKey?: string} = {}
290
+ ): void {
291
+ if (this.settled) return;
292
+ this.settled = true;
293
+ this._stopTimers();
294
+ this._clearCache();
295
+ this.api.setToken(null);
296
+ this.ui.showResult(status, {
297
+ orderId: info.orderId,
298
+ messageKey: overrides.messageKey || 'message.' + status,
299
+ ref: status === 'SUCCESS' ? (info.vendorQrRefId || 'N/A') : undefined
300
+ }, this._getUiHandlers());
301
+ this._triggerEvent({
302
+ success: status === 'SUCCESS',
303
+ failed: overrides.failed !== undefined ? overrides.failed : status === 'FAILED',
304
+ expired: status === 'EXPIRED',
305
+ cancelled: status === 'CANCELLED',
306
+ amount: info.amount,
307
+ orderId: info.orderId,
308
+ vendorQrRefId: info.vendorQrRefId
309
+ });
310
+ }
311
+
312
+ protected _getUiHandlers(): MMPayUIHandlers {
313
+ return {
314
+ onCloseRequest: () => this.ui.showCancelConfirmation(),
315
+ onCancelConfirm: () => {
316
+ this._cancelPendingPayment();
317
+ },
318
+ onDismiss: () => {
319
+ this._clearCache();
320
+ this._cleanup();
321
+ }
322
+ };
323
+ }
324
+
325
+ private async _cancelPendingPayment(): Promise<void> {
326
+ if (this.cancelling) return;
327
+ this.cancelling = true;
328
+ const flowId = this.flowId;
329
+ const pending = this.pendingPaymentPayload;
330
+ if (pending && !this.settled) {
331
+ this.ui.setCancelBusy(true);
332
+ try {
333
+ await this.api.cancelPayment({
334
+ orderId: pending.orderId,
335
+ nonce: new Date().getTime().toString() + '_cancel'
336
+ });
337
+ if (flowId === this.flowId && !this.settled) {
338
+ this._triggerEvent({
339
+ cancelled: true,
340
+ orderId: pending.orderId,
341
+ amount: pending.amount
342
+ });
343
+ }
344
+ } catch (e) { }
345
+ }
346
+ // A result that arrived while the cancel request was in flight is already
347
+ // on screen. Leave it there instead of closing over it.
348
+ if (flowId !== this.flowId || this.settled) return;
349
+ this.settled = true;
350
+ this._clearCache();
351
+ this._cleanup();
352
+ }
353
+
354
+ protected _triggerEvent(eventData: IModalEventResult): void {
355
+ if (this.onCompleteCallback) {
356
+ try {
357
+ this.onCompleteCallback(eventData);
358
+ } catch (e) { }
359
+ }
360
+ }
361
+
362
+ private _stopTimers(): void {
363
+ if (this.statusWatcher) {
364
+ this.statusWatcher.stop();
365
+ this.statusWatcher = null;
366
+ }
367
+ if (this.countdownIntervalId !== undefined) {
368
+ window.clearInterval(this.countdownIntervalId);
369
+ this.countdownIntervalId = undefined;
370
+ }
371
+ }
372
+
373
+ protected _cleanup(): void {
374
+ this._stopTimers();
375
+ this.ui.close();
376
+ }
377
+
378
+ protected _clearCache(): void {
379
+ try {
380
+ localStorage.removeItem(this.CACHE_KEY);
381
+ } catch (e) { }
382
+ }
383
+
384
+ private _readCache(): any | null {
385
+ try {
386
+ const cachedData = localStorage.getItem(this.CACHE_KEY);
387
+ if (!cachedData) return null;
388
+ const parsed = JSON.parse(cachedData);
389
+ if (parsed && parsed.payload && parsed.apiResponse && typeof parsed.expireAt === 'number') {
390
+ return parsed;
391
+ }
392
+ } catch (e) { }
393
+ this._clearCache();
394
+ return null;
395
+ }
396
+
397
+ private _writeCache(data: object): void {
398
+ try {
399
+ localStorage.setItem(this.CACHE_KEY, JSON.stringify(data));
400
+ } catch (e) { }
401
+ }
402
+
403
+ private _checkAndAutoResume(): void {
404
+ const cached = this._readCache();
405
+ if (!cached) return;
406
+ if (cached.environment !== this.environment || Date.now() >= cached.expireAt) {
407
+ this._clearCache();
408
+ return;
409
+ }
410
+ const start = () => {
411
+ // The merchant may already have started a payment by the time the page
412
+ // finished loading.
413
+ if (this.flowId !== 0) return;
414
+ this.flowId++;
415
+ this.settled = false;
416
+ this.cancelling = false;
417
+ this.api.setToken(cached.token);
418
+ this._resumePaymentState(cached.apiResponse, cached.payload, cached.expireAt);
419
+ };
420
+ if (document.body) {
421
+ start();
422
+ } else {
423
+ document.addEventListener('DOMContentLoaded', start);
424
+ }
425
+ }
426
+
427
+ private _resumePaymentState(apiResponse: any, payload: any, expireAt: number): void {
428
+ this.pendingPaymentPayload = payload;
429
+ this.pendingApiResponse = apiResponse;
430
+ const actualRefId = apiResponse.vendorQrRefId || apiResponse.transactionRefId;
431
+ apiResponse.vendorQrRefId = actualRefId;
432
+ this._showQr(apiResponse, payload.orderId);
433
+ this._startStatusWatch({orderId: payload.orderId, nonce: payload.nonce});
434
+ this._startCountdown(payload.orderId, expireAt);
435
+ this._triggerEvent({
436
+ created: true,
437
+ orderId: payload.orderId,
438
+ amount: payload.amount,
439
+ vendorQrRefId: actualRefId
440
+ });
441
+ }
442
+
443
+ protected _startStatusWatch(payload: IPollingRequest): void {
444
+ if (this.statusWatcher) this.statusWatcher.stop();
445
+ const flowId = this.flowId;
446
+ this.statusWatcher = new PaymentStatusWatcher(this.api, payload, {
447
+ transport: this.transport,
448
+ pollInterval: this.POLL_INTERVAL_MS,
449
+ onStatus: (response: IPollingResponse) => {
450
+ if (flowId !== this.flowId) return;
451
+ const status = (response.status || '').toUpperCase();
452
+ if (status === 'SUCCESS' || status === 'FAILED' || status === 'EXPIRED' || status === 'CANCELLED') {
453
+ this._finish(status, {
454
+ orderId: response.orderId || payload.orderId,
455
+ amount: response.amount || this.pendingPaymentPayload?.amount,
456
+ vendorQrRefId: response.vendorQrRefId || response.transactionRefId
457
+ });
458
+ }
459
+ },
460
+ onConnectionChange: state => {
461
+ if (flowId === this.flowId) this.ui.setConnectionState(state);
462
+ }
463
+ });
464
+ this.statusWatcher.start();
465
+ }
466
+
467
+ protected _startCountdown(orderId: string, expireAt: number): void {
468
+ if (this.countdownIntervalId !== undefined) window.clearInterval(this.countdownIntervalId);
469
+ const flowId = this.flowId;
470
+ const update = () => {
471
+ if (flowId !== this.flowId) return;
472
+ const remaining = Math.max(0, Math.floor((expireAt - Date.now()) / 1000));
473
+ this.ui.setCountdown(remaining);
474
+ if (remaining <= 0) {
475
+ this._finish('EXPIRED', {
476
+ orderId,
477
+ amount: this.pendingPaymentPayload?.amount
478
+ }, {messageKey: 'message.TIMEOUT'});
479
+ }
480
+ };
481
+ update();
482
+ if (!this.settled) {
483
+ this.countdownIntervalId = window.setInterval(update, 1000);
484
+ }
485
+ }
486
+ }
487
+
488
+ if (typeof window !== 'undefined') {
489
+ (window as any).MMPaySDK = MMPaySDK;
490
+ }
package/src/logo.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * MyanMyanPay logo (256px PNG from myanmyanpay.com/mmp-logo.png), embedded so
3
+ * the loading screen shows it instantly instead of waiting on another request.
4
+ */
5
+ export const MMPAY_LOGO_DATA_URI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAMAAABrrFhUAAAAHlBMVEX///8AAAADAwNFRUVjY2NTU1MuLi7n5+e9vb2BgYFUuDMdAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAE90lEQVR4nO2d2XbCMAxE5yokhf//4R5atrCEYIckMLoPfeiJVTPIrm3JijBHmCPMEeYIc4Q5whxhjjBHmCPMEeYIc4Q5whxhjjBHmCPMEeYIc4Q5whxhjjBHmCPMEeYIc4Q5whxhjjBHmCPMEeYIc4Q5whxhjjBHmCPMEeYIc4Q5whxhjjBHmCPMEeYIc4Q5whxhjjBHle2jlLcZmlWAqPjzvaY1dmI5AYLomjK6uPzQkxmaV4Cg2aqUbXdpqqsyFMsIEDQ/xd2Wfk4dD7ppDM0sAFH+te3Zni1NZmhOAYKuqtunb67SASTVuIAqBGjquq3mKMBEhlKAEkQp6QHkEFDOAQd249dx3W5oEqwytOAc0IzuQdAOCfCKoZJWbxRgLMMCtC8YWpkAuHsAh98+/MJeFOC5oTUKEIPPvyDAGEMrFCCIpr1Pc9i2jxNglKEVCsDm8a7mp2W0ADFoaHN4ZnUCBBsN0f4/9FyAZ4b+FFifAEEM72p/YrQHjDK0PgHawW7fPvVAgOebjOun1iLA5km/N2MFGGcoBVB6wIEcAsTCh6Kd4xzA+Vj871z6st9td6atEWDI0OICxKkLN8uX8zn1pZ8UCDBkaHEBOISGtqf162W/T5u3WgEeGlpeAPYxzfYYnTT0AHp7V0sB2HvoqYeWApxJAUgPUA6BAzkH4LEO6JFzAPeX8JsaD9iseS9wheFusE8KQHqA/IZAxClX19IDovez8YoL7InNdrttjwKYRYY4HQpubw8EBkJ6XxMbJDimKe2OHRgT1B2VHzBs6FGreQWIc4rvn0/+n440m/u8lh8AIwwtL0Bzs/tzzxDBPkfIOUtsLJkn2H6jB+wepHTdy/IazhV+lBx2hzXlCpeRN0aISZVMDyhBlJJDAPs5oPO+N8iEFz4/8uYolWPg4u5w1N0drvknwEpuj9cZik+sH0C/25MZmlUAav50//Mv1AmyhogwR5gjzBHmqLL96KOwm6Oxdxl6EVW1dv83GOYLoZhqKRyfuRSOyTZDlRWZltsMba23wzHhgUidoSyk1OSpcCwyBJpKz824ABkY4UsiQ7tm/uDoraEFBWjcw+NNJkgE7h7A4bdPtu1ZSIk3pMktX1O0OX6U+RIlLzKKj3maKxBgMyLDdYJCSo8ydRcWIGZNlr7J1V5cgJg3Xf6vZXvO1rcrpLTn+YT5xYWUrm/seAowASpumQKQHqAcAiPqH23eU0hp3XNA9/4iKqsX4N0FFFYvAO4ewP1+pwCkByiHwHRzwJIpMrH8HLDe3WA3iwC9an6G64D4TyypSxDhcz3gqqLnugRo319U9bqm67oEmKWISr+qr6EAzbWhFKAEUUp6ADkE1B+6ZoWUorD+0dcUUop8yUrz4mt24stfs0O+aIm/r+YeJRkizwyt0AOwzxFyzhIbSxZSar/RA3bN/C9dXVWucBmZLs/XpMuXkQKQHsBHD4GurttZSInPvjlK7ZXfi7vDdYWUqm5Pq7zpvuMTFVKKOkPLhMb4j04WXfpvun5Ms6YQwWLB0T0V0fl+04rPUJkhoKrW9jVEvgBhjjBHmCPMEeYIc4Q5whxhjjBHmCPMEeYIc4Q5whxhjjBHmCPMEeYIc4Q5whxhjjBHmCPMEeYIc4Q5whxhjjBHmCPMEeYIc4Q5whxhjjBHmCPMEeYIc4Q5whxhjjBHmCPMEeYIc1IAzPkF8CT7lTTnMu0AAAAASUVORK5CYII=';