17 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
sdarbinyan
51445a7341 telegram desktop 2026-06-20 15:09:15 +04:00
sdarbinyan
56df8632cb styles 2026-06-20 15:08:10 +04:00
sdarbinyan
824bed199c version 2026-06-20 15:05:09 +04:00
sdarbinyan
b5728f1238 test 3 2026-06-20 14:50:16 +04:00
sdarbinyan
04814aeeda reset 2026-06-20 14:40:22 +04:00
sdarbinyan
9386fbc2f8 currency for market 2026-06-20 14:00:28 +04:00
sdarbinyan
a06b654103 logo fix 2026-06-20 13:33:52 +04:00
sdarbinyan
9aaff4d80a removed parazite 2026-06-19 16:13:54 +04:00
sdarbinyan
7a06843bf5 fixes 2026-06-19 15:01:54 +04:00
sdarbinyan
1decc08f77 userId 2026-06-19 12:43:25 +04:00
sdarbinyan
c0cfbcbcbb changed type 2026-06-19 02:00:34 +04:00
sdarbinyan
688c225911 removed parasite 2026-06-19 01:57:27 +04:00
40 changed files with 1427 additions and 746 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"
}, },
{ {

968
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,24 +12,21 @@
"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"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^21.1.5", "@angular/animations": "21.1.5",
"@angular/cdk": "^21.1.5", "@angular/cdk": "21.1.5",
"@angular/common": "^21.0.6", "@angular/common": "21.1.5",
"@angular/compiler": "^21.0.6", "@angular/compiler": "21.1.5",
"@angular/core": "^21.0.6", "@angular/core": "21.1.5",
"@angular/forms": "^21.0.6", "@angular/forms": "21.1.5",
"@angular/material": "^21.1.5", "@angular/platform-browser": "21.1.5",
"@angular/platform-browser": "^21.0.6", "@angular/router": "21.1.5",
"@angular/platform-browser-dynamic": "^21.1.5", "@angular/service-worker": "21.1.5",
"@angular/router": "^21.0.6",
"@angular/service-worker": "^21.0.6",
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primeng": "^21.0.3", "primeng": "^21.0.3",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
@@ -37,16 +34,9 @@
"zone.js": "~0.16.0" "zone.js": "~0.16.0"
}, },
"devDependencies": { "devDependencies": {
"@angular/build": "^21.0.6", "@angular/build": "21.1.5",
"@angular/cli": "^21.0.6", "@angular/cli": "21.1.5",
"@angular/compiler-cli": "^21.0.6", "@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-telegram-login /> <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; 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,11 +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 { TelegramLoginComponent } from './components/telegram-login/telegram-login.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';
@@ -17,7 +16,7 @@ import { TranslateService } from './i18n/translate.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TelegramLoginComponent, TranslatePipe], imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss' styleUrl: './app.scss'
}) })
@@ -28,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);
@@ -56,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: () => {
@@ -92,13 +91,5 @@ export class App implements OnInit {
console.error('Update check failed:', err); console.error('Update check failed:', err);
} }
}); });
this.swUpdate.versionUpdates
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(event => {
if (event.type === 'VERSION_READY') {
console.log('New app version ready');
}
});
} }
} }

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

@@ -3,7 +3,7 @@
<header class="novo-header"> <header class="novo-header">
<div class="novo-header-container"> <div class="novo-header-container">
<div class="novo-left"> <div class="novo-left">
<a [routerLink]="'/' | langRoute" class="novo-logo" (click)="closeMenu()"> <a [attr.href]="homeUrl" class="novo-logo" (click)="navigateHome($event)">
<app-logo /> <app-logo />
<!-- <span class="novo-brand">{{ brandName }}</span> --> <!-- <span class="novo-brand">{{ brandName }}</span> -->
</a> </a>
@@ -36,13 +36,18 @@
<app-language-selector /> <app-language-selector />
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()" [attr.aria-label]="'header.cart' | translate"> <a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()" [attr.aria-label]="'header.cart' | translate">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"> <span class="novo-cart-icon">
<circle cx="9" cy="21" r="1"></circle> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="20" cy="21" r="1"></circle> <circle cx="9" cy="21" r="1"></circle>
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path> <circle cx="20" cy="21" r="1"></circle>
</svg> <path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
</svg>
@if (cartItemCount() > 0) {
<span class="novo-cart-badge">{{ cartItemCount() }}</span>
}
</span>
@if (cartItemCount() > 0) { @if (cartItemCount() > 0) {
<span class="novo-cart-badge">{{ cartItemCount() }}</span> <span class="novo-cart-total">{{ formatCartTotal(cartTotal()) }}</span>
} }
</a> </a>
@@ -59,7 +64,7 @@
<header class="dexar-header"> <header class="dexar-header">
<div class="dexar-header-container"> <div class="dexar-header-container">
<!-- Logo --> <!-- Logo -->
<a [routerLink]="'/' | langRoute" class="dexar-logo" (click)="closeMenu()"> <a [attr.href]="homeUrl" class="dexar-logo" (click)="navigateHome($event)">
<app-logo /> <app-logo />
</a> </a>
@@ -102,13 +107,18 @@
<div class="dexar-actions"> <div class="dexar-actions">
<!-- Cart Button --> <!-- Cart Button -->
<a [routerLink]="'/cart' | langRoute" routerLinkActive="dexar-cart-active" class="dexar-cart-btn" (click)="closeMenu()"> <a [routerLink]="'/cart' | langRoute" routerLinkActive="dexar-cart-active" class="dexar-cart-btn" (click)="closeMenu()">
<svg width="32" height="24" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <span class="dexar-cart-icon">
<path d="M12 0.5H36C42.3513 0.5 47.5 5.64873 47.5 12V20C47.5 26.3513 42.3513 31.5 36 31.5H12C5.64873 31.5 0.5 26.3513 0.5 20V12C0.5 5.64873 5.64873 0.5 12 0.5Z" fill="transparent" /> <svg width="32" height="24" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0.5H36C42.3513 0.5 47.5 5.64873 47.5 12V20C47.5 26.3513 42.3513 31.5 36 31.5H12C5.64873 31.5 0.5 26.3513 0.5 20V12C0.5 5.64873 5.64873 0.5 12 0.5Z" stroke="#677B78" /> <path d="M12 0.5H36C42.3513 0.5 47.5 5.64873 47.5 12V20C47.5 26.3513 42.3513 31.5 36 31.5H12C5.64873 31.5 0.5 26.3513 0.5 20V12C0.5 5.64873 5.64873 0.5 12 0.5Z" fill="transparent" />
<path d="M10 3.9C10 3.40294 10.4029 3 10.9 3H13.6C14.013 3 14.373 3.28107 14.4731 3.68172L15.2027 6.6H36.1C36.3677 6.6 36.6216 6.7192 36.7925 6.92523C36.9635 7.13125 37.0339 7.40271 36.9846 7.66586L34.2846 22.0659C34.2048 22.4915 33.8331 22.8 33.4 22.8H31.6H19H17.2C16.7669 22.8 16.3952 22.4915 16.3154 22.0659L13.6204 7.69224L12.8973 4.8H10.9C10.4029 4.8 10 4.39706 10 3.9ZM15.5844 8.4L17.9469 21H32.6531L35.0156 8.4H15.5844ZM19 22.8C17.0118 22.8 15.4 24.4118 15.4 26.4C15.4 28.3882 17.0118 30 19 30C20.9882 30 22.6 28.3882 22.6 26.4C22.6 24.4118 20.9882 22.8 19 22.8ZM31.6 22.8C29.6118 22.8 28 24.4118 28 26.4C28 28.3882 29.6118 30 31.6 30C33.5882 30 35.2 28.3882 35.2 26.4C35.2 24.4118 33.5882 22.8 31.6 22.8ZM19 24.6C19.9941 24.6 20.8 25.4059 20.8 26.4C20.8 27.3941 19.9941 28.2 19 28.2C18.0059 28.2 17.2 27.3941 17.2 26.4C17.2 25.4059 18.0059 24.6 19 24.6ZM31.6 24.6C32.5941 24.6 33.4 25.4059 33.4 26.4C33.4 27.3941 32.5941 28.2 31.6 28.2C30.6059 28.2 29.8 27.3941 29.8 26.4C29.8 25.4059 30.6059 24.6 31.6 24.6Z" fill="#1E3C38" /> <path d="M12 0.5H36C42.3513 0.5 47.5 5.64873 47.5 12V20C47.5 26.3513 42.3513 31.5 36 31.5H12C5.64873 31.5 0.5 26.3513 0.5 20V12C0.5 5.64873 5.64873 0.5 12 0.5Z" stroke="#677B78" />
</svg> <path d="M10 3.9C10 3.40294 10.4029 3 10.9 3H13.6C14.013 3 14.373 3.28107 14.4731 3.68172L15.2027 6.6H36.1C36.3677 6.6 36.6216 6.7192 36.7925 6.92523C36.9635 7.13125 37.0339 7.40271 36.9846 7.66586L34.2846 22.0659C34.2048 22.4915 33.8331 22.8 33.4 22.8H31.6H19H17.2C16.7669 22.8 16.3952 22.4915 16.3154 22.0659L13.6204 7.69224L12.8973 4.8H10.9C10.4029 4.8 10 4.39706 10 3.9ZM15.5844 8.4L17.9469 21H32.6531L35.0156 8.4H15.5844ZM19 22.8C17.0118 22.8 15.4 24.4118 15.4 26.4C15.4 28.3882 17.0118 30 19 30C20.9882 30 22.6 28.3882 22.6 26.4C22.6 24.4118 20.9882 22.8 19 22.8ZM31.6 22.8C29.6118 22.8 28 24.4118 28 26.4C28 28.3882 29.6118 30 31.6 30C33.5882 30 35.2 28.3882 35.2 26.4C35.2 24.4118 33.5882 22.8 31.6 22.8ZM19 24.6C19.9941 24.6 20.8 25.4059 20.8 26.4C20.8 27.3941 19.9941 28.2 19 28.2C18.0059 28.2 17.2 27.3941 17.2 26.4C17.2 25.4059 18.0059 24.6 19 24.6ZM31.6 24.6C32.5941 24.6 33.4 25.4059 33.4 26.4C33.4 27.3941 32.5941 28.2 31.6 28.2C30.6059 28.2 29.8 27.3941 29.8 26.4C29.8 25.4059 30.6059 24.6 31.6 24.6Z" fill="#1E3C38" />
</svg>
@if (cartItemCount() > 0) {
<span class="dexar-cart-badge">{{ cartItemCount() }}</span>
}
</span>
@if (cartItemCount() > 0) { @if (cartItemCount() > 0) {
<span class="dexar-cart-badge">{{ cartItemCount() }}</span> <span class="dexar-cart-total">{{ formatCartTotal(cartTotal()) }}</span>
} }
</a> </a>

View File

@@ -333,42 +333,60 @@
} }
.novo-cart { .novo-cart {
position: relative; display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
color: var(--text-primary); color: var(--text-primary);
text-decoration: none; text-decoration: none;
padding: 0.5rem; padding: 0.5rem;
border-radius: var(--radius-md); border-radius: var(--radius-md);
transition: all 0.3s; transition: all 0.3s;
svg {
display: block;
}
&:hover, &:hover,
&.novo-cart-active { &.novo-cart-active {
color: var(--primary-color); color: var(--primary-color);
background: var(--bg-secondary); background: var(--bg-secondary);
} }
}
.novo-cart-badge {
position: absolute; .novo-cart-icon {
top: -4px; position: relative;
right: -4px; display: flex;
background: var(--primary-color); align-items: center;
color: white; justify-content: center;
font-size: 0.7rem; line-height: 0;
font-weight: 700;
min-width: 18px; svg {
height: 18px; display: block;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 5px;
box-shadow: var(--shadow-sm);
} }
} }
.novo-cart-badge {
position: absolute;
top: -4px;
right: -4px;
background: var(--primary-color);
color: white;
font-size: 0.7rem;
font-weight: 700;
min-width: 18px;
height: 18px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 5px;
box-shadow: var(--shadow-sm);
}
.novo-cart-total {
font-size: 0.68rem;
font-weight: 700;
line-height: 1;
white-space: nowrap;
}
.novo-menu-toggle { .novo-menu-toggle {
display: none; display: none;
flex-direction: column; flex-direction: column;
@@ -492,7 +510,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 32px; gap: 32px;
height: 48px; min-height: 48px;
} }
.dexar-logo { .dexar-logo {
@@ -615,15 +633,15 @@
} }
.dexar-cart-btn { .dexar-cart-btn {
position: relative;
width: 36px;
height: 28px;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 36px;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
gap: 2px;
svg { svg {
width: 36px; width: 36px;
@@ -639,6 +657,15 @@
} }
} }
.dexar-cart-icon {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 28px;
}
.dexar-cart-badge { .dexar-cart-badge {
position: absolute; position: absolute;
top: -8px; top: -8px;
@@ -658,6 +685,15 @@
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
} }
.dexar-cart-total {
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-weight: 700;
font-size: 10px;
line-height: 1;
color: #1e3c38;
white-space: nowrap;
}
.dexar-lang-selector { .dexar-lang-selector {
display: flex; display: flex;
align-items: center; align-items: center;

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';
@@ -17,6 +18,7 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
}) })
export class HeaderComponent { export class HeaderComponent {
cartItemCount; cartItemCount;
cartTotal;
menuOpen = false; menuOpen = false;
brandName = environment.brandFullName; brandName = environment.brandFullName;
logo = environment.logo; logo = environment.logo;
@@ -28,6 +30,11 @@ export class HeaderComponent {
constructor(private cartService: CartService, private router: Router) { constructor(private cartService: CartService, private router: Router) {
this.cartItemCount = this.cartService.itemCount; this.cartItemCount = this.cartService.itemCount;
this.cartTotal = this.cartService.totalPrice;
}
get homeUrl(): string {
return `/${this.langService.currentLanguage()}`;
} }
toggleMenu(): void { toggleMenu(): void {
@@ -44,6 +51,23 @@ export class HeaderComponent {
this.renderer.removeClass(this.document.body, 'dexar-menu-open'); this.renderer.removeClass(this.document.body, 'dexar-menu-open');
} }
navigateHome(event?: Event): void {
event?.preventDefault();
this.closeMenu();
const homeUrl = this.homeUrl;
const currentUrl = this.router.url.split('?')[0].split('#')[0];
if (currentUrl === homeUrl || currentUrl === `${homeUrl}/`) {
this.document.defaultView?.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
this.router.navigateByUrl(homeUrl).then(() => {
this.document.defaultView?.scrollTo({ top: 0, behavior: 'auto' });
});
}
navigateToSearch(): void { navigateToSearch(): void {
const lang = this.langService.currentLanguage(); const lang = this.langService.currentLanguage();
this.router.navigate([`/${lang}/search`]); this.router.navigate([`/${lang}/search`]);
@@ -58,4 +82,20 @@ export class HeaderComponent {
}, 100); }, 100);
}); });
} }
formatCartTotal(total: number): string {
const locale = this.langService.currentLanguage() === 'en'
? 'en-US'
: this.langService.currentLanguage() === 'hy'
? 'hy-AM'
: 'ru-RU';
const fractionDigits = Number.isInteger(total) ? 0 : 2;
const amount = new Intl.NumberFormat(locale, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: 2,
}).format(total);
const currencySymbol = this.langService.getCurrentCurrency()?.symbol ?? this.langService.currentCurrency();
return `${amount} ${currencySymbol}`;
}
} }

View File

@@ -3,12 +3,15 @@ import { environment } from '../../../environments/environment';
@Component({ @Component({
selector: 'app-logo', selector: 'app-logo',
template: `<img [src]="logoPath" [alt]="brandName + ' logo'" class="logo-img" fetchpriority="high" />`, template: `<img [src]="logoPath" [alt]="brandName + ' logo'" class="logo-img" fetchpriority="high" draggable="false" />`,
styles: [` styles: [`
.logo-img { .logo-img {
display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
pointer-events: none;
user-select: none;
} }
`], `],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,8 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnDestroy } from '@angular/core'; import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnDestroy } from '@angular/core';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { CartService } from '../../services/cart.service';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { getDiscountedPrice } from '../../utils/item.utils';
@Component({ @Component({
selector: 'app-telegram-login', selector: 'app-telegram-login',
@@ -13,7 +11,6 @@ import { getDiscountedPrice } from '../../utils/item.utils';
}) })
export class TelegramLoginComponent implements OnDestroy { export class TelegramLoginComponent implements OnDestroy {
private authService = inject(AuthService); private authService = inject(AuthService);
private cartService = inject(CartService);
showDialog = this.authService.showLoginDialog; showDialog = this.authService.showLoginDialog;
status = this.authService.status; status = this.authService.status;
@@ -22,48 +19,76 @@ export class TelegramLoginComponent implements OnDestroy {
webSessionID = signal(''); webSessionID = signal('');
qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading'); qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading');
encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl())); encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));
awaitingTelegramReturn = signal(false);
private readonly pollIntervalMs = 5000; private readonly pollIntervalMs = 5000;
private pollTimer?: ReturnType<typeof setInterval>; private pollTimer?: ReturnType<typeof setInterval>;
private readonly handleVisibilityChange = () => {
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
this.checkLoginAfterReturn();
}
};
private readonly handleWindowFocus = () => {
this.checkLoginAfterReturn();
};
private readonly handlePageShow = () => {
this.checkLoginAfterReturn();
};
constructor() { constructor() {
effect(() => { effect(() => {
if (this.showDialog()) { if (this.showDialog()) {
this.initQrLogin(); this.initQrLogin();
} else { } else {
this.awaitingTelegramReturn.set(false);
this.stopPolling(); this.stopPolling();
} }
}); });
if (typeof window !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange);
window.addEventListener('focus', this.handleWindowFocus);
window.addEventListener('pageshow', this.handlePageShow);
}
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.awaitingTelegramReturn.set(false);
this.stopPolling(); this.stopPolling();
if (typeof window !== 'undefined') {
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
window.removeEventListener('focus', this.handleWindowFocus);
window.removeEventListener('pageshow', this.handlePageShow);
}
} }
close(): void { close(): void {
this.awaitingTelegramReturn.set(false);
this.authService.hideLogin(); this.authService.hideLogin();
this.stopPolling(); this.stopPolling();
} }
openTelegramLogin(): void { openTelegramLogin(): void {
const url = this.loginUrl(); const webSessionID = this.webSessionID();
if (!url) return; if (!webSessionID || typeof window === 'undefined') return;
window.open(url, '_blank');
if (!this.pollTimer) { if (!this.pollTimer) {
const webSessionID = this.webSessionID(); this.startPolling(webSessionID);
if (webSessionID) {
this.startPolling(webSessionID);
}
} }
this.awaitingTelegramReturn.set(true);
window.location.href = this.authService.getTelegramAppLoginUrl(webSessionID);
} }
refreshQr(): void { refreshQr(): void {
this.awaitingTelegramReturn.set(false);
this.stopPolling(); this.stopPolling();
this.initQrLogin(); this.initQrLogin();
} }
private initQrLogin(): void { private initQrLogin(): void {
this.awaitingTelegramReturn.set(false);
this.qrStatus.set('loading'); this.qrStatus.set('loading');
this.loginUrl.set(''); this.loginUrl.set('');
this.webSessionID.set(''); this.webSessionID.set('');
@@ -97,8 +122,9 @@ export class TelegramLoginComponent implements OnDestroy {
this.authService.checkSessionOnce(webSessionID).subscribe({ this.authService.checkSessionOnce(webSessionID).subscribe({
next: (session) => { next: (session) => {
if (session?.active) { if (session?.active) {
this.awaitingTelegramReturn.set(false);
this.stopPolling(); this.stopPolling();
this.syncCartAndComplete(session.sessionId); this.authService.onTelegramLoginComplete();
} }
}, },
error: () => { error: () => {
@@ -108,26 +134,34 @@ export class TelegramLoginComponent implements OnDestroy {
}, this.pollIntervalMs); }, this.pollIntervalMs);
} }
private syncCartAndComplete(sessionId: string): void {
const cartItems = this.cartService.items().map(item => ({
itemID: item.itemID,
quantity: item.quantity,
colour: item.colour || '',
size: item.size || '',
price: item.discount > 0
? item.price * (1 - item.discount / 100)
: item.price,
}));
this.authService.syncCart(sessionId, cartItems).subscribe(() => {
this.authService.onTelegramLoginComplete();
});
}
private stopPolling(): void { private stopPolling(): void {
if (this.pollTimer) { if (this.pollTimer) {
clearInterval(this.pollTimer); clearInterval(this.pollTimer);
this.pollTimer = undefined; this.pollTimer = undefined;
} }
} }
private checkLoginAfterReturn(): void {
if (!this.showDialog() || !this.awaitingTelegramReturn()) {
return;
}
const webSessionID = this.webSessionID();
if (!webSessionID) {
this.awaitingTelegramReturn.set(false);
return;
}
if (!this.pollTimer) {
this.startPolling(webSessionID);
}
this.authService.checkSessionOnce(webSessionID).subscribe(session => {
if (session?.active) {
this.awaitingTelegramReturn.set(false);
this.stopPolling();
this.authService.onTelegramLoginComplete();
}
});
}
} }

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

@@ -780,8 +780,8 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
return respond([]); return respond([]);
} }
// ── POST /websession/:id (add to cart) // ── POST /usersession/:id or /websession/:id (sync cart)
if (url.match(/\/websession\/[^/]+$/) && req.method === 'POST') { if (url.match(/\/(?:user|web)session\/[^/]+$/) && req.method === 'POST') {
return respond({ return respond({
sessionId: 'mock-session', sessionId: 'mock-session',
Status: true, Status: true,

View File

@@ -1,10 +1,10 @@
export interface AuthSession { export interface AuthSession {
sessionId: string; sessionId: string;
telegramUserId: number; userId: number | null;
username: string | null; username: string | null;
displayName: string; displayName: string;
active: boolean; active: boolean;
expiresAt: string; expires: string;
} }
export interface WebSessionStart { export interface WebSessionStart {
@@ -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

@@ -225,14 +225,14 @@
.cart-content { .cart-content {
display: grid; display: grid;
grid-template-columns: 1fr 350px; grid-template-columns: minmax(0, 1fr) 350px;
gap: 24px; gap: 24px;
align-items: start; align-items: start;
} }
// Novo wider summary // Novo wider summary
.cart-container.novo .cart-content { .cart-container.novo .cart-content {
grid-template-columns: 1fr 400px; grid-template-columns: minmax(0, 1fr) 400px;
gap: 32px; gap: 32px;
} }
@@ -240,6 +240,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
min-width: 0;
} }
// Novo larger gap // Novo larger gap
@@ -554,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
@@ -837,7 +844,7 @@
color: #6b7280; color: #6b7280;
&.delivery { &.delivery {
display: none; // Hide delivery in Novo display: flex;
} }
&.total { &.total {
@@ -1009,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 {
@@ -1527,11 +1555,31 @@
// Mobile responsive // Mobile responsive
@media (max-width: 768px) { @media (max-width: 768px) {
.cart-content { .cart-content {
grid-template-columns: 1fr; grid-template-columns: minmax(0, 1fr);
gap: 20px;
}
.cart-container.novo .cart-content {
grid-template-columns: minmax(0, 1fr);
gap: 20px;
} }
.cart-summary { .cart-summary {
position: static; position: static;
width: 100%;
max-width: 100%;
min-width: 0;
}
.cart-container.novo,
.cart-container.dexar {
padding: 16px;
}
.cart-container.novo .cart-header,
.cart-container.dexar .cart-header {
flex-wrap: wrap;
gap: 12px;
} }
.remove-btn-desktop { .remove-btn-desktop {
@@ -1809,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

@@ -1,11 +1,12 @@
import { Component, computed, ChangeDetectionStrategy, signal, OnDestroy, inject } from '@angular/core'; import { Component, ChangeDetectionStrategy, signal, OnDestroy, inject } from '@angular/core';
import { DecimalPipe } from '@angular/common'; 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 } : {})
})) }))
}; };
@@ -435,8 +455,8 @@ export class CartComponent implements OnDestroy {
} }
private getTelegramUserId(): string | null { private getTelegramUserId(): string | null {
const sessionTelegramUserId = this.authService.session()?.telegramUserId; const sessionTelegramUserId = this.authService.session()?.userId;
if (sessionTelegramUserId) { if (sessionTelegramUserId !== null && sessionTelegramUserId !== undefined) {
return sessionTelegramUserId.toString(); return sessionTelegramUserId.toString();
} }
@@ -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

@@ -26,7 +26,6 @@ export class AuthService {
/** Display name of authenticated user */ /** Display name of authenticated user */
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null); readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
private readonly apiUrl = environment.apiUrl;
private readonly authApiUrl = environment.authApiUrl; private readonly authApiUrl = environment.authApiUrl;
private sessionCheckTimer?: ReturnType<typeof setTimeout>; private sessionCheckTimer?: ReturnType<typeof setTimeout>;
@@ -85,10 +84,16 @@ export class AuthService {
/** Generate the Telegram login URL for bot-based auth */ /** Generate the Telegram login URL for bot-based auth */
getTelegramLoginUrl(webSessionID = this.generateGuid()): string { getTelegramLoginUrl(webSessionID = this.generateGuid()): string {
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot'; const botUsername = this.getTelegramBotUsername();
return `https://t.me/${botUsername}?start=${encodeURIComponent(webSessionID)}`; return `https://t.me/${botUsername}?start=${encodeURIComponent(webSessionID)}`;
} }
/** Generate a Telegram app deep link for mobile login without opening a browser tab. */
getTelegramAppLoginUrl(webSessionID: string): string {
const botUsername = this.getTelegramBotUsername();
return `tg://resolve?domain=${encodeURIComponent(botUsername)}&start=${encodeURIComponent(webSessionID)}`;
}
/** Get QR code data URL for Telegram login */ /** Get QR code data URL for Telegram login */
getTelegramQrUrl(): string { getTelegramQrUrl(): string {
return this.getTelegramLoginUrl(); return this.getTelegramLoginUrl();
@@ -113,14 +118,6 @@ export class AuthService {
); );
} }
/** Sync local cart to the backend session after login */
syncCart(sessionId: string, items: Array<{ itemID: number; quantity: number; colour?: string; size?: string; price?: number }>): Observable<unknown> {
if (!items.length) return of(null);
return this.http.post(`${this.apiUrl}/websession/${sessionId}`, items, {
withCredentials: true,
}).pipe(catchError(() => of(null)));
}
/** Show login dialog (called when user tries to pay without being logged in) */ /** Show login dialog (called when user tries to pay without being logged in) */
requestLogin(): void { requestLogin(): void {
this.showLoginSignal.set(true); this.showLoginSignal.set(true);
@@ -153,7 +150,7 @@ export class AuthService {
this.sessionSignal.set(session); this.sessionSignal.set(session);
this.statusSignal.set('authenticated'); this.statusSignal.set('authenticated');
this.setStoredWebSessionID(session.sessionId); this.setStoredWebSessionID(session.sessionId);
this.scheduleSessionRefresh(session.expiresAt); this.scheduleSessionRefresh(session.expires);
} }
private clearAuthState(status: AuthStatus): void { private clearAuthState(status: AuthStatus): void {
@@ -214,19 +211,19 @@ export class AuthService {
const explicitDisplayName = this.readString(this.readFirst(response, ['displayName', 'DisplayName', 'name', 'Name'])) const explicitDisplayName = this.readString(this.readFirst(response, ['displayName', 'DisplayName', 'name', 'Name']))
?? this.readString(this.readFirst(user, ['displayName', 'DisplayName', 'name', 'Name'])) ?? this.readString(this.readFirst(user, ['displayName', 'DisplayName', 'name', 'Name']))
const displayName = explicitDisplayName ?? username ?? (fullName || 'Telegram User'); const displayName = explicitDisplayName ?? username ?? (fullName || 'Telegram User');
const telegramUserId = this.readNumber(this.readFirst(user, ['telegramUserId', 'telegramUserID', 'TelegramUserID', 'id', 'ID'])) const telegramUserId = this.readNumber(this.readFirst(user, ['userId','telegramUserId', 'telegramUserID', 'TelegramUserID', 'id', 'ID']))
?? this.readNumber(this.readFirst(response, ['telegramUserId', 'telegramUserID', 'TelegramUserID', 'userID', 'UserID'])) ?? this.readNumber(this.readFirst(response, ['userId', 'telegramUserId', 'telegramUserID', 'TelegramUserID', 'userID', 'UserID', 'UserId']))
?? 0; ?? null;
const expiresAt = this.readString(this.readFirst(response, ['expiresAt', 'ExpiresAt', 'expires', 'Expires'])) const expiresAt = this.readString(this.readFirst(response, ['expiresAt', 'ExpiresAt', 'expires', 'Expires']))
?? new Date(Date.now() + WEB_SESSION_COOKIE_MAX_AGE_SECONDS * 1000).toISOString(); ?? new Date(Date.now() + WEB_SESSION_COOKIE_MAX_AGE_SECONDS * 1000).toISOString();
return { return {
sessionId, sessionId,
telegramUserId, userId: telegramUserId,
username, username,
displayName, displayName,
active, active,
expiresAt, expires: expiresAt,
}; };
} }
@@ -357,4 +354,8 @@ export class AuthService {
document.cookie = `${WEB_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`; document.cookie = `${WEB_SESSION_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`;
} }
private getTelegramBotUsername(): string {
return (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
}
} }

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"
]
}