This commit is contained in:
sdarbinyan
2026-06-21 23:13:01 +04:00
parent 1b2a5af2be
commit 3b802b7c7b
13 changed files with 510 additions and 33 deletions

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, timer } from 'rxjs';
import { map, retry } from 'rxjs/operators';
import { Category, Item, Subcategory } from '../models';
import { Category, DeliveryOption, Item, Subcategory } from '../models';
import { environment } from '../../environments/environment';
export interface QrCreateRequest {
@@ -41,7 +41,7 @@ export interface CartPaymentRequest {
siteorderID: string;
redirectUrl: string;
telegramUsername: string;
items: Array<{ itemID: number; price: number; name: string }>;
items: Array<{ itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption }>;
}
export interface QrDynamicStatusResponse {
@@ -98,6 +98,89 @@ export class ApiService {
return Number.isFinite(normalized) ? normalized : undefined;
}
private normalizeOptionalString(value: unknown): string {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'bigint') {
return String(value);
}
return '';
}
private asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
private normalizeDeliveryOption(value: unknown): DeliveryOption | null {
const source = this.asRecord(value);
if (!source) {
return null;
}
const deliveryPlace = this.normalizeOptionalString(
source['deliveryPlace'] ?? source['delivery_place'] ?? source['deliveryplace']
);
const deliveryTime = this.normalizeOptionalString(
source['deliveryTime'] ?? source['delivery_time'] ?? source['deliverytime']
);
const deliveryPrice = this.normalizeOptionalNumber(
source['deliveryPrice'] ?? source['delivery_price'] ?? source['deliveryprice']
);
if (deliveryPrice === undefined && !deliveryPlace && !deliveryTime) {
return null;
}
return {
deliveryPrice: deliveryPrice ?? 0,
deliveryPlace,
deliveryTime,
};
}
private normalizeDeliveryData(
raw: any,
legacyDeliveryPrice?: number
): { options: DeliveryOption[]; isDigital: boolean; requiresSelection: boolean } {
const rawDelivery = raw.delivery ?? raw.deliveries;
if (typeof rawDelivery === 'string' && rawDelivery.trim().toLowerCase() === 'digital') {
return { options: [], isDigital: true, requiresSelection: false };
}
const deliveryCandidates = Array.isArray(rawDelivery)
? rawDelivery
: rawDelivery != null
? [rawDelivery]
: [];
const options = deliveryCandidates
.map(candidate => this.normalizeDeliveryOption(candidate))
.filter((option): option is DeliveryOption => option !== null);
if (options.length > 0) {
return { options, isDigital: false, requiresSelection: rawDelivery != null };
}
if (legacyDeliveryPrice !== undefined) {
return {
options: [{ deliveryPrice: legacyDeliveryPrice, deliveryPlace: '', deliveryTime: '' }],
isDigital: false,
requiresSelection: false,
};
}
if (rawDelivery != null) {
return { options: [], isDigital: true, requiresSelection: false };
}
return { options: [], isDigital: false, requiresSelection: false };
}
/** Resolve relative image URLs (e.g. ./images/x.webp) against site origin */
private resolveImageUrl(url: string): string {
if (!url) return '';
@@ -114,14 +197,10 @@ export class ApiService {
private normalizeItem(raw: any): Item {
const { partnerID, ...rest } = raw;
const item: Item = { ...rest };
const topLevelDeliveryPrice = this.normalizeOptionalNumber(
let legacyDeliveryPrice = this.normalizeOptionalNumber(
raw.deliveryPrice ?? raw.delivery_price ?? raw.deliveryprice
);
if (topLevelDeliveryPrice !== undefined) {
item.deliveryPrice = topLevelDeliveryPrice;
}
// Extract price/currency/remaining/colour/size from itemDetails[]
// Note: Go struct tag is "itemdetails" but actual API may send "itemDetails"
const details = raw.itemDetails || raw.itemdetails;
@@ -139,20 +218,25 @@ export class ApiService {
if (!item.currency) item.currency = detail.currency;
if (!item.colour) item.colour = this.normalizeColor(detail.colour || detail.color || '');
if (!item.size) item.size = detail.size || '';
if (item.deliveryPrice == null) {
const detailDeliveryPrice = this.normalizeOptionalNumber(
if (legacyDeliveryPrice === undefined) {
legacyDeliveryPrice = this.normalizeOptionalNumber(
detail.deliveryPrice ?? detail.delivery_price ?? detail.deliveryprice
);
if (detailDeliveryPrice !== undefined) {
item.deliveryPrice = detailDeliveryPrice;
}
}
// Use remaining from detail for stock level
if (raw.remaining == null && detail.remaining != null) {
(raw as any).remaining = detail.remaining;
}
}
const deliveryData = this.normalizeDeliveryData(raw, legacyDeliveryPrice);
if (deliveryData.options.length > 0) {
item.deliveryOptions = deliveryData.options;
item.deliveryMode = 'selectable';
item.deliverySelectionRequired = deliveryData.requiresSelection;
} else if (deliveryData.isDigital) {
item.deliveryMode = 'digital';
item.deliverySelectionRequired = false;
}
// Map backOffice string id → legacy numeric itemID
if (raw.id != null && raw.itemID == null) {
@@ -481,8 +565,9 @@ export class ApiService {
submitPurchaseEmail(emailData: {
email: string;
phone?: string;
telegramUserId: string | null;
items: Array<{ itemID: number; name: string; price: number; currency: string }>;
items: Array<{ itemID: number; name: string; price: number; currency: string; quantity?: number; delivery?: DeliveryOption }>;
}): Observable<{ message: string }> {
return this.http.post<{ message: string }>(`${this.baseUrl}/purchase-email`, emailData);
}

View File

@@ -1,6 +1,6 @@
import { Injectable, signal, computed, effect } from '@angular/core';
import { ApiService } from './api.service';
import { Item, CartItem } from '../models';
import { DeliveryOption, Item, CartItem } from '../models';
import { getDiscountedPrice } from '../utils/item.utils';
import { environment } from '../../environments/environment';
import type { } from '../types/telegram.types';
@@ -31,13 +31,21 @@ export class CartService {
totalDeliveryPrice = computed(() => {
const items = this.cartItems();
if (!Array.isArray(items)) return 0;
return items.reduce((total, item) => total + ((item.deliveryPrice ?? 0) * item.quantity), 0);
return items.reduce((total, item) => {
return total + ((item.selectedDelivery?.deliveryPrice ?? 0) * item.quantity);
}, 0);
});
totalWithDelivery = computed(() => this.totalPrice() + this.totalDeliveryPrice());
allRequiredDeliveriesSelected = computed(() => {
const items = this.cartItems();
if (!Array.isArray(items)) return true;
return items.every(item => !this.itemRequiresDeliverySelection(item) || item.selectedDelivery != null);
});
hasDeliveryPrice = computed(() => {
const items = this.cartItems();
if (!Array.isArray(items)) return false;
return items.some(item => item.deliveryPrice !== undefined && item.deliveryPrice !== null);
return this.allRequiredDeliveriesSelected()
&& items.some(item => (item.deliveryOptions?.length ?? 0) > 0 || item.selectedDelivery != null);
});
constructor(private apiService: ApiService) {
@@ -64,14 +72,110 @@ export class CartService {
return Number.isFinite(normalized) ? normalized : undefined;
}
private normalizeOptionalString(value: unknown): string {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'bigint') {
return String(value);
}
return '';
}
private normalizeDeliveryOption(value: unknown): DeliveryOption | null {
if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const source = value as Record<string, unknown>;
const deliveryPlace = this.normalizeOptionalString(
source['deliveryPlace'] ?? source['delivery_place'] ?? source['deliveryplace']
);
const deliveryTime = this.normalizeOptionalString(
source['deliveryTime'] ?? source['delivery_time'] ?? source['deliverytime']
);
const deliveryPrice = this.normalizeOptionalNumber(
source['deliveryPrice'] ?? source['delivery_price'] ?? source['deliveryprice']
);
if (deliveryPrice === undefined && !deliveryPlace && !deliveryTime) {
return null;
}
return {
deliveryPrice: deliveryPrice ?? 0,
deliveryPlace,
deliveryTime,
};
}
private sameDeliveryOption(left: DeliveryOption, right: DeliveryOption): boolean {
return left.deliveryPrice === right.deliveryPrice
&& left.deliveryPlace === right.deliveryPlace
&& left.deliveryTime === right.deliveryTime;
}
private itemRequiresDeliverySelection(item: CartItem): boolean {
return item.deliveryMode !== 'digital'
&& (item.deliveryOptions?.length ?? 0) > 0
&& item.deliverySelectionRequired !== false;
}
private normalizeDeliveryState(item: CartItem): {
deliveryMode?: CartItem['deliveryMode'];
deliveryOptions: DeliveryOption[];
deliverySelectionRequired: boolean;
selectedDelivery: DeliveryOption | null;
} {
const normalizedOptions = Array.isArray(item.deliveryOptions)
? item.deliveryOptions
.map(option => this.normalizeDeliveryOption(option))
.filter((option): option is DeliveryOption => option !== null)
: [];
const legacyDeliveryPrice = this.normalizeOptionalNumber(item.deliveryPrice);
const deliveryOptions = normalizedOptions.length > 0
? normalizedOptions
: legacyDeliveryPrice !== undefined
? [{ deliveryPrice: legacyDeliveryPrice, deliveryPlace: '', deliveryTime: '' }]
: [];
const deliveryMode = item.deliveryMode === 'digital'
? 'digital'
: deliveryOptions.length > 0
? 'selectable'
: undefined;
const deliverySelectionRequired = deliveryOptions.length > 0
? item.deliverySelectionRequired !== false && normalizedOptions.length > 0
: false;
const selectedDelivery = this.normalizeDeliveryOption(item.selectedDelivery)
?? (deliveryOptions.length === 1 && !deliverySelectionRequired ? deliveryOptions[0] : null);
const matchedSelection = selectedDelivery && deliveryOptions.length > 0
? deliveryOptions.find(option => this.sameDeliveryOption(option, selectedDelivery)) ?? selectedDelivery
: selectedDelivery;
return {
deliveryMode,
deliveryOptions,
deliverySelectionRequired,
selectedDelivery: matchedSelection,
};
}
private normalizeCartItem(item: CartItem): CartItem {
const { deliveryPrice, ...rest } = item;
const normalizedDeliveryPrice = this.normalizeOptionalNumber(deliveryPrice);
const deliveryState = this.normalizeDeliveryState(item);
const hasDeliveryState = !!deliveryState.deliveryMode
|| deliveryState.deliveryOptions.length > 0
|| deliveryState.selectedDelivery != null;
return {
...rest,
quantity: item.quantity || 1,
...(normalizedDeliveryPrice !== undefined ? { deliveryPrice: normalizedDeliveryPrice } : {}),
...(hasDeliveryState ? { deliverySelectionRequired: deliveryState.deliverySelectionRequired } : {}),
...(deliveryState.deliveryMode ? { deliveryMode: deliveryState.deliveryMode } : {}),
...(deliveryState.deliveryOptions.length > 0 ? { deliveryOptions: deliveryState.deliveryOptions } : {}),
...(deliveryState.selectedDelivery ? { selectedDelivery: deliveryState.selectedDelivery } : {}),
};
}
@@ -181,6 +285,22 @@ export class CartService {
this.cartItems.set(updatedItems);
}
setSelectedDelivery(itemID: number, selectedDelivery: DeliveryOption | null): void {
const normalizedSelection = this.normalizeDeliveryOption(selectedDelivery);
const updatedItems = this.cartItems().map(item => {
if (item.itemID !== itemID) {
return item;
}
return this.normalizeCartItem({
...item,
selectedDelivery: normalizedSelection,
});
});
this.cartItems.set(updatedItems);
}
removeItems(itemIDs: number[]): void {
const currentItems = this.cartItems();
const updatedItems = currentItems.filter(item => !itemIDs.includes(item.itemID));