Verified zero remaining callers for each before deleting (grepped
src/app for every method name individually), not assumed from the
earlier commit's dead-code note.
Deleted from api.service.ts:
- createPayment() - legacy direct QR creation (POST {qrBaseUrl}/qr)
- createCartPayment() - legacy /cart payment creation, client-sent amount
- createPaymentIntent() - superseded by @marketplaces/payment's gateway
- checkCartPaymentStatus(), checkCartCardPaymentStatus(), checkPaymentStatus()
- legacy QR/card status polls, superseded by the same gateway
- resolvePaymentQrId/resolvePaymentQrUrl/resolvePaymentLink/
resolveBankPaymentUrl - QrCreateResponse field-normalization helpers,
no longer had a caller once the methods above were gone
- Types: QrCreateRequest, QrCreateResponse, CartPaymentRequest,
PaymentIntentRequest, QrDynamicStatusResponse
- Fields: qrBaseUrl, cartPaymentPartnerId - no longer read by anything
- Imports: HttpHeaders, environment - no longer used in this file
Did NOT touch createOrder() or createCheckoutSession() - both still have
live callers in cart.component.ts, confirmed before deciding what to keep.
cart.component.ts: corrected the createPaymentIntent() comment, which
referenced the deleted method/helper names, to name what actually got
deleted instead of what was merely "dead as of that commit."
docs/backend/FRONTEND-API-SURFACE-COMPLETE.md: moved the 4 now-deleted
QR/card endpoints out of the "still live" legacy table into a dated removal
note - the doc's own premise is "every endpoint this codebase currently
calls," so it was wrong to leave them listed as called once they weren't.
Legacy-undocumented count corrected 15->11, total 97->93.
Verified: production build succeeds (no dangling references), 247/247 unit
tests, arch:check clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
801 lines
28 KiB
TypeScript
801 lines
28 KiB
TypeScript
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 } from '../../services';
|
|
import { AuthService } from '@marketplaces/auth';
|
|
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, onImageError } 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 { EmptyStateComponent } from '../../shared/ui/empty-state/empty-state.component';
|
|
import { ButtonComponent } from '../../shared/ui/button/button.component';
|
|
import { AnalyticsService } from '../../core/analytics/services/analytics.service';
|
|
import { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service';
|
|
import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component';
|
|
import { DialogComponent } from '../../shared/ui/dialog/dialog.component';
|
|
import { CurrencyConvertPipe } from '../../pipes/currency-convert.pipe';
|
|
import { CurrencyRatesService } from '../../services/currency-rates.service';
|
|
import { MARKETPLACES_PAYMENT_GATEWAY, PaymentAttempt, PaymentMethod as PackagePaymentMethod } from '@marketplaces/payment';
|
|
|
|
type PaymentMethod = 'qr' | 'card';
|
|
|
|
@Component({
|
|
selector: 'app-cart',
|
|
imports: [DecimalPipe, RouterLink, FormsModule, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe, IconComponent, EmptyStateComponent, ButtonComponent, ConfirmDialogComponent, DialogComponent, CurrencyConvertPipe],
|
|
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);
|
|
private notifications = inject(UserNotificationService);
|
|
|
|
isAuthenticated = this.authService.isAuthenticated;
|
|
|
|
// Swipe state
|
|
swipedItemId = signal<number | null>(null);
|
|
|
|
// Payment popup states
|
|
showPaymentPopup = signal<boolean>(false);
|
|
paymentStatus = signal<'creating' | 'waiting' | 'success' | 'timeout' | 'error' | null>('creating');
|
|
qrCodeUrl = signal<string>('');
|
|
paymentUrl = signal<string>('');
|
|
bankPaymentUrl = signal<string>('');
|
|
bankPaymentFrameUrl = signal<SafeResourceUrl | null>(null);
|
|
showBankPaymentPopup = signal<boolean>(false);
|
|
selectedPaymentMethod = signal<PaymentMethod>('qr');
|
|
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);
|
|
purchaseSubmitted = signal<boolean>(false);
|
|
paidItems: CartItem[] = [];
|
|
|
|
maxChecks = Math.ceil(PAYMENT_MIN_POLL_SECONDS / (PAYMENT_POLL_INTERVAL_MS / 1000));
|
|
private pollingSubscription?: Subscription;
|
|
private closeTimeout?: ReturnType<typeof setTimeout>;
|
|
|
|
private currencyRates = inject(CurrencyRatesService);
|
|
private readonly analytics = inject(AnalyticsService);
|
|
/**
|
|
* Payment creation and status polling now go through @marketplaces/payment
|
|
* (POST/GET {qrApiUrl}/api/v1/payments) instead of api.service.ts's
|
|
* createPaymentIntent/checkCartPaymentStatus - that endpoint pair is now
|
|
* superseded, see the comment on createPaymentIntent() below. Only the I/O
|
|
* layer changed; the surrounding popup state machine (paymentStatus,
|
|
* checkoutInFlight, the bank-iframe UX, timeout/success handling) is
|
|
* untouched and stays hand-rolled - <mp-payment>'s own UI is a different,
|
|
* simpler paradigm (window.open for redirects, no iframe) that would be a
|
|
* separate, much larger change to adopt wholesale.
|
|
*/
|
|
private readonly paymentGateway = inject(MARKETPLACES_PAYMENT_GATEWAY);
|
|
|
|
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);
|
|
}
|
|
|
|
readonly clearCartConfirmOpen = signal(false);
|
|
|
|
clearCart(): void {
|
|
this.clearCartConfirmOpen.set(true);
|
|
}
|
|
|
|
confirmClearCart(): void {
|
|
this.cartService.clearCart();
|
|
this.clearCartConfirmOpen.set(false);
|
|
}
|
|
|
|
readonly getMainImage = getMainImage;
|
|
readonly onImageError = onImageError;
|
|
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(); }
|
|
/** 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);
|
|
}
|
|
/**
|
|
* A double-click (or any rapid repeat click) on the checkout button fires
|
|
* two synchronous handler calls before showPaymentPopup's change detection
|
|
* has a chance to cover the button - found via E2E
|
|
* (checkout-idempotent-click.spec.ts), which caught two real
|
|
* POST /api/v2/storefront/checkout requests from one double-click.
|
|
* checkoutInFlight closes that window: it is set before anything async
|
|
* happens, so the second call sees it and bails immediately.
|
|
*/
|
|
private readonly checkoutInFlight = signal(false);
|
|
|
|
get isCheckoutDisabled(): boolean {
|
|
return !this.termsAccepted || !this.isAuthenticated() || !this.allRequiredDeliveriesSelected() || this.checkoutInFlight();
|
|
}
|
|
|
|
selectDelivery(itemID: number, selectedDelivery: DeliveryOption | null): void {
|
|
this.cartService.setSelectedDelivery(itemID, selectedDelivery);
|
|
}
|
|
|
|
checkout(paymentMethod: PaymentMethod): void {
|
|
if (this.checkoutInFlight()) {
|
|
return;
|
|
}
|
|
|
|
if (!this.allRequiredDeliveriesSelected()) {
|
|
this.notifications.show(this.i18n.t('cart.deliveryRequired'), 'warning');
|
|
return;
|
|
}
|
|
|
|
if (!this.termsAccepted) {
|
|
this.notifications.show(this.i18n.t('cart.acceptTerms'), 'warning');
|
|
return;
|
|
}
|
|
|
|
this.checkoutInFlight.set(true);
|
|
this.analytics.track('checkout_started', { itemCount: this.items().length });
|
|
this.openPaymentPopup(paymentMethod);
|
|
}
|
|
|
|
openPaymentPopup(paymentMethod: PaymentMethod): void {
|
|
this.analytics.track('payment_started', { paymentMethod });
|
|
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.purchaseSubmitted.set(false);
|
|
this.paidItems = [...this.items()];
|
|
|
|
// 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));
|
|
}
|
|
|
|
closePaymentPopup(): void {
|
|
this.showPaymentPopup.set(false);
|
|
this.closeBankPaymentPopup();
|
|
this.stopPolling();
|
|
if (this.closeTimeout) {
|
|
clearTimeout(this.closeTimeout);
|
|
this.closeTimeout = undefined;
|
|
}
|
|
// Every retry/close path routes through here - release the checkout
|
|
// button so a shopper who dismisses an error can actually try again.
|
|
this.checkoutInFlight.set(false);
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
/**
|
|
* Server-authoritative checkout (PHASE-1-MONEY-FX-PAYMENTS-CONTRACT.md
|
|
* §5.2): the client sends offer ids and quantities, never a computed
|
|
* amount. Two calls, not one - createCartPayment's single-request shape
|
|
* doesn't exist in the new contract because the backend must price the
|
|
* session before a payment intent can reference it.
|
|
*
|
|
* offerId uses item.itemID: this codebase has no distinct Offer entity yet
|
|
* (Phase 3, Product/Offer split, not shipped in this model) - itemID is
|
|
* the same catalog identifier every other endpoint already keys off.
|
|
* Revisit once Offer exists as its own id.
|
|
*/
|
|
createPayment(paymentMethod: PaymentMethod): void {
|
|
const merchantReference = this.generateOrderId();
|
|
const checkoutPayload = {
|
|
offers: this.items().map(item => ({ offerId: String(item.itemID), qty: item.quantity })),
|
|
currency: this.langService.currentCurrency(),
|
|
};
|
|
|
|
this.apiService.createCheckoutSession(checkoutPayload).subscribe({
|
|
next: session => this.createPaymentIntent(session, paymentMethod, merchantReference),
|
|
error: err => {
|
|
console.error('Error creating checkout session:', err);
|
|
this.setPaymentError();
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Superseded api.service.ts's createPaymentIntent (POST
|
|
* /api/v2/storefront/payments/intents, our own inferred contract) with
|
|
* @marketplaces/payment's real, published one. That method, createPayment
|
|
* (legacy /qr), createCartPayment (legacy /cart), checkCartPaymentStatus,
|
|
* checkCartCardPaymentStatus, checkPaymentStatus, and the
|
|
* QrCreateResponse-based resolvePaymentQrId/resolvePaymentQrUrl/
|
|
* resolvePaymentLink/resolveBankPaymentUrl helpers - all now deleted from
|
|
* ApiService, confirmed dead first (zero remaining callers) before removal.
|
|
*/
|
|
private createPaymentIntent(
|
|
session: import('../../services/api.service').CheckoutSessionResponse,
|
|
paymentMethod: PaymentMethod,
|
|
merchantReference: string,
|
|
): void {
|
|
this.paymentGateway.create(paymentMethod as PackagePaymentMethod, {
|
|
checkoutSessionId: session.checkoutSessionId,
|
|
metadata: { merchantReference },
|
|
}).subscribe({
|
|
next: (attempt) => this.handlePaymentAttempt(attempt, paymentMethod),
|
|
error: (err) => {
|
|
console.error('Error creating payment intent:', err);
|
|
this.setPaymentError();
|
|
}
|
|
});
|
|
}
|
|
|
|
private handlePaymentAttempt(attempt: PaymentAttempt, paymentMethod: PaymentMethod): void {
|
|
if (!attempt.paymentId || (attempt.status !== 'created' && attempt.status !== 'pending' && !attempt.action)) {
|
|
console.error('Payment attempt missing required fields:', attempt);
|
|
this.setPaymentError();
|
|
return;
|
|
}
|
|
|
|
this.paymentId.set(attempt.paymentId);
|
|
|
|
if (attempt.action?.type === 'qr') {
|
|
// Same external QR-image rendering used everywhere else in this
|
|
// component (previously via ApiService.resolvePaymentQrUrl) - kept
|
|
// rather than switching to the package's own client-side qrcode
|
|
// generation, to avoid adding a second QR-rendering path for one call site.
|
|
this.qrCodeUrl.set(`https://api.qrserver.com/v1/create-qr-code/?size=256x256&margin=8&data=${encodeURIComponent(attempt.action.url)}`);
|
|
this.paymentUrl.set(attempt.action.url);
|
|
} else if (attempt.action?.type === 'redirect') {
|
|
this.bankPaymentUrl.set(attempt.action.url);
|
|
}
|
|
|
|
this.paymentStatus.set('waiting');
|
|
// The package's PaymentAttempt carries no TTL/expiry field, unlike the
|
|
// legacy provider's qrTTL - polling duration falls back to
|
|
// PAYMENT_MIN_POLL_SECONDS alone. Revisit if the real backend adds one.
|
|
this.startPolling();
|
|
if (paymentMethod === 'card' && attempt.action?.type === 'redirect') {
|
|
this.openBankPaymentPopup();
|
|
}
|
|
}
|
|
|
|
startPolling(): void {
|
|
this.stopPolling();
|
|
if (!this.paymentId()) {
|
|
this.setPaymentError();
|
|
return;
|
|
}
|
|
|
|
const pollSeconds = PAYMENT_MIN_POLL_SECONDS;
|
|
this.maxChecks = Math.ceil(pollSeconds / (PAYMENT_POLL_INTERVAL_MS / 1000));
|
|
|
|
this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS)
|
|
.pipe(
|
|
take(this.maxChecks),
|
|
exhaustMap(() =>
|
|
this.paymentGateway.status(this.paymentId(), this.selectedPaymentMethod() as PackagePaymentMethod).pipe(
|
|
timeout(8000),
|
|
catchError((err) => {
|
|
console.error('Error checking payment status:', err);
|
|
this.setPaymentError();
|
|
return EMPTY;
|
|
})
|
|
)
|
|
)
|
|
)
|
|
.subscribe({
|
|
next: (response) => {
|
|
if (!response) {
|
|
return;
|
|
}
|
|
|
|
// Package's PaymentStatus is a fixed union
|
|
// ('created'|'pending'|'authorized'|'paid'|'failed'|'cancelled'|'expired'),
|
|
// not a free-form string+code pair like the legacy provider - no
|
|
// .toUpperCase() normalization needed, and no 'REJECTED'/'APPROVED'
|
|
// equivalents exist (those were legacy-provider-specific spellings).
|
|
const paymentStatus = response.status;
|
|
|
|
if (paymentStatus === 'failed' || paymentStatus === 'expired' || paymentStatus === 'cancelled') {
|
|
this.paymentStatus.set('timeout');
|
|
this.closeBankPaymentPopup();
|
|
this.stopPolling();
|
|
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
|
this.closeTimeout = setTimeout(() => {
|
|
this.closePaymentPopup();
|
|
}, PAYMENT_TIMEOUT_CLOSE_MS);
|
|
return;
|
|
}
|
|
|
|
// 'authorized' counts as success too (PaymentResult's own status
|
|
// union) - a card payment can settle as authorized before capture.
|
|
if (paymentStatus === 'paid' || paymentStatus === 'authorized') {
|
|
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;
|
|
}
|
|
// The popup may stay open to show the error rather than closing, so
|
|
// this can't rely on closePaymentPopup() being called - release the
|
|
// checkout button here too, or a failed attempt locks retry out forever.
|
|
this.checkoutInFlight.set(false);
|
|
}
|
|
|
|
/**
|
|
* 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: {
|
|
name: this.getTelegramUsername() || this.i18n.t('common.guest'),
|
|
email,
|
|
phone,
|
|
},
|
|
payment: {
|
|
method: this.selectedPaymentMethod(),
|
|
currency: this.langService.currentCurrency(),
|
|
},
|
|
}).subscribe({
|
|
error: (err) => console.error('Error recording order:', err),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
private autoSubmitPurchase(): void {
|
|
if (this.purchaseSubmitted()) {
|
|
return;
|
|
}
|
|
|
|
const telegramUserId = this.getTelegramUserId();
|
|
|
|
// Telegram ID is mandatory for submitPurchaseEmail.
|
|
if (!telegramUserId) {
|
|
this.notifications.show(this.i18n.t('cart.telegramIdMissing'), 'warning');
|
|
this.emailSubmitting.set(false);
|
|
this.closePaymentPopup();
|
|
const lang = this.langService.currentLanguage();
|
|
this.router.navigate([`/${lang}`]);
|
|
return;
|
|
}
|
|
|
|
this.emailSubmitting.set(true);
|
|
|
|
const emailData = {
|
|
email: this.userEmail().trim(),
|
|
phone: this.userPhone().replace(/\D/g, ''),
|
|
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.purchaseSubmitted.set(true);
|
|
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}`]);
|
|
}
|
|
});
|
|
}
|
|
|
|
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.purchaseSubmitted.set(true);
|
|
if (this.closeTimeout) {
|
|
clearTimeout(this.closeTimeout);
|
|
this.closeTimeout = undefined;
|
|
}
|
|
this.emailSubmitting.set(false);
|
|
this.notifications.show(this.i18n.t('cart.emailSuccess'), 'success');
|
|
// 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);
|
|
this.notifications.show(this.i18n.t('cart.emailError'), 'warning');
|
|
}
|
|
});
|
|
}
|
|
|
|
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 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('');
|
|
}
|
|
}
|
|
}
|
|
}
|