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',
|
total: 'Total',
|
||||||
items: 'Products',
|
items: 'Products',
|
||||||
deliveryLabel: 'Delivery',
|
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',
|
toPay: 'To pay',
|
||||||
agreeWith: 'I agree with the',
|
agreeWith: 'I agree with the',
|
||||||
publicOffer: 'public offer',
|
publicOffer: 'public offer',
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ export const hy: Translations = {
|
|||||||
total: 'Ընդամենը',
|
total: 'Ընդամենը',
|
||||||
items: 'Ապրանքներ',
|
items: 'Ապրանքներ',
|
||||||
deliveryLabel: 'Առաքում',
|
deliveryLabel: 'Առաքում',
|
||||||
|
deliveryMethod: 'Առաքման տարբերակ',
|
||||||
|
selectDelivery: 'Ընտրեք առաքման տարբերակը',
|
||||||
|
deliveryPlace: 'Վայր',
|
||||||
|
deliveryTime: 'Առաքման ժամկետ',
|
||||||
|
digitalDelivery: 'Թվային առաքում',
|
||||||
|
deliveryRequired: 'Մինչ պատվերը ձևակերպելը ընտրեք առաքումը բոլոր առաքվող ապրանքների համար։',
|
||||||
toPay: 'Վճարման ենթակա',
|
toPay: 'Վճարման ենթակա',
|
||||||
agreeWith: 'Ես համաձայն եմ',
|
agreeWith: 'Ես համաձայն եմ',
|
||||||
publicOffer: 'հանրային օֆերտայի',
|
publicOffer: 'հանրային օֆերտայի',
|
||||||
|
|||||||
@@ -64,6 +64,12 @@ export const ru: Translations = {
|
|||||||
total: 'Итого',
|
total: 'Итого',
|
||||||
items: 'Товары',
|
items: 'Товары',
|
||||||
deliveryLabel: 'Доставка',
|
deliveryLabel: 'Доставка',
|
||||||
|
deliveryMethod: 'Способ доставки',
|
||||||
|
selectDelivery: 'Выберите способ доставки',
|
||||||
|
deliveryPlace: 'Место',
|
||||||
|
deliveryTime: 'Срок доставки',
|
||||||
|
digitalDelivery: 'Цифровая доставка',
|
||||||
|
deliveryRequired: 'Выберите доставку для всех товаров с доставкой перед оформлением заказа.',
|
||||||
toPay: 'К оплате',
|
toPay: 'К оплате',
|
||||||
agreeWith: 'Я согласен с',
|
agreeWith: 'Я согласен с',
|
||||||
publicOffer: 'публичной офертой',
|
publicOffer: 'публичной офертой',
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export interface Translations {
|
|||||||
total: string;
|
total: string;
|
||||||
items: string;
|
items: string;
|
||||||
deliveryLabel: string;
|
deliveryLabel: string;
|
||||||
|
deliveryMethod: string;
|
||||||
|
selectDelivery: string;
|
||||||
|
deliveryPlace: string;
|
||||||
|
deliveryTime: string;
|
||||||
|
digitalDelivery: string;
|
||||||
|
deliveryRequired: string;
|
||||||
toPay: string;
|
toPay: string;
|
||||||
agreeWith: string;
|
agreeWith: string;
|
||||||
publicOffer: string;
|
publicOffer: string;
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ export interface ItemAttribute {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeliveryOption {
|
||||||
|
deliveryPrice: number;
|
||||||
|
deliveryPlace: string;
|
||||||
|
deliveryTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeliveryMode = 'selectable' | 'digital';
|
||||||
|
|
||||||
/** Item variant detail (price, size, colour per variant) */
|
/** Item variant detail (price, size, colour per variant) */
|
||||||
export interface ItemDetail {
|
export interface ItemDetail {
|
||||||
color?: string;
|
color?: string;
|
||||||
@@ -92,6 +100,9 @@ export interface Item {
|
|||||||
// Backend API fields
|
// Backend API fields
|
||||||
colour?: string;
|
colour?: string;
|
||||||
size?: string;
|
size?: string;
|
||||||
|
deliveryOptions?: DeliveryOption[];
|
||||||
|
deliveryMode?: DeliveryMode;
|
||||||
|
deliverySelectionRequired?: boolean;
|
||||||
language?: string;
|
language?: string;
|
||||||
names?: ItemName[];
|
names?: ItemName[];
|
||||||
descriptions?: ItemDescription[];
|
descriptions?: ItemDescription[];
|
||||||
@@ -115,4 +126,5 @@ export interface Item {
|
|||||||
|
|
||||||
export interface CartItem extends Item {
|
export interface CartItem extends Item {
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
selectedDelivery?: DeliveryOption | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,12 +78,6 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<span class="current-price">{{ item.price }} {{ item.currency }}</span>
|
<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>
|
||||||
|
|
||||||
<div class="quantity-controls">
|
<div class="quantity-controls">
|
||||||
@@ -100,6 +94,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (item.deliveryMode === 'digital' || item.deliveryOptions?.length) {
|
||||||
|
<app-delivery-selector
|
||||||
|
[item]="item"
|
||||||
|
(selectedDeliveryChange)="selectDelivery(item.itemID, $event)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,6 +130,10 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (!allRequiredDeliveriesSelected()) {
|
||||||
|
<p class="delivery-warning">{{ 'cart.deliveryRequired' | translate }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="summary-row total">
|
<div class="summary-row total">
|
||||||
<span>{{ 'cart.toPay' | translate }}</span>
|
<span>{{ 'cart.toPay' | translate }}</span>
|
||||||
<span class="total-price">{{ totalWithDelivery() | number:'1.2-2' }} {{ currentCurrency }}</span>
|
<span class="total-price">{{ totalWithDelivery() | number:'1.2-2' }} {{ currentCurrency }}</span>
|
||||||
@@ -155,8 +160,8 @@
|
|||||||
<button
|
<button
|
||||||
class="checkout-btn"
|
class="checkout-btn"
|
||||||
(click)="checkout()"
|
(click)="checkout()"
|
||||||
[class.disabled]="!termsAccepted || !isAuthenticated()"
|
[class.disabled]="isCheckoutDisabled"
|
||||||
[disabled]="!termsAccepted || !isAuthenticated()"
|
[disabled]="isCheckoutDisabled"
|
||||||
>
|
>
|
||||||
{{ 'cart.checkout' | translate }}
|
{{ 'cart.checkout' | translate }}
|
||||||
</button>
|
</button>
|
||||||
@@ -215,7 +220,7 @@
|
|||||||
<div class="payment-info">
|
<div class="payment-info">
|
||||||
<div class="payment-amount">
|
<div class="payment-amount">
|
||||||
<span class="label">{{ 'cart.amountToPay' | translate }}</span>
|
<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>
|
||||||
|
|
||||||
<div class="waiting-indicator">
|
<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
|
// Dexar checkbox colors
|
||||||
.cart-container.dexar .terms-agreement .checkbox-container {
|
.cart-container.dexar .terms-agreement .checkbox-container {
|
||||||
input[type="checkbox"]:checked ~ .checkmark {
|
input[type="checkbox"]:checked ~ .checkmark {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { DecimalPipe } from '@angular/common';
|
|||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { CartService, ApiService, LanguageService, AuthService } from '../../services';
|
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 { interval, of, Subscription } from 'rxjs';
|
||||||
import { catchError, exhaustMap, take, timeout } from 'rxjs/operators';
|
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 { EmptyCartIconComponent } from '../../components/empty-cart-icon/empty-cart-icon.component';
|
||||||
import { TelegramLoginComponent } from '../../components/telegram-login/telegram-login.component';
|
import { TelegramLoginComponent } from '../../components/telegram-login/telegram-login.component';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
@@ -17,7 +18,7 @@ import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MAX_CHECKS, PAYMENT_TIMEOUT_CLOSE_MS,
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-cart',
|
selector: 'app-cart',
|
||||||
imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe],
|
imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe],
|
||||||
templateUrl: './cart.component.html',
|
templateUrl: './cart.component.html',
|
||||||
styleUrls: ['./cart.component.scss'],
|
styleUrls: ['./cart.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -29,6 +30,7 @@ export class CartComponent implements OnDestroy {
|
|||||||
totalDeliveryPrice;
|
totalDeliveryPrice;
|
||||||
totalWithDelivery;
|
totalWithDelivery;
|
||||||
hasDeliveryPrice;
|
hasDeliveryPrice;
|
||||||
|
allRequiredDeliveriesSelected;
|
||||||
termsAccepted = false;
|
termsAccepted = false;
|
||||||
isnovo = environment.theme === 'novo';
|
isnovo = environment.theme === 'novo';
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ export class CartComponent implements OnDestroy {
|
|||||||
this.totalDeliveryPrice = this.cartService.totalDeliveryPrice;
|
this.totalDeliveryPrice = this.cartService.totalDeliveryPrice;
|
||||||
this.totalWithDelivery = this.cartService.totalWithDelivery;
|
this.totalWithDelivery = this.cartService.totalWithDelivery;
|
||||||
this.hasDeliveryPrice = this.cartService.hasDeliveryPrice;
|
this.hasDeliveryPrice = this.cartService.hasDeliveryPrice;
|
||||||
|
this.allRequiredDeliveriesSelected = this.cartService.allRequiredDeliveriesSelected;
|
||||||
}
|
}
|
||||||
|
|
||||||
requestLogin(): void {
|
requestLogin(): void {
|
||||||
@@ -148,8 +151,18 @@ export class CartComponent implements OnDestroy {
|
|||||||
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
|
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
|
||||||
itemDesc(item: Item): string { return getTranslatedField(item, 'simpleDescription', this.langService.currentLanguage()); }
|
itemDesc(item: Item): string { return getTranslatedField(item, 'simpleDescription', this.langService.currentLanguage()); }
|
||||||
get currentCurrency(): string { return this.langService.currentCurrency(); }
|
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 {
|
checkout(): void {
|
||||||
|
if (!this.allRequiredDeliveriesSelected()) {
|
||||||
|
alert(this.i18n.t('cart.deliveryRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.termsAccepted) {
|
if (!this.termsAccepted) {
|
||||||
alert(this.i18n.t('cart.acceptTerms'));
|
alert(this.i18n.t('cart.acceptTerms'));
|
||||||
return;
|
return;
|
||||||
@@ -348,7 +361,8 @@ export class CartComponent implements OnDestroy {
|
|||||||
? item.price * (1 - item.discount / 100)
|
? item.price * (1 - item.discount / 100)
|
||||||
: item.price,
|
: item.price,
|
||||||
currency: item.currency,
|
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 * (1 - item.discount / 100)
|
||||||
: item.price,
|
: item.price,
|
||||||
currency: item.currency,
|
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}`;
|
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) => {
|
return this.items().map((item: CartItem) => {
|
||||||
const unitPrice = item.discount > 0
|
const unitPrice = item.discount > 0
|
||||||
? item.price * (1 - item.discount / 100)
|
? item.price * (1 - item.discount / 100)
|
||||||
@@ -489,6 +504,8 @@ export class CartComponent implements OnDestroy {
|
|||||||
itemID: item.itemID,
|
itemID: item.itemID,
|
||||||
price: unitPrice * item.quantity,
|
price: unitPrice * item.quantity,
|
||||||
name,
|
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 { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||||
import { Observable, timer } from 'rxjs';
|
import { Observable, timer } from 'rxjs';
|
||||||
import { map, retry } from 'rxjs/operators';
|
import { map, retry } from 'rxjs/operators';
|
||||||
import { Category, Item, Subcategory } from '../models';
|
import { Category, DeliveryOption, Item, Subcategory } from '../models';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
export interface QrCreateRequest {
|
export interface QrCreateRequest {
|
||||||
@@ -41,7 +41,7 @@ export interface CartPaymentRequest {
|
|||||||
siteorderID: string;
|
siteorderID: string;
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
telegramUsername: 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 {
|
export interface QrDynamicStatusResponse {
|
||||||
@@ -98,6 +98,89 @@ export class ApiService {
|
|||||||
return Number.isFinite(normalized) ? normalized : undefined;
|
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 */
|
/** Resolve relative image URLs (e.g. ./images/x.webp) against site origin */
|
||||||
private resolveImageUrl(url: string): string {
|
private resolveImageUrl(url: string): string {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
@@ -114,14 +197,10 @@ export class ApiService {
|
|||||||
private normalizeItem(raw: any): Item {
|
private normalizeItem(raw: any): Item {
|
||||||
const { partnerID, ...rest } = raw;
|
const { partnerID, ...rest } = raw;
|
||||||
const item: Item = { ...rest };
|
const item: Item = { ...rest };
|
||||||
const topLevelDeliveryPrice = this.normalizeOptionalNumber(
|
let legacyDeliveryPrice = this.normalizeOptionalNumber(
|
||||||
raw.deliveryPrice ?? raw.delivery_price ?? raw.deliveryprice
|
raw.deliveryPrice ?? raw.delivery_price ?? raw.deliveryprice
|
||||||
);
|
);
|
||||||
|
|
||||||
if (topLevelDeliveryPrice !== undefined) {
|
|
||||||
item.deliveryPrice = topLevelDeliveryPrice;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract price/currency/remaining/colour/size from itemDetails[]
|
// Extract price/currency/remaining/colour/size from itemDetails[]
|
||||||
// Note: Go struct tag is "itemdetails" but actual API may send "itemDetails"
|
// Note: Go struct tag is "itemdetails" but actual API may send "itemDetails"
|
||||||
const details = raw.itemDetails || raw.itemdetails;
|
const details = raw.itemDetails || raw.itemdetails;
|
||||||
@@ -139,20 +218,25 @@ export class ApiService {
|
|||||||
if (!item.currency) item.currency = detail.currency;
|
if (!item.currency) item.currency = detail.currency;
|
||||||
if (!item.colour) item.colour = this.normalizeColor(detail.colour || detail.color || '');
|
if (!item.colour) item.colour = this.normalizeColor(detail.colour || detail.color || '');
|
||||||
if (!item.size) item.size = detail.size || '';
|
if (!item.size) item.size = detail.size || '';
|
||||||
if (item.deliveryPrice == null) {
|
if (legacyDeliveryPrice === undefined) {
|
||||||
const detailDeliveryPrice = this.normalizeOptionalNumber(
|
legacyDeliveryPrice = this.normalizeOptionalNumber(
|
||||||
detail.deliveryPrice ?? detail.delivery_price ?? detail.deliveryprice
|
detail.deliveryPrice ?? detail.delivery_price ?? detail.deliveryprice
|
||||||
);
|
);
|
||||||
|
|
||||||
if (detailDeliveryPrice !== undefined) {
|
|
||||||
item.deliveryPrice = detailDeliveryPrice;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Use remaining from detail for stock level
|
// Use remaining from detail for stock level
|
||||||
if (raw.remaining == null && detail.remaining != null) {
|
if (raw.remaining == null && detail.remaining != null) {
|
||||||
(raw as any).remaining = detail.remaining;
|
(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
|
// Map backOffice string id → legacy numeric itemID
|
||||||
if (raw.id != null && raw.itemID == null) {
|
if (raw.id != null && raw.itemID == null) {
|
||||||
@@ -481,8 +565,9 @@ export class ApiService {
|
|||||||
|
|
||||||
submitPurchaseEmail(emailData: {
|
submitPurchaseEmail(emailData: {
|
||||||
email: string;
|
email: string;
|
||||||
|
phone?: string;
|
||||||
telegramUserId: string | null;
|
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 }> {
|
}): Observable<{ message: string }> {
|
||||||
return this.http.post<{ message: string }>(`${this.baseUrl}/purchase-email`, emailData);
|
return this.http.post<{ message: string }>(`${this.baseUrl}/purchase-email`, emailData);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, signal, computed, effect } from '@angular/core';
|
import { Injectable, signal, computed, effect } from '@angular/core';
|
||||||
import { ApiService } from './api.service';
|
import { ApiService } from './api.service';
|
||||||
import { Item, CartItem } from '../models';
|
import { DeliveryOption, Item, CartItem } from '../models';
|
||||||
import { getDiscountedPrice } from '../utils/item.utils';
|
import { getDiscountedPrice } from '../utils/item.utils';
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
import type { } from '../types/telegram.types';
|
import type { } from '../types/telegram.types';
|
||||||
@@ -31,13 +31,21 @@ export class CartService {
|
|||||||
totalDeliveryPrice = computed(() => {
|
totalDeliveryPrice = computed(() => {
|
||||||
const items = this.cartItems();
|
const items = this.cartItems();
|
||||||
if (!Array.isArray(items)) return 0;
|
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());
|
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(() => {
|
hasDeliveryPrice = computed(() => {
|
||||||
const items = this.cartItems();
|
const items = this.cartItems();
|
||||||
if (!Array.isArray(items)) return false;
|
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) {
|
constructor(private apiService: ApiService) {
|
||||||
@@ -64,14 +72,110 @@ export class CartService {
|
|||||||
return Number.isFinite(normalized) ? normalized : undefined;
|
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 {
|
private normalizeCartItem(item: CartItem): CartItem {
|
||||||
const { deliveryPrice, ...rest } = item;
|
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 {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
quantity: item.quantity || 1,
|
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);
|
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 {
|
removeItems(itemIDs: number[]): void {
|
||||||
const currentItems = this.cartItems();
|
const currentItems = this.cartItems();
|
||||||
const updatedItems = currentItems.filter(item => !itemIDs.includes(item.itemID));
|
const updatedItems = currentItems.filter(item => !itemIDs.includes(item.itemID));
|
||||||
|
|||||||
Reference in New Issue
Block a user