Compare commits
9 Commits
6850a911f3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9038a1f782 | ||
|
|
fb570a32f5 | ||
|
|
09e8465577 | ||
|
|
5c6cb051ac | ||
|
|
50508b281c | ||
|
|
b71e806bca | ||
|
|
e32ee998c1 | ||
|
|
ffde301181 | ||
|
|
f7919f6ca7 |
4
API.md
4
API.md
@@ -229,6 +229,7 @@ Response 200:
|
||||
"priority": 1,
|
||||
"quantity": 50,
|
||||
"price": 1299,
|
||||
"discount": 10,
|
||||
"currency": "USD",
|
||||
"imgs": ["https://...", "https://..."],
|
||||
"tags": ["new", "featured"],
|
||||
@@ -285,6 +286,7 @@ Body:
|
||||
"priority": 10,
|
||||
"quantity": 100,
|
||||
"price": 999,
|
||||
"discount": 0, // 0–100 (percentage off price)
|
||||
"currency": "USD", // USD | EUR | RUB | GBP | UAH
|
||||
"imgs": ["https://..."],
|
||||
"tags": ["new"],
|
||||
@@ -318,6 +320,7 @@ Body: (any subset of fields)
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"price": 899,
|
||||
"discount": 15,
|
||||
"quantity": 80,
|
||||
"visible": false
|
||||
}
|
||||
@@ -389,6 +392,7 @@ Response 201:
|
||||
- `currency` supported values: `USD`, `EUR`, `RUB`, `GBP`, `UAH`.
|
||||
- `badges`: optional string array. Predefined values with UI colors: `new`, `sale`, `exclusive`, `hot`, `limited`, `bestseller`, `featured`. Custom strings are also allowed.
|
||||
- `imgs`: always send the **complete** array on update, not individual images.
|
||||
- `discount`: integer `0`–`100` representing a percentage discount. `0` means no discount. The discounted price is calculated as `price * (1 - discount / 100)`.
|
||||
- `description`: array of `{ key, value }` pairs - free-form attributes per item.
|
||||
- `translations`: optional object keyed by language code (`"ru"`, `"en"`, etc.) — each value may contain `name`, `simpleDescription`, `description[]`. The marketplace frontend should use these when rendering in the corresponding language, falling back to the default fields if a translation is absent.
|
||||
- Auto-save from the backoffice fires `PATCH` with a single field every ~500 ms.
|
||||
|
||||
@@ -238,6 +238,7 @@ Query-параметры:
|
||||
"priority": 1,
|
||||
"quantity": 50,
|
||||
"price": 1299,
|
||||
"discount": 10,
|
||||
"currency": "USD",
|
||||
"imgs": ["https://...", "https://..."],
|
||||
"tags": ["new", "featured"],
|
||||
@@ -297,6 +298,7 @@ POST /api/subcategories/:subcategoryId/items
|
||||
"priority": 10,
|
||||
"quantity": 100,
|
||||
"price": 999,
|
||||
"discount": 0, // 0–100 (процент скидки)
|
||||
"currency": "USD", // USD | EUR | RUB | GBP | UAH
|
||||
"imgs": ["https://..."],
|
||||
"tags": ["new"],
|
||||
@@ -330,6 +332,7 @@ PATCH /api/items/:itemId
|
||||
{
|
||||
"name": "Новое название",
|
||||
"price": 899,
|
||||
"discount": 15,
|
||||
"quantity": 80,
|
||||
"visible": false,
|
||||
"translations": {
|
||||
@@ -446,6 +449,7 @@ GET /api/subcategories/:subcategoryId/items?lang=ru&page=1
|
||||
- Поддерживаемые значения `currency`: `USD`, `EUR`, `RUB`, `GBP`, `UAH`.
|
||||
- `badges`: необязательный массив строк. Стандартные значения с цветами в интерфейсе: `new`, `sale`, `exclusive`, `hot`, `limited`, `bestseller`, `featured`. Свои строки тоже допустимы.
|
||||
- `imgs`: при обновлении всегда передавай **полный** массив, не отдельные изображения.
|
||||
- `discount`: целое число `0`–`100` — процент скидки. `0` означает отсутствие скидки. Цена со скидкой вычисляется как `price * (1 - discount / 100)`.
|
||||
- `description`: массив пар `{ key, value }` — свободные атрибуты товара.
|
||||
- Автосохранение из бэкофиса отправляет `PATCH` с одним полем каждые ~500 мс.
|
||||
|
||||
|
||||
61
BACKEND_AUTH_TODO.ru.md
Normal file
61
BACKEND_AUTH_TODO.ru.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Что нужно сделать на backend для Telegram-логина
|
||||
|
||||
BackOffice уже готов к текущему backend: сначала пробует новый `/userauth/*` контракт из `marketplaces/docs/telegram-login-dialog.html`, а если backend отвечает `404`, использует текущий marketplace-flow через `/users/sessions`.
|
||||
|
||||
Чтобы все работало строго по новому единому контракту, нужно сделать следующее.
|
||||
|
||||
## Auth-service
|
||||
|
||||
Добавить или включить endpoints:
|
||||
|
||||
- `POST /userauth/qr/create` - создать короткоживущий QR/login token и вернуть ссылку Telegram.
|
||||
- `GET /userauth/qr/poll?token=...` - вернуть статус `pending`, `confirmed` или `expired`.
|
||||
- `GET /userauth/session` - вернуть текущую активную сессию пользователя или `401/404`, если сессии нет.
|
||||
- `POST /userauth/logout` - завершить текущую сессию.
|
||||
|
||||
Минимальный ответ для подтвержденного login:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "confirmed",
|
||||
"session": {
|
||||
"sessionId": "uuid",
|
||||
"telegramUserId": "123456",
|
||||
"username": "user",
|
||||
"displayName": "User Name",
|
||||
"active": true,
|
||||
"expiresAt": "2026-06-21T12:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Main API
|
||||
|
||||
Поддержать активацию backend API-сессии:
|
||||
|
||||
- `POST /usersession/{sessionId}` - принять подтвержденный `sessionId` из auth-service и открыть доступ к marketplace/backoffice API.
|
||||
|
||||
## CORS и credentials
|
||||
|
||||
Разрешить реальные frontend origins для BackOffice и Marketplace:
|
||||
|
||||
- production domain BackOffice;
|
||||
- `https://dexarmarket.ru`, если marketplace и backoffice используют общий auth-flow;
|
||||
- dev через proxy уже настроен на frontend стороне.
|
||||
|
||||
Нужно разрешить:
|
||||
|
||||
- `Access-Control-Allow-Credentials: true`;
|
||||
- методы `GET`, `POST`, `DELETE`, `OPTIONS`;
|
||||
- headers `Content-Type`, `WebSessionID`;
|
||||
- cookies с `SameSite=None; Secure; HttpOnly`, если auth-service использует cookie-сессию.
|
||||
|
||||
## Совместимость
|
||||
|
||||
Пока `/userauth/*` не развернуты, нельзя выключать текущие marketplace endpoints:
|
||||
|
||||
- `POST /users/sessions`;
|
||||
- `GET /users/sessions/{sessionId}`;
|
||||
- `DELETE /users/sessions/{sessionId}`.
|
||||
|
||||
BackOffice сейчас использует их как fallback, чтобы логин уже работал на текущем backend.
|
||||
21
README.md
21
README.md
@@ -102,20 +102,33 @@ This will compile your project and store the build artifacts in the `dist/market
|
||||
```typescript
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://localhost:3000/api', // Local backend
|
||||
useMockData: true // Use mock data for development
|
||||
apiUrl: '/api',
|
||||
authApiUrl: '',
|
||||
userSessionApiUrl: '',
|
||||
telegramBot: 'myAMLKYCBOT',
|
||||
useMockData: false
|
||||
};
|
||||
```
|
||||
|
||||
Development runs through `proxy.conf.json`, which forwards `/api` and `/usersession` to `https://api.dexarmarket.ru:445` and `/userauth` plus `/users/sessions` to `https://users.vitanova.network:456`.
|
||||
|
||||
### Production (`src/environments/environment.production.ts`)
|
||||
```typescript
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: 'https://api.dexarmarket.ru/api', // Production backend
|
||||
useMockData: false // Use real API
|
||||
apiUrl: 'https://api.dexarmarket.ru:445',
|
||||
authApiUrl: 'https://users.vitanova.network:456',
|
||||
userSessionApiUrl: 'https://api.dexarmarket.ru:445',
|
||||
telegramBot: 'myAMLKYCBOT',
|
||||
useMockData: false
|
||||
};
|
||||
```
|
||||
|
||||
Telegram login uses the shared userauth endpoints documented in `marketplaces/docs/telegram-login-dialog.html`:
|
||||
`POST /userauth/qr/create`, `GET /userauth/qr/poll?token=...`, `GET /userauth/session`, and `POST /usersession/{sessionId}`.
|
||||
If the deployed auth backend does not expose `/userauth/*` yet, the same dialog falls back to the current marketplace `/users/sessions` Telegram flow.
|
||||
Short backend checklist in Russian: [BACKEND_AUTH_TODO.ru.md](BACKEND_AUTH_TODO.ru.md).
|
||||
|
||||
## Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.production.ts"
|
||||
}
|
||||
],
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
@@ -59,6 +65,9 @@
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "market-backOffice:build:production"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
|
||||
35
proxy.conf.json
Normal file
35
proxy.conf.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "https://api.dexarmarket.ru:445",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"pathRewrite": {
|
||||
"^/api": ""
|
||||
},
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/userauth": {
|
||||
"target": "https://users.vitanova.network:456",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"headers": {
|
||||
"Origin": "https://users.vitanova.network:456"
|
||||
},
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/users/sessions": {
|
||||
"target": "https://users.vitanova.network:456",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"headers": {
|
||||
"Origin": "https://users.vitanova.network:456"
|
||||
},
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/usersession": {
|
||||
"target": "https://api.dexarmarket.ru:445",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug"
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import { provideAnimationsAsync } from '@angular/platform-browser/animations/asy
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { authCredentialsInterceptor } from './interceptors/auth-credentials.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideAnimationsAsync(),
|
||||
provideHttpClient()
|
||||
provideHttpClient(withInterceptors([authCredentialsInterceptor]))
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,57 +1,34 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { ProjectsDashboardComponent } from './pages/projects-dashboard/projects-dashboard.component';
|
||||
import { ProjectViewComponent } from './pages/project-view/project-view.component';
|
||||
import { CategoryEditorComponent } from './pages/category-editor/category-editor.component';
|
||||
import { SubcategoryEditorComponent } from './pages/subcategory-editor/subcategory-editor.component';
|
||||
import { ItemsListComponent } from './pages/items-list/items-list.component';
|
||||
import { ItemEditorComponent } from './pages/item-editor/item-editor.component';
|
||||
import { ItemPreviewComponent } from './pages/item-preview/item-preview.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ProjectsDashboardComponent
|
||||
loadComponent: () => import('./pages/projects-dashboard/projects-dashboard.component').then(m => m.ProjectsDashboardComponent)
|
||||
},
|
||||
{
|
||||
path: 'project/:projectId',
|
||||
component: ProjectViewComponent,
|
||||
loadComponent: () => import('./pages/project-view/project-view.component').then(m => m.ProjectViewComponent),
|
||||
children: [
|
||||
{
|
||||
path: 'category/:categoryId',
|
||||
component: CategoryEditorComponent
|
||||
loadComponent: () => import('./pages/category-editor/category-editor.component').then(m => m.CategoryEditorComponent)
|
||||
},
|
||||
{
|
||||
path: 'subcategory/:subcategoryId',
|
||||
component: SubcategoryEditorComponent
|
||||
loadComponent: () => import('./pages/subcategory-editor/subcategory-editor.component').then(m => m.SubcategoryEditorComponent)
|
||||
},
|
||||
{
|
||||
path: 'items/:subcategoryId',
|
||||
component: ItemsListComponent
|
||||
loadComponent: () => import('./pages/items-list/items-list.component').then(m => m.ItemsListComponent)
|
||||
},
|
||||
{
|
||||
path: 'item/:itemId',
|
||||
component: ItemEditorComponent
|
||||
loadComponent: () => import('./pages/item-editor/item-editor.component').then(m => m.ItemEditorComponent)
|
||||
},
|
||||
{
|
||||
path: 'item/:itemId/preview',
|
||||
component: ItemPreviewComponent
|
||||
loadComponent: () => import('./pages/item-preview/item-preview.component').then(m => m.ItemPreviewComponent)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'category/:categoryId',
|
||||
component: CategoryEditorComponent
|
||||
},
|
||||
{
|
||||
path: 'subcategory/:subcategoryId',
|
||||
component: SubcategoryEditorComponent
|
||||
},
|
||||
{
|
||||
path: 'items/:subcategoryId',
|
||||
component: ItemsListComponent
|
||||
},
|
||||
{
|
||||
path: 'item/:itemId',
|
||||
component: ItemEditorComponent
|
||||
}
|
||||
];
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
.auth-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 20px;
|
||||
background: linear-gradient(135deg, #f4f7fb 0%, #e8eef4 100%);
|
||||
color: #1a1a1a;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-shell__content {
|
||||
width: min(100%, 420px);
|
||||
padding: 32px 28px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
box-shadow: 0 18px 50px rgba(38, 52, 73, 0.12);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.auth-shell h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 26px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.auth-shell p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.auth-shell__button {
|
||||
width: 100%;
|
||||
margin-top: 22px;
|
||||
padding: 14px 24px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #2aabee;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.auth-shell__button:hover {
|
||||
background: #229ed9;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
|
||||
}
|
||||
|
||||
.auth-shell__spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: 0 auto 14px;
|
||||
border: 3px solid #d9e1e8;
|
||||
border-top-color: #497671;
|
||||
border-radius: 50%;
|
||||
animation: authSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes authSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,51 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { Component, effect, inject, signal } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { TranslatePipe } from './pipes/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
templateUrl: './app.html',
|
||||
imports: [RouterOutlet, TelegramLoginComponent, TranslatePipe],
|
||||
template: `
|
||||
@if (auth.status() === 'unknown' || auth.status() === 'checking') {
|
||||
<section class="auth-shell" aria-live="polite">
|
||||
<div class="auth-shell__spinner"></div>
|
||||
<p>{{ 'AUTH_CHECKING' | translate }}</p>
|
||||
</section>
|
||||
} @else if (auth.isAuthenticated()) {
|
||||
<router-outlet></router-outlet>
|
||||
} @else {
|
||||
<section class="auth-shell">
|
||||
<div class="auth-shell__content">
|
||||
<h1>{{ 'MARKETPLACE_BACKOFFICE' | translate }}</h1>
|
||||
<p>{{ 'AUTH_BACKOFFICE_REQUIRED' | translate }}</p>
|
||||
<button class="auth-shell__button" type="button" (click)="openLogin()">
|
||||
{{ 'AUTH_LOGIN_WITH_TELEGRAM' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<app-telegram-login />
|
||||
`,
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
export class App {
|
||||
protected readonly auth = inject(AuthService);
|
||||
protected readonly title = signal('market-backOffice');
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const status = this.auth.status();
|
||||
|
||||
if (status === 'unauthenticated' || status === 'expired') {
|
||||
this.auth.requestLogin();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected openLogin(): void {
|
||||
this.auth.requestLogin();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface ConfirmDialogData {
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
warning?: boolean;
|
||||
dangerous?: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -23,7 +23,7 @@ export interface ConfirmDialogData {
|
||||
],
|
||||
template: `
|
||||
<h2 mat-dialog-title>
|
||||
@if (data.warning) {
|
||||
@if (data.dangerous) {
|
||||
<mat-icon class="warning-icon">warning</mat-icon>
|
||||
}
|
||||
{{ data.title }}
|
||||
@@ -37,7 +37,7 @@ export interface ConfirmDialogData {
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
[color]="data.warning ? 'warn' : 'primary'"
|
||||
[color]="data.dangerous ? 'warn' : 'primary'"
|
||||
(click)="onConfirm()">
|
||||
{{ data.confirmText || 'Confirm' }}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<div class="inline-items">
|
||||
<div class="inline-items-header">
|
||||
<span class="items-count">{{ totalCount() }} {{ 'ITEMS_COUNT' | translate }}</span>
|
||||
<button mat-mini-fab color="accent" (click)="addItem()" [matTooltip]="'CREATE_NEW_ITEM' | translate">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (items().length > 0) {
|
||||
<div class="inline-items-grid">
|
||||
@for (item of items(); track item.id) {
|
||||
<div class="inline-item-card" (click)="openItem(item.id)" [class.hidden-item]="!item.visible">
|
||||
<div class="inline-item-image">
|
||||
@if (item.imgs.length) {
|
||||
<img [src]="item.imgs[0]" [alt]="item.name" (error)="onImageError($event)">
|
||||
}
|
||||
<div class="no-image" [style.display]="item.imgs.length ? 'none' : 'flex'">
|
||||
<mat-icon>image</mat-icon>
|
||||
</div>
|
||||
@if (item.quantity === 0) {
|
||||
<div class="out-of-stock">{{ 'OUT_OF_STOCK' | translate }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="inline-item-info">
|
||||
<span class="item-name">{{ item.name }}</span>
|
||||
<div class="item-details">
|
||||
<span class="price">{{ item.price }} {{ item.currency }}</span>
|
||||
@if (item.discount > 0) {
|
||||
<span class="discount">-{{ item.discount }}%</span>
|
||||
}
|
||||
</div>
|
||||
<div class="item-meta">
|
||||
<span class="qty">{{ 'QTY' | translate }}: {{ item.quantity }}</span>
|
||||
<mat-icon class="visibility-icon" [class.visible]="item.visible" [class.not-visible]="!item.visible">
|
||||
{{ item.visible ? 'visibility' : 'visibility_off' }}
|
||||
</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
class="delete-btn"
|
||||
(click)="deleteItem(item, $event)"
|
||||
[matTooltip]="'DELETE' | translate">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="inline-loading">
|
||||
<mat-spinner diameter="28"></mat-spinner>
|
||||
<span>{{ 'LOADING_MORE' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!loading() && items().length === 0) {
|
||||
<div class="inline-empty">
|
||||
<mat-icon>inventory_2</mat-icon>
|
||||
<span>{{ 'NO_ITEMS_FOUND' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!hasMore() && items().length > 0 && !loading()) {
|
||||
<div class="inline-end">{{ 'NO_MORE_ITEMS' | translate }}</div>
|
||||
}
|
||||
|
||||
<div #scrollSentinel class="scroll-sentinel"></div>
|
||||
</div>
|
||||
@@ -0,0 +1,207 @@
|
||||
.inline-items {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inline-items-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
.items-count {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-items-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.inline-item-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: #1976d2;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.hidden-item {
|
||||
opacity: 0.6;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-item-image {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.no-image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #ccc;
|
||||
|
||||
mat-icon {
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.out-of-stock {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
|
||||
.item-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
|
||||
.price {
|
||||
font-weight: 600;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.discount {
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: #e53935;
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #999;
|
||||
|
||||
.qty {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.visibility-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
&.visible { color: #4caf50; }
|
||||
&.not-visible { color: #f44336; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inline-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
|
||||
span {
|
||||
color: #666;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1rem;
|
||||
color: #999;
|
||||
|
||||
mat-icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-end {
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
color: #bbb;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.scroll-sentinel {
|
||||
height: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import {
|
||||
Component, Input, OnChanges, SimpleChanges, AfterViewInit, OnDestroy,
|
||||
signal, ViewChild, ElementRef, DestroyRef, inject
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { ApiService } from '../../services';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { Item } from '../../models';
|
||||
import { CreateDialogComponent } from '../create-dialog/create-dialog.component';
|
||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
|
||||
@Component({
|
||||
selector: 'app-inline-items-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
MatTooltipModule,
|
||||
TranslatePipe
|
||||
],
|
||||
templateUrl: './inline-items-list.component.html',
|
||||
styleUrls: ['./inline-items-list.component.scss']
|
||||
})
|
||||
export class InlineItemsListComponent implements OnChanges, AfterViewInit, OnDestroy {
|
||||
@Input({ required: true }) subcategoryId!: string;
|
||||
@Input({ required: true }) projectId!: string;
|
||||
|
||||
items = signal<Item[]>([]);
|
||||
loading = signal(false);
|
||||
hasMore = signal(false);
|
||||
page = signal(1);
|
||||
totalCount = signal(0);
|
||||
|
||||
@ViewChild('scrollSentinel') scrollSentinel!: ElementRef;
|
||||
private intersectionObserver?: IntersectionObserver;
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private apiService: ApiService,
|
||||
private toast: ToastService,
|
||||
private dialog: MatDialog,
|
||||
public lang: LanguageService
|
||||
) {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes['subcategoryId'] && this.subcategoryId) {
|
||||
this.page.set(1);
|
||||
this.items.set([]);
|
||||
this.loadItems();
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.setupObserver();
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.intersectionObserver?.disconnect();
|
||||
}
|
||||
|
||||
private setupObserver() {
|
||||
this.intersectionObserver?.disconnect();
|
||||
this.intersectionObserver = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && this.hasMore() && !this.loading()) {
|
||||
this.loadItems(true);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '100px', threshold: 0 }
|
||||
);
|
||||
if (this.scrollSentinel?.nativeElement) {
|
||||
this.intersectionObserver.observe(this.scrollSentinel.nativeElement);
|
||||
}
|
||||
}
|
||||
|
||||
loadItems(append = false) {
|
||||
if (this.loading()) return;
|
||||
|
||||
this.loading.set(true);
|
||||
const currentPage = append ? this.page() + 1 : 1;
|
||||
|
||||
this.apiService.getItems(this.subcategoryId, currentPage, 20).subscribe({
|
||||
next: (response) => {
|
||||
if (append) {
|
||||
this.items.set([...this.items(), ...response.items]);
|
||||
} else {
|
||||
this.items.set(response.items);
|
||||
}
|
||||
this.page.set(currentPage);
|
||||
this.hasMore.set(response.hasMore);
|
||||
this.totalCount.set(response.total);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error(this.lang.t('FAILED_LOAD_ITEMS'));
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openItem(itemId: string) {
|
||||
this.router.navigate(['/project', this.projectId, 'item', itemId]);
|
||||
}
|
||||
|
||||
addItem() {
|
||||
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||
width: '500px',
|
||||
data: {
|
||||
title: this.lang.t('CREATE_NEW_ITEM'),
|
||||
fields: [
|
||||
{ name: 'name', label: this.lang.t('ITEM_NAME'), type: 'text', required: true },
|
||||
{ name: 'simpleDescription', label: this.lang.t('SIMPLE_DESCRIPTION'), type: 'text', required: false },
|
||||
{ name: 'price', label: this.lang.t('PRICE'), type: 'number', required: true },
|
||||
{
|
||||
name: 'currency', label: this.lang.t('CURRENCY'), type: 'select', required: true, value: 'USD',
|
||||
options: [
|
||||
{ value: 'USD', label: '🇺🇸 USD' },
|
||||
{ value: 'EUR', label: '🇪🇺 EUR' },
|
||||
{ value: 'RUB', label: '🇷🇺 RUB' },
|
||||
{ value: 'GBP', label: '🇬🇧 GBP' },
|
||||
{ value: 'UAH', label: '🇺🇦 UAH' }
|
||||
]
|
||||
},
|
||||
{ name: 'quantity', label: this.lang.t('QUANTITY'), type: 'number', required: true, value: 0 },
|
||||
{ name: 'visible', label: this.lang.t('VISIBLE'), type: 'toggle', required: false, value: true }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.apiService.createItem(this.subcategoryId, result).subscribe({
|
||||
next: () => {
|
||||
this.toast.success(this.lang.t('ITEM_CREATED'));
|
||||
this.page.set(1);
|
||||
this.items.set([]);
|
||||
this.loadItems();
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error(this.lang.t('FAILED_CREATE_ITEM'));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteItem(item: Item, event: Event) {
|
||||
event.stopPropagation();
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: this.lang.t('DELETE_ITEM'),
|
||||
message: `${this.lang.t('CONFIRM_DELETE')} "${item.name}"?`,
|
||||
confirmText: this.lang.t('DELETE'),
|
||||
cancelText: this.lang.t('CANCEL'),
|
||||
dangerous: true
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(result => {
|
||||
if (result) {
|
||||
this.apiService.deleteItem(item.id).subscribe({
|
||||
next: () => {
|
||||
this.toast.success(this.lang.t('ITEM_DELETED'));
|
||||
this.page.set(1);
|
||||
this.items.set([]);
|
||||
this.loadItems();
|
||||
},
|
||||
error: () => {
|
||||
this.toast.error(this.lang.t('FAILED_DELETE_ITEM'));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.style.display = 'none';
|
||||
const parent = img.parentElement;
|
||||
if (parent) {
|
||||
const placeholder = parent.querySelector('.no-image') as HTMLElement | null;
|
||||
if (placeholder) placeholder.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { CommonModule } from '@angular/common';
|
||||
@for (item of [1,2,3,4,5]; track item) {
|
||||
<div class="skeleton-tree-item">
|
||||
<div class="skeleton-circle"></div>
|
||||
<div class="skeleton-line" [style.width]="getRandomWidth()"></div>
|
||||
<div class="skeleton-line" [style.width]="treeWidths[item - 1]"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -159,8 +159,8 @@ import { CommonModule } from '@angular/common';
|
||||
export class LoadingSkeletonComponent {
|
||||
@Input() type: 'tree' | 'card' | 'list' | 'form' = 'list';
|
||||
|
||||
getRandomWidth(): string {
|
||||
const widths = ['60%', '70%', '80%', '90%'];
|
||||
return widths[Math.floor(Math.random() * widths.length)];
|
||||
}
|
||||
/** Pre-computed widths so they don't change between CD cycles (NG0100). */
|
||||
readonly treeWidths = [1, 2, 3, 4, 5].map(
|
||||
(_, i) => ['60%', '80%', '70%', '90%', '75%'][i]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
@if (showDialog()) {
|
||||
<div class="login-overlay" (click)="close()">
|
||||
<div class="login-dialog" role="dialog" aria-modal="true" aria-label="Telegram login dialog" (click)="$event.stopPropagation()">
|
||||
<button class="close-btn" type="button" aria-label="Close dialog" (click)="close()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="dialog-content" [attr.data-state]="state()">
|
||||
<div class="login-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2>{{ 'AUTH_LOGIN_REQUIRED' | translate }}</h2>
|
||||
<p class="login-desc">{{ 'AUTH_LOGIN_DESCRIPTION' | translate }}</p>
|
||||
|
||||
<div class="login-status checking">
|
||||
<div class="spinner"></div>
|
||||
<span>{{ 'AUTH_CHECKING' | translate }}</span>
|
||||
</div>
|
||||
|
||||
<div class="action-block">
|
||||
<button class="telegram-btn" type="button" (click)="openTelegram()">
|
||||
<svg class="tg-icon" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"></path>
|
||||
</svg>
|
||||
{{ 'AUTH_LOGIN_WITH_TELEGRAM' | translate }}
|
||||
</button>
|
||||
|
||||
<div class="qr-section">
|
||||
<p class="qr-hint">{{ 'AUTH_OR_SCAN_QR' | translate }}</p>
|
||||
|
||||
<div class="qr-container qr-loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<div class="qr-container qr-ready">
|
||||
<img [src]="qrImageUrl()" [alt]="qrAlt()" width="180" height="180" loading="eager" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="qr-container qr-expired"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Refresh QR code"
|
||||
(click)="refreshQr()"
|
||||
(keydown)="refreshQrByKeyboard($event)"
|
||||
>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 4v6h6M23 20v-6h-6"></path>
|
||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
|
||||
</svg>
|
||||
<span>{{ 'AUTH_QR_EXPIRED' | translate }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="qr-container qr-error"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Retry QR login"
|
||||
(click)="refreshQr()"
|
||||
(keydown)="refreshQrByKeyboard($event)"
|
||||
>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 4v6h6M23 20v-6h-6"></path>
|
||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
|
||||
</svg>
|
||||
<span>{{ 'AUTH_QR_ERROR' | translate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="login-note">{{ 'AUTH_LOGIN_NOTE' | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
275
src/app/components/telegram-login/telegram-login.component.scss
Normal file
275
src/app/components/telegram-login/telegram-login.component.scss
Normal file
@@ -0,0 +1,275 @@
|
||||
:host {
|
||||
--bg-card: #ffffff;
|
||||
--bg-hover: #f0f0f0;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #666666;
|
||||
--accent-color: #497671;
|
||||
--accent-light: rgba(73, 118, 113, 0.1);
|
||||
--telegram: #2aabee;
|
||||
--telegram-hover: #229ed9;
|
||||
--border: #e8e8e8;
|
||||
--shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.login-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
border-radius: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s ease;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.login-dialog {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border-radius: 20px;
|
||||
padding: 32px 28px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow);
|
||||
animation: scaleIn 0.25s ease;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.login-icon {
|
||||
margin: 0 auto 16px;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.login-desc {
|
||||
margin: 0 0 24px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.telegram-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 14px 24px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: var(--telegram);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.telegram-btn:hover {
|
||||
background: var(--telegram-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3);
|
||||
}
|
||||
|
||||
.telegram-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.tg-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.qr-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.qr-hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
display: inline-flex;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.qr-container img {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.qr-loading,
|
||||
.qr-expired,
|
||||
.qr-error {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 204px;
|
||||
height: 204px;
|
||||
}
|
||||
|
||||
.qr-loading .spinner,
|
||||
.login-status .spinner {
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.qr-loading .spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e0e0e0;
|
||||
border-top-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.qr-expired,
|
||||
.qr-error {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.qr-expired:hover,
|
||||
.qr-error:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.qr-expired span,
|
||||
.qr-error span {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-note {
|
||||
margin: 16px 0 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.login-status {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-status .spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-top-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.dialog-content[data-state='checking'] .login-status {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog-content[data-state='checking'] .action-block,
|
||||
.dialog-content[data-state='checking'] .qr-section,
|
||||
.dialog-content[data-state='checking'] .login-note {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dialog-content[data-state='ready'] .qr-loading,
|
||||
.dialog-content[data-state='ready'] .qr-expired,
|
||||
.dialog-content[data-state='ready'] .qr-error,
|
||||
.dialog-content[data-state='loading'] .qr-ready,
|
||||
.dialog-content[data-state='loading'] .qr-expired,
|
||||
.dialog-content[data-state='loading'] .qr-error,
|
||||
.dialog-content[data-state='expired'] .qr-loading,
|
||||
.dialog-content[data-state='expired'] .qr-ready,
|
||||
.dialog-content[data-state='expired'] .qr-error,
|
||||
.dialog-content[data-state='error'] .qr-loading,
|
||||
.dialog-content[data-state='error'] .qr-ready,
|
||||
.dialog-content[data-state='error'] .qr-expired {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-dialog {
|
||||
padding: 24px 20px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.qr-container img {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.qr-loading,
|
||||
.qr-expired,
|
||||
.qr-error {
|
||||
width: 164px;
|
||||
height: 164px;
|
||||
}
|
||||
}
|
||||
157
src/app/components/telegram-login/telegram-login.component.ts
Normal file
157
src/app/components/telegram-login/telegram-login.component.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { ChangeDetectionStrategy, Component, OnDestroy, computed, effect, inject, signal } from '@angular/core';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
|
||||
type DialogState = 'ready' | 'loading' | 'checking' | 'expired' | 'error';
|
||||
|
||||
@Component({
|
||||
selector: 'app-telegram-login',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe],
|
||||
templateUrl: './telegram-login.component.html',
|
||||
styleUrl: './telegram-login.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class TelegramLoginComponent implements OnDestroy {
|
||||
private readonly authService = inject(AuthService);
|
||||
|
||||
protected readonly showDialog = this.authService.showLoginDialog;
|
||||
protected readonly state = signal<DialogState>('loading');
|
||||
protected readonly telegramDeepLink = signal('');
|
||||
protected readonly qrImageUrl = signal('');
|
||||
protected readonly qrAlt = computed(() => this.qrImageUrl() ? 'QR Code' : '');
|
||||
|
||||
private qrToken = '';
|
||||
private pollCount = 0;
|
||||
private pollTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.showDialog()) {
|
||||
this.startLoginFlow();
|
||||
return;
|
||||
}
|
||||
|
||||
this.stopPolling();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
protected close(): void {
|
||||
this.authService.hideLogin();
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
protected openTelegram(): void {
|
||||
const deepLink = this.telegramDeepLink();
|
||||
if (!deepLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(deepLink, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
protected refreshQr(): void {
|
||||
this.startLoginFlow();
|
||||
}
|
||||
|
||||
protected refreshQrByKeyboard(event: KeyboardEvent): void {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
this.refreshQr();
|
||||
}
|
||||
}
|
||||
|
||||
private startLoginFlow(): void {
|
||||
this.stopPolling();
|
||||
this.state.set('loading');
|
||||
this.telegramDeepLink.set('');
|
||||
this.qrImageUrl.set('');
|
||||
this.qrToken = '';
|
||||
this.pollCount = 0;
|
||||
|
||||
this.authService.createQrSession().subscribe({
|
||||
next: (response) => {
|
||||
this.qrToken = response.token;
|
||||
this.telegramDeepLink.set(response.url);
|
||||
this.qrImageUrl.set(
|
||||
response.qrUrl
|
||||
?? `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(response.url)}`
|
||||
);
|
||||
this.state.set('ready');
|
||||
this.startPolling();
|
||||
},
|
||||
error: () => {
|
||||
this.checkExistingSessionFallback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
this.stopPolling();
|
||||
|
||||
this.pollTimer = setInterval(() => {
|
||||
this.pollCount += 1;
|
||||
|
||||
if (this.pollCount >= 100) {
|
||||
this.state.set('expired');
|
||||
this.stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
this.pollQrStatus();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
private pollQrStatus(): void {
|
||||
if (!this.qrToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.authService.pollQrStatus(this.qrToken).subscribe((result) => {
|
||||
if (result.status === 'pending') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'expired') {
|
||||
this.state.set('expired');
|
||||
this.stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'confirmed') {
|
||||
this.state.set('checking');
|
||||
this.stopPolling();
|
||||
this.authService.completeLogin(result.session).subscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.set('error');
|
||||
this.stopPolling();
|
||||
});
|
||||
}
|
||||
|
||||
private checkExistingSessionFallback(): void {
|
||||
this.state.set('checking');
|
||||
this.stopPolling();
|
||||
|
||||
this.authService.checkSessionOnce().subscribe((session) => {
|
||||
if (session?.active) {
|
||||
this.authService.completeLogin(session).subscribe();
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.set('error');
|
||||
});
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,15 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
ACTIVE: 'Active',
|
||||
INACTIVE: 'Inactive',
|
||||
PROJECTS: 'Projects',
|
||||
AUTH_BACKOFFICE_REQUIRED: 'Telegram login is required to use the back office.',
|
||||
AUTH_LOGIN_REQUIRED: 'Login required',
|
||||
AUTH_LOGIN_DESCRIPTION: 'Please log in via Telegram to proceed with your order.',
|
||||
AUTH_CHECKING: 'Checking...',
|
||||
AUTH_LOGIN_WITH_TELEGRAM: 'Log in with Telegram',
|
||||
AUTH_OR_SCAN_QR: 'Or scan the QR code',
|
||||
AUTH_QR_EXPIRED: 'QR code expired. Click to refresh',
|
||||
AUTH_QR_ERROR: 'QR login failed. Click to retry',
|
||||
AUTH_LOGIN_NOTE: 'You will be redirected back after login.',
|
||||
|
||||
// --- Navigation / Project view ---
|
||||
CATEGORIES: 'Categories',
|
||||
@@ -53,6 +62,7 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
// --- Item basic fields ---
|
||||
ITEM_NAME: 'Item Name',
|
||||
PRICE: 'Price',
|
||||
DISCOUNT: 'Discount (%)',
|
||||
QUANTITY: 'Quantity',
|
||||
CURRENCY: 'Currency',
|
||||
SIMPLE_DESCRIPTION: 'Simple Description',
|
||||
@@ -102,6 +112,9 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
NO_MORE_ITEMS: 'No more items to load',
|
||||
SHOW: 'Show',
|
||||
HIDE: 'Hide',
|
||||
SHOW_ALL: 'Show All',
|
||||
HIDE_ALL: 'Hide All',
|
||||
UPDATING_VISIBILITY: 'Updating visibility...',
|
||||
|
||||
// --- Translations tab ---
|
||||
TRANSLATIONS_HINT: 'Fill in translations for marketplace localization.',
|
||||
@@ -114,6 +127,50 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
ADD_DESC_ROW: 'Add Row',
|
||||
NO_TRANSLATIONS: 'No Russian translation yet',
|
||||
TRANSLATION_SAVED: 'Translation saved',
|
||||
|
||||
// --- Attributes tab ---
|
||||
ATTRIBUTES: 'Attributes',
|
||||
ATTRIBUTES_HINT: 'Key-value attributes for product specifications.',
|
||||
ATTR_KEY: 'Attribute key',
|
||||
ATTR_VALUE: 'Attribute value',
|
||||
ATTR_KEY_PLACEHOLDER: 'e.g. Material',
|
||||
ATTR_VALUE_PLACEHOLDER: 'e.g. Cotton 100%',
|
||||
NO_ATTRIBUTES: 'No attributes yet',
|
||||
|
||||
// --- New item fields ---
|
||||
COLOUR: 'Colour',
|
||||
SIZE: 'Size',
|
||||
|
||||
// --- CRUD / Toast messages ---
|
||||
CONFIRM_DELETE: 'Are you sure you want to delete',
|
||||
VALIDATION_ERROR: 'Validation error',
|
||||
CREATE_NEW_CATEGORY: 'Create New Category',
|
||||
CREATE_NEW_SUBCATEGORY: 'Create New Subcategory',
|
||||
CREATE_NEW_ITEM: 'Create New Item',
|
||||
DELETE_ITEM: 'Delete Item',
|
||||
CATEGORY_CREATED: 'Category created!',
|
||||
SUBCATEGORY_CREATED: 'Subcategory created!',
|
||||
ITEM_CREATED: 'Item created!',
|
||||
CATEGORY_DELETED: 'Category deleted',
|
||||
SUBCATEGORY_DELETED: 'Subcategory deleted',
|
||||
ITEM_DELETED: 'Item deleted',
|
||||
UPDATED: 'Updated',
|
||||
NO_ITEMS_SELECTED: 'No items selected',
|
||||
FAILED_LOAD_CATEGORY: 'Failed to load category',
|
||||
FAILED_LOAD_SUBCATEGORY: 'Failed to load subcategory',
|
||||
FAILED_LOAD_ITEM: 'Failed to load item',
|
||||
FAILED_LOAD_ITEMS: 'Failed to load items',
|
||||
FAILED_CREATE_CATEGORY: 'Failed to create category',
|
||||
FAILED_CREATE_SUBCATEGORY: 'Failed to create subcategory',
|
||||
FAILED_CREATE_ITEM: 'Failed to create item',
|
||||
FAILED_DELETE_CATEGORY: 'Failed to delete category',
|
||||
FAILED_DELETE_SUBCATEGORY: 'Failed to delete subcategory',
|
||||
FAILED_DELETE_ITEM: 'Failed to delete item',
|
||||
FAILED_UPDATE_ITEMS: 'Failed to update items',
|
||||
FAILED_UPDATE_VISIBILITY: 'Failed to update visibility',
|
||||
FAILED_UPLOAD_IMAGE: 'Failed to upload image',
|
||||
ALL_CONTENT_VISIBLE: 'All categories, subcategories, and items are now visible',
|
||||
ALL_CONTENT_HIDDEN: 'All categories, subcategories, and items are now hidden',
|
||||
},
|
||||
|
||||
ru: {
|
||||
@@ -122,6 +179,15 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
ACTIVE: 'Активен',
|
||||
INACTIVE: 'Неактивен',
|
||||
PROJECTS: 'Проекты',
|
||||
AUTH_BACKOFFICE_REQUIRED: 'Для работы с бэкофисом нужен вход через Telegram.',
|
||||
AUTH_LOGIN_REQUIRED: 'Требуется вход',
|
||||
AUTH_LOGIN_DESCRIPTION: 'Войдите через Telegram, чтобы продолжить.',
|
||||
AUTH_CHECKING: 'Проверяем...',
|
||||
AUTH_LOGIN_WITH_TELEGRAM: 'Войти через Telegram',
|
||||
AUTH_OR_SCAN_QR: 'Или отсканируйте QR-код',
|
||||
AUTH_QR_EXPIRED: 'QR-код истек. Нажмите, чтобы обновить',
|
||||
AUTH_QR_ERROR: 'Не удалось создать QR. Нажмите, чтобы повторить',
|
||||
AUTH_LOGIN_NOTE: 'После входа вы вернетесь обратно.',
|
||||
|
||||
// --- Navigation / Project view ---
|
||||
CATEGORIES: 'Категории',
|
||||
@@ -169,6 +235,7 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
// --- Item basic fields ---
|
||||
ITEM_NAME: 'Название товара',
|
||||
PRICE: 'Цена',
|
||||
DISCOUNT: 'Скидка (%)',
|
||||
QUANTITY: 'Количество',
|
||||
CURRENCY: 'Валюта',
|
||||
SIMPLE_DESCRIPTION: 'Краткое описание',
|
||||
@@ -218,6 +285,9 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
NO_MORE_ITEMS: 'Все товары загружены',
|
||||
SHOW: 'Показать',
|
||||
HIDE: 'Скрыть',
|
||||
SHOW_ALL: 'Показать все',
|
||||
HIDE_ALL: 'Скрыть все',
|
||||
UPDATING_VISIBILITY: 'Обновляем видимость...',
|
||||
|
||||
// --- Translations tab ---
|
||||
TRANSLATIONS_HINT: 'Переводы для локализации маркетплейса.',
|
||||
@@ -230,5 +300,49 @@ export const TRANSLATIONS: Record<string, Record<string, string>> = {
|
||||
ADD_DESC_ROW: 'Добавить строку',
|
||||
NO_TRANSLATIONS: 'Русский перевод не заполнен',
|
||||
TRANSLATION_SAVED: 'Перевод сохранён',
|
||||
|
||||
// --- Attributes tab ---
|
||||
ATTRIBUTES: 'Атрибуты',
|
||||
ATTRIBUTES_HINT: 'Ключ-значение атрибуты для характеристик товара.',
|
||||
ATTR_KEY: 'Ключ атрибута',
|
||||
ATTR_VALUE: 'Значение атрибута',
|
||||
ATTR_KEY_PLACEHOLDER: 'напр. Материал',
|
||||
ATTR_VALUE_PLACEHOLDER: 'напр. Хлопок 100%',
|
||||
NO_ATTRIBUTES: 'Атрибутов пока нет',
|
||||
|
||||
// --- New item fields ---
|
||||
COLOUR: 'Цвет',
|
||||
SIZE: 'Размер',
|
||||
|
||||
// --- CRUD / Toast messages ---
|
||||
CONFIRM_DELETE: 'Вы уверены, что хотите удалить',
|
||||
VALIDATION_ERROR: 'Ошибка валидации',
|
||||
CREATE_NEW_CATEGORY: 'Создать категорию',
|
||||
CREATE_NEW_SUBCATEGORY: 'Создать подкатегорию',
|
||||
CREATE_NEW_ITEM: 'Создать товар',
|
||||
DELETE_ITEM: 'Удалить товар',
|
||||
CATEGORY_CREATED: 'Категория создана!',
|
||||
SUBCATEGORY_CREATED: 'Подкатегория создана!',
|
||||
ITEM_CREATED: 'Товар создан!',
|
||||
CATEGORY_DELETED: 'Категория удалена',
|
||||
SUBCATEGORY_DELETED: 'Подкатегория удалена',
|
||||
ITEM_DELETED: 'Товар удалён',
|
||||
UPDATED: 'Обновлено',
|
||||
NO_ITEMS_SELECTED: 'Ничего не выбрано',
|
||||
FAILED_LOAD_CATEGORY: 'Не удалось загрузить категорию',
|
||||
FAILED_LOAD_SUBCATEGORY: 'Не удалось загрузить подкатегорию',
|
||||
FAILED_LOAD_ITEM: 'Не удалось загрузить товар',
|
||||
FAILED_LOAD_ITEMS: 'Не удалось загрузить товары',
|
||||
FAILED_CREATE_CATEGORY: 'Не удалось создать категорию',
|
||||
FAILED_CREATE_SUBCATEGORY: 'Не удалось создать подкатегорию',
|
||||
FAILED_CREATE_ITEM: 'Не удалось создать товар',
|
||||
FAILED_DELETE_CATEGORY: 'Не удалось удалить категорию',
|
||||
FAILED_DELETE_SUBCATEGORY: 'Не удалось удалить подкатегорию',
|
||||
FAILED_DELETE_ITEM: 'Не удалось удалить товар',
|
||||
FAILED_UPDATE_ITEMS: 'Не удалось обновить товары',
|
||||
FAILED_UPDATE_VISIBILITY: 'Не удалось обновить видимость',
|
||||
FAILED_UPLOAD_IMAGE: 'Не удалось загрузить изображение',
|
||||
ALL_CONTENT_VISIBLE: 'Все категории, подкатегории и товары теперь видимы',
|
||||
ALL_CONTENT_HIDDEN: 'Все категории, подкатегории и товары теперь скрыты',
|
||||
},
|
||||
};
|
||||
|
||||
70
src/app/interceptors/auth-credentials.interceptor.ts
Normal file
70
src/app/interceptors/auth-credentials.interceptor.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
const AUTHENTICATED_SESSION_STORAGE_KEY = 'userauth_session_id';
|
||||
const ANONYMOUS_SESSION_STORAGE_KEY = 'web_session_id';
|
||||
|
||||
export const authCredentialsInterceptor: HttpInterceptorFn = (request, next) => {
|
||||
if (!shouldUseCredentials(request.url)) {
|
||||
return next(request);
|
||||
}
|
||||
|
||||
const webSessionId = getStoredAuthenticatedSessionId() ?? getAnonymousSessionId();
|
||||
const headers = request.headers.has('WebSessionID')
|
||||
? request.headers
|
||||
: request.headers.set('WebSessionID', webSessionId);
|
||||
|
||||
return next(request.clone({ headers, withCredentials: true }));
|
||||
};
|
||||
|
||||
function shouldUseCredentials(url: string): boolean {
|
||||
if (url.startsWith('/api') || url.startsWith('/userauth') || url.startsWith('/usersession') || url.startsWith('/users/sessions')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const credentialBases = [
|
||||
environment.apiUrl,
|
||||
(environment as Record<string, unknown>)['authApiUrl'] as string | undefined,
|
||||
(environment as Record<string, unknown>)['userSessionApiUrl'] as string | undefined
|
||||
].filter((baseUrl): baseUrl is string => Boolean(baseUrl));
|
||||
|
||||
return credentialBases.some((baseUrl) => url.startsWith(baseUrl));
|
||||
}
|
||||
|
||||
function getStoredAuthenticatedSessionId(): string | null {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionId = localStorage.getItem(AUTHENTICATED_SESSION_STORAGE_KEY);
|
||||
return sessionId?.trim() || null;
|
||||
}
|
||||
|
||||
function getAnonymousSessionId(): string {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return generateSessionId();
|
||||
}
|
||||
|
||||
let sessionId = localStorage.getItem(ANONYMOUS_SESSION_STORAGE_KEY);
|
||||
|
||||
if (!sessionId || sessionId.length !== 32) {
|
||||
sessionId = generateSessionId();
|
||||
localStorage.setItem(ANONYMOUS_SESSION_STORAGE_KEY, sessionId);
|
||||
}
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function generateSessionId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let index = 0; index < bytes.length; index++) {
|
||||
bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
21
src/app/models/auth.model.ts
Normal file
21
src/app/models/auth.model.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface AuthSession {
|
||||
sessionId: string;
|
||||
telegramUserId: number | null;
|
||||
username: string | null;
|
||||
displayName: string;
|
||||
active: boolean;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface QrLoginSession {
|
||||
token: string;
|
||||
url: string;
|
||||
qrUrl?: string;
|
||||
}
|
||||
|
||||
export interface QrPollResult {
|
||||
status: 'pending' | 'confirmed' | 'expired' | 'error';
|
||||
session?: AuthSession | null;
|
||||
}
|
||||
|
||||
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ItemName } from './item.model';
|
||||
|
||||
/**
|
||||
* Per-language translation content for a category or subcategory.
|
||||
* Stored under `translations['ru']`, `translations['en']`, etc.
|
||||
@@ -16,6 +18,15 @@ export interface Category {
|
||||
subcategories?: Subcategory[];
|
||||
/** Optional translations keyed by language code: { ru: { name: '...' } } */
|
||||
translations?: { [lang: string]: CategoryTranslation };
|
||||
|
||||
// Fields from Go backend struct
|
||||
categoryID?: number;
|
||||
parentID?: number;
|
||||
icon?: string;
|
||||
wideBanner?: string;
|
||||
itemCount?: number;
|
||||
categoriesCount?: number;
|
||||
names?: ItemName[];
|
||||
}
|
||||
|
||||
export interface Subcategory {
|
||||
|
||||
@@ -7,6 +7,60 @@ export interface ItemTranslation {
|
||||
description?: ItemDescriptionField[];
|
||||
}
|
||||
|
||||
/** Localized name entry */
|
||||
export interface ItemName {
|
||||
language: string;
|
||||
value: string;
|
||||
/** Backend typo — some responses use 'valuue' instead of 'value' */
|
||||
valuue?: string;
|
||||
}
|
||||
|
||||
/** Localized description entry */
|
||||
export interface ItemDescription {
|
||||
language: string;
|
||||
value: string;
|
||||
valuue?: string;
|
||||
}
|
||||
|
||||
/** Key-value attribute pair */
|
||||
export interface ItemAttribute {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Item variant detail (price, size, colour per variant) */
|
||||
export interface ItemDetail {
|
||||
color?: string;
|
||||
colour?: string;
|
||||
size?: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
/** Photo entry with type (photo or video) */
|
||||
export interface Photo {
|
||||
type?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Question on an item */
|
||||
export interface Question {
|
||||
question: string;
|
||||
answer: string;
|
||||
like?: number;
|
||||
dislike?: number;
|
||||
}
|
||||
|
||||
/** Review / callback on an item */
|
||||
export interface Review {
|
||||
rating?: number;
|
||||
content?: string;
|
||||
userID?: string;
|
||||
answer?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -14,16 +68,34 @@ export interface Item {
|
||||
priority: number;
|
||||
quantity: number;
|
||||
price: number;
|
||||
discount: number;
|
||||
currency: string;
|
||||
imgs: string[];
|
||||
tags: string[];
|
||||
badges?: string[];
|
||||
colour?: string;
|
||||
size?: string;
|
||||
simpleDescription: string;
|
||||
description: ItemDescriptionField[];
|
||||
subcategoryId: string;
|
||||
names?: ItemName[];
|
||||
descriptions?: ItemDescription[];
|
||||
attributes?: ItemAttribute[];
|
||||
comments?: Comment[];
|
||||
/** Optional translations keyed by language code: { ru: { name: '...', simpleDescription: '...', description: [...] } } */
|
||||
translations?: { [lang: string]: ItemTranslation };
|
||||
|
||||
// Fields from Go backend struct
|
||||
itemID?: number;
|
||||
categoryID?: number;
|
||||
rating?: number;
|
||||
visits?: number;
|
||||
itemDetails?: ItemDetail[];
|
||||
photos?: Photo[];
|
||||
questions?: Question[];
|
||||
callbacks?: Review[];
|
||||
partnerID?: string;
|
||||
remaining?: number;
|
||||
}
|
||||
|
||||
export interface ItemDescriptionField {
|
||||
|
||||
@@ -87,22 +87,34 @@
|
||||
</div>
|
||||
|
||||
@if (category()!.subcategories?.length) {
|
||||
<mat-list>
|
||||
<mat-accordion multi>
|
||||
@for (sub of category()!.subcategories; track sub.id) {
|
||||
<mat-list-item (click)="openSubcategory(sub.id)">
|
||||
<span matListItemTitle>{{ sub.name }}</span>
|
||||
<span matListItemLine>{{ 'PRIORITY' | translate }}: {{ sub.priority }}</span>
|
||||
<button mat-icon-button matListItemMeta>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</mat-list-item>
|
||||
<mat-expansion-panel>
|
||||
<mat-expansion-panel-header>
|
||||
<mat-panel-title>
|
||||
<mat-icon class="sub-icon">folder</mat-icon>
|
||||
{{ sub.name }}
|
||||
</mat-panel-title>
|
||||
<mat-panel-description>
|
||||
{{ 'PRIORITY' | translate }}: {{ sub.priority }}
|
||||
<button mat-icon-button (click)="openSubcategory(sub.id); $event.stopPropagation()" [matTooltip]="'EDIT' | translate">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
</mat-panel-description>
|
||||
</mat-expansion-panel-header>
|
||||
<app-inline-items-list
|
||||
[subcategoryId]="sub.id"
|
||||
[projectId]="projectId()">
|
||||
</app-inline-items-list>
|
||||
</mat-expansion-panel>
|
||||
}
|
||||
</mat-list>
|
||||
</mat-accordion>
|
||||
} @else {
|
||||
<p class="empty-state">{{ 'NO_SUBCATEGORIES' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Translations section hidden until client provides requirements
|
||||
<div class="translations-section">
|
||||
<h3>{{ 'TRANSLATIONS' | translate }}</h3>
|
||||
<p class="hint">{{ 'TRANSLATIONS_HINT' | translate }}</p>
|
||||
@@ -111,6 +123,7 @@
|
||||
<input matInput [(ngModel)]="ruName" (blur)="saveRuName(ruName)" [placeholder]="'NAME_TRANSLATED' | translate">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -124,12 +124,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
mat-list-item {
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
mat-accordion {
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
background-color: #f5f5f5;
|
||||
mat-expansion-panel {
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
.sub-icon {
|
||||
margin-right: 0.5rem;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
mat-panel-description {
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
|
||||
button {
|
||||
margin-right: -8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import { Component, OnInit, signal, effect } from '@angular/core';
|
||||
import { Component, OnInit, signal, effect, DestroyRef, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatExpansionModule } from '@angular/material/expansion';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { ApiService } from '../../services';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { Category } from '../../models';
|
||||
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||
import { InlineItemsListComponent } from '../../components/inline-items-list/inline-items-list.component';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
|
||||
@@ -32,11 +35,12 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
MatSlideToggleModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
MatListModule,
|
||||
MatDialogModule,
|
||||
MatTooltipModule,
|
||||
MatExpansionModule,
|
||||
LoadingSkeletonComponent,
|
||||
InlineItemsListComponent,
|
||||
TranslatePipe
|
||||
],
|
||||
templateUrl: './category-editor.component.html',
|
||||
@@ -45,18 +49,22 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
export class CategoryEditorComponent implements OnInit {
|
||||
category = signal<Category | null>(null);
|
||||
loading = signal(true);
|
||||
saving = signal(false);
|
||||
categoryId = signal<string>('');
|
||||
projectId = signal<string>('');
|
||||
|
||||
/** Whether the debounced save queue is in-flight */
|
||||
get saving() { return this.apiService.saving; }
|
||||
|
||||
/** Local buffer for the Russian translation of the category name */
|
||||
ruName = '';
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private apiService: ApiService,
|
||||
private snackBar: MatSnackBar,
|
||||
private toast: ToastService,
|
||||
private dialog: MatDialog,
|
||||
public lang: LanguageService
|
||||
) {}
|
||||
@@ -68,7 +76,7 @@ export class CategoryEditorComponent implements OnInit {
|
||||
this.projectId.set(parentParams['projectId']);
|
||||
}
|
||||
|
||||
this.route.params.subscribe(params => {
|
||||
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => {
|
||||
this.categoryId.set(params['categoryId']);
|
||||
this.loadCategory();
|
||||
});
|
||||
@@ -84,7 +92,7 @@ export class CategoryEditorComponent implements OnInit {
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load category', err);
|
||||
this.snackBar.open('Failed to load category', 'Close', { duration: 3000 });
|
||||
this.toast.error(this.lang.t('FAILED_LOAD_CATEGORY'));
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
@@ -99,13 +107,7 @@ export class CategoryEditorComponent implements OnInit {
|
||||
}
|
||||
|
||||
onFieldChange(field: keyof Category, value: any) {
|
||||
this.saving.set(true);
|
||||
this.apiService.queueSave('category', this.categoryId(), field, value);
|
||||
|
||||
setTimeout(() => {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Saved', '', { duration: 1000 });
|
||||
}, 600);
|
||||
}
|
||||
|
||||
async onImageSelect(event: Event, type: 'file' | 'url') {
|
||||
@@ -124,8 +126,7 @@ export class CategoryEditorComponent implements OnInit {
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to upload image', 'Close', { duration: 3000 });
|
||||
this.saving.set(false);
|
||||
this.toast.error(this.lang.t('FAILED_UPLOAD_IMAGE'));
|
||||
}
|
||||
});
|
||||
} else if (type === 'url') {
|
||||
@@ -151,12 +152,12 @@ export class CategoryEditorComponent implements OnInit {
|
||||
addSubcategory() {
|
||||
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||
data: {
|
||||
title: 'Create New Subcategory',
|
||||
title: this.lang.t('CREATE_NEW_SUBCATEGORY'),
|
||||
type: 'subcategory',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
|
||||
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
|
||||
{ name: 'name', label: this.lang.t('NAME'), type: 'text', required: true },
|
||||
{ name: 'priority', label: this.lang.t('PRIORITY'), type: 'number', value: 99 },
|
||||
{ name: 'visible', label: this.lang.t('VISIBLE'), type: 'toggle', value: true }
|
||||
]
|
||||
}
|
||||
});
|
||||
@@ -165,11 +166,11 @@ export class CategoryEditorComponent implements OnInit {
|
||||
if (result) {
|
||||
this.apiService.createSubcategory(this.categoryId(), 'category', result).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 });
|
||||
this.toast.success(this.lang.t('SUBCATEGORY_CREATED'));
|
||||
this.loadCategory();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to create subcategory', 'Close', { duration: 3000 });
|
||||
this.toast.error(this.lang.t('FAILED_CREATE_SUBCATEGORY'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -182,10 +183,10 @@ export class CategoryEditorComponent implements OnInit {
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Category',
|
||||
message: `Are you sure you want to delete "${cat.name}"? This will also delete all subcategories and items.`,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
title: this.lang.t('DELETE_CATEGORY'),
|
||||
message: `${this.lang.t('CONFIRM_DELETE')} "${cat.name}"?`,
|
||||
confirmText: this.lang.t('DELETE'),
|
||||
cancelText: this.lang.t('CANCEL'),
|
||||
dangerous: true
|
||||
}
|
||||
});
|
||||
@@ -194,11 +195,11 @@ export class CategoryEditorComponent implements OnInit {
|
||||
if (confirmed) {
|
||||
this.apiService.deleteCategory(this.categoryId()).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Category deleted', 'Close', { duration: 2000 });
|
||||
this.toast.success(this.lang.t('CATEGORY_DELETED'));
|
||||
this.router.navigate(['/project', this.projectId()]);
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to delete category', 'Close', { duration: 3000 });
|
||||
this.toast.error(this.lang.t('FAILED_DELETE_CATEGORY'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,6 +107,25 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>{{ 'DISCOUNT' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
step="1"
|
||||
[(ngModel)]="item()!.discount"
|
||||
(blur)="onFieldChange('discount', item()!.discount)"
|
||||
min="0"
|
||||
max="100"
|
||||
placeholder="0">
|
||||
<span matSuffix>%</span>
|
||||
@if (item()!.discount < 0 || item()!.discount > 100) {
|
||||
<mat-error>Discount must be 0–100%</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>{{ 'SIMPLE_DESCRIPTION' | translate }}</mat-label>
|
||||
<textarea
|
||||
@@ -116,6 +135,26 @@
|
||||
(blur)="onFieldChange('simpleDescription', item()!.simpleDescription)">
|
||||
</textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>{{ 'COLOUR' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="item()!.colour"
|
||||
(blur)="onFieldChange('colour', item()!.colour)"
|
||||
placeholder="e.g. Black, Red">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>{{ 'SIZE' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="item()!.size"
|
||||
(blur)="onFieldChange('size', item()!.size)"
|
||||
placeholder="e.g. M, L, XL">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
@@ -425,6 +464,78 @@
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<!-- Attributes Tab -->
|
||||
<mat-tab [label]="'ATTRIBUTES' | translate">
|
||||
<div class="tab-content">
|
||||
<div class="attributes-section">
|
||||
<h3>{{ 'ATTRIBUTES' | translate }}</h3>
|
||||
<p class="hint">{{ 'ATTRIBUTES_HINT' | translate }}</p>
|
||||
|
||||
<div class="add-desc-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ 'ATTR_KEY' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="newAttrKey"
|
||||
[placeholder]="'ATTR_KEY_PLACEHOLDER' | translate">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ 'ATTR_VALUE' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="newAttrValue"
|
||||
[placeholder]="'ATTR_VALUE_PLACEHOLDER' | translate">
|
||||
</mat-form-field>
|
||||
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="addAttribute()">
|
||||
<mat-icon>add</mat-icon>
|
||||
{{ 'ADD_FIELD' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="desc-fields-list">
|
||||
@for (attr of item()!.attributes || []; track $index) {
|
||||
<div class="desc-field-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ 'ATTR_KEY' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[value]="attr.key"
|
||||
(blur)="updateAttribute($index, 'key', $any($event.target).value)">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ 'ATTR_VALUE' | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[value]="attr.value"
|
||||
(blur)="updateAttribute($index, 'value', $any($event.target).value)">
|
||||
</mat-form-field>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="removeAttribute($index)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!(item()!.attributes?.length)) {
|
||||
<div class="empty-state">
|
||||
<mat-icon>tune</mat-icon>
|
||||
<p>{{ 'NO_ATTRIBUTES' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, OnInit, signal } from '@angular/core';
|
||||
import { Component, OnInit, signal, DestroyRef, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -16,7 +17,8 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { DragDropModule, CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { ApiService } from '../../services';
|
||||
import { ValidationService } from '../../services/validation.service';
|
||||
import { Item, ItemDescriptionField, Subcategory } from '../../models';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { Item, ItemDescriptionField, ItemAttribute, Subcategory } from '../../models';
|
||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
@@ -50,10 +52,12 @@ export class ItemEditorComponent implements OnInit {
|
||||
item = signal<Item | null>(null);
|
||||
subcategory = signal<Subcategory | null>(null);
|
||||
loading = signal(true);
|
||||
saving = signal(false);
|
||||
itemId = signal<string>('');
|
||||
projectId = signal<string>('');
|
||||
validationErrors = signal<Record<string, string>>({});
|
||||
|
||||
/** Whether the debounced save queue is in-flight */
|
||||
get saving() { return this.apiService.saving; }
|
||||
|
||||
newTag = '';
|
||||
newDescKey = '';
|
||||
@@ -65,7 +69,7 @@ export class ItemEditorComponent implements OnInit {
|
||||
ruSimpleDesc = '';
|
||||
ruDescFields: ItemDescriptionField[] = [];
|
||||
|
||||
currencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH'];
|
||||
currencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH', 'AMD'];
|
||||
|
||||
predefinedBadges: { label: string; value: string; color: string }[] = [
|
||||
{ label: 'New', value: 'new', color: '#009688' },
|
||||
@@ -78,11 +82,16 @@ export class ItemEditorComponent implements OnInit {
|
||||
];
|
||||
|
||||
newBadge = '';
|
||||
newAttrKey = '';
|
||||
newAttrValue = '';
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private apiService: ApiService,
|
||||
private toast: ToastService,
|
||||
private snackBar: MatSnackBar,
|
||||
private dialog: MatDialog,
|
||||
private validationService: ValidationService,
|
||||
@@ -96,7 +105,7 @@ export class ItemEditorComponent implements OnInit {
|
||||
this.projectId.set(parentParams['projectId']);
|
||||
}
|
||||
|
||||
this.route.params.subscribe(params => {
|
||||
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => {
|
||||
this.itemId.set(params['itemId']);
|
||||
this.loadItem();
|
||||
});
|
||||
@@ -106,6 +115,11 @@ export class ItemEditorComponent implements OnInit {
|
||||
this.loading.set(true);
|
||||
this.apiService.getItem(this.itemId()).subscribe({
|
||||
next: (item) => {
|
||||
if (!item) {
|
||||
this.toast.error(this.lang.t('ITEM_NOT_FOUND'));
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
this.item.set(item);
|
||||
// Initialise Russian translation buffers
|
||||
const ru = item.translations?.['ru'];
|
||||
@@ -117,7 +131,7 @@ export class ItemEditorComponent implements OnInit {
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load item', err);
|
||||
this.snackBar.open('Failed to load item', 'Close', { duration: 3000 });
|
||||
this.toast.error(this.lang.t('FAILED_LOAD_ITEM'));
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
@@ -147,20 +161,14 @@ export class ItemEditorComponent implements OnInit {
|
||||
if (errors[field]) {
|
||||
currentErrors[field] = errors[field];
|
||||
this.validationErrors.set(currentErrors);
|
||||
this.snackBar.open(`Validation error: ${errors[field]}`, 'Close', { duration: 3000 });
|
||||
this.toast.error(`${this.lang.t('VALIDATION_ERROR')}: ${errors[field]}`);
|
||||
return;
|
||||
} else {
|
||||
delete currentErrors[field];
|
||||
this.validationErrors.set(currentErrors);
|
||||
}
|
||||
|
||||
this.saving.set(true);
|
||||
this.apiService.queueSave('item', this.itemId(), field, value);
|
||||
|
||||
setTimeout(() => {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Saved', '', { duration: 1000 });
|
||||
}, 600);
|
||||
}
|
||||
|
||||
// Image handling
|
||||
@@ -185,7 +193,7 @@ export class ItemEditorComponent implements OnInit {
|
||||
this.onFieldChange('imgs', updatedImgs);
|
||||
}
|
||||
} catch (err) {
|
||||
this.snackBar.open('Failed to upload images', 'Close', { duration: 3000 });
|
||||
this.toast.error(this.lang.t('FAILED_UPLOAD_IMAGE'));
|
||||
} finally {
|
||||
this.uploadingImages.set(false);
|
||||
}
|
||||
@@ -277,7 +285,7 @@ export class ItemEditorComponent implements OnInit {
|
||||
description: this.ruDescFields.filter(f => f.key.trim() || f.value.trim()),
|
||||
};
|
||||
this.onFieldChange('translations' as any, currentItem.translations);
|
||||
this.snackBar.open(this.lang.t('TRANSLATION_SAVED'), '', { duration: 2000 });
|
||||
this.toast.success(this.lang.t('TRANSLATION_SAVED'));
|
||||
}
|
||||
|
||||
addRuDescRow() {
|
||||
@@ -331,6 +339,36 @@ export class ItemEditorComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
// Attributes handling
|
||||
addAttribute() {
|
||||
if (!this.newAttrKey.trim() || !this.newAttrValue.trim()) return;
|
||||
const currentItem = this.item();
|
||||
if (!currentItem) return;
|
||||
const attrs = [...(currentItem.attributes || []), { key: this.newAttrKey.trim(), value: this.newAttrValue.trim() }];
|
||||
currentItem.attributes = attrs;
|
||||
this.onFieldChange('attributes' as any, attrs);
|
||||
this.newAttrKey = '';
|
||||
this.newAttrValue = '';
|
||||
}
|
||||
|
||||
updateAttribute(index: number, field: 'key' | 'value', value: string) {
|
||||
const currentItem = this.item();
|
||||
if (!currentItem) return;
|
||||
const attrs = [...(currentItem.attributes || [])];
|
||||
attrs[index] = { ...attrs[index], [field]: value };
|
||||
currentItem.attributes = attrs;
|
||||
this.onFieldChange('attributes' as any, attrs);
|
||||
}
|
||||
|
||||
removeAttribute(index: number) {
|
||||
const currentItem = this.item();
|
||||
if (!currentItem) return;
|
||||
const attrs = [...(currentItem.attributes || [])];
|
||||
attrs.splice(index, 1);
|
||||
currentItem.attributes = attrs;
|
||||
this.onFieldChange('attributes' as any, attrs);
|
||||
}
|
||||
|
||||
goBack() {
|
||||
const item = this.item();
|
||||
if (item && item.subcategoryId) {
|
||||
@@ -361,10 +399,10 @@ export class ItemEditorComponent implements OnInit {
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Item',
|
||||
message: `Are you sure you want to delete "${item.name}"? This action cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
title: this.lang.t('DELETE_ITEM'),
|
||||
message: `${this.lang.t('CONFIRM_DELETE')} "${item.name}"?`,
|
||||
confirmText: this.lang.t('DELETE'),
|
||||
cancelText: this.lang.t('CANCEL'),
|
||||
dangerous: true
|
||||
}
|
||||
});
|
||||
@@ -373,12 +411,12 @@ export class ItemEditorComponent implements OnInit {
|
||||
if (result) {
|
||||
this.apiService.deleteItem(item.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Item deleted successfully', 'Close', { duration: 3000 });
|
||||
this.toast.success(this.lang.t('ITEM_DELETED'));
|
||||
this.router.navigate(['/project', this.projectId(), 'items', item.subcategoryId]);
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.error('Error deleting item:', err);
|
||||
this.snackBar.open('Failed to delete item', 'Close', { duration: 3000 });
|
||||
this.toast.error(this.lang.t('FAILED_DELETE_ITEM'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,7 +72,13 @@
|
||||
<h1 class="item-name">{{ item.name }}</h1>
|
||||
|
||||
<div class="price-row">
|
||||
<span class="price">{{ item.price | number:'1.2-2' }} {{ item.currency }}</span>
|
||||
@if (item.discount > 0) {
|
||||
<span class="price-old">{{ item.price | number:'1.2-2' }} {{ item.currency }}</span>
|
||||
<span class="price">{{ item.price * (1 - item.discount / 100) | number:'1.2-2' }} {{ item.currency }}</span>
|
||||
<span class="discount-tag">-{{ item.discount }}%</span>
|
||||
} @else {
|
||||
<span class="price">{{ item.price | number:'1.2-2' }} {{ item.currency }}</span>
|
||||
}
|
||||
@if (item.quantity > 0) {
|
||||
<span class="in-stock">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
|
||||
@@ -195,6 +195,22 @@
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.price-old {
|
||||
font-size: 1.1rem;
|
||||
color: #999;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.discount-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: #e53935;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.in-stock, .out-of-stock {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -98,6 +98,9 @@
|
||||
|
||||
<div class="item-details">
|
||||
<span class="price">{{ item.price }} {{ item.currency }}</span>
|
||||
@if (item.discount > 0) {
|
||||
<span class="discount-chip">-{{ item.discount }}%</span>
|
||||
}
|
||||
<span class="quantity">{{ 'QTY' | translate }}: {{ item.quantity }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -226,6 +226,16 @@
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.discount-chip {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: #e53935;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.quantity {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, OnInit, AfterViewInit, OnDestroy, signal, ViewChild, ElementRef } from '@angular/core';
|
||||
import { Component, OnInit, AfterViewInit, OnDestroy, signal, ViewChild, ElementRef, DestroyRef, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -14,6 +15,7 @@ import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { ApiService } from '../../services';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { Item } from '../../models';
|
||||
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||
@@ -58,10 +60,13 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
subcategoryId = signal<string>('');
|
||||
projectId = signal<string>('');
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private apiService: ApiService,
|
||||
private toast: ToastService,
|
||||
private snackBar: MatSnackBar,
|
||||
private dialog: MatDialog,
|
||||
public lang: LanguageService
|
||||
@@ -74,7 +79,7 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.projectId.set(parentParams['projectId']);
|
||||
}
|
||||
|
||||
this.route.params.subscribe(params => {
|
||||
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => {
|
||||
this.subcategoryId.set(params['subcategoryId']);
|
||||
this.page.set(1);
|
||||
this.items.set([]);
|
||||
@@ -113,7 +118,7 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load items', err);
|
||||
this.snackBar.open('Failed to load items', 'Close', { duration: 3000 });
|
||||
this.toast.error(this.lang.t('FAILED_LOAD_ITEMS'));
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
@@ -177,7 +182,7 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
bulkToggleVisibility(visible: boolean) {
|
||||
const itemIds = Array.from(this.selectedItems());
|
||||
if (!itemIds.length) {
|
||||
this.snackBar.open('No items selected', 'Close', { duration: 2000 });
|
||||
this.toast.warning(this.lang.t('NO_ITEMS_SELECTED'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -188,11 +193,11 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
itemIds.includes(item.id) ? { ...item, visible } : item
|
||||
)
|
||||
);
|
||||
this.snackBar.open(`Updated ${itemIds.length} items`, 'Close', { duration: 2000 });
|
||||
this.toast.success(`${this.lang.t('UPDATED')} ${itemIds.length} ${this.lang.t('ITEMS_COUNT')}`);
|
||||
this.selectedItems.set(new Set());
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to update items', 'Close', { duration: 3000 });
|
||||
this.toast.error(this.lang.t('FAILED_UPDATE_ITEMS'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -220,12 +225,12 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||
width: '500px',
|
||||
data: {
|
||||
title: 'Create New Item',
|
||||
title: this.lang.t('CREATE_NEW_ITEM'),
|
||||
fields: [
|
||||
{ name: 'name', label: 'Item Name', type: 'text', required: true },
|
||||
{ name: 'simpleDescription', label: 'Simple Description', type: 'text', required: false },
|
||||
{ name: 'price', label: 'Price', type: 'number', required: true },
|
||||
{ name: 'currency', label: 'Currency', type: 'select', required: true, value: 'USD',
|
||||
{ name: 'name', label: this.lang.t('ITEM_NAME'), type: 'text', required: true },
|
||||
{ name: 'simpleDescription', label: this.lang.t('SIMPLE_DESCRIPTION'), type: 'text', required: false },
|
||||
{ name: 'price', label: this.lang.t('PRICE'), type: 'number', required: true },
|
||||
{ name: 'currency', label: this.lang.t('CURRENCY'), type: 'select', required: true, value: 'USD',
|
||||
options: [
|
||||
{ value: 'USD', label: '🇺🇸 USD' },
|
||||
{ value: 'EUR', label: '🇪🇺 EUR' },
|
||||
@@ -234,8 +239,8 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
{ value: 'UAH', label: '🇺🇦 UAH' }
|
||||
]
|
||||
},
|
||||
{ name: 'quantity', label: 'Quantity', type: 'number', required: true, value: 0 },
|
||||
{ name: 'visible', label: 'Visible', type: 'toggle', required: false, value: true }
|
||||
{ name: 'quantity', label: this.lang.t('QUANTITY'), type: 'number', required: true, value: 0 },
|
||||
{ name: 'visible', label: this.lang.t('VISIBLE'), type: 'toggle', required: false, value: true }
|
||||
]
|
||||
}
|
||||
});
|
||||
@@ -247,14 +252,14 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
this.apiService.createItem(subcategoryId, result).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Item created successfully', 'Close', { duration: 3000 });
|
||||
this.toast.success(this.lang.t('ITEM_CREATED'));
|
||||
this.page.set(1);
|
||||
this.items.set([]);
|
||||
this.loadItems();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error creating item:', err);
|
||||
this.snackBar.open('Failed to create item', 'Close', { duration: 3000 });
|
||||
this.toast.error(this.lang.t('FAILED_CREATE_ITEM'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -266,10 +271,10 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Item',
|
||||
message: `Are you sure you want to delete "${item.name}"? This action cannot be undone.`,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
title: this.lang.t('DELETE_ITEM'),
|
||||
message: `${this.lang.t('CONFIRM_DELETE')} "${item.name}"?`,
|
||||
confirmText: this.lang.t('DELETE'),
|
||||
cancelText: this.lang.t('CANCEL'),
|
||||
dangerous: true
|
||||
}
|
||||
});
|
||||
@@ -278,14 +283,14 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
if (result) {
|
||||
this.apiService.deleteItem(item.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Item deleted successfully', 'Close', { duration: 3000 });
|
||||
this.toast.success(this.lang.t('ITEM_DELETED'));
|
||||
this.page.set(1);
|
||||
this.items.set([]);
|
||||
this.loadItems();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error deleting item:', err);
|
||||
this.snackBar.open('Failed to delete item', 'Close', { duration: 3000 });
|
||||
this.toast.error(this.lang.t('FAILED_DELETE_ITEM'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,10 +14,38 @@
|
||||
<mat-sidenav-container class="sidenav-container">
|
||||
<mat-sidenav mode="side" opened class="categories-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>{{ 'CATEGORIES' | translate }}</h2>
|
||||
<button mat-mini-fab color="primary" (click)="addCategory()" [matTooltip]="'ADD_CATEGORY' | translate">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
<div class="sidebar-title-row">
|
||||
<h2>{{ 'CATEGORIES' | translate }}</h2>
|
||||
<button mat-mini-fab color="primary" (click)="addCategory()" [matTooltip]="'ADD_CATEGORY' | translate">
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-bulk-row">
|
||||
<div class="bulk-visibility-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
(click)="setAllVisibility(true)"
|
||||
[disabled]="loading() || bulkVisibilityUpdating() || !categories().length">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
{{ 'SHOW_ALL' | translate }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="primary"
|
||||
(click)="setAllVisibility(false)"
|
||||
[disabled]="loading() || bulkVisibilityUpdating() || !categories().length">
|
||||
<mat-icon>visibility_off</mat-icon>
|
||||
{{ 'HIDE_ALL' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (bulkVisibilityUpdating()) {
|
||||
<span class="bulk-status">{{ 'UPDATING_VISIBILITY' | translate }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
|
||||
@@ -46,6 +46,8 @@
|
||||
|
||||
.categories-sidebar {
|
||||
width: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
background-color: #fff;
|
||||
|
||||
@@ -53,8 +55,35 @@
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
|
||||
.sidebar-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sidebar-bulk-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bulk-visibility-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
button {
|
||||
flex: 1 1 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.bulk-status {
|
||||
font-size: 0.85rem;
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
@@ -73,7 +102,8 @@
|
||||
|
||||
.tree-container {
|
||||
overflow-y: auto;
|
||||
height: calc(100% - 65px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Component, OnInit, signal, computed } from '@angular/core';
|
||||
import { Component, OnInit, signal, computed, DestroyRef, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router, RouterOutlet, NavigationEnd } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { filter } from 'rxjs/operators';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||
import { MatTreeModule } from '@angular/material/tree';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
@@ -10,10 +11,10 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { ApiService } from '../../services';
|
||||
import { ValidationService } from '../../services/validation.service';
|
||||
import { Category, Subcategory } from '../../models';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { Category, Subcategory, Project } from '../../models';
|
||||
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||
@@ -47,7 +48,6 @@ interface CategoryNode {
|
||||
MatToolbarModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
MatSnackBarModule,
|
||||
MatTooltipModule,
|
||||
LoadingSkeletonComponent,
|
||||
TranslatePipe
|
||||
@@ -57,31 +57,37 @@ interface CategoryNode {
|
||||
})
|
||||
export class ProjectViewComponent implements OnInit {
|
||||
projectId = signal<string>('');
|
||||
project = signal<any>(null);
|
||||
project = signal<Project | null>(null);
|
||||
categories = signal<Category[]>([]);
|
||||
loading = signal(true);
|
||||
bulkVisibilityUpdating = signal(false);
|
||||
treeData = signal<CategoryNode[]>([]);
|
||||
selectedNodeId = signal<string | null>(null);
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private apiService: ApiService,
|
||||
private dialog: MatDialog,
|
||||
private snackBar: MatSnackBar,
|
||||
private toast: ToastService,
|
||||
private validationService: ValidationService,
|
||||
public lang: LanguageService
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.route.params.subscribe(params => {
|
||||
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => {
|
||||
this.projectId.set(params['projectId']);
|
||||
this.loadProject();
|
||||
this.loadCategories();
|
||||
});
|
||||
|
||||
// Track selected route — filter to NavigationEnd so snapshot is fully resolved
|
||||
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
|
||||
this.router.events.pipe(
|
||||
filter(e => e instanceof NavigationEnd),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
).subscribe(() => {
|
||||
const child = this.route.children[0]?.snapshot;
|
||||
const subcategoryId = child?.params['subcategoryId'];
|
||||
const categoryId = child?.params['categoryId'];
|
||||
@@ -98,7 +104,7 @@ export class ProjectViewComponent implements OnInit {
|
||||
this.apiService.getProjects().subscribe({
|
||||
next: (projects) => {
|
||||
const project = projects.find(p => p.id === this.projectId());
|
||||
this.project.set(project);
|
||||
this.project.set(project ?? null);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load project', err);
|
||||
@@ -196,15 +202,35 @@ export class ProjectViewComponent implements OnInit {
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
setAllVisibility(visible: boolean) {
|
||||
if (this.bulkVisibilityUpdating() || !this.categories().length) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.bulkVisibilityUpdating.set(true);
|
||||
this.apiService.setProjectVisibility(this.categories(), visible).subscribe({
|
||||
next: () => {
|
||||
this.loadCategories();
|
||||
this.bulkVisibilityUpdating.set(false);
|
||||
this.toast.success(this.lang.t(visible ? 'ALL_CONTENT_VISIBLE' : 'ALL_CONTENT_HIDDEN'));
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to update project visibility', err);
|
||||
this.bulkVisibilityUpdating.set(false);
|
||||
this.toast.error(err.message || this.lang.t('FAILED_UPDATE_VISIBILITY'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addCategory() {
|
||||
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||
data: {
|
||||
title: 'Create New Category',
|
||||
title: this.lang.t('CREATE_NEW_CATEGORY'),
|
||||
type: 'category',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
|
||||
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
|
||||
{ name: 'name', label: this.lang.t('NAME'), type: 'text', required: true },
|
||||
{ name: 'priority', label: this.lang.t('PRIORITY'), type: 'number', value: 99 },
|
||||
{ name: 'visible', label: this.lang.t('VISIBLE'), type: 'toggle', value: true }
|
||||
]
|
||||
}
|
||||
});
|
||||
@@ -215,17 +241,17 @@ export class ProjectViewComponent implements OnInit {
|
||||
const errors = this.validationService.validateCategoryOrSubcategory(result);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMsg = Object.values(errors).join(', ');
|
||||
this.snackBar.open(`Validation error: ${errorMsg}`, 'Close', { duration: 4000 });
|
||||
this.toast.error(`${this.lang.t('VALIDATION_ERROR')}: ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.apiService.createCategory(this.projectId(), result).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Category created!', 'Close', { duration: 2000 });
|
||||
this.toast.success(this.lang.t('CATEGORY_CREATED'));
|
||||
this.loadCategories();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open(err.message || 'Failed to create category', 'Close', { duration: 3000 });
|
||||
this.toast.error(err.message || this.lang.t('FAILED_CREATE_CATEGORY'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -237,13 +263,13 @@ export class ProjectViewComponent implements OnInit {
|
||||
|
||||
const dialogRef = this.dialog.open(CreateDialogComponent, {
|
||||
data: {
|
||||
title: 'Create New Subcategory',
|
||||
title: this.lang.t('CREATE_NEW_SUBCATEGORY'),
|
||||
type: 'subcategory',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ name: 'name', label: this.lang.t('NAME'), type: 'text', required: true },
|
||||
{ name: 'id', label: 'ID', type: 'text', required: true, hint: 'Used for routing' },
|
||||
{ name: 'priority', label: 'Priority', type: 'number', value: 99 },
|
||||
{ name: 'visible', label: 'Visible', type: 'toggle', value: true }
|
||||
{ name: 'priority', label: this.lang.t('PRIORITY'), type: 'number', value: 99 },
|
||||
{ name: 'visible', label: this.lang.t('VISIBLE'), type: 'toggle', value: true }
|
||||
]
|
||||
}
|
||||
});
|
||||
@@ -254,18 +280,18 @@ export class ProjectViewComponent implements OnInit {
|
||||
const errors = this.validationService.validateCategoryOrSubcategory(result);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
const errorMsg = Object.values(errors).join(', ');
|
||||
this.snackBar.open(`Validation error: ${errorMsg}`, 'Close', { duration: 4000 });
|
||||
this.toast.error(`${this.lang.t('VALIDATION_ERROR')}: ${errorMsg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const parentType = parentNode.type === 'category' ? 'category' : 'subcategory';
|
||||
this.apiService.createSubcategory(parentNode.id, parentType, result).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Subcategory created!', 'Close', { duration: 2000 });
|
||||
this.toast.success(this.lang.t('SUBCATEGORY_CREATED'));
|
||||
this.loadCategories();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open(err.message || 'Failed to create subcategory', 'Close', { duration: 3000 });
|
||||
this.toast.error(err.message || this.lang.t('FAILED_CREATE_SUBCATEGORY'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -282,10 +308,10 @@ export class ProjectViewComponent implements OnInit {
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Category',
|
||||
title: this.lang.t('DELETE_CATEGORY'),
|
||||
message: message,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
confirmText: this.lang.t('DELETE'),
|
||||
cancelText: this.lang.t('CANCEL'),
|
||||
dangerous: true
|
||||
}
|
||||
});
|
||||
@@ -294,11 +320,11 @@ export class ProjectViewComponent implements OnInit {
|
||||
if (confirmed) {
|
||||
this.apiService.deleteCategory(node.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Category deleted', 'Close', { duration: 2000 });
|
||||
this.toast.success(this.lang.t('CATEGORY_DELETED'));
|
||||
this.loadCategories();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to delete category', 'Close', { duration: 3000 });
|
||||
this.toast.error(this.lang.t('FAILED_DELETE_CATEGORY'));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -323,10 +349,10 @@ export class ProjectViewComponent implements OnInit {
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Subcategory',
|
||||
title: this.lang.t('DELETE_SUBCATEGORY'),
|
||||
message: message,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
confirmText: this.lang.t('DELETE'),
|
||||
cancelText: this.lang.t('CANCEL'),
|
||||
dangerous: true
|
||||
}
|
||||
});
|
||||
@@ -335,11 +361,11 @@ export class ProjectViewComponent implements OnInit {
|
||||
if (confirmed) {
|
||||
this.apiService.deleteSubcategory(node.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Subcategory deleted', 'Close', { duration: 2000 });
|
||||
this.toast.success(this.lang.t('SUBCATEGORY_DELETED'));
|
||||
this.loadCategories();
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to delete subcategory', 'Close', { duration: 3000 });
|
||||
this.toast.error(this.lang.t('FAILED_DELETE_SUBCATEGORY'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, OnInit, signal } from '@angular/core';
|
||||
import { Component, OnInit, signal, DestroyRef, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ApiService } from '../../services';
|
||||
import { Project } from '../../models';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
@@ -21,6 +22,8 @@ export class ProjectsDashboardComponent implements OnInit {
|
||||
error = signal<string | null>(null);
|
||||
currentProjectId = signal<string | null>(null);
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private router: Router,
|
||||
@@ -37,7 +40,7 @@ export class ProjectsDashboardComponent implements OnInit {
|
||||
}
|
||||
|
||||
// Listen to route changes
|
||||
this.router.events.subscribe(() => {
|
||||
this.router.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
const segments = this.router.url.split('/');
|
||||
if (segments[1] === 'project' && segments[2]) {
|
||||
this.currentProjectId.set(segments[2]);
|
||||
|
||||
@@ -36,13 +36,9 @@
|
||||
<mat-label>ID</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[(ngModel)]="subcategory()!.id"
|
||||
(blur)="onFieldChange('id', subcategory()!.id)"
|
||||
required>
|
||||
[value]="subcategory()!.id"
|
||||
disabled>
|
||||
<mat-hint>{{ 'ID' | translate }}</mat-hint>
|
||||
@if (!subcategory()!.id || subcategory()!.id.trim().length === 0) {
|
||||
<mat-error>ID is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="form-row">
|
||||
@@ -103,19 +99,21 @@
|
||||
</div>
|
||||
|
||||
<div class="items-section">
|
||||
<h3>{{ 'VIEW_ITEMS' | translate }}</h3>
|
||||
@if (subcategory()!.subcategories?.length) {
|
||||
<p class="no-items-note">
|
||||
<mat-icon>account_tree</mat-icon>
|
||||
{{ 'SUBCATEGORIES' | translate }}
|
||||
</p>
|
||||
} @else {
|
||||
<button mat-raised-button color="primary" (click)="viewItems()">
|
||||
<mat-icon>{{ subcategory()!.hasItems ? 'list' : 'add' }}</mat-icon>
|
||||
{{ subcategory()!.hasItems ? (('VIEW_ITEMS' | translate) + ' (' + (subcategory()!.itemCount || 0) + ')') : ('ADD_SUBCATEGORY' | translate) }}
|
||||
</button>
|
||||
<app-inline-items-list
|
||||
[subcategoryId]="subcategoryId()"
|
||||
[projectId]="projectId()">
|
||||
</app-inline-items-list>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Translations section hidden until client provides requirements
|
||||
<div class="translations-section">
|
||||
<h3>{{ 'TRANSLATIONS' | translate }}</h3>
|
||||
<p class="hint">{{ 'TRANSLATIONS_HINT' | translate }}</p>
|
||||
@@ -124,6 +122,7 @@
|
||||
<input matInput [(ngModel)]="ruName" (blur)="saveRuName(ruName)" [placeholder]="'NAME_TRANSLATED' | translate">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -110,10 +110,10 @@
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.no-items-note {
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { Component, OnInit, signal } from '@angular/core';
|
||||
import { Component, OnInit, signal, DestroyRef, inject } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { ApiService } from '../../services';
|
||||
import { ToastService } from '../../services/toast.service';
|
||||
import { Subcategory } from '../../models';
|
||||
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||
import { InlineItemsListComponent } from '../../components/inline-items-list/inline-items-list.component';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
|
||||
@@ -30,10 +32,10 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
MatSlideToggleModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatSnackBarModule,
|
||||
MatDialogModule,
|
||||
MatTooltipModule,
|
||||
LoadingSkeletonComponent,
|
||||
InlineItemsListComponent,
|
||||
TranslatePipe
|
||||
],
|
||||
templateUrl: './subcategory-editor.component.html',
|
||||
@@ -42,18 +44,22 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
|
||||
export class SubcategoryEditorComponent implements OnInit {
|
||||
subcategory = signal<Subcategory | null>(null);
|
||||
loading = signal(true);
|
||||
saving = signal(false);
|
||||
subcategoryId = signal<string>('');
|
||||
projectId = signal<string>('');
|
||||
|
||||
/** Whether the debounced save queue is in-flight */
|
||||
get saving() { return this.apiService.saving; }
|
||||
|
||||
/** Local buffer for the Russian translation of the subcategory name */
|
||||
ruName = '';
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private apiService: ApiService,
|
||||
private snackBar: MatSnackBar,
|
||||
private toast: ToastService,
|
||||
private dialog: MatDialog,
|
||||
public lang: LanguageService
|
||||
) {}
|
||||
@@ -65,7 +71,7 @@ export class SubcategoryEditorComponent implements OnInit {
|
||||
this.projectId.set(parentParams['projectId']);
|
||||
}
|
||||
|
||||
this.route.params.subscribe(params => {
|
||||
this.route.params.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => {
|
||||
this.subcategoryId.set(params['subcategoryId']);
|
||||
this.loadSubcategory();
|
||||
});
|
||||
@@ -81,7 +87,7 @@ export class SubcategoryEditorComponent implements OnInit {
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to load subcategory', err);
|
||||
this.snackBar.open('Failed to load subcategory', 'Close', { duration: 3000 });
|
||||
this.toast.error(this.lang.t('FAILED_LOAD_SUBCATEGORY'));
|
||||
this.loading.set(false);
|
||||
}
|
||||
});
|
||||
@@ -96,13 +102,7 @@ export class SubcategoryEditorComponent implements OnInit {
|
||||
}
|
||||
|
||||
onFieldChange(field: keyof Subcategory, value: any) {
|
||||
this.saving.set(true);
|
||||
this.apiService.queueSave('subcategory', this.subcategoryId(), field, value);
|
||||
|
||||
setTimeout(() => {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Saved', '', { duration: 1000 });
|
||||
}, 600);
|
||||
}
|
||||
|
||||
async onImageSelect(event: Event, type: 'file' | 'url') {
|
||||
@@ -121,8 +121,7 @@ export class SubcategoryEditorComponent implements OnInit {
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.snackBar.open('Failed to upload image', 'Close', { duration: 3000 });
|
||||
this.saving.set(false);
|
||||
this.toast.error(this.lang.t('FAILED_UPLOAD_IMAGE'));
|
||||
}
|
||||
});
|
||||
} else if (type === 'url') {
|
||||
@@ -149,10 +148,10 @@ export class SubcategoryEditorComponent implements OnInit {
|
||||
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
data: {
|
||||
title: 'Delete Subcategory',
|
||||
message: `Are you sure you want to delete "${sub.name}"? This will also delete all items in this subcategory.`,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
title: this.lang.t('DELETE_SUBCATEGORY'),
|
||||
message: `${this.lang.t('CONFIRM_DELETE')} "${sub.name}"?`,
|
||||
confirmText: this.lang.t('DELETE'),
|
||||
cancelText: this.lang.t('CANCEL'),
|
||||
dangerous: true
|
||||
}
|
||||
});
|
||||
@@ -161,7 +160,7 @@ export class SubcategoryEditorComponent implements OnInit {
|
||||
if (result) {
|
||||
this.apiService.deleteSubcategory(sub.id).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Subcategory deleted successfully', 'Close', { duration: 3000 });
|
||||
this.toast.success(this.lang.t('SUBCATEGORY_DELETED'));
|
||||
// Navigate to the direct parent (subcategory) if parentId exists, otherwise the root category
|
||||
if (sub.parentId && sub.parentId !== sub.categoryId) {
|
||||
this.router.navigate(['/project', this.projectId(), 'subcategory', sub.parentId]);
|
||||
@@ -171,7 +170,7 @@ export class SubcategoryEditorComponent implements OnInit {
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.error('Error deleting subcategory:', err);
|
||||
this.snackBar.open('Failed to delete subcategory', 'Close', { duration: 3000 });
|
||||
this.toast.error(this.lang.t('FAILED_DELETE_SUBCATEGORY'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, Subject, timer } from 'rxjs';
|
||||
import { debounce, retry, catchError, tap, map } from 'rxjs/operators';
|
||||
import { EMPTY, Observable, Subject, from, of, throwError } from 'rxjs';
|
||||
import {
|
||||
catchError,
|
||||
concatMap,
|
||||
debounceTime,
|
||||
expand,
|
||||
groupBy,
|
||||
map,
|
||||
mergeMap,
|
||||
reduce,
|
||||
retry,
|
||||
toArray,
|
||||
} from 'rxjs/operators';
|
||||
import { Project, Category, Subcategory, Item, ItemsListResponse } from '../models';
|
||||
import { ItemName } from '../models/item.model';
|
||||
import { MockDataService } from './mock-data.service';
|
||||
import { ToastService } from './toast.service';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
@Injectable({
|
||||
@@ -12,20 +25,129 @@ import { environment } from '../../environments/environment';
|
||||
export class ApiService {
|
||||
private http = inject(HttpClient);
|
||||
private mockService = inject(MockDataService);
|
||||
private toast = inject(ToastService);
|
||||
private readonly API_BASE = environment.apiUrl;
|
||||
|
||||
|
||||
/** Whether a debounced save is in-flight */
|
||||
saving = signal(false);
|
||||
|
||||
// Debounced save queue
|
||||
private saveQueue$ = new Subject<SaveOperation>();
|
||||
|
||||
constructor() {
|
||||
// Set up auto-save with 500ms debounce
|
||||
// Debounce per unique type+id+field so independent fields don't clobber each other
|
||||
this.saveQueue$
|
||||
.pipe(debounce(() => timer(500)))
|
||||
.pipe(
|
||||
groupBy(op => `${op.type}:${op.id}:${op.field}`),
|
||||
mergeMap(group$ => group$.pipe(debounceTime(500)))
|
||||
)
|
||||
.subscribe(operation => {
|
||||
this.executeSave(operation);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Normalizers ──────────────────────────────────────────
|
||||
|
||||
/** Get text from an ItemName entry, handling the backend 'valuue' typo */
|
||||
private nameValue(entry: ItemName): string {
|
||||
return entry.value || entry.valuue || '';
|
||||
}
|
||||
|
||||
/** Normalize an item from the backend — extracts itemDetails, names, photos */
|
||||
private normalizeItem(raw: any): Item {
|
||||
const item: Item = { ...raw };
|
||||
|
||||
// Extract price/currency/remaining from itemDetails[0] if present
|
||||
const details = raw.itemDetails || raw.itemdetails;
|
||||
if (details && Array.isArray(details) && details.length > 0) {
|
||||
const d = details[0];
|
||||
item.itemDetails = details;
|
||||
if (item.price == null || item.price === 0) item.price = d.price ?? 0;
|
||||
if (!item.currency) item.currency = d.currency || 'RUB';
|
||||
if (!item.colour) item.colour = d.colour || d.color || '';
|
||||
if (!item.size) item.size = d.size || '';
|
||||
if (item.quantity == null && d.remaining != null) item.quantity = d.remaining;
|
||||
}
|
||||
|
||||
// Build translations from names[]/descriptions[] if translations map is empty
|
||||
if (raw.names && Array.isArray(raw.names)) {
|
||||
item.translations = item.translations || {};
|
||||
for (const n of raw.names) {
|
||||
const lang = n.language?.toLowerCase();
|
||||
const val = this.nameValue(n);
|
||||
if (lang && val) {
|
||||
item.translations[lang] = item.translations[lang] || {};
|
||||
item.translations[lang].name = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (raw.descriptions && Array.isArray(raw.descriptions)) {
|
||||
item.translations = item.translations || {};
|
||||
for (const d of raw.descriptions) {
|
||||
const lang = d.language?.toLowerCase();
|
||||
const val = d.value || d.valuue || '';
|
||||
if (lang && val) {
|
||||
item.translations[lang] = item.translations[lang] || {};
|
||||
item.translations[lang].simpleDescription = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build imgs[] from photos[] if imgs is empty
|
||||
if ((!item.imgs || item.imgs.length === 0) && raw.photos && Array.isArray(raw.photos)) {
|
||||
item.imgs = raw.photos.map((p: any) => p.url);
|
||||
}
|
||||
|
||||
// Map callbacks → comments if comments is missing
|
||||
if ((!item.comments || item.comments.length === 0) && raw.callbacks && Array.isArray(raw.callbacks)) {
|
||||
item.comments = raw.callbacks.map((c: any) => ({
|
||||
id: c.userID || '',
|
||||
text: c.content || '',
|
||||
author: c.userID || '',
|
||||
stars: c.rating,
|
||||
createdAt: c.timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
// Defaults
|
||||
item.name = item.name || '';
|
||||
item.price = item.price ?? 0;
|
||||
item.discount = item.discount ?? 0;
|
||||
item.quantity = item.quantity ?? 0;
|
||||
item.currency = item.currency || 'RUB';
|
||||
item.imgs = item.imgs || [];
|
||||
item.tags = item.tags || [];
|
||||
item.description = item.description || [];
|
||||
item.simpleDescription = item.simpleDescription || '';
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Normalize a category — merges names[] into translations, maps Go struct fields */
|
||||
private normalizeCategory(raw: any): Category {
|
||||
const cat: Category = { ...raw };
|
||||
|
||||
if (raw.names && Array.isArray(raw.names)) {
|
||||
cat.translations = cat.translations || {};
|
||||
for (const n of raw.names) {
|
||||
const lang = n.language?.toLowerCase();
|
||||
const val = this.nameValue(n);
|
||||
if (lang && val) {
|
||||
cat.translations[lang] = cat.translations[lang] || {};
|
||||
cat.translations[lang].name = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map Go struct fields
|
||||
if (raw.icon && !cat.img) cat.img = raw.icon;
|
||||
if (raw.wideicon) cat.wideBanner = raw.wideicon;
|
||||
if (raw.ItemsCount != null) cat.itemCount = raw.ItemsCount;
|
||||
if (raw.CategoriesCount != null) cat.categoriesCount = raw.CategoriesCount;
|
||||
|
||||
return cat;
|
||||
}
|
||||
|
||||
// Projects
|
||||
getProjects(): Observable<Project[]> {
|
||||
if (environment.useMockData) return this.mockService.getProjects();
|
||||
@@ -38,16 +160,18 @@ export class ApiService {
|
||||
// Categories
|
||||
getCategories(projectId: string): Observable<Category[]> {
|
||||
if (environment.useMockData) return this.mockService.getCategories(projectId);
|
||||
return this.http.get<Category[]>(`${this.API_BASE}/projects/${projectId}/categories`).pipe(
|
||||
return this.http.get<any[]>(`${this.API_BASE}/projects/${projectId}/categories`).pipe(
|
||||
retry(2),
|
||||
map(cats => cats.map(c => this.normalizeCategory(c))),
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
getCategory(categoryId: string): Observable<Category> {
|
||||
if (environment.useMockData) return this.mockService.getCategory(categoryId);
|
||||
return this.http.get<Category>(`${this.API_BASE}/categories/${categoryId}`).pipe(
|
||||
return this.http.get<any>(`${this.API_BASE}/categories/${categoryId}`).pipe(
|
||||
retry(2),
|
||||
map(c => this.normalizeCategory(c)),
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
@@ -140,16 +264,21 @@ export class ApiService {
|
||||
params = params.set('tags', filters.tags.join(','));
|
||||
}
|
||||
|
||||
return this.http.get<ItemsListResponse>(`${this.API_BASE}/subcategories/${subcategoryId}/items`, { params }).pipe(
|
||||
return this.http.get<any>(`${this.API_BASE}/subcategories/${subcategoryId}/items`, { params }).pipe(
|
||||
retry(2),
|
||||
map(resp => ({
|
||||
...resp,
|
||||
items: (resp.items || []).map((i: any) => this.normalizeItem(i))
|
||||
})),
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
|
||||
getItem(itemId: string): Observable<Item> {
|
||||
if (environment.useMockData) return this.mockService.getItem(itemId);
|
||||
return this.http.get<Item>(`${this.API_BASE}/items/${itemId}`).pipe(
|
||||
return this.http.get<any>(`${this.API_BASE}/items/${itemId}`).pipe(
|
||||
retry(2),
|
||||
map(raw => this.normalizeItem(raw)),
|
||||
catchError(this.handleError)
|
||||
);
|
||||
}
|
||||
@@ -184,6 +313,25 @@ export class ApiService {
|
||||
);
|
||||
}
|
||||
|
||||
setProjectVisibility(categories: Category[], visible: boolean): Observable<void> {
|
||||
const { categoryIds, subcategoryIds } = this.collectVisibilityTargets(categories);
|
||||
const requests = [
|
||||
...categoryIds.map(id => () => this.updateCategory(id, { visible })),
|
||||
...subcategoryIds.map(id => () => this.updateSubcategory(id, { visible })),
|
||||
...subcategoryIds.map(id => () => this.updateSubcategoryItemsVisibility(id, visible)),
|
||||
];
|
||||
|
||||
if (!requests.length) {
|
||||
return of(void 0);
|
||||
}
|
||||
|
||||
return from(requests).pipe(
|
||||
concatMap(request => request()),
|
||||
toArray(),
|
||||
map(() => void 0)
|
||||
);
|
||||
}
|
||||
|
||||
// Image upload
|
||||
uploadImage(file: File): Observable<{ url: string }> {
|
||||
if (environment.useMockData) return this.mockService.uploadImage(file);
|
||||
@@ -198,7 +346,8 @@ export class ApiService {
|
||||
}
|
||||
|
||||
// Debounced auto-save
|
||||
queueSave(type: 'category' | 'subcategory' | 'item', id: string, field: string, value: any) {
|
||||
queueSave(type: 'category' | 'subcategory' | 'item', id: string, field: string, value: unknown) {
|
||||
this.saving.set(true);
|
||||
this.saveQueue$.next({ type, id, field, value });
|
||||
}
|
||||
|
||||
@@ -219,12 +368,59 @@ export class ApiService {
|
||||
}
|
||||
|
||||
request.subscribe({
|
||||
next: () => console.log(`Saved ${operation.type} ${operation.id} - ${operation.field}`),
|
||||
error: (err) => console.error(`Failed to save ${operation.type}`, err)
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.toast.success('Saved');
|
||||
},
|
||||
error: (err) => {
|
||||
this.saving.set(false);
|
||||
this.toast.error(err.message || 'Failed to save');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleError(error: any): Observable<never> {
|
||||
private updateSubcategoryItemsVisibility(subcategoryId: string, visible: boolean): Observable<void> {
|
||||
return this.getAllSubcategoryItemIds(subcategoryId).pipe(
|
||||
concatMap(itemIds => itemIds.length ? this.bulkUpdateItems(itemIds, { visible }) : of(void 0))
|
||||
);
|
||||
}
|
||||
|
||||
private getAllSubcategoryItemIds(subcategoryId: string): Observable<string[]> {
|
||||
const pageSize = 100;
|
||||
|
||||
return this.getItems(subcategoryId, 1, pageSize).pipe(
|
||||
expand(response =>
|
||||
response.hasMore ? this.getItems(subcategoryId, response.page + 1, pageSize) : EMPTY
|
||||
),
|
||||
reduce((itemIds, response) => {
|
||||
itemIds.push(...response.items.map(item => item.id));
|
||||
return itemIds;
|
||||
}, [] as string[])
|
||||
);
|
||||
}
|
||||
|
||||
private collectVisibilityTargets(categories: Category[]) {
|
||||
const categoryIds = categories.map(category => category.id);
|
||||
const subcategoryIds: string[] = [];
|
||||
|
||||
const visitSubcategories = (subcategories: Subcategory[]) => {
|
||||
for (const subcategory of subcategories) {
|
||||
subcategoryIds.push(subcategory.id);
|
||||
|
||||
if (subcategory.subcategories?.length) {
|
||||
visitSubcategories(subcategory.subcategories);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const category of categories) {
|
||||
visitSubcategories(category.subcategories || []);
|
||||
}
|
||||
|
||||
return { categoryIds, subcategoryIds };
|
||||
}
|
||||
|
||||
private handleError = (error: any): Observable<never> => {
|
||||
let errorMessage = 'An unexpected error occurred';
|
||||
|
||||
if (error.error instanceof ErrorEvent) {
|
||||
@@ -269,15 +465,15 @@ export class ApiService {
|
||||
url: error.url
|
||||
});
|
||||
|
||||
throw { message: errorMessage, status: error.status, originalError: error };
|
||||
}
|
||||
return throwError(() => ({ message: errorMessage, status: error.status, originalError: error }));
|
||||
};
|
||||
}
|
||||
|
||||
interface SaveOperation {
|
||||
type: 'category' | 'subcategory' | 'item';
|
||||
id: string;
|
||||
field: string;
|
||||
value: any;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
interface ItemFilters {
|
||||
|
||||
492
src/app/services/auth.service.ts
Normal file
492
src/app/services/auth.service.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { catchError, map, tap } from 'rxjs/operators';
|
||||
import { environment } from '../../environments/environment';
|
||||
import { AuthSession, AuthStatus, QrLoginSession, QrPollResult } from '../models/auth.model';
|
||||
|
||||
const SESSION_REFRESH_FALLBACK_MS = 60 * 60 * 1000;
|
||||
const AUTHENTICATED_SESSION_STORAGE_KEY = 'userauth_session_id';
|
||||
const LEGACY_WEB_SESSION_TOKEN_PREFIX = 'websession:';
|
||||
|
||||
interface QrCreateResponse {
|
||||
token?: string;
|
||||
url?: string;
|
||||
deeplink?: string;
|
||||
deepLink?: string;
|
||||
telegramUrl?: string;
|
||||
qrUrl?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly authApiBaseUrl = this.trimTrailingSlash(
|
||||
(environment as Record<string, unknown>)['authApiUrl'] as string || ''
|
||||
);
|
||||
private readonly userSessionApiBaseUrl = this.trimTrailingSlash(
|
||||
(environment as Record<string, unknown>)['userSessionApiUrl'] as string || environment.apiUrl || ''
|
||||
);
|
||||
|
||||
private readonly sessionSignal = signal<AuthSession | null>(null);
|
||||
private readonly statusSignal = signal<AuthStatus>('unknown');
|
||||
private readonly showLoginSignal = signal(false);
|
||||
private sessionRefreshTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
readonly session = this.sessionSignal.asReadonly();
|
||||
readonly status = this.statusSignal.asReadonly();
|
||||
readonly showLoginDialog = this.showLoginSignal.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated');
|
||||
readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null);
|
||||
|
||||
constructor() {
|
||||
this.checkSession();
|
||||
}
|
||||
|
||||
checkSession(): void {
|
||||
this.statusSignal.set('checking');
|
||||
|
||||
this.checkSessionOnce().subscribe((session) => {
|
||||
if (session?.active) {
|
||||
this.activateSession(session);
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearAuthState('unauthenticated');
|
||||
});
|
||||
}
|
||||
|
||||
checkSessionOnce(): Observable<AuthSession | null> {
|
||||
return this.http
|
||||
.get<Record<string, unknown>>(this.buildAuthUrl('/userauth/session'), {
|
||||
withCredentials: true
|
||||
})
|
||||
.pipe(
|
||||
map((response) => this.normalizeSession(response)),
|
||||
catchError(() => this.checkStoredLegacySession())
|
||||
);
|
||||
}
|
||||
|
||||
createQrSession(): Observable<QrLoginSession> {
|
||||
return this.createUserauthQrSession().pipe(
|
||||
catchError(() => this.createLegacyWebSession())
|
||||
);
|
||||
}
|
||||
|
||||
pollQrStatus(token: string): Observable<QrPollResult> {
|
||||
if (token.startsWith(LEGACY_WEB_SESSION_TOKEN_PREFIX)) {
|
||||
return this.pollLegacyWebSession(token.slice(LEGACY_WEB_SESSION_TOKEN_PREFIX.length));
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<Record<string, unknown>>(
|
||||
this.buildAuthUrl(`/userauth/qr/poll?token=${encodeURIComponent(token)}`),
|
||||
{ withCredentials: true }
|
||||
)
|
||||
.pipe(
|
||||
map((response) => this.normalizeQrPoll(response)),
|
||||
catchError(() => of({ status: 'error' as const }))
|
||||
);
|
||||
}
|
||||
|
||||
completeLogin(session?: AuthSession | null): Observable<void> {
|
||||
const activeSession = session?.active ? session : null;
|
||||
|
||||
if (activeSession) {
|
||||
this.activateSession(activeSession);
|
||||
}
|
||||
|
||||
return this.syncSessionAfterLogin(activeSession?.sessionId).pipe(
|
||||
tap(() => {
|
||||
if (activeSession) {
|
||||
this.hideLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
this.checkSession();
|
||||
}),
|
||||
map(() => void 0)
|
||||
);
|
||||
}
|
||||
|
||||
requestLogin(): void {
|
||||
this.showLoginSignal.set(true);
|
||||
}
|
||||
|
||||
hideLogin(): void {
|
||||
this.showLoginSignal.set(false);
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
const legacySessionId = this.readStoredAuthenticatedSessionId();
|
||||
|
||||
this.http
|
||||
.post(this.buildAuthUrl('/userauth/logout'), {}, { withCredentials: true })
|
||||
.pipe(catchError(() => of(null)))
|
||||
.subscribe(() => {
|
||||
if (legacySessionId) {
|
||||
this.deleteLegacyWebSession(legacySessionId).subscribe();
|
||||
}
|
||||
|
||||
this.clearAuthState('unauthenticated');
|
||||
this.requestLogin();
|
||||
});
|
||||
}
|
||||
|
||||
private createUserauthQrSession(): Observable<QrLoginSession> {
|
||||
return this.http
|
||||
.post<QrCreateResponse>(
|
||||
this.buildAuthUrl('/userauth/qr/create'),
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
)
|
||||
.pipe(
|
||||
map((response) => {
|
||||
const token = response?.token?.trim() ?? '';
|
||||
const url = response?.url ?? response?.deeplink ?? response?.deepLink ?? response?.telegramUrl ?? '';
|
||||
|
||||
if (!token || !url) {
|
||||
throw new Error('Invalid QR create response');
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
url,
|
||||
qrUrl: response.qrUrl
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private createLegacyWebSession(): Observable<QrLoginSession> {
|
||||
const webSessionId = this.generateGuid();
|
||||
|
||||
return this.http
|
||||
.post<Record<string, unknown>>(
|
||||
this.buildAuthUrl('/users/sessions'),
|
||||
{ webSessionID: webSessionId },
|
||||
{
|
||||
headers: { WebSessionID: webSessionId },
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
.pipe(
|
||||
map((response) => {
|
||||
const sessionId = this.extractSessionId(response, webSessionId);
|
||||
|
||||
return {
|
||||
token: `${LEGACY_WEB_SESSION_TOKEN_PREFIX}${sessionId}`,
|
||||
url: this.getTelegramLoginUrl(sessionId)
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private pollLegacyWebSession(sessionId: string): Observable<QrPollResult> {
|
||||
if (!sessionId) {
|
||||
return of({ status: 'error' });
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<Record<string, unknown>>(this.buildAuthUrl(`/users/sessions/${encodeURIComponent(sessionId)}`), {
|
||||
headers: { WebSessionID: sessionId },
|
||||
withCredentials: true
|
||||
})
|
||||
.pipe(
|
||||
map((response) => {
|
||||
const session = this.normalizeSession(response, sessionId);
|
||||
|
||||
return session?.active
|
||||
? { status: 'confirmed' as const, session }
|
||||
: { status: 'pending' as const };
|
||||
}),
|
||||
catchError(() => of({ status: 'error' as const }))
|
||||
);
|
||||
}
|
||||
|
||||
private checkStoredLegacySession(): Observable<AuthSession | null> {
|
||||
const storedSessionId = this.readStoredAuthenticatedSessionId();
|
||||
|
||||
if (!storedSessionId) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return this.pollLegacyWebSession(storedSessionId).pipe(
|
||||
map((result) => result.status === 'confirmed' ? result.session ?? null : null)
|
||||
);
|
||||
}
|
||||
|
||||
private deleteLegacyWebSession(sessionId: string): Observable<unknown> {
|
||||
return this.http
|
||||
.delete(this.buildAuthUrl(`/users/sessions/${encodeURIComponent(sessionId)}`), {
|
||||
headers: { WebSessionID: sessionId },
|
||||
withCredentials: true
|
||||
})
|
||||
.pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
private syncSessionAfterLogin(sessionId?: string): Observable<void> {
|
||||
if (!sessionId) {
|
||||
return of(void 0);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.post(this.buildUserSessionUrl(`/usersession/${encodeURIComponent(sessionId)}`), {}, {
|
||||
withCredentials: true
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => of(null)),
|
||||
map(() => void 0)
|
||||
);
|
||||
}
|
||||
|
||||
private activateSession(session: AuthSession): void {
|
||||
this.sessionSignal.set(session);
|
||||
this.statusSignal.set('authenticated');
|
||||
this.storeAuthenticatedSessionId(session.sessionId);
|
||||
this.hideLogin();
|
||||
this.scheduleSessionRefresh(session.expiresAt);
|
||||
}
|
||||
|
||||
private clearAuthState(status: AuthStatus): void {
|
||||
this.sessionSignal.set(null);
|
||||
this.statusSignal.set(status);
|
||||
this.clearStoredAuthenticatedSessionId();
|
||||
this.clearSessionRefresh();
|
||||
}
|
||||
|
||||
private storeAuthenticatedSessionId(sessionId: string): void {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(AUTHENTICATED_SESSION_STORAGE_KEY, sessionId);
|
||||
}
|
||||
|
||||
private clearStoredAuthenticatedSessionId(): void {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.removeItem(AUTHENTICATED_SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
private readStoredAuthenticatedSessionId(): string | null {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return localStorage.getItem(AUTHENTICATED_SESSION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
private scheduleSessionRefresh(expiresAt: string): void {
|
||||
this.clearSessionRefresh();
|
||||
|
||||
const expiresAtMs = new Date(expiresAt).getTime();
|
||||
const refreshInMs = Number.isFinite(expiresAtMs)
|
||||
? Math.max(expiresAtMs - Date.now() - 60_000, 30_000)
|
||||
: SESSION_REFRESH_FALLBACK_MS;
|
||||
|
||||
this.sessionRefreshTimer = setTimeout(() => {
|
||||
this.checkSession();
|
||||
}, refreshInMs);
|
||||
}
|
||||
|
||||
private clearSessionRefresh(): void {
|
||||
if (this.sessionRefreshTimer) {
|
||||
clearTimeout(this.sessionRefreshTimer);
|
||||
this.sessionRefreshTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeQrPoll(response: Record<string, unknown>): QrPollResult {
|
||||
const status = this.readString(this.readFirst(response, ['status', 'Status'])) ?? 'pending';
|
||||
|
||||
if (status === 'confirmed') {
|
||||
const rawSession = this.asRecord(this.readFirst(response, ['session', 'Session'])) ?? response;
|
||||
return {
|
||||
status: 'confirmed',
|
||||
session: this.normalizeSession(rawSession)
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'expired') {
|
||||
return { status: 'expired' };
|
||||
}
|
||||
|
||||
return { status: 'pending' };
|
||||
}
|
||||
|
||||
private normalizeSession(response: Record<string, unknown> | null, fallbackSessionId?: string): AuthSession | null {
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionSource = this.asRecord(this.readFirst(response, ['session', 'Session'])) ?? response;
|
||||
const user = this.asRecord(this.readFirst(sessionSource, ['user', 'User', 'telegramUser', 'TelegramUser'])) ?? sessionSource;
|
||||
const sessionId = this.extractSessionId(sessionSource, fallbackSessionId);
|
||||
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = this.readFirst(sessionSource, [
|
||||
'active',
|
||||
'Active',
|
||||
'authenticated',
|
||||
'Authenticated',
|
||||
'status',
|
||||
'Status',
|
||||
'loggedIn',
|
||||
'LoggedIn'
|
||||
]);
|
||||
const active = status === undefined ? true : this.isActiveStatus(status);
|
||||
const username = this.readString(this.readFirst(user, ['username', 'Username']))
|
||||
?? this.readString(this.readFirst(sessionSource, ['username', 'Username']));
|
||||
const firstName = this.readString(this.readFirst(user, ['firstName', 'first_name', 'FirstName']));
|
||||
const lastName = this.readString(this.readFirst(user, ['lastName', 'last_name', 'LastName']));
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||
const displayName = this.readString(this.readFirst(sessionSource, ['displayName', 'DisplayName', 'name', 'Name']))
|
||||
?? this.readString(this.readFirst(user, ['displayName', 'DisplayName', 'name', 'Name']))
|
||||
?? username
|
||||
?? fullName
|
||||
?? 'Telegram User';
|
||||
const telegramUserId = this.readNumber(this.readFirst(sessionSource, [
|
||||
'telegramUserId',
|
||||
'telegramUserID',
|
||||
'TelegramUserID',
|
||||
'userId',
|
||||
'userID',
|
||||
'UserID'
|
||||
])) ?? this.readNumber(this.readFirst(user, ['telegramUserId', 'telegramUserID', 'id', 'ID']));
|
||||
const expiresAt = this.readString(this.readFirst(sessionSource, ['expiresAt', 'ExpiresAt', 'expires', 'Expires']))
|
||||
?? new Date(Date.now() + SESSION_REFRESH_FALLBACK_MS).toISOString();
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
telegramUserId,
|
||||
username,
|
||||
displayName,
|
||||
active,
|
||||
expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
private extractSessionId(response: Record<string, unknown> | null, fallbackSessionId?: string): string {
|
||||
if (!response) {
|
||||
return fallbackSessionId ?? '';
|
||||
}
|
||||
|
||||
return this.readString(this.readFirst(response, [
|
||||
'sessionId',
|
||||
'SessionId',
|
||||
'sessionID',
|
||||
'SessionID',
|
||||
'webSessionID',
|
||||
'WebSessionID',
|
||||
'webSessionId',
|
||||
'userSessionId',
|
||||
'userSessionID',
|
||||
'id',
|
||||
'ID'
|
||||
])) ?? fallbackSessionId ?? '';
|
||||
}
|
||||
|
||||
private buildAuthUrl(path: string): string {
|
||||
return `${this.authApiBaseUrl}${this.withLeadingSlash(path)}`;
|
||||
}
|
||||
|
||||
private buildUserSessionUrl(path: string): string {
|
||||
return `${this.userSessionApiBaseUrl}${this.withLeadingSlash(path)}`;
|
||||
}
|
||||
|
||||
private withLeadingSlash(path: string): string {
|
||||
return path.startsWith('/') ? path : `/${path}`;
|
||||
}
|
||||
|
||||
private trimTrailingSlash(value: string): string {
|
||||
return value.endsWith('/') ? value.slice(0, -1) : value;
|
||||
}
|
||||
|
||||
private readFirst(source: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
||||
return source[key];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private readString(value: unknown): string | null {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private readNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
private isActiveStatus(status: unknown): boolean {
|
||||
if (status === true || status === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof status !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ['true', '1', 'active', 'authenticated', 'confirmed', 'success', 'logged_in'].includes(status.toLowerCase());
|
||||
}
|
||||
|
||||
private getTelegramLoginUrl(sessionId: string): string {
|
||||
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
|
||||
return `https://t.me/${botUsername}?start=${encodeURIComponent(sessionId)}`;
|
||||
}
|
||||
|
||||
private generateGuid(): string {
|
||||
if (globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
|
||||
if (globalThis.crypto?.getRandomValues) {
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let index = 0; index < bytes.length; index++) {
|
||||
bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0'));
|
||||
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './api.service';
|
||||
export * from './auth.service';
|
||||
export * from './validation.service';
|
||||
export * from './toast.service';
|
||||
export * from './language.service';
|
||||
|
||||
@@ -12,14 +12,14 @@ export class MockDataService {
|
||||
name: 'dexar',
|
||||
displayName: 'Dexar Marketplace',
|
||||
active: true,
|
||||
logoUrl: 'https://via.placeholder.com/150?text=Dexar'
|
||||
logoUrl: 'https://placehold.co/150?text=Dexar'
|
||||
},
|
||||
{
|
||||
id: 'novo',
|
||||
name: 'novo',
|
||||
displayName: 'Novo Shop',
|
||||
active: true,
|
||||
logoUrl: 'https://via.placeholder.com/150?text=Novo'
|
||||
logoUrl: 'https://placehold.co/150?text=Novo'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -29,7 +29,7 @@ export class MockDataService {
|
||||
name: 'Electronics',
|
||||
visible: true,
|
||||
priority: 1,
|
||||
img: 'https://via.placeholder.com/400x300?text=Electronics',
|
||||
img: 'https://placehold.co/400x300?text=Electronics',
|
||||
projectId: 'dexar',
|
||||
subcategories: [
|
||||
{
|
||||
@@ -37,7 +37,7 @@ export class MockDataService {
|
||||
name: 'Smartphones',
|
||||
visible: true,
|
||||
priority: 1,
|
||||
img: 'https://via.placeholder.com/400x300?text=Smartphones',
|
||||
img: 'https://placehold.co/400x300?text=Smartphones',
|
||||
categoryId: 'cat1',
|
||||
itemCount: 15
|
||||
},
|
||||
@@ -46,7 +46,7 @@ export class MockDataService {
|
||||
name: 'Laptops',
|
||||
visible: true,
|
||||
priority: 2,
|
||||
img: 'https://via.placeholder.com/400x300?text=Laptops',
|
||||
img: 'https://placehold.co/400x300?text=Laptops',
|
||||
categoryId: 'cat1',
|
||||
itemCount: 12
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export class MockDataService {
|
||||
name: 'Clothing',
|
||||
visible: true,
|
||||
priority: 2,
|
||||
img: 'https://via.placeholder.com/400x300?text=Clothing',
|
||||
img: 'https://placehold.co/400x300?text=Clothing',
|
||||
projectId: 'dexar',
|
||||
subcategories: [
|
||||
{
|
||||
@@ -65,7 +65,7 @@ export class MockDataService {
|
||||
name: 'Men',
|
||||
visible: true,
|
||||
priority: 1,
|
||||
img: 'https://via.placeholder.com/400x300?text=Men',
|
||||
img: 'https://placehold.co/400x300?text=Men',
|
||||
categoryId: 'cat2',
|
||||
itemCount: 25
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export class MockDataService {
|
||||
name: 'Home & Garden',
|
||||
visible: false,
|
||||
priority: 3,
|
||||
img: 'https://via.placeholder.com/400x300?text=Home',
|
||||
img: 'https://placehold.co/400x300?text=Home',
|
||||
projectId: 'novo',
|
||||
subcategories: []
|
||||
}
|
||||
@@ -90,13 +90,16 @@ export class MockDataService {
|
||||
priority: 1,
|
||||
quantity: 50,
|
||||
price: 1299,
|
||||
discount: 0,
|
||||
currency: 'USD',
|
||||
imgs: [
|
||||
'https://via.placeholder.com/600x400?text=iPhone+Front',
|
||||
'https://via.placeholder.com/600x400?text=iPhone+Back'
|
||||
'https://placehold.co/600x400?text=iPhone+Front',
|
||||
'https://placehold.co/600x400?text=iPhone+Back'
|
||||
],
|
||||
tags: ['new', 'featured', 'bestseller'],
|
||||
badges: ['new', 'featured'],
|
||||
colour: 'Natural Titanium',
|
||||
size: '',
|
||||
simpleDescription: 'Latest iPhone with titanium design and A17 Pro chip',
|
||||
description: [
|
||||
{ key: 'Color', value: 'Natural Titanium' },
|
||||
@@ -104,6 +107,19 @@ export class MockDataService {
|
||||
{ key: 'Display', value: '6.7 inch Super Retina XDR' },
|
||||
{ key: 'Chip', value: 'A17 Pro' }
|
||||
],
|
||||
attributes: [
|
||||
{ key: 'Color', value: 'Natural Titanium' },
|
||||
{ key: 'Storage', value: '256GB' },
|
||||
{ key: 'Chip', value: 'A17 Pro' }
|
||||
],
|
||||
names: [
|
||||
{ language: 'ru', value: 'iPhone 15 Pro Max' },
|
||||
{ language: 'en', value: 'iPhone 15 Pro Max' }
|
||||
],
|
||||
descriptions: [
|
||||
{ language: 'ru', value: 'Новейший iPhone с титановым корпусом и чипом A17 Pro' },
|
||||
{ language: 'en', value: 'Latest iPhone with titanium design and A17 Pro chip' }
|
||||
],
|
||||
subcategoryId: 'sub1',
|
||||
comments: [
|
||||
{
|
||||
@@ -121,8 +137,9 @@ export class MockDataService {
|
||||
priority: 2,
|
||||
quantity: 35,
|
||||
price: 1199,
|
||||
discount: 10,
|
||||
currency: 'USD',
|
||||
imgs: ['https://via.placeholder.com/600x400?text=Samsung+S24'],
|
||||
imgs: ['https://placehold.co/600x400?text=Samsung+S24'],
|
||||
tags: ['new', 'android'],
|
||||
badges: ['new'],
|
||||
simpleDescription: 'Premium Samsung flagship with S Pen',
|
||||
@@ -140,8 +157,9 @@ export class MockDataService {
|
||||
priority: 3,
|
||||
quantity: 20,
|
||||
price: 999,
|
||||
discount: 15,
|
||||
currency: 'USD',
|
||||
imgs: ['https://via.placeholder.com/600x400?text=Pixel+8'],
|
||||
imgs: ['https://placehold.co/600x400?text=Pixel+8'],
|
||||
tags: ['sale', 'android', 'ai'],
|
||||
badges: ['sale', 'hot'],
|
||||
simpleDescription: 'Best AI photography phone',
|
||||
@@ -158,8 +176,9 @@ export class MockDataService {
|
||||
priority: 1,
|
||||
quantity: 15,
|
||||
price: 2499,
|
||||
discount: 0,
|
||||
currency: 'USD',
|
||||
imgs: ['https://via.placeholder.com/600x400?text=MacBook'],
|
||||
imgs: ['https://placehold.co/600x400?text=MacBook'],
|
||||
tags: ['featured', 'professional'],
|
||||
badges: ['exclusive'],
|
||||
simpleDescription: 'Powerful laptop for professionals',
|
||||
@@ -177,8 +196,9 @@ export class MockDataService {
|
||||
priority: 2,
|
||||
quantity: 0,
|
||||
price: 1799,
|
||||
discount: 5,
|
||||
currency: 'USD',
|
||||
imgs: ['https://via.placeholder.com/600x400?text=Dell+XPS'],
|
||||
imgs: ['https://placehold.co/600x400?text=Dell+XPS'],
|
||||
tags: ['out-of-stock'],
|
||||
simpleDescription: 'Premium Windows laptop',
|
||||
description: [
|
||||
@@ -189,25 +209,38 @@ export class MockDataService {
|
||||
}
|
||||
];
|
||||
|
||||
// Generate more items for testing infinite scroll
|
||||
private generateMoreItems(subcategoryId: string, count: number): Item[] {
|
||||
// Cache for generated test items so pagination is stable
|
||||
private generatedItems = new Map<string, Item[]>();
|
||||
|
||||
// Generate more items for testing infinite scroll (cached per subcategory)
|
||||
private getGeneratedItems(subcategoryId: string, count: number): Item[] {
|
||||
if (this.generatedItems.has(subcategoryId)) {
|
||||
return this.generatedItems.get(subcategoryId)!;
|
||||
}
|
||||
const items: Item[] = [];
|
||||
for (let i = 6; i <= count + 5; i++) {
|
||||
items.push({
|
||||
id: `item${i}`,
|
||||
id: `${subcategoryId}-item${i}`,
|
||||
name: `Test Product ${i}`,
|
||||
visible: Math.random() > 0.3,
|
||||
visible: i % 4 !== 0,
|
||||
priority: i,
|
||||
quantity: Math.floor(Math.random() * 100),
|
||||
price: Math.floor(Math.random() * 1000) + 100,
|
||||
quantity: (i * 7) % 100,
|
||||
price: ((i * 13) % 1000) + 100,
|
||||
discount: i % 3 === 0 ? (i * 5) % 30 + 5 : 0,
|
||||
currency: 'USD',
|
||||
imgs: [`https://via.placeholder.com/600x400?text=Product+${i}`],
|
||||
imgs: [`https://placehold.co/600x400?text=Product+${i}`],
|
||||
tags: ['test'],
|
||||
colour: '',
|
||||
size: i % 2 === 0 ? 'M' : 'L',
|
||||
simpleDescription: `This is test product number ${i}`,
|
||||
description: [{ key: 'Size', value: 'Medium' }],
|
||||
attributes: [{ key: 'Size', value: i % 2 === 0 ? 'M' : 'L' }],
|
||||
names: [],
|
||||
descriptions: [],
|
||||
subcategoryId
|
||||
});
|
||||
}
|
||||
this.generatedItems.set(subcategoryId, items);
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -354,7 +387,7 @@ export class MockDataService {
|
||||
}
|
||||
|
||||
getItems(subcategoryId: string, page = 1, limit = 20, search?: string, filters?: any): Observable<ItemsListResponse> {
|
||||
let allItems = [...this.items, ...this.generateMoreItems(subcategoryId, 50)];
|
||||
let allItems = [...this.items, ...this.getGeneratedItems(subcategoryId, 50)];
|
||||
|
||||
// Filter by subcategory
|
||||
allItems = allItems.filter(item => item.subcategoryId === subcategoryId);
|
||||
@@ -386,14 +419,26 @@ export class MockDataService {
|
||||
}
|
||||
|
||||
getItem(itemId: string): Observable<Item> {
|
||||
const item = this.items.find(i => i.id === itemId)!;
|
||||
return of(item).pipe(delay(200));
|
||||
let item = this.items.find(i => i.id === itemId);
|
||||
if (!item) {
|
||||
for (const generated of this.generatedItems.values()) {
|
||||
item = generated.find(i => i.id === itemId);
|
||||
if (item) break;
|
||||
}
|
||||
}
|
||||
return of(item!).pipe(delay(200));
|
||||
}
|
||||
|
||||
updateItem(itemId: string, data: Partial<Item>): Observable<Item> {
|
||||
const item = this.items.find(i => i.id === itemId)!;
|
||||
Object.assign(item, data);
|
||||
return of(item).pipe(delay(300));
|
||||
let item = this.items.find(i => i.id === itemId);
|
||||
if (!item) {
|
||||
for (const generated of this.generatedItems.values()) {
|
||||
item = generated.find(i => i.id === itemId);
|
||||
if (item) break;
|
||||
}
|
||||
}
|
||||
if (item) Object.assign(item, data);
|
||||
return of(item!).pipe(delay(300));
|
||||
}
|
||||
|
||||
createItem(subcategoryId: string, data: Partial<Item>): Observable<Item> {
|
||||
@@ -404,12 +449,18 @@ export class MockDataService {
|
||||
priority: data.priority || 99,
|
||||
quantity: data.quantity || 0,
|
||||
price: data.price || 0,
|
||||
discount: data.discount || 0,
|
||||
currency: data.currency || 'USD',
|
||||
imgs: data.imgs || [],
|
||||
tags: data.tags || [],
|
||||
badges: data.badges || [],
|
||||
colour: data.colour || '',
|
||||
size: data.size || '',
|
||||
simpleDescription: data.simpleDescription || '',
|
||||
description: data.description || [],
|
||||
attributes: data.attributes || [],
|
||||
names: data.names || [],
|
||||
descriptions: data.descriptions || [],
|
||||
subcategoryId
|
||||
};
|
||||
this.items.push(newItem);
|
||||
@@ -424,7 +475,6 @@ export class MockDataService {
|
||||
}
|
||||
|
||||
deleteItem(itemId: string): Observable<void> {
|
||||
const item = this.items.find(i => i.id === itemId);
|
||||
const index = this.items.findIndex(i => i.id === itemId);
|
||||
if (index > -1) {
|
||||
const subcategoryId = this.items[index].subcategoryId;
|
||||
@@ -438,13 +488,28 @@ export class MockDataService {
|
||||
subcategory.hasItems = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Also remove from generated items cache
|
||||
for (const [key, generated] of this.generatedItems.entries()) {
|
||||
const gi = generated.findIndex(i => i.id === itemId);
|
||||
if (gi > -1) {
|
||||
generated.splice(gi, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return of(void 0).pipe(delay(300));
|
||||
}
|
||||
|
||||
bulkUpdateItems(itemIds: string[], data: Partial<Item>): Observable<void> {
|
||||
itemIds.forEach(id => {
|
||||
const item = this.items.find(i => i.id === id);
|
||||
let item = this.items.find(i => i.id === id);
|
||||
if (!item) {
|
||||
for (const generated of this.generatedItems.values()) {
|
||||
item = generated.find(i => i.id === id);
|
||||
if (item) break;
|
||||
}
|
||||
}
|
||||
if (item) Object.assign(item, data);
|
||||
});
|
||||
return of(void 0).pipe(delay(400));
|
||||
@@ -452,7 +517,7 @@ export class MockDataService {
|
||||
|
||||
uploadImage(file: File): Observable<{ url: string }> {
|
||||
// Simulate upload
|
||||
const url = `https://via.placeholder.com/600x400?text=${encodeURIComponent(file.name)}`;
|
||||
const url = `https://placehold.co/600x400?text=${encodeURIComponent(file.name)}`;
|
||||
return of({ url }).pipe(delay(1000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,15 @@ export class ValidationService {
|
||||
return null;
|
||||
}
|
||||
|
||||
validateDiscount(value: any): string | null {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const num = Number(value);
|
||||
if (isNaN(num)) return 'Discount must be a number';
|
||||
if (num < 0) return 'Discount cannot be negative';
|
||||
if (num > 100) return 'Discount cannot exceed 100%';
|
||||
return null;
|
||||
}
|
||||
|
||||
validateUrl(value: string): string | null {
|
||||
if (!value || value.trim().length === 0) {
|
||||
return null; // Optional field
|
||||
@@ -98,7 +107,7 @@ export class ValidationService {
|
||||
}
|
||||
|
||||
validateCurrency(value: string): string | null {
|
||||
const validCurrencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH'];
|
||||
const validCurrencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH', 'AMD'];
|
||||
if (!validCurrencies.includes(value)) {
|
||||
return `Currency must be one of: ${validCurrencies.join(', ')}`;
|
||||
}
|
||||
@@ -130,6 +139,11 @@ export class ValidationService {
|
||||
const quantityError = this.validateQuantity(item['quantity']);
|
||||
if (quantityError) errors['quantity'] = quantityError;
|
||||
}
|
||||
|
||||
if (item['discount'] !== undefined) {
|
||||
const discountError = this.validateDiscount(item['discount']);
|
||||
if (discountError) errors['discount'] = discountError;
|
||||
}
|
||||
|
||||
if (item['currency'] !== undefined) {
|
||||
const currencyError = this.validateCurrency(item['currency']);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
useMockData: false,
|
||||
apiUrl: '/api',
|
||||
apiUrl: 'https://api.dexarmarket.ru:445',
|
||||
authApiUrl: 'https://users.vitanova.network:456',
|
||||
userSessionApiUrl: 'https://api.dexarmarket.ru:445',
|
||||
telegramBot: 'myAMLKYCBOT',
|
||||
marketplaceUrl: 'https://dexarmarket.ru'
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
useMockData: true, // Set to false when backend is ready
|
||||
useMockData: false,
|
||||
apiUrl: '/api',
|
||||
authApiUrl: '',
|
||||
userSessionApiUrl: '',
|
||||
telegramBot: 'myAMLKYCBOT',
|
||||
marketplaceUrl: 'http://localhost:4200'
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"rootDir": "./src",
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"rootDir": "./",
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user