delivery
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
<div class="delivery-selector">
|
||||
@if (isDigital) {
|
||||
<div class="delivery-chip delivery-chip--digital">{{ 'cart.digitalDelivery' | translate }}</div>
|
||||
} @else if (options.length > 0) {
|
||||
<label class="delivery-label" [for]="selectId">{{ 'cart.deliveryMethod' | translate }}</label>
|
||||
|
||||
<div class="delivery-control">
|
||||
<select [id]="selectId" [ngModel]="selectedKey" (ngModelChange)="onSelectionChange($event)">
|
||||
@if (required) {
|
||||
<option value="">{{ 'cart.selectDelivery' | translate }}</option>
|
||||
}
|
||||
|
||||
@for (option of options; track trackByOption($index, option)) {
|
||||
<option [value]="optionKey(option)">{{ optionLabel(option) }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (selectedDelivery) {
|
||||
<div class="delivery-meta">
|
||||
@if (selectedDelivery.deliveryPlace) {
|
||||
<span>{{ 'cart.deliveryPlace' | translate }}: {{ selectedDelivery.deliveryPlace }}</span>
|
||||
}
|
||||
@if (selectedDelivery.deliveryTime) {
|
||||
<span>{{ 'cart.deliveryTime' | translate }}: {{ selectedDelivery.deliveryTime }}</span>
|
||||
}
|
||||
<span>
|
||||
{{ 'cart.deliveryLabel' | translate }}:
|
||||
{{ selectedDeliveryTotal | number:'1.2-2' }} {{ currency }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,81 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.delivery-selector {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #d3dad9;
|
||||
background: #fafbfb;
|
||||
}
|
||||
|
||||
.delivery-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #3f5f5c;
|
||||
}
|
||||
|
||||
.delivery-control select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #c9d5d3;
|
||||
background: #fff;
|
||||
color: #1f2937;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.delivery-control select:focus {
|
||||
outline: none;
|
||||
border-color: #497671;
|
||||
box-shadow: 0 0 0 3px rgba(73, 118, 113, 0.12);
|
||||
}
|
||||
|
||||
.delivery-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 10px;
|
||||
font-size: 0.82rem;
|
||||
color: #697777;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.delivery-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.delivery-chip--digital {
|
||||
color: #2563eb;
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
:host-context(.cart-container.novo) .delivery-selector {
|
||||
border-color: #d1fae5;
|
||||
background: #f9fffc;
|
||||
}
|
||||
|
||||
:host-context(.cart-container.novo) .delivery-label {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
:host-context(.cart-container.novo) .delivery-control select {
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
:host-context(.cart-container.novo) .delivery-control select:focus {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
:host-context(.cart-container.novo) .delivery-meta {
|
||||
color: #4b5563;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { DecimalPipe } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CartItem, DeliveryOption } from '../../models';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
|
||||
let nextDeliverySelectorId = 0;
|
||||
|
||||
@Component({
|
||||
selector: 'app-delivery-selector',
|
||||
standalone: true,
|
||||
imports: [FormsModule, DecimalPipe, TranslatePipe],
|
||||
templateUrl: './delivery-selector.component.html',
|
||||
styleUrls: ['./delivery-selector.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DeliverySelectorComponent {
|
||||
@Input({ required: true }) item: CartItem | null = null;
|
||||
|
||||
@Output() selectedDeliveryChange = new EventEmitter<DeliveryOption | null>();
|
||||
|
||||
readonly selectId = `delivery-select-${nextDeliverySelectorId++}`;
|
||||
|
||||
get options(): DeliveryOption[] {
|
||||
return this.item?.deliveryOptions ?? [];
|
||||
}
|
||||
|
||||
get selectedDelivery(): DeliveryOption | null {
|
||||
return this.item?.selectedDelivery ?? null;
|
||||
}
|
||||
|
||||
get currency(): string {
|
||||
return this.item?.currency || 'RUB';
|
||||
}
|
||||
|
||||
get required(): boolean {
|
||||
return this.item?.deliveryMode !== 'digital'
|
||||
&& this.options.length > 0
|
||||
&& this.item?.deliverySelectionRequired !== false;
|
||||
}
|
||||
|
||||
get isDigital(): boolean {
|
||||
return this.item?.deliveryMode === 'digital';
|
||||
}
|
||||
|
||||
get selectedKey(): string {
|
||||
return this.selectedDelivery ? this.optionKey(this.selectedDelivery) : '';
|
||||
}
|
||||
|
||||
get selectedDeliveryTotal(): number {
|
||||
return (this.selectedDelivery?.deliveryPrice ?? 0) * (this.item?.quantity ?? 1);
|
||||
}
|
||||
|
||||
optionKey(option: DeliveryOption): string {
|
||||
return `${option.deliveryPlace}__${option.deliveryTime}__${option.deliveryPrice}`;
|
||||
}
|
||||
|
||||
optionLabel(option: DeliveryOption): string {
|
||||
const details = [option.deliveryPlace, option.deliveryTime].filter(Boolean);
|
||||
details.push(`${option.deliveryPrice.toFixed(2)} ${this.currency}`);
|
||||
return details.join(' • ');
|
||||
}
|
||||
|
||||
trackByOption(index: number, option: DeliveryOption): string {
|
||||
return `${index}-${this.optionKey(option)}`;
|
||||
}
|
||||
|
||||
onSelectionChange(nextKey: string): void {
|
||||
if (!nextKey) {
|
||||
this.selectedDeliveryChange.emit(null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedDeliveryChange.emit(
|
||||
this.options.find(option => this.optionKey(option) === nextKey) ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,12 @@ export const en: Translations = {
|
||||
total: 'Total',
|
||||
items: 'Products',
|
||||
deliveryLabel: 'Delivery',
|
||||
deliveryMethod: 'Delivery option',
|
||||
selectDelivery: 'Select delivery option',
|
||||
deliveryPlace: 'Place',
|
||||
deliveryTime: 'Delivery time',
|
||||
digitalDelivery: 'Digital delivery',
|
||||
deliveryRequired: 'Select delivery for every shippable item before checkout.',
|
||||
toPay: 'To pay',
|
||||
agreeWith: 'I agree with the',
|
||||
publicOffer: 'public offer',
|
||||
|
||||
@@ -64,6 +64,12 @@ export const hy: Translations = {
|
||||
total: 'Ընդամենը',
|
||||
items: 'Ապրանքներ',
|
||||
deliveryLabel: 'Առաքում',
|
||||
deliveryMethod: 'Առաքման տարբերակ',
|
||||
selectDelivery: 'Ընտրեք առաքման տարբերակը',
|
||||
deliveryPlace: 'Վայր',
|
||||
deliveryTime: 'Առաքման ժամկետ',
|
||||
digitalDelivery: 'Թվային առաքում',
|
||||
deliveryRequired: 'Մինչ պատվերը ձևակերպելը ընտրեք առաքումը բոլոր առաքվող ապրանքների համար։',
|
||||
toPay: 'Վճարման ենթակա',
|
||||
agreeWith: 'Ես համաձայն եմ',
|
||||
publicOffer: 'հանրային օֆերտայի',
|
||||
|
||||
@@ -64,6 +64,12 @@ export const ru: Translations = {
|
||||
total: 'Итого',
|
||||
items: 'Товары',
|
||||
deliveryLabel: 'Доставка',
|
||||
deliveryMethod: 'Способ доставки',
|
||||
selectDelivery: 'Выберите способ доставки',
|
||||
deliveryPlace: 'Место',
|
||||
deliveryTime: 'Срок доставки',
|
||||
digitalDelivery: 'Цифровая доставка',
|
||||
deliveryRequired: 'Выберите доставку для всех товаров с доставкой перед оформлением заказа.',
|
||||
toPay: 'К оплате',
|
||||
agreeWith: 'Я согласен с',
|
||||
publicOffer: 'публичной офертой',
|
||||
|
||||
@@ -62,6 +62,12 @@ export interface Translations {
|
||||
total: string;
|
||||
items: string;
|
||||
deliveryLabel: string;
|
||||
deliveryMethod: string;
|
||||
selectDelivery: string;
|
||||
deliveryPlace: string;
|
||||
deliveryTime: string;
|
||||
digitalDelivery: string;
|
||||
deliveryRequired: string;
|
||||
toPay: string;
|
||||
agreeWith: string;
|
||||
publicOffer: string;
|
||||
|
||||
@@ -62,6 +62,14 @@ export interface ItemAttribute {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface DeliveryOption {
|
||||
deliveryPrice: number;
|
||||
deliveryPlace: string;
|
||||
deliveryTime: string;
|
||||
}
|
||||
|
||||
export type DeliveryMode = 'selectable' | 'digital';
|
||||
|
||||
/** Item variant detail (price, size, colour per variant) */
|
||||
export interface ItemDetail {
|
||||
color?: string;
|
||||
@@ -92,6 +100,9 @@ export interface Item {
|
||||
// Backend API fields
|
||||
colour?: string;
|
||||
size?: string;
|
||||
deliveryOptions?: DeliveryOption[];
|
||||
deliveryMode?: DeliveryMode;
|
||||
deliverySelectionRequired?: boolean;
|
||||
language?: string;
|
||||
names?: ItemName[];
|
||||
descriptions?: ItemDescription[];
|
||||
@@ -115,4 +126,5 @@ export interface Item {
|
||||
|
||||
export interface CartItem extends Item {
|
||||
quantity: number;
|
||||
selectedDelivery?: DeliveryOption | null;
|
||||
}
|
||||
|
||||
@@ -78,12 +78,6 @@
|
||||
} @else {
|
||||
<span class="current-price">{{ item.price }} {{ item.currency }}</span>
|
||||
}
|
||||
|
||||
@if (item.deliveryPrice != null) {
|
||||
<span class="delivery-price">
|
||||
{{ 'cart.deliveryLabel' | translate }}: {{ item.deliveryPrice | number:'1.2-2' }} {{ item.currency }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="quantity-controls">
|
||||
@@ -100,6 +94,13 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (item.deliveryMode === 'digital' || item.deliveryOptions?.length) {
|
||||
<app-delivery-selector
|
||||
[item]="item"
|
||||
(selectedDeliveryChange)="selectDelivery(item.itemID, $event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -129,6 +130,10 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!allRequiredDeliveriesSelected()) {
|
||||
<p class="delivery-warning">{{ 'cart.deliveryRequired' | translate }}</p>
|
||||
}
|
||||
|
||||
<div class="summary-row total">
|
||||
<span>{{ 'cart.toPay' | translate }}</span>
|
||||
<span class="total-price">{{ totalWithDelivery() | number:'1.2-2' }} {{ currentCurrency }}</span>
|
||||
@@ -155,8 +160,8 @@
|
||||
<button
|
||||
class="checkout-btn"
|
||||
(click)="checkout()"
|
||||
[class.disabled]="!termsAccepted || !isAuthenticated()"
|
||||
[disabled]="!termsAccepted || !isAuthenticated()"
|
||||
[class.disabled]="isCheckoutDisabled"
|
||||
[disabled]="isCheckoutDisabled"
|
||||
>
|
||||
{{ 'cart.checkout' | translate }}
|
||||
</button>
|
||||
@@ -215,7 +220,7 @@
|
||||
<div class="payment-info">
|
||||
<div class="payment-amount">
|
||||
<span class="label">{{ 'cart.amountToPay' | translate }}</span>
|
||||
<span class="amount">{{ totalPrice() | number:'1.2-2' }} {{ currentCurrency }}</span>
|
||||
<span class="amount">{{ totalWithDelivery() | number:'1.2-2' }} {{ currentCurrency }}</span>
|
||||
</div>
|
||||
|
||||
<div class="waiting-indicator">
|
||||
|
||||
@@ -1016,6 +1016,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
.delivery-warning {
|
||||
margin: -4px 0 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cart-container.dexar .cart-summary .delivery-warning {
|
||||
color: #9a6700;
|
||||
background: #fff8e8;
|
||||
border: 1px solid #f1ddb2;
|
||||
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.cart-container.novo .cart-summary .delivery-warning {
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
}
|
||||
|
||||
// Dexar checkbox colors
|
||||
.cart-container.dexar .terms-agreement .checkbox-container {
|
||||
input[type="checkbox"]:checked ~ .checkmark {
|
||||
|
||||
@@ -3,9 +3,10 @@ import { DecimalPipe } from '@angular/common';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CartService, ApiService, LanguageService, AuthService } from '../../services';
|
||||
import { Item, CartItem } from '../../models';
|
||||
import { Item, CartItem, DeliveryOption } from '../../models';
|
||||
import { interval, of, Subscription } from 'rxjs';
|
||||
import { catchError, exhaustMap, take, timeout } from 'rxjs/operators';
|
||||
import { DeliverySelectorComponent } from '../../components/delivery-selector/delivery-selector.component';
|
||||
import { EmptyCartIconComponent } from '../../components/empty-cart-icon/empty-cart-icon.component';
|
||||
import { TelegramLoginComponent } from '../../components/telegram-login/telegram-login.component';
|
||||
import { environment } from '../../../environments/environment';
|
||||
@@ -17,7 +18,7 @@ import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MAX_CHECKS, PAYMENT_TIMEOUT_CLOSE_MS,
|
||||
|
||||
@Component({
|
||||
selector: 'app-cart',
|
||||
imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe],
|
||||
imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe],
|
||||
templateUrl: './cart.component.html',
|
||||
styleUrls: ['./cart.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -29,6 +30,7 @@ export class CartComponent implements OnDestroy {
|
||||
totalDeliveryPrice;
|
||||
totalWithDelivery;
|
||||
hasDeliveryPrice;
|
||||
allRequiredDeliveriesSelected;
|
||||
termsAccepted = false;
|
||||
isnovo = environment.theme === 'novo';
|
||||
|
||||
@@ -74,6 +76,7 @@ export class CartComponent implements OnDestroy {
|
||||
this.totalDeliveryPrice = this.cartService.totalDeliveryPrice;
|
||||
this.totalWithDelivery = this.cartService.totalWithDelivery;
|
||||
this.hasDeliveryPrice = this.cartService.hasDeliveryPrice;
|
||||
this.allRequiredDeliveriesSelected = this.cartService.allRequiredDeliveriesSelected;
|
||||
}
|
||||
|
||||
requestLogin(): void {
|
||||
@@ -148,8 +151,18 @@ export class CartComponent implements OnDestroy {
|
||||
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(): void {
|
||||
if (!this.allRequiredDeliveriesSelected()) {
|
||||
alert(this.i18n.t('cart.deliveryRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.termsAccepted) {
|
||||
alert(this.i18n.t('cart.acceptTerms'));
|
||||
return;
|
||||
@@ -348,7 +361,8 @@ export class CartComponent implements OnDestroy {
|
||||
? item.price * (1 - item.discount / 100)
|
||||
: item.price,
|
||||
currency: item.currency,
|
||||
quantity: item.quantity
|
||||
quantity: item.quantity,
|
||||
...(item.selectedDelivery ? { delivery: item.selectedDelivery } : {})
|
||||
}))
|
||||
};
|
||||
|
||||
@@ -416,7 +430,8 @@ export class CartComponent implements OnDestroy {
|
||||
? item.price * (1 - item.discount / 100)
|
||||
: item.price,
|
||||
currency: item.currency,
|
||||
quantity: item.quantity
|
||||
quantity: item.quantity,
|
||||
...(item.selectedDelivery ? { delivery: item.selectedDelivery } : {})
|
||||
}))
|
||||
};
|
||||
|
||||
@@ -476,7 +491,7 @@ export class CartComponent implements OnDestroy {
|
||||
return `order_${timestamp}_${random}`;
|
||||
}
|
||||
|
||||
private buildPaymentItems(): Array<{ itemID: number; price: number; name: string }> {
|
||||
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)
|
||||
@@ -489,6 +504,8 @@ export class CartComponent implements OnDestroy {
|
||||
itemID: item.itemID,
|
||||
price: unitPrice * item.quantity,
|
||||
name,
|
||||
quantity: item.quantity,
|
||||
...(item.selectedDelivery ? { delivery: item.selectedDelivery } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user