refactor: migrate cart payment modals to shared app-dialog primitive
Third attempt, done properly this time - first two were reverted
(one stopped cleanly on real conflicts, one botched sequencing and
deleted the old focus-trap before finishing the swap).
DialogComponent gains closeOnEscape/closeOnBackdropClick (default true,
backward-compatible with its 13 other call sites) and ariaLabel (for
dialogs with no visible title header). FOCUSABLE_SELECTOR now includes
iframe for the bank-payment panel's focus trap.
Cart wires closeOnBackdropClick=false on both dialogs (in-flight payment
shouldn't cancel on a stray click) and closeOnEscape tied to the bank
popup's open state, so Escape closes the nested bank iframe first and
falls back to the QR view - matches the original priority exactly.
Original geometry (500px QR modal/40px padding, 960x760 bank modal/
56-16-16 padding, both mobile breakpoints) preserved via :host ::ng-deep
overrides scoped per dialog instance - same pattern already used by
product-carousel-widget.component.ts.
cart.component.ts loses ~90 lines of hand-rolled ViewChild/HostListener/
focus-trap code - app-dialog owns all of it now.
Verified live in browser: dialog sizing/padding/aria-label correct at
mobile+desktop, backdrop-click confirmed inert, Escape-priority confirmed
(bank closes first, then QR), initial focus lands on close button.
83/83 tests pass, tsc/build clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 11:13:33 +04:00
|
|
|
import { Component, ChangeDetectionStrategy, signal, OnDestroy, inject } from '@angular/core';
|
2026-02-19 01:23:25 +04:00
|
|
|
import { DecimalPipe } from '@angular/common';
|
2026-01-18 18:57:06 +04:00
|
|
|
import { Router, RouterLink } from '@angular/router';
|
|
|
|
|
import { FormsModule } from '@angular/forms';
|
2026-06-28 22:18:35 +04:00
|
|
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
2026-08-18 01:05:16 +04:00
|
|
|
import { CartService, ApiService, LanguageService } from '../../services';
|
|
|
|
|
import { AuthService } from '@marketplaces/auth';
|
2026-06-21 23:13:01 +04:00
|
|
|
import { Item, CartItem, DeliveryOption } from '../../models';
|
2026-06-29 23:22:00 +04:00
|
|
|
import { EMPTY, interval, of, Subscription } from 'rxjs';
|
2026-06-06 22:38:01 +04:00
|
|
|
import { catchError, exhaustMap, take, timeout } from 'rxjs/operators';
|
2026-06-21 23:13:01 +04:00
|
|
|
import { DeliverySelectorComponent } from '../../components/delivery-selector/delivery-selector.component';
|
2026-03-24 00:09:11 +04:00
|
|
|
import { TelegramLoginComponent } from '../../components/telegram-login/telegram-login.component';
|
2026-07-26 00:12:43 +04:00
|
|
|
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField, onImageError } from '../../utils/item.utils';
|
2026-02-26 22:23:08 +04:00
|
|
|
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
2026-02-26 23:09:20 +04:00
|
|
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
|
|
|
|
import { TranslateService } from '../../i18n/translate.service';
|
2026-07-23 00:26:35 +04:00
|
|
|
import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MIN_POLL_SECONDS, PAYMENT_TIMEOUT_CLOSE_MS, LINK_COPIED_DURATION_MS } from '../../config/constants';
|
2026-07-20 02:45:37 +04:00
|
|
|
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
fix(storefront): composition audit fixes for cart, catalog, product, compare, wishlist, static pages
- Replace hand-rolled loading/error/empty markup with shared app-skeleton,
app-empty-state, and app-button across catalog, product details, cart,
compare, wishlist, and the public static-page renderer
- Fix hardcoded hex colors that bypassed theme CSS variables (catalog,
product details), restoring multi-tenant theme correctness
- Remove ~1100 lines of dead "alt" cart theme CSS (never applied by the
template) from cart.component.scss, bringing it back under the 40kB
build budget (89.49kB -> 59.39kB cart-component chunk)
- Swap legacy global .btn/.btn-ghost/.btn-primary classes for app-button
in compare and wishlist empty/toolbar actions
2026-07-23 10:37:06 +04:00
|
|
|
import { EmptyStateComponent } from '../../shared/ui/empty-state/empty-state.component';
|
|
|
|
|
import { ButtonComponent } from '../../shared/ui/button/button.component';
|
2026-07-23 00:26:35 +04:00
|
|
|
import { ConfigService } from '../../core/config/config.service';
|
2026-08-18 00:05:46 +04:00
|
|
|
import { AnalyticsService } from '../../core/analytics/services/analytics.service';
|
2026-07-23 00:26:35 +04:00
|
|
|
import { TenantResolverService } from '../../core/config/tenant-resolver.service';
|
2026-07-26 00:08:00 +04:00
|
|
|
import { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service';
|
|
|
|
|
import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component';
|
refactor: migrate cart payment modals to shared app-dialog primitive
Third attempt, done properly this time - first two were reverted
(one stopped cleanly on real conflicts, one botched sequencing and
deleted the old focus-trap before finishing the swap).
DialogComponent gains closeOnEscape/closeOnBackdropClick (default true,
backward-compatible with its 13 other call sites) and ariaLabel (for
dialogs with no visible title header). FOCUSABLE_SELECTOR now includes
iframe for the bank-payment panel's focus trap.
Cart wires closeOnBackdropClick=false on both dialogs (in-flight payment
shouldn't cancel on a stray click) and closeOnEscape tied to the bank
popup's open state, so Escape closes the nested bank iframe first and
falls back to the QR view - matches the original priority exactly.
Original geometry (500px QR modal/40px padding, 960x760 bank modal/
56-16-16 padding, both mobile breakpoints) preserved via :host ::ng-deep
overrides scoped per dialog instance - same pattern already used by
product-carousel-widget.component.ts.
cart.component.ts loses ~90 lines of hand-rolled ViewChild/HostListener/
focus-trap code - app-dialog owns all of it now.
Verified live in browser: dialog sizing/padding/aria-label correct at
mobile+desktop, backdrop-click confirmed inert, Escape-priority confirmed
(bank closes first, then QR), initial focus lands on close button.
83/83 tests pass, tsc/build clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 11:13:33 +04:00
|
|
|
import { DialogComponent } from '../../shared/ui/dialog/dialog.component';
|
2026-08-15 01:45:12 +04:00
|
|
|
import { CurrencyConvertPipe } from '../../pipes/currency-convert.pipe';
|
|
|
|
|
import { CurrencyRatesService } from '../../services/currency-rates.service';
|
2026-01-18 18:57:06 +04:00
|
|
|
|
2026-06-29 00:06:18 +04:00
|
|
|
type PaymentMethod = 'qr' | 'card';
|
|
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
@Component({
|
|
|
|
|
selector: 'app-cart',
|
2026-08-15 01:45:12 +04:00
|
|
|
imports: [DecimalPipe, RouterLink, FormsModule, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe, IconComponent, EmptyStateComponent, ButtonComponent, ConfirmDialogComponent, DialogComponent, CurrencyConvertPipe],
|
2026-01-18 18:57:06 +04:00
|
|
|
templateUrl: './cart.component.html',
|
|
|
|
|
styleUrls: ['./cart.component.scss'],
|
|
|
|
|
changeDetection: ChangeDetectionStrategy.OnPush
|
|
|
|
|
})
|
2026-02-26 21:54:21 +04:00
|
|
|
export class CartComponent implements OnDestroy {
|
2026-01-18 18:57:06 +04:00
|
|
|
items;
|
|
|
|
|
itemCount;
|
|
|
|
|
totalPrice;
|
2026-06-20 15:16:25 +04:00
|
|
|
totalDeliveryPrice;
|
|
|
|
|
totalWithDelivery;
|
|
|
|
|
hasDeliveryPrice;
|
2026-06-21 23:13:01 +04:00
|
|
|
allRequiredDeliveriesSelected;
|
2026-01-18 18:57:06 +04:00
|
|
|
termsAccepted = false;
|
|
|
|
|
|
2026-02-26 23:09:20 +04:00
|
|
|
private i18n = inject(TranslateService);
|
2026-02-28 17:18:24 +04:00
|
|
|
private authService = inject(AuthService);
|
2026-07-26 00:08:00 +04:00
|
|
|
private notifications = inject(UserNotificationService);
|
2026-02-26 23:09:20 +04:00
|
|
|
|
2026-03-24 00:09:11 +04:00
|
|
|
isAuthenticated = this.authService.isAuthenticated;
|
|
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
// Swipe state
|
|
|
|
|
swipedItemId = signal<number | null>(null);
|
|
|
|
|
|
|
|
|
|
// Payment popup states
|
|
|
|
|
showPaymentPopup = signal<boolean>(false);
|
2026-06-18 18:29:39 +04:00
|
|
|
paymentStatus = signal<'creating' | 'waiting' | 'success' | 'timeout' | 'error' | null>('creating');
|
2026-01-18 18:57:06 +04:00
|
|
|
qrCodeUrl = signal<string>('');
|
|
|
|
|
paymentUrl = signal<string>('');
|
2026-06-28 22:18:35 +04:00
|
|
|
bankPaymentUrl = signal<string>('');
|
|
|
|
|
bankPaymentFrameUrl = signal<SafeResourceUrl | null>(null);
|
|
|
|
|
showBankPaymentPopup = signal<boolean>(false);
|
2026-06-29 00:06:18 +04:00
|
|
|
selectedPaymentMethod = signal<PaymentMethod>('qr');
|
2026-01-18 18:57:06 +04:00
|
|
|
paymentId = signal<string>('');
|
|
|
|
|
linkCopied = signal<boolean>(false);
|
|
|
|
|
|
|
|
|
|
// Email collection after successful payment
|
|
|
|
|
userEmail = signal<string>('');
|
|
|
|
|
userPhone = signal<string>('');
|
|
|
|
|
emailTouched = signal<boolean>(false);
|
|
|
|
|
phoneTouched = signal<boolean>(false);
|
|
|
|
|
emailError = signal<string>('');
|
|
|
|
|
phoneError = signal<string>('');
|
|
|
|
|
emailSubmitting = signal<boolean>(false);
|
2026-08-13 07:37:36 +04:00
|
|
|
purchaseSubmitted = signal<boolean>(false);
|
2026-01-18 18:57:06 +04:00
|
|
|
paidItems: CartItem[] = [];
|
|
|
|
|
|
2026-07-23 00:26:35 +04:00
|
|
|
maxChecks = Math.ceil(PAYMENT_MIN_POLL_SECONDS / (PAYMENT_POLL_INTERVAL_MS / 1000));
|
2026-01-18 18:57:06 +04:00
|
|
|
private pollingSubscription?: Subscription;
|
|
|
|
|
private closeTimeout?: ReturnType<typeof setTimeout>;
|
|
|
|
|
|
2026-07-23 00:26:35 +04:00
|
|
|
private configService = inject(ConfigService);
|
|
|
|
|
private tenantResolver = inject(TenantResolverService);
|
2026-08-15 01:45:12 +04:00
|
|
|
private currencyRates = inject(CurrencyRatesService);
|
2026-08-18 00:05:46 +04:00
|
|
|
private readonly analytics = inject(AnalyticsService);
|
2026-07-23 00:26:35 +04:00
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
constructor(
|
|
|
|
|
private cartService: CartService,
|
|
|
|
|
private apiService: ApiService,
|
2026-02-26 22:23:08 +04:00
|
|
|
private router: Router,
|
2026-06-28 22:18:35 +04:00
|
|
|
private langService: LanguageService,
|
|
|
|
|
private sanitizer: DomSanitizer
|
2026-01-18 18:57:06 +04:00
|
|
|
) {
|
|
|
|
|
this.items = this.cartService.items;
|
|
|
|
|
this.itemCount = this.cartService.itemCount;
|
|
|
|
|
this.totalPrice = this.cartService.totalPrice;
|
2026-06-20 15:16:25 +04:00
|
|
|
this.totalDeliveryPrice = this.cartService.totalDeliveryPrice;
|
|
|
|
|
this.totalWithDelivery = this.cartService.totalWithDelivery;
|
|
|
|
|
this.hasDeliveryPrice = this.cartService.hasDeliveryPrice;
|
2026-06-21 23:13:01 +04:00
|
|
|
this.allRequiredDeliveriesSelected = this.cartService.allRequiredDeliveriesSelected;
|
2026-03-24 00:09:11 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
requestLogin(): void {
|
|
|
|
|
this.authService.requestLogin();
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ngOnDestroy(): void {
|
|
|
|
|
this.stopPolling();
|
|
|
|
|
if (this.closeTimeout) {
|
|
|
|
|
clearTimeout(this.closeTimeout);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 01:12:07 +04:00
|
|
|
removeItem(item: CartItem): void {
|
|
|
|
|
this.cartService.removeItem(item.itemID, this.cartVariant(item));
|
2026-01-18 18:57:06 +04:00
|
|
|
this.swipedItemId.set(null);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 01:12:07 +04:00
|
|
|
updateQuantity(item: CartItem, quantity: number): void {
|
|
|
|
|
this.cartService.updateQuantity(item.itemID, quantity, this.cartVariant(item));
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-05 01:12:07 +04:00
|
|
|
increaseQuantity(item: CartItem): void {
|
|
|
|
|
this.updateQuantity(item, item.quantity + 1);
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-05 01:12:07 +04:00
|
|
|
decreaseQuantity(item: CartItem): void {
|
|
|
|
|
if (item.quantity <= 1) {
|
|
|
|
|
this.removeItem(item);
|
2026-01-18 18:57:06 +04:00
|
|
|
} else {
|
2026-07-05 01:12:07 +04:00
|
|
|
this.updateQuantity(item, item.quantity - 1);
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 01:12:07 +04:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
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 = () => {
|
2026-02-26 21:54:21 +04:00
|
|
|
document.removeEventListener('touchmove', onMove);
|
2026-01-18 18:57:06 +04:00
|
|
|
document.removeEventListener('touchend', cleanup);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-26 21:54:21 +04:00
|
|
|
document.addEventListener('touchmove', onMove);
|
2026-01-18 18:57:06 +04:00
|
|
|
document.addEventListener('touchend', cleanup);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-26 00:08:00 +04:00
|
|
|
readonly clearCartConfirmOpen = signal(false);
|
|
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
clearCart(): void {
|
2026-07-26 00:08:00 +04:00
|
|
|
this.clearCartConfirmOpen.set(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
confirmClearCart(): void {
|
|
|
|
|
this.cartService.clearCart();
|
|
|
|
|
this.clearCartConfirmOpen.set(false);
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 01:23:25 +04:00
|
|
|
readonly getMainImage = getMainImage;
|
2026-07-26 00:12:43 +04:00
|
|
|
readonly onImageError = onImageError;
|
2026-02-19 01:23:25 +04:00
|
|
|
readonly trackByItemId = trackByItemId;
|
|
|
|
|
readonly getDiscountedPrice = getDiscountedPrice;
|
2026-02-20 10:44:03 +04:00
|
|
|
readonly getBadgeClass = getBadgeClass;
|
2026-01-18 18:57:06 +04:00
|
|
|
|
2026-03-24 00:09:11 +04:00
|
|
|
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(); }
|
2026-08-15 01:45:12 +04:00
|
|
|
/** Cart totals are summed in the base currency (RUB); convert to whatever the shopper has selected. */
|
|
|
|
|
convertTotal(amountInBaseCurrency: number): number {
|
|
|
|
|
return this.currencyRates.convert(amountInBaseCurrency, this.currencyRates.baseCurrency, this.currentCurrency);
|
|
|
|
|
}
|
2026-06-21 23:13:01 +04:00
|
|
|
get isCheckoutDisabled(): boolean { return !this.termsAccepted || !this.isAuthenticated() || !this.allRequiredDeliveriesSelected(); }
|
|
|
|
|
|
|
|
|
|
selectDelivery(itemID: number, selectedDelivery: DeliveryOption | null): void {
|
|
|
|
|
this.cartService.setSelectedDelivery(itemID, selectedDelivery);
|
|
|
|
|
}
|
2026-01-18 18:57:06 +04:00
|
|
|
|
2026-06-29 00:06:18 +04:00
|
|
|
checkout(paymentMethod: PaymentMethod): void {
|
2026-06-21 23:13:01 +04:00
|
|
|
if (!this.allRequiredDeliveriesSelected()) {
|
2026-07-26 00:08:00 +04:00
|
|
|
this.notifications.show(this.i18n.t('cart.deliveryRequired'), 'warning');
|
2026-06-21 23:13:01 +04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
if (!this.termsAccepted) {
|
2026-07-26 00:08:00 +04:00
|
|
|
this.notifications.show(this.i18n.t('cart.acceptTerms'), 'warning');
|
2026-01-18 18:57:06 +04:00
|
|
|
return;
|
|
|
|
|
}
|
2026-08-18 00:05:46 +04:00
|
|
|
this.analytics.track('checkout_started', { itemCount: this.items().length });
|
2026-06-29 00:06:18 +04:00
|
|
|
this.openPaymentPopup(paymentMethod);
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 00:06:18 +04:00
|
|
|
openPaymentPopup(paymentMethod: PaymentMethod): void {
|
2026-08-18 00:05:46 +04:00
|
|
|
this.analytics.track('payment_started', { paymentMethod });
|
2026-01-18 18:57:06 +04:00
|
|
|
this.showPaymentPopup.set(true);
|
2026-06-29 00:06:18 +04:00
|
|
|
this.selectedPaymentMethod.set(paymentMethod);
|
2026-01-18 18:57:06 +04:00
|
|
|
this.paymentStatus.set('creating');
|
2026-06-02 00:57:36 +04:00
|
|
|
this.paymentId.set('');
|
|
|
|
|
this.qrCodeUrl.set('');
|
|
|
|
|
this.paymentUrl.set('');
|
2026-06-28 22:18:35 +04:00
|
|
|
this.bankPaymentUrl.set('');
|
|
|
|
|
this.bankPaymentFrameUrl.set(null);
|
|
|
|
|
this.showBankPaymentPopup.set(false);
|
2026-06-02 00:57:36 +04:00
|
|
|
this.linkCopied.set(false);
|
2026-01-18 18:57:06 +04:00
|
|
|
this.userEmail.set('');
|
|
|
|
|
this.userPhone.set('');
|
|
|
|
|
this.emailTouched.set(false);
|
|
|
|
|
this.phoneTouched.set(false);
|
|
|
|
|
this.emailError.set('');
|
|
|
|
|
this.phoneError.set('');
|
|
|
|
|
this.emailSubmitting.set(false);
|
2026-08-13 07:37:36 +04:00
|
|
|
this.purchaseSubmitted.set(false);
|
2026-01-18 18:57:06 +04:00
|
|
|
this.paidItems = [...this.items()];
|
feat: FX-quote-backed currency conversion, delete admin rate editor
F10-F12 of the frontend backlog. Contract: PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md §3.
Removed the failure mode §5 of that contract exists to close: rates were
typed once by an admin into Settings, persisted to localStorage, seeded from
a hardcoded DEFAULT_RATES table (USD: 0.011, AMD: 4.3) that never updated and
drifted from market. Nothing recorded which rate produced a displayed price
or when.
- currency-rates.service.ts now fetches through FX_QUOTE_GATEWAY instead of
reading admin-typed/localStorage numbers. Stays
synchronous at the call site (getRate/convert) -
rewriting every consuming template to `| async`
is a separate, larger change (F13, not this
commit). Before a quote has loaded for a pair,
getRate returns 1 rather than a fabricated
market rate; isRateReady() lets a caller that
cares distinguish the two. ensureFreshQuote()
added for checkout to await before charging,
per contract §3.2's stale-quote policy.
- language.service.ts setCurrency() now triggers a quote fetch instead
of just flipping the display signal.
- cart.component.ts openPaymentPopup() awaits ensureFreshQuote()
before computing the charged amount.
- admin-settings-page.* currency-rate editor deleted (F11) - card,
component state, and the three orphaned i18n
keys it was the only consumer of.
Two real bugs surfaced fixing this, neither cosmetic:
1. fx-quote-local.gateway.ts had CurrencyRatesService.convert() as its rate
source. That is now circular - CurrencyRatesService depends on
FX_QUOTE_GATEWAY, and under useMockData:true this gateway IS
FX_QUOTE_GATEWAY. Would have recursed the moment mock FX data was
exercised. Fixed by giving the local gateway its own static mock table -
the correct home for those numbers now: explicitly labelled dev/mock data,
only wired in behind useMockData, never presented as a live rate.
2. currency-convert.pipe.ts memoized its result on (amount, from, to) alone.
That was already latently wrong - rates could change via the old
setRate() without the pipe re-evaluating for an already-rendered price -
but never surfaced because rates never changed mid-session in practice.
Async quote loading made it concrete and reproducible: a price rendered
before its quote arrived stayed wrong forever, because none of the three
cached inputs ever changed again on their own. Fixed with a ratesVersion
counter on the service, bumped on every quote arrival, included in the
pipe's cache key.
Both found and fixed via the E2E suite (docs from the prior commit) actually
exercising the real code path: GET /api/v2/pricing/fx-quote intercepted with
a contract-shaped response rather than flipping the whole app into mock mode,
so the test runs the real FxQuoteApiGateway, not a stand-in for it.
Verified: 3/3 E2E green, 115/115 unit tests green, arch:check clean,
production build succeeds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:03:02 +04:00
|
|
|
|
|
|
|
|
// Contract §3.2 stale-quote policy: the amount about to be charged must
|
|
|
|
|
// be computed from a rate fetched now, not one cached from whenever the
|
|
|
|
|
// shopper last switched currency or opened this page. 'creating' is
|
|
|
|
|
// already showing, so this adds a wait, not a new state.
|
|
|
|
|
// ensureFreshQuote never rejects (a fetch failure resolves with the
|
|
|
|
|
// previously cached rate left in place) - createPayment always runs.
|
|
|
|
|
void this.currencyRates.ensureFreshQuote(this.currentCurrency).then(() => this.createPayment(paymentMethod));
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
closePaymentPopup(): void {
|
|
|
|
|
this.showPaymentPopup.set(false);
|
2026-06-28 22:18:35 +04:00
|
|
|
this.closeBankPaymentPopup();
|
2026-01-18 18:57:06 +04:00
|
|
|
this.stopPolling();
|
|
|
|
|
if (this.closeTimeout) {
|
|
|
|
|
clearTimeout(this.closeTimeout);
|
|
|
|
|
this.closeTimeout = undefined;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:57:36 +04:00
|
|
|
retryPayment(): void {
|
|
|
|
|
if (this.closeTimeout) {
|
|
|
|
|
clearTimeout(this.closeTimeout);
|
|
|
|
|
this.closeTimeout = undefined;
|
2026-03-24 02:25:50 +04:00
|
|
|
}
|
2026-01-18 18:57:06 +04:00
|
|
|
|
2026-06-02 00:57:36 +04:00
|
|
|
this.paymentStatus.set('creating');
|
|
|
|
|
this.paymentId.set('');
|
|
|
|
|
this.qrCodeUrl.set('');
|
|
|
|
|
this.paymentUrl.set('');
|
2026-06-28 22:18:35 +04:00
|
|
|
this.bankPaymentUrl.set('');
|
|
|
|
|
this.bankPaymentFrameUrl.set(null);
|
|
|
|
|
this.showBankPaymentPopup.set(false);
|
2026-06-02 00:57:36 +04:00
|
|
|
this.linkCopied.set(false);
|
2026-06-29 00:06:18 +04:00
|
|
|
this.createPayment(this.selectedPaymentMethod());
|
2026-06-02 00:57:36 +04:00
|
|
|
}
|
|
|
|
|
|
2026-06-29 00:06:18 +04:00
|
|
|
createPayment(paymentMethod: PaymentMethod): void {
|
2026-06-06 22:38:01 +04:00
|
|
|
const orderId = this.generateOrderId();
|
|
|
|
|
const paymentPayload = {
|
2026-08-15 01:45:12 +04:00
|
|
|
amount: Number(this.convertTotal(this.totalWithDelivery())),
|
2026-08-13 07:39:35 +04:00
|
|
|
currency: this.langService.currentCurrency(),
|
2026-06-06 22:38:01 +04:00
|
|
|
siteuserID: this.getPaymentUserId(),
|
|
|
|
|
siteorderID: orderId,
|
|
|
|
|
redirectUrl: '',
|
|
|
|
|
telegramUsername: this.getTelegramUsername(),
|
2026-06-29 00:06:18 +04:00
|
|
|
paymentMethod,
|
2026-07-23 00:26:35 +04:00
|
|
|
qrDescription: this.getPaymentDescription(),
|
|
|
|
|
customerID: this.getTelegramUserId() ?? undefined,
|
2026-06-06 22:38:01 +04:00
|
|
|
items: this.buildPaymentItems(),
|
2026-06-02 01:46:12 +04:00
|
|
|
};
|
|
|
|
|
|
2026-06-06 22:38:01 +04:00
|
|
|
this.apiService.createCartPayment(paymentPayload)
|
2026-06-02 00:57:36 +04:00
|
|
|
.subscribe({
|
2026-03-24 02:25:50 +04:00
|
|
|
next: (response) => {
|
2026-06-02 01:46:12 +04:00
|
|
|
const qrId = this.apiService.resolvePaymentQrId(response);
|
|
|
|
|
const qrUrl = this.apiService.resolvePaymentQrUrl(response);
|
|
|
|
|
const paymentLink = this.apiService.resolvePaymentLink(response);
|
2026-06-28 22:18:35 +04:00
|
|
|
const bankUrl = this.apiService.resolveBankPaymentUrl(response);
|
2026-06-02 00:57:36 +04:00
|
|
|
|
2026-06-29 00:06:18 +04:00
|
|
|
if (!qrId || (paymentMethod === 'qr' && !qrUrl) || (paymentMethod === 'card' && !bankUrl)) {
|
2026-06-28 22:18:35 +04:00
|
|
|
console.error('Payment response missing payment fields:', response);
|
2026-06-02 00:57:36 +04:00
|
|
|
this.setPaymentError();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.paymentId.set(qrId);
|
|
|
|
|
this.qrCodeUrl.set(qrUrl);
|
2026-06-02 01:46:12 +04:00
|
|
|
this.paymentUrl.set(paymentLink);
|
2026-06-28 22:18:35 +04:00
|
|
|
this.bankPaymentUrl.set(bankUrl);
|
2026-06-02 01:46:12 +04:00
|
|
|
|
2026-03-24 02:25:50 +04:00
|
|
|
this.paymentStatus.set('waiting');
|
2026-07-23 00:26:35 +04:00
|
|
|
this.startPolling(response.qrTTL);
|
2026-06-29 00:06:18 +04:00
|
|
|
if (paymentMethod === 'card') {
|
|
|
|
|
this.openBankPaymentPopup();
|
|
|
|
|
}
|
2026-03-24 02:25:50 +04:00
|
|
|
},
|
|
|
|
|
error: (err) => {
|
|
|
|
|
console.error('Error creating payment:', err);
|
2026-06-02 00:57:36 +04:00
|
|
|
this.setPaymentError();
|
2026-03-24 02:25:50 +04:00
|
|
|
}
|
|
|
|
|
});
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-23 00:26:35 +04:00
|
|
|
startPolling(qrTTL?: number): void {
|
2026-03-06 17:45:34 +04:00
|
|
|
this.stopPolling();
|
2026-06-06 16:16:37 +04:00
|
|
|
if (!this.paymentId()) {
|
2026-06-02 01:46:12 +04:00
|
|
|
this.setPaymentError();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 00:26:35 +04:00
|
|
|
const pollSeconds = Math.max(PAYMENT_MIN_POLL_SECONDS, (qrTTL ?? 0) * 60);
|
|
|
|
|
this.maxChecks = Math.ceil(pollSeconds / (PAYMENT_POLL_INTERVAL_MS / 1000));
|
|
|
|
|
|
2026-03-06 18:40:58 +04:00
|
|
|
this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS)
|
2026-01-18 18:57:06 +04:00
|
|
|
.pipe(
|
2026-07-23 00:26:35 +04:00
|
|
|
take(this.maxChecks), // qrTTL minutes from create response, minimum 1 minute
|
2026-06-05 17:57:18 +04:00
|
|
|
exhaustMap(() => {
|
2026-06-29 22:15:19 +04:00
|
|
|
const statusRequest = this.selectedPaymentMethod() === 'card'
|
|
|
|
|
? this.apiService.checkCartCardPaymentStatus(this.paymentId())
|
|
|
|
|
: this.apiService.checkCartPaymentStatus(this.paymentId());
|
|
|
|
|
|
|
|
|
|
return statusRequest.pipe(
|
2026-06-05 17:57:18 +04:00
|
|
|
timeout(8000),
|
2026-06-02 00:57:36 +04:00
|
|
|
catchError((err) => {
|
|
|
|
|
console.error('Error checking payment status:', err);
|
2026-06-29 23:22:00 +04:00
|
|
|
this.setPaymentError();
|
|
|
|
|
return EMPTY;
|
2026-06-02 00:57:36 +04:00
|
|
|
})
|
|
|
|
|
);
|
2026-01-18 18:57:06 +04:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.subscribe({
|
|
|
|
|
next: (response) => {
|
2026-06-02 00:57:36 +04:00
|
|
|
if (!response) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 13:11:05 +04:00
|
|
|
const paymentStatus = response.status?.toUpperCase() || '';
|
2026-06-06 22:38:01 +04:00
|
|
|
const paymentCode = response.code?.toUpperCase() || '';
|
2026-06-02 00:57:36 +04:00
|
|
|
|
2026-06-06 16:16:37 +04:00
|
|
|
if (paymentStatus === 'FAILED' || paymentStatus === 'EXPIRED' || paymentStatus === 'CANCELLED' || paymentStatus === 'REJECTED') {
|
2026-06-02 00:57:36 +04:00
|
|
|
this.paymentStatus.set('timeout');
|
2026-06-28 22:18:35 +04:00
|
|
|
this.closeBankPaymentPopup();
|
2026-06-02 00:57:36 +04:00
|
|
|
this.stopPolling();
|
|
|
|
|
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
|
|
|
|
this.closeTimeout = setTimeout(() => {
|
|
|
|
|
this.closePaymentPopup();
|
|
|
|
|
}, PAYMENT_TIMEOUT_CLOSE_MS);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
// Check if payment is successful
|
2026-06-06 22:38:01 +04:00
|
|
|
if (paymentStatus === 'COMPLETED' || paymentStatus === 'APPROVED' || paymentStatus === 'PAID' || paymentCode === 'SUCCESS') {
|
2026-01-18 18:57:06 +04:00
|
|
|
this.paymentStatus.set('success');
|
2026-06-28 22:18:35 +04:00
|
|
|
this.closeBankPaymentPopup();
|
2026-01-18 18:57:06 +04:00
|
|
|
this.stopPolling();
|
2026-06-18 18:30:20 +04:00
|
|
|
// Auto-submit purchase after 5 seconds
|
2026-06-18 15:09:56 +04:00
|
|
|
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
|
|
|
|
this.closeTimeout = setTimeout(() => {
|
|
|
|
|
this.autoSubmitPurchase();
|
|
|
|
|
}, 5000);
|
2026-07-20 01:02:36 +04:00
|
|
|
this.recordOrder();
|
2026-06-18 18:30:20 +04:00
|
|
|
this.cartService.clearCart();
|
2026-07-20 01:02:36 +04:00
|
|
|
|
2026-06-18 18:30:20 +04:00
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
// 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');
|
2026-06-28 22:18:35 +04:00
|
|
|
this.closeBankPaymentPopup();
|
2026-01-18 18:57:06 +04:00
|
|
|
// Close popup after showing timeout message
|
2026-03-06 17:45:34 +04:00
|
|
|
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
2026-01-18 18:57:06 +04:00
|
|
|
this.closeTimeout = setTimeout(() => {
|
|
|
|
|
this.closePaymentPopup();
|
2026-03-06 18:40:58 +04:00
|
|
|
}, PAYMENT_TIMEOUT_CLOSE_MS);
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
stopPolling(): void {
|
|
|
|
|
if (this.pollingSubscription) {
|
|
|
|
|
this.pollingSubscription.unsubscribe();
|
2026-06-02 00:57:36 +04:00
|
|
|
this.pollingSubscription = undefined;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 22:18:35 +04:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:57:36 +04:00
|
|
|
private setPaymentError(): void {
|
|
|
|
|
this.paymentStatus.set('error');
|
2026-06-28 22:18:35 +04:00
|
|
|
this.closeBankPaymentPopup();
|
2026-06-02 00:57:36 +04:00
|
|
|
this.stopPolling();
|
|
|
|
|
if (this.closeTimeout) {
|
|
|
|
|
clearTimeout(this.closeTimeout);
|
|
|
|
|
this.closeTimeout = undefined;
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 01:02:36 +04:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
})),
|
|
|
|
|
customer: {
|
2026-07-25 23:54:08 +04:00
|
|
|
name: this.getTelegramUsername() || this.i18n.t('common.guest'),
|
2026-07-20 01:02:36 +04:00
|
|
|
email,
|
|
|
|
|
phone,
|
|
|
|
|
},
|
|
|
|
|
payment: {
|
|
|
|
|
method: this.selectedPaymentMethod(),
|
2026-08-13 07:39:35 +04:00
|
|
|
currency: this.langService.currentCurrency(),
|
2026-07-20 01:02:36 +04:00
|
|
|
},
|
|
|
|
|
}).subscribe({
|
|
|
|
|
error: (err) => console.error('Error recording order:', err),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 07:37:36 +04:00
|
|
|
/**
|
|
|
|
|
* Fallback fired a few seconds after payment success if the user hasn't
|
|
|
|
|
* already submitted the email/phone form themselves (submitEmail()).
|
|
|
|
|
* Navigates home only once the submission result is known, never before -
|
|
|
|
|
* and sends whatever the user has typed so far instead of blank fields.
|
|
|
|
|
*/
|
2026-06-18 15:09:56 +04:00
|
|
|
private autoSubmitPurchase(): void {
|
2026-08-13 07:37:36 +04:00
|
|
|
if (this.purchaseSubmitted()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 15:09:56 +04:00
|
|
|
const telegramUserId = this.getTelegramUserId();
|
2026-08-13 07:37:36 +04:00
|
|
|
|
|
|
|
|
// Telegram ID is mandatory for submitPurchaseEmail.
|
2026-06-18 15:09:56 +04:00
|
|
|
if (!telegramUserId) {
|
2026-08-13 07:37:36 +04:00
|
|
|
this.notifications.show(this.i18n.t('cart.telegramIdMissing'), 'warning');
|
2026-06-18 15:09:56 +04:00
|
|
|
this.emailSubmitting.set(false);
|
2026-08-13 07:37:36 +04:00
|
|
|
this.closePaymentPopup();
|
|
|
|
|
const lang = this.langService.currentLanguage();
|
|
|
|
|
this.router.navigate([`/${lang}`]);
|
2026-06-18 15:09:56 +04:00
|
|
|
return;
|
|
|
|
|
}
|
2026-08-13 07:37:36 +04:00
|
|
|
|
2026-06-18 15:09:56 +04:00
|
|
|
this.emailSubmitting.set(true);
|
2026-08-13 07:37:36 +04:00
|
|
|
|
2026-06-18 15:09:56 +04:00
|
|
|
const emailData = {
|
2026-08-13 07:37:36 +04:00
|
|
|
email: this.userEmail().trim(),
|
|
|
|
|
phone: this.userPhone().replace(/\D/g, ''),
|
2026-06-18 15:09:56 +04:00
|
|
|
telegramUserId: telegramUserId,
|
|
|
|
|
items: this.paidItems.map((item: CartItem) => ({
|
|
|
|
|
itemID: item.itemID,
|
|
|
|
|
name: item.name,
|
2026-08-13 07:37:36 +04:00
|
|
|
price: item.discount > 0
|
2026-06-18 15:09:56 +04:00
|
|
|
? item.price * (1 - item.discount / 100)
|
|
|
|
|
: item.price,
|
|
|
|
|
currency: item.currency,
|
2026-06-21 23:13:01 +04:00
|
|
|
quantity: item.quantity,
|
2026-06-22 10:46:51 +04:00
|
|
|
...(item.selectedDelivery ? { delivery: [item.selectedDelivery] } : {})
|
2026-06-18 15:09:56 +04:00
|
|
|
}))
|
|
|
|
|
};
|
2026-08-13 07:37:36 +04:00
|
|
|
|
2026-06-18 15:09:56 +04:00
|
|
|
this.apiService.submitPurchaseEmail(emailData).subscribe({
|
|
|
|
|
next: () => {
|
2026-08-13 07:37:36 +04:00
|
|
|
this.purchaseSubmitted.set(true);
|
2026-06-18 15:09:56 +04:00
|
|
|
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}`]);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
copyPaymentLink(): void {
|
|
|
|
|
const url = this.paymentUrl();
|
|
|
|
|
if (url) {
|
|
|
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
|
|
|
this.linkCopied.set(true);
|
2026-03-06 18:40:58 +04:00
|
|
|
setTimeout(() => this.linkCopied.set(false), LINK_COPIED_DURATION_MS);
|
2026-01-18 18:57:06 +04:00
|
|
|
}).catch(err => {
|
2026-02-26 23:09:20 +04:00
|
|
|
console.error(this.i18n.t('cart.copyError'), err);
|
2026-01-18 18:57:06 +04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-06-21 23:13:01 +04:00
|
|
|
quantity: item.quantity,
|
2026-06-22 10:46:51 +04:00
|
|
|
...(item.selectedDelivery ? { delivery: [item.selectedDelivery] } : {})
|
2026-01-18 18:57:06 +04:00
|
|
|
}))
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.apiService.submitPurchaseEmail(emailData).subscribe({
|
|
|
|
|
next: () => {
|
2026-08-13 07:37:36 +04:00
|
|
|
this.purchaseSubmitted.set(true);
|
|
|
|
|
if (this.closeTimeout) {
|
|
|
|
|
clearTimeout(this.closeTimeout);
|
|
|
|
|
this.closeTimeout = undefined;
|
|
|
|
|
}
|
2026-01-18 18:57:06 +04:00
|
|
|
this.emailSubmitting.set(false);
|
2026-07-26 00:08:00 +04:00
|
|
|
this.notifications.show(this.i18n.t('cart.emailSuccess'), 'success');
|
2026-01-18 18:57:06 +04:00
|
|
|
// Close popup and redirect to home page
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
this.closePaymentPopup();
|
2026-02-26 22:23:08 +04:00
|
|
|
const lang = this.langService.currentLanguage();
|
|
|
|
|
this.router.navigate([`/${lang}`]);
|
2026-01-18 18:57:06 +04:00
|
|
|
}, 500);
|
|
|
|
|
},
|
|
|
|
|
error: (err) => {
|
|
|
|
|
console.error('Error submitting email:', err);
|
|
|
|
|
this.emailSubmitting.set(false);
|
2026-07-26 00:08:00 +04:00
|
|
|
this.notifications.show(this.i18n.t('cart.emailError'), 'warning');
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getTelegramUserId(): string | null {
|
2026-06-19 12:43:25 +04:00
|
|
|
const sessionTelegramUserId = this.authService.session()?.userId;
|
2026-06-19 15:01:54 +04:00
|
|
|
if (sessionTelegramUserId !== null && sessionTelegramUserId !== undefined) {
|
2026-06-02 00:57:36 +04:00
|
|
|
return sessionTelegramUserId.toString();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user) {
|
|
|
|
|
return window.Telegram.WebApp.initDataUnsafe.user.id.toString();
|
|
|
|
|
}
|
2026-06-02 00:57:36 +04:00
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:57:36 +04:00
|
|
|
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()}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 00:26:35 +04:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 09:09:12 +04:00
|
|
|
return this.i18n.t('cart.paymentDescriptionFallback');
|
2026-07-23 00:26:35 +04:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:57:36 +04:00
|
|
|
private generateOrderId(): string {
|
|
|
|
|
const timestamp = Date.now();
|
|
|
|
|
const random = Math.random().toString(36).substring(2, 8);
|
|
|
|
|
return `order_${timestamp}_${random}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 10:46:51 +04:00
|
|
|
private buildPaymentItems(): Array<{ itemID: number; price: number; name: string; quantity: number; delivery?: DeliveryOption[] }> {
|
2026-06-06 22:38:01 +04:00
|
|
|
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,
|
2026-06-21 23:13:01 +04:00
|
|
|
quantity: item.quantity,
|
2026-06-22 10:46:51 +04:00
|
|
|
...(item.selectedDelivery ? { delivery: [item.selectedDelivery] } : {}),
|
2026-06-06 22:38:01 +04:00
|
|
|
};
|
|
|
|
|
});
|
2026-06-06 16:16:37 +04:00
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
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) {
|
2026-02-26 23:09:20 +04:00
|
|
|
this.phoneError.set(this.i18n.t('cart.phoneRequired'));
|
2026-01-18 18:57:06 +04:00
|
|
|
} else if (digitsOnly.length < 11) {
|
2026-02-26 23:09:20 +04:00
|
|
|
this.phoneError.set(this.i18n.t('cart.phoneMoreDigits', { count: 11 - digitsOnly.length }));
|
2026-01-18 18:57:06 +04:00
|
|
|
} else if (digitsOnly.length > 11) {
|
2026-02-26 23:09:20 +04:00
|
|
|
this.phoneError.set(this.i18n.t('cart.phoneTooMany'));
|
2026-01-18 18:57:06 +04:00
|
|
|
} 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) {
|
2026-02-26 23:09:20 +04:00
|
|
|
this.emailError.set(this.i18n.t('cart.emailRequired'));
|
2026-01-18 18:57:06 +04:00
|
|
|
} else if (email.length < 5) {
|
2026-02-26 23:09:20 +04:00
|
|
|
this.emailError.set(this.i18n.t('cart.emailTooShort'));
|
2026-01-18 18:57:06 +04:00
|
|
|
} else if (email.length > 100) {
|
2026-02-26 23:09:20 +04:00
|
|
|
this.emailError.set(this.i18n.t('cart.emailTooLong'));
|
2026-01-18 18:57:06 +04:00
|
|
|
} else if (!email.includes('@')) {
|
2026-02-26 23:09:20 +04:00
|
|
|
this.emailError.set(this.i18n.t('cart.emailNeedsAt'));
|
2026-01-18 18:57:06 +04:00
|
|
|
} else if (!email.includes('.')) {
|
2026-02-26 23:09:20 +04:00
|
|
|
this.emailError.set(this.i18n.t('cart.emailNeedsDomain'));
|
2026-01-18 18:57:06 +04:00
|
|
|
} else {
|
|
|
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
|
if (!emailRegex.test(email)) {
|
2026-02-26 23:09:20 +04:00
|
|
|
this.emailError.set(this.i18n.t('cart.emailInvalid'));
|
2026-01-18 18:57:06 +04:00
|
|
|
} else {
|
|
|
|
|
this.emailError.set('');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|