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
34
package.json
34
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>
|
||||
@defer (on viewport) {
|
||||
<app-footer></app-footer>
|
||||
<app-telegram-login />
|
||||
} @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,6 +36,7 @@
|
||||
<app-language-selector />
|
||||
|
||||
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()" [attr.aria-label]="'header.cart' | translate">
|
||||
<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>
|
||||
@@ -44,6 +45,10 @@
|
||||
@if (cartItemCount() > 0) {
|
||||
<span class="novo-cart-badge">{{ cartItemCount() }}</span>
|
||||
}
|
||||
</span>
|
||||
@if (cartItemCount() > 0) {
|
||||
<span class="novo-cart-total">{{ formatCartTotal(cartTotal()) }}</span>
|
||||
}
|
||||
</a>
|
||||
|
||||
<button class="menu-toggle novo-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [class.novo-active]="menuOpen" [attr.aria-label]="menuOpen ? 'Close menu' : 'Open menu'" [attr.aria-expanded]="menuOpen">
|
||||
@@ -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,6 +107,7 @@
|
||||
<div class="dexar-actions">
|
||||
<!-- Cart Button -->
|
||||
<a [routerLink]="'/cart' | langRoute" routerLinkActive="dexar-cart-active" class="dexar-cart-btn" (click)="closeMenu()">
|
||||
<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" />
|
||||
@@ -110,6 +116,10 @@
|
||||
@if (cartItemCount() > 0) {
|
||||
<span class="dexar-cart-badge">{{ cartItemCount() }}</span>
|
||||
}
|
||||
</span>
|
||||
@if (cartItemCount() > 0) {
|
||||
<span class="dexar-cart-total">{{ formatCartTotal(cartTotal()) }}</span>
|
||||
}
|
||||
</a>
|
||||
|
||||
<!-- Region Selector (desktop only) -->
|
||||
|
||||
@@ -333,22 +333,34 @@
|
||||
}
|
||||
|
||||
.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-icon {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 0;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.novo-cart-badge {
|
||||
position: absolute;
|
||||
@@ -367,6 +379,12 @@
|
||||
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 {
|
||||
@@ -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;
|
||||
|
||||
window.open(url, '_blank');
|
||||
if (!this.pollTimer) {
|
||||
const webSessionID = this.webSessionID();
|
||||
if (webSessionID) {
|
||||
if (!webSessionID || typeof window === 'undefined') return;
|
||||
|
||||
if (!this.pollTimer) {
|
||||
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>
|
||||
|
||||
@if (hasDeliveryPrice()) {
|
||||
<div class="summary-row delivery">
|
||||
<span>{{ 'cart.deliveryLabel' | translate }}</span>
|
||||
<span>0 {{ currentCurrency }}</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
|
||||
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,16 +200,17 @@ export class CartService {
|
||||
} else {
|
||||
// Get item details from API and add to cart
|
||||
this.addingItems.add(itemID);
|
||||
this.apiService.getItem(itemID).subscribe({
|
||||
import('./api.service').then(({ ApiService }) => {
|
||||
this.injector.get(ApiService).getItem(itemID).subscribe({
|
||||
next: (item) => {
|
||||
const cartItem: CartItem = {
|
||||
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);
|
||||
},
|
||||
@@ -134,6 +219,10 @@ export class CartService {
|
||||
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