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') {
+
✓
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: {