diff --git a/src/app/components/delivery-selector/delivery-selector.component.html b/src/app/components/delivery-selector/delivery-selector.component.html new file mode 100644 index 0000000..a982503 --- /dev/null +++ b/src/app/components/delivery-selector/delivery-selector.component.html @@ -0,0 +1,34 @@ +
+ @if (isDigital) { +
{{ 'cart.digitalDelivery' | translate }}
+ } @else if (options.length > 0) { + + +
+ +
+ + @if (selectedDelivery) { +
+ @if (selectedDelivery.deliveryPlace) { + {{ 'cart.deliveryPlace' | translate }}: {{ selectedDelivery.deliveryPlace }} + } + @if (selectedDelivery.deliveryTime) { + {{ 'cart.deliveryTime' | translate }}: {{ selectedDelivery.deliveryTime }} + } + + {{ 'cart.deliveryLabel' | translate }}: + {{ selectedDeliveryTotal | number:'1.2-2' }} {{ currency }} + +
+ } + } +
\ No newline at end of file diff --git a/src/app/components/delivery-selector/delivery-selector.component.scss b/src/app/components/delivery-selector/delivery-selector.component.scss new file mode 100644 index 0000000..a60a855 --- /dev/null +++ b/src/app/components/delivery-selector/delivery-selector.component.scss @@ -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; +} \ No newline at end of file diff --git a/src/app/components/delivery-selector/delivery-selector.component.ts b/src/app/components/delivery-selector/delivery-selector.component.ts new file mode 100644 index 0000000..26c54ca --- /dev/null +++ b/src/app/components/delivery-selector/delivery-selector.component.ts @@ -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(); + + 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 + ); + } +} \ No newline at end of file diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 31c6f58..46dab19 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -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', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index e04874b..809a342 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -64,6 +64,12 @@ export const hy: Translations = { total: 'Ընդամենը', items: 'Ապրանքներ', deliveryLabel: 'Առաքում', + deliveryMethod: 'Առաքման տարբերակ', + selectDelivery: 'Ընտրեք առաքման տարբերակը', + deliveryPlace: 'Վայր', + deliveryTime: 'Առաքման ժամկետ', + digitalDelivery: 'Թվային առաքում', + deliveryRequired: 'Մինչ պատվերը ձևակերպելը ընտրեք առաքումը բոլոր առաքվող ապրանքների համար։', toPay: 'Վճարման ենթակա', agreeWith: 'Ես համաձայն եմ', publicOffer: 'հանրային օֆերտայի', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index ea35cfe..8b38edf 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -64,6 +64,12 @@ export const ru: Translations = { total: 'Итого', items: 'Товары', deliveryLabel: 'Доставка', + deliveryMethod: 'Способ доставки', + selectDelivery: 'Выберите способ доставки', + deliveryPlace: 'Место', + deliveryTime: 'Срок доставки', + digitalDelivery: 'Цифровая доставка', + deliveryRequired: 'Выберите доставку для всех товаров с доставкой перед оформлением заказа.', toPay: 'К оплате', agreeWith: 'Я согласен с', publicOffer: 'публичной офертой', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index ca3caf2..094af77 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -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; diff --git a/src/app/models/item.model.ts b/src/app/models/item.model.ts index 1d123ed..e54d459 100644 --- a/src/app/models/item.model.ts +++ b/src/app/models/item.model.ts @@ -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; } diff --git a/src/app/pages/cart/cart.component.html b/src/app/pages/cart/cart.component.html index 76d9f74..3b66bb1 100644 --- a/src/app/pages/cart/cart.component.html +++ b/src/app/pages/cart/cart.component.html @@ -78,12 +78,6 @@ } @else { {{ item.price }} {{ item.currency }} } - - @if (item.deliveryPrice != null) { - - {{ 'cart.deliveryLabel' | translate }}: {{ item.deliveryPrice | number:'1.2-2' }} {{ item.currency }} - - }
@@ -100,6 +94,13 @@
+ + @if (item.deliveryMode === 'digital' || item.deliveryOptions?.length) { + + } @@ -129,6 +130,10 @@ } + @if (!allRequiredDeliveriesSelected()) { +

{{ 'cart.deliveryRequired' | translate }}

+ } +
{{ 'cart.toPay' | translate }} {{ totalWithDelivery() | number:'1.2-2' }} {{ currentCurrency }} @@ -155,8 +160,8 @@ @@ -215,7 +220,7 @@
{{ 'cart.amountToPay' | translate }} - {{ totalPrice() | number:'1.2-2' }} {{ currentCurrency }} + {{ totalWithDelivery() | number:'1.2-2' }} {{ currentCurrency }}
diff --git a/src/app/pages/cart/cart.component.scss b/src/app/pages/cart/cart.component.scss index 190df66..48eb235 100644 --- a/src/app/pages/cart/cart.component.scss +++ b/src/app/pages/cart/cart.component.scss @@ -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 { diff --git a/src/app/pages/cart/cart.component.ts b/src/app/pages/cart/cart.component.ts index 3662533..45e11d2 100644 --- a/src/app/pages/cart/cart.component.ts +++ b/src/app/pages/cart/cart.component.ts @@ -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 } : {}), }; }); } diff --git a/src/app/services/api.service.ts b/src/app/services/api.service.ts index 1ed10e1..c5eec8e 100644 --- a/src/app/services/api.service.ts +++ b/src/app/services/api.service.ts @@ -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 | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : 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); } diff --git a/src/app/services/cart.service.ts b/src/app/services/cart.service.ts index 12e641c..2ace73a 100644 --- a/src/app/services/cart.service.ts +++ b/src/app/services/cart.service.ts @@ -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; + 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));