very first commit
This commit is contained in:
392
src/app/pages/item-detail/item-detail.component.html
Normal file
392
src/app/pages/item-detail/item-detail.component.html
Normal file
@@ -0,0 +1,392 @@
|
||||
@if (isnovo) {
|
||||
<!-- novo VERSION - Modern Design -->
|
||||
<div class="novo-item-container">
|
||||
@if (loading()) {
|
||||
<div class="novo-loading">
|
||||
<div class="novo-spinner"></div>
|
||||
<p>Загрузка...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<div class="novo-error">
|
||||
<p>{{ error() }}</p>
|
||||
<a routerLink="/" class="novo-back-link">Вернуться</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (item() && !loading()) {
|
||||
<div class="novo-item-content">
|
||||
<div class="novo-gallery">
|
||||
@if (item()?.photos && item()!.photos!.length > 0) {
|
||||
<div class="novo-main-photo">
|
||||
@if (item()!.photos![selectedPhotoIndex()]?.video) {
|
||||
<video [src]="item()!.photos![selectedPhotoIndex()].url" controls></video>
|
||||
} @else {
|
||||
<img [src]="item()!.photos![selectedPhotoIndex()].url" [alt]="item()!.name" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (item()!.photos!.length > 1) {
|
||||
<div class="novo-thumbnails">
|
||||
@for (photo of item()!.photos!; track $index) {
|
||||
<div
|
||||
class="novo-thumb"
|
||||
[class.active]="selectedPhotoIndex() === $index"
|
||||
(click)="selectPhoto($index)">
|
||||
@if (photo.video) {
|
||||
<div class="video-indicator">▶</div>
|
||||
}
|
||||
<img [src]="photo.url" [alt]="'Photo ' + ($index + 1)" loading="lazy" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="novo-main-photo novo-no-image">
|
||||
<svg width="80" height="80" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"></rect>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||
<path d="M21 15l-5-5L5 21"></path>
|
||||
</svg>
|
||||
<p>Нет изображения</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="novo-info">
|
||||
<h1 class="novo-title">{{ item()!.name }}</h1>
|
||||
|
||||
<div class="novo-rating">
|
||||
<span class="stars">{{ getRatingStars(item()!.rating) }}</span>
|
||||
<span class="value">{{ item()!.rating }}</span>
|
||||
<span class="reviews">({{ item()!.callbacks?.length || 0 }})</span>
|
||||
</div>
|
||||
|
||||
<div class="novo-price-block">
|
||||
@if (item()!.discount > 0) {
|
||||
<div class="price-row">
|
||||
<span class="old-price">{{ item()!.price }} {{ item()!.currency }}</span>
|
||||
<span class="discount-badge">-{{ item()!.discount }}%</span>
|
||||
</div>
|
||||
<div class="current-price">{{ getDiscountedPrice() | number:'1.2-2' }} {{ item()!.currency }}</div>
|
||||
} @else {
|
||||
<div class="current-price">{{ item()!.price }} {{ item()!.currency }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="novo-stock">
|
||||
<span class="stock-label">Наличие:</span>
|
||||
<div class="stock-indicator" [class.high]="item()!.remainings === 'high'" [class.medium]="item()!.remainings === 'medium'" [class.low]="item()!.remainings === 'low'">
|
||||
<span class="dot"></span>
|
||||
{{ item()!.remainings === 'high' ? 'В наличии' : item()!.remainings === 'medium' ? 'Мало' : 'Осталось немного' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="novo-add-cart" (click)="addToCart()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="9" cy="21" r="1"></circle>
|
||||
<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>
|
||||
Добавить в корзину
|
||||
</button>
|
||||
|
||||
<div class="novo-description">
|
||||
<h3>Описание</h3>
|
||||
<div [innerHTML]="getSafeHtml(item()!.description)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="novo-reviews">
|
||||
<h2>Отзывы ({{ item()!.callbacks?.length || 0 }})</h2>
|
||||
|
||||
<!-- novo Review Form -->
|
||||
<div class="novo-review-form">
|
||||
<h3>Ваш отзыв</h3>
|
||||
<div class="novo-rating-input">
|
||||
<label>Оценка:</label>
|
||||
<div class="novo-star-selector">
|
||||
@for (star of [1, 2, 3, 4, 5]; track star) {
|
||||
<span
|
||||
class="novo-star"
|
||||
[class.selected]="newReview.rating >= star"
|
||||
(click)="setRating(star)">
|
||||
★
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
[(ngModel)]="newReview.comment"
|
||||
placeholder="Поделитесь своими впечатлениями о товаре..."
|
||||
rows="4"
|
||||
class="novo-textarea">
|
||||
</textarea>
|
||||
<div class="novo-form-actions">
|
||||
<label class="novo-anonymous-toggle">
|
||||
<input type="checkbox" [(ngModel)]="newReview.anonymous">
|
||||
<span>Анонимно</span>
|
||||
</label>
|
||||
@if (!newReview.anonymous && getUserDisplayName()) {
|
||||
<span class="novo-username-preview">{{ getUserDisplayName() }}</span>
|
||||
}
|
||||
<button
|
||||
class="novo-submit-review-btn"
|
||||
(click)="submitReview()"
|
||||
[disabled]="!newReview.rating || !newReview.comment.trim() || reviewSubmitStatus() === 'loading'"
|
||||
[class.submitting]="reviewSubmitStatus() === 'loading'">
|
||||
@if (reviewSubmitStatus() === 'loading') {
|
||||
<span class="novo-spinner-small"></span>
|
||||
Отправка...
|
||||
} @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>
|
||||
Отправить
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (reviewSubmitStatus() === 'success') {
|
||||
<div class="novo-status-message success">
|
||||
<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>
|
||||
Спасибо за ваш отзыв!
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (reviewSubmitStatus() === 'error') {
|
||||
<div class="novo-status-message error">
|
||||
<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>
|
||||
Ошибка отправки. Попробуйте позже.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="novo-reviews-list">
|
||||
@if (item()!.callbacks && item()!.callbacks!.length > 0) {
|
||||
@for (review of item()!.callbacks!; track review.userID) {
|
||||
<div class="novo-review-card">
|
||||
<div class="review-header">
|
||||
<div class="reviewer-info">
|
||||
<span class="reviewer-name">{{ review.userID || 'Пользователь' }}</span>
|
||||
@if (review.timestamp) {
|
||||
<span class="review-date">{{ formatDate(review.timestamp) }}</span>
|
||||
}
|
||||
</div>
|
||||
<span class="review-stars">{{ getRatingStars(review.rating || 0) }}</span>
|
||||
</div>
|
||||
<p class="review-text">{{ review.content }}</p>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<p class="novo-no-reviews">Пока нет отзывов. Станьте первым!</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<!-- DEXAR VERSION - Original -->
|
||||
<div class="item-detail-container">
|
||||
@if (loading()) {
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Загрузка товара...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<div class="error">
|
||||
<p>{{ error() }}</p>
|
||||
<a routerLink="/" class="back-link">Вернуться на главную</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (item() && !loading()) {
|
||||
<div class="item-content">
|
||||
<div class="item-gallery">
|
||||
@if (item()?.photos && item()!.photos!.length > 0) {
|
||||
<div class="main-photo">
|
||||
@if (item()!.photos![selectedPhotoIndex()]?.video) {
|
||||
<video [src]="item()!.photos![selectedPhotoIndex()].url" controls></video>
|
||||
} @else {
|
||||
<img [src]="item()!.photos![selectedPhotoIndex()].url" [alt]="item()!.name" fetchpriority="high" decoding="async" width="600" height="600" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="photo-thumbnails">
|
||||
@for (photo of item()!.photos!; track $index) {
|
||||
<div
|
||||
class="thumbnail"
|
||||
[class.active]="selectedPhotoIndex() === $index"
|
||||
(click)="selectPhoto($index)">
|
||||
@if (photo.video) {
|
||||
<div class="video-indicator">▶</div>
|
||||
}
|
||||
<img [src]="photo.url" [alt]="'Photo ' + ($index + 1)" loading="lazy" decoding="async" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="main-photo">
|
||||
<div class="no-image">📦 Нет изображения</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="item-info">
|
||||
<h1>{{ item()!.name }}</h1>
|
||||
|
||||
<div class="rating-section">
|
||||
<span class="rating">{{ getRatingStars(item()!.rating) }} {{ item()!.rating }}</span>
|
||||
<span class="reviews-count">({{ item()!.callbacks?.length || 0 }} отзывов)</span>
|
||||
</div>
|
||||
|
||||
<div class="price-section">
|
||||
@if (item()!.discount > 0) {
|
||||
<div class="discount-info">
|
||||
<span class="original-price">{{ item()!.price }} {{ item()!.currency }}</span>
|
||||
<span class="discount-badge">-{{ item()!.discount }}%</span>
|
||||
</div>
|
||||
<div class="current-price">{{ getDiscountedPrice() | number:'1.2-2' }} {{ item()!.currency }}</div>
|
||||
} @else {
|
||||
<div class="current-price">{{ item()!.price }} {{ item()!.currency }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="stock-info">
|
||||
<div class="stock-bar">
|
||||
<span class="bar-segment" [class.filled]="item()!.remainings === 'high' || item()!.remainings === 'medium' || item()!.remainings === 'low'" [class.high]="item()!.remainings === 'high'" [class.medium]="item()!.remainings === 'medium'" [class.low]="item()!.remainings === 'low'"></span>
|
||||
<span class="bar-segment" [class.filled]="item()!.remainings === 'high' || item()!.remainings === 'medium'" [class.high]="item()!.remainings === 'high'" [class.medium]="item()!.remainings === 'medium'"></span>
|
||||
<span class="bar-segment" [class.filled]="item()!.remainings === 'high'" [class.high]="item()!.remainings === 'high'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="add-to-cart-btn" (click)="addToCart()">
|
||||
Добавить в корзину
|
||||
</button>
|
||||
|
||||
<div class="description-section">
|
||||
<h2>Описание</h2>
|
||||
<div class="description" [innerHTML]="getSafeHtml(item()!.description)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reviews-section">
|
||||
<h2>Отзывы ({{ item()!.callbacks?.length || 0 }})</h2>
|
||||
|
||||
<!-- Review Submission Form -->
|
||||
<div class="review-form">
|
||||
<h3>Оставить отзыв</h3>
|
||||
<div class="rating-input">
|
||||
<label>Оценка:</label>
|
||||
<div class="star-selector">
|
||||
@for (star of [1, 2, 3, 4, 5]; track star) {
|
||||
<span
|
||||
class="star"
|
||||
[class.selected]="newReview.rating >= star"
|
||||
(click)="setRating(star)">
|
||||
⭐
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
[(ngModel)]="newReview.comment"
|
||||
placeholder="Напишите свой отзыв..."
|
||||
rows="4">
|
||||
</textarea>
|
||||
<div class="form-actions">
|
||||
<label class="anonymous-toggle">
|
||||
<input type="checkbox" [(ngModel)]="newReview.anonymous">
|
||||
Анонимно
|
||||
</label>
|
||||
@if (!newReview.anonymous && getUserDisplayName()) {
|
||||
<span class="username-preview">Опубликуется как: {{ getUserDisplayName() }}</span>
|
||||
}
|
||||
<button
|
||||
class="submit-review-btn"
|
||||
(click)="submitReview()"
|
||||
[disabled]="!newReview.rating || !newReview.comment.trim() || reviewSubmitStatus() === 'loading'"
|
||||
[class.submitting]="reviewSubmitStatus() === 'loading'">
|
||||
@if (reviewSubmitStatus() === 'loading') {
|
||||
<span class="spinner-small"></span>
|
||||
Отправка...
|
||||
} @else {
|
||||
Отправить отзыв
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (reviewSubmitStatus() === 'success') {
|
||||
<div class="status-message success">
|
||||
<span class="icon">✓</span>
|
||||
Спасибо за ваш отзыв!
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (reviewSubmitStatus() === 'error') {
|
||||
<div class="status-message error">
|
||||
<span class="icon">✗</span>
|
||||
Ошибка отправки. Попробуйте позже.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="reviews-list">
|
||||
@if (item()?.callbacks && item()!.callbacks!.length > 0) {
|
||||
@for (callback of item()!.callbacks; track $index) {
|
||||
<div class="review-card">
|
||||
<div class="review-header">
|
||||
<span class="review-author">{{ callback.userID || 'Аноним' }}</span>
|
||||
@if (callback.rating) {
|
||||
<div class="review-rating">{{ getRatingStars(callback.rating) }}</div>
|
||||
}
|
||||
@if (callback.timestamp) {
|
||||
<span class="review-date">{{ formatDate(callback.timestamp) }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (callback.content) {
|
||||
<p class="review-text">{{ callback.content }}</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<p class="no-reviews">Пока нет отзывов</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="questions-section">
|
||||
<h2>Вопросы и ответы ({{ item()!.questions?.length || 0 }})</h2>
|
||||
<div class="questions-list">
|
||||
@if (item()?.questions && item()!.questions!.length > 0) {
|
||||
@for (question of item()!.questions; track $index) {
|
||||
<div class="question-card">
|
||||
<div class="question-text">
|
||||
<strong>В:</strong> {{ question.question }}
|
||||
</div>
|
||||
<div class="answer-text">
|
||||
<strong>О:</strong> {{ question.answer }}
|
||||
</div>
|
||||
<div class="question-votes">
|
||||
<span class="vote-up">👍 {{ question.upvotes }}</span>
|
||||
<span class="vote-down">👎 {{ question.downvotes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<p class="no-questions">Пока нет вопросов</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
1125
src/app/pages/item-detail/item-detail.component.scss
Normal file
1125
src/app/pages/item-detail/item-detail.component.scss
Normal file
File diff suppressed because it is too large
Load Diff
174
src/app/pages/item-detail/item-detail.component.ts
Normal file
174
src/app/pages/item-detail/item-detail.component.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Component, OnInit, OnDestroy, signal, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ApiService, CartService, TelegramService } from '../../services';
|
||||
import { Item } from '../../models';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-item-detail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, FormsModule],
|
||||
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';
|
||||
|
||||
newReview = {
|
||||
rating: 0,
|
||||
comment: '',
|
||||
anonymous: false
|
||||
};
|
||||
|
||||
reviewSubmitStatus = signal<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
|
||||
private routeSubscription?: Subscription;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private apiService: ApiService,
|
||||
private cartService: CartService,
|
||||
private telegramService: TelegramService,
|
||||
private sanitizer: DomSanitizer
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.routeSubscription = this.route.params.subscribe(params => {
|
||||
const id = parseInt(params['id'], 10);
|
||||
this.loadItem(id);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routeSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
loadItem(itemID: number): void {
|
||||
this.loading.set(true);
|
||||
|
||||
this.apiService.getItem(itemID).subscribe({
|
||||
next: (item) => {
|
||||
this.item.set(item);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load item');
|
||||
this.loading.set(false);
|
||||
console.error('Error loading item:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectPhoto(index: number): void {
|
||||
this.selectedPhotoIndex.set(index);
|
||||
}
|
||||
|
||||
addToCart(): void {
|
||||
const currentItem = this.item();
|
||||
if (currentItem) {
|
||||
this.cartService.addItem(currentItem.itemID);
|
||||
}
|
||||
}
|
||||
|
||||
getDiscountedPrice(): number {
|
||||
const currentItem = this.item();
|
||||
if (!currentItem) return 0;
|
||||
return currentItem.price * (1 - currentItem.discount / 100);
|
||||
}
|
||||
|
||||
getSafeHtml(html: string): SafeHtml {
|
||||
return this.sanitizer.sanitize(1, 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 'Сегодня';
|
||||
if (diffDays === 1) return 'Вчера';
|
||||
if (diffDays < 7) return `${diffDays} дн. назад`;
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} нед. назад`;
|
||||
|
||||
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 'Пользователь';
|
||||
}
|
||||
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(),
|
||||
username: this.newReview.anonymous ? null : this.getUserDisplayName(),
|
||||
userId: this.telegramService.getUserId(),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
this.apiService.submitReview(reviewData).subscribe({
|
||||
next: (response) => {
|
||||
this.reviewSubmitStatus.set('success');
|
||||
this.newReview = { rating: 0, comment: '', anonymous: false };
|
||||
|
||||
// Скрыть сообщение через 3 секунды
|
||||
setTimeout(() => {
|
||||
this.reviewSubmitStatus.set('idle');
|
||||
}, 3000);
|
||||
|
||||
// Перезагрузить данные товара через небольшую задержку
|
||||
setTimeout(() => {
|
||||
this.loadItem(currentItem.itemID);
|
||||
}, 500);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error submitting review:', err);
|
||||
this.reviewSubmitStatus.set('error');
|
||||
|
||||
// Скрыть сообщение об ошибке через 5 секунд
|
||||
setTimeout(() => {
|
||||
this.reviewSubmitStatus.set('idle');
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user