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([]); private isTelegram = typeof window !== 'undefined' && !!window.Telegram?.WebApp; private addingItems = new Set(); 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([]); } }