import { Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { HttpClient } from '@angular/common/http'; import { FastcheckService } from '../../fastcheck.service'; import { FASTCHECK_API, QR_VITANOVA_API } from '../../api'; import { TranslatePipe } from '../../translate/translate.pipe'; import { TranslationService } from '../../translate/translation.service'; interface WebSessionResponse { sessionId: string; userId: string; expires: string; userSessionId: string; Status: boolean; } interface CheckFastcheckResponse { id: string; code: string; owneID: string; amount: number; currency: string; createdAt: string; creattransactionID: string; firedAT: string; firetransactionID: string; } /** * Response of GET /api/settings?id=. * Shape is defensive — backend may include min/max limits and/or an * already-active fastcheck (or QR) for this partner so the page can autofill. */ interface SettingsResponse { minAmount?: number; maxAmount?: number; // Possible active fastcheck data — backend may use different casing. fastcheck?: string; fastcheckNumber?: string; code?: string; amount?: number; note?: string; status?: string; // QR-side info (not rendered on this page yet, but accepted for future use). qrId?: string; qrUrl?: string; payload?: string; } @Component({ selector: 'app-fastcheck-page', imports: [FormsModule, TranslatePipe], templateUrl: './fastcheck-page.html', styleUrl: './fastcheck-page.scss' }) export class FastcheckPage { private readonly defaultPartnerId = 'fast-c202-4062-bcfb-8b4c8cc59adc'; private http = inject(HttpClient); private store = inject(FastcheckService); private i18n = inject(TranslationService); private t(key: string): string { return this.i18n.translate(key); } // Telegram bot used for the sign-in deep link. private readonly telegramBot = 'DexarSupport_bot'; fastcheckNumber = signal(''); fastcheckAmount = signal(null); fastcheckCode = signal(''); codeEnabled = signal(false); error = signal(''); amountLoading = signal(false); // Pass-through partner id from ?id= used by the "New" button link. partnerId = signal(''); newQrUrl = computed(() => { const id = this.partnerId() || this.defaultPartnerId; return `https://qr.vitanova.network/?id=${encodeURIComponent(id)}`; }); // Non-blocking settings hint shown above the form when /settings fails. settingsError = signal(''); settingsLoaded = signal(false); // True only after /settings returned 200 — used to disable Pay button otherwise. settingsOk = signal(false); popupOpen = signal(false); popupLoading = signal(false); popupError = signal(''); webSessionId = signal(''); paid = signal(false); loginOnly = signal(false); sessionToken = signal(localStorage.getItem('fc_session') ?? ''); private pollHandle: ReturnType | null = null; private lastLookedUpNumber = ''; canPay = computed(() => { const digits = this.fastcheckNumber().replace(/\D/g, ''); const codeDigits = this.fastcheckCode().replace(/\D/g, ''); return digits.length === 18 && codeDigits.length === 6 && this.codeEnabled() && !this.amountLoading(); }); telegramLink = computed(() => { const sid = this.webSessionId(); return sid ? `https://t.me/${this.telegramBot}?start=${encodeURIComponent(sid)}` : `https://t.me/${this.telegramBot}`; }); qrUrl = computed(() => { const link = this.telegramLink(); return `https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=8&data=${encodeURIComponent(link)}`; }); get isMobile(): boolean { return typeof window !== 'undefined' && window.innerWidth < 768; } constructor() { // Pull autofill data: prefer router navigation state, fall back to service. const navState = typeof window !== 'undefined' ? (window.history?.state ?? {}) : {}; const created = (navState?.fastcheck) ? { fastcheck: navState.fastcheck, code: navState.code ?? '', amount: navState.amount ?? null, expiration: navState.expiration } : this.store.consume(); if (created) { this.fastcheckNumber.set(created.fastcheck); this.fastcheckAmount.set(created.amount); this.fastcheckCode.set(created.code); this.codeEnabled.set(true); } const params = new URLSearchParams(window.location.search); // ?id= — used by the "New" button URL; fallback to default id. this.partnerId.set(params.get('id') ?? this.defaultPartnerId); // ?iid=xxxxxx-xxxxxx-xxxxxx — auto-fill and trigger lookup const iidParam = params.get('iid') ?? ''; if (iidParam && !created) { const digits = iidParam.replace(/\D/g, '').slice(0, 18); const groups: string[] = []; for (let i = 0; i < digits.length; i += 6) groups.push(digits.slice(i, i + 6)); const masked = groups.join('-'); this.fastcheckNumber.set(masked); if (digits.length === 18) this.lookupFastcheck(masked); } // Always call settings on each load — may return active check data to autofill. this.loadSettings(!!created || !!iidParam); // Connectivity check — makes visible requests in DevTools and surfaces // backend availability issues early. this.pingBackends(); } /** Fire GET /ping on both backends and log status. Non-blocking. */ private pingBackends(): void { const targets = [ { name: 'fastcheck', url: `${FASTCHECK_API}/ping` }, { name: 'qr-vitanova', url: `${QR_VITANOVA_API}/ping` } ]; for (const t of targets) { this.http.get(t.url, { observe: 'response' }).subscribe({ next: (res) => console.debug(`[ping:${t.name}]`, res.status, t.url), error: (err) => console.warn(`[ping:${t.name}] failed`, err?.status ?? err, t.url) }); } } /** * GET /api/settings?id=. Idempotent — applies response to the * current UI without creating any new check. Errors are non-blocking. * * @param alreadyAutofilled - true when constructor already populated the form * from nav state / iid; in that case settings must not overwrite it. */ private loadSettings(alreadyAutofilled: boolean): void { const id = this.partnerId(); if (!id) { this.settingsError.set(this.t('errors.settings_missing_id')); this.settingsLoaded.set(true); this.settingsOk.set(false); return; } const url = `${QR_VITANOVA_API}/settings?id=${encodeURIComponent(id)}`; this.http.get(url).subscribe({ next: (res) => { this.settingsLoaded.set(true); this.settingsError.set(''); this.settingsOk.set(true); this.applySettings(res ?? {}, alreadyAutofilled); }, error: () => { // Non-blocking: keep manual mode available, but Pay stays disabled. this.settingsLoaded.set(true); this.settingsOk.set(false); this.settingsError.set(this.t('errors.settings_failed')); } }); } /** Apply settings response to UI state without creating any new check. */ private applySettings(res: SettingsResponse, alreadyAutofilled: boolean): void { // Active check autofill — only if user hasn't already got data on screen. if (alreadyAutofilled) return; const rawNumber = res.fastcheck ?? res.fastcheckNumber ?? ''; if (rawNumber) { const digits = String(rawNumber).replace(/\D/g, '').slice(0, 18); if (digits.length > 0) { const groups: string[] = []; for (let i = 0; i < digits.length; i += 6) groups.push(digits.slice(i, i + 6)); this.fastcheckNumber.set(groups.join('-')); if (digits.length === 18) { // Trigger the regular lookup so amount/code-enabled stay consistent. this.lookupFastcheck(groups.join('-')); } } } if (typeof res.amount === 'number') { this.fastcheckAmount.set(res.amount); } if (typeof res.code === 'string' && res.code) { const codeDigits = res.code.replace(/\D/g, '').slice(0, 6); this.fastcheckCode.set(codeDigits); this.codeEnabled.set(true); } // Final statuses end the flow — no polling, no new requests. const status = (res.status ?? '').toUpperCase(); if (status === 'COMPLETED' || status === 'APPROVED') { this.paid.set(true); } } pay(): void { if (!this.canPay()) { return; } this.error.set(''); this.loginOnly.set(false); this.openPopup(); } private openPopup(): void { this.popupOpen.set(true); this.popupError.set(''); this.paid.set(false); this.popupLoading.set(true); const existing = this.sessionToken(); if (existing) { this.http.get(`${FASTCHECK_API}/websession/${existing}`).subscribe({ next: (res) => { if (res?.Status) { this.popupLoading.set(false); this.webSessionId.set(existing); if (this.loginOnly()) { this.paid.set(true); } else { this.acceptFastcheck(existing); } } else { this.sessionToken.set(''); this.createNewSession(); } }, error: () => { this.sessionToken.set(''); this.createNewSession(); } }); return; } this.createNewSession(); } private createNewSession(): void { this.http.get(`${FASTCHECK_API}/websession`).subscribe({ next: (res) => { this.popupLoading.set(false); this.webSessionId.set(res.sessionId); if (this.isMobile) { window.location.href = `https://t.me/${this.telegramBot}?start=${encodeURIComponent(res.sessionId)}`; } else { this.startPolling(res.sessionId); } }, error: () => { this.popupLoading.set(false); this.popupError.set(this.t('errors.session_failed')); } }); } closePopup(): void { this.popupOpen.set(false); this.stopPolling(); if (this.loginOnly() && this.paid()) { // Keep session alive — user is logged in, preserve token for next action. const tok = this.webSessionId(); localStorage.setItem('fc_session', tok); this.sessionToken.set(tok); } else if (this.webSessionId()) { // Best-effort logout; ignore errors. this.http .request('DELETE', `${FASTCHECK_API}/websession/${this.webSessionId()}`, { body: { sessionId: this.webSessionId() } }) .subscribe({ error: () => undefined }); localStorage.removeItem('fc_session'); this.sessionToken.set(''); } this.webSessionId.set(''); } private startPolling(sessionId: string): void { this.stopPolling(); this.pollHandle = setInterval(() => { this.http .get(`${FASTCHECK_API}/websession/${sessionId}`) .subscribe({ next: (res) => { if (res?.Status) { this.stopPolling(); if (this.loginOnly()) { this.paid.set(true); } else { this.acceptFastcheck(sessionId); } } }, error: () => undefined }); }, 3000); } private stopPolling(): void { if (this.pollHandle !== null) { clearInterval(this.pollHandle); this.pollHandle = null; } } private acceptFastcheck(sessionId: string): void { this.popupLoading.set(true); this.http .post( `${FASTCHECK_API}/fastcheck`, { fastcheck: this.fastcheckNumber().trim(), code: this.fastcheckCode().trim() }, { headers: { Authorization: JSON.stringify({ sessionID: sessionId }) } } ) .subscribe({ next: () => { this.popupLoading.set(false); this.paid.set(true); // Fire DELETE to mark fastcheck as consumed on the merchant side. this.http .delete(`${FASTCHECK_API}/fastcheck/${encodeURIComponent(this.fastcheckNumber())}`) .subscribe({ error: () => undefined }); this.fireMerchantCallback(); }, error: () => { this.popupLoading.set(false); this.popupError.set(this.t('errors.payment_failed')); } }); } private fireMerchantCallback(): void { const params = new URLSearchParams(window.location.search); const returnUrl = params.get('return_url'); if (returnUrl) { setTimeout(() => { window.location.href = `${returnUrl}${returnUrl.includes('?') ? '&' : '?'}fastcheck=${encodeURIComponent( this.fastcheckNumber() )}&status=ok`; }, 1500); } } onAmountChange(value: number | null): void { this.fastcheckAmount.set(value); } /** Mask fastcheck number as XXXXXX-XXXXXX-XXXXXX, allow only digits. */ onNumberChange(raw: string): void { const digits = (raw ?? '').replace(/\D/g, '').slice(0, 18); const groups: string[] = []; for (let i = 0; i < digits.length; i += 6) { groups.push(digits.slice(i, i + 6)); } const masked = groups.join('-'); this.fastcheckNumber.set(masked); this.error.set(''); if (digits.length < 18 && this.lastLookedUpNumber) { this.fastcheckAmount.set(null); this.codeEnabled.set(false); this.lastLookedUpNumber = ''; } if (digits.length === 18 && masked !== this.lastLookedUpNumber) { this.lookupFastcheck(masked); } } /** Allow only digits, max 6, in the code field. */ onCodeChange(raw: string): void { const digits = (raw ?? '').replace(/\D/g, '').slice(0, 6); this.fastcheckCode.set(digits); this.error.set(''); } private lookupFastcheck(number: string): void { this.lastLookedUpNumber = number; this.amountLoading.set(true); this.fastcheckAmount.set(null); this.codeEnabled.set(false); // API doc: GET /fastcheck/ this.http .get(`${FASTCHECK_API}/fastcheck/${number}`) .subscribe({ next: (res) => { this.amountLoading.set(false); if (res?.id) { this.fastcheckAmount.set(typeof res.amount === 'number' ? res.amount : null); this.codeEnabled.set(true); } else { this.error.set(this.t('errors.not_found')); this.lastLookedUpNumber = ''; } }, error: (err) => { this.amountLoading.set(false); const serverMsg: string | undefined = err?.error?.message; this.error.set(serverMsg ?? this.t('errors.lookup_failed')); this.lastLookedUpNumber = ''; } }); } shareByTelegram(): void { this.loginOnly.set(true); this.openPopup(); } }