cleaned up

This commit is contained in:
sdarbinyan
2026-06-21 23:42:39 +04:00
parent 3b802b7c7b
commit 4fb918f5e4
25 changed files with 415 additions and 550 deletions

View File

@@ -40,6 +40,10 @@
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.ts"
},
{
"replace": "src/app/interceptors/mock-data.interceptor.ts",
"with": "src/app/interceptors/mock-data.interceptor.production.ts"
}
],
"styles": [
@@ -49,7 +53,7 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumWarning": "600kB",
"maximumError": "1MB"
},
{
@@ -92,6 +96,10 @@
{
"replace": "src/app/brands/brand-routes.ts",
"with": "src/app/brands/brand-routes.novo.ts"
},
{
"replace": "src/app/interceptors/mock-data.interceptor.ts",
"with": "src/app/interceptors/mock-data.interceptor.production.ts"
}
],
"index": "src/index.novo.html",
@@ -124,7 +132,7 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumWarning": "600kB",
"maximumError": "1MB"
},
{
@@ -158,6 +166,10 @@
{
"replace": "src/app/brands/brand-routes.ts",
"with": "src/app/brands/brand-routes.lavero.ts"
},
{
"replace": "src/app/interceptors/mock-data.interceptor.ts",
"with": "src/app/interceptors/mock-data.interceptor.production.ts"
}
],
"index": "src/index.lavero.html",
@@ -190,7 +202,7 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumWarning": "600kB",
"maximumError": "1MB"
},
{

585
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,6 @@
"build:dexar": "ng build --configuration=production",
"build:novo": "ng build --configuration=novo-production",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"lavero": "ng serve --configuration=lavero --port 4202 --proxy-config proxy.conf.lavero.json",
"start:lavero": "ng serve --configuration=lavero --port 4202",
"build:lavero": "ng build --configuration=lavero-production"
@@ -25,9 +24,7 @@
"@angular/compiler": "21.1.5",
"@angular/core": "21.1.5",
"@angular/forms": "21.1.5",
"@angular/material": "21.1.5",
"@angular/platform-browser": "21.1.5",
"@angular/platform-browser-dynamic": "21.1.5",
"@angular/router": "21.1.5",
"@angular/service-worker": "21.1.5",
"primeicons": "^7.0.0",
@@ -40,13 +37,6 @@
"@angular/build": "21.1.5",
"@angular/cli": "21.1.5",
"@angular/compiler-cli": "21.1.5",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.13.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.3"
}
}
}

View File

@@ -18,6 +18,10 @@
}
<router-outlet></router-outlet>
</main>
<app-footer></app-footer>
@defer (on viewport) {
<app-footer></app-footer>
} @placeholder {
<div class="footer-placeholder" aria-hidden="true"></div>
}
<!-- <app-telegram-login /> -->
}

View File

@@ -5,6 +5,10 @@
flex-direction: column;
}
.footer-placeholder {
min-height: 1px;
}
.server-check-overlay,
.server-error-overlay {
display: flex;

View File

@@ -2,10 +2,10 @@
import { Component, OnInit, signal, ApplicationRef, inject, DestroyRef } from '@angular/core';
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { HttpClient } from '@angular/common/http';
import { HeaderComponent } from './components/header/header.component';
import { FooterComponent } from './components/footer/footer.component';
import { BackButtonComponent } from './components/back-button/back-button.component';
import { ApiService } from './services';
import { interval, concat } from 'rxjs';
import { filter, first } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -27,7 +27,7 @@ export class App implements OnInit {
serverAvailable = signal(false);
private destroyRef = inject(DestroyRef);
private apiService = inject(ApiService);
private http = inject(HttpClient);
private titleService = inject(Title);
private swUpdate = inject(SwUpdate);
private appRef = inject(ApplicationRef);
@@ -55,7 +55,7 @@ export class App implements OnInit {
private checkServerHealth(): void {
this.checkingServer.set(true);
this.apiService.ping()
this.http.get<{ message: string }>(`${environment.apiUrl}/ping`)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {

View File

@@ -1,6 +1,7 @@
import { Component, ChangeDetectionStrategy, Renderer2, inject, DOCUMENT } from '@angular/core';
import { Router, RouterLink, RouterLinkActive } from '@angular/router';
import { CartService, LanguageService } from '../../services';
import { CartService } from '../../services/cart.service';
import { LanguageService } from '../../services/language.service';
import { environment } from '../../../environments/environment';
import { LogoComponent } from '../logo/logo.component';
import { LanguageSelectorComponent } from '../language-selector/language-selector.component';

View File

@@ -23,7 +23,6 @@ export class TelegramLoginComponent implements OnDestroy {
private readonly pollIntervalMs = 5000;
private pollTimer?: ReturnType<typeof setInterval>;
private telegramFallbackTimer?: ReturnType<typeof setTimeout>;
private readonly handleVisibilityChange = () => {
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
this.checkLoginAfterReturn();

View File

@@ -2,7 +2,6 @@
export const PAYMENT_POLL_INTERVAL_MS = 5000;
export const PAYMENT_MAX_CHECKS = 36;
export const PAYMENT_TIMEOUT_CLOSE_MS = 3000;
export const PAYMENT_ERROR_CLOSE_MS = 4000;
export const LINK_COPIED_DURATION_MS = 2000;
// Infinite scroll
@@ -12,7 +11,6 @@ export const ITEMS_PER_PAGE = 50;
// Search
export const SEARCH_DEBOUNCE_MS = 300;
export const SEARCH_MIN_LENGTH = 3;
// Cache
export const CACHE_DURATION_MS = 5 * 60 * 1000;

View File

@@ -50,11 +50,6 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
);
};
/** Clear all cached responses */
export function clearCache(): void {
cache.clear();
}
function cleanupExpiredCache(): void {
const now = Date.now();
for (const [url, data] of cache.entries()) {

View File

@@ -0,0 +1,3 @@
import { HttpInterceptorFn } from '@angular/common/http';
export const mockDataInterceptor: HttpInterceptorFn = (req, next) => next(req);

View File

@@ -12,14 +12,4 @@ export interface WebSessionStart {
url: string;
}
export interface TelegramAuthData {
id: number;
first_name: string;
last_name?: string;
username?: string;
photo_url?: string;
auth_date: number;
hash: string;
}
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';

View File

@@ -33,6 +33,6 @@ export interface Subcategory {
subcategories?: Subcategory[];
}
export interface CategoryTranslation {
interface CategoryTranslation {
name?: string;
}

View File

@@ -1,4 +1,4 @@
export interface Photo {
interface Photo {
photo?: string;
video?: string;
url: string;
@@ -10,7 +10,7 @@ export interface DescriptionField {
value: string;
}
export interface Comment {
interface Comment {
id?: string;
text: string;
author?: string;
@@ -18,13 +18,13 @@ export interface Comment {
createdAt?: string;
}
export interface ItemTranslation {
interface ItemTranslation {
name?: string;
simpleDescription?: string;
description?: DescriptionField[];
}
export interface Review {
interface Review {
rating?: number;
content?: string;
userID?: string;
@@ -32,10 +32,7 @@ export interface Review {
timestamp?: string;
}
/** @deprecated Use {@link Review} instead */
export type Callback = Review;
export interface Question {
interface Question {
question: string;
answer: string;
upvotes: number;
@@ -51,13 +48,13 @@ export interface ItemName {
}
/** Localized description entry from backend */
export interface ItemDescription {
interface ItemDescription {
language: string;
value: string;
}
/** Key-value attribute pair */
export interface ItemAttribute {
interface ItemAttribute {
key: string;
value: string;
}
@@ -68,7 +65,7 @@ export interface DeliveryOption {
deliveryTime: string;
}
export type DeliveryMode = 'selectable' | 'digital';
type DeliveryMode = 'selectable' | 'digital';
/** Item variant detail (price, size, colour per variant) */
export interface ItemDetail {

View File

@@ -112,7 +112,6 @@ export class CartComponent implements OnDestroy {
}
onSwipeStart(itemID: number, event: TouchEvent): void {
const item = event.currentTarget as HTMLElement;
const startX = event.touches[0].clientX;
const onMove = (e: TouchEvent) => {

View File

@@ -121,11 +121,11 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
}
// TrackBy function for performance optimization
trackByCategoryId(index: number, category: Category): number {
trackByCategoryId(_index: number, category: Category): number {
return category.categoryID;
}
trackBySubId(index: number, sub: Subcategory): string {
trackBySubId(_index: number, sub: Subcategory): string {
return sub.id;
}

View File

@@ -9,7 +9,7 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { Subscription } from 'rxjs';
import { environment } from '../../../environments/environment';
import { SecurityContext } from '@angular/core';
import { getDiscountedPrice, getAllImages, getStockStatus, getBadgeClass, getTranslatedField } from '../../utils/item.utils';
import { 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';
@@ -297,7 +297,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
};
this.apiService.submitReview(reviewData).subscribe({
next: (response) => {
next: () => {
this.reviewSubmitStatus.set('success');
this.newReview = { rating: 0, comment: '', anonymous: false };

View File

@@ -2,7 +2,8 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, timer } from 'rxjs';
import { map, retry } from 'rxjs/operators';
import { Category, DeliveryOption, Item, Subcategory } from '../models';
import { Category, DeliveryOption, Item } from '../models';
import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils';
import { environment } from '../../environments/environment';
export interface QrCreateRequest {
@@ -69,7 +70,7 @@ export class ApiService {
private readonly retryConfig = {
count: 2,
delay: (error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
delay: (_error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
};
constructor(private http: HttpClient) {}
@@ -86,63 +87,6 @@ export class ApiService {
return c.startsWith('0x') ? '#' + c.slice(2) : c;
}
private normalizeOptionalNumber(value: unknown): number | undefined {
if (value === null || value === undefined || value === '') {
return undefined;
}
const normalized = typeof value === 'number'
? value
: Number(String(value).replace(',', '.'));
return Number.isFinite(normalized) ? normalized : undefined;
}
private normalizeOptionalString(value: unknown): string {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'bigint') {
return String(value);
}
return '';
}
private asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
private normalizeDeliveryOption(value: unknown): DeliveryOption | null {
const source = this.asRecord(value);
if (!source) {
return null;
}
const deliveryPlace = this.normalizeOptionalString(
source['deliveryPlace'] ?? source['delivery_place'] ?? source['deliveryplace']
);
const deliveryTime = this.normalizeOptionalString(
source['deliveryTime'] ?? source['delivery_time'] ?? source['deliverytime']
);
const deliveryPrice = this.normalizeOptionalNumber(
source['deliveryPrice'] ?? source['delivery_price'] ?? source['deliveryprice']
);
if (deliveryPrice === undefined && !deliveryPlace && !deliveryTime) {
return null;
}
return {
deliveryPrice: deliveryPrice ?? 0,
deliveryPlace,
deliveryTime,
};
}
private normalizeDeliveryData(
raw: any,
legacyDeliveryPrice?: number
@@ -159,7 +103,7 @@ export class ApiService {
? [rawDelivery]
: [];
const options = deliveryCandidates
.map(candidate => this.normalizeDeliveryOption(candidate))
.map(candidate => normalizeDeliveryOption(candidate))
.filter((option): option is DeliveryOption => option !== null);
if (options.length > 0) {
@@ -197,7 +141,7 @@ export class ApiService {
private normalizeItem(raw: any): Item {
const { partnerID, ...rest } = raw;
const item: Item = { ...rest };
let legacyDeliveryPrice = this.normalizeOptionalNumber(
let legacyDeliveryPrice = normalizeOptionalNumber(
raw.deliveryPrice ?? raw.delivery_price ?? raw.deliveryprice
);
@@ -210,7 +154,7 @@ export class ApiService {
...d,
colour: this.normalizeColor(d.colour || d.color || ''),
color: undefined,
deliveryPrice: this.normalizeOptionalNumber(
deliveryPrice: normalizeOptionalNumber(
d.deliveryPrice ?? d.delivery_price ?? d.deliveryprice
),
}));
@@ -219,7 +163,7 @@ export class ApiService {
if (!item.colour) item.colour = this.normalizeColor(detail.colour || detail.color || '');
if (!item.size) item.size = detail.size || '';
if (legacyDeliveryPrice === undefined) {
legacyDeliveryPrice = this.normalizeOptionalNumber(
legacyDeliveryPrice = normalizeOptionalNumber(
detail.deliveryPrice ?? detail.delivery_price ?? detail.deliveryprice
);
}

View File

@@ -1,7 +1,7 @@
import { Injectable, signal, computed, effect } from '@angular/core';
import { ApiService } from './api.service';
import { DeliveryOption, Item, CartItem } from '../models';
import { Injectable, signal, computed, effect, Injector } from '@angular/core';
import { DeliveryOption, CartItem } from '../models';
import { getDiscountedPrice } from '../utils/item.utils';
import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils';
import { environment } from '../../environments/environment';
import type { } from '../types/telegram.types';
@@ -48,7 +48,7 @@ export class CartService {
&& items.some(item => (item.deliveryOptions?.length ?? 0) > 0 || item.selectedDelivery != null);
});
constructor(private apiService: ApiService) {
constructor(private injector: Injector) {
this.loadCart();
// Auto-save whenever cart changes (skip the initial empty state)
@@ -60,57 +60,6 @@ export class CartService {
});
}
private normalizeOptionalNumber(value: unknown): number | undefined {
if (value === null || value === undefined || value === '') {
return undefined;
}
const normalized = typeof value === 'number'
? value
: Number(String(value).replace(',', '.'));
return Number.isFinite(normalized) ? normalized : undefined;
}
private normalizeOptionalString(value: unknown): string {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'bigint') {
return String(value);
}
return '';
}
private normalizeDeliveryOption(value: unknown): DeliveryOption | null {
if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const source = value as Record<string, unknown>;
const deliveryPlace = this.normalizeOptionalString(
source['deliveryPlace'] ?? source['delivery_place'] ?? source['deliveryplace']
);
const deliveryTime = this.normalizeOptionalString(
source['deliveryTime'] ?? source['delivery_time'] ?? source['deliverytime']
);
const deliveryPrice = this.normalizeOptionalNumber(
source['deliveryPrice'] ?? source['delivery_price'] ?? source['deliveryprice']
);
if (deliveryPrice === undefined && !deliveryPlace && !deliveryTime) {
return null;
}
return {
deliveryPrice: deliveryPrice ?? 0,
deliveryPlace,
deliveryTime,
};
}
private sameDeliveryOption(left: DeliveryOption, right: DeliveryOption): boolean {
return left.deliveryPrice === right.deliveryPrice
&& left.deliveryPlace === right.deliveryPlace
@@ -131,10 +80,10 @@ export class CartService {
} {
const normalizedOptions = Array.isArray(item.deliveryOptions)
? item.deliveryOptions
.map(option => this.normalizeDeliveryOption(option))
.map(option => normalizeDeliveryOption(option))
.filter((option): option is DeliveryOption => option !== null)
: [];
const legacyDeliveryPrice = this.normalizeOptionalNumber(item.deliveryPrice);
const legacyDeliveryPrice = normalizeOptionalNumber(item.deliveryPrice);
const deliveryOptions = normalizedOptions.length > 0
? normalizedOptions
: legacyDeliveryPrice !== undefined
@@ -148,7 +97,7 @@ export class CartService {
const deliverySelectionRequired = deliveryOptions.length > 0
? item.deliverySelectionRequired !== false && normalizedOptions.length > 0
: false;
const selectedDelivery = this.normalizeDeliveryOption(item.selectedDelivery)
const selectedDelivery = normalizeDeliveryOption(item.selectedDelivery)
?? (deliveryOptions.length === 1 && !deliverySelectionRequired ? deliveryOptions[0] : null);
const matchedSelection = selectedDelivery && deliveryOptions.length > 0
? deliveryOptions.find(option => this.sameDeliveryOption(option, selectedDelivery)) ?? selectedDelivery
@@ -251,23 +200,28 @@ export class CartService {
} else {
// Get item details from API and add to cart
this.addingItems.add(itemID);
this.apiService.getItem(itemID).subscribe({
next: (item) => {
const cartItem = this.normalizeCartItem({
...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.addingItems.delete(itemID);
},
error: (err) => {
console.error('Error adding to cart:', err);
this.addingItems.delete(itemID);
}
import('./api.service').then(({ ApiService }) => {
this.injector.get(ApiService).getItem(itemID).subscribe({
next: (item) => {
const cartItem = this.normalizeCartItem({
...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.addingItems.delete(itemID);
},
error: (err) => {
console.error('Error adding to cart:', err);
this.addingItems.delete(itemID);
}
});
}).catch((err) => {
console.error('Error loading API service:', err);
this.addingItems.delete(itemID);
});
}
}
@@ -286,7 +240,7 @@ export class CartService {
}
setSelectedDelivery(itemID: number, selectedDelivery: DeliveryOption | null): void {
const normalizedSelection = this.normalizeDeliveryOption(selectedDelivery);
const normalizedSelection = normalizeDeliveryOption(selectedDelivery);
const updatedItems = this.cartItems().map(item => {
if (item.itemID !== itemID) {
return item;

View File

@@ -1,4 +1,4 @@
export interface TelegramUser {
interface TelegramUser {
id: number;
first_name: string;
last_name?: string;
@@ -21,3 +21,5 @@ declare global {
};
}
}
export {};

View File

@@ -13,25 +13,10 @@ export function getMainImage(item: Item): string {
return item.photos?.[0]?.url || '/assets/images/placeholder.svg';
}
export function getAllImages(item: Item): string[] {
if (item.imgs && item.imgs.length > 0) {
return item.imgs;
}
return item.photos?.map(p => p.url) || [];
}
export function trackByItemId(index: number, item: Item): number | string {
export function trackByItemId(_index: number, item: Item): number | string {
return item.id || item.itemID;
}
/**
* Get the display description — supports both legacy HTML string
* and structured key-value pairs from backOffice API.
*/
export function hasStructuredDescription(item: Item): boolean {
return Array.isArray(item.descriptionFields) && item.descriptionFields.length > 0;
}
/**
* Compute stock status from quantity if the legacy `remainings` field is absent.
*/

View File

@@ -0,0 +1,58 @@
import { DeliveryOption } from '../models';
export function normalizeOptionalNumber(value: unknown): number | undefined {
if (value === null || value === undefined || value === '') {
return undefined;
}
const normalized = typeof value === 'number'
? value
: Number(String(value).replace(',', '.'));
return Number.isFinite(normalized) ? normalized : undefined;
}
function normalizeOptionalString(value: unknown): string {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'bigint') {
return String(value);
}
return '';
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
export function normalizeDeliveryOption(value: unknown): DeliveryOption | null {
const source = asRecord(value);
if (!source) {
return null;
}
const deliveryPlace = normalizeOptionalString(
source['deliveryPlace'] ?? source['delivery_place'] ?? source['deliveryplace']
);
const deliveryTime = normalizeOptionalString(
source['deliveryTime'] ?? source['delivery_time'] ?? source['deliverytime']
);
const deliveryPrice = normalizeOptionalNumber(
source['deliveryPrice'] ?? source['delivery_price'] ?? source['deliveryprice']
);
if (deliveryPrice === undefined && !deliveryPlace && !deliveryTime) {
return null;
}
return {
deliveryPrice: deliveryPrice ?? 0,
deliveryPlace,
deliveryTime,
};
}

View File

@@ -5,12 +5,6 @@
/* Google Fonts loaded via <link> in index.html for non-blocking rendering */
/* Font optimization */
@font-face {
font-family: system-ui;
font-display: swap;
}
/* Default CSS Variables - will be overridden by theme files */
:root {
--primary-color: #497671;
@@ -191,6 +185,7 @@ a, button, input, textarea, select {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}

View File

@@ -28,9 +28,6 @@
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -1,15 +0,0 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"rootDir": "./src",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.ts"
]
}