From 63b0e183962e5a5d2353e8d3360abaaa9c493eb8 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Tue, 2 Jun 2026 00:57:36 +0400 Subject: [PATCH] api change --- .../telegram-login.component.html | 4 +- src/app/i18n/en.ts | 3 + src/app/i18n/hy.ts | 3 + src/app/i18n/ru.ts | 3 + src/app/i18n/translations.ts | 3 + src/app/interceptors/mock-data.interceptor.ts | 53 +++++- src/app/pages/cart/cart.component.html | 14 ++ src/app/pages/cart/cart.component.scss | 39 ++++ src/app/pages/cart/cart.component.ts | 176 ++++++++++++++---- src/app/services/api.service.ts | 163 +++++++++++----- 10 files changed, 373 insertions(+), 88 deletions(-) diff --git a/src/app/components/telegram-login/telegram-login.component.html b/src/app/components/telegram-login/telegram-login.component.html index 2127fae..8debe8a 100644 --- a/src/app/components/telegram-login/telegram-login.component.html +++ b/src/app/components/telegram-login/telegram-login.component.html @@ -29,11 +29,11 @@ {{ 'auth.loginWithTelegram' | translate }} - @if (loginUrl()) { +

{{ 'auth.orScanQr' | translate }}

diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 01a752d..31c6f58 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -85,6 +85,9 @@ export const en: Translations = { paymentSuccessDesc: 'Enter your contact details and we will send your purchase within a few minutes', sending: 'Sending...', send: 'Send', + paymentError: 'Unable to create payment', + paymentErrorDesc: 'We could not prepare the payment QR code right now. Please try again in a moment.', + retryPayment: 'Try again', paymentTimeout: 'Payment timed out', paymentTimeoutDesc: 'We did not receive payment confirmation within 3 minutes.', autoClose: 'Window will close automatically...', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index b39ee40..e04874b 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -85,6 +85,9 @@ export const hy: Translations = { paymentSuccessDesc: 'Մուտքագրեք ձեր տվյալները, և մենք կուղարկենք գնումը մի քանի րոպեի ընթացքում', sending: 'Ուղարկվում է...', send: 'Ուղարկել', + paymentError: 'Չհաջողվեց ստեղծել վճարումը', + paymentErrorDesc: 'Այս պահին չհաջողվեց պատրաստել վճարման QR կոդը։ Խնդրում ենք մի փոքր հետո կրկին փորձել։', + retryPayment: 'Փորձել կրկին', paymentTimeout: 'Ժամանակը սպառվեց', paymentTimeoutDesc: 'Մենք չստացանք վճարման հաստատում 3 րոպեի ընթացքում։', autoClose: 'Պատուհանը կփակվի ավտոմատ...', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 0fbe8d5..ea35cfe 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -85,6 +85,9 @@ export const ru: Translations = { paymentSuccessDesc: 'Введите ваши контактные данные, и мы отправим вам покупку в течение нескольких минут', sending: 'Отправка...', send: 'Отправить', + paymentError: 'Не удалось создать платеж', + paymentErrorDesc: 'Сейчас не получилось подготовить QR-код для оплаты. Попробуйте еще раз через минуту.', + retryPayment: 'Попробовать снова', paymentTimeout: 'Время ожидания истекло', paymentTimeoutDesc: 'Мы не получили подтверждение оплаты в течение 3 минут.', autoClose: 'Окно закроется автоматически...', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 2fe975b..ca3caf2 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -83,6 +83,9 @@ export interface Translations { paymentSuccessDesc: string; sending: string; send: string; + paymentError: string; + paymentErrorDesc: string; + retryPayment: string; paymentTimeout: string; paymentTimeoutDesc: string; autoClose: string; diff --git a/src/app/interceptors/mock-data.interceptor.ts b/src/app/interceptors/mock-data.interceptor.ts index 7ea18ce..14ede6a 100644 --- a/src/app/interceptors/mock-data.interceptor.ts +++ b/src/app/interceptors/mock-data.interceptor.ts @@ -795,11 +795,41 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => { qrId: 'mock-qr-' + Date.now(), qrStatus: 'NEW', qrExpirationDate: new Date(Date.now() + 180000).toISOString(), - Payload: 'https://example.com/pay/mock', + payload: 'https://example.com/pay/mock', qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=mock-payment' }, 300); } + // ── POST /qr (create payment QR directly) + if (url.endsWith('/qr') && req.method === 'POST') { + return respond({ + qrId: 'mock-qr-' + Date.now(), + qrStatus: 'NEW', + qrExpirationDate: new Date(Date.now() + 180000).toISOString(), + payload: 'https://example.com/pay/mock', + qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=mock-payment' + }, 300); + } + + // ── POST /cart (legacy create payment QR) + if (url.endsWith('/cart') && req.method === 'POST') { + const body = req.body; + const looksLikePaymentRequest = !!body + && typeof body === 'object' + && !Array.isArray(body) + && 'amount' in body + && 'items' in body; + if (looksLikePaymentRequest) { + return respond({ + qrId: 'mock-qr-' + Date.now(), + qrStatus: 'NEW', + qrExpirationDate: new Date(Date.now() + 180000).toISOString(), + payload: 'https://example.com/pay/mock', + qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=mock-payment' + }, 300); + } + } + // ── POST /items/:id/callback (review) if (url.match(/\/items\/\d+\/callback$/) && req.method === 'POST') { return respond({ message: 'Review submitted (mock)' }, 200); @@ -813,7 +843,7 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => { // ── GET /websession/:id/:qrId (check QR payment status) if (url.match(/\/websession\/[^/]+\/[^/]+$/) && !url.match(/\/websession\/[^/]+\/qr$/) && req.method === 'GET') { return respond({ - paymentStatus: 'SUCCESS', + paymentStatus: 'COMPLETED', code: 'SUCCESS', amount: 0, currency: 'RUB', @@ -828,6 +858,25 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => { }, 500); } + // ── GET /qr/payment/:qrId (legacy/direct payment status) + if (url.match(/\/qr\/payment\/[^/]+$/) && req.method === 'GET') { + return respond({ + paymentStatus: 'COMPLETED', + code: 'SUCCESS', + amount: 0, + currency: 'RUB', + qrId: 'mock', + transactionId: 999, + transactionDate: new Date().toISOString(), + additionalInfo: '', + paymentPurpose: '', + createDate: new Date().toISOString(), + order: 'mock-order', + qrExpirationDate: new Date().toISOString(), + phoneNumber: '+70000000000' + }, 500); + } + // Fallback — pass through return next(req); }; diff --git a/src/app/pages/cart/cart.component.html b/src/app/pages/cart/cart.component.html index 21e91aa..94f4864 100644 --- a/src/app/pages/cart/cart.component.html +++ b/src/app/pages/cart/cart.component.html @@ -227,6 +227,20 @@
} + @if (paymentStatus() === 'error') { +
+
!
+

{{ 'cart.paymentError' | translate }}

+

{{ 'cart.paymentErrorDesc' | translate }}

+ +
+ +
+
+ } + @if (paymentStatus() === 'success') {
diff --git a/src/app/pages/cart/cart.component.scss b/src/app/pages/cart/cart.component.scss index e34349c..244fdad 100644 --- a/src/app/pages/cart/cart.component.scss +++ b/src/app/pages/cart/cart.component.scss @@ -1196,6 +1196,45 @@ } } + &.error { + .error-icon { + width: 80px; + height: 80px; + background: #f97316; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + font-weight: 700; + color: white; + margin: 0 auto 20px; + } + + .payment-error-actions { + margin-top: 24px; + } + + .retry-payment-btn { + padding: 14px 24px; + background: #497671; + color: white; + border: none; + border-radius: 13px; + font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #3a5f5b; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(73, 118, 113, 0.3); + } + } + } + &.timeout { .timeout-icon { font-size: 4rem; diff --git a/src/app/pages/cart/cart.component.ts b/src/app/pages/cart/cart.component.ts index 1d9e0e0..0948da1 100644 --- a/src/app/pages/cart/cart.component.ts +++ b/src/app/pages/cart/cart.component.ts @@ -4,8 +4,8 @@ import { Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { CartService, ApiService, LanguageService, AuthService } from '../../services'; import { Item, CartItem } from '../../models'; -import { interval, Subscription } from 'rxjs'; -import { switchMap, take } from 'rxjs/operators'; +import { interval, of, Subscription } from 'rxjs'; +import { catchError, switchMap, take } from 'rxjs/operators'; import { EmptyCartIconComponent } from '../../components/empty-cart-icon/empty-cart-icon.component'; import { TelegramLoginComponent } from '../../components/telegram-login/telegram-login.component'; import { environment } from '../../../environments/environment'; @@ -39,7 +39,7 @@ export class CartComponent implements OnDestroy { // Payment popup states showPaymentPopup = signal(false); - paymentStatus = signal<'creating' | 'waiting' | 'success' | 'timeout'>('creating'); + paymentStatus = signal<'creating' | 'waiting' | 'success' | 'timeout' | 'error'>('creating'); qrCodeUrl = signal(''); paymentUrl = signal(''); paymentId = signal(''); @@ -58,6 +58,7 @@ export class CartComponent implements OnDestroy { maxChecks = PAYMENT_MAX_CHECKS; private pollingSubscription?: Subscription; private closeTimeout?: ReturnType; + private paymentBackend: 'websession' | 'qr' | 'cart' | null = null; constructor( private cartService: CartService, @@ -159,6 +160,11 @@ export class CartComponent implements OnDestroy { openPaymentPopup(): void { this.showPaymentPopup.set(true); this.paymentStatus.set('creating'); + this.paymentBackend = null; + this.paymentId.set(''); + this.qrCodeUrl.set(''); + this.paymentUrl.set(''); + this.linkCopied.set(false); this.userEmail.set(''); this.userPhone.set(''); this.emailTouched.set(false); @@ -179,14 +185,23 @@ export class CartComponent implements OnDestroy { } } - createPayment(): void { - const sessionId = this.authService.session()?.sessionId || ''; - if (!sessionId) { - this.paymentStatus.set('timeout'); - return; + retryPayment(): void { + if (this.closeTimeout) { + clearTimeout(this.closeTimeout); + this.closeTimeout = undefined; } - // First sync cart items to server via websession, then create QR + this.paymentStatus.set('creating'); + this.paymentBackend = null; + this.paymentId.set(''); + this.qrCodeUrl.set(''); + this.paymentUrl.set(''); + this.linkCopied.set(false); + this.createPayment(); + } + + createPayment(): void { + const sessionId = this.authService.session()?.sessionId || ''; const cartItems = this.items().map((item: CartItem) => ({ itemID: item.itemID, quantity: item.quantity, @@ -197,35 +212,59 @@ export class CartComponent implements OnDestroy { : item.price, })); - this.apiService.addToCart(sessionId, cartItems).subscribe({ - next: () => { - this.apiService.createPayment(sessionId).subscribe({ + const paymentData = { + amount: this.totalPrice(), + currency: this.currentCurrency, + siteuserID: this.getPaymentUserId(), + siteorderID: this.generateOrderId(), + redirectUrl: '', + telegramUsername: this.getTelegramUsername(), + items: this.items().map((item: CartItem) => ({ + itemID: item.itemID, + price: item.discount > 0 + ? item.price * (1 - item.discount / 100) + : item.price, + name: item.name, + quantity: item.quantity, + })) + }; + + const syncCart$ = sessionId + ? this.apiService.addToCart(sessionId, cartItems).pipe( + catchError((err) => { + console.error('Error syncing cart:', err); + return of(null); + }) + ) + : of(null); + + syncCart$ + .pipe( + switchMap(() => this.apiService.createPayment(paymentData, sessionId || undefined)) + ) + .subscribe({ next: (response) => { - this.paymentId.set(response.qrId); - this.qrCodeUrl.set(response.qrUrl); - this.paymentUrl.set(response.Payload); + const qrId = this.apiService.resolvePaymentQrId(response.response); + const qrUrl = this.apiService.resolvePaymentQrUrl(response.response); + + if (!qrId || !qrUrl) { + console.error('Payment response missing qr fields:', response.response); + this.setPaymentError(); + return; + } + + this.paymentBackend = response.backend; + this.paymentId.set(qrId); + this.qrCodeUrl.set(qrUrl); + this.paymentUrl.set(this.apiService.resolvePaymentLink(response.response)); this.paymentStatus.set('waiting'); this.startPolling(); }, error: (err) => { console.error('Error creating payment:', err); - this.paymentStatus.set('timeout'); - if (this.closeTimeout) clearTimeout(this.closeTimeout); - this.closeTimeout = setTimeout(() => { - this.closePaymentPopup(); - }, PAYMENT_ERROR_CLOSE_MS); + this.setPaymentError(); } }); - }, - error: (err) => { - console.error('Error syncing cart:', err); - this.paymentStatus.set('timeout'); - if (this.closeTimeout) clearTimeout(this.closeTimeout); - this.closeTimeout = setTimeout(() => { - this.closePaymentPopup(); - }, PAYMENT_ERROR_CLOSE_MS); - } - }); } startPolling(): void { @@ -235,13 +274,38 @@ export class CartComponent implements OnDestroy { take(this.maxChecks), // maximum 36 checks (3 minutes) switchMap(() => { const sessionId = this.authService.session()?.sessionId || ''; - return this.apiService.checkPaymentStatus(sessionId, this.paymentId()); + return this.apiService.checkPaymentStatus(this.paymentId(), { + sessionId: sessionId || undefined, + backend: this.paymentBackend ?? undefined, + }).pipe( + catchError((err) => { + console.error('Error checking payment status:', err); + return of(null); + }) + ); }) ) .subscribe({ next: (response) => { + if (!response) { + return; + } + + const paymentStatus = response.paymentStatus?.toUpperCase(); + const paymentCode = response.code?.toUpperCase(); + + if (paymentStatus === 'EXPIRED' || paymentStatus === 'CANCELLED') { + this.paymentStatus.set('timeout'); + this.stopPolling(); + if (this.closeTimeout) clearTimeout(this.closeTimeout); + this.closeTimeout = setTimeout(() => { + this.closePaymentPopup(); + }, PAYMENT_TIMEOUT_CLOSE_MS); + return; + } + // Check if payment is successful - if (response.paymentStatus === 'SUCCESS' && response.code === 'SUCCESS') { + if ((paymentStatus === 'COMPLETED' || paymentStatus === 'SUCCESS') && paymentCode === 'SUCCESS') { this.paymentStatus.set('success'); this.stopPolling(); // Clear cart but don't close popup - wait for email submission @@ -260,14 +324,6 @@ export class CartComponent implements OnDestroy { this.closePaymentPopup(); }, PAYMENT_TIMEOUT_CLOSE_MS); } - }, - error: (err) => { - console.error('Error checking payment status:', err); - // Continue checking even on error until time runs out - if (this.closeTimeout) clearTimeout(this.closeTimeout); - this.closeTimeout = setTimeout(() => { - this.closePaymentPopup(); - }, PAYMENT_TIMEOUT_CLOSE_MS); } }); } @@ -275,6 +331,17 @@ export class CartComponent implements OnDestroy { stopPolling(): void { if (this.pollingSubscription) { this.pollingSubscription.unsubscribe(); + this.pollingSubscription = undefined; + } + } + + private setPaymentError(): void { + this.paymentStatus.set('error'); + this.stopPolling(); + this.paymentBackend = null; + if (this.closeTimeout) { + clearTimeout(this.closeTimeout); + this.closeTimeout = undefined; } } @@ -346,12 +413,41 @@ export class CartComponent implements OnDestroy { } private getTelegramUserId(): string | null { + const sessionTelegramUserId = this.authService.session()?.telegramUserId; + if (sessionTelegramUserId) { + return sessionTelegramUserId.toString(); + } + if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user) { return window.Telegram.WebApp.initDataUnsafe.user.id.toString(); } + return null; } + private getTelegramUsername(): string { + const sessionUsername = this.authService.session()?.username; + if (sessionUsername) { + return sessionUsername; + } + + if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user) { + return window.Telegram.WebApp.initDataUnsafe.user.username || 'nontelegram'; + } + + return 'nontelegram'; + } + + private getPaymentUserId(): string { + return this.getTelegramUserId() ?? `web_${Date.now()}`; + } + + private generateOrderId(): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 8); + return `order_${timestamp}_${random}`; + } + onPhoneInput(event: Event): void { const input = event.target as HTMLInputElement; let value = input.value.replace(/\D/g, ''); // Remove all non-digits diff --git a/src/app/services/api.service.ts b/src/app/services/api.service.ts index ef45d1b..749b0cd 100644 --- a/src/app/services/api.service.ts +++ b/src/app/services/api.service.ts @@ -1,10 +1,62 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable, timer } from 'rxjs'; -import { map, retry } from 'rxjs/operators'; +import { catchError, map, retry } from 'rxjs/operators'; import { Category, Item, Subcategory } from '../models'; import { environment } from '../../environments/environment'; +type PaymentBackend = 'websession' | 'qr' | 'cart'; + +interface PaymentRequestItem { + itemID: number; + price: number; + name: string; + quantity?: number; +} + +export interface PaymentRequest { + amount: number; + currency: string; + siteuserID: string; + siteorderID: string; + redirectUrl: string; + telegramUsername: string; + items: PaymentRequestItem[]; +} + +export interface PaymentCreateResponse { + qrId?: string; + qrID?: string; + qrStatus?: string; + qrExpirationDate?: string; + payload?: string; + Payload?: string; + qrUrl?: string; + partnerID?: string | number; + partnerId?: string | number; + PartnerID?: string | number; +} + +export interface PaymentCreateResult { + backend: PaymentBackend; + response: PaymentCreateResponse; +} + +export interface PaymentStatusResponse { + additionalInfo: string; + paymentPurpose: string; + amount: number; + code: string; + createDate: string; + currency: string; + order: string; + paymentStatus: string; + qrId: string; + transactionDate: string; + transactionId: number; + qrExpirationDate: string; +} + @Injectable({ providedIn: 'root' }) @@ -348,51 +400,74 @@ export class ApiService { return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/questiion`, body); } - // Payment - SBP Integration via websession QR - createPayment(sessionId: string): Observable<{ - qrId: string; - qrStatus: string; - qrExpirationDate: string; - Payload: string; - qrUrl: string; - }> { - return this.http.post<{ - qrId: string; - qrStatus: string; - qrExpirationDate: string; - Payload: string; - qrUrl: string; - }>(`${this.baseUrl}/websession/${sessionId}/qr`, {}); + createPayment(paymentData: PaymentRequest, sessionId?: string): Observable { + const directQrPayment$ = this.http.post(`${this.baseUrl}/qr`, paymentData).pipe( + map(response => ({ backend: 'qr' as const, response })) + ); + const legacyCartPayment$ = this.http.post(`${this.baseUrl}/cart`, paymentData).pipe( + map(response => ({ backend: 'cart' as const, response })) + ); + const directPayment$ = directQrPayment$.pipe( + catchError(() => legacyCartPayment$) + ); + + if (!sessionId) { + return directPayment$; + } + + return this.http.post(`${this.baseUrl}/websession/${sessionId}/qr`, {}).pipe( + map(response => ({ backend: 'websession' as const, response })), + catchError(() => directPayment$) + ); } - checkPaymentStatus(sessionId: string, qrId: string): Observable<{ - additionalInfo: string; - paymentPurpose: string; - amount: number; - code: string; - createDate: string; - currency: string; - order: string; - paymentStatus: string; - qrId: string; - transactionDate: string; - transactionId: number; - qrExpirationDate: string; - }> { - return this.http.get<{ - additionalInfo: string; - paymentPurpose: string; - amount: number; - code: string; - createDate: string; - currency: string; - order: string; - paymentStatus: string; - qrId: string; - transactionDate: string; - transactionId: number; - qrExpirationDate: string; - }>(`${this.baseUrl}/websession/${sessionId}/${qrId}`); + checkPaymentStatus(qrId: string, options?: { sessionId?: string; backend?: PaymentBackend }): Observable { + const legacyStatus$ = this.http.get(`${this.baseUrl}/qr/payment/${qrId}`); + + if (options?.backend === 'websession' && options.sessionId) { + return this.http.get(`${this.baseUrl}/websession/${options.sessionId}/${qrId}`).pipe( + catchError(() => legacyStatus$) + ); + } + + if (options?.backend === 'qr' || options?.backend === 'cart') { + return legacyStatus$; + } + + if (options?.sessionId) { + return this.http.get(`${this.baseUrl}/websession/${options.sessionId}/${qrId}`).pipe( + catchError(() => legacyStatus$) + ); + } + + return legacyStatus$; + } + + resolvePaymentQrId(response: PaymentCreateResponse): string { + return response.qrId ?? response.qrID ?? ''; + } + + resolvePaymentQrUrl(response: PaymentCreateResponse): string { + if (response.qrUrl) { + return response.qrUrl; + } + + const qrId = this.resolvePaymentQrId(response); + const partnerId = response.partnerID ?? response.partnerId ?? response.PartnerID; + + if (!qrId) { + return ''; + } + + if (partnerId != null) { + return `${this.baseUrl}/qr/dynamic/${encodeURIComponent(String(partnerId))}/${encodeURIComponent(qrId)}`; + } + + return `${this.baseUrl}/qr/static/${encodeURIComponent(qrId)}`; + } + + resolvePaymentLink(response: PaymentCreateResponse): string { + return response.payload ?? response.Payload ?? this.resolvePaymentQrUrl(response); } submitPurchaseEmail(emailData: {