import { Component, ChangeDetectionStrategy, signal, OnDestroy, inject } from '@angular/core'; import { DecimalPipe } from '@angular/common'; import { Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { CartService, ApiService, LanguageService, AuthService } from '../../services'; import { Item, CartItem, DeliveryOption } from '../../models'; import { EMPTY, interval, of, Subscription } from 'rxjs'; import { catchError, exhaustMap, take, timeout } from 'rxjs/operators'; import { DeliverySelectorComponent } from '../../components/delivery-selector/delivery-selector.component'; import { TelegramLoginComponent } from '../../components/telegram-login/telegram-login.component'; 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_MIN_POLL_SECONDS, PAYMENT_TIMEOUT_CLOSE_MS, LINK_COPIED_DURATION_MS } from '../../config/constants'; import { IconComponent } from '../../shared/ui/icon/icon.component'; import { ConfigService } from '../../core/config/config.service'; import { TenantResolverService } from '../../core/config/tenant-resolver.service'; type PaymentMethod = 'qr' | 'card'; @Component({ selector: 'app-cart', imports: [DecimalPipe, RouterLink, FormsModule, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe, IconComponent], templateUrl: './cart.component.html', styleUrls: ['./cart.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class CartComponent implements OnDestroy { items; itemCount; totalPrice; totalDeliveryPrice; totalWithDelivery; hasDeliveryPrice; allRequiredDeliveriesSelected; termsAccepted = false; 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' | null>('creating'); qrCodeUrl = signal(''); paymentUrl = signal(''); bankPaymentUrl = signal(''); bankPaymentFrameUrl = signal(null); showBankPaymentPopup = signal(false); selectedPaymentMethod = signal('qr'); 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 = Math.ceil(PAYMENT_MIN_POLL_SECONDS / (PAYMENT_POLL_INTERVAL_MS / 1000)); private pollingSubscription?: Subscription; private closeTimeout?: ReturnType; private configService = inject(ConfigService); private tenantResolver = inject(TenantResolverService); constructor( private cartService: CartService, private apiService: ApiService, private router: Router, private langService: LanguageService, private sanitizer: DomSanitizer ) { this.items = this.cartService.items; this.itemCount = this.cartService.itemCount; this.totalPrice = this.cartService.totalPrice; this.totalDeliveryPrice = this.cartService.totalDeliveryPrice; this.totalWithDelivery = this.cartService.totalWithDelivery; this.hasDeliveryPrice = this.cartService.hasDeliveryPrice; this.allRequiredDeliveriesSelected = this.cartService.allRequiredDeliveriesSelected; } requestLogin(): void { this.authService.requestLogin(); } ngOnDestroy(): void { this.stopPolling(); if (this.closeTimeout) { clearTimeout(this.closeTimeout); } } removeItem(item: CartItem): void { this.cartService.removeItem(item.itemID, this.cartVariant(item)); this.swipedItemId.set(null); } updateQuantity(item: CartItem, quantity: number): void { this.cartService.updateQuantity(item.itemID, quantity, this.cartVariant(item)); } increaseQuantity(item: CartItem): void { this.updateQuantity(item, item.quantity + 1); } decreaseQuantity(item: CartItem): void { if (item.quantity <= 1) { this.removeItem(item); } else { this.updateQuantity(item, item.quantity - 1); } } private cartVariant(item: CartItem): { colour?: string; size?: string; price?: number; currency?: string } { return { colour: item.colour, size: item.size, price: item.price, currency: item.currency, }; } onSwipeStart(itemID: number, event: TouchEvent): void { 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(); } get isCheckoutDisabled(): boolean { return !this.termsAccepted || !this.isAuthenticated() || !this.allRequiredDeliveriesSelected(); } selectDelivery(itemID: number, selectedDelivery: DeliveryOption | null): void { this.cartService.setSelectedDelivery(itemID, selectedDelivery); } checkout(paymentMethod: PaymentMethod): void { if (!this.allRequiredDeliveriesSelected()) { alert(this.i18n.t('cart.deliveryRequired')); return; } if (!this.termsAccepted) { alert(this.i18n.t('cart.acceptTerms')); return; } this.openPaymentPopup(paymentMethod); } openPaymentPopup(paymentMethod: PaymentMethod): void { this.showPaymentPopup.set(true); this.selectedPaymentMethod.set(paymentMethod); this.paymentStatus.set('creating'); this.paymentId.set(''); this.qrCodeUrl.set(''); this.paymentUrl.set(''); this.bankPaymentUrl.set(''); this.bankPaymentFrameUrl.set(null); this.showBankPaymentPopup.set(false); 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(paymentMethod); } closePaymentPopup(): void { this.showPaymentPopup.set(false); this.closeBankPaymentPopup(); 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.paymentId.set(''); this.qrCodeUrl.set(''); this.paymentUrl.set(''); this.bankPaymentUrl.set(''); this.bankPaymentFrameUrl.set(null); this.showBankPaymentPopup.set(false); this.linkCopied.set(false); this.createPayment(this.selectedPaymentMethod()); } createPayment(paymentMethod: PaymentMethod): void { const orderId = this.generateOrderId(); const paymentPayload = { amount: Number(this.totalWithDelivery()), currency: 'RUB' as const, siteuserID: this.getPaymentUserId(), siteorderID: orderId, redirectUrl: '', telegramUsername: this.getTelegramUsername(), paymentMethod, qrDescription: this.getPaymentDescription(), customerID: this.getTelegramUserId() ?? undefined, items: this.buildPaymentItems(), }; this.apiService.createCartPayment(paymentPayload) .subscribe({ next: (response) => { const qrId = this.apiService.resolvePaymentQrId(response); const qrUrl = this.apiService.resolvePaymentQrUrl(response); const paymentLink = this.apiService.resolvePaymentLink(response); const bankUrl = this.apiService.resolveBankPaymentUrl(response); if (!qrId || (paymentMethod === 'qr' && !qrUrl) || (paymentMethod === 'card' && !bankUrl)) { console.error('Payment response missing payment fields:', response); this.setPaymentError(); return; } this.paymentId.set(qrId); this.qrCodeUrl.set(qrUrl); this.paymentUrl.set(paymentLink); this.bankPaymentUrl.set(bankUrl); this.paymentStatus.set('waiting'); this.startPolling(response.qrTTL); if (paymentMethod === 'card') { this.openBankPaymentPopup(); } }, error: (err) => { console.error('Error creating payment:', err); this.setPaymentError(); } }); } startPolling(qrTTL?: number): void { this.stopPolling(); if (!this.paymentId()) { this.setPaymentError(); return; } const pollSeconds = Math.max(PAYMENT_MIN_POLL_SECONDS, (qrTTL ?? 0) * 60); this.maxChecks = Math.ceil(pollSeconds / (PAYMENT_POLL_INTERVAL_MS / 1000)); this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS) .pipe( take(this.maxChecks), // qrTTL minutes from create response, minimum 1 minute exhaustMap(() => { const statusRequest = this.selectedPaymentMethod() === 'card' ? this.apiService.checkCartCardPaymentStatus(this.paymentId()) : this.apiService.checkCartPaymentStatus(this.paymentId()); return statusRequest.pipe( timeout(8000), catchError((err) => { console.error('Error checking payment status:', err); this.setPaymentError(); return EMPTY; }) ); }) ) .subscribe({ next: (response) => { if (!response) { return; } const paymentStatus = response.status?.toUpperCase() || ''; const paymentCode = response.code?.toUpperCase() || ''; if (paymentStatus === 'FAILED' || paymentStatus === 'EXPIRED' || paymentStatus === 'CANCELLED' || paymentStatus === 'REJECTED') { this.paymentStatus.set('timeout'); this.closeBankPaymentPopup(); 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' || paymentStatus === 'PAID' || paymentCode === 'SUCCESS') { this.paymentStatus.set('success'); this.closeBankPaymentPopup(); this.stopPolling(); // Auto-submit purchase after 5 seconds if (this.closeTimeout) clearTimeout(this.closeTimeout); this.closeTimeout = setTimeout(() => { this.autoSubmitPurchase(); }, 5000); this.recordOrder(); 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'); this.closeBankPaymentPopup(); // 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; } } openBankPaymentPopup(): void { const bankUrl = this.bankPaymentUrl(); if (!bankUrl) { return; } this.bankPaymentFrameUrl.set(this.sanitizer.bypassSecurityTrustResourceUrl(bankUrl)); this.showBankPaymentPopup.set(true); } closeBankPaymentPopup(): void { this.showBankPaymentPopup.set(false); this.bankPaymentFrameUrl.set(null); } private setPaymentError(): void { this.paymentStatus.set('error'); this.closeBankPaymentPopup(); this.stopPolling(); if (this.closeTimeout) { clearTimeout(this.closeTimeout); this.closeTimeout = undefined; } } /** * Records the just-paid cart as a backoffice AdminOrder (POST /orders) so it shows up * in Backoffice → Orders/Transactions. Best-effort and fire-and-forget: a failure here * must never affect the already-confirmed payment or block autoSubmitPurchase. */ private recordOrder(): void { const email = this.userEmail().trim(); const phone = this.userPhone().replace(/\D/g, ''); this.apiService.createOrder({ items: this.paidItems.map((item: CartItem) => ({ productId: String(item.itemID), name: item.name, quantity: item.quantity, price: item.discount > 0 ? item.price * (1 - item.discount / 100) : item.price, })), customer: { name: this.getTelegramUsername() || 'Guest', email, phone, }, payment: { method: this.selectedPaymentMethod(), currency: 'RUB', }, }).subscribe({ error: (err) => console.error('Error recording order:', err), }); } private autoSubmitPurchase(): void { setTimeout(() => { const lang = this.langService.currentLanguage(); this.router.navigate([`/${lang}`]);}, 0); const telegramUserId = this.getTelegramUserId(); // Telegram ID is mandatory if (!telegramUserId) { console.error('Cannot submit purchase: Telegram ID is required'); this.emailSubmitting.set(false); return; } this.emailSubmitting.set(true); const emailData = { email: '', phone: '', telegramUserId: telegramUserId, 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, ...(item.selectedDelivery ? { delivery: [item.selectedDelivery] } : {}) })) }; this.apiService.submitPurchaseEmail(emailData).subscribe({ next: () => { this.emailSubmitting.set(false); this.closePaymentPopup(); const lang = this.langService.currentLanguage(); this.router.navigate([`/${lang}`]); }, error: (err) => { console.error('Error submitting purchase:', err); this.emailSubmitting.set(false); // Still close popup and redirect even if submission fails this.closePaymentPopup(); const lang = this.langService.currentLanguage(); this.router.navigate([`/${lang}`]); } }); this.paymentStatus.set(null); } 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, ...(item.selectedDelivery ? { delivery: [item.selectedDelivery] } : {}) })) }; 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()?.userId; if (sessionTelegramUserId !== null && sessionTelegramUserId !== undefined) { 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 getPaymentDescription(): string { const brandName = this.configService.getBootstrapSnapshot()?.branding?.brandName?.trim(); if (brandName) { return brandName; } const hostname = this.tenantResolver.getHostname(); if (hostname && !this.tenantResolver.isLocalhost()) { return hostname; } return 'Покупка на Маркетплейсе'; } private generateOrderId(): string { const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 8); return `order_${timestamp}_${random}`; } private buildPaymentItems(): Array<{ itemID: number; price: number; name: string; quantity: number; delivery?: DeliveryOption[] }> { return this.items().map((item: CartItem) => { const unitPrice = item.discount > 0 ? item.price * (1 - item.discount / 100) : item.price; const details = [item.colour, item.size].filter(Boolean).join(', '); const translatedName = this.itemName(item).trim() || `Item ${item.itemID}`; const name = details ? `${item.quantity} x ${translatedName} (${details})` : `${item.quantity} x ${translatedName}`; return { itemID: item.itemID, price: unitPrice * item.quantity, name, quantity: item.quantity, ...(item.selectedDelivery ? { delivery: [item.selectedDelivery] } : {}), }; }); } 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(''); } } } }