5 Commits

Author SHA1 Message Date
sdarbinyan
394ac5ec9d visible and count 2026-06-22 01:45:23 +04:00
sdarbinyan
4fb918f5e4 cleaned up 2026-06-21 23:42:39 +04:00
sdarbinyan
3b802b7c7b delivery 2026-06-21 23:13:01 +04:00
sdarbinyan
1b2a5af2be test 2026-06-21 01:45:05 +04:00
sdarbinyan
6410321895 price 2026-06-20 15:16:25 +04:00
35 changed files with 964 additions and 521 deletions

View File

@@ -40,6 +40,10 @@
{ {
"replace": "src/environments/environment.ts", "replace": "src/environments/environment.ts",
"with": "src/environments/environment.production.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": [ "styles": [
@@ -49,7 +53,7 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "500kB", "maximumWarning": "600kB",
"maximumError": "1MB" "maximumError": "1MB"
}, },
{ {
@@ -92,6 +96,10 @@
{ {
"replace": "src/app/brands/brand-routes.ts", "replace": "src/app/brands/brand-routes.ts",
"with": "src/app/brands/brand-routes.novo.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", "index": "src/index.novo.html",
@@ -124,7 +132,7 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "500kB", "maximumWarning": "600kB",
"maximumError": "1MB" "maximumError": "1MB"
}, },
{ {
@@ -158,6 +166,10 @@
{ {
"replace": "src/app/brands/brand-routes.ts", "replace": "src/app/brands/brand-routes.ts",
"with": "src/app/brands/brand-routes.lavero.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", "index": "src/index.lavero.html",
@@ -190,7 +202,7 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "500kB", "maximumWarning": "600kB",
"maximumError": "1MB" "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:dexar": "ng build --configuration=production",
"build:novo": "ng build --configuration=novo-production", "build:novo": "ng build --configuration=novo-production",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test",
"lavero": "ng serve --configuration=lavero --port 4202 --proxy-config proxy.conf.lavero.json", "lavero": "ng serve --configuration=lavero --port 4202 --proxy-config proxy.conf.lavero.json",
"start:lavero": "ng serve --configuration=lavero --port 4202", "start:lavero": "ng serve --configuration=lavero --port 4202",
"build:lavero": "ng build --configuration=lavero-production" "build:lavero": "ng build --configuration=lavero-production"
@@ -25,9 +24,7 @@
"@angular/compiler": "21.1.5", "@angular/compiler": "21.1.5",
"@angular/core": "21.1.5", "@angular/core": "21.1.5",
"@angular/forms": "21.1.5", "@angular/forms": "21.1.5",
"@angular/material": "21.1.5",
"@angular/platform-browser": "21.1.5", "@angular/platform-browser": "21.1.5",
"@angular/platform-browser-dynamic": "21.1.5",
"@angular/router": "21.1.5", "@angular/router": "21.1.5",
"@angular/service-worker": "21.1.5", "@angular/service-worker": "21.1.5",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
@@ -40,13 +37,6 @@
"@angular/build": "21.1.5", "@angular/build": "21.1.5",
"@angular/cli": "21.1.5", "@angular/cli": "21.1.5",
"@angular/compiler-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" "typescript": "~5.9.3"
} }
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
<div class="delivery-selector">
@if (isDigital) {
<div class="delivery-chip delivery-chip--digital">{{ 'cart.digitalDelivery' | translate }}</div>
} @else if (options.length > 0) {
<label class="delivery-label" [for]="selectId">{{ 'cart.deliveryMethod' | translate }}</label>
<div class="delivery-control">
<select [id]="selectId" [ngModel]="selectedKey" (ngModelChange)="onSelectionChange($event)">
@if (required) {
<option value="">{{ 'cart.selectDelivery' | translate }}</option>
}
@for (option of options; track trackByOption($index, option)) {
<option [value]="optionKey(option)">{{ optionLabel(option) }}</option>
}
</select>
</div>
@if (selectedDelivery) {
<div class="delivery-meta">
@if (selectedDelivery.deliveryPlace) {
<span>{{ 'cart.deliveryPlace' | translate }}: {{ selectedDelivery.deliveryPlace }}</span>
}
@if (selectedDelivery.deliveryTime) {
<span>{{ 'cart.deliveryTime' | translate }}: {{ selectedDelivery.deliveryTime }}</span>
}
<span>
{{ 'cart.deliveryLabel' | translate }}:
{{ selectedDeliveryTotal | number:'1.2-2' }} {{ currency }}
</span>
</div>
}
}
</div>

View File

@@ -0,0 +1,81 @@
:host {
display: block;
}
.delivery-selector {
margin-top: 12px;
padding: 12px;
border-radius: 12px;
border: 1px solid #d3dad9;
background: #fafbfb;
}
.delivery-label {
display: block;
margin-bottom: 8px;
font-size: 0.8rem;
font-weight: 700;
color: #3f5f5c;
}
.delivery-control select {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid #c9d5d3;
background: #fff;
color: #1f2937;
font-size: 0.9rem;
line-height: 1.4;
}
.delivery-control select:focus {
outline: none;
border-color: #497671;
box-shadow: 0 0 0 3px rgba(73, 118, 113, 0.12);
}
.delivery-meta {
display: grid;
gap: 4px;
margin-top: 10px;
font-size: 0.82rem;
color: #697777;
line-height: 1.45;
}
.delivery-chip {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 700;
}
.delivery-chip--digital {
color: #2563eb;
background: rgba(37, 99, 235, 0.12);
}
:host-context(.cart-container.novo) .delivery-selector {
border-color: #d1fae5;
background: #f9fffc;
}
:host-context(.cart-container.novo) .delivery-label {
color: #047857;
}
:host-context(.cart-container.novo) .delivery-control select {
border-color: #bbf7d0;
}
:host-context(.cart-container.novo) .delivery-control select:focus {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
}
:host-context(.cart-container.novo) .delivery-meta {
color: #4b5563;
}

View File

@@ -0,0 +1,78 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { CartItem, DeliveryOption } from '../../models';
import { TranslatePipe } from '../../i18n/translate.pipe';
let nextDeliverySelectorId = 0;
@Component({
selector: 'app-delivery-selector',
standalone: true,
imports: [FormsModule, DecimalPipe, TranslatePipe],
templateUrl: './delivery-selector.component.html',
styleUrls: ['./delivery-selector.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class DeliverySelectorComponent {
@Input({ required: true }) item: CartItem | null = null;
@Output() selectedDeliveryChange = new EventEmitter<DeliveryOption | null>();
readonly selectId = `delivery-select-${nextDeliverySelectorId++}`;
get options(): DeliveryOption[] {
return this.item?.deliveryOptions ?? [];
}
get selectedDelivery(): DeliveryOption | null {
return this.item?.selectedDelivery ?? null;
}
get currency(): string {
return this.item?.currency || 'RUB';
}
get required(): boolean {
return this.item?.deliveryMode !== 'digital'
&& this.options.length > 0
&& this.item?.deliverySelectionRequired !== false;
}
get isDigital(): boolean {
return this.item?.deliveryMode === 'digital';
}
get selectedKey(): string {
return this.selectedDelivery ? this.optionKey(this.selectedDelivery) : '';
}
get selectedDeliveryTotal(): number {
return (this.selectedDelivery?.deliveryPrice ?? 0) * (this.item?.quantity ?? 1);
}
optionKey(option: DeliveryOption): string {
return `${option.deliveryPlace}__${option.deliveryTime}__${option.deliveryPrice}`;
}
optionLabel(option: DeliveryOption): string {
const details = [option.deliveryPlace, option.deliveryTime].filter(Boolean);
details.push(`${option.deliveryPrice.toFixed(2)} ${this.currency}`);
return details.join(' • ');
}
trackByOption(index: number, option: DeliveryOption): string {
return `${index}-${this.optionKey(option)}`;
}
onSelectionChange(nextKey: string): void {
if (!nextKey) {
this.selectedDeliveryChange.emit(null);
return;
}
this.selectedDeliveryChange.emit(
this.options.find(option => this.optionKey(option) === nextKey) ?? null
);
}
}

View File

@@ -1,6 +1,7 @@
import { Component, ChangeDetectionStrategy, Renderer2, inject, DOCUMENT } from '@angular/core'; import { Component, ChangeDetectionStrategy, Renderer2, inject, DOCUMENT } from '@angular/core';
import { Router, RouterLink, RouterLinkActive } from '@angular/router'; 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 { environment } from '../../../environments/environment';
import { LogoComponent } from '../logo/logo.component'; import { LogoComponent } from '../logo/logo.component';
import { LanguageSelectorComponent } from '../language-selector/language-selector.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 readonly pollIntervalMs = 5000;
private pollTimer?: ReturnType<typeof setInterval>; private pollTimer?: ReturnType<typeof setInterval>;
private telegramFallbackTimer?: ReturnType<typeof setTimeout>;
private readonly handleVisibilityChange = () => { private readonly handleVisibilityChange = () => {
if (typeof document !== 'undefined' && document.visibilityState === 'visible') { if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
this.checkLoginAfterReturn(); this.checkLoginAfterReturn();
@@ -42,7 +41,6 @@ export class TelegramLoginComponent implements OnDestroy {
this.initQrLogin(); this.initQrLogin();
} else { } else {
this.awaitingTelegramReturn.set(false); this.awaitingTelegramReturn.set(false);
this.clearTelegramFallback();
this.stopPolling(); this.stopPolling();
} }
}); });
@@ -56,7 +54,6 @@ export class TelegramLoginComponent implements OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.awaitingTelegramReturn.set(false); this.awaitingTelegramReturn.set(false);
this.clearTelegramFallback();
this.stopPolling(); this.stopPolling();
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -68,53 +65,30 @@ export class TelegramLoginComponent implements OnDestroy {
close(): void { close(): void {
this.awaitingTelegramReturn.set(false); this.awaitingTelegramReturn.set(false);
this.clearTelegramFallback();
this.authService.hideLogin(); this.authService.hideLogin();
this.stopPolling(); this.stopPolling();
} }
openTelegramLogin(): void { openTelegramLogin(): void {
const url = this.loginUrl();
const webSessionID = this.webSessionID(); const webSessionID = this.webSessionID();
if (!url || !webSessionID) return; if (!webSessionID || typeof window === 'undefined') return;
if (!this.pollTimer) { if (!this.pollTimer) {
this.startPolling(webSessionID); this.startPolling(webSessionID);
} }
const appUrl = this.authService.getTelegramAppLoginUrl(webSessionID);
this.awaitingTelegramReturn.set(true); this.awaitingTelegramReturn.set(true);
this.clearTelegramFallback(); window.location.href = this.authService.getTelegramAppLoginUrl(webSessionID);
if (this.isMobileBrowser()) {
window.location.href = appUrl;
return;
}
this.openExternalApp(appUrl);
this.telegramFallbackTimer = window.setTimeout(() => {
if (!this.showDialog() || !this.awaitingTelegramReturn()) {
return;
}
if (typeof document !== 'undefined' && (!document.hasFocus() || document.visibilityState === 'hidden')) {
return;
}
window.open(url, '_blank', 'noopener,noreferrer');
}, 1200);
} }
refreshQr(): void { refreshQr(): void {
this.awaitingTelegramReturn.set(false); this.awaitingTelegramReturn.set(false);
this.clearTelegramFallback();
this.stopPolling(); this.stopPolling();
this.initQrLogin(); this.initQrLogin();
} }
private initQrLogin(): void { private initQrLogin(): void {
this.awaitingTelegramReturn.set(false); this.awaitingTelegramReturn.set(false);
this.clearTelegramFallback();
this.qrStatus.set('loading'); this.qrStatus.set('loading');
this.loginUrl.set(''); this.loginUrl.set('');
this.webSessionID.set(''); this.webSessionID.set('');
@@ -149,7 +123,6 @@ export class TelegramLoginComponent implements OnDestroy {
next: (session) => { next: (session) => {
if (session?.active) { if (session?.active) {
this.awaitingTelegramReturn.set(false); this.awaitingTelegramReturn.set(false);
this.clearTelegramFallback();
this.stopPolling(); this.stopPolling();
this.authService.onTelegramLoginComplete(); this.authService.onTelegramLoginComplete();
} }
@@ -168,13 +141,6 @@ export class TelegramLoginComponent implements OnDestroy {
} }
} }
private clearTelegramFallback(): void {
if (this.telegramFallbackTimer) {
clearTimeout(this.telegramFallbackTimer);
this.telegramFallbackTimer = undefined;
}
}
private checkLoginAfterReturn(): void { private checkLoginAfterReturn(): void {
if (!this.showDialog() || !this.awaitingTelegramReturn()) { if (!this.showDialog() || !this.awaitingTelegramReturn()) {
return; return;
@@ -193,35 +159,9 @@ export class TelegramLoginComponent implements OnDestroy {
this.authService.checkSessionOnce(webSessionID).subscribe(session => { this.authService.checkSessionOnce(webSessionID).subscribe(session => {
if (session?.active) { if (session?.active) {
this.awaitingTelegramReturn.set(false); this.awaitingTelegramReturn.set(false);
this.clearTelegramFallback();
this.stopPolling(); this.stopPolling();
this.authService.onTelegramLoginComplete(); this.authService.onTelegramLoginComplete();
} }
}); });
} }
private openExternalApp(url: string): void {
if (typeof document === 'undefined') {
window.location.href = url;
return;
}
const anchor = document.createElement('a');
anchor.href = url;
anchor.style.display = 'none';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}
private isMobileBrowser(): boolean {
if (typeof navigator === 'undefined') {
return false;
}
const userAgent = navigator.userAgent || navigator.vendor;
const isTouchMac = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
return /Android|iPhone|iPad|iPod|IEMobile|Opera Mini/i.test(userAgent) || isTouchMac;
}
} }

View File

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

View File

@@ -64,6 +64,12 @@ export const en: Translations = {
total: 'Total', total: 'Total',
items: 'Products', items: 'Products',
deliveryLabel: 'Delivery', deliveryLabel: 'Delivery',
deliveryMethod: 'Delivery option',
selectDelivery: 'Select delivery option',
deliveryPlace: 'Place',
deliveryTime: 'Delivery time',
digitalDelivery: 'Digital delivery',
deliveryRequired: 'Select delivery for every shippable item before checkout.',
toPay: 'To pay', toPay: 'To pay',
agreeWith: 'I agree with the', agreeWith: 'I agree with the',
publicOffer: 'public offer', publicOffer: 'public offer',

View File

@@ -64,6 +64,12 @@ export const hy: Translations = {
total: 'Ընդամենը', total: 'Ընդամենը',
items: 'Ապրանքներ', items: 'Ապրանքներ',
deliveryLabel: 'Առաքում', deliveryLabel: 'Առաքում',
deliveryMethod: 'Առաքման տարբերակ',
selectDelivery: 'Ընտրեք առաքման տարբերակը',
deliveryPlace: 'Վայր',
deliveryTime: 'Առաքման ժամկետ',
digitalDelivery: 'Թվային առաքում',
deliveryRequired: 'Մինչ պատվերը ձևակերպելը ընտրեք առաքումը բոլոր առաքվող ապրանքների համար։',
toPay: 'Վճարման ենթակա', toPay: 'Վճարման ենթակա',
agreeWith: 'Ես համաձայն եմ', agreeWith: 'Ես համաձայն եմ',
publicOffer: 'հանրային օֆերտայի', publicOffer: 'հանրային օֆերտայի',

View File

@@ -64,6 +64,12 @@ export const ru: Translations = {
total: 'Итого', total: 'Итого',
items: 'Товары', items: 'Товары',
deliveryLabel: 'Доставка', deliveryLabel: 'Доставка',
deliveryMethod: 'Способ доставки',
selectDelivery: 'Выберите способ доставки',
deliveryPlace: 'Место',
deliveryTime: 'Срок доставки',
digitalDelivery: 'Цифровая доставка',
deliveryRequired: 'Выберите доставку для всех товаров с доставкой перед оформлением заказа.',
toPay: 'К оплате', toPay: 'К оплате',
agreeWith: 'Я согласен с', agreeWith: 'Я согласен с',
publicOffer: 'публичной офертой', publicOffer: 'публичной офертой',

View File

@@ -62,6 +62,12 @@ export interface Translations {
total: string; total: string;
items: string; items: string;
deliveryLabel: string; deliveryLabel: string;
deliveryMethod: string;
selectDelivery: string;
deliveryPlace: string;
deliveryTime: string;
digitalDelivery: string;
deliveryRequired: string;
toPay: string; toPay: string;
agreeWith: string; agreeWith: string;
publicOffer: string; publicOffer: string;

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 { function cleanupExpiredCache(): void {
const now = Date.now(); const now = Date.now();
for (const [url, data] of cache.entries()) { 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; 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'; export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';

View File

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

View File

@@ -1,4 +1,4 @@
export interface Photo { interface Photo {
photo?: string; photo?: string;
video?: string; video?: string;
url: string; url: string;
@@ -10,7 +10,7 @@ export interface DescriptionField {
value: string; value: string;
} }
export interface Comment { interface Comment {
id?: string; id?: string;
text: string; text: string;
author?: string; author?: string;
@@ -18,13 +18,13 @@ export interface Comment {
createdAt?: string; createdAt?: string;
} }
export interface ItemTranslation { interface ItemTranslation {
name?: string; name?: string;
simpleDescription?: string; simpleDescription?: string;
description?: DescriptionField[]; description?: DescriptionField[];
} }
export interface Review { interface Review {
rating?: number; rating?: number;
content?: string; content?: string;
userID?: string; userID?: string;
@@ -32,10 +32,7 @@ export interface Review {
timestamp?: string; timestamp?: string;
} }
/** @deprecated Use {@link Review} instead */ interface Question {
export type Callback = Review;
export interface Question {
question: string; question: string;
answer: string; answer: string;
upvotes: number; upvotes: number;
@@ -51,23 +48,32 @@ export interface ItemName {
} }
/** Localized description entry from backend */ /** Localized description entry from backend */
export interface ItemDescription { interface ItemDescription {
language: string; language: string;
value: string; value: string;
} }
/** Key-value attribute pair */ /** Key-value attribute pair */
export interface ItemAttribute { interface ItemAttribute {
key: string; key: string;
value: string; value: string;
} }
export interface DeliveryOption {
deliveryPrice: number;
deliveryPlace: string;
deliveryTime: string;
}
type DeliveryMode = 'selectable' | 'digital';
/** Item variant detail (price, size, colour per variant) */ /** Item variant detail (price, size, colour per variant) */
export interface ItemDetail { export interface ItemDetail {
color?: string; color?: string;
colour?: string; colour?: string;
size?: string; size?: string;
price: number; price: number;
deliveryPrice?: number;
currency: string; currency: string;
remaining: number; remaining: number;
} }
@@ -80,6 +86,7 @@ export interface Item {
description: string; description: string;
currency: string; currency: string;
price: number; price: number;
deliveryPrice?: number;
discount: number; discount: number;
remainings?: string; remainings?: string;
rating: number; rating: number;
@@ -90,6 +97,9 @@ export interface Item {
// Backend API fields // Backend API fields
colour?: string; colour?: string;
size?: string; size?: string;
deliveryOptions?: DeliveryOption[];
deliveryMode?: DeliveryMode;
deliverySelectionRequired?: boolean;
language?: string; language?: string;
names?: ItemName[]; names?: ItemName[];
descriptions?: ItemDescription[]; descriptions?: ItemDescription[];
@@ -113,4 +123,5 @@ export interface Item {
export interface CartItem extends Item { export interface CartItem extends Item {
quantity: number; quantity: number;
selectedDelivery?: DeliveryOption | null;
} }

View File

@@ -94,6 +94,13 @@
</button> </button>
</div> </div>
</div> </div>
@if (item.deliveryMode === 'digital' || item.deliveryOptions?.length) {
<app-delivery-selector
[item]="item"
(selectedDeliveryChange)="selectDelivery(item.itemID, $event)"
/>
}
</div> </div>
</div> </div>
@@ -116,14 +123,20 @@
<span class="value">{{ totalPrice() | number:'1.2-2' }} {{ currentCurrency }}</span> <span class="value">{{ totalPrice() | number:'1.2-2' }} {{ currentCurrency }}</span>
</div> </div>
<div class="summary-row delivery"> @if (hasDeliveryPrice()) {
<span>{{ 'cart.deliveryLabel' | translate }}</span> <div class="summary-row delivery">
<span>0 {{ currentCurrency }}</span> <span>{{ 'cart.deliveryLabel' | translate }}</span>
</div> <span class="value">{{ totalDeliveryPrice() | number:'1.2-2' }} {{ currentCurrency }}</span>
</div>
}
@if (!allRequiredDeliveriesSelected()) {
<p class="delivery-warning">{{ 'cart.deliveryRequired' | translate }}</p>
}
<div class="summary-row total"> <div class="summary-row total">
<span>{{ 'cart.toPay' | translate }}</span> <span>{{ 'cart.toPay' | translate }}</span>
<span class="total-price">{{ totalPrice() | number:'1.2-2' }} {{ currentCurrency }}</span> <span class="total-price">{{ totalWithDelivery() | number:'1.2-2' }} {{ currentCurrency }}</span>
</div> </div>
<div class="terms-agreement"> <div class="terms-agreement">
@@ -147,8 +160,8 @@
<button <button
class="checkout-btn" class="checkout-btn"
(click)="checkout()" (click)="checkout()"
[class.disabled]="!termsAccepted || !isAuthenticated()" [class.disabled]="isCheckoutDisabled"
[disabled]="!termsAccepted || !isAuthenticated()" [disabled]="isCheckoutDisabled"
> >
{{ 'cart.checkout' | translate }} {{ 'cart.checkout' | translate }}
</button> </button>
@@ -207,7 +220,7 @@
<div class="payment-info"> <div class="payment-info">
<div class="payment-amount"> <div class="payment-amount">
<span class="label">{{ 'cart.amountToPay' | translate }}</span> <span class="label">{{ 'cart.amountToPay' | translate }}</span>
<span class="amount">{{ totalPrice() | number:'1.2-2' }} {{ currentCurrency }}</span> <span class="amount">{{ totalWithDelivery() | number:'1.2-2' }} {{ currentCurrency }}</span>
</div> </div>
<div class="waiting-indicator"> <div class="waiting-indicator">

View File

@@ -555,6 +555,12 @@
font-weight: 700; font-weight: 700;
color: #497671; color: #497671;
} }
.delivery-price {
font-size: 0.85rem;
font-weight: 500;
color: #697777;
}
} }
// Dexar quantity controls // Dexar quantity controls
@@ -838,7 +844,7 @@
color: #6b7280; color: #6b7280;
&.delivery { &.delivery {
display: none; // Hide delivery in Novo display: flex;
} }
&.total { &.total {
@@ -1010,6 +1016,27 @@
} }
} }
.delivery-warning {
margin: -4px 0 0;
padding: 10px 12px;
border-radius: 10px;
font-size: 0.85rem;
line-height: 1.5;
}
.cart-container.dexar .cart-summary .delivery-warning {
color: #9a6700;
background: #fff8e8;
border: 1px solid #f1ddb2;
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.cart-container.novo .cart-summary .delivery-warning {
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
}
// Dexar checkbox colors // Dexar checkbox colors
.cart-container.dexar .terms-agreement .checkbox-container { .cart-container.dexar .terms-agreement .checkbox-container {
input[type="checkbox"]:checked ~ .checkmark { input[type="checkbox"]:checked ~ .checkmark {
@@ -1830,6 +1857,7 @@
margin: 0; margin: 0;
line-height: 1.5; line-height: 1.5;
display: -webkit-box; display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;

View File

@@ -3,9 +3,10 @@ import { DecimalPipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { CartService, ApiService, LanguageService, AuthService } from '../../services'; import { CartService, ApiService, LanguageService, AuthService } from '../../services';
import { Item, CartItem } from '../../models'; import { Item, CartItem, DeliveryOption } from '../../models';
import { interval, of, Subscription } from 'rxjs'; import { interval, of, Subscription } from 'rxjs';
import { catchError, exhaustMap, take, timeout } from 'rxjs/operators'; import { catchError, exhaustMap, take, timeout } from 'rxjs/operators';
import { DeliverySelectorComponent } from '../../components/delivery-selector/delivery-selector.component';
import { EmptyCartIconComponent } from '../../components/empty-cart-icon/empty-cart-icon.component'; import { EmptyCartIconComponent } from '../../components/empty-cart-icon/empty-cart-icon.component';
import { TelegramLoginComponent } from '../../components/telegram-login/telegram-login.component'; import { TelegramLoginComponent } from '../../components/telegram-login/telegram-login.component';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
@@ -17,7 +18,7 @@ import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MAX_CHECKS, PAYMENT_TIMEOUT_CLOSE_MS,
@Component({ @Component({
selector: 'app-cart', selector: 'app-cart',
imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe], imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe],
templateUrl: './cart.component.html', templateUrl: './cart.component.html',
styleUrls: ['./cart.component.scss'], styleUrls: ['./cart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -26,6 +27,10 @@ export class CartComponent implements OnDestroy {
items; items;
itemCount; itemCount;
totalPrice; totalPrice;
totalDeliveryPrice;
totalWithDelivery;
hasDeliveryPrice;
allRequiredDeliveriesSelected;
termsAccepted = false; termsAccepted = false;
isnovo = environment.theme === 'novo'; isnovo = environment.theme === 'novo';
@@ -68,6 +73,10 @@ export class CartComponent implements OnDestroy {
this.items = this.cartService.items; this.items = this.cartService.items;
this.itemCount = this.cartService.itemCount; this.itemCount = this.cartService.itemCount;
this.totalPrice = this.cartService.totalPrice; this.totalPrice = this.cartService.totalPrice;
this.totalDeliveryPrice = this.cartService.totalDeliveryPrice;
this.totalWithDelivery = this.cartService.totalWithDelivery;
this.hasDeliveryPrice = this.cartService.hasDeliveryPrice;
this.allRequiredDeliveriesSelected = this.cartService.allRequiredDeliveriesSelected;
} }
requestLogin(): void { requestLogin(): void {
@@ -103,7 +112,6 @@ export class CartComponent implements OnDestroy {
} }
onSwipeStart(itemID: number, event: TouchEvent): void { onSwipeStart(itemID: number, event: TouchEvent): void {
const item = event.currentTarget as HTMLElement;
const startX = event.touches[0].clientX; const startX = event.touches[0].clientX;
const onMove = (e: TouchEvent) => { const onMove = (e: TouchEvent) => {
@@ -142,8 +150,18 @@ export class CartComponent implements OnDestroy {
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); } itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
itemDesc(item: Item): string { return getTranslatedField(item, 'simpleDescription', this.langService.currentLanguage()); } itemDesc(item: Item): string { return getTranslatedField(item, 'simpleDescription', this.langService.currentLanguage()); }
get currentCurrency(): string { return this.langService.currentCurrency(); } get currentCurrency(): string { return this.langService.currentCurrency(); }
get isCheckoutDisabled(): boolean { return !this.termsAccepted || !this.isAuthenticated() || !this.allRequiredDeliveriesSelected(); }
selectDelivery(itemID: number, selectedDelivery: DeliveryOption | null): void {
this.cartService.setSelectedDelivery(itemID, selectedDelivery);
}
checkout(): void { checkout(): void {
if (!this.allRequiredDeliveriesSelected()) {
alert(this.i18n.t('cart.deliveryRequired'));
return;
}
if (!this.termsAccepted) { if (!this.termsAccepted) {
alert(this.i18n.t('cart.acceptTerms')); alert(this.i18n.t('cart.acceptTerms'));
return; return;
@@ -195,7 +213,7 @@ export class CartComponent implements OnDestroy {
createPayment(): void { createPayment(): void {
const orderId = this.generateOrderId(); const orderId = this.generateOrderId();
const paymentPayload = { const paymentPayload = {
amount: Number(this.totalPrice()), amount: Number(this.totalWithDelivery()),
currency: 'RUB' as const, currency: 'RUB' as const,
siteuserID: this.getPaymentUserId(), siteuserID: this.getPaymentUserId(),
siteorderID: orderId, siteorderID: orderId,
@@ -342,7 +360,8 @@ export class CartComponent implements OnDestroy {
? item.price * (1 - item.discount / 100) ? item.price * (1 - item.discount / 100)
: item.price, : item.price,
currency: item.currency, currency: item.currency,
quantity: item.quantity quantity: item.quantity,
...(item.selectedDelivery ? { delivery: item.selectedDelivery } : {})
})) }))
}; };
@@ -410,7 +429,8 @@ export class CartComponent implements OnDestroy {
? item.price * (1 - item.discount / 100) ? item.price * (1 - item.discount / 100)
: item.price, : item.price,
currency: item.currency, currency: item.currency,
quantity: item.quantity quantity: item.quantity,
...(item.selectedDelivery ? { delivery: item.selectedDelivery } : {})
})) }))
}; };
@@ -470,7 +490,7 @@ export class CartComponent implements OnDestroy {
return `order_${timestamp}_${random}`; return `order_${timestamp}_${random}`;
} }
private buildPaymentItems(): Array<{ itemID: number; price: number; name: string }> { private buildPaymentItems(): Array<{ itemID: number; price: number; name: string; quantity: number; delivery?: DeliveryOption }> {
return this.items().map((item: CartItem) => { return this.items().map((item: CartItem) => {
const unitPrice = item.discount > 0 const unitPrice = item.discount > 0
? item.price * (1 - item.discount / 100) ? item.price * (1 - item.discount / 100)
@@ -483,6 +503,8 @@ export class CartComponent implements OnDestroy {
itemID: item.itemID, itemID: item.itemID,
price: unitPrice * item.quantity, price: unitPrice * item.quantity,
name, name,
quantity: item.quantity,
...(item.selectedDelivery ? { delivery: item.selectedDelivery } : {}),
}; };
}); });
} }

View File

@@ -63,10 +63,10 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
// Check for nested subcategories from API response (backOffice format) // Check for nested subcategories from API response (backOffice format)
const nested = parent?.subcategories || []; const nested = parent?.subcategories || [];
const visibleNested = nested.filter(s => s.visible !== false); const visibleNested = nested.filter(s => this.isDisplayableNestedSubcategory(s));
// Also check flat legacy subcategories // Also check flat legacy subcategories
const flatSubs = cats.filter(c => c.parentID === parentID); const flatSubs = cats.filter(c => c.parentID === parentID && this.isDisplayableFlatSubcategory(c));
if (visibleNested.length > 0) { if (visibleNested.length > 0) {
// Use nested subcategories from API // Use nested subcategories from API
@@ -110,6 +110,20 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
}); });
} }
private isDisplayableFlatSubcategory(category: Category): boolean {
return category.visible !== false
&& ((category.itemCount ?? 0) > 0 || (category.subcategories?.length ?? 0) > 0);
}
private isDisplayableNestedSubcategory(subcategory: Subcategory): boolean {
return subcategory.visible !== false
&& (
(subcategory.itemCount ?? 0) > 0
|| subcategory.hasItems === true
|| (subcategory.subcategories?.length ?? 0) > 0
);
}
hasSubcategories(): boolean { hasSubcategories(): boolean {
return this.subcategories().length > 0 || this.nestedSubcategories().length > 0; return this.subcategories().length > 0 || this.nestedSubcategories().length > 0;
} }
@@ -121,11 +135,11 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
} }
// TrackBy function for performance optimization // TrackBy function for performance optimization
trackByCategoryId(index: number, category: Category): number { trackByCategoryId(_index: number, category: Category): number {
return category.categoryID; return category.categoryID;
} }
trackBySubId(index: number, sub: Subcategory): string { trackBySubId(_index: number, sub: Subcategory): string {
return sub.id; return sub.id;
} }

View File

@@ -28,6 +28,7 @@ export class HomeComponent implements OnInit, OnDestroy {
topLevelCategories = computed(() => { topLevelCategories = computed(() => {
return this.categories() return this.categories()
.filter(cat => cat.parentID === 0) .filter(cat => cat.parentID === 0)
.filter(cat => this.isDisplayableTopLevelCategory(cat))
.sort((a, b) => (a.priority ?? Infinity) - (b.priority ?? Infinity)); .sort((a, b) => (a.priority ?? Infinity) - (b.priority ?? Infinity));
}); });
@@ -42,7 +43,7 @@ export class HomeComponent implements OnInit, OnDestroy {
private subcategoriesCache = computed(() => { private subcategoriesCache = computed(() => {
const cache = new Map<number, Category[]>(); const cache = new Map<number, Category[]>();
this.categories().forEach(cat => { this.categories().forEach(cat => {
if (cat.parentID !== 0) { if (cat.parentID !== 0 && this.isDisplayableFlatSubcategory(cat)) {
if (!cache.has(cat.parentID)) { if (!cache.has(cat.parentID)) {
cache.set(cat.parentID, []); cache.set(cat.parentID, []);
} }
@@ -90,6 +91,21 @@ export class HomeComponent implements OnInit, OnDestroy {
return this.subcategoriesCache().get(parentID) || []; return this.subcategoriesCache().get(parentID) || [];
} }
private isDisplayableFlatSubcategory(category: Category): boolean {
return category.visible !== false
&& ((category.itemCount ?? 0) > 0 || (category.subcategories?.length ?? 0) > 0);
}
private isDisplayableTopLevelCategory(category: Category): boolean {
return category.visible !== false
&& (
(category.itemCount ?? 0) > 0
|| (category.categoriesCount ?? 0) > 0
|| (category.subcategories?.length ?? 0) > 0
|| this.getSubCategories(category.categoryID).length > 0
);
}
isWideCategory(categoryID: number): boolean { isWideCategory(categoryID: number): boolean {
return this.wideCategories().has(categoryID); return this.wideCategories().has(categoryID);
} }

View File

@@ -9,7 +9,7 @@ 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';
import { SecurityContext } from '@angular/core'; 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 { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { TranslateService } from '../../i18n/translate.service'; import { TranslateService } from '../../i18n/translate.service';
@@ -297,7 +297,7 @@ export class ItemDetailComponent implements OnInit, OnDestroy {
}; };
this.apiService.submitReview(reviewData).subscribe({ this.apiService.submitReview(reviewData).subscribe({
next: (response) => { next: () => {
this.reviewSubmitStatus.set('success'); this.reviewSubmitStatus.set('success');
this.newReview = { rating: 0, comment: '', anonymous: false }; 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 { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, timer } from 'rxjs'; import { Observable, timer } from 'rxjs';
import { map, retry } from 'rxjs/operators'; import { map, retry } from 'rxjs/operators';
import { Category, Item, Subcategory } from '../models'; import { Category, DeliveryOption, Item, Subcategory } from '../models';
import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
export interface QrCreateRequest { export interface QrCreateRequest {
@@ -41,7 +42,7 @@ export interface CartPaymentRequest {
siteorderID: string; siteorderID: string;
redirectUrl: string; redirectUrl: string;
telegramUsername: string; telegramUsername: string;
items: Array<{ itemID: number; price: number; name: string }>; items: Array<{ itemID: number; price: number; name: string; quantity?: number; delivery?: DeliveryOption }>;
} }
export interface QrDynamicStatusResponse { export interface QrDynamicStatusResponse {
@@ -69,7 +70,7 @@ export class ApiService {
private readonly retryConfig = { private readonly retryConfig = {
count: 2, 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) {} constructor(private http: HttpClient) {}
@@ -86,6 +87,83 @@ export class ApiService {
return c.startsWith('0x') ? '#' + c.slice(2) : c; return c.startsWith('0x') ? '#' + c.slice(2) : c;
} }
private normalizeDeliveryData(
raw: any,
legacyDeliveryPrice?: number
): { options: DeliveryOption[]; isDigital: boolean; requiresSelection: boolean } {
const rawDelivery = raw.delivery ?? raw.deliveries;
if (typeof rawDelivery === 'string' && rawDelivery.trim().toLowerCase() === 'digital') {
return { options: [], isDigital: true, requiresSelection: false };
}
const deliveryCandidates = Array.isArray(rawDelivery)
? rawDelivery
: rawDelivery != null
? [rawDelivery]
: [];
const options = deliveryCandidates
.map(candidate => normalizeDeliveryOption(candidate))
.filter((option): option is DeliveryOption => option !== null);
if (options.length > 0) {
return { options, isDigital: false, requiresSelection: rawDelivery != null };
}
if (legacyDeliveryPrice !== undefined) {
return {
options: [{ deliveryPrice: legacyDeliveryPrice, deliveryPlace: '', deliveryTime: '' }],
isDigital: false,
requiresSelection: false,
};
}
if (rawDelivery != null) {
return { options: [], isDigital: true, requiresSelection: false };
}
return { options: [], isDigital: false, requiresSelection: false };
}
private normalizeSubcategory(raw: any): Subcategory {
const subcategory: Subcategory = {
id: String(raw.id ?? raw.categoryId ?? raw.categoryID ?? ''),
name: typeof raw.name === 'string' ? raw.name : '',
visible: raw.visible ?? true,
priority: raw.priority ?? 0,
img: raw.img ? this.resolveImageUrl(raw.img) : undefined,
categoryId: String(raw.categoryId ?? raw.categoryID ?? raw.id ?? ''),
parentId: String(raw.parentId ?? raw.parentID ?? ''),
itemCount: raw.itemCount ?? raw.ItemsCount ?? 0,
hasItems: raw.hasItems,
subcategories: Array.isArray(raw.subcategories)
? raw.subcategories
.map((sub: any) => this.normalizeSubcategory(sub))
.filter((sub: Subcategory) => this.isDisplayableSubcategory(sub))
: [],
};
return subcategory;
}
private isDisplayableSubcategory(subcategory: Subcategory): boolean {
if (subcategory.visible === false) {
return false;
}
return (subcategory.itemCount ?? 0) > 0
|| subcategory.hasItems === true
|| (subcategory.subcategories?.length ?? 0) > 0;
}
private isDisplayableCategory(category: Category): boolean {
return category.visible !== false;
}
private isDisplayableItem(item: Item): boolean {
return item.visible !== false;
}
/** Resolve relative image URLs (e.g. ./images/x.webp) against site origin */ /** 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 '';
@@ -102,6 +180,9 @@ export class ApiService {
private normalizeItem(raw: any): Item { private normalizeItem(raw: any): Item {
const { partnerID, ...rest } = raw; const { partnerID, ...rest } = raw;
const item: Item = { ...rest }; const item: Item = { ...rest };
let legacyDeliveryPrice = normalizeOptionalNumber(
raw.deliveryPrice ?? raw.delivery_price ?? raw.deliveryprice
);
// Extract price/currency/remaining/colour/size from itemDetails[] // Extract price/currency/remaining/colour/size from itemDetails[]
// Note: Go struct tag is "itemdetails" but actual API may send "itemDetails" // Note: Go struct tag is "itemdetails" but actual API may send "itemDetails"
@@ -112,16 +193,33 @@ export class ApiService {
...d, ...d,
colour: this.normalizeColor(d.colour || d.color || ''), colour: this.normalizeColor(d.colour || d.color || ''),
color: undefined, color: undefined,
deliveryPrice: normalizeOptionalNumber(
d.deliveryPrice ?? d.delivery_price ?? d.deliveryprice
),
})); }));
if (item.price == null || item.price === 0) item.price = detail.price; if (item.price == null || item.price === 0) item.price = detail.price;
if (!item.currency) item.currency = detail.currency; if (!item.currency) item.currency = detail.currency;
if (!item.colour) item.colour = this.normalizeColor(detail.colour || detail.color || ''); if (!item.colour) item.colour = this.normalizeColor(detail.colour || detail.color || '');
if (!item.size) item.size = detail.size || ''; if (!item.size) item.size = detail.size || '';
if (legacyDeliveryPrice === undefined) {
legacyDeliveryPrice = normalizeOptionalNumber(
detail.deliveryPrice ?? detail.delivery_price ?? detail.deliveryprice
);
}
// Use remaining from detail for stock level // Use remaining from detail for stock level
if (raw.remaining == null && detail.remaining != null) { if (raw.remaining == null && detail.remaining != null) {
(raw as any).remaining = detail.remaining; (raw as any).remaining = detail.remaining;
} }
} }
const deliveryData = this.normalizeDeliveryData(raw, legacyDeliveryPrice);
if (deliveryData.options.length > 0) {
item.deliveryOptions = deliveryData.options;
item.deliveryMode = 'selectable';
item.deliverySelectionRequired = deliveryData.requiresSelection;
} else if (deliveryData.isDigital) {
item.deliveryMode = 'digital';
item.deliverySelectionRequired = false;
}
// Map backOffice string id → legacy numeric itemID // Map backOffice string id → legacy numeric itemID
if (raw.id != null && raw.itemID == null) { if (raw.id != null && raw.itemID == null) {
@@ -242,7 +340,9 @@ export class ApiService {
if (!items || !Array.isArray(items)) { if (!items || !Array.isArray(items)) {
return []; return [];
} }
return items.map(item => this.normalizeItem(item)); return items
.map(item => this.normalizeItem(item))
.filter(item => this.isDisplayableItem(item));
} }
/** /**
@@ -300,7 +400,9 @@ export class ApiService {
cat.name = cat.name || ''; cat.name = cat.name || '';
if (raw.subcategories && Array.isArray(raw.subcategories)) { if (raw.subcategories && Array.isArray(raw.subcategories)) {
cat.subcategories = raw.subcategories; cat.subcategories = raw.subcategories
.map((sub: any) => this.normalizeSubcategory(sub))
.filter((sub: Subcategory) => this.isDisplayableSubcategory(sub));
} }
return cat; return cat;
@@ -308,7 +410,9 @@ export class ApiService {
private normalizeCategories(cats: any[] | null | undefined): Category[] { private normalizeCategories(cats: any[] | null | undefined): Category[] {
if (!cats || !Array.isArray(cats)) return []; if (!cats || !Array.isArray(cats)) return [];
return cats.map(c => this.normalizeCategory(c)); return cats
.map(c => this.normalizeCategory(c))
.filter(category => this.isDisplayableCategory(category));
} }
// ─── Core Marketplace Endpoints ─────────────────────────── // ─── Core Marketplace Endpoints ───────────────────────────
@@ -450,8 +554,9 @@ export class ApiService {
submitPurchaseEmail(emailData: { submitPurchaseEmail(emailData: {
email: string; email: string;
phone?: string;
telegramUserId: string | null; telegramUserId: string | null;
items: Array<{ itemID: number; name: string; price: number; currency: string }>; items: Array<{ itemID: number; name: string; price: number; currency: string; quantity?: number; delivery?: DeliveryOption }>;
}): Observable<{ message: string }> { }): Observable<{ message: string }> {
return this.http.post<{ message: string }>(`${this.baseUrl}/purchase-email`, emailData); return this.http.post<{ message: string }>(`${this.baseUrl}/purchase-email`, emailData);
} }

View File

@@ -1,7 +1,7 @@
import { Injectable, signal, computed, effect } from '@angular/core'; import { Injectable, signal, computed, effect, Injector } from '@angular/core';
import { ApiService } from './api.service'; import { DeliveryOption, CartItem } from '../models';
import { Item, CartItem } from '../models';
import { getDiscountedPrice } from '../utils/item.utils'; import { getDiscountedPrice } from '../utils/item.utils';
import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import type { } from '../types/telegram.types'; import type { } from '../types/telegram.types';
@@ -28,8 +28,27 @@ export class CartService {
return total + (getDiscountedPrice(item) * item.quantity); return total + (getDiscountedPrice(item) * item.quantity);
}, 0); }, 0);
}); });
totalDeliveryPrice = computed(() => {
const items = this.cartItems();
if (!Array.isArray(items)) return 0;
return items.reduce((total, item) => {
return total + ((item.selectedDelivery?.deliveryPrice ?? 0) * item.quantity);
}, 0);
});
totalWithDelivery = computed(() => this.totalPrice() + this.totalDeliveryPrice());
allRequiredDeliveriesSelected = computed(() => {
const items = this.cartItems();
if (!Array.isArray(items)) return true;
return items.every(item => !this.itemRequiresDeliverySelection(item) || item.selectedDelivery != null);
});
hasDeliveryPrice = computed(() => {
const items = this.cartItems();
if (!Array.isArray(items)) return false;
return this.allRequiredDeliveriesSelected()
&& items.some(item => (item.deliveryOptions?.length ?? 0) > 0 || item.selectedDelivery != null);
});
constructor(private apiService: ApiService) { constructor(private injector: Injector) {
this.loadCart(); this.loadCart();
// Auto-save whenever cart changes (skip the initial empty state) // Auto-save whenever cart changes (skip the initial empty state)
@@ -41,6 +60,74 @@ export class CartService {
}); });
} }
private sameDeliveryOption(left: DeliveryOption, right: DeliveryOption): boolean {
return left.deliveryPrice === right.deliveryPrice
&& left.deliveryPlace === right.deliveryPlace
&& left.deliveryTime === right.deliveryTime;
}
private itemRequiresDeliverySelection(item: CartItem): boolean {
return item.deliveryMode !== 'digital'
&& (item.deliveryOptions?.length ?? 0) > 0
&& item.deliverySelectionRequired !== false;
}
private normalizeDeliveryState(item: CartItem): {
deliveryMode?: CartItem['deliveryMode'];
deliveryOptions: DeliveryOption[];
deliverySelectionRequired: boolean;
selectedDelivery: DeliveryOption | null;
} {
const normalizedOptions = Array.isArray(item.deliveryOptions)
? item.deliveryOptions
.map(option => normalizeDeliveryOption(option))
.filter((option): option is DeliveryOption => option !== null)
: [];
const legacyDeliveryPrice = normalizeOptionalNumber(item.deliveryPrice);
const deliveryOptions = normalizedOptions.length > 0
? normalizedOptions
: legacyDeliveryPrice !== undefined
? [{ deliveryPrice: legacyDeliveryPrice, deliveryPlace: '', deliveryTime: '' }]
: [];
const deliveryMode = item.deliveryMode === 'digital'
? 'digital'
: deliveryOptions.length > 0
? 'selectable'
: undefined;
const deliverySelectionRequired = deliveryOptions.length > 0
? item.deliverySelectionRequired !== false && normalizedOptions.length > 0
: false;
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
: selectedDelivery;
return {
deliveryMode,
deliveryOptions,
deliverySelectionRequired,
selectedDelivery: matchedSelection,
};
}
private normalizeCartItem(item: CartItem): CartItem {
const { deliveryPrice, ...rest } = item;
const deliveryState = this.normalizeDeliveryState(item);
const hasDeliveryState = !!deliveryState.deliveryMode
|| deliveryState.deliveryOptions.length > 0
|| deliveryState.selectedDelivery != null;
return {
...rest,
quantity: item.quantity || 1,
...(hasDeliveryState ? { deliverySelectionRequired: deliveryState.deliverySelectionRequired } : {}),
...(deliveryState.deliveryMode ? { deliveryMode: deliveryState.deliveryMode } : {}),
...(deliveryState.deliveryOptions.length > 0 ? { deliveryOptions: deliveryState.deliveryOptions } : {}),
...(deliveryState.selectedDelivery ? { selectedDelivery: deliveryState.selectedDelivery } : {}),
};
}
private saveToStorage(items: CartItem[]): void { private saveToStorage(items: CartItem[]): void {
const data = JSON.stringify(items); const data = JSON.stringify(items);
@@ -90,10 +177,7 @@ export class CartService {
try { try {
const items = JSON.parse(json); const items = JSON.parse(json);
if (Array.isArray(items)) { if (Array.isArray(items)) {
this.cartItems.set(items.map(item => ({ this.cartItems.set(items.map(item => this.normalizeCartItem(item)));
...item,
quantity: item.quantity || 1
})));
return true; return true;
} }
} catch (err) { } catch (err) {
@@ -116,23 +200,28 @@ export class CartService {
} else { } else {
// Get item details from API and add to cart // Get item details from API and add to cart
this.addingItems.add(itemID); this.addingItems.add(itemID);
this.apiService.getItem(itemID).subscribe({ import('./api.service').then(({ ApiService }) => {
next: (item) => { this.injector.get(ApiService).getItem(itemID).subscribe({
const cartItem: CartItem = { next: (item) => {
...item, const cartItem = this.normalizeCartItem({
quantity, ...item,
...(variant?.colour != null && { colour: variant.colour }), quantity,
...(variant?.size != null && { size: variant.size }), ...(variant?.colour != null && { colour: variant.colour }),
...(variant?.price != null && { price: variant.price }), ...(variant?.size != null && { size: variant.size }),
...(variant?.currency != null && { currency: variant.currency }), ...(variant?.price != null && { price: variant.price }),
}; ...(variant?.currency != null && { currency: variant.currency }),
this.cartItems.set([...this.cartItems(), cartItem]); });
this.addingItems.delete(itemID); this.cartItems.set([...this.cartItems(), cartItem]);
}, this.addingItems.delete(itemID);
error: (err) => { },
console.error('Error adding to cart:', err); error: (err) => {
this.addingItems.delete(itemID); console.error('Error adding to cart:', err);
} this.addingItems.delete(itemID);
}
});
}).catch((err) => {
console.error('Error loading API service:', err);
this.addingItems.delete(itemID);
}); });
} }
} }
@@ -150,6 +239,22 @@ export class CartService {
this.cartItems.set(updatedItems); this.cartItems.set(updatedItems);
} }
setSelectedDelivery(itemID: number, selectedDelivery: DeliveryOption | null): void {
const normalizedSelection = normalizeDeliveryOption(selectedDelivery);
const updatedItems = this.cartItems().map(item => {
if (item.itemID !== itemID) {
return item;
}
return this.normalizeCartItem({
...item,
selectedDelivery: normalizedSelection,
});
});
this.cartItems.set(updatedItems);
}
removeItems(itemIDs: number[]): void { removeItems(itemIDs: number[]): void {
const currentItems = this.cartItems(); const currentItems = this.cartItems();
const updatedItems = currentItems.filter(item => !itemIDs.includes(item.itemID)); const updatedItems = currentItems.filter(item => !itemIDs.includes(item.itemID));

View File

@@ -1,4 +1,4 @@
export interface TelegramUser { interface TelegramUser {
id: number; id: number;
first_name: string; first_name: string;
last_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'; return item.photos?.[0]?.url || '/assets/images/placeholder.svg';
} }
export function getAllImages(item: Item): string[] { export function trackByItemId(_index: number, item: Item): number | 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 {
return item.id || item.itemID; 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. * 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 */ /* 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 */ /* Default CSS Variables - will be overridden by theme files */
:root { :root {
--primary-color: #497671; --primary-color: #497671;
@@ -191,6 +185,7 @@ a, button, input, textarea, select {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }

View File

@@ -28,9 +28,6 @@
"references": [ "references": [
{ {
"path": "./tsconfig.app.json" "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"
]
}