326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
import { Component, OnInit, OnDestroy, signal, computed, ChangeDetectionStrategy, inject } from '@angular/core';
|
||
import { DecimalPipe } from '@angular/common';
|
||
import { FormsModule } from '@angular/forms';
|
||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||
import { ApiService, CartService, TelegramService, LanguageService, SeoService } from '../../services';
|
||
import { AuthService } from '../../services/auth.service';
|
||
import { Item, ItemDetail, DescriptionField } from '../../models';
|
||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||
import { Subscription } from 'rxjs';
|
||
import { environment } from '../../../environments/environment';
|
||
import { SecurityContext } from '@angular/core';
|
||
import { getDiscountedPrice, getAllImages, getStockStatus, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||
import { TranslateService } from '../../i18n/translate.service';
|
||
|
||
@Component({
|
||
selector: 'app-item-detail',
|
||
imports: [DecimalPipe, RouterLink, FormsModule, LangRoutePipe, TranslatePipe],
|
||
templateUrl: './item-detail.component.html',
|
||
styleUrls: ['./item-detail.component.scss'],
|
||
changeDetection: ChangeDetectionStrategy.OnPush
|
||
})
|
||
export class ItemDetailComponent implements OnInit, OnDestroy {
|
||
item = signal<Item | null>(null);
|
||
selectedPhotoIndex = signal(0);
|
||
loading = signal(true);
|
||
error = signal<string | null>(null);
|
||
isnovo = environment.theme === 'novo';
|
||
|
||
// Variant selection
|
||
selectedColour = signal<string | null>(null);
|
||
selectedSize = signal<string | null>(null);
|
||
|
||
availableColours = computed(() => {
|
||
const details = this.item()?.itemDetails;
|
||
if (!details?.length) return [] as string[];
|
||
const unique = [...new Set(details.map(d => d.colour || d.color).filter((c): c is string => !!c))];
|
||
return unique;
|
||
});
|
||
|
||
availableSizes = computed(() => {
|
||
const details = this.item()?.itemDetails;
|
||
if (!details?.length) return [] as string[];
|
||
// If a colour is selected, only show sizes available for that colour
|
||
const colour = this.selectedColour();
|
||
const filtered = colour
|
||
? details.filter(d => (d.colour || d.color) === colour)
|
||
: details;
|
||
const unique = [...new Set(filtered.map(d => d.size).filter((s): s is string => !!s && s.toLowerCase() !== 'default'))];
|
||
return unique;
|
||
});
|
||
|
||
selectedDetail = computed<ItemDetail | null>(() => {
|
||
const details = this.item()?.itemDetails;
|
||
if (!details?.length) return null;
|
||
const colour = this.selectedColour();
|
||
const size = this.selectedSize();
|
||
return details.find(d =>
|
||
(!colour || (d.colour || d.color) === colour) &&
|
||
(!size || d.size === size)
|
||
) ?? null;
|
||
});
|
||
|
||
effectivePrice = computed(() => {
|
||
const detail = this.selectedDetail();
|
||
return detail?.price ?? this.item()?.price ?? 0;
|
||
});
|
||
|
||
effectiveCurrency = computed(() => {
|
||
const detail = this.selectedDetail();
|
||
return detail?.currency ?? this.item()?.currency ?? '';
|
||
});
|
||
|
||
effectiveRemaining = computed(() => {
|
||
const detail = this.selectedDetail();
|
||
return detail?.remaining ?? this.item()?.quantity ?? null;
|
||
});
|
||
|
||
newReview = {
|
||
rating: 0,
|
||
comment: '',
|
||
anonymous: false
|
||
};
|
||
|
||
reviewSubmitStatus = signal<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||
|
||
private routeSubscription?: Subscription;
|
||
private reviewResetTimeout?: ReturnType<typeof setTimeout>;
|
||
private reviewErrorTimeout?: ReturnType<typeof setTimeout>;
|
||
private reloadTimeout?: ReturnType<typeof setTimeout>;
|
||
|
||
private seoService = inject(SeoService);
|
||
private i18n = inject(TranslateService);
|
||
private authService = inject(AuthService);
|
||
|
||
constructor(
|
||
private route: ActivatedRoute,
|
||
private apiService: ApiService,
|
||
private cartService: CartService,
|
||
private telegramService: TelegramService,
|
||
private sanitizer: DomSanitizer,
|
||
private languageService: LanguageService
|
||
) {}
|
||
|
||
ngOnInit(): void {
|
||
this.routeSubscription = this.route.params.subscribe(params => {
|
||
const id = parseInt(params['id'], 10);
|
||
this.loadItem(id);
|
||
});
|
||
}
|
||
|
||
ngOnDestroy(): void {
|
||
this.routeSubscription?.unsubscribe();
|
||
if (this.reviewResetTimeout) clearTimeout(this.reviewResetTimeout);
|
||
if (this.reviewErrorTimeout) clearTimeout(this.reviewErrorTimeout);
|
||
if (this.reloadTimeout) clearTimeout(this.reloadTimeout);
|
||
this.seoService.resetToDefaults();
|
||
}
|
||
|
||
loadItem(itemID: number): void {
|
||
this.loading.set(true);
|
||
|
||
this.apiService.getItem(itemID).subscribe({
|
||
next: (item) => {
|
||
this.item.set(item);
|
||
this.initVariantSelection(item);
|
||
this.seoService.setItemMeta(item);
|
||
this.loading.set(false);
|
||
},
|
||
error: (err) => {
|
||
this.error.set('Failed to load item');
|
||
this.loading.set(false);
|
||
console.error('Error loading item:', err);
|
||
}
|
||
});
|
||
}
|
||
|
||
private initVariantSelection(item: Item): void {
|
||
// Auto-select the first available colour and size from itemDetails
|
||
const details = item.itemDetails;
|
||
if (details?.length) {
|
||
const firstColour = details[0].colour || details[0].color || null;
|
||
this.selectedColour.set(firstColour);
|
||
const firstSize = details[0].size || null;
|
||
this.selectedSize.set(firstSize);
|
||
} else {
|
||
this.selectedColour.set(item.colour ?? null);
|
||
this.selectedSize.set(item.size ?? null);
|
||
}
|
||
}
|
||
|
||
selectColour(colour: string): void {
|
||
this.selectedColour.set(colour);
|
||
// If current size is not available for the new colour, reset to first available
|
||
const sizes = this.availableSizes();
|
||
if (sizes.length && this.selectedSize() && !sizes.includes(this.selectedSize()!)) {
|
||
this.selectedSize.set(sizes[0]);
|
||
}
|
||
}
|
||
|
||
selectSize(size: string): void {
|
||
this.selectedSize.set(size);
|
||
}
|
||
|
||
selectPhoto(index: number): void {
|
||
this.selectedPhotoIndex.set(index);
|
||
}
|
||
|
||
addToCart(): void {
|
||
const currentItem = this.item();
|
||
if (currentItem) {
|
||
this.cartService.addItem(currentItem.itemID, 1, {
|
||
colour: this.selectedColour() ?? undefined,
|
||
size: this.selectedSize() ?? undefined,
|
||
price: this.effectivePrice(),
|
||
currency: this.effectiveCurrency()
|
||
});
|
||
}
|
||
}
|
||
|
||
getDiscountedPrice(): number {
|
||
const currentItem = this.item();
|
||
if (!currentItem) return 0;
|
||
const price = this.effectivePrice();
|
||
const discount = currentItem.discount || 0;
|
||
return discount > 0 ? price * (1 - discount / 100) : price;
|
||
}
|
||
|
||
// BackOffice integration helpers
|
||
|
||
getItemName(): string {
|
||
const currentItem = this.item();
|
||
if (!currentItem) return '';
|
||
const lang = this.languageService.currentLanguage();
|
||
return getTranslatedField(currentItem, 'name', lang);
|
||
}
|
||
|
||
getSimpleDescription(): string {
|
||
const currentItem = this.item();
|
||
if (!currentItem) return '';
|
||
return currentItem.simpleDescription || currentItem.description || '';
|
||
}
|
||
|
||
hasDescriptionFields(): boolean {
|
||
const currentItem = this.item();
|
||
return !!(currentItem?.descriptionFields && currentItem.descriptionFields.length > 0);
|
||
}
|
||
|
||
getTranslatedDescriptionFields(): DescriptionField[] {
|
||
const currentItem = this.item();
|
||
if (!currentItem) return [];
|
||
const lang = this.languageService.currentLanguage();
|
||
const translation = currentItem.translations?.[lang];
|
||
if (translation?.description && translation.description.length > 0) {
|
||
return translation.description;
|
||
}
|
||
return currentItem.descriptionFields || [];
|
||
}
|
||
|
||
getStockClass(): string {
|
||
const currentItem = this.item();
|
||
if (!currentItem) return 'high';
|
||
return getStockStatus(currentItem);
|
||
}
|
||
|
||
getStockLabel(): string {
|
||
const status = this.getStockClass();
|
||
switch (status) {
|
||
case 'high': return 'В наличии';
|
||
case 'medium': return 'Заканчивается';
|
||
case 'low': return 'Последние штуки';
|
||
case 'out': return 'Нет в наличии';
|
||
default: return 'В наличии';
|
||
}
|
||
}
|
||
|
||
readonly getBadgeClass = getBadgeClass;
|
||
|
||
getSafeHtml(html: string): SafeHtml {
|
||
return this.sanitizer.sanitize(SecurityContext.HTML, html) || '';
|
||
}
|
||
|
||
getRatingStars(rating: number): string {
|
||
const fullStars = Math.floor(rating);
|
||
const hasHalfStar = rating % 1 >= 0.5;
|
||
let stars = '★'.repeat(fullStars);
|
||
if (hasHalfStar) stars += '☆';
|
||
return stars;
|
||
}
|
||
|
||
formatDate(timestamp: string): string {
|
||
const date = new Date(timestamp);
|
||
const now = new Date();
|
||
const diffMs = now.getTime() - date.getTime();
|
||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||
|
||
if (diffDays === 0) return this.i18n.t('itemDetail.today');
|
||
if (diffDays === 1) return this.i18n.t('itemDetail.yesterday');
|
||
if (diffDays < 7) return `${diffDays} ${this.i18n.t('itemDetail.daysAgo')}`;
|
||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} ${this.i18n.t('itemDetail.weeksAgo')}`;
|
||
|
||
return date.toLocaleDateString('ru-RU', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: 'numeric'
|
||
});
|
||
}
|
||
|
||
setRating(rating: number): void {
|
||
this.newReview.rating = rating;
|
||
}
|
||
|
||
getUserDisplayName(): string | null {
|
||
if (!this.telegramService.isTelegramApp()) {
|
||
return this.i18n.t('itemDetail.defaultUser');
|
||
}
|
||
return this.telegramService.getDisplayName();
|
||
}
|
||
|
||
submitReview(): void {
|
||
if (!this.newReview.rating || !this.newReview.comment.trim()) {
|
||
return;
|
||
}
|
||
|
||
const currentItem = this.item();
|
||
if (!currentItem) return;
|
||
|
||
this.reviewSubmitStatus.set('loading');
|
||
|
||
const reviewData = {
|
||
itemID: currentItem.itemID,
|
||
rating: this.newReview.rating,
|
||
comment: this.newReview.comment.trim(),
|
||
sessionID: this.authService.session()?.sessionId || '',
|
||
timestamp: new Date().toISOString()
|
||
};
|
||
|
||
this.apiService.submitReview(reviewData).subscribe({
|
||
next: (response) => {
|
||
this.reviewSubmitStatus.set('success');
|
||
this.newReview = { rating: 0, comment: '', anonymous: false };
|
||
|
||
// Сброс состояния через 3 секунды
|
||
this.reviewResetTimeout = setTimeout(() => {
|
||
this.reviewSubmitStatus.set('idle');
|
||
}, 3000);
|
||
|
||
// Перезагрузить данные товара после отправки отзыва
|
||
this.reloadTimeout = setTimeout(() => {
|
||
this.loadItem(currentItem.itemID);
|
||
}, 500);
|
||
},
|
||
error: (err) => {
|
||
console.error('Error submitting review:', err);
|
||
this.reviewSubmitStatus.set('error');
|
||
|
||
// Сброс состояния об ошибке через 5 секунд
|
||
this.reviewErrorTimeout = setTimeout(() => {
|
||
this.reviewSubmitStatus.set('idle');
|
||
}, 5000);
|
||
}
|
||
});
|
||
}
|
||
}
|