Compare commits
38 Commits
570e3f3c36
...
07367d3183
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07367d3183 | ||
|
|
357d346787 | ||
|
|
23d9f2f66f | ||
|
|
00e5ce6b20 | ||
|
|
a339a1c64e | ||
|
|
c9a80da7c3 | ||
|
|
bad3002006 | ||
|
|
178b5f0dc7 | ||
|
|
6cc5d43a10 | ||
|
|
b04e3a67f5 | ||
|
|
0b08802996 | ||
|
|
1af337f005 | ||
|
|
3feb806caa | ||
|
|
ec8ed8f6a8 | ||
|
|
8937aea57c | ||
|
|
c0bce7feac | ||
|
|
9f784406d7 | ||
|
|
461cd8421c | ||
|
|
6231128288 | ||
|
|
fd8e7e1b28 | ||
|
|
000bb78112 | ||
|
|
d3d6632375 | ||
|
|
1e84d67e24 | ||
|
|
c461d9bd5f | ||
|
|
63039b8707 | ||
|
|
908f10e022 | ||
|
|
49aab63124 | ||
|
|
320f1f44b7 | ||
|
|
8a91a862ca | ||
|
|
4ef5ea2f58 | ||
|
|
4c4417dc1d | ||
|
|
ebca66dd4c | ||
|
|
c7d8ef1295 | ||
|
|
f336420415 | ||
|
|
9fa3321322 | ||
|
|
bac415d003 | ||
|
|
0646d587eb | ||
|
|
c3b5820ac9 |
@@ -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.
|
||||
|
||||
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-XSS-Protection "1; mode=block" 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.
|
||||
@@ -178,4 +180,6 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" 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 { languageGuard } from './guards/language.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 { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
|
||||
import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard';
|
||||
@@ -229,6 +229,7 @@ const coreRoutes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
canActivate: [requireAdminPermission('users.manage')],
|
||||
loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent),
|
||||
data: {
|
||||
titleKey: 'adminShell.pages.users.title',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn } from '@angular/router';
|
||||
import { AdminAuthService } from './admin-auth.service';
|
||||
import { AdminPermissionsService } from './admin-permissions.service';
|
||||
|
||||
/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */
|
||||
export const adminAuthGuard: CanActivateFn = () => {
|
||||
@@ -13,3 +14,22 @@ export const adminAuthGuard: CanActivateFn = () => {
|
||||
adminAuth.requestLogin();
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
export function authErrorCodeFromStatus(status: number): AuthErrorCode {
|
||||
switch (status) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { catchError, switchMap, tap, throwError } from 'rxjs';
|
||||
import { Observable } from 'rxjs';
|
||||
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 { Ed25519KeypairService } from './ed25519-keypair.service';
|
||||
import { SessionService } from './session.service';
|
||||
@@ -109,7 +109,9 @@ export class AuthService {
|
||||
|
||||
private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError {
|
||||
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) {
|
||||
return { code: fallbackCode, message: error.message };
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { RuntimeProviderStrategyService } from '../providers/runtime-provider-strategy.service';
|
||||
import { ApiCategoryRepository } from './repositories/api-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', {
|
||||
providedIn: 'root',
|
||||
factory: () => {
|
||||
const strategy = inject(RuntimeProviderStrategyService);
|
||||
const apiRepository = inject(ApiCategoryRepository);
|
||||
|
||||
switch (strategy.getCategoryProviderMode()) {
|
||||
case 'mock':
|
||||
case 'remote-config':
|
||||
case 'api':
|
||||
default:
|
||||
return apiRepository;
|
||||
}
|
||||
}
|
||||
factory: () => inject(ApiCategoryRepository)
|
||||
});
|
||||
@@ -1,20 +1,14 @@
|
||||
import { InjectionToken, inject } from '@angular/core';
|
||||
import { RuntimeProviderStrategyService } from '../providers/runtime-provider-strategy.service';
|
||||
import { ApiProductDataProvider } from './providers/api-product-data.provider';
|
||||
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', {
|
||||
providedIn: 'root',
|
||||
factory: () => {
|
||||
const strategy = inject(RuntimeProviderStrategyService);
|
||||
const apiProvider = inject(ApiProductDataProvider);
|
||||
|
||||
switch (strategy.getProductProviderMode()) {
|
||||
case 'mock':
|
||||
case 'remote-config':
|
||||
case 'api':
|
||||
default:
|
||||
return apiProvider;
|
||||
}
|
||||
}
|
||||
factory: () => inject(ApiProductDataProvider)
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { PageConfig } from '../../shared/models/config';
|
||||
import { PageRenderModel } from '../page-renderer/page-renderer.model';
|
||||
import { SectionRendererService } from '../section-renderer/section-renderer.service';
|
||||
import { PlatformLayoutConfig, PlatformLayoutType } from '../../shared/models/config';
|
||||
import { SectionConfig } from '../../shared/models/config';
|
||||
import { ConfigService } from '../../core/config/config.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SectionEngineService {
|
||||
private readonly configService = inject(ConfigService);
|
||||
|
||||
constructor(private readonly sectionRenderer: SectionRendererService) {}
|
||||
|
||||
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 {
|
||||
if (typeof layout === 'string') {
|
||||
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'] {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 {
|
||||
AdminAnalyticsDateRange,
|
||||
AdminAnalyticsSeriesPoint,
|
||||
@@ -62,7 +63,13 @@ export class AdminAnalyticsFacade {
|
||||
|
||||
readonly warnings = computed(() => this.recommendations().filter(card => card.severity !== 'info'));
|
||||
|
||||
private readonly cancelPreviousLoad$ = new Subject<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.error.set(false);
|
||||
this.dashboardFacade.ensureLoaded();
|
||||
@@ -74,10 +81,13 @@ export class AdminAnalyticsFacade {
|
||||
})),
|
||||
);
|
||||
|
||||
const fail = (): void => { this.loading.set(false); this.error.set(true); };
|
||||
|
||||
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
||||
next: orderResult => {
|
||||
forkJoin({
|
||||
orderResult: this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }),
|
||||
productResult: this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }),
|
||||
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 inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff);
|
||||
|
||||
@@ -89,43 +99,28 @@ export class AdminAnalyticsFacade {
|
||||
const ordersCount = inRange.length;
|
||||
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({
|
||||
next: productResult => {
|
||||
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;
|
||||
const products = productResult.items;
|
||||
const reviews = reviewResult.items;
|
||||
|
||||
this.summary.set({
|
||||
revenueTotal,
|
||||
currency: inRange[0]?.currency ?? 'RUB',
|
||||
ordersCount,
|
||||
avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0,
|
||||
productsCount: products.length,
|
||||
categoriesCount: categories.length,
|
||||
customersCount: uniqueCustomers,
|
||||
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.summary.set({
|
||||
revenueTotal,
|
||||
currency: inRange[0]?.currency ?? 'RUB',
|
||||
ordersCount,
|
||||
avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0,
|
||||
productsCount: products.length,
|
||||
categoriesCount: categories.length,
|
||||
customersCount: uniqueCustomers,
|
||||
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: () => { 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" /> }
|
||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||
</div>
|
||||
} @else if (error) {
|
||||
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
|
||||
} @else if (treeRows.length === 0) {
|
||||
<app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate">
|
||||
<span slot="actions">
|
||||
|
||||
@@ -51,6 +51,7 @@ export class AdminCategoriesListComponent {
|
||||
@Input() flatCategories: AdminCategory[] = [];
|
||||
@Input() filters!: { search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean };
|
||||
@Input() loading = false;
|
||||
@Input() error: string | null = null;
|
||||
@Input() viewMode: AdminCategoriesViewMode = 'tree';
|
||||
@Input() density: AdminCategoriesDensity = 'comfortable';
|
||||
@Input() visibleColumns: AdminCategoryColumn[] = [...ALL_CATEGORY_COLUMNS];
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<app-form-field [label]="'adminCategories.title' | translate" [required]="true">
|
||||
<app-input [ngModel]="category.title" (ngModelChange)="updateTitle($event)" />
|
||||
</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-form-field>
|
||||
<label><span>{{ 'adminCategories.parent' | translate }}</span>
|
||||
|
||||
@@ -41,6 +41,7 @@ export class AdminCategoryFormComponent {
|
||||
@Input() breadcrumb: string[] = [];
|
||||
@Input() children: AdminCategory[] = [];
|
||||
@Input() slugTaken = false;
|
||||
@Input() slugCheckError = false;
|
||||
@Input() locales: string[] = ['en'];
|
||||
@Input() mode: 'create' | 'edit' = 'create';
|
||||
@Input() health!: AdminCategoryHealth;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { AdminCategoriesFormFactory } from '../services/admin-categories-form.factory';
|
||||
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 categories = signal<AdminCategory[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly draft = signal<AdminCategory | null>(null);
|
||||
readonly editorMode = signal<AdminCategoryEditorMode>('create');
|
||||
readonly dirty = signal(false);
|
||||
readonly slugTaken = signal(false);
|
||||
readonly slugCheckError = signal(false);
|
||||
readonly mutationError = signal<string | null>(null);
|
||||
private savedSnapshot: string | null = null;
|
||||
private draftStorageKey: string | null = null;
|
||||
|
||||
@@ -173,6 +176,7 @@ export class AdminCategoriesFacade {
|
||||
*/
|
||||
loadList(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({
|
||||
next: categories => {
|
||||
this.categories.set(categories);
|
||||
@@ -181,6 +185,7 @@ export class AdminCategoriesFacade {
|
||||
error: () => {
|
||||
this.categories.set([]);
|
||||
this.loading.set(false);
|
||||
this.error.set('common.errorDescription');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -305,16 +310,28 @@ export class AdminCategoriesFacade {
|
||||
const draft = this.draft();
|
||||
if (!draft || !draft.slug) {
|
||||
this.slugTaken.set(false);
|
||||
this.slugCheckError.set(false);
|
||||
return;
|
||||
}
|
||||
this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(take(1))
|
||||
.subscribe(taken => this.slugTaken.set(taken));
|
||||
this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(
|
||||
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();
|
||||
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 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.dirty.set(false);
|
||||
this.loadList();
|
||||
}
|
||||
onSuccess?.();
|
||||
},
|
||||
error: () => this.mutationError.set('common.errorDescription')
|
||||
});
|
||||
}
|
||||
|
||||
@@ -342,7 +361,11 @@ export class AdminCategoriesFacade {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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 { AdminCategoriesFacade } from '../facade/admin-categories.facade';
|
||||
import { AdminCategoriesListComponent } from '../components/admin-categories-list.component';
|
||||
import { LanguageService } from '../../../../services/language.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({
|
||||
selector: 'app-admin-categories-list-page',
|
||||
standalone: true,
|
||||
imports: [AdminCategoriesListComponent],
|
||||
imports: [AdminCategoriesListComponent, ConfirmDialogComponent, DialogComponent, ButtonComponent, TranslatePipe],
|
||||
template: `<app-admin-categories-list
|
||||
[treeRows]="facade.visibleTreeRows()"
|
||||
[flatCategories]="facade.filteredCategories()"
|
||||
[allCategories]="facade.categories()"
|
||||
[filters]="facade.filters()"
|
||||
[loading]="facade.loading()"
|
||||
[error]="facade.error()"
|
||||
[viewMode]="facade.viewMode()"
|
||||
[density]="facade.density()"
|
||||
[visibleColumns]="facade.visibleColumns()"
|
||||
@@ -37,14 +42,44 @@ import { TranslateService } from '../../../../i18n/translate.service';
|
||||
(selectionChange)="facade.toggleSelection($event.id, $event.checked)"
|
||||
(selectAll)="facade.toggleAll($event)"
|
||||
(bulkVisibility)="facade.applyBulkVisibility($event)"
|
||||
(bulkDelete)="facade.applyBulkDelete()"
|
||||
(bulkDelete)="bulkDelete()"
|
||||
(bulkDuplicate)="facade.applyBulkDuplicate()"
|
||||
(bulkAssignParent)="facade.applyBulkAssignParent($event)"
|
||||
(bulkAssignImage)="facade.applyBulkAssignImage($event)"
|
||||
(bulkExport)="facade.exportSelectedAsCsv()"
|
||||
(viewModeChange)="facade.setViewMode($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
|
||||
})
|
||||
export class AdminCategoriesListPageComponent {
|
||||
@@ -57,6 +92,10 @@ export class AdminCategoriesListPageComponent {
|
||||
readonly isExpandedFn = (id: string) => this.facade.isExpanded(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() {
|
||||
this.facade.loadList();
|
||||
this.facade.loadDashboardStats();
|
||||
@@ -75,12 +114,27 @@ export class AdminCategoriesListPageComponent {
|
||||
|
||||
deleteOne(id: string): void {
|
||||
if (!this.facade.canDelete(id)) {
|
||||
window.alert(this.translate.t('adminCategories.deleteBlocked'));
|
||||
this.deleteBlockedMessage.set(this.translate.t('adminCategories.deleteBlocked'));
|
||||
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.pendingDeleteId.set(null);
|
||||
}
|
||||
|
||||
bulkDelete(): void {
|
||||
this.bulkDeleteConfirmOpen.set(true);
|
||||
}
|
||||
|
||||
confirmBulkDelete(): void {
|
||||
this.facade.applyBulkDelete();
|
||||
this.bulkDeleteConfirmOpen.set(false);
|
||||
}
|
||||
|
||||
private lang(): string {
|
||||
|
||||
@@ -4,12 +4,22 @@ import { AdminCategoriesFacade } from '../facade/admin-categories.facade';
|
||||
import { AdminCategoryFormComponent } from '../components/admin-category-form.component';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-category-editor-page',
|
||||
standalone: true,
|
||||
imports: [AdminCategoryFormComponent, TranslatePipe],
|
||||
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>}`,
|
||||
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()" [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; }`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
@@ -41,8 +51,9 @@ export class AdminCategoryEditorPageComponent {
|
||||
}
|
||||
|
||||
save(publish: boolean): void {
|
||||
this.facade.saveDraft(publish);
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']);
|
||||
this.facade.saveDraft(publish, () => {
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']);
|
||||
});
|
||||
}
|
||||
|
||||
private descendantIds(id: string): string[] {
|
||||
|
||||
@@ -59,8 +59,7 @@ export class AdminCategoriesApiGateway implements AdminCategoriesGateway {
|
||||
params = params.set('excludingId', excludingId);
|
||||
}
|
||||
return this.http.get<{ taken: boolean }>(`${this.baseUrl}/slug-taken`, { params }).pipe(
|
||||
map(response => response.taken),
|
||||
catchError(() => of(false))
|
||||
map(response => response.taken)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,11 @@ export class AdminCustomersFacade {
|
||||
|
||||
readonly customers = signal<AdminCustomer[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly search = signal('');
|
||||
readonly selected = signal<AdminCustomer | null>(null);
|
||||
readonly selectedLoading = signal(false);
|
||||
readonly selectedError = signal<string | null>(null);
|
||||
|
||||
private buildCustomers(orders: AdminOrder[]): AdminCustomer[] {
|
||||
const byEmail = new Map<string, AdminOrder[]>();
|
||||
@@ -41,6 +44,7 @@ export class AdminCustomersFacade {
|
||||
|
||||
loadList(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
|
||||
next: result => {
|
||||
this.customers.set(this.buildCustomers(result.items));
|
||||
@@ -49,16 +53,24 @@ export class AdminCustomersFacade {
|
||||
error: () => {
|
||||
this.customers.set([]);
|
||||
this.loading.set(false);
|
||||
this.error.set('common.errorDescription');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
next: result => {
|
||||
const decoded = decodeURIComponent(email);
|
||||
const customers = this.buildCustomers(result.items);
|
||||
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" />
|
||||
</section>
|
||||
</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 {
|
||||
<p>{{ 'common.loading' | translate }}</p>
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
|
||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||
</div>
|
||||
} @else if (facade.error()) {
|
||||
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
|
||||
} @else if (facade.filteredCustomers().length === 0) {
|
||||
<app-empty-state [title]="'adminCustomers.emptyTitle' | translate" [description]="'adminCustomers.emptyDescription' | translate" />
|
||||
} @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)="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="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>
|
||||
}
|
||||
<app-confirm-dialog
|
||||
[open]="bulkDeleteConfirmOpen()"
|
||||
[titleText]="'adminModeration.deleteSelected' | translate"
|
||||
[message]="'adminModeration.confirmBulkDelete' | translate"
|
||||
[destructive]="true"
|
||||
(confirmed)="confirmBulkDelete()"
|
||||
(cancelled)="bulkDeleteConfirmOpen.set(false)" />
|
||||
|
||||
@if (facade.loading()) {
|
||||
<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 { ModerationDashboardComponent } from '../components/moderation-dashboard/moderation-dashboard.component';
|
||||
import { ReviewHealthWidgetComponent, ReviewHealthItem } from '../components/review-health-widget/review-health-widget.component';
|
||||
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-reviews-list-page',
|
||||
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',
|
||||
styleUrls: ['./admin-reviews-list-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -35,6 +36,7 @@ export class AdminReviewsListPageComponent {
|
||||
readonly allColumns = ALL_REVIEW_COLUMNS;
|
||||
protected readonly columnsPanelOpen = signal(false);
|
||||
protected readonly bulkStatusValue = signal<AdminReviewStatus>('approved');
|
||||
protected readonly bulkDeleteConfirmOpen = signal(false);
|
||||
|
||||
constructor() {
|
||||
this.facade.loadList();
|
||||
@@ -45,6 +47,15 @@ export class AdminReviewsListPageComponent {
|
||||
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 {
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'moderation', id]);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
}
|
||||
</div>
|
||||
<p class="order-timeline__note">{{ entry.note }}</p>
|
||||
@if (entry.actor) {
|
||||
<span class="order-timeline__actor">{{ entry.actor }}</span>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -55,3 +55,10 @@
|
||||
font-size: var(--font-size-sm, 0.8125rem);
|
||||
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. */
|
||||
note: string;
|
||||
orderNumber?: string;
|
||||
actor?: string;
|
||||
}
|
||||
|
||||
/** 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 total = signal(0);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | 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 density = signal<AdminOrdersDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminOrdersDensity) || 'comfortable');
|
||||
@@ -76,6 +79,7 @@ export class AdminOrdersFacade {
|
||||
|
||||
loadList(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.gateway.loadOrders(this.filters()).pipe(take(1)).subscribe({
|
||||
next: result => {
|
||||
this.orders.set(result.items);
|
||||
@@ -86,6 +90,7 @@ export class AdminOrdersFacade {
|
||||
this.orders.set([]);
|
||||
this.total.set(0);
|
||||
this.loading.set(false);
|
||||
this.error.set('common.errorDescription');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -96,7 +101,18 @@ export class AdminOrdersFacade {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UUID } from '../../../../shared/types/primitive.types';
|
||||
|
||||
export type AdminOrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
|
||||
export type AdminOrderPaymentStatus = 'unpaid' | 'paid' | 'refund_requested' | 'refunded';
|
||||
|
||||
@@ -33,6 +35,7 @@ export interface AdminOrderTimelineEntry {
|
||||
status: AdminOrderStatus;
|
||||
timestamp: string;
|
||||
eventKey: AdminOrderTimelineEventKey;
|
||||
actor: string;
|
||||
}
|
||||
|
||||
export interface AdminOrder {
|
||||
@@ -56,7 +59,7 @@ export interface AdminOrder {
|
||||
* Absent means marketplace-owned, exactly like every order today -
|
||||
* nothing reads this field yet, no behavior change.
|
||||
*/
|
||||
sellerId?: string;
|
||||
sellerId?: UUID;
|
||||
}
|
||||
|
||||
export interface AdminOrderListFilters {
|
||||
|
||||
@@ -46,14 +46,17 @@
|
||||
</div>
|
||||
<div class="card no-print">
|
||||
<h3>{{ 'adminOrders.changeStatus' | translate }}</h3>
|
||||
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)">
|
||||
@for (status of statuses; track status) {
|
||||
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)" [disabled]="isTerminal()">
|
||||
@for (status of selectableStatuses; track status) {
|
||||
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
|
||||
}
|
||||
@if (isTerminal()) {
|
||||
<option [value]="order.status">{{ ('adminOrders.status.' + order.status) | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
<div class="actions">
|
||||
<app-button variant="secondary" size="sm" (click)="requestRefund(order.id)">{{ 'adminOrders.requestRefund' | translate }}</app-button>
|
||||
<app-button variant="danger" size="sm" (click)="cancel(order.id)">{{ 'adminOrders.cancelOrder' | 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)" [disabled]="isTerminal()">{{ 'adminOrders.cancelOrder' | translate }}</app-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -85,7 +88,27 @@
|
||||
<app-button variant="secondary" size="sm" (click)="submitInternalNote(order.id)">{{ 'adminOrders.addNote' | translate }}</app-button>
|
||||
</div>
|
||||
</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>
|
||||
} @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 {
|
||||
<p>{{ 'common.loading' | translate }}</p>
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { LanguageService } from '../../../../services/language.service';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.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 TERMINAL_STATUSES: AdminOrderStatus[] = ['cancelled', 'refunded'];
|
||||
@@ -17,7 +18,7 @@ const TERMINAL_STATUSES: AdminOrderStatus[] = ['cancelled', 'refunded'];
|
||||
@Component({
|
||||
selector: 'app-admin-order-detail-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, OrderTimelineComponent],
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, OrderTimelineComponent, ConfirmDialogComponent],
|
||||
templateUrl: './admin-order-detail-page.component.html',
|
||||
styleUrls: ['./admin-order-detail-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -30,8 +31,12 @@ export class AdminOrderDetailPageComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
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 noteDraft = signal('');
|
||||
readonly pendingCancelId = signal<string | null>(null);
|
||||
readonly pendingRefundId = signal<string | null>(null);
|
||||
readonly internalNoteDraft = signal('');
|
||||
|
||||
readonly isTerminal = computed(() => {
|
||||
@@ -54,6 +59,7 @@ export class AdminOrderDetailPageComponent {
|
||||
note: this.translate.t('adminOrders.timelineEvent.' + entry.eventKey, {
|
||||
status: this.translate.t('adminOrders.status.' + entry.status),
|
||||
}),
|
||||
actor: entry.actor,
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -73,19 +79,36 @@ export class AdminOrderDetailPageComponent {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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.pendingCancelId.set(null);
|
||||
}
|
||||
|
||||
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.pendingRefundId.set(null);
|
||||
}
|
||||
|
||||
submitNote(id: string): void {
|
||||
|
||||
@@ -37,23 +37,32 @@
|
||||
<div class="bulk-actions">
|
||||
<span>{{ facade.selectedIds().length }} {{ 'adminProducts.selectedCount' | translate }}</span>
|
||||
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="bulkStatusValue()" (ngModelChange)="bulkStatusValue.set($event)">
|
||||
@for (status of statuses; track status) {
|
||||
@if (status !== 'all') { <option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option> }
|
||||
@for (status of bulkSelectableStatuses; track status) {
|
||||
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
<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.exportSelectedAsCsv()">{{ 'adminProducts.bulkExportAction' | 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>
|
||||
}
|
||||
<app-confirm-dialog
|
||||
[open]="bulkDeleteConfirmOpen()"
|
||||
[titleText]="'adminOrders.bulkDeleteTitle' | translate"
|
||||
[message]="'adminOrders.confirmBulkDelete' | translate"
|
||||
[destructive]="true"
|
||||
(confirmed)="confirmBulkDelete()"
|
||||
(cancelled)="bulkDeleteConfirmOpen.set(false)" />
|
||||
|
||||
@if (facade.loading()) {
|
||||
<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" /> }
|
||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||
</div>
|
||||
} @else if (facade.error()) {
|
||||
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
|
||||
} @else if (facade.orders().length === 0) {
|
||||
<app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" />
|
||||
} @else {
|
||||
|
||||
@@ -15,11 +15,12 @@ import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-sta
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
import { CardComponent } from '../../../../shared/ui/card/card.component';
|
||||
import { OrdersDashboardComponent } from '../components/orders-dashboard/orders-dashboard.component';
|
||||
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-orders-list-page',
|
||||
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',
|
||||
styleUrls: ['./admin-orders-list-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -30,9 +31,12 @@ export class AdminOrdersListPageComponent {
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
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;
|
||||
protected readonly columnsPanelOpen = signal(false);
|
||||
protected readonly bulkStatusValue = signal<AdminOrderStatus>('pending');
|
||||
protected readonly bulkDeleteConfirmOpen = signal(false);
|
||||
|
||||
constructor() {
|
||||
this.facade.loadList();
|
||||
@@ -59,6 +63,15 @@ export class AdminOrdersListPageComponent {
|
||||
this.facade.applyBulkStatus(this.bulkStatusValue());
|
||||
}
|
||||
|
||||
bulkDelete(): void {
|
||||
this.bulkDeleteConfirmOpen.set(true);
|
||||
}
|
||||
|
||||
confirmBulkDelete(): void {
|
||||
this.facade.applyBulkDelete();
|
||||
this.bulkDeleteConfirmOpen.set(false);
|
||||
}
|
||||
|
||||
printSelection(): void {
|
||||
window.print();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import { AdminOrder, AdminOrderListFilters, AdminOrdersListResult, AdminOrderStatus } from '../models/admin-order.model';
|
||||
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 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' })
|
||||
export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
||||
private readonly adminAuth = inject(AdminAuthService);
|
||||
private cache: AdminOrder[] | null = null;
|
||||
|
||||
private get currentActor(): string {
|
||||
return this.adminAuth.displayName() ?? 'admin';
|
||||
}
|
||||
|
||||
loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult> {
|
||||
const all = this.ensureData();
|
||||
const filtered = all
|
||||
@@ -36,7 +42,7 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
|
||||
...order,
|
||||
status,
|
||||
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,
|
||||
payment: { ...order.payment, status: 'refund_requested' },
|
||||
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: '',
|
||||
internalNotes: '',
|
||||
timeline: [
|
||||
{ status: 'pending', timestamp: createdAt, eventKey: 'created' },
|
||||
...(status !== 'pending' ? [{ status, timestamp: createdAt, eventKey: 'statusChanged' as const }] : []),
|
||||
{ status: 'pending', timestamp: createdAt, eventKey: 'created', actor: 'system' },
|
||||
...(status !== 'pending' ? [{ status, timestamp: createdAt, eventKey: 'statusChanged' as const, actor: 'system' }] : []),
|
||||
],
|
||||
archived: false,
|
||||
createdAt,
|
||||
|
||||
@@ -78,6 +78,8 @@
|
||||
}
|
||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||
</div>
|
||||
} @else if (error) {
|
||||
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
|
||||
} @else if (products.length === 0) {
|
||||
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate">
|
||||
<span slot="actions">
|
||||
|
||||
@@ -44,6 +44,7 @@ export class AdminProductsListComponent {
|
||||
@Input() total = 0;
|
||||
@Input() selectedIds: string[] = [];
|
||||
@Input() loading = false;
|
||||
@Input() error: string | null = null;
|
||||
@Input() infiniteScroll = false;
|
||||
@Input() viewMode: AdminProductsViewMode = 'table';
|
||||
@Input() density: AdminProductsDensity = 'comfortable';
|
||||
|
||||
@@ -91,6 +91,8 @@ export class AdminProductsFacade {
|
||||
readonly infiniteScroll = signal(false);
|
||||
readonly categories = signal<AdminProductCategoryOption[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly mutationError = signal<string | null>(null);
|
||||
readonly selectedIds = signal<string[]>([]);
|
||||
readonly draft = signal<AdminProduct | null>(null);
|
||||
readonly dirty = signal(false);
|
||||
@@ -100,6 +102,7 @@ export class AdminProductsFacade {
|
||||
|
||||
loadList(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.gateway.loadProducts(this.filters()).pipe(take(1)).subscribe({
|
||||
next: result => {
|
||||
this.products.set(result.items);
|
||||
@@ -110,6 +113,7 @@ export class AdminProductsFacade {
|
||||
this.products.set([]);
|
||||
this.total.set(0);
|
||||
this.loading.set(false);
|
||||
this.error.set('common.errorDescription');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -302,18 +306,26 @@ export class AdminProductsFacade {
|
||||
this.dirty.set(true);
|
||||
}
|
||||
|
||||
saveDraft(): void {
|
||||
saveDraft(onSuccess?: () => void): void {
|
||||
const draft = this.draft();
|
||||
if (!draft) return;
|
||||
|
||||
this.mutationError.set(null);
|
||||
const request = this.editorMode() === 'create'
|
||||
? this.gateway.createProduct(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 {
|
||||
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 AdminProductSort = 'title' | 'price' | 'priority' | 'stock' | 'updated';
|
||||
export type AdminProductEditorMode = 'create' | 'edit' | 'duplicate';
|
||||
@@ -117,7 +119,7 @@ export interface AdminProduct {
|
||||
* Absent means marketplace-owned, exactly like every product today -
|
||||
* nothing reads this field yet, nothing breaks by it being undefined.
|
||||
*/
|
||||
sellerId?: string;
|
||||
sellerId?: UUID;
|
||||
}
|
||||
|
||||
export interface AdminProductListFilters {
|
||||
|
||||
@@ -4,12 +4,22 @@ import { AdminProductsFacade } from '../facade/admin-products.facade';
|
||||
import { AdminProductFormComponent } from '../components/admin-product-form.component';
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../../../services/language.service';
|
||||
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-product-editor-page',
|
||||
standalone: true,
|
||||
imports: [AdminProductFormComponent, TranslatePipe],
|
||||
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>}`,
|
||||
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>}
|
||||
@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; }`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
@@ -33,7 +43,8 @@ export class AdminProductEditorPageComponent {
|
||||
}
|
||||
|
||||
save(): void {
|
||||
this.facade.saveDraft();
|
||||
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']);
|
||||
this.facade.saveDraft(() => {
|
||||
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 { AdminProductsFacade } from '../facade/admin-products.facade';
|
||||
import { AdminProductsListComponent } from '../components/admin-products-list.component';
|
||||
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({
|
||||
selector: 'app-admin-products-list-page',
|
||||
standalone: true,
|
||||
imports: [AdminProductsListComponent],
|
||||
imports: [AdminProductsListComponent, ConfirmDialogComponent, TranslatePipe, DialogComponent, ButtonComponent],
|
||||
template: `<app-admin-products-list
|
||||
[products]="facade.products()"
|
||||
[categories]="facade.categories()"
|
||||
@@ -15,6 +19,7 @@ import { LanguageService } from '../../../../services/language.service';
|
||||
[total]="facade.total()"
|
||||
[selectedIds]="facade.selectedIds()"
|
||||
[loading]="facade.loading()"
|
||||
[error]="facade.error()"
|
||||
[infiniteScroll]="facade.infiniteScroll()"
|
||||
[viewMode]="facade.viewMode()"
|
||||
[density]="facade.density()"
|
||||
@@ -25,7 +30,7 @@ import { LanguageService } from '../../../../services/language.service';
|
||||
(create)="create()"
|
||||
(edit)="edit($event)"
|
||||
(duplicate)="duplicate($event)"
|
||||
(delete)="facade.deleteOne($event)"
|
||||
(delete)="deleteOne($event)"
|
||||
(archive)="facade.archiveOne($event)"
|
||||
(restore)="facade.restoreOne($event)"
|
||||
(loadMore)="facade.loadMore()"
|
||||
@@ -33,14 +38,36 @@ import { LanguageService } from '../../../../services/language.service';
|
||||
(selectionChange)="facade.toggleSelection($event.id, $event.checked)"
|
||||
(selectAll)="facade.toggleAll($event)"
|
||||
(bulkVisibility)="facade.applyBulkVisibility($event)"
|
||||
(bulkDelete)="facade.applyBulkDelete()"
|
||||
(bulkDelete)="bulkDelete()"
|
||||
(bulkDuplicate)="facade.applyBulkDuplicate()"
|
||||
(bulkAssignCategory)="facade.applyBulkAssignCategory($event)"
|
||||
(bulkAssignTags)="facade.applyBulkAssignTags($event)"
|
||||
(bulkExport)="facade.exportSelectedAsCsv()"
|
||||
(viewModeChange)="facade.setViewMode($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
|
||||
})
|
||||
export class AdminProductsListPageComponent {
|
||||
@@ -50,6 +77,9 @@ export class AdminProductsListPageComponent {
|
||||
|
||||
readonly healthFn = (product: Parameters<AdminProductsFacade['health']>[0]) => this.facade.health(product);
|
||||
|
||||
readonly pendingDeleteId = signal<string | null>(null);
|
||||
readonly bulkDeleteConfirmOpen = signal(false);
|
||||
|
||||
constructor() {
|
||||
this.facade.loadCategories();
|
||||
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']); }
|
||||
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 {
|
||||
return this.languageService.currentLanguage();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
}
|
||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||
</div>
|
||||
} @else if (facade.error()) {
|
||||
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
|
||||
} @else {
|
||||
<div class="report-grid">
|
||||
<div class="report-card">
|
||||
|
||||
@@ -3,11 +3,12 @@ import { AdminAnalyticsFacade } from '../../analytics/facade/admin-analytics.fac
|
||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-reports-page',
|
||||
standalone: true,
|
||||
imports: [TranslatePipe, ButtonComponent, SkeletonComponent],
|
||||
imports: [TranslatePipe, ButtonComponent, SkeletonComponent, EmptyStateComponent],
|
||||
templateUrl: './admin-reports-page.component.html',
|
||||
styleUrls: ['./admin-reports-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -11,9 +11,11 @@ export class AdminTransactionsFacade {
|
||||
readonly transactions = signal<AdminTransaction[]>([]);
|
||||
readonly total = signal(0);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
loadList(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
this.gateway.loadTransactions(this.filters()).pipe(take(1)).subscribe({
|
||||
next: result => {
|
||||
this.transactions.set(result.items);
|
||||
@@ -24,6 +26,7 @@ export class AdminTransactionsFacade {
|
||||
this.transactions.set([]);
|
||||
this.total.set(0);
|
||||
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" /> }
|
||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||
</div>
|
||||
} @else if (facade.error()) {
|
||||
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
|
||||
} @else if (facade.transactions().length === 0) {
|
||||
<app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" />
|
||||
} @else {
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model';
|
||||
import { AdminTransactionsGateway } from './admin-transactions-gateway.interface';
|
||||
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'];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
|
||||
private readonly adminAuth = inject(AdminAuthService);
|
||||
private cache: AdminTransaction[] | null = null;
|
||||
|
||||
constructor(private readonly ordersGateway: AdminOrdersLocalGateway) {}
|
||||
|
||||
private get currentActor(): string {
|
||||
return this.adminAuth.displayName() ?? 'admin';
|
||||
}
|
||||
|
||||
loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult> {
|
||||
return new Observable<AdminTransactionsListResult>(subscriber => {
|
||||
this.ensureData().then(() => {
|
||||
@@ -38,7 +44,7 @@ export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
|
||||
...tx,
|
||||
status: 'retried',
|
||||
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,
|
||||
fraudFlag: flagged,
|
||||
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 { 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';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -8,10 +8,11 @@ export class AdminUsersFacade {
|
||||
private readonly gateway = inject(AdminUsersLocalGateway);
|
||||
|
||||
readonly users = signal<AdminUser[]>([]);
|
||||
readonly roles = signal<AdminRole[]>([]);
|
||||
readonly roles = signal<AdminUserRoleRecord[]>([]);
|
||||
readonly invitations = signal<AdminInvitation[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal(false);
|
||||
readonly mutationError = signal<string | null>(null);
|
||||
readonly sessionsTarget = signal<AdminUser | null>(null);
|
||||
readonly sessions = signal<AdminSession[]>([]);
|
||||
readonly auditTarget = signal<AdminUser | null>(null);
|
||||
@@ -33,16 +34,28 @@ export class AdminUsersFacade {
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -2,7 +2,8 @@ export type AdminUserScope = 'marketplace' | 'office';
|
||||
export type AdminUserStatus = 'active' | 'invited' | 'suspended';
|
||||
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;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
|
||||
@@ -120,4 +120,21 @@
|
||||
<p>{{ entry.timestamp | date:'short' }} — {{ ('adminUsers.actor.' + entry.actor) | translate }} — {{ auditText(entry) }}</p>
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -12,11 +12,12 @@ import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-users-page',
|
||||
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',
|
||||
styleUrls: ['./admin-users-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -28,6 +29,7 @@ export class AdminUsersPageComponent {
|
||||
readonly inviteEmail = signal('');
|
||||
readonly inviteRoleId = signal('viewer');
|
||||
readonly inviteScope = signal<AdminUserScope>('office');
|
||||
readonly pendingSuspendUserId = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
this.facade.loadAll();
|
||||
@@ -40,12 +42,21 @@ export class AdminUsersPageComponent {
|
||||
|
||||
toggleStatus(userId: string, current: AdminUserStatus): void {
|
||||
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;
|
||||
}
|
||||
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> = {
|
||||
'*': 'all',
|
||||
'products.manage': 'productsManage',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 {
|
||||
loadUsers(): Observable<AdminUser[]>;
|
||||
loadRoles(): Observable<AdminRole[]>;
|
||||
loadRoles(): Observable<AdminUserRoleRecord[]>;
|
||||
loadInvitations(): Observable<AdminInvitation[]>;
|
||||
loadSessions(userId: string): Observable<AdminSession[]>;
|
||||
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 { 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 { 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: '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: 'viewer', name: 'Viewer', permissions: ['products.view', 'orders.view'], builtIn: true },
|
||||
];
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminUsersLocalGateway implements AdminUsersGateway {
|
||||
private readonly adminAuth = inject(AdminAuthService);
|
||||
private users: AdminUser[] | null = null;
|
||||
private roles: AdminRole[] = [...BUILT_IN_ROLES];
|
||||
private roles: AdminUserRoleRecord[] = [...BUILT_IN_ROLES];
|
||||
private invitations: AdminInvitation[] = [];
|
||||
private sessions: Record<string, AdminSession[]> = {};
|
||||
private audit: Record<string, AdminUserAuditEntry[]> = {};
|
||||
@@ -23,7 +25,7 @@ export class AdminUsersLocalGateway implements AdminUsersGateway {
|
||||
return of(this.ensureUsers()).pipe(delay(50));
|
||||
}
|
||||
|
||||
loadRoles(): Observable<AdminRole[]> {
|
||||
loadRoles(): Observable<AdminUserRoleRecord[]> {
|
||||
return of(this.roles).pipe(delay(50));
|
||||
}
|
||||
|
||||
@@ -90,12 +92,16 @@ export class AdminUsersLocalGateway implements AdminUsersGateway {
|
||||
this.audit[userId] = [
|
||||
...(this.audit[userId] ?? []),
|
||||
roleId
|
||||
? { eventKey: 'roleChanged', roleId, actor: 'admin', timestamp: new Date().toISOString() }
|
||||
: { eventKey: 'statusChanged', status, actor: 'admin', timestamp: new Date().toISOString() },
|
||||
? { eventKey: 'roleChanged', roleId, actor: this.currentActor, timestamp: new Date().toISOString() }
|
||||
: { eventKey: 'statusChanged', status, actor: this.currentActor, timestamp: new Date().toISOString() },
|
||||
];
|
||||
return of(updated).pipe(delay(50));
|
||||
}
|
||||
|
||||
private get currentActor(): string {
|
||||
return this.adminAuth.displayName() ?? 'admin';
|
||||
}
|
||||
|
||||
private ensureUsers(): AdminUser[] {
|
||||
if (!this.users) {
|
||||
this.users = [
|
||||
|
||||
@@ -55,40 +55,47 @@ export class SearchFacade {
|
||||
|
||||
readonly state = this.store.state;
|
||||
|
||||
readonly popularSearches: SearchSuggestion[] = [
|
||||
{
|
||||
id: 'popular-smartphones',
|
||||
type: 'collection',
|
||||
title: 'Smartphones',
|
||||
text: 'Smartphones',
|
||||
icon: 'trendingUp',
|
||||
target: { route: '/search', query: { q: 'Smartphones' } }
|
||||
},
|
||||
{
|
||||
id: 'popular-sneakers',
|
||||
type: 'collection',
|
||||
title: 'Sneakers',
|
||||
text: 'Sneakers',
|
||||
icon: 'trendingUp',
|
||||
target: { route: '/search', query: { q: 'Sneakers' } }
|
||||
},
|
||||
{
|
||||
id: 'popular-headphones',
|
||||
type: 'collection',
|
||||
title: 'Headphones',
|
||||
text: 'Headphones',
|
||||
icon: 'trendingUp',
|
||||
target: { route: '/search', query: { q: 'Headphones' } }
|
||||
},
|
||||
{
|
||||
id: 'popular-laptops',
|
||||
type: 'collection',
|
||||
title: 'Laptops',
|
||||
text: 'Laptops',
|
||||
icon: 'trendingUp',
|
||||
target: { route: '/search', query: { q: 'Laptops' } }
|
||||
},
|
||||
];
|
||||
/**
|
||||
* Search query text stays the stable English canonical term (what the
|
||||
* backend search index matches against); only the displayed title/text
|
||||
* are translated.
|
||||
*/
|
||||
get popularSearches(): SearchSuggestion[] {
|
||||
return [
|
||||
{
|
||||
id: 'popular-smartphones',
|
||||
type: 'collection',
|
||||
title: this.translate.t('search.popularSmartphones'),
|
||||
text: this.translate.t('search.popularSmartphones'),
|
||||
icon: 'trendingUp',
|
||||
target: { route: '/search', query: { q: 'Smartphones' } }
|
||||
},
|
||||
{
|
||||
id: 'popular-sneakers',
|
||||
type: 'collection',
|
||||
title: this.translate.t('search.popularSneakers'),
|
||||
text: this.translate.t('search.popularSneakers'),
|
||||
icon: 'trendingUp',
|
||||
target: { route: '/search', query: { q: 'Sneakers' } }
|
||||
},
|
||||
{
|
||||
id: 'popular-headphones',
|
||||
type: 'collection',
|
||||
title: this.translate.t('search.popularHeadphones'),
|
||||
text: this.translate.t('search.popularHeadphones'),
|
||||
icon: 'trendingUp',
|
||||
target: { route: '/search', query: { q: 'Headphones' } }
|
||||
},
|
||||
{
|
||||
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() {
|
||||
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 { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
|
||||
const RANGE_DEBOUNCE_MS = 350;
|
||||
|
||||
@Component({
|
||||
selector: 'app-catalog-filters-panel',
|
||||
standalone: true,
|
||||
@@ -11,12 +13,20 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
styleUrls: ['./filters-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CatalogFiltersPanelComponent {
|
||||
export class CatalogFiltersPanelComponent implements OnDestroy {
|
||||
@Input() definitions: FilterGroup[] = [];
|
||||
@Input() state: SearchFilterState = { values: {}, ranges: {}, toggles: {} };
|
||||
|
||||
@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>>({});
|
||||
|
||||
toggleGroup(filterId: string): void {
|
||||
@@ -79,34 +89,51 @@ export class CatalogFiltersPanelComponent {
|
||||
|
||||
updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void {
|
||||
const value = rawValue.trim().length ? Number(rawValue) : undefined;
|
||||
const current = this.state.ranges[filterId] ?? {};
|
||||
|
||||
this.stateChange.emit({
|
||||
...this.state,
|
||||
ranges: {
|
||||
...this.state.ranges,
|
||||
[filterId]: {
|
||||
...current,
|
||||
[key]: Number.isFinite(value as number) ? value : undefined
|
||||
this.debounce(`range:${filterId}:${key}`, () => {
|
||||
const current = this.state.ranges[filterId] ?? {};
|
||||
this.stateChange.emit({
|
||||
...this.state,
|
||||
ranges: {
|
||||
...this.state.ranges,
|
||||
[filterId]: {
|
||||
...current,
|
||||
[key]: Number.isFinite(value as number) ? value : undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
updateSlider(filterId: string, value: string): void {
|
||||
const numeric = Number(value);
|
||||
this.stateChange.emit({
|
||||
...this.state,
|
||||
ranges: {
|
||||
...this.state.ranges,
|
||||
[filterId]: {
|
||||
...this.state.ranges[filterId],
|
||||
max: Number.isFinite(numeric) ? numeric : undefined,
|
||||
|
||||
this.debounce(`slider:${filterId}`, () => {
|
||||
this.stateChange.emit({
|
||||
...this.state,
|
||||
ranges: {
|
||||
...this.state.ranges,
|
||||
[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 {
|
||||
this.stateChange.emit({
|
||||
...this.state,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
(selected)="productSelected.emit(product)"
|
||||
(addToCart)="onAddToCart(product, $event.event)"
|
||||
(preview)="productPreview.emit($event)"
|
||||
(quickViewPlaceholder)="quickView.emit($event)"
|
||||
(favoriteToggled)="favoriteToggled.emit(product)"
|
||||
(compareToggled)="compareToggled.emit(product)"
|
||||
(shareRequested)="shareRequested.emit(product)"
|
||||
|
||||
@@ -28,6 +28,7 @@ export class CatalogProductGridComponent {
|
||||
@Output() productSelected = new EventEmitter<Product>();
|
||||
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
||||
@Output() productPreview = new EventEmitter<number>();
|
||||
@Output() quickView = new EventEmitter<number>();
|
||||
@Output() favoriteToggled = new EventEmitter<Product>();
|
||||
@Output() compareToggled = new EventEmitter<Product>();
|
||||
@Output() shareRequested = new EventEmitter<Product>();
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
(productSelected)="productSelected.emit($event)"
|
||||
(addToCart)="addToCart.emit($event)"
|
||||
(productPreview)="productPreview.emit($event)"
|
||||
(quickView)="quickView.emit($event)"
|
||||
(favoriteToggled)="favoriteToggled.emit($event)"
|
||||
(compareToggled)="compareToggled.emit($event)"
|
||||
(shareRequested)="shareRequested.emit($event)" />
|
||||
|
||||
@@ -40,6 +40,7 @@ export class CatalogSearchResultsComponent {
|
||||
@Output() productSelected = new EventEmitter<Product>();
|
||||
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
||||
@Output() productPreview = new EventEmitter<number>();
|
||||
@Output() quickView = new EventEmitter<number>();
|
||||
@Output() favoriteToggled = new EventEmitter<Product>();
|
||||
@Output() compareToggled = new EventEmitter<Product>();
|
||||
@Output() shareRequested = new EventEmitter<Product>();
|
||||
|
||||
@@ -229,6 +229,7 @@
|
||||
(productSelected)="selectProduct($event)"
|
||||
(addToCart)="addToCart($event)"
|
||||
(productPreview)="previewProduct($event)"
|
||||
(quickView)="openQuickView($event)"
|
||||
(favoriteToggled)="onFavoriteToggled($event)"
|
||||
(compareToggled)="onCompareToggled($event)"
|
||||
(shareRequested)="onShareRequested($event)" />
|
||||
@@ -295,3 +296,9 @@
|
||||
}
|
||||
}
|
||||
</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 { FeatureConfigService } from '../../../../core/config/feature-config.service';
|
||||
import { CategoryFacade } from '../../../../facades/platform/category.facade';
|
||||
import { ProductFacade } from '../../../../facades/platform/product.facade';
|
||||
import { SearchFacade } from '../../../../facades/platform/search.facade';
|
||||
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
|
||||
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 { ButtonComponent } from '../../../../shared/ui/button/button.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 { ProductShareService } from '../../user-experience/services/product-share.service';
|
||||
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
|
||||
@@ -56,7 +58,8 @@ type CatalogLoadingStrategy = 'pagination' | 'loadMore' | 'infiniteScroll';
|
||||
EmptyStateComponent,
|
||||
SkeletonComponent,
|
||||
ButtonComponent,
|
||||
IconComponent
|
||||
IconComponent,
|
||||
QuickViewDialogComponent
|
||||
],
|
||||
templateUrl: './catalog-container.component.html',
|
||||
styleUrls: ['./catalog-container.component.scss'],
|
||||
@@ -68,6 +71,7 @@ export class CatalogContainerComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly configService = inject(ConfigService);
|
||||
private readonly categoryFacade = inject(CategoryFacade);
|
||||
private readonly productFacade = inject(ProductFacade);
|
||||
private readonly searchFacade = inject(SearchFacade);
|
||||
private readonly featureConfig = inject(FeatureConfigService);
|
||||
private readonly cartService = inject(CartService);
|
||||
@@ -287,6 +291,31 @@ export class CatalogContainerComponent {
|
||||
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 {
|
||||
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 { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
|
||||
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 { ProductActionsComponent } from '../components/product-actions/product-actions.component';
|
||||
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
|
||||
@@ -70,6 +77,11 @@ export class ProductDetailsContainerComponent {
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
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 userExperienceConfig = signal(this.resolveUserExperienceConfig());
|
||||
@@ -229,6 +241,8 @@ export class ProductDetailsContainerComponent {
|
||||
this.route.paramMap
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(params => this.loadProduct(Number(params.get('id'))));
|
||||
|
||||
this.destroyRef.onDestroy(() => this.seoService.resetToDefaults());
|
||||
}
|
||||
|
||||
loadProduct(productId: number): void {
|
||||
@@ -261,6 +275,7 @@ export class ProductDetailsContainerComponent {
|
||||
}
|
||||
|
||||
this.product.set(product);
|
||||
this.seoService.setItemMeta(product);
|
||||
if (this.userExperienceConfig().recentlyViewed.enabled) {
|
||||
this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems);
|
||||
}
|
||||
@@ -303,10 +318,10 @@ export class ProductDetailsContainerComponent {
|
||||
}
|
||||
}
|
||||
|
||||
addToCart(): void {
|
||||
addToCart(): Promise<void> {
|
||||
const current = this.product();
|
||||
if (!current) return;
|
||||
this.cartService.addItem(current.itemID, 1, {
|
||||
if (!current) return Promise.resolve();
|
||||
return this.cartService.addItem(current.itemID, 1, {
|
||||
colour: this.selectedColour() ?? undefined,
|
||||
size: this.selectedSize() ?? undefined,
|
||||
price: this.effectivePrice(),
|
||||
@@ -314,9 +329,9 @@ export class ProductDetailsContainerComponent {
|
||||
});
|
||||
}
|
||||
|
||||
buyNow(): void {
|
||||
this.addToCart();
|
||||
this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]);
|
||||
async buyNow(): Promise<void> {
|
||||
await this.addToCart();
|
||||
void this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]);
|
||||
}
|
||||
|
||||
toggleWishlist(): void {
|
||||
@@ -353,8 +368,36 @@ export class ProductDetailsContainerComponent {
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
}
|
||||
|
||||
.star {
|
||||
color: #cdd6d5;
|
||||
color: var(--border-color);
|
||||
}
|
||||
|
||||
.star.filled {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<th scope="col">{{ 'ux.compareAttribute' | translate }}</th>
|
||||
@for (product of products; track product.itemID) {
|
||||
<th scope="col">
|
||||
<div class="compare-product-title">{{ product.name }}</div>
|
||||
<div class="compare-product-title">{{ productTitle(product) }}</div>
|
||||
</th>
|
||||
}
|
||||
</tr>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { ChangeDetectionStrategy, Component, Input, computed, inject } from '@an
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { TranslateService } from '../../../../../i18n/translate.service';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
import { LanguageService } from '../../../../../services/language.service';
|
||||
import { getTranslatedField } from '../../../../../utils/item.utils';
|
||||
|
||||
interface CompareRow {
|
||||
key: string;
|
||||
@@ -27,6 +29,7 @@ const STOCK_LABEL_KEYS: Record<string, string> = {
|
||||
})
|
||||
export class CompareTableComponent {
|
||||
private readonly i18n = inject(TranslateService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
@Input() products: Product[] = [];
|
||||
@Input() hideIdentical = false;
|
||||
@@ -61,6 +64,10 @@ export class CompareTableComponent {
|
||||
return this.hideIdentical ? baseRows.filter(row => !row.identical) : baseRows;
|
||||
});
|
||||
|
||||
productTitle(product: Product): string {
|
||||
return getTranslatedField(product, 'name', this.languageService.currentLanguage());
|
||||
}
|
||||
|
||||
isDifferentRow(row: CompareRow): boolean {
|
||||
return this.highlightDifferences && !row.identical;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<section class="compare-products-list">
|
||||
@for (product of products(); track product.itemID) {
|
||||
<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>
|
||||
</article>
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Product } from '../../../../../core/products/models/product-domain.mode
|
||||
import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.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 { CompareTableComponent } from '../components/compare-table.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 {
|
||||
private readonly uxFacade = inject(UserExperienceFacade);
|
||||
private readonly configService = inject(ConfigService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
private readonly compareConfig = this.resolveCompareConfig();
|
||||
|
||||
@@ -39,6 +42,10 @@ export class ComparePageComponent {
|
||||
this.uxFacade.clearCompare();
|
||||
}
|
||||
|
||||
productTitle(product: Product): string {
|
||||
return getTranslatedField(product, 'name', this.languageService.currentLanguage());
|
||||
}
|
||||
|
||||
private resolveCompareConfig() {
|
||||
const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience?.compare ?? {};
|
||||
return {
|
||||
|
||||
@@ -123,6 +123,10 @@ export const en: Translations = {
|
||||
emailNeedsAt: 'Email must contain @',
|
||||
emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)',
|
||||
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',
|
||||
loginRequiredDesc: 'Please log in via Telegram to place your order',
|
||||
loginWithTelegram: 'Log in with Telegram',
|
||||
@@ -140,6 +144,10 @@ export const en: Translations = {
|
||||
noResultsHint: 'Try changing your query or using different keywords',
|
||||
emptyResultsAria: 'Empty search results',
|
||||
popularCategories: 'Popular categories',
|
||||
popularSmartphones: 'Smartphones',
|
||||
popularSneakers: 'Sneakers',
|
||||
popularHeadphones: 'Headphones',
|
||||
popularLaptops: 'Laptops',
|
||||
recommendedProducts: 'Recommended products',
|
||||
aiSuggestionHint: 'AI suggestion (future)',
|
||||
suggestionType: {
|
||||
@@ -269,6 +277,7 @@ export const en: Translations = {
|
||||
compare: 'Compare',
|
||||
share: 'Share',
|
||||
quickView: 'Quick view',
|
||||
quickViewDetails: 'View full details',
|
||||
stockHigh: 'In stock',
|
||||
stockMedium: 'Limited stock',
|
||||
stockLow: 'Almost gone',
|
||||
@@ -317,6 +326,7 @@ export const en: Translations = {
|
||||
compare: 'Compare',
|
||||
share: 'Share',
|
||||
notifyMe: 'Notify me',
|
||||
notifyMeConfirmed: 'We\'ll let you know when this is back in stock.',
|
||||
zoom: 'Zoom',
|
||||
fullscreen: 'Fullscreen',
|
||||
pdfDocument: 'Product PDF document',
|
||||
@@ -1119,6 +1129,8 @@ export const en: Translations = {
|
||||
nextProducts: 'Next products',
|
||||
previousSlide: 'Previous slide',
|
||||
nextSlide: 'Next slide',
|
||||
pauseAutoplay: 'Pause slideshow',
|
||||
resumeAutoplay: 'Resume slideshow',
|
||||
closeDialog: 'Close dialog',
|
||||
dismiss: 'Dismiss',
|
||||
qrCode: 'QR Code',
|
||||
@@ -1299,7 +1311,8 @@ export const en: Translations = {
|
||||
notAvailable: 'Not available yet',
|
||||
changeStatus: 'Change status',
|
||||
hideSelected: 'Hide',
|
||||
archiveSelected: 'Archive',
|
||||
deleteSelected: 'Delete',
|
||||
confirmBulkDelete: 'Delete the selected reviews? This cannot be undone.',
|
||||
reportsQueue: 'Reports queue',
|
||||
reportsColumn: 'Reports',
|
||||
reportTarget: 'Reported item',
|
||||
@@ -1357,6 +1370,8 @@ export const en: Translations = {
|
||||
},
|
||||
},
|
||||
adminOrders: {
|
||||
bulkDeleteTitle: 'Delete orders',
|
||||
confirmBulkDelete: 'Delete the selected orders? This cannot be undone.',
|
||||
back: 'Back to orders',
|
||||
search: 'Search by order number, name, email, or phone…',
|
||||
export: 'Export',
|
||||
@@ -1533,6 +1548,7 @@ export const en: Translations = {
|
||||
title: 'Title',
|
||||
slug: 'URL slug',
|
||||
slugTaken: 'This slug is already used by another category.',
|
||||
slugCheckError: 'Could not verify this slug is unique. Try again before saving.',
|
||||
parent: 'Parent category',
|
||||
noParent: 'No parent (top level)',
|
||||
icon: 'Icon',
|
||||
@@ -1545,6 +1561,8 @@ export const en: Translations = {
|
||||
saveDraft: 'Save draft',
|
||||
publish: 'Publish',
|
||||
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?',
|
||||
deleteBlocked: 'This category can\'t be deleted while it has subcategories or assigned products.',
|
||||
emptyTitle: 'No categories yet',
|
||||
@@ -1611,6 +1629,9 @@ export const en: Translations = {
|
||||
},
|
||||
adminProducts: {
|
||||
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',
|
||||
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.',
|
||||
|
||||
@@ -123,6 +123,10 @@ export const hy: Translations = {
|
||||
emailNeedsAt: 'Email-ը պետք է պարունակի @',
|
||||
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)',
|
||||
emailInvalid: 'Սխալ email ձևաչափ',
|
||||
telegramIdMissing: 'Չհաջողվեց հաստատել ձեր Telegram հաշիվը, ուստի կոնտակտային տվյալները չեն պահպանվել։ Վճարումը հաջողությամբ կատարվել է։',
|
||||
paymentDescriptionFallback: 'Գնում Մարկետփլեյսում',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
phonePlaceholder: '+7 (___) ___-__-__',
|
||||
loginRequired: 'Մուտք գործեք ձևակերպելու համար',
|
||||
loginRequiredDesc: 'Պատվեր ձևակերպելու համար մուտք գործեք Telegram-ով',
|
||||
loginWithTelegram: 'Մուտք Telegram-ով',
|
||||
@@ -140,6 +144,10 @@ export const hy: Translations = {
|
||||
noResultsHint: 'Փորձեք փոխել հարցումը կամ օգտագործել այլ բանալի բառեր',
|
||||
emptyResultsAria: 'Դատարկ որոնման արդյունքներ',
|
||||
popularCategories: 'Հանրաճանաչ կատեգորիաներ',
|
||||
popularSmartphones: 'Սմարթֆոններ',
|
||||
popularSneakers: 'Կեդեր',
|
||||
popularHeadphones: 'Ականջակալներ',
|
||||
popularLaptops: 'Նոութբուքեր',
|
||||
recommendedProducts: 'Առաջարկվող ապրանքներ',
|
||||
aiSuggestionHint: 'AI առաջարկ (ապագայում)',
|
||||
suggestionType: {
|
||||
@@ -269,6 +277,7 @@ export const hy: Translations = {
|
||||
compare: 'Համեմատել',
|
||||
share: 'Կիսվել',
|
||||
quickView: 'Արագ դիտում',
|
||||
quickViewDetails: 'Տեսնել ամբողջությամբ',
|
||||
stockHigh: 'Առկա է',
|
||||
stockMedium: 'Սահմանափակ քանակ',
|
||||
stockLow: 'Գրեթե սպառված է',
|
||||
@@ -317,6 +326,7 @@ export const hy: Translations = {
|
||||
compare: 'Համեմատել',
|
||||
share: 'Կիսվել',
|
||||
notifyMe: 'Ծանուցել ինձ',
|
||||
notifyMeConfirmed: 'Մենք կտեղեկացնենք ձեզ, երբ ապրանքը կրկին հասանելի լինի։',
|
||||
zoom: 'Մեծացնել',
|
||||
fullscreen: 'Ամբողջ էկրան',
|
||||
pdfDocument: 'Ապրանքի PDF փաստաթուղթ',
|
||||
@@ -1119,6 +1129,8 @@ export const hy: Translations = {
|
||||
nextProducts: 'Հաջորդ ապրանքները',
|
||||
previousSlide: 'Նախորդ սլայդը',
|
||||
nextSlide: 'Հաջորդ սլայդը',
|
||||
pauseAutoplay: 'Դադարեցնել սլայդշոուն',
|
||||
resumeAutoplay: 'Վերսկսել սլայդշոուն',
|
||||
closeDialog: 'Փակել պատուհանը',
|
||||
dismiss: 'Փակել',
|
||||
qrCode: 'QR կոդ',
|
||||
@@ -1294,7 +1306,8 @@ export const hy: Translations = {
|
||||
notAvailable: 'Դեռ հասանելի չէ',
|
||||
changeStatus: 'Փոխել կարգավիճակը',
|
||||
hideSelected: 'Թաքցնել',
|
||||
archiveSelected: 'Արխիվացնել',
|
||||
deleteSelected: 'Ջնջել',
|
||||
confirmBulkDelete: 'Ջնջե՞լ ընտրված կարծիքները։ Հնարավոր չէ հետարկել։',
|
||||
reportsQueue: 'Բողոքների հերթ',
|
||||
reportsColumn: 'Բողոքներ',
|
||||
reportTarget: 'Բողոքարկված օբյեկտ',
|
||||
@@ -1352,6 +1365,8 @@ export const hy: Translations = {
|
||||
},
|
||||
},
|
||||
adminOrders: {
|
||||
bulkDeleteTitle: 'Ջնջել պատվերները',
|
||||
confirmBulkDelete: 'Ջնջե՞լ ընտրված պատվերները։ Հնարավոր չէ հետարկել։',
|
||||
back: 'Վերադառնալ պատվերներին',
|
||||
search: 'Փնտրել ըստ պատվերի համարի, անվան, էլ. փոստի կամ հեռախոսի…',
|
||||
export: 'Արտահանել',
|
||||
@@ -1528,6 +1543,7 @@ export const hy: Translations = {
|
||||
title: 'Անուն',
|
||||
slug: 'URL հասցե',
|
||||
slugTaken: 'Այս հասցեն արդեն օգտագործվում է այլ կատեգորիայի կողմից։',
|
||||
slugCheckError: 'Չհաջողվեց ստուգել հասցեի եզակիությունը։ Փորձեք կրկին՝ նախքան պահպանելը։',
|
||||
parent: 'Ծնող կատեգորիա',
|
||||
noParent: 'Առանց ծնողի (վերին մակարդակ)',
|
||||
icon: 'Պատկերակ',
|
||||
@@ -1540,6 +1556,8 @@ export const hy: Translations = {
|
||||
saveDraft: 'Պահպանել սևագիրը',
|
||||
publish: 'Հրապարակել',
|
||||
confirmDelete: 'Ջնջե՞լ այս կատեգորիան։ Հնարավոր չէ հետարկել։',
|
||||
confirmBulkDelete: 'Ջնջե՞լ ընտրված կատեգորիաները։ Հնարավոր չէ հետարկել։',
|
||||
deleteTitle: 'Ջնջել կատեգորիան',
|
||||
confirmLeaveUnsaved: 'Դուք ունեք չպահպանված փոփոխություններ։ Դո՞ւրս գալ առանց պահպանելու։',
|
||||
deleteBlocked: 'Այս կատեգորիան հնարավոր չէ ջնջել, քանի դեռ ունի ենթակատեգորիաներ կամ նշանակված ապրանքներ։',
|
||||
emptyTitle: 'Կատեգորիաներ դեռ չկան',
|
||||
@@ -1606,6 +1624,9 @@ export const hy: Translations = {
|
||||
},
|
||||
adminProducts: {
|
||||
confirmLeaveUnsaved: 'Դուք ունեք չպահպանված փոփոխություններ։ Դո՞ւրս գալ առանց պահպանելու։',
|
||||
deleteTitle: 'Ջնջել ապրանքը',
|
||||
confirmDelete: 'Ջնջե՞լ այս ապրանքը։ Հնարավոր չէ հետարկել։',
|
||||
confirmBulkDelete: 'Ջնջե՞լ ընտրված ապրանքները։ Հնարավոր չէ հետարկել։',
|
||||
emptyTitle: 'Ապրանքներ չեն գտնվել',
|
||||
emptyDescription: 'Փոխեք ֆիլտրերը կամ ստեղծեք նոր ապրանք։',
|
||||
emptyGuide: 'Լավ ապրանքին բավական է հստակ վերնագիր, առնվազն մեկ լուսանկար, գին և կարճ նկարագրություն՝ հրապարակելու համար։ Մնացածը կարող եք ավելացնել հետո։',
|
||||
|
||||
@@ -123,6 +123,10 @@ export const ru: Translations = {
|
||||
emailNeedsAt: 'Email должен содержать @',
|
||||
emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)',
|
||||
emailInvalid: 'Некорректный формат email',
|
||||
telegramIdMissing: 'Не удалось определить ваш Telegram-аккаунт, поэтому контактные данные не сохранены. Оплата прошла успешно.',
|
||||
paymentDescriptionFallback: 'Покупка на Маркетплейсе',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
phonePlaceholder: '+7 (___) ___-__-__',
|
||||
loginRequired: 'Войдите для оформления',
|
||||
loginRequiredDesc: 'Для оформления заказа войдите через Telegram',
|
||||
loginWithTelegram: 'Войти через Telegram',
|
||||
@@ -140,6 +144,10 @@ export const ru: Translations = {
|
||||
noResultsHint: 'Попробуйте изменить запрос или используйте другие ключевые слова',
|
||||
emptyResultsAria: 'Пустые результаты поиска',
|
||||
popularCategories: 'Популярные категории',
|
||||
popularSmartphones: 'Смартфоны',
|
||||
popularSneakers: 'Кроссовки',
|
||||
popularHeadphones: 'Наушники',
|
||||
popularLaptops: 'Ноутбуки',
|
||||
recommendedProducts: 'Рекомендуемые товары',
|
||||
aiSuggestionHint: 'AI-подсказка (в будущем)',
|
||||
suggestionType: {
|
||||
@@ -269,6 +277,7 @@ export const ru: Translations = {
|
||||
compare: 'Сравнить',
|
||||
share: 'Поделиться',
|
||||
quickView: 'Быстрый просмотр',
|
||||
quickViewDetails: 'Смотреть полностью',
|
||||
stockHigh: 'В наличии',
|
||||
stockMedium: 'Ограниченно',
|
||||
stockLow: 'Почти распродано',
|
||||
@@ -317,6 +326,7 @@ export const ru: Translations = {
|
||||
compare: 'Сравнить',
|
||||
share: 'Поделиться',
|
||||
notifyMe: 'Сообщить о наличии',
|
||||
notifyMeConfirmed: 'Мы сообщим вам, когда товар снова появится в наличии.',
|
||||
zoom: 'Увеличить',
|
||||
fullscreen: 'Полный экран',
|
||||
pdfDocument: 'PDF документ товара',
|
||||
@@ -1119,6 +1129,8 @@ export const ru: Translations = {
|
||||
nextProducts: 'Следующие товары',
|
||||
previousSlide: 'Предыдущий слайд',
|
||||
nextSlide: 'Следующий слайд',
|
||||
pauseAutoplay: 'Приостановить слайд-шоу',
|
||||
resumeAutoplay: 'Возобновить слайд-шоу',
|
||||
closeDialog: 'Закрыть диалог',
|
||||
dismiss: 'Скрыть',
|
||||
qrCode: 'QR-код',
|
||||
@@ -1294,7 +1306,8 @@ export const ru: Translations = {
|
||||
notAvailable: 'Пока недоступно',
|
||||
changeStatus: 'Изменить статус',
|
||||
hideSelected: 'Скрыть',
|
||||
archiveSelected: 'В архив',
|
||||
deleteSelected: 'Удалить',
|
||||
confirmBulkDelete: 'Удалить выбранные отзывы? Это действие нельзя отменить.',
|
||||
reportsQueue: 'Очередь жалоб',
|
||||
reportsColumn: 'Жалобы',
|
||||
reportTarget: 'Объект жалобы',
|
||||
@@ -1352,6 +1365,8 @@ export const ru: Translations = {
|
||||
},
|
||||
},
|
||||
adminOrders: {
|
||||
bulkDeleteTitle: 'Удалить заказы',
|
||||
confirmBulkDelete: 'Удалить выбранные заказы? Это действие нельзя отменить.',
|
||||
back: 'Назад к заказам',
|
||||
search: 'Поиск по номеру заказа, имени, email или телефону…',
|
||||
export: 'Экспорт',
|
||||
@@ -1528,6 +1543,7 @@ export const ru: Translations = {
|
||||
title: 'Название',
|
||||
slug: 'URL-адрес',
|
||||
slugTaken: 'Этот адрес уже используется другой категорией.',
|
||||
slugCheckError: 'Не удалось проверить уникальность адреса. Повторите попытку перед сохранением.',
|
||||
parent: 'Родительская категория',
|
||||
noParent: 'Без родителя (верхний уровень)',
|
||||
icon: 'Иконка',
|
||||
@@ -1540,6 +1556,8 @@ export const ru: Translations = {
|
||||
saveDraft: 'Сохранить черновик',
|
||||
publish: 'Опубликовать',
|
||||
confirmDelete: 'Удалить эту категорию? Это действие нельзя отменить.',
|
||||
confirmBulkDelete: 'Удалить выбранные категории? Это действие нельзя отменить.',
|
||||
deleteTitle: 'Удалить категорию',
|
||||
confirmLeaveUnsaved: 'У вас есть несохранённые изменения. Выйти без сохранения?',
|
||||
deleteBlocked: 'Эту категорию нельзя удалить, пока у неё есть подкатегории или назначенные товары.',
|
||||
emptyTitle: 'Категорий пока нет',
|
||||
@@ -1606,6 +1624,9 @@ export const ru: Translations = {
|
||||
},
|
||||
adminProducts: {
|
||||
confirmLeaveUnsaved: 'У вас есть несохранённые изменения. Выйти без сохранения?',
|
||||
deleteTitle: 'Удалить товар',
|
||||
confirmDelete: 'Удалить этот товар? Это действие нельзя отменить.',
|
||||
confirmBulkDelete: 'Удалить выбранные товары? Это действие нельзя отменить.',
|
||||
emptyTitle: 'Товары не найдены',
|
||||
emptyDescription: 'Измените фильтры или создайте новый товар.',
|
||||
emptyGuide: 'Хорошему товару нужны понятное название, минимум одно фото, цена и короткое описание — этого достаточно для публикации. Остальное можно добавить позже.',
|
||||
|
||||
@@ -1,14 +1,42 @@
|
||||
import { Pipe, PipeTransform, inject } from '@angular/core';
|
||||
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({
|
||||
name: 'translate',
|
||||
pure: false,
|
||||
})
|
||||
export class TranslatePipe implements PipeTransform {
|
||||
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 {
|
||||
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;
|
||||
emailNeedsDomain: string;
|
||||
emailInvalid: string;
|
||||
telegramIdMissing: string;
|
||||
paymentDescriptionFallback: string;
|
||||
emailPlaceholder: string;
|
||||
phonePlaceholder: string;
|
||||
loginRequired: string;
|
||||
loginRequiredDesc: string;
|
||||
loginWithTelegram: string;
|
||||
@@ -138,6 +142,10 @@ export interface Translations {
|
||||
noResultsHint: string;
|
||||
emptyResultsAria: string;
|
||||
popularCategories: string;
|
||||
popularSmartphones: string;
|
||||
popularSneakers: string;
|
||||
popularHeadphones: string;
|
||||
popularLaptops: string;
|
||||
recommendedProducts: string;
|
||||
aiSuggestionHint: string;
|
||||
suggestionType: {
|
||||
@@ -267,6 +275,7 @@ export interface Translations {
|
||||
compare: string;
|
||||
share: string;
|
||||
quickView: string;
|
||||
quickViewDetails: string;
|
||||
stockHigh: string;
|
||||
stockMedium: string;
|
||||
stockLow: string;
|
||||
@@ -315,6 +324,7 @@ export interface Translations {
|
||||
compare: string;
|
||||
share: string;
|
||||
notifyMe: string;
|
||||
notifyMeConfirmed: string;
|
||||
zoom: string;
|
||||
fullscreen: string;
|
||||
pdfDocument: string;
|
||||
@@ -1118,6 +1128,8 @@ export interface Translations {
|
||||
nextProducts: string;
|
||||
previousSlide: string;
|
||||
nextSlide: string;
|
||||
pauseAutoplay: string;
|
||||
resumeAutoplay: string;
|
||||
closeDialog: string;
|
||||
dismiss: string;
|
||||
qrCode: string;
|
||||
@@ -1298,7 +1310,8 @@ export interface Translations {
|
||||
notAvailable: string;
|
||||
changeStatus: string;
|
||||
hideSelected: string;
|
||||
archiveSelected: string;
|
||||
deleteSelected: string;
|
||||
confirmBulkDelete: string;
|
||||
reportsQueue: string;
|
||||
reportsColumn: string;
|
||||
reportTarget: string;
|
||||
@@ -1356,6 +1369,8 @@ export interface Translations {
|
||||
};
|
||||
};
|
||||
adminOrders: {
|
||||
bulkDeleteTitle: string;
|
||||
confirmBulkDelete: string;
|
||||
back: string;
|
||||
search: string;
|
||||
export: string;
|
||||
@@ -1541,6 +1556,7 @@ export interface Translations {
|
||||
title: string;
|
||||
slug: string;
|
||||
slugTaken: string;
|
||||
slugCheckError: string;
|
||||
parent: string;
|
||||
noParent: string;
|
||||
icon: string;
|
||||
@@ -1553,6 +1569,8 @@ export interface Translations {
|
||||
saveDraft: string;
|
||||
publish: string;
|
||||
confirmDelete: string;
|
||||
confirmBulkDelete: string;
|
||||
deleteTitle: string;
|
||||
confirmLeaveUnsaved: string;
|
||||
deleteBlocked: string;
|
||||
emptyTitle: string;
|
||||
@@ -1619,6 +1637,9 @@ export interface Translations {
|
||||
};
|
||||
adminProducts: {
|
||||
confirmLeaveUnsaved: string;
|
||||
deleteTitle: string;
|
||||
confirmDelete: string;
|
||||
confirmBulkDelete: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
emptyGuide: string;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UUID } from '../shared/types/primitive.types';
|
||||
|
||||
interface Photo {
|
||||
photo?: string;
|
||||
video?: string;
|
||||
@@ -182,7 +184,7 @@ export interface Item {
|
||||
* Absent means marketplace-owned, exactly like every product today -
|
||||
* nothing reads this field yet, nothing breaks by it being undefined.
|
||||
*/
|
||||
sellerId?: string;
|
||||
sellerId?: UUID;
|
||||
}
|
||||
|
||||
export interface CartItem extends Item {
|
||||
|
||||
@@ -98,7 +98,13 @@
|
||||
</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" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -271,6 +277,44 @@
|
||||
<div class="payment-status-screen success" role="status" aria-live="polite">
|
||||
<div class="success-icon" aria-hidden="true">✓</div>
|
||||
<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>
|
||||
}
|
||||
|
||||
|
||||
@@ -722,6 +722,57 @@
|
||||
color: white;
|
||||
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 {
|
||||
|
||||
@@ -71,6 +71,7 @@ export class CartComponent implements OnDestroy {
|
||||
emailError = signal<string>('');
|
||||
phoneError = signal<string>('');
|
||||
emailSubmitting = signal<boolean>(false);
|
||||
purchaseSubmitted = signal<boolean>(false);
|
||||
paidItems: CartItem[] = [];
|
||||
|
||||
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.phoneError.set('');
|
||||
this.emailSubmitting.set(false);
|
||||
this.purchaseSubmitted.set(false);
|
||||
this.paidItems = [...this.items()];
|
||||
this.createPayment(paymentMethod);
|
||||
}
|
||||
@@ -254,7 +256,7 @@ export class CartComponent implements OnDestroy {
|
||||
const orderId = this.generateOrderId();
|
||||
const paymentPayload = {
|
||||
amount: Number(this.totalWithDelivery()),
|
||||
currency: 'RUB' as const,
|
||||
currency: this.langService.currentCurrency(),
|
||||
siteuserID: this.getPaymentUserId(),
|
||||
siteorderID: orderId,
|
||||
redirectUrl: '',
|
||||
@@ -424,7 +426,6 @@ export class CartComponent implements OnDestroy {
|
||||
productId: String(item.itemID),
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
price: item.discount > 0 ? item.price * (1 - item.discount / 100) : item.price,
|
||||
})),
|
||||
customer: {
|
||||
name: this.getTelegramUsername() || this.i18n.t('common.guest'),
|
||||
@@ -433,36 +434,46 @@ export class CartComponent implements OnDestroy {
|
||||
},
|
||||
payment: {
|
||||
method: this.selectedPaymentMethod(),
|
||||
currency: 'RUB',
|
||||
currency: this.langService.currentCurrency(),
|
||||
},
|
||||
}).subscribe({
|
||||
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 {
|
||||
setTimeout(() => {
|
||||
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);
|
||||
if (this.purchaseSubmitted()) {
|
||||
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);
|
||||
|
||||
|
||||
const emailData = {
|
||||
email: '',
|
||||
phone: '',
|
||||
email: this.userEmail().trim(),
|
||||
phone: this.userPhone().replace(/\D/g, ''),
|
||||
telegramUserId: telegramUserId,
|
||||
items: this.paidItems.map((item: CartItem) => ({
|
||||
itemID: item.itemID,
|
||||
name: item.name,
|
||||
price: item.discount > 0
|
||||
price: item.discount > 0
|
||||
? item.price * (1 - item.discount / 100)
|
||||
: item.price,
|
||||
currency: item.currency,
|
||||
@@ -470,9 +481,10 @@ export class CartComponent implements OnDestroy {
|
||||
...(item.selectedDelivery ? { delivery: [item.selectedDelivery] } : {})
|
||||
}))
|
||||
};
|
||||
|
||||
|
||||
this.apiService.submitPurchaseEmail(emailData).subscribe({
|
||||
next: () => {
|
||||
this.purchaseSubmitted.set(true);
|
||||
this.emailSubmitting.set(false);
|
||||
this.closePaymentPopup();
|
||||
const lang = this.langService.currentLanguage();
|
||||
@@ -488,8 +500,6 @@ export class CartComponent implements OnDestroy {
|
||||
}
|
||||
});
|
||||
this.paymentStatus.set(null);
|
||||
|
||||
|
||||
}
|
||||
|
||||
copyPaymentLink(): void {
|
||||
@@ -542,6 +552,11 @@ export class CartComponent implements OnDestroy {
|
||||
|
||||
this.apiService.submitPurchaseEmail(emailData).subscribe({
|
||||
next: () => {
|
||||
this.purchaseSubmitted.set(true);
|
||||
if (this.closeTimeout) {
|
||||
clearTimeout(this.closeTimeout);
|
||||
this.closeTimeout = undefined;
|
||||
}
|
||||
this.emailSubmitting.set(false);
|
||||
this.notifications.show(this.i18n.t('cart.emailSuccess'), 'success');
|
||||
// Close popup and redirect to home page
|
||||
@@ -600,7 +615,7 @@ export class CartComponent implements OnDestroy {
|
||||
return hostname;
|
||||
}
|
||||
|
||||
return 'Покупка на Маркетплейсе';
|
||||
return this.i18n.t('cart.paymentDescriptionFallback');
|
||||
}
|
||||
|
||||
private generateOrderId(): string {
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
<app-skeleton shape="text" height="16px" />
|
||||
<app-skeleton shape="text" width="70%" height="16px" />
|
||||
</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()) {
|
||||
<section class="static-page__state">
|
||||
<app-empty-state title="404" [description]="'staticPages.notFound' | translate">
|
||||
|
||||
@@ -29,6 +29,7 @@ export class StaticPageComponent {
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly notFound = signal(false);
|
||||
readonly error = signal(false);
|
||||
readonly title = signal('');
|
||||
readonly homeRoute = signal('');
|
||||
readonly dir = signal<'ltr' | 'rtl'>('ltr');
|
||||
@@ -76,21 +77,32 @@ export class StaticPageComponent {
|
||||
private loadByKey(key: string): void {
|
||||
this.loading.set(true);
|
||||
this.notFound.set(false);
|
||||
this.error.set(false);
|
||||
|
||||
this.staticPageResolver.resolveByKey(key, this.languageService.currentLanguage()).subscribe(page => {
|
||||
this.applyPage(page?.title ?? '', page?.html ?? '', !page);
|
||||
this.staticPageResolver.resolveByKey(key, this.languageService.currentLanguage()).subscribe({
|
||||
next: page => this.applyPage(page?.title ?? '', page?.html ?? '', !page),
|
||||
error: () => this.applyError()
|
||||
});
|
||||
}
|
||||
|
||||
private loadByPath(path: string): void {
|
||||
this.loading.set(true);
|
||||
this.notFound.set(false);
|
||||
this.error.set(false);
|
||||
|
||||
this.staticPageResolver.resolveByRoute(path, this.languageService.currentLanguage()).subscribe(page => {
|
||||
this.applyPage(page?.title ?? '', page?.html ?? '', !page);
|
||||
this.staticPageResolver.resolveByRoute(path, this.languageService.currentLanguage()).subscribe({
|
||||
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 {
|
||||
if (notFound) {
|
||||
this.title.set('');
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface QrCreateResponse {
|
||||
|
||||
export interface CartPaymentRequest {
|
||||
amount: number;
|
||||
currency: 'RUB';
|
||||
currency: string;
|
||||
siteuserID: string;
|
||||
siteorderID: string;
|
||||
redirectUrl: string;
|
||||
@@ -54,7 +54,12 @@ export interface CartPaymentRequest {
|
||||
}
|
||||
|
||||
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 };
|
||||
payment?: { method: string; currency: 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[]> {
|
||||
let params = new HttpParams().set('count', count.toString());
|
||||
if (categoryID) {
|
||||
|
||||
@@ -203,20 +203,30 @@ export class CartService {
|
||||
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
|
||||
if (this.addingItems.has(itemID)) return;
|
||||
|
||||
if (this.addingItems.has(itemID)) return Promise.resolve();
|
||||
|
||||
const currentItems = this.cartItems();
|
||||
const existingItem = currentItems.find(i => this.isSameCartLine(i, itemID, variant));
|
||||
|
||||
|
||||
if (existingItem) {
|
||||
// Item exists, increase quantity
|
||||
this.updateQuantity(itemID, existingItem.quantity + quantity, variant);
|
||||
} else {
|
||||
// Get item details from API and add to cart
|
||||
this.addingItems.add(itemID);
|
||||
import('./api.service').then(({ ApiService }) => {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 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({
|
||||
next: (item) => {
|
||||
const cartItem = this.normalizeCartItem({
|
||||
@@ -229,17 +239,19 @@ export class CartService {
|
||||
});
|
||||
this.cartItems.set([...this.cartItems(), cartItem]);
|
||||
this.addingItems.delete(itemID);
|
||||
resolve();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error adding to cart:', err);
|
||||
this.addingItems.delete(itemID);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}).catch((err) => {
|
||||
console.error('Error loading API service:', err);
|
||||
this.addingItems.delete(itemID);
|
||||
});
|
||||
}
|
||||
})
|
||||
).catch((err) => {
|
||||
console.error('Error loading API service:', err);
|
||||
this.addingItems.delete(itemID);
|
||||
});
|
||||
}
|
||||
|
||||
updateQuantity(itemID: number, quantity: number, variant?: CartVariant): void {
|
||||
|
||||
@@ -4,6 +4,13 @@ import { Item } from '../models';
|
||||
import { getDiscountedPrice, getMainImage } from '../utils/item.utils';
|
||||
import { UiRuntimeFacade } from '../facades/runtime/ui-runtime.facade';
|
||||
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({
|
||||
providedIn: 'root'
|
||||
@@ -14,6 +21,7 @@ export class SeoService {
|
||||
private doc = inject(DOCUMENT);
|
||||
private readonly uiRuntime = inject(UiRuntimeFacade);
|
||||
private readonly configService = inject(ConfigService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
|
||||
constructor() {
|
||||
// Keep the runtime <title>/OG/Twitter/canonical/robots tags in sync with
|
||||
@@ -38,6 +46,10 @@ export class SeoService {
|
||||
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.
|
||||
*/
|
||||
@@ -59,7 +71,7 @@ export class SeoService {
|
||||
{ property: 'og:image', content: imageUrl },
|
||||
{ property: 'og:url', content: itemUrl },
|
||||
{ property: 'og:site_name', content: this.siteName },
|
||||
{ property: 'og:locale', content: 'ru_RU' },
|
||||
{ property: 'og:locale', content: this.ogLocale },
|
||||
|
||||
// Product-specific OG tags
|
||||
{ property: 'product:price:amount', content: price.toFixed(2) },
|
||||
@@ -74,6 +86,22 @@ export class SeoService {
|
||||
// Standard meta
|
||||
{ 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:url', content: this.siteUrl },
|
||||
{ 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:title', content: defaultTitle },
|
||||
@@ -127,6 +155,30 @@ export class SeoService {
|
||||
// Remove product-specific tags
|
||||
this.meta.removeTag("property='product:price:amount'");
|
||||
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 {
|
||||
|
||||
@@ -2,7 +2,21 @@ import { ThemeConfig } from '../../shared/models/config';
|
||||
import { ThemeCssVariables } from '../tokens/theme-css-variable.model';
|
||||
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
|
||||
? theme.spacing.scale
|
||||
: [0.25, 0.5, 1, 1.5, 2];
|
||||
@@ -49,5 +63,5 @@ export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariab
|
||||
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 configService = inject(ConfigService);
|
||||
|
||||
private systemDarkQuery?: MediaQueryList;
|
||||
private systemDarkListener?: (event: MediaQueryListEvent) => void;
|
||||
|
||||
initialize(): void {
|
||||
this.configService.loadBootstrap().pipe(take(1)).subscribe({
|
||||
next: (bootstrap) => this.applyTheme(bootstrap.theme),
|
||||
@@ -20,7 +23,20 @@ export class ThemeEngineService {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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-mode', theme.mode);
|
||||
root.setAttribute('data-theme-mode', effectiveMode);
|
||||
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 { Observable, of, map, switchMap } from 'rxjs';
|
||||
import { Observable, of, map, switchMap, catchError } from 'rxjs';
|
||||
import { CategoryFacade } from '../../facades/platform/category.facade';
|
||||
import { ProductFacade } from '../../facades/platform/product.facade';
|
||||
import { LanguageService } from '../../services/language.service';
|
||||
@@ -19,7 +19,11 @@ export class DataSourceResolverService {
|
||||
|
||||
resolve(widget: WidgetConfig, section: SectionConfig): Observable<unknown> {
|
||||
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)"
|
||||
></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>
|
||||
}
|
||||
}
|
||||
@@ -171,6 +180,23 @@ const SWIPE_THRESHOLD_PX = 50;
|
||||
&: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 {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@@ -199,6 +225,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
||||
@Output() ctaClicked = new EventEmitter<void>();
|
||||
|
||||
readonly activeIndex = signal(0);
|
||||
readonly isPaused = signal(false);
|
||||
private readonly dataSignal = signal<HeroWidgetData | null>(null);
|
||||
private autoplayHandle: ReturnType<typeof setInterval> | null = null;
|
||||
private swipeStartX: number | null = null;
|
||||
@@ -237,6 +264,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
||||
if (changes['data']) {
|
||||
this.dataSignal.set(this.data);
|
||||
this.activeIndex.set(0);
|
||||
this.isPaused.set(false);
|
||||
this.setupAutoplay();
|
||||
}
|
||||
}
|
||||
@@ -289,10 +317,20 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
||||
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 {
|
||||
this.clearAutoplay();
|
||||
const slides = this.allSlides();
|
||||
if (!this.data?.autoplay || slides.length <= 1) {
|
||||
if (!this.data?.autoplay || slides.length <= 1 || this.isPaused() || this.prefersReducedMotion()) {
|
||||
return;
|
||||
}
|
||||
this.autoplayHandle = setInterval(() => {
|
||||
|
||||
@@ -352,6 +352,19 @@
|
||||
"en": "<h2>Terms of Service</h2><p>Platform usage is governed by public offer terms.</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": [
|
||||
|
||||
Reference in New Issue
Block a user