2026-01-18 18:57:06 +04:00
|
|
|
import { Injectable, signal, computed, effect } from '@angular/core';
|
|
|
|
|
import { ApiService } from './api.service';
|
2026-06-21 23:13:01 +04:00
|
|
|
import { DeliveryOption, Item, CartItem } from '../models';
|
2026-02-26 21:54:21 +04:00
|
|
|
import { getDiscountedPrice } from '../utils/item.utils';
|
2026-02-19 01:23:25 +04:00
|
|
|
import { environment } from '../../environments/environment';
|
2026-01-18 18:57:06 +04:00
|
|
|
import type { } from '../types/telegram.types';
|
|
|
|
|
|
|
|
|
|
@Injectable({
|
|
|
|
|
providedIn: 'root'
|
|
|
|
|
})
|
|
|
|
|
export class CartService {
|
2026-02-19 01:23:25 +04:00
|
|
|
private readonly STORAGE_KEY = `${environment.brandName.toLowerCase().replace(/\s+/g, '_')}_cart`;
|
2026-01-18 18:57:06 +04:00
|
|
|
private cartItems = signal<CartItem[]>([]);
|
|
|
|
|
private isTelegram = typeof window !== 'undefined' && !!window.Telegram?.WebApp;
|
2026-02-26 21:54:21 +04:00
|
|
|
private addingItems = new Set<number>();
|
2026-03-06 17:45:34 +04:00
|
|
|
private initialized = false;
|
2026-01-18 18:57:06 +04:00
|
|
|
|
|
|
|
|
items = this.cartItems.asReadonly();
|
|
|
|
|
itemCount = computed(() => {
|
|
|
|
|
const items = this.cartItems();
|
|
|
|
|
if (!Array.isArray(items)) return 0;
|
|
|
|
|
return items.reduce((total, item) => total + item.quantity, 0);
|
|
|
|
|
});
|
|
|
|
|
totalPrice = computed(() => {
|
|
|
|
|
const items = this.cartItems();
|
|
|
|
|
if (!Array.isArray(items)) return 0;
|
|
|
|
|
return items.reduce((total, item) => {
|
2026-02-26 21:54:21 +04:00
|
|
|
return total + (getDiscountedPrice(item) * item.quantity);
|
2026-01-18 18:57:06 +04:00
|
|
|
}, 0);
|
|
|
|
|
});
|
2026-06-20 15:16:25 +04:00
|
|
|
totalDeliveryPrice = computed(() => {
|
|
|
|
|
const items = this.cartItems();
|
|
|
|
|
if (!Array.isArray(items)) return 0;
|
2026-06-21 23:13:01 +04:00
|
|
|
return items.reduce((total, item) => {
|
|
|
|
|
return total + ((item.selectedDelivery?.deliveryPrice ?? 0) * item.quantity);
|
|
|
|
|
}, 0);
|
2026-06-20 15:16:25 +04:00
|
|
|
});
|
|
|
|
|
totalWithDelivery = computed(() => this.totalPrice() + this.totalDeliveryPrice());
|
2026-06-21 23:13:01 +04:00
|
|
|
allRequiredDeliveriesSelected = computed(() => {
|
|
|
|
|
const items = this.cartItems();
|
|
|
|
|
if (!Array.isArray(items)) return true;
|
|
|
|
|
return items.every(item => !this.itemRequiresDeliverySelection(item) || item.selectedDelivery != null);
|
|
|
|
|
});
|
2026-06-20 15:16:25 +04:00
|
|
|
hasDeliveryPrice = computed(() => {
|
|
|
|
|
const items = this.cartItems();
|
|
|
|
|
if (!Array.isArray(items)) return false;
|
2026-06-21 23:13:01 +04:00
|
|
|
return this.allRequiredDeliveriesSelected()
|
|
|
|
|
&& items.some(item => (item.deliveryOptions?.length ?? 0) > 0 || item.selectedDelivery != null);
|
2026-06-20 15:16:25 +04:00
|
|
|
});
|
2026-01-18 18:57:06 +04:00
|
|
|
|
|
|
|
|
constructor(private apiService: ApiService) {
|
|
|
|
|
this.loadCart();
|
|
|
|
|
|
2026-03-06 17:45:34 +04:00
|
|
|
// Auto-save whenever cart changes (skip the initial empty state)
|
2026-01-18 18:57:06 +04:00
|
|
|
effect(() => {
|
|
|
|
|
const items = this.cartItems();
|
2026-03-06 17:45:34 +04:00
|
|
|
if (this.initialized) {
|
|
|
|
|
this.saveToStorage(items);
|
|
|
|
|
}
|
2026-01-18 18:57:06 +04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 15:16:25 +04:00
|
|
|
private normalizeOptionalNumber(value: unknown): number | undefined {
|
|
|
|
|
if (value === null || value === undefined || value === '') {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalized = typeof value === 'number'
|
|
|
|
|
? value
|
|
|
|
|
: Number(String(value).replace(',', '.'));
|
|
|
|
|
|
|
|
|
|
return Number.isFinite(normalized) ? normalized : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 23:13:01 +04:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 15:16:25 +04:00
|
|
|
private normalizeCartItem(item: CartItem): CartItem {
|
|
|
|
|
const { deliveryPrice, ...rest } = item;
|
2026-06-21 23:13:01 +04:00
|
|
|
const deliveryState = this.normalizeDeliveryState(item);
|
|
|
|
|
const hasDeliveryState = !!deliveryState.deliveryMode
|
|
|
|
|
|| deliveryState.deliveryOptions.length > 0
|
|
|
|
|
|| deliveryState.selectedDelivery != null;
|
2026-06-20 15:16:25 +04:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...rest,
|
|
|
|
|
quantity: item.quantity || 1,
|
2026-06-21 23:13:01 +04:00
|
|
|
...(hasDeliveryState ? { deliverySelectionRequired: deliveryState.deliverySelectionRequired } : {}),
|
|
|
|
|
...(deliveryState.deliveryMode ? { deliveryMode: deliveryState.deliveryMode } : {}),
|
|
|
|
|
...(deliveryState.deliveryOptions.length > 0 ? { deliveryOptions: deliveryState.deliveryOptions } : {}),
|
|
|
|
|
...(deliveryState.selectedDelivery ? { selectedDelivery: deliveryState.selectedDelivery } : {}),
|
2026-06-20 15:16:25 +04:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
private saveToStorage(items: CartItem[]): void {
|
|
|
|
|
const data = JSON.stringify(items);
|
|
|
|
|
|
2026-02-26 21:54:21 +04:00
|
|
|
// Always save to localStorage
|
|
|
|
|
localStorage.setItem(this.STORAGE_KEY, data);
|
2026-01-18 18:57:06 +04:00
|
|
|
|
|
|
|
|
// Also save to Telegram CloudStorage if available
|
|
|
|
|
if (this.isTelegram) {
|
|
|
|
|
window.Telegram!.WebApp.CloudStorage.setItem(this.STORAGE_KEY, data, (err) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
console.error('Error saving to Telegram CloudStorage:', err);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private loadCart(): void {
|
|
|
|
|
if (this.isTelegram) {
|
|
|
|
|
// Load from Telegram CloudStorage first
|
|
|
|
|
window.Telegram!.WebApp.CloudStorage.getItem(this.STORAGE_KEY, (err, value) => {
|
|
|
|
|
if (err) {
|
|
|
|
|
console.error('Error loading from Telegram CloudStorage:', err);
|
2026-02-26 21:54:21 +04:00
|
|
|
this.loadFromLocalStorage();
|
2026-01-18 18:57:06 +04:00
|
|
|
} else if (value) {
|
2026-02-26 21:54:21 +04:00
|
|
|
this.parseAndSetCart(value) || this.loadFromLocalStorage();
|
2026-01-18 18:57:06 +04:00
|
|
|
} else {
|
2026-02-26 21:54:21 +04:00
|
|
|
// No data in CloudStorage, try localStorage
|
|
|
|
|
this.loadFromLocalStorage();
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
2026-03-06 17:45:34 +04:00
|
|
|
this.initialized = true;
|
2026-01-18 18:57:06 +04:00
|
|
|
});
|
|
|
|
|
} else {
|
2026-02-26 21:54:21 +04:00
|
|
|
this.loadFromLocalStorage();
|
2026-03-06 17:45:34 +04:00
|
|
|
this.initialized = true;
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 21:54:21 +04:00
|
|
|
private loadFromLocalStorage(): void {
|
|
|
|
|
const stored = localStorage.getItem(this.STORAGE_KEY);
|
2026-01-18 18:57:06 +04:00
|
|
|
if (stored) {
|
2026-02-19 01:23:25 +04:00
|
|
|
this.parseAndSetCart(stored);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Parse JSON cart data, migrate legacy items, and set the signal. Returns true on success. */
|
|
|
|
|
private parseAndSetCart(json: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
const items = JSON.parse(json);
|
|
|
|
|
if (Array.isArray(items)) {
|
2026-06-20 15:16:25 +04:00
|
|
|
this.cartItems.set(items.map(item => this.normalizeCartItem(item)));
|
2026-02-19 01:23:25 +04:00
|
|
|
return true;
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
2026-02-19 01:23:25 +04:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error parsing cart data:', err);
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
2026-02-19 01:23:25 +04:00
|
|
|
this.cartItems.set([]);
|
|
|
|
|
return false;
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
|
2026-03-24 02:46:58 +04:00
|
|
|
addItem(itemID: number, quantity: number = 1, variant?: { colour?: string; size?: string; price?: number; currency?: string }): void {
|
2026-02-26 21:54:21 +04:00
|
|
|
// Prevent duplicate API calls for same item
|
|
|
|
|
if (this.addingItems.has(itemID)) return;
|
|
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
const currentItems = this.cartItems();
|
|
|
|
|
const existingItem = currentItems.find(i => i.itemID === itemID);
|
|
|
|
|
|
|
|
|
|
if (existingItem) {
|
|
|
|
|
// Item exists, increase quantity
|
|
|
|
|
this.updateQuantity(itemID, existingItem.quantity + quantity);
|
|
|
|
|
} else {
|
|
|
|
|
// Get item details from API and add to cart
|
2026-02-26 21:54:21 +04:00
|
|
|
this.addingItems.add(itemID);
|
2026-01-18 18:57:06 +04:00
|
|
|
this.apiService.getItem(itemID).subscribe({
|
|
|
|
|
next: (item) => {
|
2026-06-20 15:16:25 +04:00
|
|
|
const cartItem = this.normalizeCartItem({
|
2026-03-24 02:46:58 +04:00
|
|
|
...item,
|
|
|
|
|
quantity,
|
|
|
|
|
...(variant?.colour != null && { colour: variant.colour }),
|
|
|
|
|
...(variant?.size != null && { size: variant.size }),
|
|
|
|
|
...(variant?.price != null && { price: variant.price }),
|
|
|
|
|
...(variant?.currency != null && { currency: variant.currency }),
|
2026-06-20 15:16:25 +04:00
|
|
|
});
|
2026-01-18 18:57:06 +04:00
|
|
|
this.cartItems.set([...this.cartItems(), cartItem]);
|
2026-02-26 21:54:21 +04:00
|
|
|
this.addingItems.delete(itemID);
|
2026-01-18 18:57:06 +04:00
|
|
|
},
|
|
|
|
|
error: (err) => {
|
|
|
|
|
console.error('Error adding to cart:', err);
|
2026-02-26 21:54:21 +04:00
|
|
|
this.addingItems.delete(itemID);
|
2026-01-18 18:57:06 +04:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateQuantity(itemID: number, quantity: number): void {
|
|
|
|
|
if (quantity <= 0) {
|
|
|
|
|
this.removeItem(itemID);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentItems = this.cartItems();
|
|
|
|
|
const updatedItems = currentItems.map(item =>
|
|
|
|
|
item.itemID === itemID ? { ...item, quantity } : item
|
|
|
|
|
);
|
|
|
|
|
this.cartItems.set(updatedItems);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 23:13:01 +04:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 18:57:06 +04:00
|
|
|
removeItems(itemIDs: number[]): void {
|
|
|
|
|
const currentItems = this.cartItems();
|
|
|
|
|
const updatedItems = currentItems.filter(item => !itemIDs.includes(item.itemID));
|
|
|
|
|
this.cartItems.set(updatedItems);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
removeItem(itemID: number): void {
|
|
|
|
|
this.removeItems([itemID]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearCart(): void {
|
|
|
|
|
this.cartItems.set([]);
|
|
|
|
|
}
|
|
|
|
|
}
|