Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

This commit is contained in:
sdarbinyan
2026-08-13 13:01:42 +04:00
87 changed files with 1503 additions and 265 deletions

View File

@@ -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`.

View File

@@ -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;
}

View File

@@ -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',

View File

@@ -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);
};
}

View 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);
}
}

View File

@@ -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) {

View File

@@ -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 };

View File

@@ -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)
});

View File

@@ -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)
});

View File

@@ -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'] {

View File

@@ -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); }
});
}

View File

@@ -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">

View File

@@ -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];

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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[] {

View File

@@ -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)
);
}
}

View File

@@ -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');
}
});
}

View File

@@ -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>
}

View File

@@ -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 {

View File

@@ -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">

View File

@@ -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]);
}

View File

@@ -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>
}

View File

@@ -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);
}

View File

@@ -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. */

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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">

View File

@@ -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';

View File

@@ -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')
});
}
}

View File

@@ -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 {

View File

@@ -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']);
});
}
}

View File

@@ -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();
}

View File

@@ -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">

View File

@@ -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

View File

@@ -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');
}
});
}

View File

@@ -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 {

View File

@@ -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() }],
}));
}

View File

@@ -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 {

View File

@@ -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[];

View File

@@ -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>

View File

@@ -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',

View File

@@ -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[]>;

View File

@@ -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 = [

View File

@@ -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();

View File

@@ -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,

View File

@@ -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)"

View File

@@ -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>();

View File

@@ -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)" />

View File

@@ -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>();

View File

@@ -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)" />

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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 {

View File

@@ -7,7 +7,7 @@
}
.star {
color: #cdd6d5;
color: var(--border-color);
}
.star.filled {

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>
}

View File

@@ -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 {

View File

@@ -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.',

View File

@@ -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: 'Լավ ապրանքին բավական է հստակ վերնագիր, առնվազն մեկ լուսանկար, գին և կարճ նկարագրություն՝ հրապարակելու համար։ Մնացածը կարող եք ավելացնել հետո։',

View File

@@ -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: 'Хорошему товару нужны понятное название, минимум одно фото, цена и короткое описание — этого достаточно для публикации. Остальное можно добавить позже.',

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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>
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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">

View File

@@ -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('');

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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: {} });
})
);
}

View File

@@ -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(() => {

View File

@@ -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": [