Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
This commit is contained in:
@@ -454,3 +454,81 @@ Per-domain migration pattern for the six no-seam admin domains (Orders, Products
|
|||||||
- **`ADMIN_DASHBOARD_METRICS_GATEWAY` and `USER_EXPERIENCE_REPOSITORY` token factories return the mock/local class in every mode** — a real implementation must be written *and* explicitly bound; the seam existing does not mean a real backend is one line away.
|
- **`ADMIN_DASHBOARD_METRICS_GATEWAY` and `USER_EXPERIENCE_REPOSITORY` token factories return the mock/local class in every mode** — a real implementation must be written *and* explicitly bound; the seam existing does not mean a real backend is one line away.
|
||||||
|
|
||||||
For open product/business decisions this document deliberately does not resolve (rate limiting posture, refresh-token reuse detection, tenant-scoped auth, API versioning scheme, etc.), see [GAPS-AND-IMPROVEMENTS.md](GAPS-AND-IMPROVEMENTS.md).
|
For open product/business decisions this document deliberately does not resolve (rate limiting posture, refresh-token reuse detection, tenant-scoped auth, API versioning scheme, etc.), see [GAPS-AND-IMPROVEMENTS.md](GAPS-AND-IMPROVEMENTS.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Frontend-blocked TODOs — needs backend
|
||||||
|
|
||||||
|
Raised during the Phase 0 security hardening pass (see the sprint plan). Each of these has a client-side mitigation already in place where one exists, but none of them close the actual gap without a backend change.
|
||||||
|
|
||||||
|
### 12.1 Admin role claim on the session
|
||||||
|
|
||||||
|
**Gap:** `adminAuthGuard` (Mechanism A, Telegram/QR) only checks "is there an active session" — the session API has no concept of admin role at all, so the frontend cannot enforce permissions server-authoritatively. Client mitigation: `AdminPermissionsService` derives a cosmetic permission set by matching the Telegram username against the mock Users domain locally — this is UI-only and trivially bypassed by calling the API directly.
|
||||||
|
|
||||||
|
**Ask:** either (a) add a `role` field to the existing `GET /users/sessions/{id}` response when the session belongs to a registered admin, or (b) finish Mechanism B (Ed25519 challenge/response, already wired client-side, `/challenge` and `/verify` currently 404) so the JWT `role` claim becomes real. Whichever is chosen, every admin-mutating endpoint must independently authorize the request — a role claim on the session is necessary but not sufficient.
|
||||||
|
|
||||||
|
Proposed minimal shape for option (a), added to the existing poll response (§2a):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11",
|
||||||
|
"status": "active",
|
||||||
|
"user": { "id": 8823771, "username": "buyer_ivan", "firstName": "Ivan", "lastName": "P" },
|
||||||
|
"expiresAt": "2026-07-26T05:00:00Z",
|
||||||
|
"adminRole": "admin"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`adminRole` absent/null → treat as non-admin regardless of what `/backoffice/**` UI is reachable client-side.
|
||||||
|
|
||||||
|
### 12.2 HttpOnly session cookie
|
||||||
|
|
||||||
|
**Gap:** the customer session cookie (`webSessionID`, `services/auth.service.ts`) is set via `document.cookie` from the frontend, which means it cannot be `HttpOnly` — only a `Set-Cookie` response header from the backend can set that flag, and JS-set cookies are readable by any injected script. Client mitigation: CSP hardened on all three nginx tenant blocks (was missing entirely on two of three) as defense-in-depth, but this does not close the gap.
|
||||||
|
|
||||||
|
**Ask:** `POST /users/sessions` and `GET /users/sessions/{id}` issue the session id via `Set-Cookie: webSessionID=…; HttpOnly; Secure; SameSite=Lax; Max-Age=…` instead of (or in addition to, during migration) returning it in the JSON body. Once that ships, the frontend stops writing `document.cookie` itself and relies on the browser sending the cookie automatically; `credentials: 'include'` needs enabling on the relevant HTTP calls.
|
||||||
|
|
||||||
|
### 12.3 Server-side order pricing
|
||||||
|
|
||||||
|
**Gap:** `POST` order creation (§7) let the client send a computed, discount-applied `price` per line item with no server-side revalidation. Client fix already shipped: `CreateOrderRequest.items` no longer sends `price` — only `{ productId, name, quantity }`.
|
||||||
|
|
||||||
|
**Ask:** the order-creation endpoint must price every line item itself by looking up `productId` in its own catalog (applying whatever discount/promo logic is authoritative server-side), and reject/[400] if the resulting total doesn't reconcile with what the client displayed (or just recompute and use the server total as-of-record, ignoring any client total entirely). Example of the request shape now sent:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [{ "productId": "prod_1042", "name": "Sample Product", "quantity": 2 }],
|
||||||
|
"customer": { "name": "Ivan P", "email": "ivan@example.com", "phone": "79991234567" },
|
||||||
|
"payment": { "method": "card", "currency": "RUB" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Separately, `createCartPayment()` (payment-gateway charge creation) still sends a client-computed `amount` — that field can't simply be dropped, since it's what tells the payment provider how much to charge. That endpoint must independently revalidate `amount` against its own pricing before creating the charge, and reject on mismatch.
|
||||||
|
|
||||||
|
### 12.4 Real order audit trail
|
||||||
|
|
||||||
|
**Gap:** `AdminOrder` had no actor/audit field at all. Client fix already shipped: `AdminOrderTimelineEntry.actor` now exists and is populated from the signed-in admin's display name in the local mock gateway — but that's client-only bookkeeping with no server-side record.
|
||||||
|
|
||||||
|
**Ask:** when admin Orders CRUD gets a real backend (§10, step 6), every mutating endpoint (`updateStatus`, `requestRefund`, `addNote`, etc.) should record who performed the action server-side (from the authenticated session/JWT, not a client-supplied field) and return it in the order/timeline response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timeline": [
|
||||||
|
{ "status": "processing", "timestamp": "2026-08-13T10:15:00Z", "eventKey": "statusChanged", "actor": "anna@dexar.market" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`actor` must be derived server-side from the authenticated caller, never trusted from the request body.
|
||||||
|
|
||||||
|
### 12.5 Back-in-stock ("Notify Me") subscription
|
||||||
|
|
||||||
|
**Gap:** the "Notify Me" button on out-of-stock products had no real subscription mechanism at all - it just toggled wishlist. Client fix already shipped: `notifyMe()` now calls `POST /items/{id}/notify-me` and, if that fails (today it always will - the endpoint doesn't exist), falls back to a local-only record in `localStorage['restockSubscriptions']` so the request isn't silently dropped while waiting on the backend. The shopper sees the same confirmation either way.
|
||||||
|
|
||||||
|
**Ask:** implement `POST /items/{id}/notify-me`, plus whatever mechanism actually sends the notification once the item restocks (Telegram message, most likely, given the rest of the auth stack). Request body sent today:
|
||||||
|
```json
|
||||||
|
{ "telegramUserId": "8823771" }
|
||||||
|
```
|
||||||
|
`telegramUserId` may be `null` for a non-Telegram web session - decide whether to also accept an email address as an alternative identifier (the frontend has no email capture on this flow today, so that would need a small frontend addition too). Once this ships, the frontend's localStorage fallback becomes purely a resilience path rather than the common case, and could optionally sync any locally-queued subscriptions on next successful call.
|
||||||
|
|
||||||
|
### 12.6 Trending search terms
|
||||||
|
|
||||||
|
**Gap:** `SearchTrendingService.loadTrending()` is a stub returning `of(null)` - no trending-searches endpoint exists. It already degrades gracefully (UI hides the trending section rather than showing an error), so this is purely a missing-feature gap, not a bug.
|
||||||
|
|
||||||
|
**Ask:** an endpoint returning the top N search queries over some recent window, e.g.:
|
||||||
|
```json
|
||||||
|
{ "trending": [{ "query": "wireless earbuds", "count": 214 }, { "query": "winter jacket", "count": 187 }] }
|
||||||
|
```
|
||||||
|
Once it exists, wire `loadTrending()` to it and map `query` -> `SearchSuggestion.title/text`.
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ server {
|
|||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Template for onboarding a new marketplace tenant.
|
# Template for onboarding a new marketplace tenant.
|
||||||
@@ -178,4 +180,6 @@ server {
|
|||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;" always;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { languageGuard } from './guards/language.guard';
|
import { languageGuard } from './guards/language.guard';
|
||||||
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard';
|
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard';
|
||||||
import { adminAuthGuard } from './core/admin-auth/admin-auth.guard';
|
import { adminAuthGuard, requireAdminPermission } from './core/admin-auth/admin-auth.guard';
|
||||||
import { authRoutes } from './core/auth/auth.routes';
|
import { authRoutes } from './core/auth/auth.routes';
|
||||||
import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
|
import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
|
||||||
import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard';
|
import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard';
|
||||||
@@ -229,6 +229,7 @@ const coreRoutes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'users',
|
path: 'users',
|
||||||
|
canActivate: [requireAdminPermission('users.manage')],
|
||||||
loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent),
|
loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent),
|
||||||
data: {
|
data: {
|
||||||
titleKey: 'adminShell.pages.users.title',
|
titleKey: 'adminShell.pages.users.title',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { CanActivateFn } from '@angular/router';
|
import { CanActivateFn } from '@angular/router';
|
||||||
import { AdminAuthService } from './admin-auth.service';
|
import { AdminAuthService } from './admin-auth.service';
|
||||||
|
import { AdminPermissionsService } from './admin-permissions.service';
|
||||||
|
|
||||||
/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */
|
/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */
|
||||||
export const adminAuthGuard: CanActivateFn = () => {
|
export const adminAuthGuard: CanActivateFn = () => {
|
||||||
@@ -13,3 +14,22 @@ export const adminAuthGuard: CanActivateFn = () => {
|
|||||||
adminAuth.requestLogin();
|
adminAuth.requestLogin();
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI-only gate for a specific permission, on top of adminAuthGuard's
|
||||||
|
* authentication check. See AdminPermissionsService for why this is
|
||||||
|
* cosmetic until the backend ships real admin-role enforcement.
|
||||||
|
*/
|
||||||
|
export function requireAdminPermission(permission: string): CanActivateFn {
|
||||||
|
return () => {
|
||||||
|
const adminAuth = inject(AdminAuthService);
|
||||||
|
const permissions = inject(AdminPermissionsService);
|
||||||
|
|
||||||
|
if (!adminAuth.isAuthenticated()) {
|
||||||
|
adminAuth.requestLogin();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions.has(permission);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
41
src/app/core/admin-auth/admin-permissions.service.ts
Normal file
41
src/app/core/admin-auth/admin-permissions.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable, computed, inject } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { AdminAuthService } from './admin-auth.service';
|
||||||
|
import { AdminUsersLocalGateway } from '../../features/admin/users/services/admin-users-local.gateway';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI-only permission gate for the live Telegram/QR admin auth (Mechanism A),
|
||||||
|
* which carries no role claim of its own (see admin-auth.service.ts). This
|
||||||
|
* derives a permission set by matching the signed-in Telegram username
|
||||||
|
* against the mock Users domain's roleId - the same local-only stand-in the
|
||||||
|
* rest of the Users admin domain already uses (see BACKEND-API-REFERENCE.md
|
||||||
|
* §8 "Users - MOCK-ONLY, no seam"). It is cosmetic until a real backend
|
||||||
|
* ships either an admin-role claim on the session, or Mechanism B
|
||||||
|
* (Ed25519 JWT + PermissionService) goes live.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AdminPermissionsService {
|
||||||
|
private readonly adminAuth = inject(AdminAuthService);
|
||||||
|
private readonly usersGateway = inject(AdminUsersLocalGateway);
|
||||||
|
|
||||||
|
private readonly users = toSignal(this.usersGateway.loadUsers(), { initialValue: [] });
|
||||||
|
private readonly roles = toSignal(this.usersGateway.loadRoles(), { initialValue: [] });
|
||||||
|
|
||||||
|
readonly permissions = computed<readonly string[]>(() => {
|
||||||
|
const session = this.adminAuth.session();
|
||||||
|
if (!session) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const username = session.username?.replace(/^@/, '');
|
||||||
|
const matchedUser = this.users().find(user => user.telegramUsername.replace(/^@/, '') === username);
|
||||||
|
if (!matchedUser) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.roles().find(role => role.id === matchedUser.roleId)?.permissions ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
has(permission: string): boolean {
|
||||||
|
const permissions = this.permissions();
|
||||||
|
return permissions.includes('*') || permissions.includes(permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,24 @@ export interface AuthError {
|
|||||||
status?: number;
|
status?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps the backend error envelope's `error.code` (see
|
||||||
|
* BACKEND-API-REFERENCE.md §5) to the client's AuthErrorCode screens.
|
||||||
|
* Only codes with a dedicated screen are mapped; anything else falls back
|
||||||
|
* to the HTTP-status-derived code via authErrorCodeFromStatus.
|
||||||
|
*/
|
||||||
|
const BACKEND_ERROR_CODE_MAP: Record<string, AuthErrorCode> = {
|
||||||
|
TOKEN_EXPIRED: 'session-expired',
|
||||||
|
INVALID_SIGNATURE: 'invalid-signature',
|
||||||
|
UNAUTHENTICATED: 'unauthorized',
|
||||||
|
FORBIDDEN: 'forbidden',
|
||||||
|
SERVICE_UNAVAILABLE: 'backend-unavailable',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function authErrorCodeFromBackendCode(code: unknown): AuthErrorCode | undefined {
|
||||||
|
return typeof code === 'string' ? BACKEND_ERROR_CODE_MAP[code] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */
|
/** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */
|
||||||
export function authErrorCodeFromStatus(status: number): AuthErrorCode {
|
export function authErrorCodeFromStatus(status: number): AuthErrorCode {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http';
|
|||||||
import { catchError, switchMap, tap, throwError } from 'rxjs';
|
import { catchError, switchMap, tap, throwError } from 'rxjs';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { AuthTokenPair } from '../models/auth-api.model';
|
import { AuthTokenPair } from '../models/auth-api.model';
|
||||||
import { AuthError, authErrorCodeFromStatus } from '../models/auth-error.model';
|
import { AuthError, authErrorCodeFromBackendCode, authErrorCodeFromStatus } from '../models/auth-error.model';
|
||||||
import { AuthApiService } from './auth-api.service';
|
import { AuthApiService } from './auth-api.service';
|
||||||
import { Ed25519KeypairService } from './ed25519-keypair.service';
|
import { Ed25519KeypairService } from './ed25519-keypair.service';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
@@ -109,7 +109,9 @@ export class AuthService {
|
|||||||
|
|
||||||
private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError {
|
private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError {
|
||||||
if (error instanceof HttpErrorResponse) {
|
if (error instanceof HttpErrorResponse) {
|
||||||
return { code: authErrorCodeFromStatus(error.status), message: error.message, status: error.status };
|
const bodyCode = (error.error as { error?: { code?: unknown } } | null)?.error?.code;
|
||||||
|
const code = authErrorCodeFromBackendCode(bodyCode) ?? authErrorCodeFromStatus(error.status);
|
||||||
|
return { code, message: error.message, status: error.status };
|
||||||
}
|
}
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return { code: fallbackCode, message: error.message };
|
return { code: fallbackCode, message: error.message };
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
import { InjectionToken, inject } from '@angular/core';
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
import { RuntimeProviderStrategyService } from '../providers/runtime-provider-strategy.service';
|
|
||||||
import { ApiCategoryRepository } from './repositories/api-category.repository';
|
import { ApiCategoryRepository } from './repositories/api-category.repository';
|
||||||
import { CategoryRepository } from './repositories/category.repository';
|
import { CategoryRepository } from './repositories/category.repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No mock CategoryRepository implementation exists - same dead branch as
|
||||||
|
* PRODUCT_DATA_PROVIDER. Always resolved to the real API repository
|
||||||
|
* regardless of getCategoryProviderMode(); removed the dead switch.
|
||||||
|
*/
|
||||||
export const CATEGORY_REPOSITORY = new InjectionToken<CategoryRepository>('CATEGORY_REPOSITORY', {
|
export const CATEGORY_REPOSITORY = new InjectionToken<CategoryRepository>('CATEGORY_REPOSITORY', {
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
factory: () => {
|
factory: () => inject(ApiCategoryRepository)
|
||||||
const strategy = inject(RuntimeProviderStrategyService);
|
|
||||||
const apiRepository = inject(ApiCategoryRepository);
|
|
||||||
|
|
||||||
switch (strategy.getCategoryProviderMode()) {
|
|
||||||
case 'mock':
|
|
||||||
case 'remote-config':
|
|
||||||
case 'api':
|
|
||||||
default:
|
|
||||||
return apiRepository;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
@@ -1,20 +1,14 @@
|
|||||||
import { InjectionToken, inject } from '@angular/core';
|
import { InjectionToken, inject } from '@angular/core';
|
||||||
import { RuntimeProviderStrategyService } from '../providers/runtime-provider-strategy.service';
|
|
||||||
import { ApiProductDataProvider } from './providers/api-product-data.provider';
|
import { ApiProductDataProvider } from './providers/api-product-data.provider';
|
||||||
import { ProductDataProvider } from './providers/product-data-provider.interface';
|
import { ProductDataProvider } from './providers/product-data-provider.interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No mock ProductDataProvider implementation exists - RuntimeProviderStrategyService.
|
||||||
|
* getProductProviderMode() can report 'mock', but there was never a branch that acted
|
||||||
|
* on it, so this always resolved to the real API provider regardless. Removed the dead
|
||||||
|
* switch instead of leaving code that implies a mock mode which doesn't exist.
|
||||||
|
*/
|
||||||
export const PRODUCT_DATA_PROVIDER = new InjectionToken<ProductDataProvider>('PRODUCT_DATA_PROVIDER', {
|
export const PRODUCT_DATA_PROVIDER = new InjectionToken<ProductDataProvider>('PRODUCT_DATA_PROVIDER', {
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
factory: () => {
|
factory: () => inject(ApiProductDataProvider)
|
||||||
const strategy = inject(RuntimeProviderStrategyService);
|
|
||||||
const apiProvider = inject(ApiProductDataProvider);
|
|
||||||
|
|
||||||
switch (strategy.getProductProviderMode()) {
|
|
||||||
case 'mock':
|
|
||||||
case 'remote-config':
|
|
||||||
case 'api':
|
|
||||||
default:
|
|
||||||
return apiProvider;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { PageConfig } from '../../shared/models/config';
|
import { PageConfig } from '../../shared/models/config';
|
||||||
import { PageRenderModel } from '../page-renderer/page-renderer.model';
|
import { PageRenderModel } from '../page-renderer/page-renderer.model';
|
||||||
import { SectionRendererService } from '../section-renderer/section-renderer.service';
|
import { SectionRendererService } from '../section-renderer/section-renderer.service';
|
||||||
import { PlatformLayoutConfig, PlatformLayoutType } from '../../shared/models/config';
|
import { PlatformLayoutConfig, PlatformLayoutType } from '../../shared/models/config';
|
||||||
import { SectionConfig } from '../../shared/models/config';
|
import { SectionConfig } from '../../shared/models/config';
|
||||||
|
import { ConfigService } from '../../core/config/config.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SectionEngineService {
|
export class SectionEngineService {
|
||||||
|
private readonly configService = inject(ConfigService);
|
||||||
|
|
||||||
constructor(private readonly sectionRenderer: SectionRendererService) {}
|
constructor(private readonly sectionRenderer: SectionRendererService) {}
|
||||||
|
|
||||||
toPageRenderModel(page: PageConfig): PageRenderModel {
|
toPageRenderModel(page: PageConfig): PageRenderModel {
|
||||||
@@ -27,12 +30,23 @@ export class SectionEngineService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Falls back to the site-wide builder setting (bootstrap.layout.type,
|
||||||
|
* "Site Layout" in the theme editor) when a page has no layout of its
|
||||||
|
* own - previously that global setting was saved but never read by
|
||||||
|
* rendering at all, so it had no visible effect.
|
||||||
|
*/
|
||||||
private resolveLayoutType(layout: PageConfig['layout']): string {
|
private resolveLayoutType(layout: PageConfig['layout']): string {
|
||||||
if (typeof layout === 'string') {
|
if (typeof layout === 'string') {
|
||||||
return layout;
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (layout as PlatformLayoutConfig)?.type ?? 'default';
|
const pageLayoutType = (layout as PlatformLayoutConfig)?.type;
|
||||||
|
if (pageLayoutType) {
|
||||||
|
return pageLayoutType;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.configService.getBootstrapSnapshot()?.layout?.type ?? 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeSectionsByLayout(sections: PageConfig['sections'], layoutType: string): PageConfig['sections'] {
|
private normalizeSectionsByLayout(sections: PageConfig['sections'], layoutType: string): PageConfig['sections'] {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { take } from 'rxjs/operators';
|
import { forkJoin, Subject } from 'rxjs';
|
||||||
|
import { take, takeUntil } from 'rxjs/operators';
|
||||||
import {
|
import {
|
||||||
AdminAnalyticsDateRange,
|
AdminAnalyticsDateRange,
|
||||||
AdminAnalyticsSeriesPoint,
|
AdminAnalyticsSeriesPoint,
|
||||||
@@ -62,7 +63,13 @@ export class AdminAnalyticsFacade {
|
|||||||
|
|
||||||
readonly warnings = computed(() => this.recommendations().filter(card => card.severity !== 'info'));
|
readonly warnings = computed(() => this.recommendations().filter(card => card.severity !== 'info'));
|
||||||
|
|
||||||
|
private readonly cancelPreviousLoad$ = new Subject<void>();
|
||||||
|
|
||||||
load(): void {
|
load(): void {
|
||||||
|
// Cancel any still-in-flight previous load so a rapid setDateRange() double-call
|
||||||
|
// can't have a stale response overwrite a newer one.
|
||||||
|
this.cancelPreviousLoad$.next();
|
||||||
|
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.error.set(false);
|
this.error.set(false);
|
||||||
this.dashboardFacade.ensureLoaded();
|
this.dashboardFacade.ensureLoaded();
|
||||||
@@ -74,10 +81,13 @@ export class AdminAnalyticsFacade {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const fail = (): void => { this.loading.set(false); this.error.set(true); };
|
forkJoin({
|
||||||
|
orderResult: this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }),
|
||||||
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
productResult: this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }),
|
||||||
next: orderResult => {
|
categories: this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }),
|
||||||
|
reviewResult: this.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }),
|
||||||
|
}).pipe(take(1), takeUntil(this.cancelPreviousLoad$)).subscribe({
|
||||||
|
next: ({ orderResult, productResult, categories, reviewResult }) => {
|
||||||
const cutoff = Date.now() - this.dateRange() * 24 * 60 * 60 * 1000;
|
const cutoff = Date.now() - this.dateRange() * 24 * 60 * 60 * 1000;
|
||||||
const inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff);
|
const inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff);
|
||||||
|
|
||||||
@@ -89,43 +99,28 @@ export class AdminAnalyticsFacade {
|
|||||||
const ordersCount = inRange.length;
|
const ordersCount = inRange.length;
|
||||||
const uniqueCustomers = new Set(inRange.map(order => order.customer.email)).size;
|
const uniqueCustomers = new Set(inRange.map(order => order.customer.email)).size;
|
||||||
|
|
||||||
this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
const products = productResult.items;
|
||||||
next: productResult => {
|
const reviews = reviewResult.items;
|
||||||
this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({
|
|
||||||
next: categories => {
|
|
||||||
this.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
|
||||||
next: reviewResult => {
|
|
||||||
const products = productResult.items;
|
|
||||||
const reviews = reviewResult.items;
|
|
||||||
|
|
||||||
this.summary.set({
|
this.summary.set({
|
||||||
revenueTotal,
|
revenueTotal,
|
||||||
currency: inRange[0]?.currency ?? 'RUB',
|
currency: inRange[0]?.currency ?? 'RUB',
|
||||||
ordersCount,
|
ordersCount,
|
||||||
avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0,
|
avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0,
|
||||||
productsCount: products.length,
|
productsCount: products.length,
|
||||||
categoriesCount: categories.length,
|
categoriesCount: categories.length,
|
||||||
customersCount: uniqueCustomers,
|
customersCount: uniqueCustomers,
|
||||||
conversionRate: null,
|
conversionRate: null,
|
||||||
});
|
|
||||||
|
|
||||||
this.lowStockProducts.set(this.buildLowStock(products));
|
|
||||||
this.productAnalytics.set(this.buildProductAnalytics(products));
|
|
||||||
this.marketplaceHealth.set(this.buildMarketplaceHealth(products, categories, reviews, orderResult.items));
|
|
||||||
this.recommendations.set(this.buildRecommendations(products, categories));
|
|
||||||
|
|
||||||
this.loading.set(false);
|
|
||||||
},
|
|
||||||
error: fail
|
|
||||||
});
|
|
||||||
},
|
|
||||||
error: fail
|
|
||||||
});
|
|
||||||
},
|
|
||||||
error: fail
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.lowStockProducts.set(this.buildLowStock(products));
|
||||||
|
this.productAnalytics.set(this.buildProductAnalytics(products));
|
||||||
|
this.marketplaceHealth.set(this.buildMarketplaceHealth(products, categories, reviews, orderResult.items));
|
||||||
|
this.recommendations.set(this.buildRecommendations(products, categories));
|
||||||
|
|
||||||
|
this.loading.set(false);
|
||||||
},
|
},
|
||||||
error: fail
|
error: () => { this.loading.set(false); this.error.set(true); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,8 @@
|
|||||||
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
||||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
} @else if (error) {
|
||||||
|
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
|
||||||
} @else if (treeRows.length === 0) {
|
} @else if (treeRows.length === 0) {
|
||||||
<app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate">
|
<app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate">
|
||||||
<span slot="actions">
|
<span slot="actions">
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export class AdminCategoriesListComponent {
|
|||||||
@Input() flatCategories: AdminCategory[] = [];
|
@Input() flatCategories: AdminCategory[] = [];
|
||||||
@Input() filters!: { search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean };
|
@Input() filters!: { search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean };
|
||||||
@Input() loading = false;
|
@Input() loading = false;
|
||||||
|
@Input() error: string | null = null;
|
||||||
@Input() viewMode: AdminCategoriesViewMode = 'tree';
|
@Input() viewMode: AdminCategoriesViewMode = 'tree';
|
||||||
@Input() density: AdminCategoriesDensity = 'comfortable';
|
@Input() density: AdminCategoriesDensity = 'comfortable';
|
||||||
@Input() visibleColumns: AdminCategoryColumn[] = [...ALL_CATEGORY_COLUMNS];
|
@Input() visibleColumns: AdminCategoryColumn[] = [...ALL_CATEGORY_COLUMNS];
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<app-form-field [label]="'adminCategories.title' | translate" [required]="true">
|
<app-form-field [label]="'adminCategories.title' | translate" [required]="true">
|
||||||
<app-input [ngModel]="category.title" (ngModelChange)="updateTitle($event)" />
|
<app-input [ngModel]="category.title" (ngModelChange)="updateTitle($event)" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
<app-form-field [label]="'adminCategories.slug' | translate" [required]="true" [hint]="'adminProducts.slugHint' | translate" [error]="slugTaken ? ('adminCategories.slugTaken' | translate) : null">
|
<app-form-field [label]="'adminCategories.slug' | translate" [required]="true" [hint]="'adminProducts.slugHint' | translate" [error]="slugCheckError ? ('adminCategories.slugCheckError' | translate) : (slugTaken ? ('adminCategories.slugTaken' | translate) : null)">
|
||||||
<app-input [ngModel]="category.slug" (ngModelChange)="updateField('slug', $event)" />
|
<app-input [ngModel]="category.slug" (ngModelChange)="updateField('slug', $event)" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
<label><span>{{ 'adminCategories.parent' | translate }}</span>
|
<label><span>{{ 'adminCategories.parent' | translate }}</span>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export class AdminCategoryFormComponent {
|
|||||||
@Input() breadcrumb: string[] = [];
|
@Input() breadcrumb: string[] = [];
|
||||||
@Input() children: AdminCategory[] = [];
|
@Input() children: AdminCategory[] = [];
|
||||||
@Input() slugTaken = false;
|
@Input() slugTaken = false;
|
||||||
|
@Input() slugCheckError = false;
|
||||||
@Input() locales: string[] = ['en'];
|
@Input() locales: string[] = ['en'];
|
||||||
@Input() mode: 'create' | 'edit' = 'create';
|
@Input() mode: 'create' | 'edit' = 'create';
|
||||||
@Input() health!: AdminCategoryHealth;
|
@Input() health!: AdminCategoryHealth;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { take } from 'rxjs/operators';
|
import { catchError, of, take } from 'rxjs';
|
||||||
import { AdminCategory, AdminCategoryEditorMode, AdminCategoryListFilters } from '../models/admin-category.model';
|
import { AdminCategory, AdminCategoryEditorMode, AdminCategoryListFilters } from '../models/admin-category.model';
|
||||||
import { AdminCategoriesFormFactory } from '../services/admin-categories-form.factory';
|
import { AdminCategoriesFormFactory } from '../services/admin-categories-form.factory';
|
||||||
import { ADMIN_CATEGORIES_GATEWAY } from '../services/admin-categories-gateway.token';
|
import { ADMIN_CATEGORIES_GATEWAY } from '../services/admin-categories-gateway.token';
|
||||||
@@ -131,10 +131,13 @@ export class AdminCategoriesFacade {
|
|||||||
readonly filters = signal<AdminCategoryListFilters>({ search: '', visibility: 'all', includeDeleted: false });
|
readonly filters = signal<AdminCategoryListFilters>({ search: '', visibility: 'all', includeDeleted: false });
|
||||||
readonly categories = signal<AdminCategory[]>([]);
|
readonly categories = signal<AdminCategory[]>([]);
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
readonly draft = signal<AdminCategory | null>(null);
|
readonly draft = signal<AdminCategory | null>(null);
|
||||||
readonly editorMode = signal<AdminCategoryEditorMode>('create');
|
readonly editorMode = signal<AdminCategoryEditorMode>('create');
|
||||||
readonly dirty = signal(false);
|
readonly dirty = signal(false);
|
||||||
readonly slugTaken = signal(false);
|
readonly slugTaken = signal(false);
|
||||||
|
readonly slugCheckError = signal(false);
|
||||||
|
readonly mutationError = signal<string | null>(null);
|
||||||
private savedSnapshot: string | null = null;
|
private savedSnapshot: string | null = null;
|
||||||
private draftStorageKey: string | null = null;
|
private draftStorageKey: string | null = null;
|
||||||
|
|
||||||
@@ -173,6 +176,7 @@ export class AdminCategoriesFacade {
|
|||||||
*/
|
*/
|
||||||
loadList(): void {
|
loadList(): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({
|
this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({
|
||||||
next: categories => {
|
next: categories => {
|
||||||
this.categories.set(categories);
|
this.categories.set(categories);
|
||||||
@@ -181,6 +185,7 @@ export class AdminCategoriesFacade {
|
|||||||
error: () => {
|
error: () => {
|
||||||
this.categories.set([]);
|
this.categories.set([]);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
this.error.set('common.errorDescription');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -305,16 +310,28 @@ export class AdminCategoriesFacade {
|
|||||||
const draft = this.draft();
|
const draft = this.draft();
|
||||||
if (!draft || !draft.slug) {
|
if (!draft || !draft.slug) {
|
||||||
this.slugTaken.set(false);
|
this.slugTaken.set(false);
|
||||||
|
this.slugCheckError.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(take(1))
|
this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(
|
||||||
.subscribe(taken => this.slugTaken.set(taken));
|
take(1),
|
||||||
|
catchError(() => {
|
||||||
|
this.slugCheckError.set(true);
|
||||||
|
return of(true);
|
||||||
|
})
|
||||||
|
).subscribe(taken => {
|
||||||
|
this.slugTaken.set(taken);
|
||||||
|
if (!taken) {
|
||||||
|
this.slugCheckError.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
saveDraft(publish: boolean): void {
|
saveDraft(publish: boolean, onSuccess?: () => void): void {
|
||||||
const draft = this.draft();
|
const draft = this.draft();
|
||||||
if (!draft || this.slugTaken()) return;
|
if (!draft || this.slugTaken() || this.slugCheckError()) return;
|
||||||
|
|
||||||
|
this.mutationError.set(null);
|
||||||
const toSave: AdminCategory = { ...draft, status: publish ? 'published' : 'draft', updatedAt: new Date().toISOString() };
|
const toSave: AdminCategory = { ...draft, status: publish ? 'published' : 'draft', updatedAt: new Date().toISOString() };
|
||||||
const request = this.editorMode() === 'create' ? this.gateway.createCategory(toSave) : this.gateway.updateCategory(toSave);
|
const request = this.editorMode() === 'create' ? this.gateway.createCategory(toSave) : this.gateway.updateCategory(toSave);
|
||||||
|
|
||||||
@@ -327,7 +344,9 @@ export class AdminCategoriesFacade {
|
|||||||
this.savedSnapshot = JSON.stringify(saved);
|
this.savedSnapshot = JSON.stringify(saved);
|
||||||
this.dirty.set(false);
|
this.dirty.set(false);
|
||||||
this.loadList();
|
this.loadList();
|
||||||
}
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
error: () => this.mutationError.set('common.errorDescription')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +361,11 @@ export class AdminCategoriesFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
deleteOne(id: string): void {
|
deleteOne(id: string): void {
|
||||||
this.gateway.deleteCategory(id).pipe(take(1)).subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } });
|
this.mutationError.set(null);
|
||||||
|
this.gateway.deleteCategory(id).pipe(take(1)).subscribe({
|
||||||
|
next: () => { this.loadList(); this.loadDashboardStats(); },
|
||||||
|
error: () => this.mutationError.set('common.errorDescription')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
restoreOne(id: string): void {
|
restoreOne(id: string): void {
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { AdminCategoriesFacade } from '../facade/admin-categories.facade';
|
import { AdminCategoriesFacade } from '../facade/admin-categories.facade';
|
||||||
import { AdminCategoriesListComponent } from '../components/admin-categories-list.component';
|
import { AdminCategoriesListComponent } from '../components/admin-categories-list.component';
|
||||||
import { LanguageService } from '../../../../services/language.service';
|
import { LanguageService } from '../../../../services/language.service';
|
||||||
import { TranslateService } from '../../../../i18n/translate.service';
|
import { TranslateService } from '../../../../i18n/translate.service';
|
||||||
|
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||||
|
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||||
|
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||||
|
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-categories-list-page',
|
selector: 'app-admin-categories-list-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [AdminCategoriesListComponent],
|
imports: [AdminCategoriesListComponent, ConfirmDialogComponent, DialogComponent, ButtonComponent, TranslatePipe],
|
||||||
template: `<app-admin-categories-list
|
template: `<app-admin-categories-list
|
||||||
[treeRows]="facade.visibleTreeRows()"
|
[treeRows]="facade.visibleTreeRows()"
|
||||||
[flatCategories]="facade.filteredCategories()"
|
[flatCategories]="facade.filteredCategories()"
|
||||||
[allCategories]="facade.categories()"
|
[allCategories]="facade.categories()"
|
||||||
[filters]="facade.filters()"
|
[filters]="facade.filters()"
|
||||||
[loading]="facade.loading()"
|
[loading]="facade.loading()"
|
||||||
|
[error]="facade.error()"
|
||||||
[viewMode]="facade.viewMode()"
|
[viewMode]="facade.viewMode()"
|
||||||
[density]="facade.density()"
|
[density]="facade.density()"
|
||||||
[visibleColumns]="facade.visibleColumns()"
|
[visibleColumns]="facade.visibleColumns()"
|
||||||
@@ -37,14 +42,44 @@ import { TranslateService } from '../../../../i18n/translate.service';
|
|||||||
(selectionChange)="facade.toggleSelection($event.id, $event.checked)"
|
(selectionChange)="facade.toggleSelection($event.id, $event.checked)"
|
||||||
(selectAll)="facade.toggleAll($event)"
|
(selectAll)="facade.toggleAll($event)"
|
||||||
(bulkVisibility)="facade.applyBulkVisibility($event)"
|
(bulkVisibility)="facade.applyBulkVisibility($event)"
|
||||||
(bulkDelete)="facade.applyBulkDelete()"
|
(bulkDelete)="bulkDelete()"
|
||||||
(bulkDuplicate)="facade.applyBulkDuplicate()"
|
(bulkDuplicate)="facade.applyBulkDuplicate()"
|
||||||
(bulkAssignParent)="facade.applyBulkAssignParent($event)"
|
(bulkAssignParent)="facade.applyBulkAssignParent($event)"
|
||||||
(bulkAssignImage)="facade.applyBulkAssignImage($event)"
|
(bulkAssignImage)="facade.applyBulkAssignImage($event)"
|
||||||
(bulkExport)="facade.exportSelectedAsCsv()"
|
(bulkExport)="facade.exportSelectedAsCsv()"
|
||||||
(viewModeChange)="facade.setViewMode($event)"
|
(viewModeChange)="facade.setViewMode($event)"
|
||||||
(densityChange)="facade.setDensity($event)"
|
(densityChange)="facade.setDensity($event)"
|
||||||
(columnToggle)="facade.setColumnVisible($event.column, $event.visible)" />`,
|
(columnToggle)="facade.setColumnVisible($event.column, $event.visible)" />
|
||||||
|
<app-confirm-dialog
|
||||||
|
[open]="!!pendingDeleteId()"
|
||||||
|
[titleText]="'adminCategories.deleteTitle' | translate"
|
||||||
|
[message]="'adminCategories.confirmDelete' | translate"
|
||||||
|
[destructive]="true"
|
||||||
|
(confirmed)="confirmDelete()"
|
||||||
|
(cancelled)="pendingDeleteId.set(null)" />
|
||||||
|
<app-confirm-dialog
|
||||||
|
[open]="bulkDeleteConfirmOpen()"
|
||||||
|
[titleText]="'adminCategories.deleteTitle' | translate"
|
||||||
|
[message]="'adminCategories.confirmBulkDelete' | translate"
|
||||||
|
[destructive]="true"
|
||||||
|
(confirmed)="confirmBulkDelete()"
|
||||||
|
(cancelled)="bulkDeleteConfirmOpen.set(false)" />
|
||||||
|
@if (deleteBlockedMessage()) {
|
||||||
|
<app-dialog [open]="true" [titleText]="'adminCategories.deleteBlocked' | translate" size="sm" (closed)="deleteBlockedMessage.set(null)">
|
||||||
|
<p>{{ deleteBlockedMessage() }}</p>
|
||||||
|
<div class="app-confirm-dialog__actions">
|
||||||
|
<app-button variant="primary" (click)="deleteBlockedMessage.set(null)">{{ 'common.confirm' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</app-dialog>
|
||||||
|
}
|
||||||
|
@if (facade.mutationError()) {
|
||||||
|
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
|
||||||
|
<p>{{ 'common.errorDescription' | translate }}</p>
|
||||||
|
<div class="app-confirm-dialog__actions">
|
||||||
|
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</app-dialog>
|
||||||
|
}`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class AdminCategoriesListPageComponent {
|
export class AdminCategoriesListPageComponent {
|
||||||
@@ -57,6 +92,10 @@ export class AdminCategoriesListPageComponent {
|
|||||||
readonly isExpandedFn = (id: string) => this.facade.isExpanded(id);
|
readonly isExpandedFn = (id: string) => this.facade.isExpanded(id);
|
||||||
readonly canDeleteFn = (id: string) => this.facade.canDelete(id);
|
readonly canDeleteFn = (id: string) => this.facade.canDelete(id);
|
||||||
|
|
||||||
|
readonly pendingDeleteId = signal<string | null>(null);
|
||||||
|
readonly bulkDeleteConfirmOpen = signal(false);
|
||||||
|
readonly deleteBlockedMessage = signal<string | null>(null);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.facade.loadList();
|
this.facade.loadList();
|
||||||
this.facade.loadDashboardStats();
|
this.facade.loadDashboardStats();
|
||||||
@@ -75,12 +114,27 @@ export class AdminCategoriesListPageComponent {
|
|||||||
|
|
||||||
deleteOne(id: string): void {
|
deleteOne(id: string): void {
|
||||||
if (!this.facade.canDelete(id)) {
|
if (!this.facade.canDelete(id)) {
|
||||||
window.alert(this.translate.t('adminCategories.deleteBlocked'));
|
this.deleteBlockedMessage.set(this.translate.t('adminCategories.deleteBlocked'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (window.confirm(this.translate.t('adminCategories.confirmDelete'))) {
|
this.pendingDeleteId.set(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDelete(): void {
|
||||||
|
const id = this.pendingDeleteId();
|
||||||
|
if (id) {
|
||||||
this.facade.deleteOne(id);
|
this.facade.deleteOne(id);
|
||||||
}
|
}
|
||||||
|
this.pendingDeleteId.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkDelete(): void {
|
||||||
|
this.bulkDeleteConfirmOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmBulkDelete(): void {
|
||||||
|
this.facade.applyBulkDelete();
|
||||||
|
this.bulkDeleteConfirmOpen.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private lang(): string {
|
private lang(): string {
|
||||||
|
|||||||
@@ -4,12 +4,22 @@ import { AdminCategoriesFacade } from '../facade/admin-categories.facade';
|
|||||||
import { AdminCategoryFormComponent } from '../components/admin-category-form.component';
|
import { AdminCategoryFormComponent } from '../components/admin-category-form.component';
|
||||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
import { LanguageService } from '../../../../services/language.service';
|
import { LanguageService } from '../../../../services/language.service';
|
||||||
|
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||||
|
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-category-editor-page',
|
selector: 'app-admin-category-editor-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [AdminCategoryFormComponent, TranslatePipe],
|
imports: [AdminCategoryFormComponent, TranslatePipe, DialogComponent, ButtonComponent],
|
||||||
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-category-form [category]="draft" [parentOptions]="parentOptions()" [breadcrumb]="facade.breadcrumbFor(draft.parentId)" [children]="facade.childrenOf(draft.id)" [slugTaken]="facade.slugTaken()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (categoryChange)="facade.updateDraft($event)" (saveDraft)="save(false)" (publish)="save(true)" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`,
|
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-category-form [category]="draft" [parentOptions]="parentOptions()" [breadcrumb]="facade.breadcrumbFor(draft.parentId)" [children]="facade.childrenOf(draft.id)" [slugTaken]="facade.slugTaken()" [slugCheckError]="facade.slugCheckError()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (categoryChange)="facade.updateDraft($event)" (saveDraft)="save(false)" (publish)="save(true)" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}
|
||||||
|
@if (facade.mutationError()) {
|
||||||
|
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
|
||||||
|
<p>{{ 'common.errorDescription' | translate }}</p>
|
||||||
|
<div class="app-confirm-dialog__actions">
|
||||||
|
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</app-dialog>
|
||||||
|
}`,
|
||||||
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`],
|
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
@@ -41,8 +51,9 @@ export class AdminCategoryEditorPageComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
save(publish: boolean): void {
|
save(publish: boolean): void {
|
||||||
this.facade.saveDraft(publish);
|
this.facade.saveDraft(publish, () => {
|
||||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']);
|
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private descendantIds(id: string): string[] {
|
private descendantIds(id: string): string[] {
|
||||||
|
|||||||
@@ -59,8 +59,7 @@ export class AdminCategoriesApiGateway implements AdminCategoriesGateway {
|
|||||||
params = params.set('excludingId', excludingId);
|
params = params.set('excludingId', excludingId);
|
||||||
}
|
}
|
||||||
return this.http.get<{ taken: boolean }>(`${this.baseUrl}/slug-taken`, { params }).pipe(
|
return this.http.get<{ taken: boolean }>(`${this.baseUrl}/slug-taken`, { params }).pipe(
|
||||||
map(response => response.taken),
|
map(response => response.taken)
|
||||||
catchError(() => of(false))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ export class AdminCustomersFacade {
|
|||||||
|
|
||||||
readonly customers = signal<AdminCustomer[]>([]);
|
readonly customers = signal<AdminCustomer[]>([]);
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
readonly search = signal('');
|
readonly search = signal('');
|
||||||
readonly selected = signal<AdminCustomer | null>(null);
|
readonly selected = signal<AdminCustomer | null>(null);
|
||||||
|
readonly selectedLoading = signal(false);
|
||||||
|
readonly selectedError = signal<string | null>(null);
|
||||||
|
|
||||||
private buildCustomers(orders: AdminOrder[]): AdminCustomer[] {
|
private buildCustomers(orders: AdminOrder[]): AdminCustomer[] {
|
||||||
const byEmail = new Map<string, AdminOrder[]>();
|
const byEmail = new Map<string, AdminOrder[]>();
|
||||||
@@ -41,6 +44,7 @@ export class AdminCustomersFacade {
|
|||||||
|
|
||||||
loadList(): void {
|
loadList(): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
||||||
next: result => {
|
next: result => {
|
||||||
this.customers.set(this.buildCustomers(result.items));
|
this.customers.set(this.buildCustomers(result.items));
|
||||||
@@ -49,16 +53,24 @@ export class AdminCustomersFacade {
|
|||||||
error: () => {
|
error: () => {
|
||||||
this.customers.set([]);
|
this.customers.set([]);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
this.error.set('common.errorDescription');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDetail(email: string): void {
|
loadDetail(email: string): void {
|
||||||
|
this.selectedLoading.set(true);
|
||||||
|
this.selectedError.set(null);
|
||||||
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
||||||
next: result => {
|
next: result => {
|
||||||
const decoded = decodeURIComponent(email);
|
const decoded = decodeURIComponent(email);
|
||||||
const customers = this.buildCustomers(result.items);
|
const customers = this.buildCustomers(result.items);
|
||||||
this.selected.set(customers.find(customer => customer.email === decoded) ?? null);
|
this.selected.set(customers.find(customer => customer.email === decoded) ?? null);
|
||||||
|
this.selectedLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.selectedLoading.set(false);
|
||||||
|
this.selectedError.set('common.errorDescription');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,12 @@
|
|||||||
<app-order-timeline [entries]="activity()" [showOrderNumber]="true" />
|
<app-order-timeline [entries]="activity()" [showOrderNumber]="true" />
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
} @else if (facade.selectedError()) {
|
||||||
|
<div class="customer-detail-error" role="alert">
|
||||||
|
<p>{{ 'common.errorTitle' | translate }}</p>
|
||||||
|
<p>{{ 'common.errorDescription' | translate }}</p>
|
||||||
|
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminCustomers.back' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<p>{{ 'common.loading' | translate }}</p>
|
<p>{{ 'common.loading' | translate }}</p>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
||||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
} @else if (facade.error()) {
|
||||||
|
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
|
||||||
} @else if (facade.filteredCustomers().length === 0) {
|
} @else if (facade.filteredCustomers().length === 0) {
|
||||||
<app-empty-state [title]="'adminCustomers.emptyTitle' | translate" [description]="'adminCustomers.emptyDescription' | translate" />
|
<app-empty-state [title]="'adminCustomers.emptyTitle' | translate" [description]="'adminCustomers.emptyDescription' | translate" />
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -53,9 +53,16 @@
|
|||||||
<app-button variant="secondary" size="sm" (click)="applyBulkStatus()">{{ 'adminOrders.applyStatus' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="applyBulkStatus()">{{ 'adminOrders.applyStatus' | translate }}</app-button>
|
||||||
<app-button variant="secondary" size="sm" (click)="facade.applyBulkVisible(false)">{{ 'adminModeration.hideSelected' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="facade.applyBulkVisible(false)">{{ 'adminModeration.hideSelected' | translate }}</app-button>
|
||||||
<app-button variant="secondary" size="sm" (click)="facade.exportSelectedAsCsv()">{{ 'adminProducts.bulkExportAction' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="facade.exportSelectedAsCsv()">{{ 'adminProducts.bulkExportAction' | translate }}</app-button>
|
||||||
<app-button variant="danger" size="sm" (click)="facade.applyBulkDelete()">{{ 'adminModeration.archiveSelected' | translate }}</app-button>
|
<app-button variant="danger" size="sm" (click)="bulkDelete()">{{ 'adminModeration.deleteSelected' | translate }}</app-button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<app-confirm-dialog
|
||||||
|
[open]="bulkDeleteConfirmOpen()"
|
||||||
|
[titleText]="'adminModeration.deleteSelected' | translate"
|
||||||
|
[message]="'adminModeration.confirmBulkDelete' | translate"
|
||||||
|
[destructive]="true"
|
||||||
|
(confirmed)="confirmBulkDelete()"
|
||||||
|
(cancelled)="bulkDeleteConfirmOpen.set(false)" />
|
||||||
|
|
||||||
@if (facade.loading()) {
|
@if (facade.loading()) {
|
||||||
<div class="skeleton-rows" role="status" aria-live="polite" aria-busy="true">
|
<div class="skeleton-rows" role="status" aria-live="polite" aria-busy="true">
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.compo
|
|||||||
import { CardComponent } from '../../../../shared/ui/card/card.component';
|
import { CardComponent } from '../../../../shared/ui/card/card.component';
|
||||||
import { ModerationDashboardComponent } from '../components/moderation-dashboard/moderation-dashboard.component';
|
import { ModerationDashboardComponent } from '../components/moderation-dashboard/moderation-dashboard.component';
|
||||||
import { ReviewHealthWidgetComponent, ReviewHealthItem } from '../components/review-health-widget/review-health-widget.component';
|
import { ReviewHealthWidgetComponent, ReviewHealthItem } from '../components/review-health-widget/review-health-widget.component';
|
||||||
|
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-reviews-list-page',
|
selector: 'app-admin-reviews-list-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, SkeletonComponent, CardComponent, ModerationDashboardComponent, ReviewHealthWidgetComponent],
|
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, SkeletonComponent, CardComponent, ModerationDashboardComponent, ReviewHealthWidgetComponent, ConfirmDialogComponent],
|
||||||
templateUrl: './admin-reviews-list-page.component.html',
|
templateUrl: './admin-reviews-list-page.component.html',
|
||||||
styleUrls: ['./admin-reviews-list-page.component.scss'],
|
styleUrls: ['./admin-reviews-list-page.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -35,6 +36,7 @@ export class AdminReviewsListPageComponent {
|
|||||||
readonly allColumns = ALL_REVIEW_COLUMNS;
|
readonly allColumns = ALL_REVIEW_COLUMNS;
|
||||||
protected readonly columnsPanelOpen = signal(false);
|
protected readonly columnsPanelOpen = signal(false);
|
||||||
protected readonly bulkStatusValue = signal<AdminReviewStatus>('approved');
|
protected readonly bulkStatusValue = signal<AdminReviewStatus>('approved');
|
||||||
|
protected readonly bulkDeleteConfirmOpen = signal(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.facade.loadList();
|
this.facade.loadList();
|
||||||
@@ -45,6 +47,15 @@ export class AdminReviewsListPageComponent {
|
|||||||
return Math.max(1, Math.ceil(this.facade.total() / this.facade.filters().pageSize));
|
return Math.max(1, Math.ceil(this.facade.total() / this.facade.filters().pageSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bulkDelete(): void {
|
||||||
|
this.bulkDeleteConfirmOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmBulkDelete(): void {
|
||||||
|
this.facade.applyBulkDelete();
|
||||||
|
this.bulkDeleteConfirmOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
view(id: string): void {
|
view(id: string): void {
|
||||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'moderation', id]);
|
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'moderation', id]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<p class="order-timeline__note">{{ entry.note }}</p>
|
<p class="order-timeline__note">{{ entry.note }}</p>
|
||||||
|
@if (entry.actor) {
|
||||||
|
<span class="order-timeline__actor">{{ entry.actor }}</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,3 +55,10 @@
|
|||||||
font-size: var(--font-size-sm, 0.8125rem);
|
font-size: var(--font-size-sm, 0.8125rem);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.order-timeline__actor {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
color: var(--text-light);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface OrderTimelineEntry {
|
|||||||
/** Pre-resolved, already-translated display text for this event. */
|
/** Pre-resolved, already-translated display text for this event. */
|
||||||
note: string;
|
note: string;
|
||||||
orderNumber?: string;
|
orderNumber?: string;
|
||||||
|
actor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reusable vertical event timeline - used on both the order detail page and the customer activity tab. */
|
/** Reusable vertical event timeline - used on both the order detail page and the customer activity tab. */
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export class AdminOrdersFacade {
|
|||||||
readonly orders = signal<AdminOrder[]>([]);
|
readonly orders = signal<AdminOrder[]>([]);
|
||||||
readonly total = signal(0);
|
readonly total = signal(0);
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
readonly selected = signal<AdminOrder | null>(null);
|
readonly selected = signal<AdminOrder | null>(null);
|
||||||
|
readonly selectedLoading = signal(false);
|
||||||
|
readonly selectedError = signal<string | null>(null);
|
||||||
|
|
||||||
readonly viewMode = signal<AdminOrdersViewMode>((this.localStorage.getItem(VIEW_MODE_KEY) as AdminOrdersViewMode) || 'table');
|
readonly viewMode = signal<AdminOrdersViewMode>((this.localStorage.getItem(VIEW_MODE_KEY) as AdminOrdersViewMode) || 'table');
|
||||||
readonly density = signal<AdminOrdersDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminOrdersDensity) || 'comfortable');
|
readonly density = signal<AdminOrdersDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminOrdersDensity) || 'comfortable');
|
||||||
@@ -76,6 +79,7 @@ export class AdminOrdersFacade {
|
|||||||
|
|
||||||
loadList(): void {
|
loadList(): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
this.gateway.loadOrders(this.filters()).pipe(take(1)).subscribe({
|
this.gateway.loadOrders(this.filters()).pipe(take(1)).subscribe({
|
||||||
next: result => {
|
next: result => {
|
||||||
this.orders.set(result.items);
|
this.orders.set(result.items);
|
||||||
@@ -86,6 +90,7 @@ export class AdminOrdersFacade {
|
|||||||
this.orders.set([]);
|
this.orders.set([]);
|
||||||
this.total.set(0);
|
this.total.set(0);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
this.error.set('common.errorDescription');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -96,7 +101,18 @@ export class AdminOrdersFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadDetail(id: string): void {
|
loadDetail(id: string): void {
|
||||||
this.gateway.loadOrder(id).pipe(take(1)).subscribe({ next: order => this.selected.set(order) });
|
this.selectedLoading.set(true);
|
||||||
|
this.selectedError.set(null);
|
||||||
|
this.gateway.loadOrder(id).pipe(take(1)).subscribe({
|
||||||
|
next: order => {
|
||||||
|
this.selected.set(order);
|
||||||
|
this.selectedLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.selectedLoading.set(false);
|
||||||
|
this.selectedError.set('common.errorDescription');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(id: string, status: AdminOrderStatus): void {
|
setStatus(id: string, status: AdminOrderStatus): void {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { UUID } from '../../../../shared/types/primitive.types';
|
||||||
|
|
||||||
export type AdminOrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
|
export type AdminOrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
|
||||||
export type AdminOrderPaymentStatus = 'unpaid' | 'paid' | 'refund_requested' | 'refunded';
|
export type AdminOrderPaymentStatus = 'unpaid' | 'paid' | 'refund_requested' | 'refunded';
|
||||||
|
|
||||||
@@ -33,6 +35,7 @@ export interface AdminOrderTimelineEntry {
|
|||||||
status: AdminOrderStatus;
|
status: AdminOrderStatus;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
eventKey: AdminOrderTimelineEventKey;
|
eventKey: AdminOrderTimelineEventKey;
|
||||||
|
actor: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminOrder {
|
export interface AdminOrder {
|
||||||
@@ -56,7 +59,7 @@ export interface AdminOrder {
|
|||||||
* Absent means marketplace-owned, exactly like every order today -
|
* Absent means marketplace-owned, exactly like every order today -
|
||||||
* nothing reads this field yet, no behavior change.
|
* nothing reads this field yet, no behavior change.
|
||||||
*/
|
*/
|
||||||
sellerId?: string;
|
sellerId?: UUID;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminOrderListFilters {
|
export interface AdminOrderListFilters {
|
||||||
|
|||||||
@@ -46,14 +46,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card no-print">
|
<div class="card no-print">
|
||||||
<h3>{{ 'adminOrders.changeStatus' | translate }}</h3>
|
<h3>{{ 'adminOrders.changeStatus' | translate }}</h3>
|
||||||
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)">
|
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)" [disabled]="isTerminal()">
|
||||||
@for (status of statuses; track status) {
|
@for (status of selectableStatuses; track status) {
|
||||||
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
|
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
|
||||||
}
|
}
|
||||||
|
@if (isTerminal()) {
|
||||||
|
<option [value]="order.status">{{ ('adminOrders.status.' + order.status) | translate }}</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<app-button variant="secondary" size="sm" (click)="requestRefund(order.id)">{{ 'adminOrders.requestRefund' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="requestRefund(order.id)" [disabled]="isTerminal()">{{ 'adminOrders.requestRefund' | translate }}</app-button>
|
||||||
<app-button variant="danger" size="sm" (click)="cancel(order.id)">{{ 'adminOrders.cancelOrder' | translate }}</app-button>
|
<app-button variant="danger" size="sm" (click)="cancel(order.id)" [disabled]="isTerminal()">{{ 'adminOrders.cancelOrder' | translate }}</app-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -85,7 +88,27 @@
|
|||||||
<app-button variant="secondary" size="sm" (click)="submitInternalNote(order.id)">{{ 'adminOrders.addNote' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="submitInternalNote(order.id)">{{ 'adminOrders.addNote' | translate }}</app-button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<app-confirm-dialog
|
||||||
|
[open]="!!pendingCancelId()"
|
||||||
|
[titleText]="'adminOrders.cancelOrder' | translate"
|
||||||
|
[message]="'adminOrders.confirmCancel' | translate"
|
||||||
|
[destructive]="true"
|
||||||
|
(confirmed)="confirmCancel()"
|
||||||
|
(cancelled)="pendingCancelId.set(null)" />
|
||||||
|
<app-confirm-dialog
|
||||||
|
[open]="!!pendingRefundId()"
|
||||||
|
[titleText]="'adminOrders.requestRefund' | translate"
|
||||||
|
[message]="'adminOrders.confirmRefund' | translate"
|
||||||
|
[destructive]="true"
|
||||||
|
(confirmed)="confirmRefund()"
|
||||||
|
(cancelled)="pendingRefundId.set(null)" />
|
||||||
</main>
|
</main>
|
||||||
|
} @else if (facade.selectedError()) {
|
||||||
|
<div class="order-detail-error" role="alert">
|
||||||
|
<p>{{ 'common.errorTitle' | translate }}</p>
|
||||||
|
<p>{{ 'common.errorDescription' | translate }}</p>
|
||||||
|
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminOrders.back' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<p>{{ 'common.loading' | translate }}</p>
|
<p>{{ 'common.loading' | translate }}</p>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { LanguageService } from '../../../../services/language.service';
|
|||||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||||
import { OrderTimelineComponent, OrderTimelineEntry } from '../components/order-timeline/order-timeline.component';
|
import { OrderTimelineComponent, OrderTimelineEntry } from '../components/order-timeline/order-timeline.component';
|
||||||
|
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
const WORKFLOW_STEPS: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered'];
|
const WORKFLOW_STEPS: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered'];
|
||||||
const TERMINAL_STATUSES: AdminOrderStatus[] = ['cancelled', 'refunded'];
|
const TERMINAL_STATUSES: AdminOrderStatus[] = ['cancelled', 'refunded'];
|
||||||
@@ -17,7 +18,7 @@ const TERMINAL_STATUSES: AdminOrderStatus[] = ['cancelled', 'refunded'];
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-order-detail-page',
|
selector: 'app-admin-order-detail-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, OrderTimelineComponent],
|
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, OrderTimelineComponent, ConfirmDialogComponent],
|
||||||
templateUrl: './admin-order-detail-page.component.html',
|
templateUrl: './admin-order-detail-page.component.html',
|
||||||
styleUrls: ['./admin-order-detail-page.component.scss'],
|
styleUrls: ['./admin-order-detail-page.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -30,8 +31,12 @@ export class AdminOrderDetailPageComponent {
|
|||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
|
|
||||||
readonly statuses: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
|
readonly statuses: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
|
||||||
|
/** Terminal statuses are only reachable via the confirm-gated cancel()/requestRefund(), never the raw dropdown. */
|
||||||
|
readonly selectableStatuses: AdminOrderStatus[] = this.statuses.filter(status => !TERMINAL_STATUSES.includes(status));
|
||||||
readonly workflowSteps = WORKFLOW_STEPS;
|
readonly workflowSteps = WORKFLOW_STEPS;
|
||||||
readonly noteDraft = signal('');
|
readonly noteDraft = signal('');
|
||||||
|
readonly pendingCancelId = signal<string | null>(null);
|
||||||
|
readonly pendingRefundId = signal<string | null>(null);
|
||||||
readonly internalNoteDraft = signal('');
|
readonly internalNoteDraft = signal('');
|
||||||
|
|
||||||
readonly isTerminal = computed(() => {
|
readonly isTerminal = computed(() => {
|
||||||
@@ -54,6 +59,7 @@ export class AdminOrderDetailPageComponent {
|
|||||||
note: this.translate.t('adminOrders.timelineEvent.' + entry.eventKey, {
|
note: this.translate.t('adminOrders.timelineEvent.' + entry.eventKey, {
|
||||||
status: this.translate.t('adminOrders.status.' + entry.status),
|
status: this.translate.t('adminOrders.status.' + entry.status),
|
||||||
}),
|
}),
|
||||||
|
actor: entry.actor,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,19 +79,36 @@ export class AdminOrderDetailPageComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setStatus(id: string, status: AdminOrderStatus): void {
|
setStatus(id: string, status: AdminOrderStatus): void {
|
||||||
|
if (TERMINAL_STATUSES.includes(status)) {
|
||||||
|
// Unreachable from the dropdown (options are filtered), but guard anyway
|
||||||
|
// since terminal transitions must always go through the confirm dialog.
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.facade.setStatus(id, status);
|
this.facade.setStatus(id, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel(id: string): void {
|
cancel(id: string): void {
|
||||||
if (window.confirm(this.translate.t('adminOrders.confirmCancel'))) {
|
this.pendingCancelId.set(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmCancel(): void {
|
||||||
|
const id = this.pendingCancelId();
|
||||||
|
if (id) {
|
||||||
this.facade.cancelOrder(id);
|
this.facade.cancelOrder(id);
|
||||||
}
|
}
|
||||||
|
this.pendingCancelId.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
requestRefund(id: string): void {
|
requestRefund(id: string): void {
|
||||||
if (window.confirm(this.translate.t('adminOrders.confirmRefund'))) {
|
this.pendingRefundId.set(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmRefund(): void {
|
||||||
|
const id = this.pendingRefundId();
|
||||||
|
if (id) {
|
||||||
this.facade.requestRefund(id);
|
this.facade.requestRefund(id);
|
||||||
}
|
}
|
||||||
|
this.pendingRefundId.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
submitNote(id: string): void {
|
submitNote(id: string): void {
|
||||||
|
|||||||
@@ -37,23 +37,32 @@
|
|||||||
<div class="bulk-actions">
|
<div class="bulk-actions">
|
||||||
<span>{{ facade.selectedIds().length }} {{ 'adminProducts.selectedCount' | translate }}</span>
|
<span>{{ facade.selectedIds().length }} {{ 'adminProducts.selectedCount' | translate }}</span>
|
||||||
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="bulkStatusValue()" (ngModelChange)="bulkStatusValue.set($event)">
|
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="bulkStatusValue()" (ngModelChange)="bulkStatusValue.set($event)">
|
||||||
@for (status of statuses; track status) {
|
@for (status of bulkSelectableStatuses; track status) {
|
||||||
@if (status !== 'all') { <option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option> }
|
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
<app-button variant="secondary" size="sm" (click)="applyBulkStatus()">{{ 'adminOrders.applyStatus' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="applyBulkStatus()">{{ 'adminOrders.applyStatus' | translate }}</app-button>
|
||||||
<app-button variant="secondary" size="sm" (click)="facade.applyBulkArchive(true)">{{ 'adminOrders.archiveSelected' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="facade.applyBulkArchive(true)">{{ 'adminOrders.archiveSelected' | translate }}</app-button>
|
||||||
<app-button variant="secondary" size="sm" (click)="facade.exportSelectedAsCsv()">{{ 'adminProducts.bulkExportAction' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="facade.exportSelectedAsCsv()">{{ 'adminProducts.bulkExportAction' | translate }}</app-button>
|
||||||
<app-button variant="secondary" size="sm" (click)="printSelection()">{{ 'adminOrders.printSelected' | translate }}</app-button>
|
<app-button variant="secondary" size="sm" (click)="printSelection()">{{ 'adminOrders.printSelected' | translate }}</app-button>
|
||||||
<app-button variant="danger" size="sm" (click)="facade.applyBulkDelete()">{{ 'adminProducts.bulkDelete' | translate }}</app-button>
|
<app-button variant="danger" size="sm" (click)="bulkDelete()">{{ 'adminProducts.bulkDelete' | translate }}</app-button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
<app-confirm-dialog
|
||||||
|
[open]="bulkDeleteConfirmOpen()"
|
||||||
|
[titleText]="'adminOrders.bulkDeleteTitle' | translate"
|
||||||
|
[message]="'adminOrders.confirmBulkDelete' | translate"
|
||||||
|
[destructive]="true"
|
||||||
|
(confirmed)="confirmBulkDelete()"
|
||||||
|
(cancelled)="bulkDeleteConfirmOpen.set(false)" />
|
||||||
|
|
||||||
@if (facade.loading()) {
|
@if (facade.loading()) {
|
||||||
<div class="skeleton-rows" role="status" aria-live="polite" aria-busy="true">
|
<div class="skeleton-rows" role="status" aria-live="polite" aria-busy="true">
|
||||||
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
||||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
} @else if (facade.error()) {
|
||||||
|
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
|
||||||
} @else if (facade.orders().length === 0) {
|
} @else if (facade.orders().length === 0) {
|
||||||
<app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" />
|
<app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" />
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -15,11 +15,12 @@ import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-sta
|
|||||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||||
import { CardComponent } from '../../../../shared/ui/card/card.component';
|
import { CardComponent } from '../../../../shared/ui/card/card.component';
|
||||||
import { OrdersDashboardComponent } from '../components/orders-dashboard/orders-dashboard.component';
|
import { OrdersDashboardComponent } from '../components/orders-dashboard/orders-dashboard.component';
|
||||||
|
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-orders-list-page',
|
selector: 'app-admin-orders-list-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, SkeletonComponent, CardComponent, OrdersDashboardComponent],
|
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, SkeletonComponent, CardComponent, OrdersDashboardComponent, ConfirmDialogComponent],
|
||||||
templateUrl: './admin-orders-list-page.component.html',
|
templateUrl: './admin-orders-list-page.component.html',
|
||||||
styleUrls: ['./admin-orders-list-page.component.scss'],
|
styleUrls: ['./admin-orders-list-page.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -30,9 +31,12 @@ export class AdminOrdersListPageComponent {
|
|||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
readonly statuses = ['all', 'pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'] as const;
|
readonly statuses = ['all', 'pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'] as const;
|
||||||
|
/** Bulk status change excludes terminal statuses - cancel/refund must go through the confirm-gated single-order flow. */
|
||||||
|
readonly bulkSelectableStatuses: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered'];
|
||||||
readonly allColumns = ALL_ORDER_COLUMNS;
|
readonly allColumns = ALL_ORDER_COLUMNS;
|
||||||
protected readonly columnsPanelOpen = signal(false);
|
protected readonly columnsPanelOpen = signal(false);
|
||||||
protected readonly bulkStatusValue = signal<AdminOrderStatus>('pending');
|
protected readonly bulkStatusValue = signal<AdminOrderStatus>('pending');
|
||||||
|
protected readonly bulkDeleteConfirmOpen = signal(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.facade.loadList();
|
this.facade.loadList();
|
||||||
@@ -59,6 +63,15 @@ export class AdminOrdersListPageComponent {
|
|||||||
this.facade.applyBulkStatus(this.bulkStatusValue());
|
this.facade.applyBulkStatus(this.bulkStatusValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bulkDelete(): void {
|
||||||
|
this.bulkDeleteConfirmOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmBulkDelete(): void {
|
||||||
|
this.facade.applyBulkDelete();
|
||||||
|
this.bulkDeleteConfirmOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
printSelection(): void {
|
printSelection(): void {
|
||||||
window.print();
|
window.print();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { delay } from 'rxjs/operators';
|
import { delay } from 'rxjs/operators';
|
||||||
import { AdminOrder, AdminOrderListFilters, AdminOrdersListResult, AdminOrderStatus } from '../models/admin-order.model';
|
import { AdminOrder, AdminOrderListFilters, AdminOrdersListResult, AdminOrderStatus } from '../models/admin-order.model';
|
||||||
import { AdminOrdersGateway } from './admin-orders-gateway.interface';
|
import { AdminOrdersGateway } from './admin-orders-gateway.interface';
|
||||||
|
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
|
||||||
|
|
||||||
const STATUSES: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
|
const STATUSES: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
|
||||||
const CUSTOMER_NAMES = ['Anna Petrova', 'Karen Sargsyan', 'Ivan Ivanov', 'Mariam Grigoryan', 'Sergey Volkov', 'Lilit Hakobyan'];
|
const CUSTOMER_NAMES = ['Anna Petrova', 'Karen Sargsyan', 'Ivan Ivanov', 'Mariam Grigoryan', 'Sergey Volkov', 'Lilit Hakobyan'];
|
||||||
@@ -10,8 +11,13 @@ const SEED_COUNT = 24;
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
||||||
|
private readonly adminAuth = inject(AdminAuthService);
|
||||||
private cache: AdminOrder[] | null = null;
|
private cache: AdminOrder[] | null = null;
|
||||||
|
|
||||||
|
private get currentActor(): string {
|
||||||
|
return this.adminAuth.displayName() ?? 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult> {
|
loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult> {
|
||||||
const all = this.ensureData();
|
const all = this.ensureData();
|
||||||
const filtered = all
|
const filtered = all
|
||||||
@@ -36,7 +42,7 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
|||||||
...order,
|
...order,
|
||||||
status,
|
status,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
timeline: [...order.timeline, { status, timestamp: new Date().toISOString(), eventKey: 'statusChanged' as const }],
|
timeline: [...order.timeline, { status, timestamp: new Date().toISOString(), eventKey: 'statusChanged' as const, actor: this.currentActor }],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +51,7 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
|||||||
...order,
|
...order,
|
||||||
payment: { ...order.payment, status: 'refund_requested' },
|
payment: { ...order.payment, status: 'refund_requested' },
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
timeline: [...order.timeline, { status: order.status, timestamp: new Date().toISOString(), eventKey: 'refundRequested' as const }],
|
timeline: [...order.timeline, { status: order.status, timestamp: new Date().toISOString(), eventKey: 'refundRequested' as const, actor: this.currentActor }],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,8 +128,8 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
|||||||
notes: '',
|
notes: '',
|
||||||
internalNotes: '',
|
internalNotes: '',
|
||||||
timeline: [
|
timeline: [
|
||||||
{ status: 'pending', timestamp: createdAt, eventKey: 'created' },
|
{ status: 'pending', timestamp: createdAt, eventKey: 'created', actor: 'system' },
|
||||||
...(status !== 'pending' ? [{ status, timestamp: createdAt, eventKey: 'statusChanged' as const }] : []),
|
...(status !== 'pending' ? [{ status, timestamp: createdAt, eventKey: 'statusChanged' as const, actor: 'system' }] : []),
|
||||||
],
|
],
|
||||||
archived: false,
|
archived: false,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
|||||||
@@ -78,6 +78,8 @@
|
|||||||
}
|
}
|
||||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
} @else if (error) {
|
||||||
|
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
|
||||||
} @else if (products.length === 0) {
|
} @else if (products.length === 0) {
|
||||||
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate">
|
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate">
|
||||||
<span slot="actions">
|
<span slot="actions">
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export class AdminProductsListComponent {
|
|||||||
@Input() total = 0;
|
@Input() total = 0;
|
||||||
@Input() selectedIds: string[] = [];
|
@Input() selectedIds: string[] = [];
|
||||||
@Input() loading = false;
|
@Input() loading = false;
|
||||||
|
@Input() error: string | null = null;
|
||||||
@Input() infiniteScroll = false;
|
@Input() infiniteScroll = false;
|
||||||
@Input() viewMode: AdminProductsViewMode = 'table';
|
@Input() viewMode: AdminProductsViewMode = 'table';
|
||||||
@Input() density: AdminProductsDensity = 'comfortable';
|
@Input() density: AdminProductsDensity = 'comfortable';
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ export class AdminProductsFacade {
|
|||||||
readonly infiniteScroll = signal(false);
|
readonly infiniteScroll = signal(false);
|
||||||
readonly categories = signal<AdminProductCategoryOption[]>([]);
|
readonly categories = signal<AdminProductCategoryOption[]>([]);
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
|
readonly mutationError = signal<string | null>(null);
|
||||||
readonly selectedIds = signal<string[]>([]);
|
readonly selectedIds = signal<string[]>([]);
|
||||||
readonly draft = signal<AdminProduct | null>(null);
|
readonly draft = signal<AdminProduct | null>(null);
|
||||||
readonly dirty = signal(false);
|
readonly dirty = signal(false);
|
||||||
@@ -100,6 +102,7 @@ export class AdminProductsFacade {
|
|||||||
|
|
||||||
loadList(): void {
|
loadList(): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
this.gateway.loadProducts(this.filters()).pipe(take(1)).subscribe({
|
this.gateway.loadProducts(this.filters()).pipe(take(1)).subscribe({
|
||||||
next: result => {
|
next: result => {
|
||||||
this.products.set(result.items);
|
this.products.set(result.items);
|
||||||
@@ -110,6 +113,7 @@ export class AdminProductsFacade {
|
|||||||
this.products.set([]);
|
this.products.set([]);
|
||||||
this.total.set(0);
|
this.total.set(0);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
this.error.set('common.errorDescription');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -302,18 +306,26 @@ export class AdminProductsFacade {
|
|||||||
this.dirty.set(true);
|
this.dirty.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveDraft(): void {
|
saveDraft(onSuccess?: () => void): void {
|
||||||
const draft = this.draft();
|
const draft = this.draft();
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
|
|
||||||
|
this.mutationError.set(null);
|
||||||
const request = this.editorMode() === 'create'
|
const request = this.editorMode() === 'create'
|
||||||
? this.gateway.createProduct(draft)
|
? this.gateway.createProduct(draft)
|
||||||
: this.gateway.updateProduct(draft);
|
: this.gateway.updateProduct(draft);
|
||||||
|
|
||||||
request.pipe(take(1)).subscribe({ next: () => { this.dirty.set(false); this.loadList(); } });
|
request.pipe(take(1)).subscribe({
|
||||||
|
next: () => { this.dirty.set(false); this.loadList(); onSuccess?.(); },
|
||||||
|
error: () => this.mutationError.set('common.errorDescription')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteOne(id: string): void {
|
deleteOne(id: string): void {
|
||||||
this.gateway.deleteProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() });
|
this.mutationError.set(null);
|
||||||
|
this.gateway.deleteProduct(id).pipe(take(1)).subscribe({
|
||||||
|
next: () => this.loadList(),
|
||||||
|
error: () => this.mutationError.set('common.errorDescription')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { UUID } from '../../../../shared/types/primitive.types';
|
||||||
|
|
||||||
export type AdminProductStockStatus = 'in_stock' | 'low_stock' | 'out_of_stock';
|
export type AdminProductStockStatus = 'in_stock' | 'low_stock' | 'out_of_stock';
|
||||||
export type AdminProductSort = 'title' | 'price' | 'priority' | 'stock' | 'updated';
|
export type AdminProductSort = 'title' | 'price' | 'priority' | 'stock' | 'updated';
|
||||||
export type AdminProductEditorMode = 'create' | 'edit' | 'duplicate';
|
export type AdminProductEditorMode = 'create' | 'edit' | 'duplicate';
|
||||||
@@ -117,7 +119,7 @@ export interface AdminProduct {
|
|||||||
* Absent means marketplace-owned, exactly like every product today -
|
* Absent means marketplace-owned, exactly like every product today -
|
||||||
* nothing reads this field yet, nothing breaks by it being undefined.
|
* nothing reads this field yet, nothing breaks by it being undefined.
|
||||||
*/
|
*/
|
||||||
sellerId?: string;
|
sellerId?: UUID;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminProductListFilters {
|
export interface AdminProductListFilters {
|
||||||
|
|||||||
@@ -4,12 +4,22 @@ import { AdminProductsFacade } from '../facade/admin-products.facade';
|
|||||||
import { AdminProductFormComponent } from '../components/admin-product-form.component';
|
import { AdminProductFormComponent } from '../components/admin-product-form.component';
|
||||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
import { LanguageService } from '../../../../services/language.service';
|
import { LanguageService } from '../../../../services/language.service';
|
||||||
|
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||||
|
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-product-editor-page',
|
selector: 'app-admin-product-editor-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [AdminProductFormComponent, TranslatePipe],
|
imports: [AdminProductFormComponent, TranslatePipe, DialogComponent, ButtonComponent],
|
||||||
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-product-form [product]="draft" [categories]="facade.categories()" [allProducts]="facade.products()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (productChange)="facade.updateDraft($event)" (save)="save()" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`,
|
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-product-form [product]="draft" [categories]="facade.categories()" [allProducts]="facade.products()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (productChange)="facade.updateDraft($event)" (save)="save()" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}
|
||||||
|
@if (facade.mutationError()) {
|
||||||
|
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
|
||||||
|
<p>{{ 'common.errorDescription' | translate }}</p>
|
||||||
|
<div class="app-confirm-dialog__actions">
|
||||||
|
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</app-dialog>
|
||||||
|
}`,
|
||||||
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`],
|
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
@@ -33,7 +43,8 @@ export class AdminProductEditorPageComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
save(): void {
|
save(): void {
|
||||||
this.facade.saveDraft();
|
this.facade.saveDraft(() => {
|
||||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']);
|
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { AdminProductsFacade } from '../facade/admin-products.facade';
|
import { AdminProductsFacade } from '../facade/admin-products.facade';
|
||||||
import { AdminProductsListComponent } from '../components/admin-products-list.component';
|
import { AdminProductsListComponent } from '../components/admin-products-list.component';
|
||||||
import { LanguageService } from '../../../../services/language.service';
|
import { LanguageService } from '../../../../services/language.service';
|
||||||
|
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||||
|
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
|
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||||
|
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-products-list-page',
|
selector: 'app-admin-products-list-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [AdminProductsListComponent],
|
imports: [AdminProductsListComponent, ConfirmDialogComponent, TranslatePipe, DialogComponent, ButtonComponent],
|
||||||
template: `<app-admin-products-list
|
template: `<app-admin-products-list
|
||||||
[products]="facade.products()"
|
[products]="facade.products()"
|
||||||
[categories]="facade.categories()"
|
[categories]="facade.categories()"
|
||||||
@@ -15,6 +19,7 @@ import { LanguageService } from '../../../../services/language.service';
|
|||||||
[total]="facade.total()"
|
[total]="facade.total()"
|
||||||
[selectedIds]="facade.selectedIds()"
|
[selectedIds]="facade.selectedIds()"
|
||||||
[loading]="facade.loading()"
|
[loading]="facade.loading()"
|
||||||
|
[error]="facade.error()"
|
||||||
[infiniteScroll]="facade.infiniteScroll()"
|
[infiniteScroll]="facade.infiniteScroll()"
|
||||||
[viewMode]="facade.viewMode()"
|
[viewMode]="facade.viewMode()"
|
||||||
[density]="facade.density()"
|
[density]="facade.density()"
|
||||||
@@ -25,7 +30,7 @@ import { LanguageService } from '../../../../services/language.service';
|
|||||||
(create)="create()"
|
(create)="create()"
|
||||||
(edit)="edit($event)"
|
(edit)="edit($event)"
|
||||||
(duplicate)="duplicate($event)"
|
(duplicate)="duplicate($event)"
|
||||||
(delete)="facade.deleteOne($event)"
|
(delete)="deleteOne($event)"
|
||||||
(archive)="facade.archiveOne($event)"
|
(archive)="facade.archiveOne($event)"
|
||||||
(restore)="facade.restoreOne($event)"
|
(restore)="facade.restoreOne($event)"
|
||||||
(loadMore)="facade.loadMore()"
|
(loadMore)="facade.loadMore()"
|
||||||
@@ -33,14 +38,36 @@ import { LanguageService } from '../../../../services/language.service';
|
|||||||
(selectionChange)="facade.toggleSelection($event.id, $event.checked)"
|
(selectionChange)="facade.toggleSelection($event.id, $event.checked)"
|
||||||
(selectAll)="facade.toggleAll($event)"
|
(selectAll)="facade.toggleAll($event)"
|
||||||
(bulkVisibility)="facade.applyBulkVisibility($event)"
|
(bulkVisibility)="facade.applyBulkVisibility($event)"
|
||||||
(bulkDelete)="facade.applyBulkDelete()"
|
(bulkDelete)="bulkDelete()"
|
||||||
(bulkDuplicate)="facade.applyBulkDuplicate()"
|
(bulkDuplicate)="facade.applyBulkDuplicate()"
|
||||||
(bulkAssignCategory)="facade.applyBulkAssignCategory($event)"
|
(bulkAssignCategory)="facade.applyBulkAssignCategory($event)"
|
||||||
(bulkAssignTags)="facade.applyBulkAssignTags($event)"
|
(bulkAssignTags)="facade.applyBulkAssignTags($event)"
|
||||||
(bulkExport)="facade.exportSelectedAsCsv()"
|
(bulkExport)="facade.exportSelectedAsCsv()"
|
||||||
(viewModeChange)="facade.setViewMode($event)"
|
(viewModeChange)="facade.setViewMode($event)"
|
||||||
(densityChange)="facade.setDensity($event)"
|
(densityChange)="facade.setDensity($event)"
|
||||||
(columnToggle)="facade.setColumnVisible($event.column, $event.visible)" />`,
|
(columnToggle)="facade.setColumnVisible($event.column, $event.visible)" />
|
||||||
|
<app-confirm-dialog
|
||||||
|
[open]="!!pendingDeleteId()"
|
||||||
|
[titleText]="'adminProducts.deleteTitle' | translate"
|
||||||
|
[message]="'adminProducts.confirmDelete' | translate"
|
||||||
|
[destructive]="true"
|
||||||
|
(confirmed)="confirmDelete()"
|
||||||
|
(cancelled)="pendingDeleteId.set(null)" />
|
||||||
|
<app-confirm-dialog
|
||||||
|
[open]="bulkDeleteConfirmOpen()"
|
||||||
|
[titleText]="'adminProducts.deleteTitle' | translate"
|
||||||
|
[message]="'adminProducts.confirmBulkDelete' | translate"
|
||||||
|
[destructive]="true"
|
||||||
|
(confirmed)="confirmBulkDelete()"
|
||||||
|
(cancelled)="bulkDeleteConfirmOpen.set(false)" />
|
||||||
|
@if (facade.mutationError()) {
|
||||||
|
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
|
||||||
|
<p>{{ 'common.errorDescription' | translate }}</p>
|
||||||
|
<div class="app-confirm-dialog__actions">
|
||||||
|
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</app-dialog>
|
||||||
|
}`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class AdminProductsListPageComponent {
|
export class AdminProductsListPageComponent {
|
||||||
@@ -50,6 +77,9 @@ export class AdminProductsListPageComponent {
|
|||||||
|
|
||||||
readonly healthFn = (product: Parameters<AdminProductsFacade['health']>[0]) => this.facade.health(product);
|
readonly healthFn = (product: Parameters<AdminProductsFacade['health']>[0]) => this.facade.health(product);
|
||||||
|
|
||||||
|
readonly pendingDeleteId = signal<string | null>(null);
|
||||||
|
readonly bulkDeleteConfirmOpen = signal(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.facade.loadCategories();
|
this.facade.loadCategories();
|
||||||
this.facade.loadList();
|
this.facade.loadList();
|
||||||
@@ -60,6 +90,27 @@ export class AdminProductsListPageComponent {
|
|||||||
edit(id: string): void { this.facade.loadForEdit(id, 'edit'); void this.router.navigate([this.lang(), 'backoffice', 'products', id, 'edit']); }
|
edit(id: string): void { this.facade.loadForEdit(id, 'edit'); void this.router.navigate([this.lang(), 'backoffice', 'products', id, 'edit']); }
|
||||||
duplicate(id: string): void { this.facade.loadForEdit(id, 'duplicate'); void this.router.navigate([this.lang(), 'backoffice', 'products', id, 'duplicate']); }
|
duplicate(id: string): void { this.facade.loadForEdit(id, 'duplicate'); void this.router.navigate([this.lang(), 'backoffice', 'products', id, 'duplicate']); }
|
||||||
|
|
||||||
|
deleteOne(id: string): void {
|
||||||
|
this.pendingDeleteId.set(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDelete(): void {
|
||||||
|
const id = this.pendingDeleteId();
|
||||||
|
if (id) {
|
||||||
|
this.facade.deleteOne(id);
|
||||||
|
}
|
||||||
|
this.pendingDeleteId.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
bulkDelete(): void {
|
||||||
|
this.bulkDeleteConfirmOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmBulkDelete(): void {
|
||||||
|
this.facade.applyBulkDelete();
|
||||||
|
this.bulkDeleteConfirmOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
private lang(): string {
|
private lang(): string {
|
||||||
return this.languageService.currentLanguage();
|
return this.languageService.currentLanguage();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
}
|
}
|
||||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
} @else if (facade.error()) {
|
||||||
|
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
|
||||||
} @else {
|
} @else {
|
||||||
<div class="report-grid">
|
<div class="report-grid">
|
||||||
<div class="report-card">
|
<div class="report-card">
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { AdminAnalyticsFacade } from '../../analytics/facade/admin-analytics.fac
|
|||||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||||
|
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-reports-page',
|
selector: 'app-admin-reports-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [TranslatePipe, ButtonComponent, SkeletonComponent],
|
imports: [TranslatePipe, ButtonComponent, SkeletonComponent, EmptyStateComponent],
|
||||||
templateUrl: './admin-reports-page.component.html',
|
templateUrl: './admin-reports-page.component.html',
|
||||||
styleUrls: ['./admin-reports-page.component.scss'],
|
styleUrls: ['./admin-reports-page.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ export class AdminTransactionsFacade {
|
|||||||
readonly transactions = signal<AdminTransaction[]>([]);
|
readonly transactions = signal<AdminTransaction[]>([]);
|
||||||
readonly total = signal(0);
|
readonly total = signal(0);
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
|
readonly error = signal<string | null>(null);
|
||||||
|
|
||||||
loadList(): void {
|
loadList(): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
this.error.set(null);
|
||||||
this.gateway.loadTransactions(this.filters()).pipe(take(1)).subscribe({
|
this.gateway.loadTransactions(this.filters()).pipe(take(1)).subscribe({
|
||||||
next: result => {
|
next: result => {
|
||||||
this.transactions.set(result.items);
|
this.transactions.set(result.items);
|
||||||
@@ -24,6 +26,7 @@ export class AdminTransactionsFacade {
|
|||||||
this.transactions.set([]);
|
this.transactions.set([]);
|
||||||
this.total.set(0);
|
this.total.set(0);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
this.error.set('common.errorDescription');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
||||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
} @else if (facade.error()) {
|
||||||
|
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
|
||||||
} @else if (facade.transactions().length === 0) {
|
} @else if (facade.transactions().length === 0) {
|
||||||
<app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" />
|
<app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" />
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { delay } from 'rxjs/operators';
|
import { delay } from 'rxjs/operators';
|
||||||
import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model';
|
import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model';
|
||||||
import { AdminTransactionsGateway } from './admin-transactions-gateway.interface';
|
import { AdminTransactionsGateway } from './admin-transactions-gateway.interface';
|
||||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||||
|
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
|
||||||
|
|
||||||
const METHODS = ['card', 'qr', 'cash_on_delivery'];
|
const METHODS = ['card', 'qr', 'cash_on_delivery'];
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
|
export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
|
||||||
|
private readonly adminAuth = inject(AdminAuthService);
|
||||||
private cache: AdminTransaction[] | null = null;
|
private cache: AdminTransaction[] | null = null;
|
||||||
|
|
||||||
constructor(private readonly ordersGateway: AdminOrdersLocalGateway) {}
|
constructor(private readonly ordersGateway: AdminOrdersLocalGateway) {}
|
||||||
|
|
||||||
|
private get currentActor(): string {
|
||||||
|
return this.adminAuth.displayName() ?? 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult> {
|
loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult> {
|
||||||
return new Observable<AdminTransactionsListResult>(subscriber => {
|
return new Observable<AdminTransactionsListResult>(subscriber => {
|
||||||
this.ensureData().then(() => {
|
this.ensureData().then(() => {
|
||||||
@@ -38,7 +44,7 @@ export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
|
|||||||
...tx,
|
...tx,
|
||||||
status: 'retried',
|
status: 'retried',
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
audit: [...tx.audit, { action: 'Retried failed transaction', actor: 'admin', timestamp: new Date().toISOString() }],
|
audit: [...tx.audit, { action: 'Retried failed transaction', actor: this.currentActor, timestamp: new Date().toISOString() }],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +53,7 @@ export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
|
|||||||
...tx,
|
...tx,
|
||||||
fraudFlag: flagged,
|
fraudFlag: flagged,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
audit: [...tx.audit, { action: flagged ? 'Flagged as fraud' : 'Fraud flag cleared', actor: 'admin', timestamp: new Date().toISOString() }],
|
audit: [...tx.audit, { action: flagged ? 'Flagged as fraud' : 'Fraud flag cleared', actor: this.currentActor, timestamp: new Date().toISOString() }],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, inject, signal } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
import { take } from 'rxjs/operators';
|
import { take } from 'rxjs/operators';
|
||||||
import { AdminInvitation, AdminRole, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
|
import { AdminInvitation, AdminUserRoleRecord, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
|
||||||
import { AdminUsersLocalGateway } from '../services/admin-users-local.gateway';
|
import { AdminUsersLocalGateway } from '../services/admin-users-local.gateway';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -8,10 +8,11 @@ export class AdminUsersFacade {
|
|||||||
private readonly gateway = inject(AdminUsersLocalGateway);
|
private readonly gateway = inject(AdminUsersLocalGateway);
|
||||||
|
|
||||||
readonly users = signal<AdminUser[]>([]);
|
readonly users = signal<AdminUser[]>([]);
|
||||||
readonly roles = signal<AdminRole[]>([]);
|
readonly roles = signal<AdminUserRoleRecord[]>([]);
|
||||||
readonly invitations = signal<AdminInvitation[]>([]);
|
readonly invitations = signal<AdminInvitation[]>([]);
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
readonly error = signal(false);
|
readonly error = signal(false);
|
||||||
|
readonly mutationError = signal<string | null>(null);
|
||||||
readonly sessionsTarget = signal<AdminUser | null>(null);
|
readonly sessionsTarget = signal<AdminUser | null>(null);
|
||||||
readonly sessions = signal<AdminSession[]>([]);
|
readonly sessions = signal<AdminSession[]>([]);
|
||||||
readonly auditTarget = signal<AdminUser | null>(null);
|
readonly auditTarget = signal<AdminUser | null>(null);
|
||||||
@@ -33,16 +34,28 @@ export class AdminUsersFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setRole(userId: string, roleId: string): void {
|
setRole(userId: string, roleId: string): void {
|
||||||
this.gateway.setUserRole(userId, roleId).pipe(take(1)).subscribe({ next: () => this.loadAll() });
|
this.mutationError.set(null);
|
||||||
|
this.gateway.setUserRole(userId, roleId).pipe(take(1)).subscribe({
|
||||||
|
next: () => this.loadAll(),
|
||||||
|
error: () => this.mutationError.set('common.errorDescription')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus(userId: string, status: AdminUserStatus): void {
|
setStatus(userId: string, status: AdminUserStatus): void {
|
||||||
this.gateway.setUserStatus(userId, status).pipe(take(1)).subscribe({ next: () => this.loadAll() });
|
this.mutationError.set(null);
|
||||||
|
this.gateway.setUserStatus(userId, status).pipe(take(1)).subscribe({
|
||||||
|
next: () => this.loadAll(),
|
||||||
|
error: () => this.mutationError.set('common.errorDescription')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
invite(email: string, roleId: string, scope: AdminUserScope): void {
|
invite(email: string, roleId: string, scope: AdminUserScope): void {
|
||||||
if (!email.trim()) return;
|
if (!email.trim()) return;
|
||||||
this.gateway.inviteUser(email.trim(), roleId, scope).pipe(take(1)).subscribe({ next: () => this.loadAll() });
|
this.mutationError.set(null);
|
||||||
|
this.gateway.inviteUser(email.trim(), roleId, scope).pipe(take(1)).subscribe({
|
||||||
|
next: () => this.loadAll(),
|
||||||
|
error: () => this.mutationError.set('common.errorDescription')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
revokeInvitation(id: string): void {
|
revokeInvitation(id: string): void {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ export type AdminUserScope = 'marketplace' | 'office';
|
|||||||
export type AdminUserStatus = 'active' | 'invited' | 'suspended';
|
export type AdminUserStatus = 'active' | 'invited' | 'suspended';
|
||||||
export type AdminInvitationStatus = 'pending' | 'accepted' | 'expired' | 'revoked';
|
export type AdminInvitationStatus = 'pending' | 'accepted' | 'expired' | 'revoked';
|
||||||
|
|
||||||
export interface AdminRole {
|
/** Users-admin display/permissions shape - unrelated to core/auth/models/permission.model.ts's AdminRole (the real JWT/auth role union). */
|
||||||
|
export interface AdminUserRoleRecord {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
|
|||||||
@@ -120,4 +120,21 @@
|
|||||||
<p>{{ entry.timestamp | date:'short' }} — {{ ('adminUsers.actor.' + entry.actor) | translate }} — {{ auditText(entry) }}</p>
|
<p>{{ entry.timestamp | date:'short' }} — {{ ('adminUsers.actor.' + entry.actor) | translate }} — {{ auditText(entry) }}</p>
|
||||||
}
|
}
|
||||||
</app-dialog>
|
</app-dialog>
|
||||||
|
|
||||||
|
<app-confirm-dialog
|
||||||
|
[open]="!!pendingSuspendUserId()"
|
||||||
|
[titleText]="'adminUsers.suspend' | translate"
|
||||||
|
[message]="'adminUsers.confirmSuspend' | translate"
|
||||||
|
[destructive]="true"
|
||||||
|
(confirmed)="confirmSuspend()"
|
||||||
|
(cancelled)="pendingSuspendUserId.set(null)" />
|
||||||
|
|
||||||
|
@if (facade.mutationError()) {
|
||||||
|
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
|
||||||
|
<p>{{ 'common.errorDescription' | translate }}</p>
|
||||||
|
<div class="app-confirm-dialog__actions">
|
||||||
|
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</app-dialog>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import { TableComponent } from '../../../../shared/ui/table/table.component';
|
|||||||
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||||
|
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-users-page',
|
selector: 'app-admin-users-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, DialogComponent, SkeletonComponent, EmptyStateComponent],
|
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, DialogComponent, SkeletonComponent, EmptyStateComponent, ConfirmDialogComponent],
|
||||||
templateUrl: './admin-users-page.component.html',
|
templateUrl: './admin-users-page.component.html',
|
||||||
styleUrls: ['./admin-users-page.component.scss'],
|
styleUrls: ['./admin-users-page.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -28,6 +29,7 @@ export class AdminUsersPageComponent {
|
|||||||
readonly inviteEmail = signal('');
|
readonly inviteEmail = signal('');
|
||||||
readonly inviteRoleId = signal('viewer');
|
readonly inviteRoleId = signal('viewer');
|
||||||
readonly inviteScope = signal<AdminUserScope>('office');
|
readonly inviteScope = signal<AdminUserScope>('office');
|
||||||
|
readonly pendingSuspendUserId = signal<string | null>(null);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.facade.loadAll();
|
this.facade.loadAll();
|
||||||
@@ -40,12 +42,21 @@ export class AdminUsersPageComponent {
|
|||||||
|
|
||||||
toggleStatus(userId: string, current: AdminUserStatus): void {
|
toggleStatus(userId: string, current: AdminUserStatus): void {
|
||||||
const next: AdminUserStatus = current === 'suspended' ? 'active' : 'suspended';
|
const next: AdminUserStatus = current === 'suspended' ? 'active' : 'suspended';
|
||||||
if (next === 'suspended' && !window.confirm(this.translate.t('adminUsers.confirmSuspend'))) {
|
if (next === 'suspended') {
|
||||||
|
this.pendingSuspendUserId.set(userId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.facade.setStatus(userId, next);
|
this.facade.setStatus(userId, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
confirmSuspend(): void {
|
||||||
|
const userId = this.pendingSuspendUserId();
|
||||||
|
if (userId) {
|
||||||
|
this.facade.setStatus(userId, 'suspended');
|
||||||
|
}
|
||||||
|
this.pendingSuspendUserId.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly PERMISSION_KEYS: Record<string, string> = {
|
private static readonly PERMISSION_KEYS: Record<string, string> = {
|
||||||
'*': 'all',
|
'*': 'all',
|
||||||
'products.manage': 'productsManage',
|
'products.manage': 'productsManage',
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { AdminInvitation, AdminRole, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
|
import { AdminInvitation, AdminUserRoleRecord, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
|
||||||
|
|
||||||
export interface AdminUsersGateway {
|
export interface AdminUsersGateway {
|
||||||
loadUsers(): Observable<AdminUser[]>;
|
loadUsers(): Observable<AdminUser[]>;
|
||||||
loadRoles(): Observable<AdminRole[]>;
|
loadRoles(): Observable<AdminUserRoleRecord[]>;
|
||||||
loadInvitations(): Observable<AdminInvitation[]>;
|
loadInvitations(): Observable<AdminInvitation[]>;
|
||||||
loadSessions(userId: string): Observable<AdminSession[]>;
|
loadSessions(userId: string): Observable<AdminSession[]>;
|
||||||
loadAudit(userId: string): Observable<AdminUserAuditEntry[]>;
|
loadAudit(userId: string): Observable<AdminUserAuditEntry[]>;
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Observable, of } from 'rxjs';
|
import { Observable, of } from 'rxjs';
|
||||||
import { delay } from 'rxjs/operators';
|
import { delay } from 'rxjs/operators';
|
||||||
import { AdminInvitation, AdminRole, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
|
import { AdminInvitation, AdminUserRoleRecord, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
|
||||||
import { AdminUsersGateway } from './admin-users-gateway.interface';
|
import { AdminUsersGateway } from './admin-users-gateway.interface';
|
||||||
|
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
|
||||||
|
|
||||||
const BUILT_IN_ROLES: AdminRole[] = [
|
const BUILT_IN_ROLES: AdminUserRoleRecord[] = [
|
||||||
{ id: 'owner', name: 'Owner', permissions: ['*'], builtIn: true },
|
{ id: 'owner', name: 'Owner', permissions: ['*'], builtIn: true },
|
||||||
{ id: 'admin', name: 'Admin', permissions: ['products.manage', 'categories.manage', 'orders.manage', 'media.manage'], builtIn: true },
|
{ id: 'admin', name: 'Admin', permissions: ['products.manage', 'categories.manage', 'orders.manage', 'media.manage', 'users.manage'], builtIn: true },
|
||||||
{ id: 'editor', name: 'Editor', permissions: ['products.manage', 'categories.manage', 'media.manage'], builtIn: true },
|
{ id: 'editor', name: 'Editor', permissions: ['products.manage', 'categories.manage', 'media.manage'], builtIn: true },
|
||||||
{ id: 'viewer', name: 'Viewer', permissions: ['products.view', 'orders.view'], builtIn: true },
|
{ id: 'viewer', name: 'Viewer', permissions: ['products.view', 'orders.view'], builtIn: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AdminUsersLocalGateway implements AdminUsersGateway {
|
export class AdminUsersLocalGateway implements AdminUsersGateway {
|
||||||
|
private readonly adminAuth = inject(AdminAuthService);
|
||||||
private users: AdminUser[] | null = null;
|
private users: AdminUser[] | null = null;
|
||||||
private roles: AdminRole[] = [...BUILT_IN_ROLES];
|
private roles: AdminUserRoleRecord[] = [...BUILT_IN_ROLES];
|
||||||
private invitations: AdminInvitation[] = [];
|
private invitations: AdminInvitation[] = [];
|
||||||
private sessions: Record<string, AdminSession[]> = {};
|
private sessions: Record<string, AdminSession[]> = {};
|
||||||
private audit: Record<string, AdminUserAuditEntry[]> = {};
|
private audit: Record<string, AdminUserAuditEntry[]> = {};
|
||||||
@@ -23,7 +25,7 @@ export class AdminUsersLocalGateway implements AdminUsersGateway {
|
|||||||
return of(this.ensureUsers()).pipe(delay(50));
|
return of(this.ensureUsers()).pipe(delay(50));
|
||||||
}
|
}
|
||||||
|
|
||||||
loadRoles(): Observable<AdminRole[]> {
|
loadRoles(): Observable<AdminUserRoleRecord[]> {
|
||||||
return of(this.roles).pipe(delay(50));
|
return of(this.roles).pipe(delay(50));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,12 +92,16 @@ export class AdminUsersLocalGateway implements AdminUsersGateway {
|
|||||||
this.audit[userId] = [
|
this.audit[userId] = [
|
||||||
...(this.audit[userId] ?? []),
|
...(this.audit[userId] ?? []),
|
||||||
roleId
|
roleId
|
||||||
? { eventKey: 'roleChanged', roleId, actor: 'admin', timestamp: new Date().toISOString() }
|
? { eventKey: 'roleChanged', roleId, actor: this.currentActor, timestamp: new Date().toISOString() }
|
||||||
: { eventKey: 'statusChanged', status, actor: 'admin', timestamp: new Date().toISOString() },
|
: { eventKey: 'statusChanged', status, actor: this.currentActor, timestamp: new Date().toISOString() },
|
||||||
];
|
];
|
||||||
return of(updated).pipe(delay(50));
|
return of(updated).pipe(delay(50));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get currentActor(): string {
|
||||||
|
return this.adminAuth.displayName() ?? 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
private ensureUsers(): AdminUser[] {
|
private ensureUsers(): AdminUser[] {
|
||||||
if (!this.users) {
|
if (!this.users) {
|
||||||
this.users = [
|
this.users = [
|
||||||
|
|||||||
@@ -55,40 +55,47 @@ export class SearchFacade {
|
|||||||
|
|
||||||
readonly state = this.store.state;
|
readonly state = this.store.state;
|
||||||
|
|
||||||
readonly popularSearches: SearchSuggestion[] = [
|
/**
|
||||||
{
|
* Search query text stays the stable English canonical term (what the
|
||||||
id: 'popular-smartphones',
|
* backend search index matches against); only the displayed title/text
|
||||||
type: 'collection',
|
* are translated.
|
||||||
title: 'Smartphones',
|
*/
|
||||||
text: 'Smartphones',
|
get popularSearches(): SearchSuggestion[] {
|
||||||
icon: 'trendingUp',
|
return [
|
||||||
target: { route: '/search', query: { q: 'Smartphones' } }
|
{
|
||||||
},
|
id: 'popular-smartphones',
|
||||||
{
|
type: 'collection',
|
||||||
id: 'popular-sneakers',
|
title: this.translate.t('search.popularSmartphones'),
|
||||||
type: 'collection',
|
text: this.translate.t('search.popularSmartphones'),
|
||||||
title: 'Sneakers',
|
icon: 'trendingUp',
|
||||||
text: 'Sneakers',
|
target: { route: '/search', query: { q: 'Smartphones' } }
|
||||||
icon: 'trendingUp',
|
},
|
||||||
target: { route: '/search', query: { q: 'Sneakers' } }
|
{
|
||||||
},
|
id: 'popular-sneakers',
|
||||||
{
|
type: 'collection',
|
||||||
id: 'popular-headphones',
|
title: this.translate.t('search.popularSneakers'),
|
||||||
type: 'collection',
|
text: this.translate.t('search.popularSneakers'),
|
||||||
title: 'Headphones',
|
icon: 'trendingUp',
|
||||||
text: 'Headphones',
|
target: { route: '/search', query: { q: 'Sneakers' } }
|
||||||
icon: 'trendingUp',
|
},
|
||||||
target: { route: '/search', query: { q: 'Headphones' } }
|
{
|
||||||
},
|
id: 'popular-headphones',
|
||||||
{
|
type: 'collection',
|
||||||
id: 'popular-laptops',
|
title: this.translate.t('search.popularHeadphones'),
|
||||||
type: 'collection',
|
text: this.translate.t('search.popularHeadphones'),
|
||||||
title: 'Laptops',
|
icon: 'trendingUp',
|
||||||
text: 'Laptops',
|
target: { route: '/search', query: { q: 'Headphones' } }
|
||||||
icon: 'trendingUp',
|
},
|
||||||
target: { route: '/search', query: { q: 'Laptops' } }
|
{
|
||||||
},
|
id: 'popular-laptops',
|
||||||
];
|
type: 'collection',
|
||||||
|
title: this.translate.t('search.popularLaptops'),
|
||||||
|
text: this.translate.t('search.popularLaptops'),
|
||||||
|
icon: 'trendingUp',
|
||||||
|
target: { route: '/search', query: { q: 'Laptops' } }
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const history = this.historyService.getSnapshot();
|
const history = this.historyService.getSnapshot();
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, Output, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model';
|
import { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model';
|
||||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
|
|
||||||
|
const RANGE_DEBOUNCE_MS = 350;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-catalog-filters-panel',
|
selector: 'app-catalog-filters-panel',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -11,12 +13,20 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
|||||||
styleUrls: ['./filters-panel.component.scss'],
|
styleUrls: ['./filters-panel.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class CatalogFiltersPanelComponent {
|
export class CatalogFiltersPanelComponent implements OnDestroy {
|
||||||
@Input() definitions: FilterGroup[] = [];
|
@Input() definitions: FilterGroup[] = [];
|
||||||
@Input() state: SearchFilterState = { values: {}, ranges: {}, toggles: {} };
|
@Input() state: SearchFilterState = { values: {}, ranges: {}, toggles: {} };
|
||||||
|
|
||||||
@Output() stateChange = new EventEmitter<SearchFilterState>();
|
@Output() stateChange = new EventEmitter<SearchFilterState>();
|
||||||
|
|
||||||
|
private readonly debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
for (const timer of this.debounceTimers.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
readonly collapsed = signal<Record<string, boolean>>({});
|
readonly collapsed = signal<Record<string, boolean>>({});
|
||||||
|
|
||||||
toggleGroup(filterId: string): void {
|
toggleGroup(filterId: string): void {
|
||||||
@@ -79,34 +89,51 @@ export class CatalogFiltersPanelComponent {
|
|||||||
|
|
||||||
updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void {
|
updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void {
|
||||||
const value = rawValue.trim().length ? Number(rawValue) : undefined;
|
const value = rawValue.trim().length ? Number(rawValue) : undefined;
|
||||||
const current = this.state.ranges[filterId] ?? {};
|
|
||||||
|
|
||||||
this.stateChange.emit({
|
this.debounce(`range:${filterId}:${key}`, () => {
|
||||||
...this.state,
|
const current = this.state.ranges[filterId] ?? {};
|
||||||
ranges: {
|
this.stateChange.emit({
|
||||||
...this.state.ranges,
|
...this.state,
|
||||||
[filterId]: {
|
ranges: {
|
||||||
...current,
|
...this.state.ranges,
|
||||||
[key]: Number.isFinite(value as number) ? value : undefined
|
[filterId]: {
|
||||||
|
...current,
|
||||||
|
[key]: Number.isFinite(value as number) ? value : undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSlider(filterId: string, value: string): void {
|
updateSlider(filterId: string, value: string): void {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
this.stateChange.emit({
|
|
||||||
...this.state,
|
this.debounce(`slider:${filterId}`, () => {
|
||||||
ranges: {
|
this.stateChange.emit({
|
||||||
...this.state.ranges,
|
...this.state,
|
||||||
[filterId]: {
|
ranges: {
|
||||||
...this.state.ranges[filterId],
|
...this.state.ranges,
|
||||||
max: Number.isFinite(numeric) ? numeric : undefined,
|
[filterId]: {
|
||||||
|
...this.state.ranges[filterId],
|
||||||
|
max: Number.isFinite(numeric) ? numeric : undefined,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Debounces range/slider input so full catalog filter recompute doesn't run on every keystroke/drag event. */
|
||||||
|
private debounce(key: string, action: () => void): void {
|
||||||
|
const existing = this.debounceTimers.get(key);
|
||||||
|
if (existing) {
|
||||||
|
clearTimeout(existing);
|
||||||
|
}
|
||||||
|
this.debounceTimers.set(key, setTimeout(() => {
|
||||||
|
this.debounceTimers.delete(key);
|
||||||
|
action();
|
||||||
|
}, RANGE_DEBOUNCE_MS));
|
||||||
|
}
|
||||||
|
|
||||||
updateToggle(filterId: string, checked: boolean): void {
|
updateToggle(filterId: string, checked: boolean): void {
|
||||||
this.stateChange.emit({
|
this.stateChange.emit({
|
||||||
...this.state,
|
...this.state,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
(selected)="productSelected.emit(product)"
|
(selected)="productSelected.emit(product)"
|
||||||
(addToCart)="onAddToCart(product, $event.event)"
|
(addToCart)="onAddToCart(product, $event.event)"
|
||||||
(preview)="productPreview.emit($event)"
|
(preview)="productPreview.emit($event)"
|
||||||
|
(quickViewPlaceholder)="quickView.emit($event)"
|
||||||
(favoriteToggled)="favoriteToggled.emit(product)"
|
(favoriteToggled)="favoriteToggled.emit(product)"
|
||||||
(compareToggled)="compareToggled.emit(product)"
|
(compareToggled)="compareToggled.emit(product)"
|
||||||
(shareRequested)="shareRequested.emit(product)"
|
(shareRequested)="shareRequested.emit(product)"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class CatalogProductGridComponent {
|
|||||||
@Output() productSelected = new EventEmitter<Product>();
|
@Output() productSelected = new EventEmitter<Product>();
|
||||||
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
||||||
@Output() productPreview = new EventEmitter<number>();
|
@Output() productPreview = new EventEmitter<number>();
|
||||||
|
@Output() quickView = new EventEmitter<number>();
|
||||||
@Output() favoriteToggled = new EventEmitter<Product>();
|
@Output() favoriteToggled = new EventEmitter<Product>();
|
||||||
@Output() compareToggled = new EventEmitter<Product>();
|
@Output() compareToggled = new EventEmitter<Product>();
|
||||||
@Output() shareRequested = new EventEmitter<Product>();
|
@Output() shareRequested = new EventEmitter<Product>();
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
(productSelected)="productSelected.emit($event)"
|
(productSelected)="productSelected.emit($event)"
|
||||||
(addToCart)="addToCart.emit($event)"
|
(addToCart)="addToCart.emit($event)"
|
||||||
(productPreview)="productPreview.emit($event)"
|
(productPreview)="productPreview.emit($event)"
|
||||||
|
(quickView)="quickView.emit($event)"
|
||||||
(favoriteToggled)="favoriteToggled.emit($event)"
|
(favoriteToggled)="favoriteToggled.emit($event)"
|
||||||
(compareToggled)="compareToggled.emit($event)"
|
(compareToggled)="compareToggled.emit($event)"
|
||||||
(shareRequested)="shareRequested.emit($event)" />
|
(shareRequested)="shareRequested.emit($event)" />
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export class CatalogSearchResultsComponent {
|
|||||||
@Output() productSelected = new EventEmitter<Product>();
|
@Output() productSelected = new EventEmitter<Product>();
|
||||||
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
||||||
@Output() productPreview = new EventEmitter<number>();
|
@Output() productPreview = new EventEmitter<number>();
|
||||||
|
@Output() quickView = new EventEmitter<number>();
|
||||||
@Output() favoriteToggled = new EventEmitter<Product>();
|
@Output() favoriteToggled = new EventEmitter<Product>();
|
||||||
@Output() compareToggled = new EventEmitter<Product>();
|
@Output() compareToggled = new EventEmitter<Product>();
|
||||||
@Output() shareRequested = new EventEmitter<Product>();
|
@Output() shareRequested = new EventEmitter<Product>();
|
||||||
|
|||||||
@@ -229,6 +229,7 @@
|
|||||||
(productSelected)="selectProduct($event)"
|
(productSelected)="selectProduct($event)"
|
||||||
(addToCart)="addToCart($event)"
|
(addToCart)="addToCart($event)"
|
||||||
(productPreview)="previewProduct($event)"
|
(productPreview)="previewProduct($event)"
|
||||||
|
(quickView)="openQuickView($event)"
|
||||||
(favoriteToggled)="onFavoriteToggled($event)"
|
(favoriteToggled)="onFavoriteToggled($event)"
|
||||||
(compareToggled)="onCompareToggled($event)"
|
(compareToggled)="onCompareToggled($event)"
|
||||||
(shareRequested)="onShareRequested($event)" />
|
(shareRequested)="onShareRequested($event)" />
|
||||||
@@ -295,3 +296,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<app-quick-view-dialog
|
||||||
|
[product]="quickViewProduct()"
|
||||||
|
[loading]="quickViewLoading()"
|
||||||
|
(closed)="closeQuickView()"
|
||||||
|
(addToCart)="addQuickViewToCart($event)" />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Product } from '../../../../core/products/models/product-domain.model';
|
|||||||
import { ConfigService } from '../../../../core/config/config.service';
|
import { ConfigService } from '../../../../core/config/config.service';
|
||||||
import { FeatureConfigService } from '../../../../core/config/feature-config.service';
|
import { FeatureConfigService } from '../../../../core/config/feature-config.service';
|
||||||
import { CategoryFacade } from '../../../../facades/platform/category.facade';
|
import { CategoryFacade } from '../../../../facades/platform/category.facade';
|
||||||
|
import { ProductFacade } from '../../../../facades/platform/product.facade';
|
||||||
import { SearchFacade } from '../../../../facades/platform/search.facade';
|
import { SearchFacade } from '../../../../facades/platform/search.facade';
|
||||||
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
|
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
|
||||||
import { CartService } from '../../../../services';
|
import { CartService } from '../../../../services';
|
||||||
@@ -32,6 +33,7 @@ import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-sta
|
|||||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||||
import { IconComponent } from '../../../../shared/ui/icon/icon.component';
|
import { IconComponent } from '../../../../shared/ui/icon/icon.component';
|
||||||
|
import { QuickViewDialogComponent } from '../../product/components/quick-view-dialog/quick-view-dialog.component';
|
||||||
import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model';
|
import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model';
|
||||||
import { ProductShareService } from '../../user-experience/services/product-share.service';
|
import { ProductShareService } from '../../user-experience/services/product-share.service';
|
||||||
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
|
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
|
||||||
@@ -56,7 +58,8 @@ type CatalogLoadingStrategy = 'pagination' | 'loadMore' | 'infiniteScroll';
|
|||||||
EmptyStateComponent,
|
EmptyStateComponent,
|
||||||
SkeletonComponent,
|
SkeletonComponent,
|
||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
IconComponent
|
IconComponent,
|
||||||
|
QuickViewDialogComponent
|
||||||
],
|
],
|
||||||
templateUrl: './catalog-container.component.html',
|
templateUrl: './catalog-container.component.html',
|
||||||
styleUrls: ['./catalog-container.component.scss'],
|
styleUrls: ['./catalog-container.component.scss'],
|
||||||
@@ -68,6 +71,7 @@ export class CatalogContainerComponent {
|
|||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly configService = inject(ConfigService);
|
private readonly configService = inject(ConfigService);
|
||||||
private readonly categoryFacade = inject(CategoryFacade);
|
private readonly categoryFacade = inject(CategoryFacade);
|
||||||
|
private readonly productFacade = inject(ProductFacade);
|
||||||
private readonly searchFacade = inject(SearchFacade);
|
private readonly searchFacade = inject(SearchFacade);
|
||||||
private readonly featureConfig = inject(FeatureConfigService);
|
private readonly featureConfig = inject(FeatureConfigService);
|
||||||
private readonly cartService = inject(CartService);
|
private readonly cartService = inject(CartService);
|
||||||
@@ -287,6 +291,31 @@ export class CatalogContainerComponent {
|
|||||||
this.prefetchService.prefetchItem(productId);
|
this.prefetchService.prefetchItem(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readonly quickViewProduct = signal<Product | null>(null);
|
||||||
|
readonly quickViewLoading = signal(false);
|
||||||
|
|
||||||
|
openQuickView(productId: number): void {
|
||||||
|
this.quickViewProduct.set(null);
|
||||||
|
this.quickViewLoading.set(true);
|
||||||
|
this.productFacade.getProduct(productId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||||
|
next: product => {
|
||||||
|
this.quickViewProduct.set(product);
|
||||||
|
this.quickViewLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => this.quickViewLoading.set(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeQuickView(): void {
|
||||||
|
this.quickViewProduct.set(null);
|
||||||
|
this.quickViewLoading.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
addQuickViewToCart(product: Product): void {
|
||||||
|
this.cartService.addItem(product.itemID);
|
||||||
|
this.closeQuickView();
|
||||||
|
}
|
||||||
|
|
||||||
retry(): void {
|
retry(): void {
|
||||||
this.enterCategory(this.state().category?.id ?? null);
|
this.enterCategory(this.state().category?.id ?? null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<app-dialog
|
||||||
|
[open]="!!product || loading"
|
||||||
|
[titleText]="'catalog.quickView' | translate"
|
||||||
|
size="md"
|
||||||
|
(closed)="closed.emit()"
|
||||||
|
>
|
||||||
|
@if (loading) {
|
||||||
|
<div class="quick-view__loading" role="status" aria-live="polite">{{ 'common.loading' | translate }}</div>
|
||||||
|
} @else if (product) {
|
||||||
|
<div class="quick-view">
|
||||||
|
<img class="quick-view__image" [src]="mainImage" [alt]="product.name" />
|
||||||
|
<div class="quick-view__body">
|
||||||
|
<h3 class="quick-view__title">{{ product.name }}</h3>
|
||||||
|
<p class="quick-view__price">
|
||||||
|
@if (hasDiscount) {
|
||||||
|
<span class="quick-view__price-original">{{ product.price | number:'1.2-2' }} {{ product.currency }}</span>
|
||||||
|
<span class="quick-view__price-final">{{ discountedPrice | number:'1.2-2' }} {{ product.currency }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="quick-view__price-final">{{ product.price | number:'1.2-2' }} {{ product.currency }}</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
@if (product.simpleDescription) {
|
||||||
|
<p class="quick-view__description">{{ product.simpleDescription }}</p>
|
||||||
|
}
|
||||||
|
<div class="quick-view__actions">
|
||||||
|
<app-button variant="primary" (click)="addToCart.emit(product)">{{ 'carousel.addToCart' | translate }}</app-button>
|
||||||
|
<a class="quick-view__link" [routerLink]="('/product/' + product.itemID) | langRoute" (click)="closed.emit()">
|
||||||
|
{{ 'catalog.quickViewDetails' | translate }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</app-dialog>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
.quick-view {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
background: var(--bg-secondary, #f3f3f3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__price {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__price-original {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__price-final {
|
||||||
|
font-weight: var(--font-weight-bold, 700);
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__link {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__loading {
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { DecimalPipe } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||||
|
import { DialogComponent } from '../../../../../shared/ui/dialog/dialog.component';
|
||||||
|
import { ButtonComponent } from '../../../../../shared/ui/button/button.component';
|
||||||
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
|
import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe';
|
||||||
|
import { getDiscountedPrice, getMainImage } from '../../../../../utils/item.utils';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-quick-view-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DialogComponent, ButtonComponent, TranslatePipe, LangRoutePipe, DecimalPipe, RouterLink],
|
||||||
|
templateUrl: './quick-view-dialog.component.html',
|
||||||
|
styleUrl: './quick-view-dialog.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class QuickViewDialogComponent {
|
||||||
|
@Input() product: Product | null = null;
|
||||||
|
@Input() loading = false;
|
||||||
|
|
||||||
|
@Output() closed = new EventEmitter<void>();
|
||||||
|
@Output() addToCart = new EventEmitter<Product>();
|
||||||
|
|
||||||
|
get mainImage(): string {
|
||||||
|
return this.product ? getMainImage(this.product) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
get discountedPrice(): number {
|
||||||
|
return this.product ? getDiscountedPrice(this.product) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasDiscount(): boolean {
|
||||||
|
return !!this.product?.discount && this.product.discount > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,13 @@ import { LanguageService } from '../../../../services/language.service';
|
|||||||
import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
|
import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
|
||||||
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
|
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
|
||||||
import { ProductShareService } from '../../user-experience/services/product-share.service';
|
import { ProductShareService } from '../../user-experience/services/product-share.service';
|
||||||
|
import { SeoService } from '../../../../services/seo.service';
|
||||||
|
import { ApiService } from '../../../../services/api.service';
|
||||||
|
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
||||||
|
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
|
||||||
|
import { AuthService } from '../../../../services/auth.service';
|
||||||
|
|
||||||
|
const RESTOCK_SUBSCRIPTIONS_KEY = 'restockSubscriptions';
|
||||||
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component';
|
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component';
|
||||||
import { ProductActionsComponent } from '../components/product-actions/product-actions.component';
|
import { ProductActionsComponent } from '../components/product-actions/product-actions.component';
|
||||||
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
|
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
|
||||||
@@ -70,6 +77,11 @@ export class ProductDetailsContainerComponent {
|
|||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
private readonly shareService = inject(ProductShareService);
|
private readonly shareService = inject(ProductShareService);
|
||||||
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly apiService = inject(ApiService);
|
||||||
|
private readonly storage = inject(LocalStorageService);
|
||||||
|
private readonly notifications = inject(UserNotificationService);
|
||||||
|
private readonly authService = inject(AuthService);
|
||||||
|
|
||||||
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
|
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
|
||||||
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
|
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
|
||||||
@@ -229,6 +241,8 @@ export class ProductDetailsContainerComponent {
|
|||||||
this.route.paramMap
|
this.route.paramMap
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
.subscribe(params => this.loadProduct(Number(params.get('id'))));
|
.subscribe(params => this.loadProduct(Number(params.get('id'))));
|
||||||
|
|
||||||
|
this.destroyRef.onDestroy(() => this.seoService.resetToDefaults());
|
||||||
}
|
}
|
||||||
|
|
||||||
loadProduct(productId: number): void {
|
loadProduct(productId: number): void {
|
||||||
@@ -261,6 +275,7 @@ export class ProductDetailsContainerComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.product.set(product);
|
this.product.set(product);
|
||||||
|
this.seoService.setItemMeta(product);
|
||||||
if (this.userExperienceConfig().recentlyViewed.enabled) {
|
if (this.userExperienceConfig().recentlyViewed.enabled) {
|
||||||
this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems);
|
this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems);
|
||||||
}
|
}
|
||||||
@@ -303,10 +318,10 @@ export class ProductDetailsContainerComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addToCart(): void {
|
addToCart(): Promise<void> {
|
||||||
const current = this.product();
|
const current = this.product();
|
||||||
if (!current) return;
|
if (!current) return Promise.resolve();
|
||||||
this.cartService.addItem(current.itemID, 1, {
|
return this.cartService.addItem(current.itemID, 1, {
|
||||||
colour: this.selectedColour() ?? undefined,
|
colour: this.selectedColour() ?? undefined,
|
||||||
size: this.selectedSize() ?? undefined,
|
size: this.selectedSize() ?? undefined,
|
||||||
price: this.effectivePrice(),
|
price: this.effectivePrice(),
|
||||||
@@ -314,9 +329,9 @@ export class ProductDetailsContainerComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
buyNow(): void {
|
async buyNow(): Promise<void> {
|
||||||
this.addToCart();
|
await this.addToCart();
|
||||||
this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]);
|
void this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleWishlist(): void {
|
toggleWishlist(): void {
|
||||||
@@ -353,8 +368,36 @@ export class ProductDetailsContainerComponent {
|
|||||||
await this.shareService.shareProduct(current, url);
|
await this.shareService.shareProduct(current, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back-in-stock subscription. Tries the backend endpoint first (not built
|
||||||
|
* yet - see BACKEND-API-REFERENCE.md §12); falls back to a local-only
|
||||||
|
* record on any failure so the request isn't silently lost while the
|
||||||
|
* backend catches up. Either way the shopper sees the same confirmation.
|
||||||
|
*/
|
||||||
notifyMe(): void {
|
notifyMe(): void {
|
||||||
this.toggleWishlist();
|
const current = this.product();
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const telegramUserId = this.authService.session()?.userId != null
|
||||||
|
? String(this.authService.session()!.userId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
this.apiService.subscribeToRestock(current.itemID, { telegramUserId }).subscribe({
|
||||||
|
next: () => this.notifications.show(this.translate.t('productDetails.notifyMeConfirmed'), 'success'),
|
||||||
|
error: () => {
|
||||||
|
this.saveRestockSubscriptionLocally(current.itemID);
|
||||||
|
this.notifications.show(this.translate.t('productDetails.notifyMeConfirmed'), 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveRestockSubscriptionLocally(itemID: number): void {
|
||||||
|
const existing = this.storage.getJSON<number[]>(RESTOCK_SUBSCRIPTIONS_KEY) ?? [];
|
||||||
|
if (!existing.includes(itemID)) {
|
||||||
|
this.storage.setJSON(RESTOCK_SUBSCRIPTIONS_KEY, [...existing, itemID]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addRelatedToCart(payload: { product: Product; event: Event }): void {
|
addRelatedToCart(payload: { product: Product; event: Event }): void {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.star {
|
.star {
|
||||||
color: #cdd6d5;
|
color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.star.filled {
|
.star.filled {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<th scope="col">{{ 'ux.compareAttribute' | translate }}</th>
|
<th scope="col">{{ 'ux.compareAttribute' | translate }}</th>
|
||||||
@for (product of products; track product.itemID) {
|
@for (product of products; track product.itemID) {
|
||||||
<th scope="col">
|
<th scope="col">
|
||||||
<div class="compare-product-title">{{ product.name }}</div>
|
<div class="compare-product-title">{{ productTitle(product) }}</div>
|
||||||
</th>
|
</th>
|
||||||
}
|
}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { ChangeDetectionStrategy, Component, Input, computed, inject } from '@an
|
|||||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||||
import { TranslateService } from '../../../../../i18n/translate.service';
|
import { TranslateService } from '../../../../../i18n/translate.service';
|
||||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
|
import { LanguageService } from '../../../../../services/language.service';
|
||||||
|
import { getTranslatedField } from '../../../../../utils/item.utils';
|
||||||
|
|
||||||
interface CompareRow {
|
interface CompareRow {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -27,6 +29,7 @@ const STOCK_LABEL_KEYS: Record<string, string> = {
|
|||||||
})
|
})
|
||||||
export class CompareTableComponent {
|
export class CompareTableComponent {
|
||||||
private readonly i18n = inject(TranslateService);
|
private readonly i18n = inject(TranslateService);
|
||||||
|
private readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
@Input() products: Product[] = [];
|
@Input() products: Product[] = [];
|
||||||
@Input() hideIdentical = false;
|
@Input() hideIdentical = false;
|
||||||
@@ -61,6 +64,10 @@ export class CompareTableComponent {
|
|||||||
return this.hideIdentical ? baseRows.filter(row => !row.identical) : baseRows;
|
return this.hideIdentical ? baseRows.filter(row => !row.identical) : baseRows;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
productTitle(product: Product): string {
|
||||||
|
return getTranslatedField(product, 'name', this.languageService.currentLanguage());
|
||||||
|
}
|
||||||
|
|
||||||
isDifferentRow(row: CompareRow): boolean {
|
isDifferentRow(row: CompareRow): boolean {
|
||||||
return this.highlightDifferences && !row.identical;
|
return this.highlightDifferences && !row.identical;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<section class="compare-products-list">
|
<section class="compare-products-list">
|
||||||
@for (product of products(); track product.itemID) {
|
@for (product of products(); track product.itemID) {
|
||||||
<article class="compare-product-chip">
|
<article class="compare-product-chip">
|
||||||
<a [routerLink]="['/product', product.itemID] | langRoute">{{ product.name }}</a>
|
<a [routerLink]="['/product', product.itemID] | langRoute">{{ productTitle(product) }}</a>
|
||||||
<button type="button" [attr.aria-label]="'ux.removeFromCompare' | translate" (click)="remove(product.itemID)">×</button>
|
<button type="button" [attr.aria-label]="'ux.removeFromCompare' | translate" (click)="remove(product.itemID)">×</button>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Product } from '../../../../../core/products/models/product-domain.mode
|
|||||||
import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade';
|
import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade';
|
||||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe';
|
import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe';
|
||||||
|
import { LanguageService } from '../../../../../services/language.service';
|
||||||
|
import { getTranslatedField } from '../../../../../utils/item.utils';
|
||||||
import { DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../../shared/models/config';
|
import { DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../../shared/models/config';
|
||||||
import { CompareTableComponent } from '../components/compare-table.component';
|
import { CompareTableComponent } from '../components/compare-table.component';
|
||||||
import { EmptyStateComponent } from '../../../../../shared/ui/empty-state/empty-state.component';
|
import { EmptyStateComponent } from '../../../../../shared/ui/empty-state/empty-state.component';
|
||||||
@@ -21,6 +23,7 @@ import { ButtonComponent } from '../../../../../shared/ui/button/button.componen
|
|||||||
export class ComparePageComponent {
|
export class ComparePageComponent {
|
||||||
private readonly uxFacade = inject(UserExperienceFacade);
|
private readonly uxFacade = inject(UserExperienceFacade);
|
||||||
private readonly configService = inject(ConfigService);
|
private readonly configService = inject(ConfigService);
|
||||||
|
private readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
private readonly compareConfig = this.resolveCompareConfig();
|
private readonly compareConfig = this.resolveCompareConfig();
|
||||||
|
|
||||||
@@ -39,6 +42,10 @@ export class ComparePageComponent {
|
|||||||
this.uxFacade.clearCompare();
|
this.uxFacade.clearCompare();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
productTitle(product: Product): string {
|
||||||
|
return getTranslatedField(product, 'name', this.languageService.currentLanguage());
|
||||||
|
}
|
||||||
|
|
||||||
private resolveCompareConfig() {
|
private resolveCompareConfig() {
|
||||||
const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience?.compare ?? {};
|
const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience?.compare ?? {};
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ export const en: Translations = {
|
|||||||
emailNeedsAt: 'Email must contain @',
|
emailNeedsAt: 'Email must contain @',
|
||||||
emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)',
|
emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)',
|
||||||
emailInvalid: 'Invalid email format',
|
emailInvalid: 'Invalid email format',
|
||||||
|
telegramIdMissing: 'We could not identify your Telegram account, so we could not save your contact details. Your payment was still successful.',
|
||||||
|
paymentDescriptionFallback: 'Purchase on Marketplace',
|
||||||
|
emailPlaceholder: 'you@example.com',
|
||||||
|
phonePlaceholder: '+7 (___) ___-__-__',
|
||||||
loginRequired: 'Log in to checkout',
|
loginRequired: 'Log in to checkout',
|
||||||
loginRequiredDesc: 'Please log in via Telegram to place your order',
|
loginRequiredDesc: 'Please log in via Telegram to place your order',
|
||||||
loginWithTelegram: 'Log in with Telegram',
|
loginWithTelegram: 'Log in with Telegram',
|
||||||
@@ -140,6 +144,10 @@ export const en: Translations = {
|
|||||||
noResultsHint: 'Try changing your query or using different keywords',
|
noResultsHint: 'Try changing your query or using different keywords',
|
||||||
emptyResultsAria: 'Empty search results',
|
emptyResultsAria: 'Empty search results',
|
||||||
popularCategories: 'Popular categories',
|
popularCategories: 'Popular categories',
|
||||||
|
popularSmartphones: 'Smartphones',
|
||||||
|
popularSneakers: 'Sneakers',
|
||||||
|
popularHeadphones: 'Headphones',
|
||||||
|
popularLaptops: 'Laptops',
|
||||||
recommendedProducts: 'Recommended products',
|
recommendedProducts: 'Recommended products',
|
||||||
aiSuggestionHint: 'AI suggestion (future)',
|
aiSuggestionHint: 'AI suggestion (future)',
|
||||||
suggestionType: {
|
suggestionType: {
|
||||||
@@ -269,6 +277,7 @@ export const en: Translations = {
|
|||||||
compare: 'Compare',
|
compare: 'Compare',
|
||||||
share: 'Share',
|
share: 'Share',
|
||||||
quickView: 'Quick view',
|
quickView: 'Quick view',
|
||||||
|
quickViewDetails: 'View full details',
|
||||||
stockHigh: 'In stock',
|
stockHigh: 'In stock',
|
||||||
stockMedium: 'Limited stock',
|
stockMedium: 'Limited stock',
|
||||||
stockLow: 'Almost gone',
|
stockLow: 'Almost gone',
|
||||||
@@ -317,6 +326,7 @@ export const en: Translations = {
|
|||||||
compare: 'Compare',
|
compare: 'Compare',
|
||||||
share: 'Share',
|
share: 'Share',
|
||||||
notifyMe: 'Notify me',
|
notifyMe: 'Notify me',
|
||||||
|
notifyMeConfirmed: 'We\'ll let you know when this is back in stock.',
|
||||||
zoom: 'Zoom',
|
zoom: 'Zoom',
|
||||||
fullscreen: 'Fullscreen',
|
fullscreen: 'Fullscreen',
|
||||||
pdfDocument: 'Product PDF document',
|
pdfDocument: 'Product PDF document',
|
||||||
@@ -1119,6 +1129,8 @@ export const en: Translations = {
|
|||||||
nextProducts: 'Next products',
|
nextProducts: 'Next products',
|
||||||
previousSlide: 'Previous slide',
|
previousSlide: 'Previous slide',
|
||||||
nextSlide: 'Next slide',
|
nextSlide: 'Next slide',
|
||||||
|
pauseAutoplay: 'Pause slideshow',
|
||||||
|
resumeAutoplay: 'Resume slideshow',
|
||||||
closeDialog: 'Close dialog',
|
closeDialog: 'Close dialog',
|
||||||
dismiss: 'Dismiss',
|
dismiss: 'Dismiss',
|
||||||
qrCode: 'QR Code',
|
qrCode: 'QR Code',
|
||||||
@@ -1299,7 +1311,8 @@ export const en: Translations = {
|
|||||||
notAvailable: 'Not available yet',
|
notAvailable: 'Not available yet',
|
||||||
changeStatus: 'Change status',
|
changeStatus: 'Change status',
|
||||||
hideSelected: 'Hide',
|
hideSelected: 'Hide',
|
||||||
archiveSelected: 'Archive',
|
deleteSelected: 'Delete',
|
||||||
|
confirmBulkDelete: 'Delete the selected reviews? This cannot be undone.',
|
||||||
reportsQueue: 'Reports queue',
|
reportsQueue: 'Reports queue',
|
||||||
reportsColumn: 'Reports',
|
reportsColumn: 'Reports',
|
||||||
reportTarget: 'Reported item',
|
reportTarget: 'Reported item',
|
||||||
@@ -1357,6 +1370,8 @@ export const en: Translations = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminOrders: {
|
adminOrders: {
|
||||||
|
bulkDeleteTitle: 'Delete orders',
|
||||||
|
confirmBulkDelete: 'Delete the selected orders? This cannot be undone.',
|
||||||
back: 'Back to orders',
|
back: 'Back to orders',
|
||||||
search: 'Search by order number, name, email, or phone…',
|
search: 'Search by order number, name, email, or phone…',
|
||||||
export: 'Export',
|
export: 'Export',
|
||||||
@@ -1533,6 +1548,7 @@ export const en: Translations = {
|
|||||||
title: 'Title',
|
title: 'Title',
|
||||||
slug: 'URL slug',
|
slug: 'URL slug',
|
||||||
slugTaken: 'This slug is already used by another category.',
|
slugTaken: 'This slug is already used by another category.',
|
||||||
|
slugCheckError: 'Could not verify this slug is unique. Try again before saving.',
|
||||||
parent: 'Parent category',
|
parent: 'Parent category',
|
||||||
noParent: 'No parent (top level)',
|
noParent: 'No parent (top level)',
|
||||||
icon: 'Icon',
|
icon: 'Icon',
|
||||||
@@ -1545,6 +1561,8 @@ export const en: Translations = {
|
|||||||
saveDraft: 'Save draft',
|
saveDraft: 'Save draft',
|
||||||
publish: 'Publish',
|
publish: 'Publish',
|
||||||
confirmDelete: 'Delete this category? This cannot be undone.',
|
confirmDelete: 'Delete this category? This cannot be undone.',
|
||||||
|
confirmBulkDelete: 'Delete the selected categories? This cannot be undone.',
|
||||||
|
deleteTitle: 'Delete category',
|
||||||
confirmLeaveUnsaved: 'You have unsaved changes. Leave without saving?',
|
confirmLeaveUnsaved: 'You have unsaved changes. Leave without saving?',
|
||||||
deleteBlocked: 'This category can\'t be deleted while it has subcategories or assigned products.',
|
deleteBlocked: 'This category can\'t be deleted while it has subcategories or assigned products.',
|
||||||
emptyTitle: 'No categories yet',
|
emptyTitle: 'No categories yet',
|
||||||
@@ -1611,6 +1629,9 @@ export const en: Translations = {
|
|||||||
},
|
},
|
||||||
adminProducts: {
|
adminProducts: {
|
||||||
confirmLeaveUnsaved: 'You have unsaved changes. Leave without saving?',
|
confirmLeaveUnsaved: 'You have unsaved changes. Leave without saving?',
|
||||||
|
deleteTitle: 'Delete product',
|
||||||
|
confirmDelete: 'Delete this product? This cannot be undone.',
|
||||||
|
confirmBulkDelete: 'Delete the selected products? This cannot be undone.',
|
||||||
emptyTitle: 'No products found',
|
emptyTitle: 'No products found',
|
||||||
emptyDescription: 'Try adjusting your filters, or create a new product.',
|
emptyDescription: 'Try adjusting your filters, or create a new product.',
|
||||||
emptyGuide: 'Good products have a clear title, at least one photo, a price, and a short description — that\'s enough to publish. You can always add more detail later.',
|
emptyGuide: 'Good products have a clear title, at least one photo, a price, and a short description — that\'s enough to publish. You can always add more detail later.',
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ export const hy: Translations = {
|
|||||||
emailNeedsAt: 'Email-ը պետք է պարունակի @',
|
emailNeedsAt: 'Email-ը պետք է պարունակի @',
|
||||||
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)',
|
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)',
|
||||||
emailInvalid: 'Սխալ email ձևաչափ',
|
emailInvalid: 'Սխալ email ձևաչափ',
|
||||||
|
telegramIdMissing: 'Չհաջողվեց հաստատել ձեր Telegram հաշիվը, ուստի կոնտակտային տվյալները չեն պահպանվել։ Վճարումը հաջողությամբ կատարվել է։',
|
||||||
|
paymentDescriptionFallback: 'Գնում Մարկետփլեյսում',
|
||||||
|
emailPlaceholder: 'you@example.com',
|
||||||
|
phonePlaceholder: '+7 (___) ___-__-__',
|
||||||
loginRequired: 'Մուտք գործեք ձևակերպելու համար',
|
loginRequired: 'Մուտք գործեք ձևակերպելու համար',
|
||||||
loginRequiredDesc: 'Պատվեր ձևակերպելու համար մուտք գործեք Telegram-ով',
|
loginRequiredDesc: 'Պատվեր ձևակերպելու համար մուտք գործեք Telegram-ով',
|
||||||
loginWithTelegram: 'Մուտք Telegram-ով',
|
loginWithTelegram: 'Մուտք Telegram-ով',
|
||||||
@@ -140,6 +144,10 @@ export const hy: Translations = {
|
|||||||
noResultsHint: 'Փորձեք փոխել հարցումը կամ օգտագործել այլ բանալի բառեր',
|
noResultsHint: 'Փորձեք փոխել հարցումը կամ օգտագործել այլ բանալի բառեր',
|
||||||
emptyResultsAria: 'Դատարկ որոնման արդյունքներ',
|
emptyResultsAria: 'Դատարկ որոնման արդյունքներ',
|
||||||
popularCategories: 'Հանրաճանաչ կատեգորիաներ',
|
popularCategories: 'Հանրաճանաչ կատեգորիաներ',
|
||||||
|
popularSmartphones: 'Սմարթֆոններ',
|
||||||
|
popularSneakers: 'Կեդեր',
|
||||||
|
popularHeadphones: 'Ականջակալներ',
|
||||||
|
popularLaptops: 'Նոութբուքեր',
|
||||||
recommendedProducts: 'Առաջարկվող ապրանքներ',
|
recommendedProducts: 'Առաջարկվող ապրանքներ',
|
||||||
aiSuggestionHint: 'AI առաջարկ (ապագայում)',
|
aiSuggestionHint: 'AI առաջարկ (ապագայում)',
|
||||||
suggestionType: {
|
suggestionType: {
|
||||||
@@ -269,6 +277,7 @@ export const hy: Translations = {
|
|||||||
compare: 'Համեմատել',
|
compare: 'Համեմատել',
|
||||||
share: 'Կիսվել',
|
share: 'Կիսվել',
|
||||||
quickView: 'Արագ դիտում',
|
quickView: 'Արագ դիտում',
|
||||||
|
quickViewDetails: 'Տեսնել ամբողջությամբ',
|
||||||
stockHigh: 'Առկա է',
|
stockHigh: 'Առկա է',
|
||||||
stockMedium: 'Սահմանափակ քանակ',
|
stockMedium: 'Սահմանափակ քանակ',
|
||||||
stockLow: 'Գրեթե սպառված է',
|
stockLow: 'Գրեթե սպառված է',
|
||||||
@@ -317,6 +326,7 @@ export const hy: Translations = {
|
|||||||
compare: 'Համեմատել',
|
compare: 'Համեմատել',
|
||||||
share: 'Կիսվել',
|
share: 'Կիսվել',
|
||||||
notifyMe: 'Ծանուցել ինձ',
|
notifyMe: 'Ծանուցել ինձ',
|
||||||
|
notifyMeConfirmed: 'Մենք կտեղեկացնենք ձեզ, երբ ապրանքը կրկին հասանելի լինի։',
|
||||||
zoom: 'Մեծացնել',
|
zoom: 'Մեծացնել',
|
||||||
fullscreen: 'Ամբողջ էկրան',
|
fullscreen: 'Ամբողջ էկրան',
|
||||||
pdfDocument: 'Ապրանքի PDF փաստաթուղթ',
|
pdfDocument: 'Ապրանքի PDF փաստաթուղթ',
|
||||||
@@ -1119,6 +1129,8 @@ export const hy: Translations = {
|
|||||||
nextProducts: 'Հաջորդ ապրանքները',
|
nextProducts: 'Հաջորդ ապրանքները',
|
||||||
previousSlide: 'Նախորդ սլայդը',
|
previousSlide: 'Նախորդ սլայդը',
|
||||||
nextSlide: 'Հաջորդ սլայդը',
|
nextSlide: 'Հաջորդ սլայդը',
|
||||||
|
pauseAutoplay: 'Դադարեցնել սլայդշոուն',
|
||||||
|
resumeAutoplay: 'Վերսկսել սլայդշոուն',
|
||||||
closeDialog: 'Փակել պատուհանը',
|
closeDialog: 'Փակել պատուհանը',
|
||||||
dismiss: 'Փակել',
|
dismiss: 'Փակել',
|
||||||
qrCode: 'QR կոդ',
|
qrCode: 'QR կոդ',
|
||||||
@@ -1294,7 +1306,8 @@ export const hy: Translations = {
|
|||||||
notAvailable: 'Դեռ հասանելի չէ',
|
notAvailable: 'Դեռ հասանելի չէ',
|
||||||
changeStatus: 'Փոխել կարգավիճակը',
|
changeStatus: 'Փոխել կարգավիճակը',
|
||||||
hideSelected: 'Թաքցնել',
|
hideSelected: 'Թաքցնել',
|
||||||
archiveSelected: 'Արխիվացնել',
|
deleteSelected: 'Ջնջել',
|
||||||
|
confirmBulkDelete: 'Ջնջե՞լ ընտրված կարծիքները։ Հնարավոր չէ հետարկել։',
|
||||||
reportsQueue: 'Բողոքների հերթ',
|
reportsQueue: 'Բողոքների հերթ',
|
||||||
reportsColumn: 'Բողոքներ',
|
reportsColumn: 'Բողոքներ',
|
||||||
reportTarget: 'Բողոքարկված օբյեկտ',
|
reportTarget: 'Բողոքարկված օբյեկտ',
|
||||||
@@ -1352,6 +1365,8 @@ export const hy: Translations = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminOrders: {
|
adminOrders: {
|
||||||
|
bulkDeleteTitle: 'Ջնջել պատվերները',
|
||||||
|
confirmBulkDelete: 'Ջնջե՞լ ընտրված պատվերները։ Հնարավոր չէ հետարկել։',
|
||||||
back: 'Վերադառնալ պատվերներին',
|
back: 'Վերադառնալ պատվերներին',
|
||||||
search: 'Փնտրել ըստ պատվերի համարի, անվան, էլ. փոստի կամ հեռախոսի…',
|
search: 'Փնտրել ըստ պատվերի համարի, անվան, էլ. փոստի կամ հեռախոսի…',
|
||||||
export: 'Արտահանել',
|
export: 'Արտահանել',
|
||||||
@@ -1528,6 +1543,7 @@ export const hy: Translations = {
|
|||||||
title: 'Անուն',
|
title: 'Անուն',
|
||||||
slug: 'URL հասցե',
|
slug: 'URL հասցե',
|
||||||
slugTaken: 'Այս հասցեն արդեն օգտագործվում է այլ կատեգորիայի կողմից։',
|
slugTaken: 'Այս հասցեն արդեն օգտագործվում է այլ կատեգորիայի կողմից։',
|
||||||
|
slugCheckError: 'Չհաջողվեց ստուգել հասցեի եզակիությունը։ Փորձեք կրկին՝ նախքան պահպանելը։',
|
||||||
parent: 'Ծնող կատեգորիա',
|
parent: 'Ծնող կատեգորիա',
|
||||||
noParent: 'Առանց ծնողի (վերին մակարդակ)',
|
noParent: 'Առանց ծնողի (վերին մակարդակ)',
|
||||||
icon: 'Պատկերակ',
|
icon: 'Պատկերակ',
|
||||||
@@ -1540,6 +1556,8 @@ export const hy: Translations = {
|
|||||||
saveDraft: 'Պահպանել սևագիրը',
|
saveDraft: 'Պահպանել սևագիրը',
|
||||||
publish: 'Հրապարակել',
|
publish: 'Հրապարակել',
|
||||||
confirmDelete: 'Ջնջե՞լ այս կատեգորիան։ Հնարավոր չէ հետարկել։',
|
confirmDelete: 'Ջնջե՞լ այս կատեգորիան։ Հնարավոր չէ հետարկել։',
|
||||||
|
confirmBulkDelete: 'Ջնջե՞լ ընտրված կատեգորիաները։ Հնարավոր չէ հետարկել։',
|
||||||
|
deleteTitle: 'Ջնջել կատեգորիան',
|
||||||
confirmLeaveUnsaved: 'Դուք ունեք չպահպանված փոփոխություններ։ Դո՞ւրս գալ առանց պահպանելու։',
|
confirmLeaveUnsaved: 'Դուք ունեք չպահպանված փոփոխություններ։ Դո՞ւրս գալ առանց պահպանելու։',
|
||||||
deleteBlocked: 'Այս կատեգորիան հնարավոր չէ ջնջել, քանի դեռ ունի ենթակատեգորիաներ կամ նշանակված ապրանքներ։',
|
deleteBlocked: 'Այս կատեգորիան հնարավոր չէ ջնջել, քանի դեռ ունի ենթակատեգորիաներ կամ նշանակված ապրանքներ։',
|
||||||
emptyTitle: 'Կատեգորիաներ դեռ չկան',
|
emptyTitle: 'Կատեգորիաներ դեռ չկան',
|
||||||
@@ -1606,6 +1624,9 @@ export const hy: Translations = {
|
|||||||
},
|
},
|
||||||
adminProducts: {
|
adminProducts: {
|
||||||
confirmLeaveUnsaved: 'Դուք ունեք չպահպանված փոփոխություններ։ Դո՞ւրս գալ առանց պահպանելու։',
|
confirmLeaveUnsaved: 'Դուք ունեք չպահպանված փոփոխություններ։ Դո՞ւրս գալ առանց պահպանելու։',
|
||||||
|
deleteTitle: 'Ջնջել ապրանքը',
|
||||||
|
confirmDelete: 'Ջնջե՞լ այս ապրանքը։ Հնարավոր չէ հետարկել։',
|
||||||
|
confirmBulkDelete: 'Ջնջե՞լ ընտրված ապրանքները։ Հնարավոր չէ հետարկել։',
|
||||||
emptyTitle: 'Ապրանքներ չեն գտնվել',
|
emptyTitle: 'Ապրանքներ չեն գտնվել',
|
||||||
emptyDescription: 'Փոխեք ֆիլտրերը կամ ստեղծեք նոր ապրանք։',
|
emptyDescription: 'Փոխեք ֆիլտրերը կամ ստեղծեք նոր ապրանք։',
|
||||||
emptyGuide: 'Լավ ապրանքին բավական է հստակ վերնագիր, առնվազն մեկ լուսանկար, գին և կարճ նկարագրություն՝ հրապարակելու համար։ Մնացածը կարող եք ավելացնել հետո։',
|
emptyGuide: 'Լավ ապրանքին բավական է հստակ վերնագիր, առնվազն մեկ լուսանկար, գին և կարճ նկարագրություն՝ հրապարակելու համար։ Մնացածը կարող եք ավելացնել հետո։',
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ export const ru: Translations = {
|
|||||||
emailNeedsAt: 'Email должен содержать @',
|
emailNeedsAt: 'Email должен содержать @',
|
||||||
emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)',
|
emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)',
|
||||||
emailInvalid: 'Некорректный формат email',
|
emailInvalid: 'Некорректный формат email',
|
||||||
|
telegramIdMissing: 'Не удалось определить ваш Telegram-аккаунт, поэтому контактные данные не сохранены. Оплата прошла успешно.',
|
||||||
|
paymentDescriptionFallback: 'Покупка на Маркетплейсе',
|
||||||
|
emailPlaceholder: 'you@example.com',
|
||||||
|
phonePlaceholder: '+7 (___) ___-__-__',
|
||||||
loginRequired: 'Войдите для оформления',
|
loginRequired: 'Войдите для оформления',
|
||||||
loginRequiredDesc: 'Для оформления заказа войдите через Telegram',
|
loginRequiredDesc: 'Для оформления заказа войдите через Telegram',
|
||||||
loginWithTelegram: 'Войти через Telegram',
|
loginWithTelegram: 'Войти через Telegram',
|
||||||
@@ -140,6 +144,10 @@ export const ru: Translations = {
|
|||||||
noResultsHint: 'Попробуйте изменить запрос или используйте другие ключевые слова',
|
noResultsHint: 'Попробуйте изменить запрос или используйте другие ключевые слова',
|
||||||
emptyResultsAria: 'Пустые результаты поиска',
|
emptyResultsAria: 'Пустые результаты поиска',
|
||||||
popularCategories: 'Популярные категории',
|
popularCategories: 'Популярные категории',
|
||||||
|
popularSmartphones: 'Смартфоны',
|
||||||
|
popularSneakers: 'Кроссовки',
|
||||||
|
popularHeadphones: 'Наушники',
|
||||||
|
popularLaptops: 'Ноутбуки',
|
||||||
recommendedProducts: 'Рекомендуемые товары',
|
recommendedProducts: 'Рекомендуемые товары',
|
||||||
aiSuggestionHint: 'AI-подсказка (в будущем)',
|
aiSuggestionHint: 'AI-подсказка (в будущем)',
|
||||||
suggestionType: {
|
suggestionType: {
|
||||||
@@ -269,6 +277,7 @@ export const ru: Translations = {
|
|||||||
compare: 'Сравнить',
|
compare: 'Сравнить',
|
||||||
share: 'Поделиться',
|
share: 'Поделиться',
|
||||||
quickView: 'Быстрый просмотр',
|
quickView: 'Быстрый просмотр',
|
||||||
|
quickViewDetails: 'Смотреть полностью',
|
||||||
stockHigh: 'В наличии',
|
stockHigh: 'В наличии',
|
||||||
stockMedium: 'Ограниченно',
|
stockMedium: 'Ограниченно',
|
||||||
stockLow: 'Почти распродано',
|
stockLow: 'Почти распродано',
|
||||||
@@ -317,6 +326,7 @@ export const ru: Translations = {
|
|||||||
compare: 'Сравнить',
|
compare: 'Сравнить',
|
||||||
share: 'Поделиться',
|
share: 'Поделиться',
|
||||||
notifyMe: 'Сообщить о наличии',
|
notifyMe: 'Сообщить о наличии',
|
||||||
|
notifyMeConfirmed: 'Мы сообщим вам, когда товар снова появится в наличии.',
|
||||||
zoom: 'Увеличить',
|
zoom: 'Увеличить',
|
||||||
fullscreen: 'Полный экран',
|
fullscreen: 'Полный экран',
|
||||||
pdfDocument: 'PDF документ товара',
|
pdfDocument: 'PDF документ товара',
|
||||||
@@ -1119,6 +1129,8 @@ export const ru: Translations = {
|
|||||||
nextProducts: 'Следующие товары',
|
nextProducts: 'Следующие товары',
|
||||||
previousSlide: 'Предыдущий слайд',
|
previousSlide: 'Предыдущий слайд',
|
||||||
nextSlide: 'Следующий слайд',
|
nextSlide: 'Следующий слайд',
|
||||||
|
pauseAutoplay: 'Приостановить слайд-шоу',
|
||||||
|
resumeAutoplay: 'Возобновить слайд-шоу',
|
||||||
closeDialog: 'Закрыть диалог',
|
closeDialog: 'Закрыть диалог',
|
||||||
dismiss: 'Скрыть',
|
dismiss: 'Скрыть',
|
||||||
qrCode: 'QR-код',
|
qrCode: 'QR-код',
|
||||||
@@ -1294,7 +1306,8 @@ export const ru: Translations = {
|
|||||||
notAvailable: 'Пока недоступно',
|
notAvailable: 'Пока недоступно',
|
||||||
changeStatus: 'Изменить статус',
|
changeStatus: 'Изменить статус',
|
||||||
hideSelected: 'Скрыть',
|
hideSelected: 'Скрыть',
|
||||||
archiveSelected: 'В архив',
|
deleteSelected: 'Удалить',
|
||||||
|
confirmBulkDelete: 'Удалить выбранные отзывы? Это действие нельзя отменить.',
|
||||||
reportsQueue: 'Очередь жалоб',
|
reportsQueue: 'Очередь жалоб',
|
||||||
reportsColumn: 'Жалобы',
|
reportsColumn: 'Жалобы',
|
||||||
reportTarget: 'Объект жалобы',
|
reportTarget: 'Объект жалобы',
|
||||||
@@ -1352,6 +1365,8 @@ export const ru: Translations = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminOrders: {
|
adminOrders: {
|
||||||
|
bulkDeleteTitle: 'Удалить заказы',
|
||||||
|
confirmBulkDelete: 'Удалить выбранные заказы? Это действие нельзя отменить.',
|
||||||
back: 'Назад к заказам',
|
back: 'Назад к заказам',
|
||||||
search: 'Поиск по номеру заказа, имени, email или телефону…',
|
search: 'Поиск по номеру заказа, имени, email или телефону…',
|
||||||
export: 'Экспорт',
|
export: 'Экспорт',
|
||||||
@@ -1528,6 +1543,7 @@ export const ru: Translations = {
|
|||||||
title: 'Название',
|
title: 'Название',
|
||||||
slug: 'URL-адрес',
|
slug: 'URL-адрес',
|
||||||
slugTaken: 'Этот адрес уже используется другой категорией.',
|
slugTaken: 'Этот адрес уже используется другой категорией.',
|
||||||
|
slugCheckError: 'Не удалось проверить уникальность адреса. Повторите попытку перед сохранением.',
|
||||||
parent: 'Родительская категория',
|
parent: 'Родительская категория',
|
||||||
noParent: 'Без родителя (верхний уровень)',
|
noParent: 'Без родителя (верхний уровень)',
|
||||||
icon: 'Иконка',
|
icon: 'Иконка',
|
||||||
@@ -1540,6 +1556,8 @@ export const ru: Translations = {
|
|||||||
saveDraft: 'Сохранить черновик',
|
saveDraft: 'Сохранить черновик',
|
||||||
publish: 'Опубликовать',
|
publish: 'Опубликовать',
|
||||||
confirmDelete: 'Удалить эту категорию? Это действие нельзя отменить.',
|
confirmDelete: 'Удалить эту категорию? Это действие нельзя отменить.',
|
||||||
|
confirmBulkDelete: 'Удалить выбранные категории? Это действие нельзя отменить.',
|
||||||
|
deleteTitle: 'Удалить категорию',
|
||||||
confirmLeaveUnsaved: 'У вас есть несохранённые изменения. Выйти без сохранения?',
|
confirmLeaveUnsaved: 'У вас есть несохранённые изменения. Выйти без сохранения?',
|
||||||
deleteBlocked: 'Эту категорию нельзя удалить, пока у неё есть подкатегории или назначенные товары.',
|
deleteBlocked: 'Эту категорию нельзя удалить, пока у неё есть подкатегории или назначенные товары.',
|
||||||
emptyTitle: 'Категорий пока нет',
|
emptyTitle: 'Категорий пока нет',
|
||||||
@@ -1606,6 +1624,9 @@ export const ru: Translations = {
|
|||||||
},
|
},
|
||||||
adminProducts: {
|
adminProducts: {
|
||||||
confirmLeaveUnsaved: 'У вас есть несохранённые изменения. Выйти без сохранения?',
|
confirmLeaveUnsaved: 'У вас есть несохранённые изменения. Выйти без сохранения?',
|
||||||
|
deleteTitle: 'Удалить товар',
|
||||||
|
confirmDelete: 'Удалить этот товар? Это действие нельзя отменить.',
|
||||||
|
confirmBulkDelete: 'Удалить выбранные товары? Это действие нельзя отменить.',
|
||||||
emptyTitle: 'Товары не найдены',
|
emptyTitle: 'Товары не найдены',
|
||||||
emptyDescription: 'Измените фильтры или создайте новый товар.',
|
emptyDescription: 'Измените фильтры или создайте новый товар.',
|
||||||
emptyGuide: 'Хорошему товару нужны понятное название, минимум одно фото, цена и короткое описание — этого достаточно для публикации. Остальное можно добавить позже.',
|
emptyGuide: 'Хорошему товару нужны понятное название, минимум одно фото, цена и короткое описание — этого достаточно для публикации. Остальное можно добавить позже.',
|
||||||
|
|||||||
@@ -1,14 +1,42 @@
|
|||||||
import { Pipe, PipeTransform, inject } from '@angular/core';
|
import { Pipe, PipeTransform, inject } from '@angular/core';
|
||||||
import { TranslateService } from './translate.service';
|
import { TranslateService } from './translate.service';
|
||||||
|
import { LanguageService } from '../services/language.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stays impure (must re-run every CD cycle so a live language switch
|
||||||
|
* updates every binding without touching hundreds of `| translate`
|
||||||
|
* template call sites to pass the language explicitly as a pure-pipe
|
||||||
|
* argument). Memoized per-instance instead: repeat calls with the same
|
||||||
|
* key/params/language - the overwhelming majority of CD cycles, since
|
||||||
|
* nothing actually changed - hit a Map lookup instead of re-walking the
|
||||||
|
* translation tree and re-running the interpolation regex.
|
||||||
|
*/
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'translate',
|
name: 'translate',
|
||||||
pure: false,
|
pure: false,
|
||||||
})
|
})
|
||||||
export class TranslatePipe implements PipeTransform {
|
export class TranslatePipe implements PipeTransform {
|
||||||
private translateService = inject(TranslateService);
|
private translateService = inject(TranslateService);
|
||||||
|
private langService = inject(LanguageService);
|
||||||
|
|
||||||
|
private lastLang = '';
|
||||||
|
private readonly cache = new Map<string, string>();
|
||||||
|
|
||||||
transform(key: string, params?: Record<string, string | number>): string {
|
transform(key: string, params?: Record<string, string | number>): string {
|
||||||
return this.translateService.t(key, params);
|
const lang = this.langService.currentLanguage();
|
||||||
|
if (lang !== this.lastLang) {
|
||||||
|
this.lastLang = lang;
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = params ? `${key}::${JSON.stringify(params)}` : key;
|
||||||
|
const cached = this.cache.get(cacheKey);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = this.translateService.t(key, params);
|
||||||
|
this.cache.set(cacheKey, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ export interface Translations {
|
|||||||
emailNeedsAt: string;
|
emailNeedsAt: string;
|
||||||
emailNeedsDomain: string;
|
emailNeedsDomain: string;
|
||||||
emailInvalid: string;
|
emailInvalid: string;
|
||||||
|
telegramIdMissing: string;
|
||||||
|
paymentDescriptionFallback: string;
|
||||||
|
emailPlaceholder: string;
|
||||||
|
phonePlaceholder: string;
|
||||||
loginRequired: string;
|
loginRequired: string;
|
||||||
loginRequiredDesc: string;
|
loginRequiredDesc: string;
|
||||||
loginWithTelegram: string;
|
loginWithTelegram: string;
|
||||||
@@ -138,6 +142,10 @@ export interface Translations {
|
|||||||
noResultsHint: string;
|
noResultsHint: string;
|
||||||
emptyResultsAria: string;
|
emptyResultsAria: string;
|
||||||
popularCategories: string;
|
popularCategories: string;
|
||||||
|
popularSmartphones: string;
|
||||||
|
popularSneakers: string;
|
||||||
|
popularHeadphones: string;
|
||||||
|
popularLaptops: string;
|
||||||
recommendedProducts: string;
|
recommendedProducts: string;
|
||||||
aiSuggestionHint: string;
|
aiSuggestionHint: string;
|
||||||
suggestionType: {
|
suggestionType: {
|
||||||
@@ -267,6 +275,7 @@ export interface Translations {
|
|||||||
compare: string;
|
compare: string;
|
||||||
share: string;
|
share: string;
|
||||||
quickView: string;
|
quickView: string;
|
||||||
|
quickViewDetails: string;
|
||||||
stockHigh: string;
|
stockHigh: string;
|
||||||
stockMedium: string;
|
stockMedium: string;
|
||||||
stockLow: string;
|
stockLow: string;
|
||||||
@@ -315,6 +324,7 @@ export interface Translations {
|
|||||||
compare: string;
|
compare: string;
|
||||||
share: string;
|
share: string;
|
||||||
notifyMe: string;
|
notifyMe: string;
|
||||||
|
notifyMeConfirmed: string;
|
||||||
zoom: string;
|
zoom: string;
|
||||||
fullscreen: string;
|
fullscreen: string;
|
||||||
pdfDocument: string;
|
pdfDocument: string;
|
||||||
@@ -1118,6 +1128,8 @@ export interface Translations {
|
|||||||
nextProducts: string;
|
nextProducts: string;
|
||||||
previousSlide: string;
|
previousSlide: string;
|
||||||
nextSlide: string;
|
nextSlide: string;
|
||||||
|
pauseAutoplay: string;
|
||||||
|
resumeAutoplay: string;
|
||||||
closeDialog: string;
|
closeDialog: string;
|
||||||
dismiss: string;
|
dismiss: string;
|
||||||
qrCode: string;
|
qrCode: string;
|
||||||
@@ -1298,7 +1310,8 @@ export interface Translations {
|
|||||||
notAvailable: string;
|
notAvailable: string;
|
||||||
changeStatus: string;
|
changeStatus: string;
|
||||||
hideSelected: string;
|
hideSelected: string;
|
||||||
archiveSelected: string;
|
deleteSelected: string;
|
||||||
|
confirmBulkDelete: string;
|
||||||
reportsQueue: string;
|
reportsQueue: string;
|
||||||
reportsColumn: string;
|
reportsColumn: string;
|
||||||
reportTarget: string;
|
reportTarget: string;
|
||||||
@@ -1356,6 +1369,8 @@ export interface Translations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
adminOrders: {
|
adminOrders: {
|
||||||
|
bulkDeleteTitle: string;
|
||||||
|
confirmBulkDelete: string;
|
||||||
back: string;
|
back: string;
|
||||||
search: string;
|
search: string;
|
||||||
export: string;
|
export: string;
|
||||||
@@ -1541,6 +1556,7 @@ export interface Translations {
|
|||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
slugTaken: string;
|
slugTaken: string;
|
||||||
|
slugCheckError: string;
|
||||||
parent: string;
|
parent: string;
|
||||||
noParent: string;
|
noParent: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
@@ -1553,6 +1569,8 @@ export interface Translations {
|
|||||||
saveDraft: string;
|
saveDraft: string;
|
||||||
publish: string;
|
publish: string;
|
||||||
confirmDelete: string;
|
confirmDelete: string;
|
||||||
|
confirmBulkDelete: string;
|
||||||
|
deleteTitle: string;
|
||||||
confirmLeaveUnsaved: string;
|
confirmLeaveUnsaved: string;
|
||||||
deleteBlocked: string;
|
deleteBlocked: string;
|
||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
@@ -1619,6 +1637,9 @@ export interface Translations {
|
|||||||
};
|
};
|
||||||
adminProducts: {
|
adminProducts: {
|
||||||
confirmLeaveUnsaved: string;
|
confirmLeaveUnsaved: string;
|
||||||
|
deleteTitle: string;
|
||||||
|
confirmDelete: string;
|
||||||
|
confirmBulkDelete: string;
|
||||||
emptyTitle: string;
|
emptyTitle: string;
|
||||||
emptyDescription: string;
|
emptyDescription: string;
|
||||||
emptyGuide: string;
|
emptyGuide: string;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { UUID } from '../shared/types/primitive.types';
|
||||||
|
|
||||||
interface Photo {
|
interface Photo {
|
||||||
photo?: string;
|
photo?: string;
|
||||||
video?: string;
|
video?: string;
|
||||||
@@ -182,7 +184,7 @@ export interface Item {
|
|||||||
* Absent means marketplace-owned, exactly like every product today -
|
* Absent means marketplace-owned, exactly like every product today -
|
||||||
* nothing reads this field yet, nothing breaks by it being undefined.
|
* nothing reads this field yet, nothing breaks by it being undefined.
|
||||||
*/
|
*/
|
||||||
sellerId?: string;
|
sellerId?: UUID;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CartItem extends Item {
|
export interface CartItem extends Item {
|
||||||
|
|||||||
@@ -98,7 +98,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="delete-btn-mobile" (click)="removeItem(item)" [attr.aria-label]="'cart.removeItem' | translate">
|
<button
|
||||||
|
class="delete-btn-mobile"
|
||||||
|
(click)="removeItem(item)"
|
||||||
|
[attr.aria-label]="'cart.removeItem' | translate"
|
||||||
|
[attr.tabindex]="swipedItemId() === item.itemID ? null : -1"
|
||||||
|
[attr.aria-hidden]="swipedItemId() === item.itemID ? null : 'true'"
|
||||||
|
>
|
||||||
<app-icon name="trash" [size]="20" />
|
<app-icon name="trash" [size]="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -271,6 +277,44 @@
|
|||||||
<div class="payment-status-screen success" role="status" aria-live="polite">
|
<div class="payment-status-screen success" role="status" aria-live="polite">
|
||||||
<div class="success-icon" aria-hidden="true">✓</div>
|
<div class="success-icon" aria-hidden="true">✓</div>
|
||||||
<h2>{{ 'cart.paymentSuccess' | translate }}</h2>
|
<h2>{{ 'cart.paymentSuccess' | translate }}</h2>
|
||||||
|
@if (!purchaseSubmitted()) {
|
||||||
|
<p>{{ 'cart.paymentSuccessDesc' | translate }}</p>
|
||||||
|
<form class="contact-capture-form" (ngSubmit)="submitEmail()">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
[value]="userEmail()"
|
||||||
|
[placeholder]="'cart.emailPlaceholder' | translate"
|
||||||
|
[attr.aria-invalid]="!!emailError()"
|
||||||
|
(input)="onEmailInput($event)"
|
||||||
|
(blur)="onEmailBlur()"
|
||||||
|
[disabled]="emailSubmitting()"
|
||||||
|
/>
|
||||||
|
@if (emailError()) {
|
||||||
|
<span class="field-error">{{ emailError() }}</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
name="phone"
|
||||||
|
[value]="userPhone()"
|
||||||
|
[placeholder]="'cart.phonePlaceholder' | translate"
|
||||||
|
[attr.aria-invalid]="!!phoneError()"
|
||||||
|
(input)="onPhoneInput($event)"
|
||||||
|
(blur)="onPhoneBlur()"
|
||||||
|
[disabled]="emailSubmitting()"
|
||||||
|
/>
|
||||||
|
@if (phoneError()) {
|
||||||
|
<span class="field-error">{{ phoneError() }}</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<button type="submit" [disabled]="emailSubmitting()">
|
||||||
|
{{ emailSubmitting() ? ('cart.sending' | translate) : ('cart.send' | translate) }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -722,6 +722,57 @@
|
|||||||
color: white;
|
color: white;
|
||||||
margin: 0 auto 20px;
|
margin: 0 auto 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contact-capture-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface-color, #fff);
|
||||||
|
|
||||||
|
&[aria-invalid='true'] {
|
||||||
|
border-color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
font-size: var(--font-size-xs, 0.75rem);
|
||||||
|
color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
background: var(--primary-color, #2563eb);
|
||||||
|
color: white;
|
||||||
|
font-weight: var(--font-weight-bold, 700);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.error {
|
&.error {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export class CartComponent implements OnDestroy {
|
|||||||
emailError = signal<string>('');
|
emailError = signal<string>('');
|
||||||
phoneError = signal<string>('');
|
phoneError = signal<string>('');
|
||||||
emailSubmitting = signal<boolean>(false);
|
emailSubmitting = signal<boolean>(false);
|
||||||
|
purchaseSubmitted = signal<boolean>(false);
|
||||||
paidItems: CartItem[] = [];
|
paidItems: CartItem[] = [];
|
||||||
|
|
||||||
maxChecks = Math.ceil(PAYMENT_MIN_POLL_SECONDS / (PAYMENT_POLL_INTERVAL_MS / 1000));
|
maxChecks = Math.ceil(PAYMENT_MIN_POLL_SECONDS / (PAYMENT_POLL_INTERVAL_MS / 1000));
|
||||||
@@ -219,6 +220,7 @@ export class CartComponent implements OnDestroy {
|
|||||||
this.emailError.set('');
|
this.emailError.set('');
|
||||||
this.phoneError.set('');
|
this.phoneError.set('');
|
||||||
this.emailSubmitting.set(false);
|
this.emailSubmitting.set(false);
|
||||||
|
this.purchaseSubmitted.set(false);
|
||||||
this.paidItems = [...this.items()];
|
this.paidItems = [...this.items()];
|
||||||
this.createPayment(paymentMethod);
|
this.createPayment(paymentMethod);
|
||||||
}
|
}
|
||||||
@@ -254,7 +256,7 @@ export class CartComponent implements OnDestroy {
|
|||||||
const orderId = this.generateOrderId();
|
const orderId = this.generateOrderId();
|
||||||
const paymentPayload = {
|
const paymentPayload = {
|
||||||
amount: Number(this.totalWithDelivery()),
|
amount: Number(this.totalWithDelivery()),
|
||||||
currency: 'RUB' as const,
|
currency: this.langService.currentCurrency(),
|
||||||
siteuserID: this.getPaymentUserId(),
|
siteuserID: this.getPaymentUserId(),
|
||||||
siteorderID: orderId,
|
siteorderID: orderId,
|
||||||
redirectUrl: '',
|
redirectUrl: '',
|
||||||
@@ -424,7 +426,6 @@ export class CartComponent implements OnDestroy {
|
|||||||
productId: String(item.itemID),
|
productId: String(item.itemID),
|
||||||
name: item.name,
|
name: item.name,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
price: item.discount > 0 ? item.price * (1 - item.discount / 100) : item.price,
|
|
||||||
})),
|
})),
|
||||||
customer: {
|
customer: {
|
||||||
name: this.getTelegramUsername() || this.i18n.t('common.guest'),
|
name: this.getTelegramUsername() || this.i18n.t('common.guest'),
|
||||||
@@ -433,36 +434,46 @@ export class CartComponent implements OnDestroy {
|
|||||||
},
|
},
|
||||||
payment: {
|
payment: {
|
||||||
method: this.selectedPaymentMethod(),
|
method: this.selectedPaymentMethod(),
|
||||||
currency: 'RUB',
|
currency: this.langService.currentCurrency(),
|
||||||
},
|
},
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
error: (err) => console.error('Error recording order:', err),
|
error: (err) => console.error('Error recording order:', err),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback fired a few seconds after payment success if the user hasn't
|
||||||
|
* already submitted the email/phone form themselves (submitEmail()).
|
||||||
|
* Navigates home only once the submission result is known, never before -
|
||||||
|
* and sends whatever the user has typed so far instead of blank fields.
|
||||||
|
*/
|
||||||
private autoSubmitPurchase(): void {
|
private autoSubmitPurchase(): void {
|
||||||
setTimeout(() => {
|
if (this.purchaseSubmitted()) {
|
||||||
const lang = this.langService.currentLanguage();
|
|
||||||
this.router.navigate([`/${lang}`]);}, 0);
|
|
||||||
const telegramUserId = this.getTelegramUserId();
|
|
||||||
|
|
||||||
// Telegram ID is mandatory
|
|
||||||
if (!telegramUserId) {
|
|
||||||
console.error('Cannot submit purchase: Telegram ID is required');
|
|
||||||
this.emailSubmitting.set(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const telegramUserId = this.getTelegramUserId();
|
||||||
|
|
||||||
|
// Telegram ID is mandatory for submitPurchaseEmail.
|
||||||
|
if (!telegramUserId) {
|
||||||
|
this.notifications.show(this.i18n.t('cart.telegramIdMissing'), 'warning');
|
||||||
|
this.emailSubmitting.set(false);
|
||||||
|
this.closePaymentPopup();
|
||||||
|
const lang = this.langService.currentLanguage();
|
||||||
|
this.router.navigate([`/${lang}`]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.emailSubmitting.set(true);
|
this.emailSubmitting.set(true);
|
||||||
|
|
||||||
const emailData = {
|
const emailData = {
|
||||||
email: '',
|
email: this.userEmail().trim(),
|
||||||
phone: '',
|
phone: this.userPhone().replace(/\D/g, ''),
|
||||||
telegramUserId: telegramUserId,
|
telegramUserId: telegramUserId,
|
||||||
items: this.paidItems.map((item: CartItem) => ({
|
items: this.paidItems.map((item: CartItem) => ({
|
||||||
itemID: item.itemID,
|
itemID: item.itemID,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
price: item.discount > 0
|
price: item.discount > 0
|
||||||
? item.price * (1 - item.discount / 100)
|
? item.price * (1 - item.discount / 100)
|
||||||
: item.price,
|
: item.price,
|
||||||
currency: item.currency,
|
currency: item.currency,
|
||||||
@@ -470,9 +481,10 @@ export class CartComponent implements OnDestroy {
|
|||||||
...(item.selectedDelivery ? { delivery: [item.selectedDelivery] } : {})
|
...(item.selectedDelivery ? { delivery: [item.selectedDelivery] } : {})
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
this.apiService.submitPurchaseEmail(emailData).subscribe({
|
this.apiService.submitPurchaseEmail(emailData).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
this.purchaseSubmitted.set(true);
|
||||||
this.emailSubmitting.set(false);
|
this.emailSubmitting.set(false);
|
||||||
this.closePaymentPopup();
|
this.closePaymentPopup();
|
||||||
const lang = this.langService.currentLanguage();
|
const lang = this.langService.currentLanguage();
|
||||||
@@ -488,8 +500,6 @@ export class CartComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.paymentStatus.set(null);
|
this.paymentStatus.set(null);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
copyPaymentLink(): void {
|
copyPaymentLink(): void {
|
||||||
@@ -542,6 +552,11 @@ export class CartComponent implements OnDestroy {
|
|||||||
|
|
||||||
this.apiService.submitPurchaseEmail(emailData).subscribe({
|
this.apiService.submitPurchaseEmail(emailData).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
this.purchaseSubmitted.set(true);
|
||||||
|
if (this.closeTimeout) {
|
||||||
|
clearTimeout(this.closeTimeout);
|
||||||
|
this.closeTimeout = undefined;
|
||||||
|
}
|
||||||
this.emailSubmitting.set(false);
|
this.emailSubmitting.set(false);
|
||||||
this.notifications.show(this.i18n.t('cart.emailSuccess'), 'success');
|
this.notifications.show(this.i18n.t('cart.emailSuccess'), 'success');
|
||||||
// Close popup and redirect to home page
|
// Close popup and redirect to home page
|
||||||
@@ -600,7 +615,7 @@ export class CartComponent implements OnDestroy {
|
|||||||
return hostname;
|
return hostname;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Покупка на Маркетплейсе';
|
return this.i18n.t('cart.paymentDescriptionFallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateOrderId(): string {
|
private generateOrderId(): string {
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
<app-skeleton shape="text" height="16px" />
|
<app-skeleton shape="text" height="16px" />
|
||||||
<app-skeleton shape="text" width="70%" height="16px" />
|
<app-skeleton shape="text" width="70%" height="16px" />
|
||||||
</section>
|
</section>
|
||||||
|
} @else if (error()) {
|
||||||
|
<section class="static-page__state">
|
||||||
|
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate">
|
||||||
|
<div slot="actions">
|
||||||
|
<app-button variant="primary" [routerLink]="homeRoute()">{{ 'staticPages.backHome' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
</app-empty-state>
|
||||||
|
</section>
|
||||||
} @else if (notFound()) {
|
} @else if (notFound()) {
|
||||||
<section class="static-page__state">
|
<section class="static-page__state">
|
||||||
<app-empty-state title="404" [description]="'staticPages.notFound' | translate">
|
<app-empty-state title="404" [description]="'staticPages.notFound' | translate">
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export class StaticPageComponent {
|
|||||||
|
|
||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
readonly notFound = signal(false);
|
readonly notFound = signal(false);
|
||||||
|
readonly error = signal(false);
|
||||||
readonly title = signal('');
|
readonly title = signal('');
|
||||||
readonly homeRoute = signal('');
|
readonly homeRoute = signal('');
|
||||||
readonly dir = signal<'ltr' | 'rtl'>('ltr');
|
readonly dir = signal<'ltr' | 'rtl'>('ltr');
|
||||||
@@ -76,21 +77,32 @@ export class StaticPageComponent {
|
|||||||
private loadByKey(key: string): void {
|
private loadByKey(key: string): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.notFound.set(false);
|
this.notFound.set(false);
|
||||||
|
this.error.set(false);
|
||||||
|
|
||||||
this.staticPageResolver.resolveByKey(key, this.languageService.currentLanguage()).subscribe(page => {
|
this.staticPageResolver.resolveByKey(key, this.languageService.currentLanguage()).subscribe({
|
||||||
this.applyPage(page?.title ?? '', page?.html ?? '', !page);
|
next: page => this.applyPage(page?.title ?? '', page?.html ?? '', !page),
|
||||||
|
error: () => this.applyError()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadByPath(path: string): void {
|
private loadByPath(path: string): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.notFound.set(false);
|
this.notFound.set(false);
|
||||||
|
this.error.set(false);
|
||||||
|
|
||||||
this.staticPageResolver.resolveByRoute(path, this.languageService.currentLanguage()).subscribe(page => {
|
this.staticPageResolver.resolveByRoute(path, this.languageService.currentLanguage()).subscribe({
|
||||||
this.applyPage(page?.title ?? '', page?.html ?? '', !page);
|
next: page => this.applyPage(page?.title ?? '', page?.html ?? '', !page),
|
||||||
|
error: () => this.applyError()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyError(): void {
|
||||||
|
this.title.set('');
|
||||||
|
this.safeHtml.set(this.sanitizer.bypassSecurityTrustHtml(''));
|
||||||
|
this.error.set(true);
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
private applyPage(title: string, html: string, notFound: boolean): void {
|
private applyPage(title: string, html: string, notFound: boolean): void {
|
||||||
if (notFound) {
|
if (notFound) {
|
||||||
this.title.set('');
|
this.title.set('');
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export interface QrCreateResponse {
|
|||||||
|
|
||||||
export interface CartPaymentRequest {
|
export interface CartPaymentRequest {
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: 'RUB';
|
currency: string;
|
||||||
siteuserID: string;
|
siteuserID: string;
|
||||||
siteorderID: string;
|
siteorderID: string;
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
@@ -54,7 +54,12 @@ export interface CartPaymentRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateOrderRequest {
|
export interface CreateOrderRequest {
|
||||||
items: Array<{ productId: string; name: string; quantity: number; price: number }>;
|
/**
|
||||||
|
* No `price` field: the backend must price each line item from its own
|
||||||
|
* catalog by `productId`, never trust a client-supplied amount.
|
||||||
|
* See BACKEND-API-REFERENCE.md §12.
|
||||||
|
*/
|
||||||
|
items: Array<{ productId: string; name: string; quantity: number }>;
|
||||||
customer: { name: string; email: string; phone: string };
|
customer: { name: string; email: string; phone: string };
|
||||||
payment?: { method: string; currency: string };
|
payment?: { method: string; currency: string };
|
||||||
shipping?: { address: string; method: string; trackingNumber: string };
|
shipping?: { address: string; method: string; trackingNumber: string };
|
||||||
@@ -689,6 +694,17 @@ export class ApiService {
|
|||||||
return this.http.post<{ message: string }>(`${this.baseUrl}/purchase-email`, emailData);
|
return this.http.post<{ message: string }>(`${this.baseUrl}/purchase-email`, emailData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back-in-stock subscription. No backend endpoint exists for this yet
|
||||||
|
* (tracked in BACKEND-API-REFERENCE.md §12) - callers should fall back to
|
||||||
|
* a local-only record (see CartService/UX facade patterns) when this 404s
|
||||||
|
* or the request otherwise fails, rather than surfacing an error to the
|
||||||
|
* shopper for something this minor.
|
||||||
|
*/
|
||||||
|
subscribeToRestock(itemID: number, contact: { telegramUserId: string | null; email?: string }): Observable<void> {
|
||||||
|
return this.http.post<void>(`${this.baseUrl}/items/${itemID}/notify-me`, contact);
|
||||||
|
}
|
||||||
|
|
||||||
getRandomItems(count: number = 5, categoryID?: number): Observable<Item[]> {
|
getRandomItems(count: number = 5, categoryID?: number): Observable<Item[]> {
|
||||||
let params = new HttpParams().set('count', count.toString());
|
let params = new HttpParams().set('count', count.toString());
|
||||||
if (categoryID) {
|
if (categoryID) {
|
||||||
|
|||||||
@@ -203,20 +203,30 @@ export class CartService {
|
|||||||
return this.cartItems().findIndex(item => this.isSameCartLine(item, itemID, variant));
|
return this.cartItems().findIndex(item => this.isSameCartLine(item, itemID, variant));
|
||||||
}
|
}
|
||||||
|
|
||||||
addItem(itemID: number, quantity: number = 1, variant?: CartVariant): void {
|
/**
|
||||||
|
* Resolves once the item is actually present in the cart signal - not
|
||||||
|
* merely once the call was fired. New (not-yet-in-cart) items fetch their
|
||||||
|
* details from the API first, so callers that navigate straight after
|
||||||
|
* (e.g. Buy Now -> /cart) must await this rather than treat it as fired
|
||||||
|
* synchronously.
|
||||||
|
*/
|
||||||
|
addItem(itemID: number, quantity: number = 1, variant?: CartVariant): Promise<void> {
|
||||||
// Prevent duplicate API calls for same item
|
// Prevent duplicate API calls for same item
|
||||||
if (this.addingItems.has(itemID)) return;
|
if (this.addingItems.has(itemID)) return Promise.resolve();
|
||||||
|
|
||||||
const currentItems = this.cartItems();
|
const currentItems = this.cartItems();
|
||||||
const existingItem = currentItems.find(i => this.isSameCartLine(i, itemID, variant));
|
const existingItem = currentItems.find(i => this.isSameCartLine(i, itemID, variant));
|
||||||
|
|
||||||
if (existingItem) {
|
if (existingItem) {
|
||||||
// Item exists, increase quantity
|
// Item exists, increase quantity
|
||||||
this.updateQuantity(itemID, existingItem.quantity + quantity, variant);
|
this.updateQuantity(itemID, existingItem.quantity + quantity, variant);
|
||||||
} else {
|
return Promise.resolve();
|
||||||
// Get item details from API and add to cart
|
}
|
||||||
this.addingItems.add(itemID);
|
|
||||||
import('./api.service').then(({ ApiService }) => {
|
// Get item details from API and add to cart
|
||||||
|
this.addingItems.add(itemID);
|
||||||
|
return import('./api.service').then(({ ApiService }) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
this.injector.get(ApiService).getItem(itemID).subscribe({
|
this.injector.get(ApiService).getItem(itemID).subscribe({
|
||||||
next: (item) => {
|
next: (item) => {
|
||||||
const cartItem = this.normalizeCartItem({
|
const cartItem = this.normalizeCartItem({
|
||||||
@@ -229,17 +239,19 @@ export class CartService {
|
|||||||
});
|
});
|
||||||
this.cartItems.set([...this.cartItems(), cartItem]);
|
this.cartItems.set([...this.cartItems(), cartItem]);
|
||||||
this.addingItems.delete(itemID);
|
this.addingItems.delete(itemID);
|
||||||
|
resolve();
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
console.error('Error adding to cart:', err);
|
console.error('Error adding to cart:', err);
|
||||||
this.addingItems.delete(itemID);
|
this.addingItems.delete(itemID);
|
||||||
|
resolve();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}).catch((err) => {
|
})
|
||||||
console.error('Error loading API service:', err);
|
).catch((err) => {
|
||||||
this.addingItems.delete(itemID);
|
console.error('Error loading API service:', err);
|
||||||
});
|
this.addingItems.delete(itemID);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateQuantity(itemID: number, quantity: number, variant?: CartVariant): void {
|
updateQuantity(itemID: number, quantity: number, variant?: CartVariant): void {
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { Item } from '../models';
|
|||||||
import { getDiscountedPrice, getMainImage } from '../utils/item.utils';
|
import { getDiscountedPrice, getMainImage } from '../utils/item.utils';
|
||||||
import { UiRuntimeFacade } from '../facades/runtime/ui-runtime.facade';
|
import { UiRuntimeFacade } from '../facades/runtime/ui-runtime.facade';
|
||||||
import { ConfigService } from '../core/config/config.service';
|
import { ConfigService } from '../core/config/config.service';
|
||||||
|
import { LanguageService } from './language.service';
|
||||||
|
|
||||||
|
const OG_LOCALE_MAP: Record<string, string> = {
|
||||||
|
ru: 'ru_RU',
|
||||||
|
en: 'en_US',
|
||||||
|
hy: 'hy_AM',
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@@ -14,6 +21,7 @@ export class SeoService {
|
|||||||
private doc = inject(DOCUMENT);
|
private doc = inject(DOCUMENT);
|
||||||
private readonly uiRuntime = inject(UiRuntimeFacade);
|
private readonly uiRuntime = inject(UiRuntimeFacade);
|
||||||
private readonly configService = inject(ConfigService);
|
private readonly configService = inject(ConfigService);
|
||||||
|
private readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Keep the runtime <title>/OG/Twitter/canonical/robots tags in sync with
|
// Keep the runtime <title>/OG/Twitter/canonical/robots tags in sync with
|
||||||
@@ -38,6 +46,10 @@ export class SeoService {
|
|||||||
return this.uiRuntime.marketplaceDisplayName() || 'Marketplace';
|
return this.uiRuntime.marketplaceDisplayName() || 'Marketplace';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get ogLocale(): string {
|
||||||
|
return OG_LOCALE_MAP[this.languageService.currentLanguage()] ?? 'en_US';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set Open Graph & Twitter Card meta tags for a product/item page.
|
* Set Open Graph & Twitter Card meta tags for a product/item page.
|
||||||
*/
|
*/
|
||||||
@@ -59,7 +71,7 @@ export class SeoService {
|
|||||||
{ property: 'og:image', content: imageUrl },
|
{ property: 'og:image', content: imageUrl },
|
||||||
{ property: 'og:url', content: itemUrl },
|
{ property: 'og:url', content: itemUrl },
|
||||||
{ property: 'og:site_name', content: this.siteName },
|
{ property: 'og:site_name', content: this.siteName },
|
||||||
{ property: 'og:locale', content: 'ru_RU' },
|
{ property: 'og:locale', content: this.ogLocale },
|
||||||
|
|
||||||
// Product-specific OG tags
|
// Product-specific OG tags
|
||||||
{ property: 'product:price:amount', content: price.toFixed(2) },
|
{ property: 'product:price:amount', content: price.toFixed(2) },
|
||||||
@@ -74,6 +86,22 @@ export class SeoService {
|
|||||||
// Standard meta
|
// Standard meta
|
||||||
{ name: 'description', content: description },
|
{ name: 'description', content: description },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
this.setJsonLd({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Product',
|
||||||
|
name: item.name,
|
||||||
|
description,
|
||||||
|
image: imageUrl,
|
||||||
|
url: itemUrl,
|
||||||
|
offers: {
|
||||||
|
'@type': 'Offer',
|
||||||
|
price: price.toFixed(2),
|
||||||
|
priceCurrency: item.currency || 'RUB',
|
||||||
|
availability: (item.quantity ?? 0) > 0 ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
|
||||||
|
url: itemUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,7 +139,7 @@ export class SeoService {
|
|||||||
{ property: 'og:image', content: defaultImage },
|
{ property: 'og:image', content: defaultImage },
|
||||||
{ property: 'og:url', content: this.siteUrl },
|
{ property: 'og:url', content: this.siteUrl },
|
||||||
{ property: 'og:site_name', content: this.siteName },
|
{ property: 'og:site_name', content: this.siteName },
|
||||||
{ property: 'og:locale', content: 'ru_RU' },
|
{ property: 'og:locale', content: this.ogLocale },
|
||||||
|
|
||||||
{ name: 'twitter:card', content: 'summary_large_image' },
|
{ name: 'twitter:card', content: 'summary_large_image' },
|
||||||
{ name: 'twitter:title', content: defaultTitle },
|
{ name: 'twitter:title', content: defaultTitle },
|
||||||
@@ -127,6 +155,30 @@ export class SeoService {
|
|||||||
// Remove product-specific tags
|
// Remove product-specific tags
|
||||||
this.meta.removeTag("property='product:price:amount'");
|
this.meta.removeTag("property='product:price:amount'");
|
||||||
this.meta.removeTag("property='product:price:currency'");
|
this.meta.removeTag("property='product:price:currency'");
|
||||||
|
|
||||||
|
this.setJsonLd({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: this.siteName,
|
||||||
|
url: this.siteUrl,
|
||||||
|
...(defaultImage ? { logo: defaultImage } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace (or remove, when data is null) the page's JSON-LD structured-data script tag. */
|
||||||
|
private setJsonLd(data: Record<string, unknown> | null): void {
|
||||||
|
const existing = this.doc.getElementById('seo-json-ld');
|
||||||
|
existing?.remove();
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = this.doc.createElement('script');
|
||||||
|
script.id = 'seo-json-ld';
|
||||||
|
script.type = 'application/ld+json';
|
||||||
|
script.text = JSON.stringify(data);
|
||||||
|
this.doc.head.appendChild(script);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setOrUpdate(tags: Array<{ property?: string; name?: string; content: string }>): void {
|
private setOrUpdate(tags: Array<{ property?: string; name?: string; content: string }>): void {
|
||||||
|
|||||||
@@ -2,7 +2,21 @@ import { ThemeConfig } from '../../shared/models/config';
|
|||||||
import { ThemeCssVariables } from '../tokens/theme-css-variable.model';
|
import { ThemeCssVariables } from '../tokens/theme-css-variable.model';
|
||||||
import { THEME_VARIABLE_MAP } from '../tokens/theme-variable-map';
|
import { THEME_VARIABLE_MAP } from '../tokens/theme-variable-map';
|
||||||
|
|
||||||
export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariables {
|
/**
|
||||||
|
* Dark-mode neutral/surface overrides. Brand colors (primary/secondary/
|
||||||
|
* accent/success/warning/danger/info) are intentionally left as configured -
|
||||||
|
* only the background/text/border axis flips for dark mode, same as most
|
||||||
|
* dark-theme implementations.
|
||||||
|
*/
|
||||||
|
const DARK_MODE_OVERRIDES: ThemeCssVariables = {
|
||||||
|
[THEME_VARIABLE_MAP.backgroundPrimary]: '#091413',
|
||||||
|
[THEME_VARIABLE_MAP.backgroundSecondary]: '#285a48',
|
||||||
|
[THEME_VARIABLE_MAP.textPrimary]: '#b0e4cc',
|
||||||
|
[THEME_VARIABLE_MAP.textSecondary]: '#408a71',
|
||||||
|
[THEME_VARIABLE_MAP.border]: '#285a48',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapThemeConfigToCssVariables(theme: ThemeConfig, effectiveMode: 'light' | 'dark' = 'light'): ThemeCssVariables {
|
||||||
const spacingScale = Array.isArray(theme.spacing.scale) && theme.spacing.scale.length > 0
|
const spacingScale = Array.isArray(theme.spacing.scale) && theme.spacing.scale.length > 0
|
||||||
? theme.spacing.scale
|
? theme.spacing.scale
|
||||||
: [0.25, 0.5, 1, 1.5, 2];
|
: [0.25, 0.5, 1, 1.5, 2];
|
||||||
@@ -49,5 +63,5 @@ export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariab
|
|||||||
vars[`--shadow-${key}`] = value;
|
vars[`--shadow-${key}`] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
return vars;
|
return effectiveMode === 'dark' ? { ...vars, ...DARK_MODE_OVERRIDES } : vars;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export class ThemeEngineService {
|
|||||||
private readonly document = inject(DOCUMENT);
|
private readonly document = inject(DOCUMENT);
|
||||||
private readonly configService = inject(ConfigService);
|
private readonly configService = inject(ConfigService);
|
||||||
|
|
||||||
|
private systemDarkQuery?: MediaQueryList;
|
||||||
|
private systemDarkListener?: (event: MediaQueryListEvent) => void;
|
||||||
|
|
||||||
initialize(): void {
|
initialize(): void {
|
||||||
this.configService.loadBootstrap().pipe(take(1)).subscribe({
|
this.configService.loadBootstrap().pipe(take(1)).subscribe({
|
||||||
next: (bootstrap) => this.applyTheme(bootstrap.theme),
|
next: (bootstrap) => this.applyTheme(bootstrap.theme),
|
||||||
@@ -20,7 +23,20 @@ export class ThemeEngineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyTheme(theme: ThemeConfig): void {
|
applyTheme(theme: ThemeConfig): void {
|
||||||
const variables = mapThemeConfigToCssVariables(theme);
|
this.teardownSystemModeListener();
|
||||||
|
|
||||||
|
const effectiveMode = this.resolveEffectiveMode(theme.mode);
|
||||||
|
this.render(theme, effectiveMode);
|
||||||
|
|
||||||
|
if (theme.mode === 'system' && typeof window !== 'undefined' && window.matchMedia) {
|
||||||
|
this.systemDarkQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
this.systemDarkListener = () => this.render(theme, this.resolveEffectiveMode('system'));
|
||||||
|
this.systemDarkQuery.addEventListener('change', this.systemDarkListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(theme: ThemeConfig, effectiveMode: 'light' | 'dark'): void {
|
||||||
|
const variables = mapThemeConfigToCssVariables(theme, effectiveMode);
|
||||||
const root = this.document.documentElement;
|
const root = this.document.documentElement;
|
||||||
|
|
||||||
for (const [name, value] of Object.entries(variables)) {
|
for (const [name, value] of Object.entries(variables)) {
|
||||||
@@ -28,7 +44,27 @@ export class ThemeEngineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
root.setAttribute('data-theme-id', theme.themeId);
|
root.setAttribute('data-theme-id', theme.themeId);
|
||||||
root.setAttribute('data-theme-mode', theme.mode);
|
root.setAttribute('data-theme-mode', effectiveMode);
|
||||||
root.setAttribute('data-icon-set', theme.iconSet);
|
root.setAttribute('data-icon-set', theme.iconSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveEffectiveMode(mode: ThemeConfig['mode']): 'light' | 'dark' {
|
||||||
|
if (mode === 'dark') {
|
||||||
|
return 'dark';
|
||||||
|
}
|
||||||
|
if (mode === 'light') {
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
|
return typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
private teardownSystemModeListener(): void {
|
||||||
|
if (this.systemDarkQuery && this.systemDarkListener) {
|
||||||
|
this.systemDarkQuery.removeEventListener('change', this.systemDarkListener);
|
||||||
|
}
|
||||||
|
this.systemDarkQuery = undefined;
|
||||||
|
this.systemDarkListener = undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Observable, of, map, switchMap } from 'rxjs';
|
import { Observable, of, map, switchMap, catchError } from 'rxjs';
|
||||||
import { CategoryFacade } from '../../facades/platform/category.facade';
|
import { CategoryFacade } from '../../facades/platform/category.facade';
|
||||||
import { ProductFacade } from '../../facades/platform/product.facade';
|
import { ProductFacade } from '../../facades/platform/product.facade';
|
||||||
import { LanguageService } from '../../services/language.service';
|
import { LanguageService } from '../../services/language.service';
|
||||||
@@ -19,7 +19,11 @@ export class DataSourceResolverService {
|
|||||||
|
|
||||||
resolve(widget: WidgetConfig, section: SectionConfig): Observable<unknown> {
|
resolve(widget: WidgetConfig, section: SectionConfig): Observable<unknown> {
|
||||||
return this.widgetManifest.getWidget(widget.type).pipe(
|
return this.widgetManifest.getWidget(widget.type).pipe(
|
||||||
switchMap((definition) => this.resolveByDefinition(definition, widget, section))
|
switchMap((definition) => this.resolveByDefinition(definition, widget, section)),
|
||||||
|
catchError((error) => {
|
||||||
|
console.error(`Failed to resolve widget data for ${widget.type}:${widget.id}`, error);
|
||||||
|
return of({ section, settings: {} });
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ const SWIPE_THRESHOLD_PX = 50;
|
|||||||
(click)="goTo($index)"
|
(click)="goTo($index)"
|
||||||
></button>
|
></button>
|
||||||
}
|
}
|
||||||
|
@if (data?.autoplay) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hero-widget__pause"
|
||||||
|
[attr.aria-label]="(isPaused() ? 'common.resumeAutoplay' : 'common.pauseAutoplay') | translate"
|
||||||
|
[attr.aria-pressed]="isPaused()"
|
||||||
|
(click)="toggleAutoplay()"
|
||||||
|
>{{ isPaused() ? '▶' : '⏸' }}</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,6 +180,23 @@ const SWIPE_THRESHOLD_PX = 50;
|
|||||||
&:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; }
|
&:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-widget__pause {
|
||||||
|
margin-left: var(--space-sm, 8px);
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border-color, #d3dad9);
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes hero-widget-in {
|
@keyframes hero-widget-in {
|
||||||
from { opacity: 0; transform: translateY(12px); }
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
@@ -199,6 +225,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
|||||||
@Output() ctaClicked = new EventEmitter<void>();
|
@Output() ctaClicked = new EventEmitter<void>();
|
||||||
|
|
||||||
readonly activeIndex = signal(0);
|
readonly activeIndex = signal(0);
|
||||||
|
readonly isPaused = signal(false);
|
||||||
private readonly dataSignal = signal<HeroWidgetData | null>(null);
|
private readonly dataSignal = signal<HeroWidgetData | null>(null);
|
||||||
private autoplayHandle: ReturnType<typeof setInterval> | null = null;
|
private autoplayHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
private swipeStartX: number | null = null;
|
private swipeStartX: number | null = null;
|
||||||
@@ -237,6 +264,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
|||||||
if (changes['data']) {
|
if (changes['data']) {
|
||||||
this.dataSignal.set(this.data);
|
this.dataSignal.set(this.data);
|
||||||
this.activeIndex.set(0);
|
this.activeIndex.set(0);
|
||||||
|
this.isPaused.set(false);
|
||||||
this.setupAutoplay();
|
this.setupAutoplay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,10 +317,20 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
|||||||
this.ctaClicked.emit();
|
this.ctaClicked.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** WCAG 2.2.2: auto-updating content lasting >5s needs a way to pause it. */
|
||||||
|
toggleAutoplay(): void {
|
||||||
|
this.isPaused.update(paused => !paused);
|
||||||
|
this.setupAutoplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private prefersReducedMotion(): boolean {
|
||||||
|
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
private setupAutoplay(): void {
|
private setupAutoplay(): void {
|
||||||
this.clearAutoplay();
|
this.clearAutoplay();
|
||||||
const slides = this.allSlides();
|
const slides = this.allSlides();
|
||||||
if (!this.data?.autoplay || slides.length <= 1) {
|
if (!this.data?.autoplay || slides.length <= 1 || this.isPaused() || this.prefersReducedMotion()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.autoplayHandle = setInterval(() => {
|
this.autoplayHandle = setInterval(() => {
|
||||||
|
|||||||
@@ -352,6 +352,19 @@
|
|||||||
"en": "<h2>Terms of Service</h2><p>Platform usage is governed by public offer terms.</p>",
|
"en": "<h2>Terms of Service</h2><p>Platform usage is governed by public offer terms.</p>",
|
||||||
"hy": "<h2>Օգտագործման պայմաններ</h2><p>Հարթակի օգտագործումը կարգավորվում է հրապարակային առաջարկի պայմաններով։</p>"
|
"hy": "<h2>Օգտագործման պայմաններ</h2><p>Հարթակի օգտագործումը կարգավորվում է հրապարակային առաջարկի պայմաններով։</p>"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"contacts": {
|
||||||
|
"route": "/contacts",
|
||||||
|
"title": {
|
||||||
|
"ru": "Контакты",
|
||||||
|
"en": "Contacts",
|
||||||
|
"hy": "Կապ"
|
||||||
|
},
|
||||||
|
"html": {
|
||||||
|
"ru": "<h2>Контакты</h2><p>Здесь будет размещена контактная информация продавца — адрес, телефон, email и часы работы. Заполняется администратором маркетплейса.</p>",
|
||||||
|
"en": "<h2>Contacts</h2><p>Seller contact details (address, phone, email, business hours) go here. Fill this in from the admin panel.</p>",
|
||||||
|
"hy": "<h2>Կապ</h2><p>Այստեղ կտեղադրվի վաճառողի կոնտակտային տեղեկատվությունը՝ հասցե, հեռախոս, էլ. փոստ և աշխատանքային ժամեր։ Լրացվում է կայքի ադմինիստրատորի կողմից։</p>"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": [
|
"pages": [
|
||||||
|
|||||||
Reference in New Issue
Block a user