fixes
This commit is contained in:
@@ -15,7 +15,7 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
// Кэшируем списки категорий, товары категорий и отдельные товары
|
||||
const isCategoryList = /\/category$/.test(req.url);
|
||||
const isCategoryItems = /\/category\/\d+/.test(req.url);
|
||||
const isItem = /\/item\/\d+/.test(req.url);
|
||||
const isItem = /\/items\/\d+/.test(req.url);
|
||||
if (!isCategoryList && !isCategoryItems && !isItem) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
@@ -735,34 +735,28 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return respond([]);
|
||||
}
|
||||
|
||||
// ── POST /cart (add to cart / create payment)
|
||||
if (url.endsWith('/cart') && req.method === 'POST') {
|
||||
const body = req.body as any;
|
||||
if (body?.amount) {
|
||||
// Payment mock
|
||||
return respond({
|
||||
qrId: 'mock-qr-' + Date.now(),
|
||||
qrStatus: 'CREATED',
|
||||
qrExpirationDate: new Date(Date.now() + 180000).toISOString(),
|
||||
payload: 'https://example.com/pay/mock',
|
||||
qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=mock-payment'
|
||||
}, 300);
|
||||
}
|
||||
return respond({ message: 'Added (mock)' });
|
||||
// ── POST /websession/:id (add to cart)
|
||||
if (url.match(/\/websession\/[^/]+$/) && req.method === 'POST') {
|
||||
return respond({
|
||||
sessionId: 'mock-session',
|
||||
Status: true,
|
||||
cart: req.body
|
||||
});
|
||||
}
|
||||
|
||||
// ── PATCH /cart
|
||||
if (url.endsWith('/cart') && req.method === 'PATCH') {
|
||||
return respond({ message: 'Updated (mock)' });
|
||||
// ── POST /websession/:id/qr (create payment QR)
|
||||
if (url.match(/\/websession\/[^/]+\/qr$/) && req.method === 'POST') {
|
||||
return respond({
|
||||
qrId: 'mock-qr-' + Date.now(),
|
||||
qrStatus: 'NEW',
|
||||
qrExpirationDate: new Date(Date.now() + 180000).toISOString(),
|
||||
Payload: 'https://example.com/pay/mock',
|
||||
qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=mock-payment'
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ── DELETE /cart
|
||||
if (url.endsWith('/cart') && req.method === 'DELETE') {
|
||||
return respond({ message: 'Removed (mock)' });
|
||||
}
|
||||
|
||||
// ── POST /comment
|
||||
if (url.endsWith('/comment') && req.method === 'POST') {
|
||||
// ── POST /items/:id/callback (review)
|
||||
if (url.match(/\/items\/\d+\/callback$/) && req.method === 'POST') {
|
||||
return respond({ message: 'Review submitted (mock)' }, 200);
|
||||
}
|
||||
|
||||
@@ -771,8 +765,8 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return respond({ message: 'Email sent (mock)' }, 200);
|
||||
}
|
||||
|
||||
// ── GET /qr/payment/:id (always return success for testing)
|
||||
if (url.includes('/qr/payment/') && req.method === 'GET') {
|
||||
// ── GET /websession/:id/:qrId (check QR payment status)
|
||||
if (url.match(/\/websession\/[^/]+\/[^/]+$/) && !url.match(/\/websession\/[^/]+\/qr$/) && req.method === 'GET') {
|
||||
return respond({
|
||||
paymentStatus: 'SUCCESS',
|
||||
code: 'SUCCESS',
|
||||
@@ -785,8 +779,7 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
paymentPurpose: '',
|
||||
createDate: new Date().toISOString(),
|
||||
order: 'mock-order',
|
||||
qrExpirationDate: new Date().toISOString(),
|
||||
phoneNumber: ''
|
||||
qrExpirationDate: new Date().toISOString()
|
||||
}, 500);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ItemName } from './item.model';
|
||||
|
||||
export interface Category {
|
||||
categoryID: number;
|
||||
name: string;
|
||||
@@ -5,7 +7,10 @@ export interface Category {
|
||||
icon?: string;
|
||||
wideBanner?: string;
|
||||
itemCount?: number;
|
||||
categoriesCount?: number;
|
||||
priority?: number;
|
||||
names?: ItemName[];
|
||||
translations?: Record<string, CategoryTranslation>;
|
||||
|
||||
// BackOffice API fields
|
||||
id?: string;
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface Question {
|
||||
answer: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
like?: number;
|
||||
dislike?: number;
|
||||
}
|
||||
|
||||
/** Localized name entry from backend */
|
||||
@@ -60,6 +62,16 @@ export interface ItemAttribute {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Item variant detail (price, size, colour per variant) */
|
||||
export interface ItemDetail {
|
||||
color?: string;
|
||||
colour?: string;
|
||||
size?: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
categoryID: number;
|
||||
itemID: number;
|
||||
@@ -95,6 +107,8 @@ export interface Item {
|
||||
subcategoryId?: string;
|
||||
translations?: Record<string, ItemTranslation>;
|
||||
comments?: Comment[];
|
||||
visits?: number;
|
||||
itemDetails?: ItemDetail[];
|
||||
}
|
||||
|
||||
export interface CartItem extends Item {
|
||||
|
||||
@@ -182,37 +182,45 @@ export class CartComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
createPayment(): void {
|
||||
const telegramUsername = this.getTelegramUsername();
|
||||
const userId = this.getUserId();
|
||||
const orderId = this.generateOrderId();
|
||||
const sessionId = this.authService.session()?.sessionId || '';
|
||||
if (!sessionId) {
|
||||
this.paymentStatus.set('timeout');
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentData = {
|
||||
amount: this.totalPrice(),
|
||||
currency: this.langService.currentCurrency(),
|
||||
siteuserID: userId,
|
||||
siteorderID: orderId,
|
||||
redirectUrl: '',
|
||||
telegramUsername: telegramUsername,
|
||||
items: this.items().map((item: CartItem) => ({
|
||||
itemID: item.itemID,
|
||||
price: item.discount > 0
|
||||
? item.price * (1 - item.discount / 100)
|
||||
: item.price,
|
||||
name: item.name,
|
||||
quantity: item.quantity
|
||||
}))
|
||||
};
|
||||
// First sync cart items to server via websession, then create QR
|
||||
const cartItems = this.items().map((item: CartItem) => ({
|
||||
itemID: item.itemID,
|
||||
quantity: item.quantity,
|
||||
colour: item.colour || '',
|
||||
size: item.size || '',
|
||||
price: item.discount > 0
|
||||
? item.price * (1 - item.discount / 100)
|
||||
: item.price,
|
||||
}));
|
||||
|
||||
this.apiService.createPayment(paymentData).subscribe({
|
||||
next: (response) => {
|
||||
this.paymentId.set(response.qrId);
|
||||
this.qrCodeUrl.set(response.qrUrl);
|
||||
this.paymentUrl.set(response.payload);
|
||||
this.paymentStatus.set('waiting');
|
||||
this.startPolling();
|
||||
this.apiService.addToCart(sessionId, cartItems).subscribe({
|
||||
next: () => {
|
||||
this.apiService.createPayment(sessionId).subscribe({
|
||||
next: (response) => {
|
||||
this.paymentId.set(response.qrId);
|
||||
this.qrCodeUrl.set(response.qrUrl);
|
||||
this.paymentUrl.set(response.Payload);
|
||||
this.paymentStatus.set('waiting');
|
||||
this.startPolling();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error creating payment:', err);
|
||||
this.paymentStatus.set('timeout');
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.closePaymentPopup();
|
||||
}, PAYMENT_ERROR_CLOSE_MS);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error creating payment:', err);
|
||||
console.error('Error syncing cart:', err);
|
||||
this.paymentStatus.set('timeout');
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
@@ -228,7 +236,8 @@ export class CartComponent implements OnDestroy {
|
||||
.pipe(
|
||||
take(this.maxChecks), // maximum 36 checks (3 minutes)
|
||||
switchMap(() => {
|
||||
return this.apiService.checkPaymentStatus(this.paymentId());
|
||||
const sessionId = this.authService.session()?.sessionId || '';
|
||||
return this.apiService.checkPaymentStatus(sessionId, this.paymentId());
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
@@ -283,27 +292,6 @@ export class CartComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private getTelegramUsername(): string {
|
||||
if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user) {
|
||||
const user = window.Telegram.WebApp.initDataUnsafe.user;
|
||||
return user.username || 'nontelegram';
|
||||
}
|
||||
return 'nontelegram';
|
||||
}
|
||||
|
||||
private getUserId(): string {
|
||||
if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user) {
|
||||
return window.Telegram.WebApp.initDataUnsafe.user.id.toString();
|
||||
}
|
||||
return `web_${Date.now()}`;
|
||||
}
|
||||
|
||||
private generateOrderId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `order_${timestamp}_${random}`;
|
||||
}
|
||||
|
||||
submitEmail(): void {
|
||||
// Mark both fields as touched
|
||||
this.emailTouched.set(true);
|
||||
|
||||
@@ -48,13 +48,13 @@
|
||||
<a [routerLink]="['/category', cat.categoryID] | langRoute" class="category-card">
|
||||
<div class="category-image">
|
||||
@if (cat.icon) {
|
||||
<img [src]="cat.icon" [alt]="cat.name" loading="lazy" decoding="async" />
|
||||
<img [src]="cat.icon" [alt]="categoryName(cat)" loading="lazy" decoding="async" />
|
||||
} @else {
|
||||
<div class="category-fallback">{{ cat.name.charAt(0) }}</div>
|
||||
<div class="category-fallback">{{ categoryName(cat).charAt(0) }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="category-info">
|
||||
<h3 class="category-name">{{ cat.name }}</h3>
|
||||
<h3 class="category-name">{{ categoryName(cat) }}</h3>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Subscription } from 'rxjs';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../i18n/translate.service';
|
||||
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
|
||||
import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField, getTranslatedCategoryName } from '../../utils/item.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-subcategories',
|
||||
@@ -59,7 +59,7 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
||||
next: (cats) => {
|
||||
this.categories.set(cats);
|
||||
const parent = cats.find(c => c.categoryID === parentID);
|
||||
this.parentName.set(parent ? parent.name : this.i18n.t('home.categoriesTitle'));
|
||||
this.parentName.set(parent ? getTranslatedCategoryName(parent, this.langService.currentLanguage()) : this.i18n.t('home.categoriesTitle'));
|
||||
|
||||
// Check for nested subcategories from API response (backOffice format)
|
||||
const nested = parent?.subcategories || [];
|
||||
@@ -135,4 +135,6 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
||||
readonly getBadgeClass = getBadgeClass;
|
||||
|
||||
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
|
||||
|
||||
categoryName(cat: Category): string { return getTranslatedCategoryName(cat, this.langService.currentLanguage()); }
|
||||
}
|
||||
|
||||
@@ -66,15 +66,15 @@
|
||||
<a [routerLink]="['/category', category.categoryID] | langRoute" class="novo-category-card">
|
||||
<div class="novo-category-image">
|
||||
@if (category.icon) {
|
||||
<img [src]="category.icon" [alt]="category.name" loading="lazy" />
|
||||
<img [src]="category.icon" [alt]="categoryName(category)" loading="lazy" />
|
||||
} @else {
|
||||
<div class="novo-category-placeholder">
|
||||
<span>{{ category.name.charAt(0) }}</span>
|
||||
<span>{{ categoryName(category).charAt(0) }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="novo-category-info">
|
||||
<h3>{{ category.name }}</h3>
|
||||
<h3>{{ categoryName(category) }}</h3>
|
||||
<span class="novo-category-arrow">→</span>
|
||||
</div>
|
||||
</a>
|
||||
@@ -154,15 +154,15 @@
|
||||
[class.dexar-category-card--wide]="isWideCategory(category.categoryID)">
|
||||
<div class="dexar-category-image">
|
||||
@if (isWideCategory(category.categoryID) && category.wideBanner) {
|
||||
<img [src]="category.wideBanner" [alt]="category.name" loading="lazy" decoding="async" />
|
||||
<img [src]="category.wideBanner" [alt]="categoryName(category)" loading="lazy" decoding="async" />
|
||||
} @else if (category.icon) {
|
||||
<img [src]="category.icon" [alt]="category.name" loading="lazy" decoding="async" />
|
||||
<img [src]="category.icon" [alt]="categoryName(category)" loading="lazy" decoding="async" />
|
||||
} @else {
|
||||
<div class="dexar-category-fallback">{{ category.name.charAt(0) }}</div>
|
||||
<div class="dexar-category-fallback">{{ categoryName(category).charAt(0) }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="dexar-category-info">
|
||||
<h3 class="dexar-category-name">{{ category.name }}</h3>
|
||||
<h3 class="dexar-category-name">{{ categoryName(category) }}</h3>
|
||||
<p class="dexar-category-count">{{ 'home.itemsCount' | translate:{ count: getItemCount(category.categoryID) } }}</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Router, RouterLink } from '@angular/router';
|
||||
import { ApiService, LanguageService } from '../../services';
|
||||
import { Category } from '../../models';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { getTranslatedCategoryName } from '../../utils/item.utils';
|
||||
import { ItemsCarouselComponent } from '../../components/items-carousel/items-carousel.component';
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
@@ -123,6 +124,10 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.router.navigate([`/${lang}/search`]);
|
||||
}
|
||||
|
||||
categoryName(cat: Category): string {
|
||||
return getTranslatedCategoryName(cat, this.langService.currentLanguage());
|
||||
}
|
||||
|
||||
scrollToCatalog(): void {
|
||||
const target = document.getElementById('catalog');
|
||||
if (!target) return;
|
||||
|
||||
@@ -3,6 +3,7 @@ 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, DescriptionField } from '../../models';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { Subscription } from 'rxjs';
|
||||
@@ -42,6 +43,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
|
||||
|
||||
private seoService = inject(SeoService);
|
||||
private i18n = inject(TranslateService);
|
||||
private authService = inject(AuthService);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
@@ -207,8 +209,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
|
||||
itemID: currentItem.itemID,
|
||||
rating: this.newReview.rating,
|
||||
comment: this.newReview.comment.trim(),
|
||||
username: this.newReview.anonymous ? null : this.getUserDisplayName(),
|
||||
userId: this.telegramService.getUserId(),
|
||||
sessionID: this.authService.session()?.sessionId || '',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
@@ -18,6 +18,20 @@ export class ApiService {
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
/** Map API language codes (RU/EN/AM) → frontend codes (ru/en/hy) */
|
||||
private normalizeLang(apiLang: string): string {
|
||||
const map: Record<string, string> = { 'RU': 'ru', 'EN': 'en', 'AM': 'hy' };
|
||||
return map[apiLang] || apiLang.toLowerCase();
|
||||
}
|
||||
|
||||
/** Resolve relative image URLs (e.g. ./images/x.webp) against API base */
|
||||
private resolveImageUrl(url: string): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/')) return url;
|
||||
if (url.startsWith('./')) return `${this.baseUrl}/${url.slice(2)}`;
|
||||
return `${this.baseUrl}/${url}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an item from the API response — supports both
|
||||
* legacy marketplace format and the new backOffice API format.
|
||||
@@ -26,6 +40,22 @@ export class ApiService {
|
||||
const { partnerID, ...rest } = raw;
|
||||
const item: Item = { ...rest };
|
||||
|
||||
// Extract price/currency/remaining/colour/size from itemDetails[]
|
||||
// Note: Go struct tag is "itemdetails" but actual API may send "itemDetails"
|
||||
const details = raw.itemDetails || raw.itemdetails;
|
||||
if (details && Array.isArray(details) && details.length > 0) {
|
||||
const detail = details[0];
|
||||
item.itemDetails = raw.itemDetails;
|
||||
if (item.price == null || item.price === 0) item.price = detail.price;
|
||||
if (!item.currency) item.currency = detail.currency;
|
||||
if (!item.colour) item.colour = detail.colour || detail.color || '';
|
||||
if (!item.size) item.size = detail.size || '';
|
||||
// Use remaining from detail for stock level
|
||||
if (raw.remaining == null && detail.remaining != null) {
|
||||
(raw as any).remaining = detail.remaining;
|
||||
}
|
||||
}
|
||||
|
||||
// Map backOffice string id → legacy numeric itemID
|
||||
if (raw.id != null && raw.itemID == null) {
|
||||
item.id = String(raw.id);
|
||||
@@ -37,13 +67,16 @@ export class ApiService {
|
||||
item.photos = raw.imgs.map((url: string) => ({ url }));
|
||||
}
|
||||
// Normalize photo type: API sends type='video'|'photo', template checks .video
|
||||
// Also resolve relative URLs (e.g. ./images/x.webp) against API base
|
||||
if (item.photos) {
|
||||
item.photos = item.photos.map((p: any) => ({
|
||||
...p,
|
||||
url: this.resolveImageUrl(p.url),
|
||||
video: p.video || (p.type === 'video' ? p.url : undefined),
|
||||
}));
|
||||
}
|
||||
item.imgs = raw.imgs || raw.photos?.map((p: any) => p.url) || [];
|
||||
item.imgs = raw.imgs?.map((u: string) => this.resolveImageUrl(u))
|
||||
|| item.photos?.map((p: any) => p.url) || [];
|
||||
|
||||
// Map backOffice description (key-value array) → legacy description string
|
||||
if (Array.isArray(raw.description)) {
|
||||
@@ -54,12 +87,22 @@ export class ApiService {
|
||||
}
|
||||
|
||||
// Map backend names[] → translations (multi-lang name support)
|
||||
// Note: API has typo "valuue" in some responses, handle both
|
||||
if (raw.names && Array.isArray(raw.names)) {
|
||||
item.names = raw.names;
|
||||
if (!item.translations) item.translations = {};
|
||||
for (const entry of raw.names) {
|
||||
if (!item.translations[entry.language]) item.translations[entry.language] = {};
|
||||
item.translations[entry.language].name = entry.value;
|
||||
const lang = this.normalizeLang(entry.language);
|
||||
const val = entry.value || entry.valuue || '';
|
||||
if (val) {
|
||||
if (!item.translations[lang]) item.translations[lang] = {};
|
||||
item.translations[lang].name = val;
|
||||
}
|
||||
}
|
||||
// Fallback: if top-level name is missing, use first available translation
|
||||
if (!item.name && raw.names.length > 0) {
|
||||
const ruName = raw.names.find((n: any) => n.language === 'RU' || n.language === 'ru');
|
||||
item.name = ruName?.value || ruName?.valuue || raw.names[0].value || raw.names[0].valuue || '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,17 +111,18 @@ export class ApiService {
|
||||
item.descriptions = raw.descriptions;
|
||||
if (!item.translations) item.translations = {};
|
||||
for (const entry of raw.descriptions) {
|
||||
if (!item.translations[entry.language]) item.translations[entry.language] = {};
|
||||
item.translations[entry.language].simpleDescription = entry.value;
|
||||
const lang = this.normalizeLang(entry.language);
|
||||
if (!item.translations[lang]) item.translations[lang] = {};
|
||||
item.translations[lang].simpleDescription = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve attributes from backend
|
||||
item.attributes = raw.attributes || [];
|
||||
|
||||
// Preserve colour & size
|
||||
item.colour = raw.colour || '';
|
||||
item.size = raw.size || '';
|
||||
// Preserve colour & size (only if not already set from itemDetails)
|
||||
if (!item.colour) item.colour = raw.colour || '';
|
||||
if (!item.size) item.size = raw.size || '';
|
||||
|
||||
// Map backOffice comments → legacy callbacks
|
||||
if (raw.comments && (!raw.callbacks || raw.callbacks.length === 0)) {
|
||||
@@ -107,8 +151,12 @@ export class ApiService {
|
||||
item.rating = item.rating || 0;
|
||||
|
||||
// Defaults
|
||||
item.name = item.name || '';
|
||||
item.price = item.price ?? 0;
|
||||
item.discount = item.discount || 0;
|
||||
item.remainings = item.remainings || (raw.quantity != null
|
||||
item.remainings = item.remainings || (raw.remaining != null
|
||||
? (raw.remaining <= 0 ? 'out' : raw.remaining <= 5 ? 'low' : raw.remaining <= 20 ? 'medium' : 'high')
|
||||
: raw.quantity != null
|
||||
? (raw.quantity <= 0 ? 'out' : raw.quantity <= 5 ? 'low' : raw.quantity <= 20 ? 'medium' : 'high')
|
||||
: 'high');
|
||||
item.currency = item.currency || 'RUB';
|
||||
@@ -120,6 +168,16 @@ export class ApiService {
|
||||
item.translations = item.translations || raw.translations || {};
|
||||
item.visible = raw.visible ?? true;
|
||||
item.priority = raw.priority ?? 0;
|
||||
item.visits = raw.visits ?? 0;
|
||||
|
||||
// Map question like/dislike → upvotes/downvotes
|
||||
if (item.questions) {
|
||||
item.questions = item.questions.map((q: any) => ({
|
||||
...q,
|
||||
upvotes: q.upvotes ?? q.like ?? 0,
|
||||
downvotes: q.downvotes ?? q.dislike ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
@@ -149,9 +207,41 @@ export class ApiService {
|
||||
}
|
||||
cat.img = raw.img || raw.icon;
|
||||
|
||||
// Resolve relative icon/image URLs
|
||||
if (cat.icon) cat.icon = this.resolveImageUrl(cat.icon);
|
||||
if (cat.img) cat.img = this.resolveImageUrl(cat.img);
|
||||
|
||||
// Map backend wideicon → wideBanner
|
||||
if (raw.wideicon && !cat.wideBanner) {
|
||||
cat.wideBanner = raw.wideicon;
|
||||
}
|
||||
|
||||
cat.parentID = raw.parentID ?? 0;
|
||||
cat.visible = raw.visible ?? true;
|
||||
cat.priority = raw.priority ?? 0;
|
||||
cat.itemCount = raw.itemCount ?? raw.ItemsCount ?? 0;
|
||||
cat.categoriesCount = raw.categoriesCount ?? raw.CategoriesCount ?? 0;
|
||||
|
||||
// Map backend names[] → translations (multi-lang name support)
|
||||
// Note: API has typo "valuue" in some responses, handle both
|
||||
if (raw.names && Array.isArray(raw.names)) {
|
||||
cat.names = raw.names;
|
||||
cat.translations = cat.translations || {};
|
||||
for (const entry of raw.names) {
|
||||
const lang = this.normalizeLang(entry.language);
|
||||
const val = entry.value || entry.valuue || '';
|
||||
if (val) {
|
||||
if (!cat.translations[lang]) cat.translations[lang] = {};
|
||||
cat.translations[lang].name = val;
|
||||
}
|
||||
}
|
||||
// Fallback: if top-level name is missing, use first available translation
|
||||
if (!cat.name && raw.names.length > 0) {
|
||||
const ruName = raw.names.find((n: any) => n.language === 'RU' || n.language === 'ru');
|
||||
cat.name = ruName?.value || ruName?.valuue || raw.names[0].value || raw.names[0].valuue || '';
|
||||
}
|
||||
}
|
||||
cat.name = cat.name || '';
|
||||
|
||||
if (raw.subcategories && Array.isArray(raw.subcategories)) {
|
||||
cat.subcategories = raw.subcategories;
|
||||
@@ -185,15 +275,41 @@ export class ApiService {
|
||||
}
|
||||
|
||||
getItem(itemID: number): Observable<Item> {
|
||||
return this.http.get<any>(`${this.baseUrl}/item/${itemID}`)
|
||||
return this.http.get<any>(`${this.baseUrl}/items/${itemID}`)
|
||||
.pipe(retry(this.retryConfig), map(item => this.normalizeItem(item)));
|
||||
}
|
||||
|
||||
searchItems(search: string, count: number = 50, skip: number = 0): Observable<{ items: Item[], total: number }> {
|
||||
const params = new HttpParams()
|
||||
searchItems(
|
||||
search: string,
|
||||
count: number = 50,
|
||||
skip: number = 0,
|
||||
options?: {
|
||||
categoryIDs?: number[];
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
tag?: string;
|
||||
sort?: 'relevance' | 'price_asc' | 'price_desc' | 'popular' | 'rating';
|
||||
}
|
||||
): Observable<{ items: Item[], total: number }> {
|
||||
let params = new HttpParams()
|
||||
.set('search', search)
|
||||
.set('count', count.toString())
|
||||
.set('skip', skip.toString());
|
||||
if (options?.categoryIDs?.length) {
|
||||
params = params.set('categoryIDs', options.categoryIDs.join(','));
|
||||
}
|
||||
if (options?.minPrice != null) {
|
||||
params = params.set('minPrice', options.minPrice.toString());
|
||||
}
|
||||
if (options?.maxPrice != null) {
|
||||
params = params.set('maxPrice', options.maxPrice.toString());
|
||||
}
|
||||
if (options?.tag) {
|
||||
params = params.set('tag', options.tag);
|
||||
}
|
||||
if (options?.sort) {
|
||||
params = params.set('sort', options.sort);
|
||||
}
|
||||
return this.http.get<any>(`${this.baseUrl}/searchitems`, { params })
|
||||
.pipe(
|
||||
retry(this.retryConfig),
|
||||
@@ -204,21 +320,9 @@ export class ApiService {
|
||||
);
|
||||
}
|
||||
|
||||
addToCart(itemID: number, quantity: number = 1): Observable<{ message: string }> {
|
||||
return this.http.post<{ message: string }>(`${this.baseUrl}/cart`, { itemID, quantity });
|
||||
}
|
||||
|
||||
updateCartQuantity(itemID: number, quantity: number): Observable<{ message: string }> {
|
||||
return this.http.patch<{ message: string }>(`${this.baseUrl}/cart`, { itemID, quantity });
|
||||
}
|
||||
|
||||
removeFromCart(itemIDs: number[]): Observable<{ message: string }> {
|
||||
return this.http.delete<{ message: string }>(`${this.baseUrl}/cart`, { body: itemIDs });
|
||||
}
|
||||
|
||||
getCart(): Observable<Item[]> {
|
||||
return this.http.get<any[]>(`${this.baseUrl}/cart`)
|
||||
.pipe(map(items => this.normalizeItems(items)));
|
||||
// Cart operations — spec uses websession-based paths
|
||||
addToCart(sessionId: string, items: Array<{ itemID: number; quantity: number; colour?: string; size?: string; price?: number }>): Observable<any> {
|
||||
return this.http.post<any>(`${this.baseUrl}/websession/${sessionId}`, items);
|
||||
}
|
||||
|
||||
// Review submission
|
||||
@@ -226,39 +330,42 @@ export class ApiService {
|
||||
itemID: number;
|
||||
rating: number;
|
||||
comment: string;
|
||||
username: string | null;
|
||||
userId: number | null;
|
||||
sessionID: string;
|
||||
timestamp: string;
|
||||
}): Observable<{ message: string }> {
|
||||
return this.http.post<{ message: string }>(`${this.baseUrl}/comment`, reviewData);
|
||||
const { itemID, ...body } = reviewData;
|
||||
return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/callback`, body);
|
||||
}
|
||||
|
||||
// Payment - SBP Integration
|
||||
createPayment(paymentData: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
siteuserID: string;
|
||||
siteorderID: string;
|
||||
redirectUrl: string;
|
||||
telegramUsername: string;
|
||||
items: Array<{ itemID: number; price: number; name: string }>;
|
||||
}): Observable<{
|
||||
// Question submission — spec path has typo "questiion"
|
||||
submitQuestion(questionData: {
|
||||
itemID: number;
|
||||
question: string;
|
||||
sessionID: string;
|
||||
timestamp: string;
|
||||
}): Observable<{ message: string }> {
|
||||
const { itemID, ...body } = questionData;
|
||||
return this.http.post<{ message: string }>(`${this.baseUrl}/items/${itemID}/questiion`, body);
|
||||
}
|
||||
|
||||
// Payment - SBP Integration via websession QR
|
||||
createPayment(sessionId: string): Observable<{
|
||||
qrId: string;
|
||||
qrStatus: string;
|
||||
qrExpirationDate: string;
|
||||
payload: string;
|
||||
Payload: string;
|
||||
qrUrl: string;
|
||||
}> {
|
||||
return this.http.post<{
|
||||
qrId: string;
|
||||
qrStatus: string;
|
||||
qrExpirationDate: string;
|
||||
payload: string;
|
||||
Payload: string;
|
||||
qrUrl: string;
|
||||
}>(`${this.baseUrl}/cart`, paymentData);
|
||||
}>(`${this.baseUrl}/websession/${sessionId}/qr`, {});
|
||||
}
|
||||
|
||||
checkPaymentStatus(qrId: string): Observable<{
|
||||
checkPaymentStatus(sessionId: string, qrId: string): Observable<{
|
||||
additionalInfo: string;
|
||||
paymentPurpose: string;
|
||||
amount: number;
|
||||
@@ -271,7 +378,6 @@ export class ApiService {
|
||||
transactionDate: string;
|
||||
transactionId: number;
|
||||
qrExpirationDate: string;
|
||||
phoneNumber: string;
|
||||
}> {
|
||||
return this.http.get<{
|
||||
additionalInfo: string;
|
||||
@@ -286,8 +392,7 @@ export class ApiService {
|
||||
transactionDate: string;
|
||||
transactionId: number;
|
||||
qrExpirationDate: string;
|
||||
phoneNumber: string;
|
||||
}>(`${this.baseUrl}/qr/payment/${qrId}`);
|
||||
}>(`${this.baseUrl}/websession/${sessionId}/${qrId}`);
|
||||
}
|
||||
|
||||
submitPurchaseEmail(emailData: {
|
||||
@@ -303,7 +408,7 @@ export class ApiService {
|
||||
if (categoryID) {
|
||||
params = params.set('category', categoryID.toString());
|
||||
}
|
||||
return this.http.get<any[]>(`${this.baseUrl}/randomitems`, { params })
|
||||
return this.http.get<any[]>(`${this.baseUrl}/items/randomitems`, { params })
|
||||
.pipe(retry(this.retryConfig), map(items => this.normalizeItems(items)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export class AuthService {
|
||||
|
||||
/** Generate the Telegram login URL for bot-based auth */
|
||||
getTelegramLoginUrl(): string {
|
||||
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'dexarmarket_bot';
|
||||
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
|
||||
const callbackUrl = encodeURIComponent(`${this.apiUrl}/auth/telegram/callback`);
|
||||
return `https://t.me/${botUsername}?start=auth_${callbackUrl}`;
|
||||
}
|
||||
|
||||
@@ -19,11 +19,11 @@ export class SeoService {
|
||||
* Set Open Graph & Twitter Card meta tags for a product/item page.
|
||||
*/
|
||||
setItemMeta(item: Item): void {
|
||||
const price = item.discount > 0 ? getDiscountedPrice(item) : item.price;
|
||||
const price = item.discount > 0 ? getDiscountedPrice(item) : (item.price ?? 0);
|
||||
const imageUrl = this.resolveUrl(getMainImage(item));
|
||||
const itemUrl = `${this.siteUrl}/item/${item.itemID}`;
|
||||
const description = this.truncate(this.stripHtml(item.description), 160);
|
||||
const titleText = `${item.name} — ${this.siteName}`;
|
||||
const description = this.truncate(this.stripHtml(item.description || ''), 160);
|
||||
const titleText = `${item.name || 'Product'} — ${this.siteName}`;
|
||||
|
||||
this.title.setTitle(titleText);
|
||||
this.setCanonical(itemUrl);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Item } from '../models';
|
||||
import { Category } from '../models/category.model';
|
||||
|
||||
export function getDiscountedPrice(item: Item): number {
|
||||
return item.price * (1 - (item.discount || 0) / 100);
|
||||
@@ -69,20 +70,23 @@ export function getTranslatedField(
|
||||
field: 'name' | 'simpleDescription',
|
||||
lang: string
|
||||
): string {
|
||||
// 1. Check translations map (backOffice format)
|
||||
// 1. Check translations map (already normalized to frontend codes)
|
||||
const translation = item.translations?.[lang];
|
||||
if (translation && translation[field]) {
|
||||
return translation[field]!;
|
||||
}
|
||||
|
||||
// 2. Check names[]/descriptions[] arrays (backend API format)
|
||||
// 2. Check names[]/descriptions[] arrays (may have API codes: RU/EN/AM)
|
||||
// Note: API has typo "valuue" in some responses — handle both
|
||||
if (field === 'name' && item.names?.length) {
|
||||
const entry = item.names.find(n => n.language === lang);
|
||||
if (entry) return entry.value;
|
||||
const entry = item.names.find(n => n.language === lang || n.language === lang.toUpperCase() || (lang === 'hy' && n.language === 'AM'));
|
||||
const val = entry?.value || (entry as any)?.valuue || '';
|
||||
if (val) return val;
|
||||
}
|
||||
if (field === 'simpleDescription' && item.descriptions?.length) {
|
||||
const entry = item.descriptions.find(d => d.language === lang);
|
||||
if (entry) return entry.value;
|
||||
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
|
||||
@@ -90,3 +94,19 @@ export function getTranslatedField(
|
||||
if (field === 'simpleDescription') return item.simpleDescription || item.description || '';
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translated category name for the current language.
|
||||
*/
|
||||
export function getTranslatedCategoryName(cat: Category, lang: string): string {
|
||||
const translation = cat.translations?.[lang];
|
||||
if (translation?.name) return translation.name;
|
||||
|
||||
if (cat.names?.length) {
|
||||
const entry = cat.names.find(n => n.language === lang || n.language === lang.toUpperCase() || (lang === 'hy' && n.language === 'AM'));
|
||||
const val = entry?.value || (entry as any)?.valuue || '';
|
||||
if (val) return val;
|
||||
}
|
||||
|
||||
return cat.name || '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user