This commit is contained in:
sdarbinyan
2026-03-24 02:46:58 +04:00
parent 5ed255dddb
commit 44553f5bd4
6 changed files with 163 additions and 48 deletions

View File

@@ -82,12 +82,12 @@
<div class="novo-price-block"> <div class="novo-price-block">
@if (item()!.discount > 0) { @if (item()!.discount > 0) {
<div class="price-row"> <div class="price-row">
<span class="old-price">{{ item()!.price }} {{ item()!.currency }}</span> <span class="old-price">{{ effectivePrice() }} {{ effectiveCurrency() }}</span>
<span class="discount-badge">-{{ item()!.discount }}%</span> <span class="discount-badge">-{{ item()!.discount }}%</span>
</div> </div>
<div class="current-price">{{ getDiscountedPrice() | number:'1.2-2' }} {{ item()!.currency }}</div> <div class="current-price">{{ getDiscountedPrice() | number:'1.2-2' }} {{ effectiveCurrency() }}</div>
} @else { } @else {
<div class="current-price">{{ item()!.price }} {{ item()!.currency }}</div> <div class="current-price">{{ effectivePrice() }} {{ effectiveCurrency() }}</div>
} }
</div> </div>
@@ -97,23 +97,37 @@
<span class="dot"></span> <span class="dot"></span>
{{ getStockLabel() }} {{ getStockLabel() }}
</div> </div>
@if (item()!.quantity != null) { @if (effectiveRemaining() != null) {
<span class="stock-qty">({{ item()!.quantity }} шт.)</span> <span class="stock-qty">({{ effectiveRemaining() }} шт.)</span>
} }
</div> </div>
@if (item()!.colour || item()!.size) { @if (availableColours().length || availableSizes().length || item()!.colour || item()!.size) {
<div class="novo-variants"> <div class="novo-variants">
@if (item()!.colour) { @if (availableColours().length) {
<div class="variant-group"> <div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span> <span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span>
<span class="variant-chip colour-chip">{{ item()!.colour }}</span> @for (c of availableColours(); track c) {
<span class="variant-chip colour-chip" [class.active]="selectedColour() === c" (click)="selectColour(c)">{{ c }}</span>
}
</div>
} @else if (item()!.colour) {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span>
<span class="variant-chip colour-chip active">{{ item()!.colour }}</span>
</div> </div>
} }
@if (item()!.size) { @if (availableSizes().length) {
<div class="variant-group"> <div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}:</span> <span class="variant-label">{{ 'itemDetail.size' | translate }}:</span>
<span class="variant-chip size-chip">{{ item()!.size }}</span> @for (s of availableSizes(); track s) {
<span class="variant-chip size-chip" [class.active]="selectedSize() === s" (click)="selectSize(s)">{{ s }}</span>
}
</div>
} @else if (item()!.size) {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}:</span>
<span class="variant-chip size-chip active">{{ item()!.size }}</span>
</div> </div>
} }
</div> </div>
@@ -347,12 +361,12 @@
<div class="dx-price-block"> <div class="dx-price-block">
@if (item()!.discount > 0) { @if (item()!.discount > 0) {
<div class="dx-price-row"> <div class="dx-price-row">
<span class="dx-old-price">{{ item()!.price }} {{ item()!.currency }}</span> <span class="dx-old-price">{{ effectivePrice() }} {{ effectiveCurrency() }}</span>
<span class="dx-discount-tag">-{{ item()!.discount }}%</span> <span class="dx-discount-tag">-{{ item()!.discount }}%</span>
</div> </div>
} }
<div class="dx-current-price"> <div class="dx-current-price">
{{ item()!.discount > 0 ? (getDiscountedPrice() | number:'1.2-2') : item()!.price }} {{ item()!.currency }} {{ item()!.discount > 0 ? (getDiscountedPrice() | number:'1.2-2') : effectivePrice() }} {{ effectiveCurrency() }}
</div> </div>
</div> </div>
@@ -362,23 +376,37 @@
<span class="dx-stock-dot"></span> <span class="dx-stock-dot"></span>
{{ getStockLabel() }} {{ getStockLabel() }}
</span> </span>
@if (item()!.quantity != null) { @if (effectiveRemaining() != null) {
<span class="dx-stock-qty">({{ item()!.quantity }} шт.)</span> <span class="dx-stock-qty">({{ effectiveRemaining() }} шт.)</span>
} }
</div> </div>
@if (item()!.colour || item()!.size) { @if (availableColours().length || availableSizes().length || item()!.colour || item()!.size) {
<div class="dx-variants"> <div class="dx-variants">
@if (item()!.colour) { @if (availableColours().length) {
<div class="variant-group"> <div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span> <span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span>
<span class="variant-chip colour-chip">{{ item()!.colour }}</span> @for (c of availableColours(); track c) {
<span class="variant-chip colour-chip" [class.active]="selectedColour() === c" (click)="selectColour(c)">{{ c }}</span>
}
</div>
} @else if (item()!.colour) {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.colour' | translate }}:</span>
<span class="variant-chip colour-chip active">{{ item()!.colour }}</span>
</div> </div>
} }
@if (item()!.size) { @if (availableSizes().length) {
<div class="variant-group"> <div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}:</span> <span class="variant-label">{{ 'itemDetail.size' | translate }}:</span>
<span class="variant-chip size-chip">{{ item()!.size }}</span> @for (s of availableSizes(); track s) {
<span class="variant-chip size-chip" [class.active]="selectedSize() === s" (click)="selectSize(s)">{{ s }}</span>
}
</div>
} @else if (item()!.size) {
<div class="variant-group">
<span class="variant-label">{{ 'itemDetail.size' | translate }}:</span>
<span class="variant-chip size-chip active">{{ item()!.size }}</span>
</div> </div>
} }
</div> </div>
@@ -405,7 +433,8 @@
</button> </button>
<div class="dx-description"> <div class="dx-description">
@if (getSimpleDescription()) { <!-- @if (getSimpleDescription()) { -->
@if (false) {
<p class="dx-simple-desc">{{ getSimpleDescription() }}</p> <p class="dx-simple-desc">{{ getSimpleDescription() }}</p>
} }

View File

@@ -322,6 +322,18 @@ $dx-card-bg: #f5f3f9;
border: 1.5px solid $dx-border; border: 1.5px solid $dx-border;
background: rgba(73, 118, 113, 0.06); background: rgba(73, 118, 113, 0.06);
color: $dx-primary; color: $dx-primary;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
&:hover {
background: rgba(73, 118, 113, 0.12);
}
&.active {
border-color: $dx-primary;
background: rgba(73, 118, 113, 0.18);
box-shadow: 0 0 0 2px rgba(73, 118, 113, 0.25);
}
} }
} }

View File

@@ -1,10 +1,10 @@
import { Component, OnInit, OnDestroy, signal, ChangeDetectionStrategy, inject } from '@angular/core'; import { Component, OnInit, OnDestroy, signal, computed, ChangeDetectionStrategy, inject } from '@angular/core';
import { DecimalPipe } from '@angular/common'; import { DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService, CartService, TelegramService, LanguageService, SeoService } from '../../services'; import { ApiService, CartService, TelegramService, LanguageService, SeoService } from '../../services';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { Item, DescriptionField } from '../../models'; import { Item, ItemDetail, DescriptionField } from '../../models';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
@@ -27,6 +27,55 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
loading = signal(true); loading = signal(true);
error = signal<string | null>(null); error = signal<string | null>(null);
isnovo = environment.theme === 'novo'; 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))];
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 = { newReview = {
rating: 0, rating: 0,
@@ -75,6 +124,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
this.apiService.getItem(itemID).subscribe({ this.apiService.getItem(itemID).subscribe({
next: (item) => { next: (item) => {
this.item.set(item); this.item.set(item);
this.initVariantSelection(item);
this.seoService.setItemMeta(item); this.seoService.setItemMeta(item);
this.loading.set(false); this.loading.set(false);
}, },
@@ -86,6 +136,33 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
}); });
} }
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 { selectPhoto(index: number): void {
this.selectedPhotoIndex.set(index); this.selectedPhotoIndex.set(index);
} }
@@ -93,14 +170,21 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
addToCart(): void { addToCart(): void {
const currentItem = this.item(); const currentItem = this.item();
if (currentItem) { if (currentItem) {
this.cartService.addItem(currentItem.itemID); this.cartService.addItem(currentItem.itemID, 1, {
colour: this.selectedColour() ?? undefined,
size: this.selectedSize() ?? undefined,
price: this.effectivePrice(),
currency: this.effectiveCurrency()
});
} }
} }
getDiscountedPrice(): number { getDiscountedPrice(): number {
const currentItem = this.item(); const currentItem = this.item();
if (!currentItem) return 0; if (!currentItem) return 0;
return getDiscountedPrice(currentItem); const price = this.effectivePrice();
const discount = currentItem.discount || 0;
return discount > 0 ? price * (1 - discount / 100) : price;
} }
// BackOffice integration helpers // BackOffice integration helpers
@@ -115,8 +199,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
getSimpleDescription(): string { getSimpleDescription(): string {
const currentItem = this.item(); const currentItem = this.item();
if (!currentItem) return ''; if (!currentItem) return '';
const lang = this.languageService.currentLanguage(); return currentItem.simpleDescription || currentItem.description || '';
return getTranslatedField(currentItem, 'simpleDescription', lang);
} }
hasDescriptionFields(): boolean { hasDescriptionFields(): boolean {

View File

@@ -24,12 +24,13 @@ export class ApiService {
return map[apiLang] || apiLang.toLowerCase(); return map[apiLang] || apiLang.toLowerCase();
} }
/** Resolve relative image URLs (e.g. ./images/x.webp) against API base */ /** Resolve relative image URLs (e.g. ./images/x.webp) against site origin */
private resolveImageUrl(url: string): string { private resolveImageUrl(url: string): string {
if (!url) return ''; if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) return url; if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) return url;
if (url.startsWith('./')) return `${this.baseUrl}/${url.slice(2)}`; const origin = `https://${environment.domain}`;
return `${this.baseUrl}/${url}`; if (url.startsWith('./')) return `${origin}/${url.slice(2)}`;
return `${origin}/${url}`;
} }
/** /**
@@ -106,17 +107,6 @@ export class ApiService {
} }
} }
// Map backend descriptions[] → translations (multi-lang descriptions)
if (raw.descriptions && Array.isArray(raw.descriptions)) {
item.descriptions = raw.descriptions;
if (!item.translations) item.translations = {};
for (const entry of raw.descriptions) {
const lang = this.normalizeLang(entry.language);
if (!item.translations[lang]) item.translations[lang] = {};
item.translations[lang].simpleDescription = entry.value;
}
}
// Preserve attributes from backend // Preserve attributes from backend
item.attributes = raw.attributes || []; item.attributes = raw.attributes || [];

View File

@@ -103,7 +103,7 @@ export class CartService {
return false; return false;
} }
addItem(itemID: number, quantity: number = 1): void { addItem(itemID: number, quantity: number = 1, variant?: { colour?: string; size?: string; price?: number; currency?: string }): void {
// Prevent duplicate API calls for same item // Prevent duplicate API calls for same item
if (this.addingItems.has(itemID)) return; if (this.addingItems.has(itemID)) return;
@@ -118,7 +118,14 @@ export class CartService {
this.addingItems.add(itemID); this.addingItems.add(itemID);
this.apiService.getItem(itemID).subscribe({ this.apiService.getItem(itemID).subscribe({
next: (item) => { next: (item) => {
const cartItem: CartItem = { ...item, quantity }; const cartItem: CartItem = {
...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.cartItems.set([...this.cartItems(), cartItem]);
this.addingItems.delete(itemID); this.addingItems.delete(itemID);
}, },

View File

@@ -83,12 +83,6 @@ export function getTranslatedField(
const val = entry?.value || (entry as any)?.valuue || ''; const val = entry?.value || (entry as any)?.valuue || '';
if (val) return val; if (val) return val;
} }
if (field === 'simpleDescription' && item.descriptions?.length) {
const entry = item.descriptions.find(d => d.language === lang || d.language === lang.toUpperCase() || (lang === 'hy' && d.language === 'AM'));
const val = entry?.value || (entry as any)?.valuue || '';
if (val) return val;
}
// 3. Fallback to base field // 3. Fallback to base field
if (field === 'name') return item.name; if (field === 'name') return item.name;
if (field === 'simpleDescription') return item.simpleDescription || item.description || ''; if (field === 'simpleDescription') return item.simpleDescription || item.description || '';