import { Component, computed, ChangeDetectionStrategy, signal, OnDestroy, inject } from '@angular/core'; import { DecimalPipe } from '@angular/common'; 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, of, Subscription } from 'rxjs'; import { catchError, exhaustMap, switchMap, take, timeout } 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'; import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils'; import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslateService } from '../../i18n/translate.service'; import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MAX_CHECKS, PAYMENT_TIMEOUT_CLOSE_MS, PAYMENT_ERROR_CLOSE_MS, LINK_COPIED_DURATION_MS } from '../../config/constants'; @Component({ selector: 'app-cart', imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe], templateUrl: './cart.component.html', styleUrls: ['./cart.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class CartComponent implements OnDestroy { items; itemCount; totalPrice; termsAccepted = false; isnovo = environment.theme === 'novo'; private i18n = inject(TranslateService); private authService = inject(AuthService); isAuthenticated = this.authService.isAuthenticated; // Swipe state swipedItemId = signal(null); // Payment popup states showPaymentPopup = signal(false); paymentStatus = signal<'creating' | 'waiting' | 'success' | 'timeout' | 'error'>('creating'); qrCodeUrl = signal(''); paymentUrl = signal(''); paymentId = signal(''); linkCopied = signal(false); // Email collection after successful payment userEmail = signal(''); userPhone = signal(''); emailTouched = signal(false); phoneTouched = signal(false); emailError = signal(''); phoneError = signal(''); emailSubmitting = signal(false); paidItems: CartItem[] = []; maxChecks = PAYMENT_MAX_CHECKS; private pollingSubscription?: Subscription; private closeTimeout?: ReturnType; private qrPartnerId = ''; constructor( private cartService: CartService, private apiService: ApiService, private router: Router, private langService: LanguageService ) { this.items = this.cartService.items; this.itemCount = this.cartService.itemCount; this.totalPrice = this.cartService.totalPrice; } requestLogin(): void { this.authService.requestLogin(); } ngOnDestroy(): void { this.stopPolling(); if (this.closeTimeout) { clearTimeout(this.closeTimeout); } } removeItem(itemID: number): void { this.cartService.removeItem(itemID); this.swipedItemId.set(null); } updateQuantity(itemID: number, quantity: number): void { this.cartService.updateQuantity(itemID, quantity); } increaseQuantity(itemID: number, currentQuantity: number): void { this.updateQuantity(itemID, currentQuantity + 1); } decreaseQuantity(itemID: number, currentQuantity: number): void { if (currentQuantity <= 1) { this.removeItem(itemID); } else { this.updateQuantity(itemID, currentQuantity - 1); } } onSwipeStart(itemID: number, event: TouchEvent): void { const item = event.currentTarget as HTMLElement; const startX = event.touches[0].clientX; const onMove = (e: TouchEvent) => { const currentX = e.touches[0].clientX; const diff = startX - currentX; if (diff > 50) { this.swipedItemId.set(itemID); cleanup(); } else if (diff < -10) { this.swipedItemId.set(null); cleanup(); } }; const cleanup = () => { document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', cleanup); }; document.addEventListener('touchmove', onMove); document.addEventListener('touchend', cleanup); } clearCart(): void { if (confirm(this.i18n.t('cart.confirmClear'))) { this.cartService.clearCart(); } } readonly getMainImage = getMainImage; readonly trackByItemId = trackByItemId; readonly getDiscountedPrice = getDiscountedPrice; readonly getBadgeClass = getBadgeClass; itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); } itemDesc(item: Item): string { return getTranslatedField(item, 'simpleDescription', this.langService.currentLanguage()); } get currentCurrency(): string { return this.langService.currentCurrency(); } checkout(): void { if (!this.termsAccepted) { alert(this.i18n.t('cart.acceptTerms')); return; } // Auth gate: require Telegram login before payment if (!this.authService.isAuthenticated()) { this.authService.requestLogin(); return; } this.openPaymentPopup(); } openPaymentPopup(): void { this.showPaymentPopup.set(true); this.paymentStatus.set('creating'); this.qrPartnerId = ''; this.paymentId.set(''); this.qrCodeUrl.set(''); this.paymentUrl.set(''); this.linkCopied.set(false); this.userEmail.set(''); this.userPhone.set(''); this.emailTouched.set(false); this.phoneTouched.set(false); this.emailError.set(''); this.phoneError.set(''); this.emailSubmitting.set(false); this.paidItems = [...this.items()]; this.createPayment(); } closePaymentPopup(): void { this.showPaymentPopup.set(false); this.stopPolling(); if (this.closeTimeout) { clearTimeout(this.closeTimeout); this.closeTimeout = undefined; } } retryPayment(): void { if (this.closeTimeout) { clearTimeout(this.closeTimeout); this.closeTimeout = undefined; } this.paymentStatus.set('creating'); this.qrPartnerId = ''; this.paymentId.set(''); this.qrCodeUrl.set(''); this.paymentUrl.set(''); this.linkCopied.set(false); this.createPayment(); } createPayment(): void { const sessionId = this.authService.session()?.sessionId || ''; const partnerQrId = this.getPartnerQrId(); if (!partnerQrId) { this.setPaymentError(); return; } const cartItems = this.items().map((item: CartItem) => ({ itemID: item.itemID, quantity: item.quantity, colour: item.colour || '', size: item.size || '', price: item.discount > 0 ? item.price * (1 - item.discount / 100) : item.price, })); const syncCart$ = sessionId ? this.apiService.addToCart(sessionId, cartItems).pipe( catchError((err) => { console.error('Error syncing cart:', err); return of(null); }) ) : of(null); const userIdValue = this.getUrlParam('userid-value') || undefined; const authorizationKey = this.getUrlParam('authorization-key') || undefined; const qrPayload = { qrtype: 'QRDynamic' as const, amount: Number(this.totalPrice()), currency: 'RUB' as const, partnerqrID: partnerQrId, qrDescription: `Order ${this.generateOrderId()}, total: ${this.totalPrice().toFixed(2)} RUB`, Userid: userIdValue ?? this.getPaymentUserId(), Reference: this.getUrlParam('ref') || (typeof window !== 'undefined' ? window.location.hostname : ''), RedirectUrl: 'https://fastcheck.store?id=fast-c202-4062-bcfb-8b4c8cc59adc', }; syncCart$ .pipe( switchMap(() => this.apiService.createPayment(qrPayload, { authorizationKey, userIdValue })) ) .subscribe({ next: (response) => { const qrId = this.apiService.resolvePaymentQrId(response); const qrUrl = this.apiService.resolvePaymentQrUrl(response); const paymentLink = this.apiService.resolvePaymentLink(response); if (!qrId || !qrUrl) { console.error('Payment response missing qr fields:', response); this.setPaymentError(); return; } this.qrPartnerId = partnerQrId; this.paymentId.set(qrId); this.qrCodeUrl.set(qrUrl); this.paymentUrl.set(paymentLink); if (paymentLink && typeof window !== 'undefined' && window.innerWidth < 768) { window.location.href = paymentLink; return; } this.paymentStatus.set('waiting'); this.startPolling(); }, error: (err) => { console.error('Error creating payment:', err); this.setPaymentError(); } }); } startPolling(): void { this.stopPolling(); if (!this.qrPartnerId || !this.paymentId()) { this.setPaymentError(); return; } this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS) .pipe( take(this.maxChecks), // maximum 36 checks (3 minutes) exhaustMap(() => { return this.apiService.checkPaymentStatus(this.qrPartnerId, this.paymentId()).pipe( timeout(8000), 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' || paymentStatus === 'REJECTED') { 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 (paymentStatus === 'COMPLETED' || paymentStatus === 'APPROVED') { this.paymentStatus.set('success'); this.stopPolling(); // Clear cart but don't close popup - wait for email submission this.cartService.clearCart(); } // Continue checking for 3 minutes regardless of other statuses }, complete: () => { this.stopPolling(); // If all checks are done but payment not completed if (this.paymentStatus() === 'waiting') { this.paymentStatus.set('timeout'); // Close popup after showing timeout message if (this.closeTimeout) clearTimeout(this.closeTimeout); this.closeTimeout = setTimeout(() => { this.closePaymentPopup(); }, PAYMENT_TIMEOUT_CLOSE_MS); } } }); } stopPolling(): void { if (this.pollingSubscription) { this.pollingSubscription.unsubscribe(); this.pollingSubscription = undefined; } } private setPaymentError(): void { this.paymentStatus.set('error'); this.stopPolling(); this.qrPartnerId = ''; if (this.closeTimeout) { clearTimeout(this.closeTimeout); this.closeTimeout = undefined; } } copyPaymentLink(): void { const url = this.paymentUrl(); if (url) { navigator.clipboard.writeText(url).then(() => { this.linkCopied.set(true); setTimeout(() => this.linkCopied.set(false), LINK_COPIED_DURATION_MS); }).catch(err => { console.error(this.i18n.t('cart.copyError'), err); }); } } submitEmail(): void { // Mark both fields as touched this.emailTouched.set(true); this.phoneTouched.set(true); // Validate both fields this.validateEmail(); const digitsOnly = this.userPhone().replace(/\D/g, ''); this.validatePhone(digitsOnly); // Check if there are any errors if (this.emailError() || this.phoneError()) { return; } const email = this.userEmail().trim(); const phoneRaw = this.userPhone().replace(/\D/g, ''); // Remove all formatting, send only digits this.emailSubmitting.set(true); const emailData = { email: email, phone: phoneRaw, telegramUserId: this.getTelegramUserId(), items: this.paidItems.map((item: CartItem) => ({ itemID: item.itemID, name: item.name, price: item.discount > 0 ? item.price * (1 - item.discount / 100) : item.price, currency: item.currency, quantity: item.quantity })) }; this.apiService.submitPurchaseEmail(emailData).subscribe({ next: () => { this.emailSubmitting.set(false); // Show success message alert(this.i18n.t('cart.emailSuccess')); // Close popup and redirect to home page setTimeout(() => { this.closePaymentPopup(); const lang = this.langService.currentLanguage(); this.router.navigate([`/${lang}`]); }, 500); }, error: (err) => { console.error('Error submitting email:', err); this.emailSubmitting.set(false); alert(this.i18n.t('cart.emailError')); } }); } 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 getPartnerQrId(): string { const fromQuery = this.getUrlParam('id'); if (fromQuery) return fromQuery; const envValue = (environment as any)['partnerqrID']; return typeof envValue === 'string' ? envValue : ''; } private getUrlParam(name: string): string | null { if (typeof window === 'undefined') return null; return new URLSearchParams(window.location.search).get(name); } 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 // Auto-add +7 for Russian numbers if (value.length > 0 && !value.startsWith('7') && !value.startsWith('8')) { value = '7' + value; } // Convert 8 to 7 for Russian format if (value.startsWith('8')) { value = '7' + value.substring(1); } // Format: +7 (XXX) XXX-XX-XX let formatted = ''; if (value.length > 0) { formatted = '+7'; if (value.length > 1) { formatted += ' (' + value.substring(1, 4); } if (value.length >= 4) { formatted += ') ' + value.substring(4, 7); } if (value.length >= 7) { formatted += '-' + value.substring(7, 9); } if (value.length >= 9) { formatted += '-' + value.substring(9, 11); } } this.userPhone.set(formatted); this.validatePhone(value); } onPhoneBlur(): void { this.phoneTouched.set(true); const digitsOnly = this.userPhone().replace(/\D/g, ''); this.validatePhone(digitsOnly); } validatePhone(digitsOnly: string): void { if (!this.phoneTouched() && digitsOnly.length === 0) { this.phoneError.set(''); return; } if (digitsOnly.length === 0) { this.phoneError.set(this.i18n.t('cart.phoneRequired')); } else if (digitsOnly.length < 11) { this.phoneError.set(this.i18n.t('cart.phoneMoreDigits', { count: 11 - digitsOnly.length })); } else if (digitsOnly.length > 11) { this.phoneError.set(this.i18n.t('cart.phoneTooMany')); } else { this.phoneError.set(''); } } onEmailInput(event: Event): void { const input = event.target as HTMLInputElement; this.userEmail.set(input.value); if (this.emailTouched()) { this.validateEmail(); } } onEmailBlur(): void { this.emailTouched.set(true); this.validateEmail(); } validateEmail(): void { const email = this.userEmail().trim(); if (!this.emailTouched() && email.length === 0) { this.emailError.set(''); return; } if (email.length === 0) { this.emailError.set(this.i18n.t('cart.emailRequired')); } else if (email.length < 5) { this.emailError.set(this.i18n.t('cart.emailTooShort')); } else if (email.length > 100) { this.emailError.set(this.i18n.t('cart.emailTooLong')); } else if (!email.includes('@')) { this.emailError.set(this.i18n.t('cart.emailNeedsAt')); } else if (!email.includes('.')) { this.emailError.set(this.i18n.t('cart.emailNeedsDomain')); } else { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { this.emailError.set(this.i18n.t('cart.emailInvalid')); } else { this.emailError.set(''); } } } }