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

@@ -1,6 +1,7 @@
import { Injectable, signal, computed, effect } from '@angular/core';
import { ApiService } from './api.service';
import { Item, CartItem } from '../models';
import { getDiscountedPrice } from '../utils/item.utils';
import { environment } from '../../environments/environment';
import type { } from '../types/telegram.types';
@@ -11,6 +12,7 @@ export class CartService {
private readonly STORAGE_KEY = `${environment.brandName.toLowerCase().replace(/\s+/g, '_')}_cart`;
private cartItems = signal<CartItem[]>([]);
private isTelegram = typeof window !== 'undefined' && !!window.Telegram?.WebApp;
private addingItems = new Set<number>();
items = this.cartItems.asReadonly();
itemCount = computed(() => {
@@ -22,8 +24,7 @@ export class CartService {
const items = this.cartItems();
if (!Array.isArray(items)) return 0;
return items.reduce((total, item) => {
const price = item.price * (1 - (item.discount || 0) / 100);
return total + (price * item.quantity);
return total + (getDiscountedPrice(item) * item.quantity);
}, 0);
});
@@ -40,8 +41,8 @@ export class CartService {
private saveToStorage(items: CartItem[]): void {
const data = JSON.stringify(items);
// Always save to sessionStorage
sessionStorage.setItem(this.STORAGE_KEY, data);
// Always save to localStorage
localStorage.setItem(this.STORAGE_KEY, data);
// Also save to Telegram CloudStorage if available
if (this.isTelegram) {
@@ -59,21 +60,21 @@ export class CartService {
window.Telegram!.WebApp.CloudStorage.getItem(this.STORAGE_KEY, (err, value) => {
if (err) {
console.error('Error loading from Telegram CloudStorage:', err);
this.loadFromSessionStorage();
this.loadFromLocalStorage();
} else if (value) {
this.parseAndSetCart(value) || this.loadFromSessionStorage();
this.parseAndSetCart(value) || this.loadFromLocalStorage();
} else {
// No data in CloudStorage, try sessionStorage
this.loadFromSessionStorage();
// No data in CloudStorage, try localStorage
this.loadFromLocalStorage();
}
});
} else {
this.loadFromSessionStorage();
this.loadFromLocalStorage();
}
}
private loadFromSessionStorage(): void {
const stored = sessionStorage.getItem(this.STORAGE_KEY);
private loadFromLocalStorage(): void {
const stored = localStorage.getItem(this.STORAGE_KEY);
if (stored) {
this.parseAndSetCart(stored);
}
@@ -98,6 +99,9 @@ export class CartService {
}
addItem(itemID: number, quantity: number = 1): void {
// Prevent duplicate API calls for same item
if (this.addingItems.has(itemID)) return;
const currentItems = this.cartItems();
const existingItem = currentItems.find(i => i.itemID === itemID);
@@ -106,14 +110,16 @@ export class CartService {
this.updateQuantity(itemID, existingItem.quantity + quantity);
} else {
// Get item details from API and add to cart
this.addingItems.add(itemID);
this.apiService.getItem(itemID).subscribe({
next: (item) => {
const cartItem: CartItem = { ...item, quantity };
this.cartItems.set([...this.cartItems(), cartItem]);
this.addingItems.delete(itemID);
},
error: (err) => {
console.error('Error adding to cart:', err);
alert('Ошибка добавления в корзину: ' + (err.error?.message || err.message));
this.addingItems.delete(itemID);
}
});
}

View File

@@ -2,3 +2,4 @@ export * from './api.service';
export * from './cart.service';
export * from './telegram.service';
export * from './language.service';
export * from './seo.service';

View File

@@ -1,4 +1,5 @@
import { Injectable, signal } from '@angular/core';
import { Router } from '@angular/router';
export interface Language {
code: string;
@@ -16,13 +17,13 @@ export class LanguageService {
languages: Language[] = [
{ code: 'ru', name: 'Русский', flag: '🇷🇺', flagSvg: '/flags/ru.svg', enabled: true },
{ code: 'en', name: 'English', flag: '🇬🇧', flagSvg: '/flags/en.svg', enabled: false },
{ code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: false }
{ code: 'en', name: 'English', flag: '🇬🇧', flagSvg: '/flags/en.svg', enabled: true },
{ code: 'hy', name: 'Հայերեն', flag: '🇦🇲', flagSvg: '/flags/arm.svg', enabled: true }
];
currentLanguage = this.currentLanguageSignal.asReadonly();
constructor() {
constructor(private router: Router) {
// Load saved language from localStorage
const savedLang = localStorage.getItem('selectedLanguage');
if (savedLang && this.languages.find(l => l.code === savedLang && l.enabled)) {
@@ -38,6 +39,19 @@ export class LanguageService {
}
}
/** Change language and navigate to the same page with the new prefix */
switchLanguage(langCode: string): void {
const lang = this.languages.find(l => l.code === langCode);
if (!lang?.enabled) return;
const currentUrl = this.router.url;
const currentLang = this.currentLanguageSignal();
const newUrl = currentUrl.replace(new RegExp(`^/${currentLang}`), `/${langCode}`);
this.setLanguage(langCode);
this.router.navigateByUrl(newUrl);
}
getCurrentLanguage(): Language | undefined {
return this.languages.find(l => l.code === this.currentLanguageSignal());
}

View File

@@ -0,0 +1,117 @@
import { Injectable, inject } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { environment } from '../../environments/environment';
import { Item } from '../models';
import { getDiscountedPrice, getMainImage } from '../utils/item.utils';
@Injectable({
providedIn: 'root'
})
export class SeoService {
private meta = inject(Meta);
private title = inject(Title);
private readonly siteUrl = `https://${environment.domain}`;
private readonly siteName = environment.brandFullName;
/**
* 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 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}`;
this.title.setTitle(titleText);
this.setOrUpdate([
// Open Graph
{ property: 'og:type', content: 'product' },
{ property: 'og:title', content: item.name },
{ property: 'og:description', content: description },
{ property: 'og:image', content: imageUrl },
{ property: 'og:url', content: itemUrl },
{ property: 'og:site_name', content: this.siteName },
{ property: 'og:locale', content: 'ru_RU' },
// Product-specific OG tags
{ property: 'product:price:amount', content: price.toFixed(2) },
{ property: 'product:price:currency', content: item.currency || 'RUB' },
// Twitter Card
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: item.name },
{ name: 'twitter:description', content: description },
{ name: 'twitter:image', content: imageUrl },
// Standard meta
{ name: 'description', content: description },
]);
}
/**
* Reset meta tags back to defaults (call on navigation away from item page).
*/
resetToDefaults(): void {
const defaultTitle = `${this.siteName} — Marketplace`;
const defaultDescription = 'Modern marketplace for buying digital goods. Wide selection, convenient search, fast delivery.';
const defaultImage = `${this.siteUrl}/og-image.jpg`;
this.title.setTitle(defaultTitle);
this.setOrUpdate([
{ property: 'og:type', content: 'website' },
{ property: 'og:title', content: defaultTitle },
{ property: 'og:description', content: defaultDescription },
{ property: 'og:image', content: defaultImage },
{ property: 'og:url', content: this.siteUrl },
{ property: 'og:site_name', content: this.siteName },
{ property: 'og:locale', content: 'ru_RU' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: defaultTitle },
{ name: 'twitter:description', content: defaultDescription },
{ name: 'twitter:image', content: defaultImage },
{ name: 'description', content: defaultDescription },
]);
// Remove product-specific tags
this.meta.removeTag("property='product:price:amount'");
this.meta.removeTag("property='product:price:currency'");
}
private setOrUpdate(tags: Array<{ property?: string; name?: string; content: string }>): void {
for (const tag of tags) {
const selector = tag.property
? `property='${tag.property}'`
: `name='${tag.name}'`;
const existing = this.meta.getTag(selector);
if (existing) {
this.meta.updateTag(tag as any, selector);
} else {
this.meta.addTag(tag as any);
}
}
}
/** Convert relative URLs to absolute */
private resolveUrl(url: string): string {
if (!url || url.startsWith('http')) return url;
return `${this.siteUrl}${url.startsWith('/') ? '' : '/'}${url}`;
}
/** Strip HTML tags from a string */
private stripHtml(html: string): string {
return html?.replace(/<[^>]*>/g, '') || '';
}
/** Truncate text to maxLength, adding ellipsis */
private truncate(text: string, maxLength: number): string {
if (!text || text.length <= maxLength) return text || '';
return text.substring(0, maxLength - 1) + '…';
}
}

View File

@@ -34,6 +34,6 @@ export class TelegramService {
}
getDisplayName(): string {
return this.getUsername() || this.getFullName() || 'Пользователь';
return this.getUsername() || this.getFullName() || 'User';
}
}