Files
marketplaces/src/app/services/cart.service.ts
sdarbinyan 4fb918f5e4 cleaned up
2026-06-21 23:42:39 +04:00

272 lines
9.5 KiB
TypeScript

import { Injectable, signal, computed, effect, Injector } from '@angular/core';
import { DeliveryOption, CartItem } from '../models';
import { getDiscountedPrice } from '../utils/item.utils';
import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils';
import { environment } from '../../environments/environment';
import type { } from '../types/telegram.types';
@Injectable({
providedIn: 'root'
})
export class CartService {
private readonly STORAGE_KEY = `${environment.brandName.toLowerCase().replace(/\s+/g, '_')}_cart`;
private cartItems = signal<CartItem[]>([]);
private isTelegram = typeof window !== 'undefined' && !!window.Telegram?.WebApp;
private addingItems = new Set<number>();
private initialized = false;
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) => {
return total + (getDiscountedPrice(item) * item.quantity);
}, 0);
});
totalDeliveryPrice = computed(() => {
const items = this.cartItems();
if (!Array.isArray(items)) return 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 this.allRequiredDeliveriesSelected()
&& items.some(item => (item.deliveryOptions?.length ?? 0) > 0 || item.selectedDelivery != null);
});
constructor(private injector: Injector) {
this.loadCart();
// Auto-save whenever cart changes (skip the initial empty state)
effect(() => {
const items = this.cartItems();
if (this.initialized) {
this.saveToStorage(items);
}
});
}
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 => normalizeDeliveryOption(option))
.filter((option): option is DeliveryOption => option !== null)
: [];
const legacyDeliveryPrice = 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 = 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 deliveryState = this.normalizeDeliveryState(item);
const hasDeliveryState = !!deliveryState.deliveryMode
|| deliveryState.deliveryOptions.length > 0
|| deliveryState.selectedDelivery != null;
return {
...rest,
quantity: item.quantity || 1,
...(hasDeliveryState ? { deliverySelectionRequired: deliveryState.deliverySelectionRequired } : {}),
...(deliveryState.deliveryMode ? { deliveryMode: deliveryState.deliveryMode } : {}),
...(deliveryState.deliveryOptions.length > 0 ? { deliveryOptions: deliveryState.deliveryOptions } : {}),
...(deliveryState.selectedDelivery ? { selectedDelivery: deliveryState.selectedDelivery } : {}),
};
}
private saveToStorage(items: CartItem[]): void {
const data = JSON.stringify(items);
// Always save to localStorage
localStorage.setItem(this.STORAGE_KEY, data);
// 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);
this.loadFromLocalStorage();
} else if (value) {
this.parseAndSetCart(value) || this.loadFromLocalStorage();
} else {
// No data in CloudStorage, try localStorage
this.loadFromLocalStorage();
}
this.initialized = true;
});
} else {
this.loadFromLocalStorage();
this.initialized = true;
}
}
private loadFromLocalStorage(): void {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) {
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)) {
this.cartItems.set(items.map(item => this.normalizeCartItem(item)));
return true;
}
} catch (err) {
console.error('Error parsing cart data:', err);
}
this.cartItems.set([]);
return false;
}
addItem(itemID: number, quantity: number = 1, variant?: { colour?: string; size?: string; price?: number; currency?: string }): void {
// Prevent duplicate API calls for same item
if (this.addingItems.has(itemID)) return;
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
this.addingItems.add(itemID);
import('./api.service').then(({ ApiService }) => {
this.injector.get(ApiService).getItem(itemID).subscribe({
next: (item) => {
const cartItem = this.normalizeCartItem({
...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 }),
});
this.cartItems.set([...this.cartItems(), cartItem]);
this.addingItems.delete(itemID);
},
error: (err) => {
console.error('Error adding to cart:', err);
this.addingItems.delete(itemID);
}
});
}).catch((err) => {
console.error('Error loading API service:', err);
this.addingItems.delete(itemID);
});
}
}
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);
}
setSelectedDelivery(itemID: number, selectedDelivery: DeliveryOption | null): void {
const normalizedSelection = 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));
this.cartItems.set(updatedItems);
}
removeItem(itemID: number): void {
this.removeItems([itemID]);
}
clearCart(): void {
this.cartItems.set([]);
}
}