18 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
sdarbinyan
3e79304e5c timer 2026-06-18 18:32:36 +04:00
40 changed files with 1431 additions and 748 deletions

View File

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

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:novo": "ng build --configuration=novo-production",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"lavero": "ng serve --configuration=lavero --port 4202 --proxy-config proxy.conf.lavero.json",
"start:lavero": "ng serve --configuration=lavero --port 4202",
"build:lavero": "ng build --configuration=lavero-production"
},
"private": true,
"dependencies": {
"@angular/animations": "^21.1.5",
"@angular/cdk": "^21.1.5",
"@angular/common": "^21.0.6",
"@angular/compiler": "^21.0.6",
"@angular/core": "^21.0.6",
"@angular/forms": "^21.0.6",
"@angular/material": "^21.1.5",
"@angular/platform-browser": "^21.0.6",
"@angular/platform-browser-dynamic": "^21.1.5",
"@angular/router": "^21.0.6",
"@angular/service-worker": "^21.0.6",
"@angular/animations": "21.1.5",
"@angular/cdk": "21.1.5",
"@angular/common": "21.1.5",
"@angular/compiler": "21.1.5",
"@angular/core": "21.1.5",
"@angular/forms": "21.1.5",
"@angular/platform-browser": "21.1.5",
"@angular/router": "21.1.5",
"@angular/service-worker": "21.1.5",
"primeicons": "^7.0.0",
"primeng": "^21.0.3",
"rxjs": "~7.8.0",
@@ -37,16 +34,9 @@
"zone.js": "~0.16.0"
},
"devDependencies": {
"@angular/build": "^21.0.6",
"@angular/cli": "^21.0.6",
"@angular/compiler-cli": "^21.0.6",
"@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",
"@angular/build": "21.1.5",
"@angular/cli": "21.1.5",
"@angular/compiler-cli": "21.1.5",
"typescript": "~5.9.3"
}
}

View File

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

View File

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

View File

@@ -2,11 +2,10 @@
import { Component, OnInit, signal, ApplicationRef, inject, DestroyRef } from '@angular/core';
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { HttpClient } from '@angular/common/http';
import { HeaderComponent } from './components/header/header.component';
import { FooterComponent } from './components/footer/footer.component';
import { BackButtonComponent } from './components/back-button/back-button.component';
import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component';
import { ApiService } from './services';
import { interval, concat } from 'rxjs';
import { filter, first } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -17,7 +16,7 @@ import { TranslateService } from './i18n/translate.service';
@Component({
selector: 'app-root',
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TelegramLoginComponent, TranslatePipe],
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe],
templateUrl: './app.html',
styleUrl: './app.scss'
})
@@ -28,7 +27,7 @@ export class App implements OnInit {
serverAvailable = signal(false);
private destroyRef = inject(DestroyRef);
private apiService = inject(ApiService);
private http = inject(HttpClient);
private titleService = inject(Title);
private swUpdate = inject(SwUpdate);
private appRef = inject(ApplicationRef);
@@ -56,7 +55,7 @@ export class App implements OnInit {
private checkServerHealth(): void {
this.checkingServer.set(true);
this.apiService.ping()
this.http.get<{ message: string }>(`${environment.apiUrl}/ping`)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () => {
@@ -92,13 +91,5 @@ export class App implements OnInit {
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">
<div class="novo-header-container">
<div class="novo-left">
<a [routerLink]="'/' | langRoute" class="novo-logo" (click)="closeMenu()">
<a [attr.href]="homeUrl" class="novo-logo" (click)="navigateHome($event)">
<app-logo />
<!-- <span class="novo-brand">{{ brandName }}</span> -->
</a>
@@ -36,13 +36,18 @@
<app-language-selector />
<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">
<circle cx="9" cy="21" r="1"></circle>
<circle cx="20" 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>
</svg>
<span class="novo-cart-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="9" cy="21" r="1"></circle>
<circle cx="20" 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>
</svg>
@if (cartItemCount() > 0) {
<span class="novo-cart-badge">{{ cartItemCount() }}</span>
}
</span>
@if (cartItemCount() > 0) {
<span class="novo-cart-badge">{{ cartItemCount() }}</span>
<span class="novo-cart-total">{{ formatCartTotal(cartTotal()) }}</span>
}
</a>
@@ -59,7 +64,7 @@
<header class="dexar-header">
<div class="dexar-header-container">
<!-- Logo -->
<a [routerLink]="'/' | langRoute" class="dexar-logo" (click)="closeMenu()">
<a [attr.href]="homeUrl" class="dexar-logo" (click)="navigateHome($event)">
<app-logo />
</a>
@@ -102,13 +107,18 @@
<div class="dexar-actions">
<!-- Cart Button -->
<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">
<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="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="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>
<span class="dexar-cart-icon">
<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" fill="transparent" />
<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="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) {
<span class="dexar-cart-badge">{{ cartItemCount() }}</span>
<span class="dexar-cart-total">{{ formatCartTotal(cartTotal()) }}</span>
}
</a>

View File

@@ -333,42 +333,60 @@
}
.novo-cart {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
color: var(--text-primary);
text-decoration: none;
padding: 0.5rem;
border-radius: var(--radius-md);
transition: all 0.3s;
svg {
display: block;
}
&:hover,
&.novo-cart-active {
color: var(--primary-color);
background: var(--bg-secondary);
}
}
.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-icon {
position: relative;
display: flex;
align-items: center;
justify-content: center;
line-height: 0;
svg {
display: block;
}
}
.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 {
display: none;
flex-direction: column;
@@ -492,7 +510,7 @@
display: flex;
align-items: center;
gap: 32px;
height: 48px;
min-height: 48px;
}
.dexar-logo {
@@ -615,15 +633,15 @@
}
.dexar-cart-btn {
position: relative;
width: 36px;
height: 28px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 36px;
text-decoration: none;
cursor: pointer;
transition: opacity 0.3s ease;
gap: 2px;
svg {
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 {
position: absolute;
top: -8px;
@@ -658,6 +685,15 @@
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 {
display: flex;
align-items: center;

View File

@@ -1,6 +1,7 @@
import { Component, ChangeDetectionStrategy, Renderer2, inject, DOCUMENT } from '@angular/core';
import { Router, RouterLink, RouterLinkActive } from '@angular/router';
import { CartService, LanguageService } from '../../services';
import { CartService } from '../../services/cart.service';
import { LanguageService } from '../../services/language.service';
import { environment } from '../../../environments/environment';
import { LogoComponent } from '../logo/logo.component';
import { LanguageSelectorComponent } from '../language-selector/language-selector.component';
@@ -17,6 +18,7 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
})
export class HeaderComponent {
cartItemCount;
cartTotal;
menuOpen = false;
brandName = environment.brandFullName;
logo = environment.logo;
@@ -28,6 +30,11 @@ export class HeaderComponent {
constructor(private cartService: CartService, private router: Router) {
this.cartItemCount = this.cartService.itemCount;
this.cartTotal = this.cartService.totalPrice;
}
get homeUrl(): string {
return `/${this.langService.currentLanguage()}`;
}
toggleMenu(): void {
@@ -44,6 +51,23 @@ export class HeaderComponent {
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 {
const lang = this.langService.currentLanguage();
this.router.navigate([`/${lang}/search`]);
@@ -58,4 +82,20 @@ export class HeaderComponent {
}, 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({
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: [`
.logo-img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
user-select: none;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,8 +1,6 @@
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnDestroy } from '@angular/core';
import { AuthService } from '../../services/auth.service';
import { CartService } from '../../services/cart.service';
import { TranslatePipe } from '../../i18n/translate.pipe';
import { getDiscountedPrice } from '../../utils/item.utils';
@Component({
selector: 'app-telegram-login',
@@ -13,7 +11,6 @@ import { getDiscountedPrice } from '../../utils/item.utils';
})
export class TelegramLoginComponent implements OnDestroy {
private authService = inject(AuthService);
private cartService = inject(CartService);
showDialog = this.authService.showLoginDialog;
status = this.authService.status;
@@ -22,48 +19,76 @@ export class TelegramLoginComponent implements OnDestroy {
webSessionID = signal('');
qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading');
encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));
awaitingTelegramReturn = signal(false);
private readonly pollIntervalMs = 5000;
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() {
effect(() => {
if (this.showDialog()) {
this.initQrLogin();
} else {
this.awaitingTelegramReturn.set(false);
this.stopPolling();
}
});
if (typeof window !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange);
window.addEventListener('focus', this.handleWindowFocus);
window.addEventListener('pageshow', this.handlePageShow);
}
}
ngOnDestroy(): void {
this.awaitingTelegramReturn.set(false);
this.stopPolling();
if (typeof window !== 'undefined') {
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
window.removeEventListener('focus', this.handleWindowFocus);
window.removeEventListener('pageshow', this.handlePageShow);
}
}
close(): void {
this.awaitingTelegramReturn.set(false);
this.authService.hideLogin();
this.stopPolling();
}
openTelegramLogin(): void {
const url = this.loginUrl();
if (!url) return;
const webSessionID = this.webSessionID();
if (!webSessionID || typeof window === 'undefined') return;
window.open(url, '_blank');
if (!this.pollTimer) {
const webSessionID = this.webSessionID();
if (webSessionID) {
this.startPolling(webSessionID);
}
this.startPolling(webSessionID);
}
this.awaitingTelegramReturn.set(true);
window.location.href = this.authService.getTelegramAppLoginUrl(webSessionID);
}
refreshQr(): void {
this.awaitingTelegramReturn.set(false);
this.stopPolling();
this.initQrLogin();
}
private initQrLogin(): void {
this.awaitingTelegramReturn.set(false);
this.qrStatus.set('loading');
this.loginUrl.set('');
this.webSessionID.set('');
@@ -97,8 +122,9 @@ export class TelegramLoginComponent implements OnDestroy {
this.authService.checkSessionOnce(webSessionID).subscribe({
next: (session) => {
if (session?.active) {
this.awaitingTelegramReturn.set(false);
this.stopPolling();
this.syncCartAndComplete(session.sessionId);
this.authService.onTelegramLoginComplete();
}
},
error: () => {
@@ -108,26 +134,34 @@ export class TelegramLoginComponent implements OnDestroy {
}, 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 {
if (this.pollTimer) {
clearInterval(this.pollTimer);
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_MAX_CHECKS = 36;
export const PAYMENT_TIMEOUT_CLOSE_MS = 3000;
export const PAYMENT_ERROR_CLOSE_MS = 4000;
export const LINK_COPIED_DURATION_MS = 2000;
// Infinite scroll
@@ -12,7 +11,6 @@ export const ITEMS_PER_PAGE = 50;
// Search
export const SEARCH_DEBOUNCE_MS = 300;
export const SEARCH_MIN_LENGTH = 3;
// Cache
export const CACHE_DURATION_MS = 5 * 60 * 1000;

View File

@@ -64,6 +64,12 @@ export const en: Translations = {
total: 'Total',
items: 'Products',
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',
agreeWith: 'I agree with the',
publicOffer: 'public offer',

View File

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

View File

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

View File

@@ -62,6 +62,12 @@ export interface Translations {
total: string;
items: string;
deliveryLabel: string;
deliveryMethod: string;
selectDelivery: string;
deliveryPlace: string;
deliveryTime: string;
digitalDelivery: string;
deliveryRequired: string;
toPay: string;
agreeWith: 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 {
const now = Date.now();
for (const [url, data] of cache.entries()) {

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
export interface AuthSession {
sessionId: string;
telegramUserId: number;
userId: number | null;
username: string | null;
displayName: string;
active: boolean;
expiresAt: string;
expires: string;
}
export interface WebSessionStart {
@@ -12,14 +12,4 @@ export interface WebSessionStart {
url: string;
}
export interface TelegramAuthData {
id: number;
first_name: string;
last_name?: string;
username?: string;
photo_url?: string;
auth_date: number;
hash: string;
}
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';

View File

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

View File

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

View File

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

View File

@@ -225,14 +225,14 @@
.cart-content {
display: grid;
grid-template-columns: 1fr 350px;
grid-template-columns: minmax(0, 1fr) 350px;
gap: 24px;
align-items: start;
}
// Novo wider summary
.cart-container.novo .cart-content {
grid-template-columns: 1fr 400px;
grid-template-columns: minmax(0, 1fr) 400px;
gap: 32px;
}
@@ -240,6 +240,7 @@
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
// Novo larger gap
@@ -554,6 +555,12 @@
font-weight: 700;
color: #497671;
}
.delivery-price {
font-size: 0.85rem;
font-weight: 500;
color: #697777;
}
}
// Dexar quantity controls
@@ -837,7 +844,7 @@
color: #6b7280;
&.delivery {
display: none; // Hide delivery in Novo
display: flex;
}
&.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
.cart-container.dexar .terms-agreement .checkbox-container {
input[type="checkbox"]:checked ~ .checkmark {
@@ -1527,11 +1555,31 @@
// Mobile responsive
@media (max-width: 768px) {
.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 {
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 {
@@ -1809,6 +1857,7 @@
margin: 0;
line-height: 1.5;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
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 { Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
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 { 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 { TelegramLoginComponent } from '../../components/telegram-login/telegram-login.component';
import { environment } from '../../../environments/environment';
@@ -17,7 +18,7 @@ import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MAX_CHECKS, PAYMENT_TIMEOUT_CLOSE_MS,
@Component({
selector: 'app-cart',
imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe],
imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe],
templateUrl: './cart.component.html',
styleUrls: ['./cart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -26,6 +27,10 @@ export class CartComponent implements OnDestroy {
items;
itemCount;
totalPrice;
totalDeliveryPrice;
totalWithDelivery;
hasDeliveryPrice;
allRequiredDeliveriesSelected;
termsAccepted = false;
isnovo = environment.theme === 'novo';
@@ -68,6 +73,10 @@ export class CartComponent implements OnDestroy {
this.items = this.cartService.items;
this.itemCount = this.cartService.itemCount;
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 {
@@ -103,7 +112,6 @@ export class CartComponent implements OnDestroy {
}
onSwipeStart(itemID: number, event: TouchEvent): void {
const item = event.currentTarget as HTMLElement;
const startX = event.touches[0].clientX;
const onMove = (e: TouchEvent) => {
@@ -142,8 +150,18 @@ export class CartComponent implements OnDestroy {
itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); }
itemDesc(item: Item): string { return getTranslatedField(item, 'simpleDescription', this.langService.currentLanguage()); }
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 {
if (!this.allRequiredDeliveriesSelected()) {
alert(this.i18n.t('cart.deliveryRequired'));
return;
}
if (!this.termsAccepted) {
alert(this.i18n.t('cart.acceptTerms'));
return;
@@ -195,7 +213,7 @@ export class CartComponent implements OnDestroy {
createPayment(): void {
const orderId = this.generateOrderId();
const paymentPayload = {
amount: Number(this.totalPrice()),
amount: Number(this.totalWithDelivery()),
currency: 'RUB' as const,
siteuserID: this.getPaymentUserId(),
siteorderID: orderId,
@@ -317,6 +335,9 @@ export class CartComponent implements OnDestroy {
}
private autoSubmitPurchase(): void {
setTimeout(() => {
const lang = this.langService.currentLanguage();
this.router.navigate([`/${lang}`]);}, 0);
const telegramUserId = this.getTelegramUserId();
// Telegram ID is mandatory
@@ -339,7 +360,8 @@ export class CartComponent implements OnDestroy {
? item.price * (1 - item.discount / 100)
: item.price,
currency: item.currency,
quantity: item.quantity
quantity: item.quantity,
...(item.selectedDelivery ? { delivery: item.selectedDelivery } : {})
}))
};
@@ -361,8 +383,7 @@ export class CartComponent implements OnDestroy {
});
this.paymentStatus.set(null);
const lang = this.langService.currentLanguage();
this.router.navigate([`/${lang}`]);
}
copyPaymentLink(): void {
@@ -408,7 +429,8 @@ export class CartComponent implements OnDestroy {
? item.price * (1 - item.discount / 100)
: item.price,
currency: item.currency,
quantity: item.quantity
quantity: item.quantity,
...(item.selectedDelivery ? { delivery: item.selectedDelivery } : {})
}))
};
@@ -433,8 +455,8 @@ export class CartComponent implements OnDestroy {
}
private getTelegramUserId(): string | null {
const sessionTelegramUserId = this.authService.session()?.telegramUserId;
if (sessionTelegramUserId) {
const sessionTelegramUserId = this.authService.session()?.userId;
if (sessionTelegramUserId !== null && sessionTelegramUserId !== undefined) {
return sessionTelegramUserId.toString();
}
@@ -468,7 +490,7 @@ export class CartComponent implements OnDestroy {
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) => {
const unitPrice = item.discount > 0
? item.price * (1 - item.discount / 100)
@@ -481,6 +503,8 @@ export class CartComponent implements OnDestroy {
itemID: item.itemID,
price: unitPrice * item.quantity,
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)
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
const flatSubs = cats.filter(c => c.parentID === parentID);
const flatSubs = cats.filter(c => c.parentID === parentID && this.isDisplayableFlatSubcategory(c));
if (visibleNested.length > 0) {
// 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 {
return this.subcategories().length > 0 || this.nestedSubcategories().length > 0;
}
@@ -121,11 +135,11 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
}
// TrackBy function for performance optimization
trackByCategoryId(index: number, category: Category): number {
trackByCategoryId(_index: number, category: Category): number {
return category.categoryID;
}
trackBySubId(index: number, sub: Subcategory): string {
trackBySubId(_index: number, sub: Subcategory): string {
return sub.id;
}

View File

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

View File

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

View File

@@ -2,7 +2,8 @@ import { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, timer } from 'rxjs';
import { map, retry } from 'rxjs/operators';
import { Category, Item, Subcategory } from '../models';
import { Category, DeliveryOption, Item, Subcategory } from '../models';
import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils';
import { environment } from '../../environments/environment';
export interface QrCreateRequest {
@@ -41,7 +42,7 @@ export interface CartPaymentRequest {
siteorderID: string;
redirectUrl: 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 {
@@ -69,7 +70,7 @@ export class ApiService {
private readonly retryConfig = {
count: 2,
delay: (error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
delay: (_error: unknown, retryCount: number) => timer(Math.pow(2, retryCount) * 500)
};
constructor(private http: HttpClient) {}
@@ -86,6 +87,83 @@ export class ApiService {
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 */
private resolveImageUrl(url: string): string {
if (!url) return '';
@@ -102,6 +180,9 @@ export class ApiService {
private normalizeItem(raw: any): Item {
const { partnerID, ...rest } = raw;
const item: Item = { ...rest };
let legacyDeliveryPrice = normalizeOptionalNumber(
raw.deliveryPrice ?? raw.delivery_price ?? raw.deliveryprice
);
// Extract price/currency/remaining/colour/size from itemDetails[]
// Note: Go struct tag is "itemdetails" but actual API may send "itemDetails"
@@ -112,16 +193,33 @@ export class ApiService {
...d,
colour: this.normalizeColor(d.colour || d.color || ''),
color: undefined,
deliveryPrice: normalizeOptionalNumber(
d.deliveryPrice ?? d.delivery_price ?? d.deliveryprice
),
}));
if (item.price == null || item.price === 0) item.price = detail.price;
if (!item.currency) item.currency = detail.currency;
if (!item.colour) item.colour = this.normalizeColor(detail.colour || detail.color || '');
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
if (raw.remaining == null && detail.remaining != null) {
(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
if (raw.id != null && raw.itemID == null) {
@@ -242,7 +340,9 @@ export class ApiService {
if (!items || !Array.isArray(items)) {
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 || '';
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;
@@ -308,7 +410,9 @@ export class ApiService {
private normalizeCategories(cats: any[] | null | undefined): Category[] {
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 ───────────────────────────
@@ -450,8 +554,9 @@ export class ApiService {
submitPurchaseEmail(emailData: {
email: string;
phone?: string;
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 }> {
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 */
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
private readonly apiUrl = environment.apiUrl;
private readonly authApiUrl = environment.authApiUrl;
private sessionCheckTimer?: ReturnType<typeof setTimeout>;
@@ -85,10 +84,16 @@ export class AuthService {
/** Generate the Telegram login URL for bot-based auth */
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)}`;
}
/** 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 */
getTelegramQrUrl(): string {
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) */
requestLogin(): void {
this.showLoginSignal.set(true);
@@ -153,7 +150,7 @@ export class AuthService {
this.sessionSignal.set(session);
this.statusSignal.set('authenticated');
this.setStoredWebSessionID(session.sessionId);
this.scheduleSessionRefresh(session.expiresAt);
this.scheduleSessionRefresh(session.expires);
}
private clearAuthState(status: AuthStatus): void {
@@ -214,19 +211,19 @@ export class AuthService {
const explicitDisplayName = this.readString(this.readFirst(response, ['displayName', 'DisplayName', 'name', 'Name']))
?? this.readString(this.readFirst(user, ['displayName', 'DisplayName', 'name', 'Name']))
const displayName = explicitDisplayName ?? username ?? (fullName || 'Telegram User');
const telegramUserId = this.readNumber(this.readFirst(user, ['telegramUserId', 'telegramUserID', 'TelegramUserID', 'id', 'ID']))
?? this.readNumber(this.readFirst(response, ['telegramUserId', 'telegramUserID', 'TelegramUserID', 'userID', 'UserID']))
?? 0;
const telegramUserId = this.readNumber(this.readFirst(user, ['userId','telegramUserId', 'telegramUserID', 'TelegramUserID', 'id', 'ID']))
?? this.readNumber(this.readFirst(response, ['userId', 'telegramUserId', 'telegramUserID', 'TelegramUserID', 'userID', 'UserID', 'UserId']))
?? null;
const expiresAt = this.readString(this.readFirst(response, ['expiresAt', 'ExpiresAt', 'expires', 'Expires']))
?? new Date(Date.now() + WEB_SESSION_COOKIE_MAX_AGE_SECONDS * 1000).toISOString();
return {
sessionId,
telegramUserId,
userId: telegramUserId,
username,
displayName,
active,
expiresAt,
expires: expiresAt,
};
}
@@ -357,4 +354,8 @@ export class AuthService {
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 { ApiService } from './api.service';
import { Item, CartItem } from '../models';
import { Injectable, signal, computed, effect, Injector } from '@angular/core';
import { DeliveryOption, CartItem } from '../models';
import { getDiscountedPrice } from '../utils/item.utils';
import { normalizeDeliveryOption, normalizeOptionalNumber } from '../utils/normalization.utils';
import { environment } from '../../environments/environment';
import type { } from '../types/telegram.types';
@@ -28,8 +28,27 @@ export class CartService {
return total + (getDiscountedPrice(item) * item.quantity);
}, 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();
// 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 {
const data = JSON.stringify(items);
@@ -90,10 +177,7 @@ export class CartService {
try {
const items = JSON.parse(json);
if (Array.isArray(items)) {
this.cartItems.set(items.map(item => ({
...item,
quantity: item.quantity || 1
})));
this.cartItems.set(items.map(item => this.normalizeCartItem(item)));
return true;
}
} catch (err) {
@@ -116,23 +200,28 @@ export class CartService {
} else {
// Get item details from API and add to cart
this.addingItems.add(itemID);
this.apiService.getItem(itemID).subscribe({
next: (item) => {
const cartItem: CartItem = {
...item,
quantity,
...(variant?.colour != null && { colour: variant.colour }),
...(variant?.size != null && { size: variant.size }),
...(variant?.price != null && { price: variant.price }),
...(variant?.currency != null && { currency: variant.currency }),
};
this.cartItems.set([...this.cartItems(), cartItem]);
this.addingItems.delete(itemID);
},
error: (err) => {
console.error('Error adding to cart:', err);
this.addingItems.delete(itemID);
}
import('./api.service').then(({ ApiService }) => {
this.injector.get(ApiService).getItem(itemID).subscribe({
next: (item) => {
const cartItem = this.normalizeCartItem({
...item,
quantity,
...(variant?.colour != null && { colour: variant.colour }),
...(variant?.size != null && { size: variant.size }),
...(variant?.price != null && { price: variant.price }),
...(variant?.currency != null && { currency: variant.currency }),
});
this.cartItems.set([...this.cartItems(), cartItem]);
this.addingItems.delete(itemID);
},
error: (err) => {
console.error('Error adding to cart:', err);
this.addingItems.delete(itemID);
}
});
}).catch((err) => {
console.error('Error loading API service:', err);
this.addingItems.delete(itemID);
});
}
}
@@ -150,6 +239,22 @@ export class CartService {
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 {
const currentItems = this.cartItems();
const updatedItems = currentItems.filter(item => !itemIDs.includes(item.itemID));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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