8 Commits

Author SHA1 Message Date
sdarbinyan
394ac5ec9d visible and count 2026-06-22 01:45:23 +04:00
sdarbinyan
4fb918f5e4 cleaned up 2026-06-21 23:42:39 +04:00
sdarbinyan
3b802b7c7b delivery 2026-06-21 23:13:01 +04:00
sdarbinyan
1b2a5af2be test 2026-06-21 01:45:05 +04:00
sdarbinyan
6410321895 price 2026-06-20 15:16:25 +04:00
sdarbinyan
51445a7341 telegram desktop 2026-06-20 15:09:15 +04:00
sdarbinyan
56df8632cb styles 2026-06-20 15:08:10 +04:00
sdarbinyan
824bed199c version 2026-06-20 15:05:09 +04:00
35 changed files with 1214 additions and 664 deletions

View File

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

968
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,24 +12,21 @@
"build:dexar": "ng build --configuration=production",
"build:novo": "ng build --configuration=novo-production",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"lavero": "ng serve --configuration=lavero --port 4202 --proxy-config proxy.conf.lavero.json",
"start:lavero": "ng serve --configuration=lavero --port 4202",
"build:lavero": "ng build --configuration=lavero-production"
},
"private": true,
"dependencies": {
"@angular/animations": "^21.1.5",
"@angular/cdk": "^21.1.5",
"@angular/common": "^21.0.6",
"@angular/compiler": "^21.0.6",
"@angular/core": "^21.0.6",
"@angular/forms": "^21.0.6",
"@angular/material": "^21.1.5",
"@angular/platform-browser": "^21.0.6",
"@angular/platform-browser-dynamic": "^21.1.5",
"@angular/router": "^21.0.6",
"@angular/service-worker": "^21.0.6",
"@angular/animations": "21.1.5",
"@angular/cdk": "21.1.5",
"@angular/common": "21.1.5",
"@angular/compiler": "21.1.5",
"@angular/core": "21.1.5",
"@angular/forms": "21.1.5",
"@angular/platform-browser": "21.1.5",
"@angular/router": "21.1.5",
"@angular/service-worker": "21.1.5",
"primeicons": "^7.0.0",
"primeng": "^21.0.3",
"rxjs": "~7.8.0",
@@ -37,16 +34,9 @@
"zone.js": "~0.16.0"
},
"devDependencies": {
"@angular/build": "^21.0.6",
"@angular/cli": "^21.0.6",
"@angular/compiler-cli": "^21.0.6",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.13.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"@angular/build": "21.1.5",
"@angular/cli": "21.1.5",
"@angular/compiler-cli": "21.1.5",
"typescript": "~5.9.3"
}
}

View File

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

View File

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

View File

@@ -2,10 +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 { ApiService } from './services';
import { interval, concat } from 'rxjs';
import { filter, first } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -27,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);
@@ -55,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: () => {

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { Component, ChangeDetectionStrategy, Renderer2, inject, DOCUMENT } from '@angular/core';
import { 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';

View File

@@ -70,21 +70,15 @@ export class TelegramLoginComponent implements OnDestroy {
}
openTelegramLogin(): void {
const url = this.loginUrl();
const webSessionID = this.webSessionID();
if (!url || !webSessionID) return;
if (!webSessionID || typeof window === 'undefined') return;
if (!this.pollTimer) {
this.startPolling(webSessionID);
}
if (this.isMobileBrowser()) {
this.awaitingTelegramReturn.set(true);
window.location.href = this.authService.getTelegramAppLoginUrl(webSessionID);
return;
}
window.open(url, '_blank', 'noopener,noreferrer');
this.awaitingTelegramReturn.set(true);
window.location.href = this.authService.getTelegramAppLoginUrl(webSessionID);
}
refreshQr(): void {
@@ -170,15 +164,4 @@ export class TelegramLoginComponent implements OnDestroy {
}
});
}
private isMobileBrowser(): boolean {
if (typeof navigator === 'undefined') {
return false;
}
const userAgent = navigator.userAgent || navigator.vendor;
const isTouchMac = navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
return /Android|iPhone|iPad|iPod|IEMobile|Opera Mini/i.test(userAgent) || isTouchMac;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,11 +50,6 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
);
};
/** Clear all cached responses */
export function clearCache(): void {
cache.clear();
}
function cleanupExpiredCache(): void {
const now = Date.now();
for (const [url, data] of cache.entries()) {

View File

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

View File

@@ -12,14 +12,4 @@ export interface WebSessionStart {
url: string;
}
export interface TelegramAuthData {
id: number;
first_name: string;
last_name?: string;
username?: string;
photo_url?: string;
auth_date: number;
hash: string;
}
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';

View File

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

View File

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

View File

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

View File

@@ -225,14 +225,14 @@
.cart-content {
display: grid;
grid-template-columns: 1fr 350px;
grid-template-columns: minmax(0, 1fr) 350px;
gap: 24px;
align-items: start;
}
// Novo wider summary
.cart-container.novo .cart-content {
grid-template-columns: 1fr 400px;
grid-template-columns: minmax(0, 1fr) 400px;
gap: 32px;
}
@@ -240,6 +240,7 @@
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
// Novo larger gap
@@ -554,6 +555,12 @@
font-weight: 700;
color: #497671;
}
.delivery-price {
font-size: 0.85rem;
font-weight: 500;
color: #697777;
}
}
// Dexar quantity controls
@@ -837,7 +844,7 @@
color: #6b7280;
&.delivery {
display: none; // Hide delivery in Novo
display: flex;
}
&.total {
@@ -1009,6 +1016,27 @@
}
}
.delivery-warning {
margin: -4px 0 0;
padding: 10px 12px;
border-radius: 10px;
font-size: 0.85rem;
line-height: 1.5;
}
.cart-container.dexar .cart-summary .delivery-warning {
color: #9a6700;
background: #fff8e8;
border: 1px solid #f1ddb2;
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.cart-container.novo .cart-summary .delivery-warning {
color: #92400e;
background: #fffbeb;
border: 1px solid #fde68a;
}
// Dexar checkbox colors
.cart-container.dexar .terms-agreement .checkbox-container {
input[type="checkbox"]:checked ~ .checkmark {
@@ -1527,11 +1555,31 @@
// Mobile responsive
@media (max-width: 768px) {
.cart-content {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr);
gap: 20px;
}
.cart-container.novo .cart-content {
grid-template-columns: minmax(0, 1fr);
gap: 20px;
}
.cart-summary {
position: static;
width: 100%;
max-width: 100%;
min-width: 0;
}
.cart-container.novo,
.cart-container.dexar {
padding: 16px;
}
.cart-container.novo .cart-header,
.cart-container.dexar .cart-header {
flex-wrap: wrap;
gap: 12px;
}
.remove-btn-desktop {
@@ -1809,6 +1857,7 @@
margin: 0;
line-height: 1.5;
display: -webkit-box;
line-clamp: 2;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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