Merge remote-tracking branch 'origin' into back-office-integration

This commit is contained in:
sdarbinyan
2026-02-28 16:13:14 +04:00
217 changed files with 10170 additions and 5789 deletions

View File

@@ -4,14 +4,14 @@
@if (loading()) {
<div class="novo-loading">
<div class="novo-spinner"></div>
<p>Загрузка...</p>
<p>{{ 'itemDetail.loading' | translate }}</p>
</div>
}
@if (error()) {
<div class="novo-error">
<p>{{ error() }}</p>
<a routerLink="/" class="novo-back-link">Вернуться</a>
<a [routerLink]="'/' | langRoute" class="novo-back-link">{{ 'itemDetail.back' | translate }}</a>
</div>
}
@@ -49,7 +49,7 @@
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<path d="M21 15l-5-5L5 21"></path>
</svg>
<p>Нет изображения</p>
<p>{{ 'itemDetail.noImage' | translate }}</p>
</div>
}
</div>
@@ -92,7 +92,7 @@
</div>
<div class="novo-stock">
<span class="stock-label">Наличие:</span>
<span class="stock-label">{{ 'itemDetail.stock' | translate }}</span>
<div class="stock-indicator" [class]="getStockClass()">
<span class="dot"></span>
{{ getStockLabel() }}
@@ -108,7 +108,7 @@
<circle cx="20" cy="21" r="1"></circle>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
</svg>
Добавить в корзину
{{ 'itemDetail.addToCart' | translate }}
</button>
<div class="novo-description">
@@ -117,7 +117,7 @@
}
@if (hasDescriptionFields()) {
<h3>Характеристики</h3>
<h3>{{ 'itemDetail.specifications' | translate }}</h3>
<table class="novo-specs-table">
<tbody>
@for (field of getTranslatedDescriptionFields(); track field.key) {
@@ -129,7 +129,7 @@
</tbody>
</table>
} @else {
<h3>Описание</h3>
<h3>{{ 'itemDetail.description' | translate }}</h3>
<div [innerHTML]="getSafeHtml(item()!.description)"></div>
}
</div>
@@ -137,13 +137,13 @@
</div>
<div class="novo-reviews">
<h2>Отзывы ({{ item()!.callbacks?.length || 0 }})</h2>
<h2>{{ 'itemDetail.reviews' | translate }} ({{ item()!.callbacks?.length || 0 }})</h2>
<!-- novo Review Form -->
<div class="novo-review-form">
<h3>Ваш отзыв</h3>
<h3>{{ 'itemDetail.yourReview' | translate }}</h3>
<div class="novo-rating-input">
<label>Оценка:</label>
<label>{{ 'itemDetail.rating' | translate }}</label>
<div class="novo-star-selector">
@for (star of [1, 2, 3, 4, 5]; track star) {
<span
@@ -157,14 +157,14 @@
</div>
<textarea
[(ngModel)]="newReview.comment"
placeholder="Поделитесь своими впечатлениями о товаре..."
[placeholder]="'itemDetail.reviewPlaceholder' | translate"
rows="4"
class="novo-textarea">
</textarea>
<div class="novo-form-actions">
<label class="novo-anonymous-toggle">
<input type="checkbox" [(ngModel)]="newReview.anonymous">
<span>Анонимно</span>
<span>{{ 'itemDetail.anonymous' | translate }}</span>
</label>
@if (!newReview.anonymous && getUserDisplayName()) {
<span class="novo-username-preview">{{ getUserDisplayName() }}</span>
@@ -176,12 +176,12 @@
[class.submitting]="reviewSubmitStatus() === 'loading'">
@if (reviewSubmitStatus() === 'loading') {
<span class="novo-spinner-small"></span>
Отправка...
{{ 'itemDetail.submitting' | translate }}
} @else {
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
</svg>
Отправить
{{ 'itemDetail.submit' | translate }}
}
</button>
</div>
@@ -191,7 +191,7 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M20 6L9 17l-5-5"/>
</svg>
Спасибо за ваш отзыв!
{{ 'itemDetail.reviewSuccess' | translate }}
</div>
}
@@ -200,7 +200,7 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
Ошибка отправки. Попробуйте позже.
{{ 'itemDetail.reviewError' | translate }}
</div>
}
</div>
@@ -211,7 +211,7 @@
<div class="novo-review-card">
<div class="review-header">
<div class="reviewer-info">
<span class="reviewer-name">{{ review.userID || 'Пользователь' }}</span>
<span class="reviewer-name">{{ review.userID ? review.userID : ('itemDetail.defaultUser' | translate) }}</span>
@if (review.timestamp) {
<span class="review-date">{{ formatDate(review.timestamp) }}</span>
}
@@ -222,7 +222,7 @@
</div>
}
} @else {
<p class="novo-no-reviews">Пока нет отзывов. Станьте первым!</p>
<p class="novo-no-reviews">{{ 'itemDetail.noReviews' | translate }}</p>
}
</div>
</div>
@@ -234,14 +234,14 @@
@if (loading()) {
<div class="dx-loading">
<div class="dx-spinner"></div>
<p>Загрузка товара...</p>
<p>{{ 'itemDetail.loadingDexar' | translate }}</p>
</div>
}
@if (error()) {
<div class="dx-error">
<p>{{ error() }}</p>
<a routerLink="/" class="dx-back-link">Вернуться на главную</a>
<a [routerLink]="'/' | langRoute" class="dx-back-link">{{ 'itemDetail.backHome' | translate }}</a>
</div>
}
@@ -259,7 +259,7 @@
@if (photo.video) {
<div class="dx-video-badge"></div>
}
<img [src]="photo.url" [alt]="'Фото ' + ($index + 1)" loading="lazy" decoding="async" />
<img [src]="photo.url" [alt]="('itemDetail.photo' | translate) + ' ' + ($index + 1)" loading="lazy" decoding="async" />
</div>
}
</div>
@@ -278,7 +278,7 @@
<circle cx="8.5" cy="8.5" r="1.5"></circle>
<path d="M21 15l-5-5L5 21"></path>
</svg>
<span>Нет изображения</span>
<span>{{ 'itemDetail.noImage' | translate }}</span>
</div>
}
</div>
@@ -313,7 +313,7 @@
}
</div>
<span class="dx-rating-value">{{ item()!.rating }}</span>
<span class="dx-rating-count">({{ item()!.callbacks?.length || 0 }} отзывов)</span>
<span class="dx-rating-count">({{ item()!.callbacks?.length || 0 }} {{ 'itemDetail.reviewsCount' | translate }})</span>
</div>
<div class="dx-price-block">
@@ -329,7 +329,7 @@
</div>
<div class="dx-stock">
<span class="dx-stock-label">Наличие:</span>
<span class="dx-stock-label">{{ 'itemDetail.stock' | translate }}</span>
<span class="dx-stock-status" [class]="getStockClass()">
<span class="dx-stock-dot"></span>
{{ getStockLabel() }}
@@ -345,7 +345,7 @@
<circle cx="20" cy="21" r="1"></circle>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
</svg>
Добавить в корзину
{{ 'itemDetail.addToCart' | translate }}
</button>
<div class="dx-description">
@@ -354,7 +354,7 @@
}
@if (hasDescriptionFields()) {
<h2>Характеристики</h2>
<h2>{{ 'itemDetail.specifications' | translate }}</h2>
<table class="dx-specs-table">
<tbody>
@for (field of getTranslatedDescriptionFields(); track field.key) {
@@ -366,7 +366,7 @@
</tbody>
</table>
} @else {
<h2>Описание</h2>
<h2>{{ 'itemDetail.description' | translate }}</h2>
<div class="dx-description-text" [innerHTML]="getSafeHtml(item()!.description)"></div>
}
</div>
@@ -375,12 +375,12 @@
<!-- Reviews Section -->
<div class="dx-reviews-section">
<h2>Отзывы ({{ item()!.callbacks?.length || 0 }})</h2>
<h2>{{ 'itemDetail.reviews' | translate }} ({{ item()!.callbacks?.length || 0 }})</h2>
<div class="dx-review-form">
<h3>Оставить отзыв</h3>
<h3>{{ 'itemDetail.leaveReview' | translate }}</h3>
<div class="dx-rating-input">
<label>Оценка:</label>
<label>{{ 'itemDetail.rating' | translate }}</label>
<div class="dx-star-selector">
@for (star of [1, 2, 3, 4, 5]; track star) {
<span
@@ -394,14 +394,14 @@
</div>
<textarea
[(ngModel)]="newReview.comment"
placeholder="Поделитесь впечатлениями о товаре..."
[placeholder]="'itemDetail.reviewPlaceholderDexar' | translate"
rows="4"
class="dx-textarea">
</textarea>
<div class="dx-form-actions">
<label class="dx-anon-toggle">
<input type="checkbox" [(ngModel)]="newReview.anonymous">
<span>Анонимно</span>
<span>{{ 'itemDetail.anonymous' | translate }}</span>
</label>
@if (!newReview.anonymous && getUserDisplayName()) {
<span class="dx-user-preview">{{ getUserDisplayName() }}</span>
@@ -413,9 +413,9 @@
[class.submitting]="reviewSubmitStatus() === 'loading'">
@if (reviewSubmitStatus() === 'loading') {
<span class="dx-spinner-sm"></span>
Отправка...
{{ 'itemDetail.submitting' | translate }}
} @else {
Отправить
{{ 'itemDetail.submit' | translate }}
}
</button>
</div>
@@ -423,14 +423,14 @@
@if (reviewSubmitStatus() === 'success') {
<div class="dx-status success">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M20 6L9 17l-5-5"/></svg>
Спасибо за ваш отзыв!
{{ 'itemDetail.reviewSuccess' | translate }}
</div>
}
@if (reviewSubmitStatus() === 'error') {
<div class="dx-status error">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M18 6L6 18M6 6l12 12"/></svg>
Ошибка отправки. Попробуйте позже.
{{ 'itemDetail.reviewError' | translate }}
</div>
}
</div>
@@ -441,7 +441,7 @@
<div class="dx-review-card">
<div class="dx-review-header">
<div class="dx-reviewer">
<span class="dx-reviewer-name">{{ callback.userID || 'Аноним' }}</span>
<span class="dx-reviewer-name">{{ callback.userID ? callback.userID : ('itemDetail.defaultUser' | translate) }}</span>
@if (callback.timestamp) {
<span class="dx-review-date">{{ formatDate(callback.timestamp) }}</span>
}
@@ -462,7 +462,7 @@
</div>
}
} @else {
<p class="dx-no-reviews">Пока нет отзывов. Станьте первым!</p>
<p class="dx-no-reviews">{{ 'itemDetail.noReviews' | translate }}</p>
}
</div>
</div>
@@ -470,7 +470,7 @@
<!-- Q&A Section -->
@if (item()!.questions && item()!.questions!.length > 0) {
<div class="dx-qa-section">
<h2>Вопросы и ответы ({{ item()!.questions!.length }})</h2>
<h2>{{ 'itemDetail.qna' | translate }} ({{ item()!.questions!.length }})</h2>
<div class="dx-qa-list">
@for (question of item()!.questions!; track $index) {
<div class="dx-qa-card">

View File

@@ -1,17 +1,21 @@
import { Component, OnInit, OnDestroy, signal, ChangeDetectionStrategy } from '@angular/core';
import { Component, OnInit, OnDestroy, signal, 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 } from '../../services';
import { ApiService, CartService, TelegramService, LanguageService, SeoService } from '../../services';
import { Item, DescriptionField } from '../../models';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Subscription } from 'rxjs';
import { environment } from '../../../environments/environment';
import { getAllImages, getStockStatus, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
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],
imports: [DecimalPipe, RouterLink, FormsModule, LangRoutePipe, TranslatePipe],
templateUrl: './item-detail.component.html',
styleUrls: ['./item-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -32,6 +36,12 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
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);
constructor(
private route: ActivatedRoute,
@@ -51,6 +61,10 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
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 {
@@ -59,6 +73,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
this.apiService.getItem(itemID).subscribe({
next: (item) => {
this.item.set(item);
this.seoService.setItemMeta(item);
this.loading.set(false);
},
error: (err) => {
@@ -83,7 +98,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
getDiscountedPrice(): number {
const currentItem = this.item();
if (!currentItem) return 0;
return currentItem.price * (1 - (currentItem.discount || 0) / 100);
return getDiscountedPrice(currentItem);
}
// BackOffice integration helpers
@@ -138,14 +153,14 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
readonly getBadgeClass = getBadgeClass;
getSafeHtml(html: string): SafeHtml {
return this.sanitizer.sanitize(1, html) || '';
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 += '?';
let stars = ''.repeat(fullStars);
if (hasHalfStar) stars += '';
return stars;
}
@@ -155,10 +170,10 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return '<27><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>';
if (diffDays === 1) return '<27><><EFBFBD><EFBFBD><EFBFBD>';
if (diffDays < 7) return `${diffDays} <EFBFBD><EFBFBD>. <20><><EFBFBD><EFBFBD><EFBFBD>`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} <EFBFBD><EFBFBD><EFBFBD>. <20><><EFBFBD><EFBFBD><EFBFBD>`;
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',
@@ -173,7 +188,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
getUserDisplayName(): string | null {
if (!this.telegramService.isTelegramApp()) {
return 'Пользователь';
return this.i18n.t('itemDetail.defaultUser');
}
return this.telegramService.getDisplayName();
}
@@ -202,13 +217,13 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
this.reviewSubmitStatus.set('success');
this.newReview = { rating: 0, comment: '', anonymous: false };
// <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> 3 <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
setTimeout(() => {
// Сброс состояния через 3 секунды
this.reviewResetTimeout = setTimeout(() => {
this.reviewSubmitStatus.set('idle');
}, 3000);
// <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
setTimeout(() => {
// Перезагрузить данные товара после отправки отзыва
this.reloadTimeout = setTimeout(() => {
this.loadItem(currentItem.itemID);
}, 500);
},
@@ -216,8 +231,8 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
console.error('Error submitting review:', err);
this.reviewSubmitStatus.set('error');
// <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> 5 <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
setTimeout(() => {
// Сброс состояния об ошибке через 5 секунд
this.reviewErrorTimeout = setTimeout(() => {
this.reviewSubmitStatus.set('idle');
}, 5000);
}