Files
marketplaces/src/app/pages/item-detail/item-detail.component.ts
sdarbinyan e1112bbd50 fix(icons): replace remaining hand-rolled inline SVGs with Lucide
telegram-login: close X and lock icons (auth-gate icon reused across
telegram-login and cart - same 'log in required' concept, now the
same lock icon in both instead of two different hand-drawn shapes);
retry/refresh icon (QR expired/error states, was duplicated). Added
missing aria-label on the close button (had none).

catalog-empty-state, category, subcategories, item-detail: empty-state
illustrations (package/search/grid), add-to-cart icons, success/error
status icons, and thumbs up/down vote icons replaced. Rating stars
(item-detail, both product rating and per-review rating) now use one
Star icon with a color input and a .dx-star--filled CSS class for the
solid/outline toggle, instead of hand-toggling raw fill/stroke SVG
attributes - fixes the same rating-star pattern being drawn two
different ways (outline-only in the carousel earlier, fill-toggling
here).

Added color input to app-icon (was stroke-only via currentColor
before) and four more icons to the registry: refresh, thumbsUp/Down,
locate/mapPin (added earlier this session).

Also fixed the currency-dropdown chevron in language-selector that a
prior replace_all missed (same markup, different [class.rotated]
binding target).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 02:53:52 +04:00

370 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, DeliveryOption } from '../../models';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Subscription } from 'rxjs';
import { SecurityContext } from '@angular/core';
import { 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';
import { ProductFacade } from '../../facades/platform/product.facade';
import { ProductCardComponent } from '../../components/product-card/product-card.component';
import { IconComponent } from '../../shared/ui/icon/icon.component';
@Component({
selector: 'app-item-detail',
imports: [DecimalPipe, RouterLink, FormsModule, LangRoutePipe, TranslatePipe, ProductCardComponent, IconComponent],
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);
relatedProducts = signal<Item[]>([]);
loading = signal(true);
error = signal<string | null>(null);
// 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;
});
deliveryOptions = computed(() => this.item()?.deliveryOptions ?? []);
hasDeliveryInfo = computed(() => {
const currentItem = this.item();
return currentItem?.deliveryMode === 'digital' || this.deliveryOptions().length > 0;
});
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);
private readonly productFacade = inject(ProductFacade);
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.productFacade.getProduct(itemID).subscribe({
next: (item) => {
this.item.set(item);
this.initVariantSelection(item);
this.seoService.setItemMeta(item);
this.loadRelatedProducts(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);
}
}
private loadRelatedProducts(item: Item): void {
this.productFacade.getRelatedProducts({
productID: item.itemID,
categoryID: item.categoryID,
count: 8
}).subscribe({
next: (result) => this.relatedProducts.set(result.items),
error: () => this.relatedProducts.set([])
});
}
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()
});
}
}
addRelatedToCart(itemID: number, event: Event): void {
event.preventDefault();
event.stopPropagation();
this.cartService.addItem(itemID);
}
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;
}
isDigitalDelivery(): boolean {
return this.item()?.deliveryMode === 'digital';
}
getDeliveryCurrency(): string {
return this.effectiveCurrency() || this.item()?.currency || 'RUB';
}
trackByDeliveryOption(index: number, option: DeliveryOption): string {
return `${index}-${option.deliveryPlace}-${option.deliveryTime}-${option.deliveryPrice}`;
}
// BackOffice integration helpers
getItemName(): string {
const currentItem = this.item();
if (!currentItem) return '';
const lang = this.languageService.currentLanguage();
return getTranslatedField(currentItem, 'name', lang);
}
relatedItemName(item: Item): string {
return getTranslatedField(item, 'name', this.languageService.currentLanguage());
}
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: () => {
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);
}
});
}
}