Compare commits
21 Commits
fb3bb6c77c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fb918f5e4 | ||
|
|
3b802b7c7b | ||
|
|
1b2a5af2be | ||
|
|
6410321895 | ||
|
|
51445a7341 | ||
|
|
56df8632cb | ||
|
|
824bed199c | ||
|
|
b5728f1238 | ||
|
|
04814aeeda | ||
|
|
9386fbc2f8 | ||
|
|
a06b654103 | ||
|
|
9aaff4d80a | ||
|
|
7a06843bf5 | ||
|
|
1decc08f77 | ||
|
|
c0cfbcbcbb | ||
|
|
688c225911 | ||
|
|
3e79304e5c | ||
|
|
e7d8ec8c63 | ||
|
|
1e3cd99c69 | ||
|
|
3ab67cbe2d | ||
|
|
b3c056980d |
18
angular.json
18
angular.json
@@ -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
968
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
36
package.json
36
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 /> -->
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.footer-placeholder {
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.server-check-overlay,
|
||||
.server-error-overlay {
|
||||
display: flex;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -64,6 +64,12 @@ export const hy: Translations = {
|
||||
total: 'Ընդամենը',
|
||||
items: 'Ապրանքներ',
|
||||
deliveryLabel: 'Առաքում',
|
||||
deliveryMethod: 'Առաքման տարբերակ',
|
||||
selectDelivery: 'Ընտրեք առաքման տարբերակը',
|
||||
deliveryPlace: 'Վայր',
|
||||
deliveryTime: 'Առաքման ժամկետ',
|
||||
digitalDelivery: 'Թվային առաքում',
|
||||
deliveryRequired: 'Մինչ պատվերը ձևակերպելը ընտրեք առաքումը բոլոր առաքվող ապրանքների համար։',
|
||||
toPay: 'Վճարման ենթակա',
|
||||
agreeWith: 'Ես համաձայն եմ',
|
||||
publicOffer: 'հանրային օֆերտայի',
|
||||
|
||||
@@ -64,6 +64,12 @@ export const ru: Translations = {
|
||||
total: 'Итого',
|
||||
items: 'Товары',
|
||||
deliveryLabel: 'Доставка',
|
||||
deliveryMethod: 'Способ доставки',
|
||||
selectDelivery: 'Выберите способ доставки',
|
||||
deliveryPlace: 'Место',
|
||||
deliveryTime: 'Срок доставки',
|
||||
digitalDelivery: 'Цифровая доставка',
|
||||
deliveryRequired: 'Выберите доставку для всех товаров с доставкой перед оформлением заказа.',
|
||||
toPay: 'К оплате',
|
||||
agreeWith: 'Я согласен с',
|
||||
publicOffer: 'публичной офертой',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
3
src/app/interceptors/mock-data.interceptor.production.ts
Normal file
3
src/app/interceptors/mock-data.interceptor.production.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
|
||||
export const mockDataInterceptor: HttpInterceptorFn = (req, next) => next(req);
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -33,6 +33,6 @@ export interface Subcategory {
|
||||
subcategories?: Subcategory[];
|
||||
}
|
||||
|
||||
export interface CategoryTranslation {
|
||||
interface CategoryTranslation {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
@@ -245,9 +258,9 @@
|
||||
<div class="payment-status-screen success">
|
||||
<div class="success-icon">✓</div>
|
||||
<h2>{{ 'cart.paymentSuccess' | translate }}</h2>
|
||||
<p class="success-text">{{ 'cart.paymentSuccessDesc' | translate }}</p>
|
||||
<!-- <p class="success-text">{{ 'cart.paymentSuccessDesc' | translate }}</p> -->
|
||||
|
||||
<div class="email-form">
|
||||
<!-- <div class="email-form">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="email"
|
||||
@@ -296,7 +309,7 @@
|
||||
{{ 'cart.send' | translate }}
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -39,7 +44,7 @@ export class CartComponent implements OnDestroy {
|
||||
|
||||
// Payment popup states
|
||||
showPaymentPopup = signal<boolean>(false);
|
||||
paymentStatus = signal<'creating' | 'waiting' | 'success' | 'timeout' | 'error'>('creating');
|
||||
paymentStatus = signal<'creating' | 'waiting' | 'success' | 'timeout' | 'error' | null>('creating');
|
||||
qrCodeUrl = signal<string>('');
|
||||
paymentUrl = signal<string>('');
|
||||
paymentId = signal<string>('');
|
||||
@@ -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,
|
||||
@@ -274,13 +292,14 @@ export class CartComponent implements OnDestroy {
|
||||
if (paymentStatus === 'COMPLETED' || paymentStatus === 'APPROVED' || paymentStatus === 'PAID' || paymentCode === 'SUCCESS') {
|
||||
this.paymentStatus.set('success');
|
||||
this.stopPolling();
|
||||
this.cartService.clearCart();
|
||||
|
||||
// Auto-submit purchase after 5 seconds
|
||||
// Auto-submit purchase after 5 seconds
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.autoSubmitPurchase();
|
||||
}, 5000);
|
||||
this.cartService.clearCart();
|
||||
|
||||
|
||||
}
|
||||
// Continue checking for 3 minutes regardless of other statuses
|
||||
},
|
||||
@@ -316,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
|
||||
@@ -338,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 } : {})
|
||||
}))
|
||||
};
|
||||
|
||||
@@ -358,6 +381,9 @@ export class CartComponent implements OnDestroy {
|
||||
this.router.navigate([`/${lang}`]);
|
||||
}
|
||||
});
|
||||
this.paymentStatus.set(null);
|
||||
|
||||
|
||||
}
|
||||
|
||||
copyPaymentLink(): void {
|
||||
@@ -403,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 } : {})
|
||||
}))
|
||||
};
|
||||
|
||||
@@ -428,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();
|
||||
}
|
||||
|
||||
@@ -463,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)
|
||||
@@ -476,6 +503,8 @@ export class CartComponent implements OnDestroy {
|
||||
itemID: item.itemID,
|
||||
price: unitPrice * item.quantity,
|
||||
name,
|
||||
quantity: item.quantity,
|
||||
...(item.selectedDelivery ? { delivery: item.selectedDelivery } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,11 +121,11 @@ export class SubcategoriesComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// TrackBy function for performance optimization
|
||||
trackByCategoryId(index: number, category: Category): number {
|
||||
trackByCategoryId(_index: number, category: Category): number {
|
||||
return category.categoryID;
|
||||
}
|
||||
|
||||
trackBySubId(index: number, sub: Subcategory): string {
|
||||
trackBySubId(_index: number, sub: Subcategory): string {
|
||||
return sub.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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 } 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,44 @@ 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 };
|
||||
}
|
||||
|
||||
/** Resolve relative image URLs (e.g. ./images/x.webp) against site origin */
|
||||
private resolveImageUrl(url: string): string {
|
||||
if (!url) return '';
|
||||
@@ -102,6 +141,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 +154,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) {
|
||||
@@ -450,8 +509,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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface TelegramUser {
|
||||
interface TelegramUser {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
@@ -21,3 +21,5 @@ declare global {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
58
src/app/utils/normalization.utils.ts
Normal file
58
src/app/utils/normalization.utils.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -28,9 +28,6 @@
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user