diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 99e679a..c081dfd 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -185,6 +185,33 @@ const coreRoutes: Routes = [
breadcrumb: [{ labelKey: 'adminShell.nav.customers', path: ['customers'] }, { labelKey: 'adminShell.pages.customerDetail.title' }]
}
},
+ {
+ path: 'moderation',
+ loadComponent: () => import('./features/admin/moderation/pages/admin-reviews-list-page.component').then(m => m.AdminReviewsListPageComponent),
+ data: {
+ titleKey: 'adminShell.pages.moderation.title',
+ descriptionKey: 'adminShell.pages.moderation.description',
+ breadcrumb: [{ labelKey: 'adminShell.nav.moderation' }]
+ }
+ },
+ {
+ path: 'moderation/reports',
+ loadComponent: () => import('./features/admin/moderation/pages/admin-reports-list-page.component').then(m => m.AdminReportsListPageComponent),
+ data: {
+ titleKey: 'adminShell.pages.reportsQueue.title',
+ descriptionKey: 'adminShell.pages.reportsQueue.description',
+ breadcrumb: [{ labelKey: 'adminShell.nav.moderation', path: ['moderation'] }, { labelKey: 'adminShell.pages.reportsQueue.title' }]
+ }
+ },
+ {
+ path: 'moderation/:id',
+ loadComponent: () => import('./features/admin/moderation/pages/admin-review-detail-page.component').then(m => m.AdminReviewDetailPageComponent),
+ data: {
+ titleKey: 'adminShell.pages.reviewDetail.title',
+ descriptionKey: 'adminShell.pages.reviewDetail.description',
+ breadcrumb: [{ labelKey: 'adminShell.nav.moderation', path: ['moderation'] }, { labelKey: 'adminShell.pages.reviewDetail.title' }]
+ }
+ },
{
path: 'media',
loadComponent: () => import('./features/backoffice/media/media-library-page.component').then(m => m.MediaLibraryPageComponent),
diff --git a/src/app/features/admin/moderation/components/moderation-dashboard/moderation-dashboard.component.html b/src/app/features/admin/moderation/components/moderation-dashboard/moderation-dashboard.component.html
new file mode 100644
index 0000000..75b7240
--- /dev/null
+++ b/src/app/features/admin/moderation/components/moderation-dashboard/moderation-dashboard.component.html
@@ -0,0 +1,24 @@
+
+
+
+
+ {{ 'adminOrders.recentActivity' | translate }}
+ @if (stats().recentActivity.length === 0) {
+ {{ 'adminOrders.noRecentActivity' | translate }}
+ } @else {
+
+ @for (entry of stats().recentActivity; track entry.timestamp + entry.reviewId) {
+ - {{ entry.timestamp | date:'short' }} — {{ entry.productName }} — {{ entry.action }}
+ }
+
+ }
+
+
diff --git a/src/app/features/admin/moderation/components/moderation-dashboard/moderation-dashboard.component.scss b/src/app/features/admin/moderation/components/moderation-dashboard/moderation-dashboard.component.scss
new file mode 100644
index 0000000..fbe84ae
--- /dev/null
+++ b/src/app/features/admin/moderation/components/moderation-dashboard/moderation-dashboard.component.scss
@@ -0,0 +1,7 @@
+.moderation-dashboard { display: grid; gap: 16px; margin-bottom: 8px; }
+.moderation-dashboard__metrics { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 12px; }
+@media (max-width: 1100px) { .moderation-dashboard__metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
+@media (max-width: 640px) { .moderation-dashboard__metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+.moderation-dashboard__activity h4 { margin: 0 0 8px; font-size: 0.95rem; font-weight: 700; }
+.moderation-dashboard__activity ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); }
+.moderation-dashboard__activity p { margin: 0; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); }
diff --git a/src/app/features/admin/moderation/components/moderation-dashboard/moderation-dashboard.component.ts b/src/app/features/admin/moderation/components/moderation-dashboard/moderation-dashboard.component.ts
new file mode 100644
index 0000000..43a5695
--- /dev/null
+++ b/src/app/features/admin/moderation/components/moderation-dashboard/moderation-dashboard.component.ts
@@ -0,0 +1,18 @@
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { DatePipe } from '@angular/common';
+import { TranslatePipe } from '../../../../../i18n/translate.pipe';
+import { CardComponent } from '../../../../../shared/ui/card/card.component';
+import { DashboardMetricComponent } from '../../../dashboard/components/dashboard-metric.component';
+import { AdminModerationDashboardStats } from '../../facade/admin-moderation.facade';
+
+@Component({
+ selector: 'app-moderation-dashboard',
+ standalone: true,
+ imports: [TranslatePipe, DatePipe, CardComponent, DashboardMetricComponent],
+ templateUrl: './moderation-dashboard.component.html',
+ styleUrl: './moderation-dashboard.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ModerationDashboardComponent {
+ readonly stats = input.required();
+}
diff --git a/src/app/features/admin/moderation/components/review-health-widget/review-health-widget.component.html b/src/app/features/admin/moderation/components/review-health-widget/review-health-widget.component.html
new file mode 100644
index 0000000..ca066ca
--- /dev/null
+++ b/src/app/features/admin/moderation/components/review-health-widget/review-health-widget.component.html
@@ -0,0 +1,14 @@
+
+
+
{{ completionPercent() }}%
+ @if (!compact()) {
+
+ @for (item of items(); track item.labelKey) {
+ -
+ {{ item.done ? '✓' : '○' }}
+ {{ item.labelKey | translate }}
+
+ }
+
+ }
+
diff --git a/src/app/features/admin/moderation/components/review-health-widget/review-health-widget.component.scss b/src/app/features/admin/moderation/components/review-health-widget/review-health-widget.component.scss
new file mode 100644
index 0000000..68f59e0
--- /dev/null
+++ b/src/app/features/admin/moderation/components/review-health-widget/review-health-widget.component.scss
@@ -0,0 +1,8 @@
+.review-health { display: grid; gap: 6px; align-items: center; }
+.review-health__bar { height: 6px; border-radius: 999px; background: var(--surface-muted, #eef2f0); overflow: hidden; }
+.review-health__bar-fill { height: 100%; background: var(--brand-primary, #1e8a6e); }
+.review-health__percent { font-size: 0.75rem; font-weight: 700; color: var(--text-secondary, #5f6e6a); }
+.review-health__list { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font-size: 0.78rem; color: var(--text-secondary, #5f6e6a); }
+.review-health__list li { display: flex; align-items: center; gap: 6px; }
+.review-health__list-item--done { color: var(--text-primary, #1e3c38); }
+.review-health--compact { grid-template-columns: 1fr auto; }
diff --git a/src/app/features/admin/moderation/components/review-health-widget/review-health-widget.component.ts b/src/app/features/admin/moderation/components/review-health-widget/review-health-widget.component.ts
new file mode 100644
index 0000000..60aa108
--- /dev/null
+++ b/src/app/features/admin/moderation/components/review-health-widget/review-health-widget.component.ts
@@ -0,0 +1,22 @@
+import { ChangeDetectionStrategy, Component, input } from '@angular/core';
+import { TranslatePipe } from '../../../../../i18n/translate.pipe';
+
+export interface ReviewHealthItem {
+ labelKey: string;
+ done: boolean;
+}
+
+/** Reusable checklist + completion meter, fed real per-item booleans from AdminModerationFacade.health() - never invents its own data. */
+@Component({
+ selector: 'app-review-health-widget',
+ standalone: true,
+ imports: [TranslatePipe],
+ templateUrl: './review-health-widget.component.html',
+ styleUrl: './review-health-widget.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ReviewHealthWidgetComponent {
+ readonly items = input.required();
+ readonly completionPercent = input.required();
+ readonly compact = input(false);
+}
diff --git a/src/app/features/admin/moderation/facade/admin-moderation.facade.ts b/src/app/features/admin/moderation/facade/admin-moderation.facade.ts
new file mode 100644
index 0000000..92d5bb6
--- /dev/null
+++ b/src/app/features/admin/moderation/facade/admin-moderation.facade.ts
@@ -0,0 +1,218 @@
+import { Injectable, computed, inject, signal } from '@angular/core';
+import { take } from 'rxjs/operators';
+import { AdminReview, AdminReviewListFilters, AdminReviewStatus } from '../models/admin-review.model';
+import { AdminReport, AdminReportStatus } from '../models/admin-report.model';
+import { AdminModerationLocalGateway } from '../services/admin-moderation-local.gateway';
+import { LocalStorageService } from '../../../../core/storage/local-storage.service';
+
+export type AdminModerationViewMode = 'table' | 'cards';
+export type AdminModerationDensity = 'comfortable' | 'compact';
+
+export const ALL_REVIEW_COLUMNS = ['product', 'rating', 'status', 'reports', 'created'] as const;
+export type AdminReviewColumn = typeof ALL_REVIEW_COLUMNS[number];
+
+export interface AdminReviewHealth {
+ hasRating: boolean;
+ hasText: boolean;
+ hasMedia: boolean;
+ isModerated: boolean;
+ reportStatusClear: boolean;
+ isVisible: boolean;
+ completionPercent: number;
+}
+
+export interface AdminModerationDashboardStats {
+ pending: number;
+ approved: number;
+ rejected: number;
+ reported: number;
+ spam: number;
+ averageRating: number | null;
+ recentActivity: { reviewId: string; productName: string; action: string; timestamp: string }[];
+ moderationHealthPercent: number;
+}
+
+const VIEW_MODE_KEY = 'admin-moderation:view-mode';
+const DENSITY_KEY = 'admin-moderation:density';
+const COLUMNS_KEY = 'admin-moderation:visible-columns';
+
+@Injectable({ providedIn: 'root' })
+export class AdminModerationFacade {
+ private readonly gateway = inject(AdminModerationLocalGateway);
+ private readonly localStorage = inject(LocalStorageService);
+
+ readonly filters = signal({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 10 });
+ readonly reviews = signal([]);
+ readonly total = signal(0);
+ readonly loading = signal(false);
+ readonly selected = signal(null);
+ readonly reports = signal([]);
+
+ readonly viewMode = signal((this.localStorage.getItem(VIEW_MODE_KEY) as AdminModerationViewMode) || 'table');
+ readonly density = signal((this.localStorage.getItem(DENSITY_KEY) as AdminModerationDensity) || 'comfortable');
+ readonly visibleColumns = signal(this.localStorage.getJSON(COLUMNS_KEY) ?? [...ALL_REVIEW_COLUMNS]);
+ readonly selectedIds = signal([]);
+ readonly dashboardStats = signal(null);
+
+ setViewMode(mode: AdminModerationViewMode): void {
+ this.viewMode.set(mode);
+ this.localStorage.setItem(VIEW_MODE_KEY, mode);
+ }
+
+ setDensity(density: AdminModerationDensity): void {
+ this.density.set(density);
+ this.localStorage.setItem(DENSITY_KEY, density);
+ }
+
+ setColumnVisible(column: AdminReviewColumn, visible: boolean): void {
+ const next = visible ? [...new Set([...this.visibleColumns(), column])] : this.visibleColumns().filter(c => c !== column);
+ this.visibleColumns.set(next);
+ this.localStorage.setJSON(COLUMNS_KEY, next);
+ }
+
+ toggleSelection(id: string, checked: boolean): void {
+ this.selectedIds.update(current => checked ? [...new Set([...current, id])] : current.filter(item => item !== id));
+ }
+
+ toggleAll(checked: boolean): void {
+ this.selectedIds.set(checked ? this.reviews().map(review => review.id) : []);
+ }
+
+ clearSelection(): void {
+ this.selectedIds.set([]);
+ }
+
+ loadList(): void {
+ this.loading.set(true);
+ this.gateway.loadReviews(this.filters()).pipe(take(1)).subscribe({
+ next: result => {
+ this.reviews.set(result.items);
+ this.total.set(result.total);
+ this.loading.set(false);
+ },
+ error: () => {
+ this.reviews.set([]);
+ this.total.set(0);
+ this.loading.set(false);
+ }
+ });
+ }
+
+ updateFilters(patch: Partial): void {
+ this.filters.update(current => ({ ...current, ...patch, page: patch.page ?? 1 }));
+ this.loadList();
+ }
+
+ loadDetail(id: string): void {
+ this.gateway.loadReview(id).pipe(take(1)).subscribe({ next: review => this.selected.set(review) });
+ }
+
+ setStatus(id: string, status: AdminReviewStatus, note = ''): void {
+ this.gateway.setReviewStatus(id, status, note).pipe(take(1)).subscribe({ next: review => this.selected.set(review) });
+ }
+
+ setVisible(id: string, visible: boolean): void {
+ this.gateway.setReviewVisible(id, visible).pipe(take(1)).subscribe({ next: review => this.selected.set(review) });
+ }
+
+ setPinned(id: string, pinned: boolean): void {
+ this.gateway.setReviewPinned(id, pinned).pipe(take(1)).subscribe({ next: review => this.selected.set(review) });
+ }
+
+ setFeatured(id: string, featured: boolean): void {
+ this.gateway.setReviewFeatured(id, featured).pipe(take(1)).subscribe({ next: review => this.selected.set(review) });
+ }
+
+ addNote(id: string, note: string): void {
+ if (!note.trim()) return;
+ this.gateway.addModeratorNote(id, note.trim()).pipe(take(1)).subscribe({ next: review => this.selected.set(review) });
+ }
+
+ applyBulkStatus(status: AdminReviewStatus): void {
+ const ids = [...this.selectedIds()];
+ ids.forEach(id => this.gateway.setReviewStatus(id, status, `Bulk status change to ${status}`).pipe(take(1)).subscribe());
+ this.clearSelection();
+ this.loadList();
+ this.loadDashboardStats();
+ }
+
+ applyBulkVisible(visible: boolean): void {
+ const ids = [...this.selectedIds()];
+ ids.forEach(id => this.gateway.setReviewVisible(id, visible).pipe(take(1)).subscribe());
+ this.clearSelection();
+ this.loadList();
+ }
+
+ applyBulkDelete(): void {
+ const ids = [...this.selectedIds()];
+ ids.forEach(id => this.gateway.deleteReview(id).pipe(take(1)).subscribe());
+ this.clearSelection();
+ this.loadList();
+ this.loadDashboardStats();
+ }
+
+ exportSelectedAsCsv(): void {
+ const selected = new Set(this.selectedIds());
+ const rows = this.reviews().filter(review => selected.has(review.id));
+ const header = 'Product,Customer,Rating,Status,Reports,Created At';
+ const lines = rows.map(review => [review.productName, review.customerName, review.rating, review.status, review.reportCount, review.createdAt].join(','));
+ const csv = [header, ...lines].join('\n');
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = 'reviews-export.csv';
+ link.click();
+ URL.revokeObjectURL(url);
+ }
+
+ loadReports(): void {
+ this.gateway.loadReports().pipe(take(1)).subscribe({ next: reports => this.reports.set(reports) });
+ }
+
+ setReportStatus(id: string, status: AdminReportStatus): void {
+ this.gateway.setReportStatus(id, status).pipe(take(1)).subscribe({ next: () => this.loadReports() });
+ }
+
+ health(review: AdminReview): AdminReviewHealth {
+ const hasRating = review.rating > 0;
+ const hasText = review.text.trim().length > 0;
+ const hasMedia = review.photos.length > 0;
+ const isModerated = review.status !== 'pending';
+ const reportStatusClear = review.reportCount === 0;
+ const isVisible = review.visible;
+ const checks = [hasRating, hasText, hasMedia, isModerated, reportStatusClear, isVisible];
+ const completionPercent = Math.round((checks.filter(Boolean).length / checks.length) * 100);
+ return { hasRating, hasText, hasMedia, isModerated, reportStatusClear, isVisible, completionPercent };
+ }
+
+ /** Dashboard reflects the whole review queue, not just the current filtered/paginated page. */
+ loadDashboardStats(): void {
+ this.gateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }).pipe(take(1))
+ .subscribe({ next: result => this.dashboardStats.set(this.computeDashboardStats(result.items)) });
+ }
+
+ private computeDashboardStats(reviews: AdminReview[]): AdminModerationDashboardStats {
+ const rated = reviews.filter(review => review.rating > 0);
+ const averageRating = rated.length > 0 ? Math.round((rated.reduce((sum, review) => sum + review.rating, 0) / rated.length) * 10) / 10 : null;
+
+ const recentActivity = reviews
+ .flatMap(review => review.timeline.map(entry => ({ reviewId: review.id, productName: review.productName, action: entry.action, timestamp: entry.timestamp })))
+ .sort((a, b) => b.timestamp.localeCompare(a.timestamp))
+ .slice(0, 8);
+
+ const moderated = reviews.filter(review => review.status !== 'pending').length;
+ const moderationHealthPercent = reviews.length > 0 ? Math.round((moderated / reviews.length) * 100) : 100;
+
+ return {
+ pending: reviews.filter(review => review.status === 'pending').length,
+ approved: reviews.filter(review => review.status === 'approved').length,
+ rejected: reviews.filter(review => review.status === 'rejected').length,
+ reported: reviews.filter(review => review.reportCount > 0).length,
+ spam: reviews.filter(review => review.status === 'spam').length,
+ averageRating,
+ recentActivity,
+ moderationHealthPercent,
+ };
+ }
+}
diff --git a/src/app/features/admin/moderation/models/admin-report.model.ts b/src/app/features/admin/moderation/models/admin-report.model.ts
new file mode 100644
index 0000000..8e8abd0
--- /dev/null
+++ b/src/app/features/admin/moderation/models/admin-report.model.ts
@@ -0,0 +1,13 @@
+export type AdminReportTargetType = 'product' | 'review' | 'customer' | 'category' | 'unknown';
+export type AdminReportStatus = 'open' | 'resolved' | 'dismissed';
+
+export interface AdminReport {
+ id: string;
+ targetType: AdminReportTargetType;
+ targetId: string;
+ targetLabel: string;
+ reason: string;
+ reporterEmail: string;
+ status: AdminReportStatus;
+ createdAt: string;
+}
diff --git a/src/app/features/admin/moderation/models/admin-review.model.ts b/src/app/features/admin/moderation/models/admin-review.model.ts
new file mode 100644
index 0000000..5ea49fe
--- /dev/null
+++ b/src/app/features/admin/moderation/models/admin-review.model.ts
@@ -0,0 +1,43 @@
+export type AdminReviewStatus = 'pending' | 'approved' | 'rejected' | 'spam';
+
+export interface AdminReviewTimelineEntry {
+ action: string;
+ actor: string;
+ note: string;
+ timestamp: string;
+}
+
+export interface AdminReview {
+ id: string;
+ productId: string;
+ productName: string;
+ customerName: string;
+ customerEmail: string;
+ rating: number;
+ text: string;
+ photos: string[];
+ status: AdminReviewStatus;
+ visible: boolean;
+ pinned: boolean;
+ featured: boolean;
+ reportCount: number;
+ moderatorNotes: string;
+ timeline: AdminReviewTimelineEntry[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface AdminReviewListFilters {
+ search: string;
+ status: 'all' | AdminReviewStatus;
+ rating: 'all' | number;
+ page: number;
+ pageSize: number;
+}
+
+export interface AdminReviewsListResult {
+ items: AdminReview[];
+ total: number;
+ page: number;
+ pageSize: number;
+}
diff --git a/src/app/features/admin/moderation/pages/admin-reports-list-page.component.html b/src/app/features/admin/moderation/pages/admin-reports-list-page.component.html
new file mode 100644
index 0000000..a523e11
--- /dev/null
+++ b/src/app/features/admin/moderation/pages/admin-reports-list-page.component.html
@@ -0,0 +1,46 @@
+
+
+
+ @if (facade.reports().length === 0) {
+
+ } @else {
+
+
+
+ | {{ 'adminModeration.reportTarget' | translate }} |
+ {{ 'adminModeration.reportType' | translate }} |
+ {{ 'adminModeration.reportReason' | translate }} |
+ {{ 'backoffice.status' | translate }} |
+ {{ 'adminOrders.createdAt' | translate }} |
+ {{ 'adminProducts.actions' | translate }} |
+
+
+
+ @for (report of facade.reports(); track report.id) {
+
+ |
+ @if (canOpen(report.targetType, report.targetId)) {
+
+ } @else {
+ {{ report.targetLabel || ('adminModeration.notAvailable' | translate) }}
+ }
+ |
+ {{ ('adminModeration.targetType.' + report.targetType) | translate }} |
+ {{ report.reason }} |
+ {{ ('adminModeration.reportStatus.' + report.status) | translate }} |
+ {{ report.createdAt | date:'short' }} |
+
+ @if (report.status === 'open') {
+ {{ 'adminModeration.resolve' | translate }}
+ {{ 'adminModeration.dismiss' | translate }}
+ }
+ |
+
+ }
+
+
+ }
+
diff --git a/src/app/features/admin/moderation/pages/admin-reports-list-page.component.scss b/src/app/features/admin/moderation/pages/admin-reports-list-page.component.scss
new file mode 100644
index 0000000..44551b2
--- /dev/null
+++ b/src/app/features/admin/moderation/pages/admin-reports-list-page.component.scss
@@ -0,0 +1,6 @@
+.admin-reports-card { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; }
+.toolbar { display: flex; align-items: center; gap: 12px; }
+.toolbar h1 { margin: 0; font-size: 1.25rem; }
+.actions { display: flex; gap: 8px; }
+.link-button { all: unset; cursor: pointer; color: var(--brand-primary, #1e8a6e); font-weight: 600; }
+.link-button:hover, .link-button:focus-visible { text-decoration: underline; }
diff --git a/src/app/features/admin/moderation/pages/admin-reports-list-page.component.ts b/src/app/features/admin/moderation/pages/admin-reports-list-page.component.ts
new file mode 100644
index 0000000..db11129
--- /dev/null
+++ b/src/app/features/admin/moderation/pages/admin-reports-list-page.component.ts
@@ -0,0 +1,47 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { Router } from '@angular/router';
+import { AdminModerationFacade } from '../facade/admin-moderation.facade';
+import { AdminReportTargetType } from '../models/admin-report.model';
+import { TranslatePipe } from '../../../../i18n/translate.pipe';
+import { LanguageService } from '../../../../services/language.service';
+import { ButtonComponent } from '../../../../shared/ui/button/button.component';
+import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
+import { TableComponent } from '../../../../shared/ui/table/table.component';
+import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
+
+@Component({
+ selector: 'app-admin-reports-list-page',
+ standalone: true,
+ imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent, EmptyStateComponent],
+ templateUrl: './admin-reports-list-page.component.html',
+ styleUrls: ['./admin-reports-list-page.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class AdminReportsListPageComponent {
+ readonly facade = inject(AdminModerationFacade);
+ private readonly router = inject(Router);
+ private readonly languageService = inject(LanguageService);
+
+ constructor() {
+ this.facade.loadReports();
+ }
+
+ back(): void {
+ void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'moderation']);
+ }
+
+ openTarget(targetType: AdminReportTargetType, targetId: string): void {
+ if (!targetId) return;
+ const lang = this.languageService.currentLanguage();
+ if (targetType === 'review') {
+ void this.router.navigate([lang, 'backoffice', 'moderation', targetId]);
+ } else if (targetType === 'product') {
+ void this.router.navigate([lang, 'backoffice', 'products', targetId, 'edit']);
+ }
+ }
+
+ canOpen(targetType: AdminReportTargetType, targetId: string): boolean {
+ return !!targetId && (targetType === 'review' || targetType === 'product');
+ }
+}
diff --git a/src/app/features/admin/moderation/pages/admin-review-detail-page.component.html b/src/app/features/admin/moderation/pages/admin-review-detail-page.component.html
new file mode 100644
index 0000000..085dcd6
--- /dev/null
+++ b/src/app/features/admin/moderation/pages/admin-review-detail-page.component.html
@@ -0,0 +1,67 @@
+@if (facade.selected(); as review) {
+
+
+
+
+
+
{{ 'adminModeration.customer' | translate }}
+
{{ review.customerName }}
+
{{ review.customerEmail }}
+
+
+
{{ 'adminModeration.product' | translate }}
+
{{ review.productName }}
+
+
+
+
+ {{ 'adminModeration.reviewText' | translate }}
+ {{ review.text }}
+ {{ 'adminModeration.photos' | translate }}
+ @if (review.photos.length === 0) {
+ {{ 'adminModeration.notAvailable' | translate }}
+ } @else {
+
+ @for (photo of review.photos; track photo) {
![]()
}
+
+ }
+
+
+
+ {{ 'adminModeration.workflow' | translate }}
+
+
{{ 'adminModeration.approve' | translate }}
+
{{ 'adminModeration.reject' | translate }}
+
{{ 'adminModeration.markSpam' | translate }}
+ @if (review.visible) {
+
{{ 'adminModeration.hide' | translate }}
+ } @else {
+
{{ 'adminModeration.restore' | translate }}
+ }
+
{{ (review.pinned ? 'adminModeration.unpin' : 'adminModeration.pin') | translate }}
+
{{ (review.featured ? 'adminModeration.unfeature' : 'adminModeration.feature') | translate }}
+
+
+
+
+ {{ 'adminOrders.timeline' | translate }}
+
+
+
+
+ {{ 'adminModeration.moderatorNotes' | translate }}
+ @for (line of review.moderatorNotes.split('\n'); track $index) { @if (line) { {{ line }}
} }
+
+ {{ 'adminOrders.addNote' | translate }}
+
+
+} @else {
+ {{ 'common.loading' | translate }}
+}
diff --git a/src/app/features/admin/moderation/pages/admin-review-detail-page.component.scss b/src/app/features/admin/moderation/pages/admin-review-detail-page.component.scss
new file mode 100644
index 0000000..9e42d91
--- /dev/null
+++ b/src/app/features/admin/moderation/pages/admin-review-detail-page.component.scss
@@ -0,0 +1,14 @@
+.review-detail { max-width: 1000px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; }
+.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
+.toolbar h1 { margin: 0; font-size: 1.25rem; }
+.grid { display: grid; gap: 16px; }
+.grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.card { border: 1px solid var(--border-color, #d3dad9); border-radius: 12px; padding: 14px; display: grid; gap: 6px; }
+.card h3 { margin: 0 0 6px; }
+.card p { margin: 0; }
+.muted { color: var(--text-secondary, #5f6e6a); font-size: 0.85rem; }
+.photo-row { display: flex; flex-wrap: wrap; gap: 8px; }
+.photo-row img { width: 72px; height: 72px; object-fit: cover; border-radius: 8px; }
+.workflow-actions { display: flex; flex-wrap: wrap; gap: 8px; }
+textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 8px; font: inherit; }
+@media (max-width: 700px) { .grid.two { grid-template-columns: 1fr; } }
diff --git a/src/app/features/admin/moderation/pages/admin-review-detail-page.component.ts b/src/app/features/admin/moderation/pages/admin-review-detail-page.component.ts
new file mode 100644
index 0000000..b451e2b
--- /dev/null
+++ b/src/app/features/admin/moderation/pages/admin-review-detail-page.component.ts
@@ -0,0 +1,48 @@
+import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { AdminModerationFacade } from '../facade/admin-moderation.facade';
+import { TranslatePipe } from '../../../../i18n/translate.pipe';
+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 '../../orders/components/order-timeline/order-timeline.component';
+
+@Component({
+ selector: 'app-admin-review-detail-page',
+ standalone: true,
+ imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, OrderTimelineComponent],
+ templateUrl: './admin-review-detail-page.component.html',
+ styleUrls: ['./admin-review-detail-page.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class AdminReviewDetailPageComponent {
+ readonly facade = inject(AdminModerationFacade);
+ private readonly route = inject(ActivatedRoute);
+ private readonly router = inject(Router);
+ private readonly languageService = inject(LanguageService);
+
+ readonly noteDraft = signal('');
+
+ constructor() {
+ const id = this.route.snapshot.paramMap.get('id');
+ if (id) {
+ this.facade.loadDetail(id);
+ }
+ }
+
+ timelineEntries(): OrderTimelineEntry[] {
+ const review = this.facade.selected();
+ return review ? review.timeline.map(entry => ({ timestamp: entry.timestamp, note: `${entry.action}${entry.note ? ' — ' + entry.note : ''} (${entry.actor})` })) : [];
+ }
+
+ back(): void {
+ void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'moderation']);
+ }
+
+ submitNote(id: string): void {
+ this.facade.addNote(id, this.noteDraft());
+ this.noteDraft.set('');
+ }
+}
diff --git a/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.html b/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.html
new file mode 100644
index 0000000..a803927
--- /dev/null
+++ b/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.html
@@ -0,0 +1,133 @@
+
+ @if (facade.dashboardStats(); as stats) {
+
+ }
+
+
+
+ @if (columnsPanelOpen()) {
+
+ @for (column of allColumns; track column) {
+
+ }
+
+ }
+
+ @if (facade.selectedIds().length > 0) {
+
+
{{ facade.selectedIds().length }} {{ 'adminProducts.selectedCount' | translate }}
+
+
{{ 'adminOrders.applyStatus' | translate }}
+
{{ 'adminModeration.hideSelected' | translate }}
+
{{ 'adminProducts.bulkExportAction' | translate }}
+
{{ 'adminModeration.archiveSelected' | translate }}
+
+ }
+
+ @if (facade.loading()) {
+
+ @for (i of [1,2,3,4]; track i) {
}
+
+ } @else if (facade.reviews().length === 0) {
+
+ {{ 'adminModeration.emptyGuide' | translate }}
+ } @else if (facade.viewMode() === 'table') {
+
+ } @else {
+
+ @for (review of facade.reviews(); track review.id) {
+
+
+
+ {{ review.customerName }}
+ {{ review.rating }}★
+
+ {{ review.productName }}
+ {{ review.text }}
+ {{ ('adminModeration.status.' + review.status) | translate }}
+
+ {{ 'adminOrders.view' | translate }}
+
+ }
+
+ }
+
+ @if (facade.reviews().length > 0) {
+
+ }
+
diff --git a/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.scss b/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.scss
new file mode 100644
index 0000000..db0bd4a
--- /dev/null
+++ b/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.scss
@@ -0,0 +1,32 @@
+.admin-reviews-card { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; }
+.toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; align-items: flex-start; }
+.filters { display: flex; flex-wrap: wrap; gap: 10px; flex: 1; }
+select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
+.toolbar__view-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+.view-toggle { display: flex; gap: 2px; }
+.columns-panel { display: flex; flex-wrap: wrap; gap: 12px; }
+.check { display: inline-flex; align-items: center; gap: 6px; }
+.bulk-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.skeleton-rows { display: grid; gap: 8px; }
+.pager { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
+.admin-reviews-card__guide { margin: 0; text-align: center; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); }
+.health-cell { min-width: 140px; }
+
+.table-scroll {
+ max-height: 70vh;
+ overflow: auto;
+
+ thead th { position: sticky; top: 0; background: #fff; z-index: 1; }
+}
+
+.density-compact td, .density-compact th { padding: 4px 8px; }
+
+.review-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
+.review-grid--compact { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
+.review-grid__item { position: relative; display: grid; gap: 6px; }
+.review-grid__select { position: absolute; top: 8px; left: 8px; z-index: 1; }
+.review-grid__meta { display: flex; justify-content: space-between; font-size: 0.85rem; }
+.review-grid__product { margin: 0; font-size: 0.78rem; color: var(--text-tertiary, #9aa6a2); }
+.review-grid__text { margin: 0; font-size: 0.85rem; color: var(--text-secondary, #5f6e6a); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+
+@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } }
diff --git a/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.ts b/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.ts
new file mode 100644
index 0000000..5bf8cee
--- /dev/null
+++ b/src/app/features/admin/moderation/pages/admin-reviews-list-page.component.ts
@@ -0,0 +1,79 @@
+import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { AdminModerationFacade, AdminReviewColumn, ALL_REVIEW_COLUMNS } from '../facade/admin-moderation.facade';
+import { AdminReviewStatus } from '../models/admin-review.model';
+import { TranslatePipe } from '../../../../i18n/translate.pipe';
+import { LanguageService } from '../../../../services/language.service';
+import { ButtonComponent } from '../../../../shared/ui/button/button.component';
+import { InputComponent } from '../../../../shared/ui/input/input.component';
+import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
+import { TableComponent } from '../../../../shared/ui/table/table.component';
+import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
+import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
+import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
+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';
+
+@Component({
+ selector: 'app-admin-reviews-list-page',
+ standalone: true,
+ imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, SkeletonComponent, CardComponent, ModerationDashboardComponent, ReviewHealthWidgetComponent],
+ templateUrl: './admin-reviews-list-page.component.html',
+ styleUrls: ['./admin-reviews-list-page.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class AdminReviewsListPageComponent {
+ readonly facade = inject(AdminModerationFacade);
+ private readonly router = inject(Router);
+ private readonly languageService = inject(LanguageService);
+
+ readonly statuses = ['all', 'pending', 'approved', 'rejected', 'spam'] as const;
+ readonly ratings = ['all', 5, 4, 3, 2, 1] as const;
+ readonly allColumns = ALL_REVIEW_COLUMNS;
+ protected readonly columnsPanelOpen = signal(false);
+ protected readonly bulkStatusValue = signal('approved');
+
+ constructor() {
+ this.facade.loadList();
+ this.facade.loadDashboardStats();
+ }
+
+ totalPages(): number {
+ return Math.max(1, Math.ceil(this.facade.total() / this.facade.filters().pageSize));
+ }
+
+ view(id: string): void {
+ void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'moderation', id]);
+ }
+
+ viewReports(): void {
+ void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'moderation', 'reports']);
+ }
+
+ isSelected(id: string): boolean {
+ return this.facade.selectedIds().includes(id);
+ }
+
+ isColumnVisible(column: AdminReviewColumn): boolean {
+ return this.facade.visibleColumns().includes(column);
+ }
+
+ healthItems(review: Parameters[0]): ReviewHealthItem[] {
+ const health = this.facade.health(review);
+ return [
+ { labelKey: 'adminModeration.healthRating', done: health.hasRating },
+ { labelKey: 'adminModeration.healthText', done: health.hasText },
+ { labelKey: 'adminModeration.healthMedia', done: health.hasMedia },
+ { labelKey: 'adminModeration.healthModeration', done: health.isModerated },
+ { labelKey: 'adminModeration.healthReportStatus', done: health.reportStatusClear },
+ { labelKey: 'adminModeration.healthVisibility', done: health.isVisible },
+ ];
+ }
+
+ applyBulkStatus(): void {
+ this.facade.applyBulkStatus(this.bulkStatusValue());
+ }
+}
diff --git a/src/app/features/admin/moderation/services/admin-moderation-gateway.interface.ts b/src/app/features/admin/moderation/services/admin-moderation-gateway.interface.ts
new file mode 100644
index 0000000..071f8a7
--- /dev/null
+++ b/src/app/features/admin/moderation/services/admin-moderation-gateway.interface.ts
@@ -0,0 +1,16 @@
+import { Observable } from 'rxjs';
+import { AdminReview, AdminReviewListFilters, AdminReviewsListResult, AdminReviewStatus } from '../models/admin-review.model';
+import { AdminReport, AdminReportStatus } from '../models/admin-report.model';
+
+export interface AdminModerationGateway {
+ loadReviews(filters: AdminReviewListFilters): Observable;
+ loadReview(id: string): Observable;
+ setReviewStatus(id: string, status: AdminReviewStatus, note: string): Observable;
+ setReviewVisible(id: string, visible: boolean): Observable;
+ setReviewPinned(id: string, pinned: boolean): Observable;
+ setReviewFeatured(id: string, featured: boolean): Observable;
+ addModeratorNote(id: string, note: string): Observable;
+ deleteReview(id: string): Observable;
+ loadReports(): Observable;
+ setReportStatus(id: string, status: AdminReportStatus): Observable;
+}
diff --git a/src/app/features/admin/moderation/services/admin-moderation-local.gateway.ts b/src/app/features/admin/moderation/services/admin-moderation-local.gateway.ts
new file mode 100644
index 0000000..dc21c08
--- /dev/null
+++ b/src/app/features/admin/moderation/services/admin-moderation-local.gateway.ts
@@ -0,0 +1,186 @@
+import { Injectable, inject } from '@angular/core';
+import { Observable, of } from 'rxjs';
+import { delay } from 'rxjs/operators';
+import { BackofficeDataService } from '../../../../core/backoffice/backoffice-data.service';
+import { ProductCardConfig } from '../../../../shared/models/ui';
+import { AdminReview, AdminReviewListFilters, AdminReviewsListResult, AdminReviewStatus } from '../models/admin-review.model';
+import { AdminReport, AdminReportStatus } from '../models/admin-report.model';
+import { AdminModerationGateway } from './admin-moderation-gateway.interface';
+
+const STATUSES: AdminReviewStatus[] = ['pending', 'approved', 'rejected', 'spam'];
+const AUTHORS = ['Anna Petrova', 'Karen Sargsyan', 'Ivan Ivanov', 'Mariam Grigoryan', 'Sergey Volkov', 'Lilit Hakobyan'];
+const SNIPPETS = [
+ 'Great quality, exactly as described.',
+ 'Arrived late and packaging was damaged.',
+ 'Good value for the price, would buy again.',
+ 'Not what I expected based on the photos.',
+ 'Excellent customer service and fast delivery.',
+ 'Average product, nothing special.',
+];
+const SEED_COUNT = 32;
+
+@Injectable({ providedIn: 'root' })
+export class AdminModerationLocalGateway implements AdminModerationGateway {
+ private readonly backofficeData = inject(BackofficeDataService);
+ private cache: AdminReview[] | null = null;
+ private reportsCache: AdminReport[] | null = null;
+ private products: ProductCardConfig[] = [];
+
+ loadReviews(filters: AdminReviewListFilters): Observable {
+ return new Observable(subscriber => {
+ this.ensureData().then(() => {
+ const all = this.cache ?? [];
+ const filtered = all
+ .filter(review => !filters.search || `${review.customerName} ${review.productName} ${review.text}`.toLowerCase().includes(filters.search.toLowerCase()))
+ .filter(review => filters.status === 'all' || review.status === filters.status)
+ .filter(review => filters.rating === 'all' || review.rating === filters.rating)
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+ const start = (filters.page - 1) * filters.pageSize;
+ subscriber.next({
+ items: filtered.slice(start, start + filters.pageSize),
+ total: filtered.length,
+ page: filters.page,
+ pageSize: filters.pageSize,
+ });
+ subscriber.complete();
+ });
+ }).pipe(delay(50));
+ }
+
+ loadReview(id: string): Observable {
+ return new Observable(subscriber => {
+ this.ensureData().then(() => {
+ subscriber.next((this.cache ?? []).find(review => review.id === id) ?? null);
+ subscriber.complete();
+ });
+ });
+ }
+
+ setReviewStatus(id: string, status: AdminReviewStatus, note: string): Observable {
+ return this.mutate(id, review => ({
+ ...review,
+ status,
+ updatedAt: new Date().toISOString(),
+ timeline: [...review.timeline, { action: `Status set to ${status}`, actor: 'Admin', note, timestamp: new Date().toISOString() }],
+ }));
+ }
+
+ setReviewVisible(id: string, visible: boolean): Observable {
+ return this.mutate(id, review => ({
+ ...review,
+ visible,
+ updatedAt: new Date().toISOString(),
+ timeline: [...review.timeline, { action: visible ? 'Restored' : 'Hidden', actor: 'Admin', note: '', timestamp: new Date().toISOString() }],
+ }));
+ }
+
+ setReviewPinned(id: string, pinned: boolean): Observable {
+ return this.mutate(id, review => ({ ...review, pinned, updatedAt: new Date().toISOString() }));
+ }
+
+ setReviewFeatured(id: string, featured: boolean): Observable {
+ return this.mutate(id, review => ({ ...review, featured, updatedAt: new Date().toISOString() }));
+ }
+
+ addModeratorNote(id: string, note: string): Observable {
+ return this.mutate(id, review => ({
+ ...review,
+ moderatorNotes: review.moderatorNotes ? `${review.moderatorNotes}\n${note}` : note,
+ updatedAt: new Date().toISOString(),
+ }));
+ }
+
+ deleteReview(id: string): Observable {
+ this.cache = (this.cache ?? []).filter(review => review.id !== id);
+ return of(void 0).pipe(delay(50));
+ }
+
+ loadReports(): Observable {
+ return new Observable(subscriber => {
+ this.ensureData().then(() => {
+ subscriber.next(this.reportsCache ?? []);
+ subscriber.complete();
+ });
+ });
+ }
+
+ setReportStatus(id: string, status: AdminReportStatus): Observable {
+ const existing = (this.reportsCache ?? []).find(report => report.id === id);
+ if (!existing) {
+ return of(null);
+ }
+ const updated = { ...existing, status };
+ this.reportsCache = (this.reportsCache ?? []).map(report => report.id === id ? updated : report);
+ return of(updated).pipe(delay(50));
+ }
+
+ private mutate(id: string, update: (review: AdminReview) => AdminReview): Observable {
+ const all = this.cache ?? [];
+ const existing = all.find(review => review.id === id);
+ if (!existing) {
+ return of(null);
+ }
+ const updated = update(existing);
+ this.cache = all.map(review => review.id === id ? updated : review);
+ return of(updated).pipe(delay(50));
+ }
+
+ private async ensureData(): Promise {
+ if (this.cache && this.reportsCache) {
+ return;
+ }
+ this.products = await new Promise(resolve => this.backofficeData.loadProducts().subscribe(value => resolve(value)));
+ this.cache = Array.from({ length: SEED_COUNT }, (_, index) => this.seedReview(index));
+ this.reportsCache = this.seedReports();
+ }
+
+ private seedReview(index: number): AdminReview {
+ const product = this.products[index % Math.max(1, this.products.length)];
+ const status = STATUSES[index % STATUSES.length];
+ const rating = 1 + (index % 5);
+ const createdAt = new Date(Date.now() - index * 20 * 60 * 60 * 1000).toISOString();
+ const reportCount = index % 7 === 0 ? 1 + (index % 3) : 0;
+ return {
+ id: `review-${index + 1}`,
+ productId: product?.id ?? '',
+ productName: product?.title ?? 'Unknown product',
+ customerName: AUTHORS[index % AUTHORS.length],
+ customerEmail: `${AUTHORS[index % AUTHORS.length].toLowerCase().replace(/\s+/g, '.')}@example.com`,
+ rating,
+ text: SNIPPETS[index % SNIPPETS.length],
+ photos: index % 5 === 0 ? [product?.imageUrl ?? ''].filter(Boolean) : [],
+ status,
+ visible: status === 'approved',
+ pinned: false,
+ featured: false,
+ reportCount,
+ moderatorNotes: '',
+ timeline: [{ action: 'Submitted', actor: 'Customer', note: '', timestamp: createdAt }],
+ createdAt,
+ updatedAt: createdAt,
+ };
+ }
+
+ private seedReports(): AdminReport[] {
+ return Array.from({ length: 10 }, (_, index) => {
+ const targetTypes: Array = ['review', 'product', 'customer', 'category'];
+ const targetType = targetTypes[index % targetTypes.length];
+ const product = this.products[index % Math.max(1, this.products.length)];
+ const review = this.cache?.[index % (this.cache?.length ?? 1)];
+ const targetLabel = targetType === 'review' ? (review?.text.slice(0, 40) ?? 'Unknown review')
+ : targetType === 'product' ? (product?.title ?? 'Unknown product')
+ : targetType === 'customer' ? (review?.customerName ?? 'Unknown customer')
+ : 'Unknown category';
+ return {
+ id: `report-${index + 1}`,
+ targetType,
+ targetId: targetType === 'review' ? (review?.id ?? '') : targetType === 'product' ? (product?.id ?? '') : '',
+ targetLabel,
+ reason: index % 2 === 0 ? 'Inappropriate content' : 'Spam or misleading',
+ reporterEmail: `reporter${index + 1}@example.com`,
+ status: index % 3 === 0 ? 'resolved' : index % 3 === 1 ? 'dismissed' : 'open',
+ createdAt: new Date(Date.now() - index * 30 * 60 * 60 * 1000).toISOString(),
+ };
+ });
+ }
+}
diff --git a/src/app/features/admin/shell/admin-nav.model.ts b/src/app/features/admin/shell/admin-nav.model.ts
index 81fb9ed..613803a 100644
--- a/src/app/features/admin/shell/admin-nav.model.ts
+++ b/src/app/features/admin/shell/admin-nav.model.ts
@@ -33,7 +33,7 @@ export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
{ type: 'link', id: 'orders', icon: 'pi-shopping-cart', labelKey: 'adminShell.nav.orders', path: ['orders'] },
{ type: 'link', id: 'customers', icon: 'pi-user', labelKey: 'adminShell.nav.customers', path: ['customers'] },
{ type: 'link', id: 'transactions', icon: 'pi-credit-card', labelKey: 'adminShell.nav.transactions', path: ['transactions'] },
- { type: 'link', id: 'reviews', icon: 'pi-star', labelKey: 'adminShell.nav.reviews', comingSoon: true },
+ { type: 'link', id: 'moderation', icon: 'pi-star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] },
{ type: 'link', id: 'reports', icon: 'pi-chart-bar', labelKey: 'adminShell.nav.reports', comingSoon: true },
{ type: 'link', id: 'content', icon: 'pi-file-edit', labelKey: 'adminShell.nav.content', absolutePath: ['edit', 'static-pages'] },
{ type: 'link', id: 'media', icon: 'pi-images', labelKey: 'adminShell.nav.mediaLibrary', path: ['media'] },
diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts
index f9f063a..afd46c6 100644
--- a/src/app/i18n/en.ts
+++ b/src/app/i18n/en.ts
@@ -1140,6 +1140,77 @@ export const en: Translations = {
optionalDataMissingDescription: 'Optional configuration data is absent.',
optionalDataMissingResolution: 'Add optional value if UX depends on it.',
},
+ adminModeration: {
+ search: 'Search by customer, product, or review text…',
+ allStatuses: 'All statuses',
+ allRatings: 'All ratings',
+ rating: 'Rating',
+ customer: 'Customer',
+ product: 'Product',
+ reviewText: 'Review',
+ photos: 'Photos',
+ workflow: 'Actions',
+ approve: 'Approve',
+ reject: 'Reject',
+ markSpam: 'Mark as spam',
+ hide: 'Hide',
+ restore: 'Restore',
+ pin: 'Pin',
+ unpin: 'Unpin',
+ feature: 'Feature',
+ unfeature: 'Unfeature',
+ featureDisabledHint: 'Only approved reviews can be featured.',
+ moderatorNotes: 'Moderator notes',
+ notAvailable: 'Not available yet',
+ changeStatus: 'Change status',
+ hideSelected: 'Hide',
+ archiveSelected: 'Archive',
+ reportsQueue: 'Reports queue',
+ reportsColumn: 'Reports',
+ reportTarget: 'Reported item',
+ reportType: 'Type',
+ reportReason: 'Reason',
+ resolve: 'Resolve',
+ dismiss: 'Dismiss',
+ reportsEmptyTitle: 'No reports',
+ reportsEmptyDescription: 'Customer reports about products, reviews, or accounts will show up here.',
+ reportedCount: 'Reported',
+ averageRating: 'Average rating',
+ moderationHealth: 'Moderation health',
+ emptyTitle: 'No reviews yet',
+ emptyDescription: 'Reviews will appear here once customers start leaving feedback.',
+ emptyGuide: 'Customers leave a star rating and text after purchase. New reviews start as Pending until a moderator approves, rejects, or marks them as spam — approved reviews become visible on the storefront. Aim to clear the pending queue regularly.',
+ healthRating: 'Rating',
+ healthText: 'Text',
+ healthMedia: 'Media',
+ healthModeration: 'Moderated',
+ healthReportStatus: 'No open reports',
+ healthVisibility: 'Visible',
+ health: 'Health',
+ column_product: 'Product',
+ column_rating: 'Rating',
+ column_status: 'Status',
+ column_reports: 'Reports',
+ column_created: 'Submitted',
+ status: {
+ pending: 'Pending',
+ approved: 'Approved',
+ rejected: 'Rejected',
+ spam: 'Spam',
+ },
+ targetType: {
+ product: 'Product',
+ review: 'Review',
+ customer: 'Customer',
+ category: 'Category',
+ unknown: 'Unknown',
+ },
+ reportStatus: {
+ open: 'Open',
+ resolved: 'Resolved',
+ dismissed: 'Dismissed',
+ },
+ },
adminOrders: {
back: 'Back to orders',
search: 'Search by order number, name, email, or phone…',
@@ -1542,6 +1613,7 @@ export const en: Translations = {
orders: 'Orders',
customers: 'Customers',
transactions: 'Transactions',
+ moderation: 'Reviews & Moderation',
reviews: 'Reviews',
reports: 'Reports',
content: 'Content',
@@ -1581,6 +1653,9 @@ export const en: Translations = {
orderDetail: { title: 'Order', description: 'Order details and status' },
customers: { title: 'Customers', description: 'Everyone who has placed an order' },
customerDetail: { title: 'Customer', description: 'Customer profile, orders, and activity' },
+ moderation: { title: 'Reviews & Moderation', description: 'Review and moderate customer feedback' },
+ reviewDetail: { title: 'Review', description: 'Review details and moderation actions' },
+ reportsQueue: { title: 'Reports queue', description: 'Content reported by customers' },
transactions: { title: 'Transactions', description: 'Payments, refunds, and fraud review' },
media: { title: 'Media Library', description: 'Images and files for products and pages' },
users: { title: 'Users', description: 'Admin team members and permissions' },
diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts
index 499c85a..5dde690 100644
--- a/src/app/i18n/hy.ts
+++ b/src/app/i18n/hy.ts
@@ -1135,6 +1135,77 @@ export const hy: Translations = {
optionalDataMissingDescription: 'Optional config արժեքը բացակայում է։',
optionalDataMissingResolution: 'Ավելացրեք արժեքը, եթե UX-ը կախված է դրանից։',
},
+ adminModeration: {
+ search: 'Փնտրել ըստ հաճախորդի, ապրանքի կամ կարծիքի տեքստի…',
+ allStatuses: 'Բոլոր կարգավիճակները',
+ allRatings: 'Բոլոր գնահատականները',
+ rating: 'Գնահատական',
+ customer: 'Հաճախորդ',
+ product: 'Ապրանք',
+ reviewText: 'Կարծիք',
+ photos: 'Լուսանկարներ',
+ workflow: 'Գործողություններ',
+ approve: 'Հաստատել',
+ reject: 'Մերժել',
+ markSpam: 'Նշել որպես սպամ',
+ hide: 'Թաքցնել',
+ restore: 'Վերականգնել',
+ pin: 'Ամրացնել',
+ unpin: 'Հանել ամրացումը',
+ feature: 'Առանձնացնել',
+ unfeature: 'Հանել առանձնացումը',
+ featureDisabledHint: 'Առանձնացնել կարելի է միայն հաստատված կարծիքները։',
+ moderatorNotes: 'Մոդերատորի նշումներ',
+ notAvailable: 'Դեռ հասանելի չէ',
+ changeStatus: 'Փոխել կարգավիճակը',
+ hideSelected: 'Թաքցնել',
+ archiveSelected: 'Արխիվացնել',
+ reportsQueue: 'Բողոքների հերթ',
+ reportsColumn: 'Բողոքներ',
+ reportTarget: 'Բողոքարկված օբյեկտ',
+ reportType: 'Տեսակ',
+ reportReason: 'Պատճառ',
+ resolve: 'Լուծված',
+ dismiss: 'Մերժել',
+ reportsEmptyTitle: 'Բողոքներ չկան',
+ reportsEmptyDescription: 'Հաճախորդների բողոքները ապրանքների, կարծիքների կամ հաշիվների վերաբերյալ կհայտնվեն այստեղ։',
+ reportedCount: 'Բողոքարկված',
+ averageRating: 'Միջին գնահատական',
+ moderationHealth: 'Մոդերացիայի վիճակ',
+ emptyTitle: 'Կարծիքներ դեռ չկան',
+ emptyDescription: 'Կարծիքները կհայտնվեն այստեղ հաճախորդների առաջին գնահատականներից հետո։',
+ emptyGuide: 'Հաճախորդները թողնում են աստղանիշ գնահատական և տեքստ գնումից հետո։ Նոր կարծիքները ստանում են «Սպասման մեջ» կարգավիճակ, մինչև մոդերատորը հաստատի, մերժի կամ նշի որպես սպամ․ հաստատված կարծիքները դառնում են տեսանելի խանութում։ Փորձեք պարբերաբար մշակել սպասման հերթը։',
+ healthRating: 'Գնահատական',
+ healthText: 'Տեքստ',
+ healthMedia: 'Մեդիա',
+ healthModeration: 'Մոդերացված',
+ healthReportStatus: 'Բաց բողոքներ չկան',
+ healthVisibility: 'Տեսանելի է',
+ health: 'Վիճակ',
+ column_product: 'Ապրանք',
+ column_rating: 'Գնահատական',
+ column_status: 'Կարգավիճակ',
+ column_reports: 'Բողոքներ',
+ column_created: 'Ուղարկված է',
+ status: {
+ pending: 'Սպասման մեջ',
+ approved: 'Հաստատված',
+ rejected: 'Մերժված',
+ spam: 'Սպամ',
+ },
+ targetType: {
+ product: 'Ապրանք',
+ review: 'Կարծիք',
+ customer: 'Հաճախորդ',
+ category: 'Կատեգորիա',
+ unknown: 'Անհայտ',
+ },
+ reportStatus: {
+ open: 'Բաց',
+ resolved: 'Լուծված',
+ dismissed: 'Մերժված',
+ },
+ },
adminOrders: {
back: 'Վերադառնալ պատվերներին',
search: 'Փնտրել ըստ պատվերի համարի, անվան, էլ. փոստի կամ հեռախոսի…',
@@ -1536,6 +1607,7 @@ export const hy: Translations = {
categories: 'Կատեգորիաներ',
orders: 'Պատվերներ',
customers: 'Հաճախորդներ',
+ moderation: 'Կարծիքներ և մոդերացիա',
transactions: 'Գործարքներ',
reviews: 'Կարծիքներ',
reports: 'Հաշվետվություններ',
@@ -1576,6 +1648,9 @@ export const hy: Translations = {
orderDetail: { title: 'Պատվեր', description: 'Պատվերի մանրամասներն ու կարգավիճակը' },
customers: { title: 'Հաճախորդներ', description: 'Բոլորը, ովքեր պատվեր են կատարել' },
customerDetail: { title: 'Հաճախորդ', description: 'Հաճախորդի պրոֆիլ, պատվերներ և ակտիվություն' },
+ moderation: { title: 'Կարծիքներ և մոդերացիա', description: 'Դիտեք և մոդերացրեք հաճախորդների կարծիքները' },
+ reviewDetail: { title: 'Կարծիք', description: 'Կարծիքի մանրամասներ և մոդերացիայի գործողություններ' },
+ reportsQueue: { title: 'Բողոքների հերթ', description: 'Հաճախորդների կողմից բողոքարկված բովանդակություն' },
transactions: { title: 'Գործարքներ', description: 'Վճարումներ, վերադարձներ և կեղծիքի ստուգում' },
media: { title: 'Մեդիադարան', description: 'Ապրանքների և էջերի պատկերներ ու ֆայլեր' },
users: { title: 'Օգտատերեր', description: 'Ադմինիստրատորների թիմը և իրավունքները' },
diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts
index 64596da..210fe63 100644
--- a/src/app/i18n/ru.ts
+++ b/src/app/i18n/ru.ts
@@ -1135,6 +1135,77 @@ export const ru: Translations = {
optionalDataMissingDescription: 'Необязательное конфигурационное значение отсутствует.',
optionalDataMissingResolution: 'Добавьте значение, если оно нужно UX.',
},
+ adminModeration: {
+ search: 'Поиск по клиенту, товару или тексту отзыва…',
+ allStatuses: 'Все статусы',
+ allRatings: 'Все оценки',
+ rating: 'Оценка',
+ customer: 'Клиент',
+ product: 'Товар',
+ reviewText: 'Отзыв',
+ photos: 'Фото',
+ workflow: 'Действия',
+ approve: 'Одобрить',
+ reject: 'Отклонить',
+ markSpam: 'Отметить как спам',
+ hide: 'Скрыть',
+ restore: 'Восстановить',
+ pin: 'Закрепить',
+ unpin: 'Открепить',
+ feature: 'Выделить',
+ unfeature: 'Убрать выделение',
+ featureDisabledHint: 'Выделять можно только одобренные отзывы.',
+ moderatorNotes: 'Заметки модератора',
+ notAvailable: 'Пока недоступно',
+ changeStatus: 'Изменить статус',
+ hideSelected: 'Скрыть',
+ archiveSelected: 'В архив',
+ reportsQueue: 'Очередь жалоб',
+ reportsColumn: 'Жалобы',
+ reportTarget: 'Объект жалобы',
+ reportType: 'Тип',
+ reportReason: 'Причина',
+ resolve: 'Решено',
+ dismiss: 'Отклонить',
+ reportsEmptyTitle: 'Жалоб нет',
+ reportsEmptyDescription: 'Жалобы клиентов на товары, отзывы или аккаунты появятся здесь.',
+ reportedCount: 'С жалобами',
+ averageRating: 'Средняя оценка',
+ moderationHealth: 'Состояние модерации',
+ emptyTitle: 'Отзывов пока нет',
+ emptyDescription: 'Отзывы появятся здесь после первых оценок от клиентов.',
+ emptyGuide: 'Клиенты оставляют оценку и текст после покупки. Новые отзывы получают статус «Ожидает» до одобрения, отклонения или пометки как спам модератором — одобренные отзывы становятся видны на витрине. Старайтесь регулярно разбирать очередь ожидающих отзывов.',
+ healthRating: 'Оценка',
+ healthText: 'Текст',
+ healthMedia: 'Медиа',
+ healthModeration: 'Промодерирован',
+ healthReportStatus: 'Нет открытых жалоб',
+ healthVisibility: 'Виден',
+ health: 'Состояние',
+ column_product: 'Товар',
+ column_rating: 'Оценка',
+ column_status: 'Статус',
+ column_reports: 'Жалобы',
+ column_created: 'Отправлен',
+ status: {
+ pending: 'Ожидает',
+ approved: 'Одобрен',
+ rejected: 'Отклонён',
+ spam: 'Спам',
+ },
+ targetType: {
+ product: 'Товар',
+ review: 'Отзыв',
+ customer: 'Клиент',
+ category: 'Категория',
+ unknown: 'Неизвестно',
+ },
+ reportStatus: {
+ open: 'Открыта',
+ resolved: 'Решена',
+ dismissed: 'Отклонена',
+ },
+ },
adminOrders: {
back: 'Назад к заказам',
search: 'Поиск по номеру заказа, имени, email или телефону…',
@@ -1536,6 +1607,7 @@ export const ru: Translations = {
categories: 'Категории',
orders: 'Заказы',
customers: 'Клиенты',
+ moderation: 'Отзывы и модерация',
transactions: 'Транзакции',
reviews: 'Отзывы',
reports: 'Отчёты',
@@ -1576,6 +1648,9 @@ export const ru: Translations = {
orderDetail: { title: 'Заказ', description: 'Детали и статус заказа' },
customers: { title: 'Клиенты', description: 'Все, кто оформлял заказ' },
customerDetail: { title: 'Клиент', description: 'Профиль клиента, заказы и активность' },
+ moderation: { title: 'Отзывы и модерация', description: 'Проверяйте и модерируйте отзывы клиентов' },
+ reviewDetail: { title: 'Отзыв', description: 'Детали отзыва и действия модерации' },
+ reportsQueue: { title: 'Очередь жалоб', description: 'Контент, на который пожаловались клиенты' },
transactions: { title: 'Транзакции', description: 'Платежи, возвраты и проверка мошенничества' },
media: { title: 'Медиатека', description: 'Изображения и файлы для товаров и страниц' },
users: { title: 'Пользователи', description: 'Команда администраторов и права доступа' },
diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts
index 0eff8cb..8f67fa1 100644
--- a/src/app/i18n/translations.ts
+++ b/src/app/i18n/translations.ts
@@ -1138,6 +1138,77 @@ export interface Translations {
optionalDataMissingDescription: string;
optionalDataMissingResolution: string;
};
+ adminModeration: {
+ search: string;
+ allStatuses: string;
+ allRatings: string;
+ rating: string;
+ customer: string;
+ product: string;
+ reviewText: string;
+ photos: string;
+ workflow: string;
+ approve: string;
+ reject: string;
+ markSpam: string;
+ hide: string;
+ restore: string;
+ pin: string;
+ unpin: string;
+ feature: string;
+ unfeature: string;
+ featureDisabledHint: string;
+ moderatorNotes: string;
+ notAvailable: string;
+ changeStatus: string;
+ hideSelected: string;
+ archiveSelected: string;
+ reportsQueue: string;
+ reportsColumn: string;
+ reportTarget: string;
+ reportType: string;
+ reportReason: string;
+ resolve: string;
+ dismiss: string;
+ reportsEmptyTitle: string;
+ reportsEmptyDescription: string;
+ reportedCount: string;
+ averageRating: string;
+ moderationHealth: string;
+ emptyTitle: string;
+ emptyDescription: string;
+ emptyGuide: string;
+ healthRating: string;
+ healthText: string;
+ healthMedia: string;
+ healthModeration: string;
+ healthReportStatus: string;
+ healthVisibility: string;
+ health: string;
+ column_product: string;
+ column_rating: string;
+ column_status: string;
+ column_reports: string;
+ column_created: string;
+ status: {
+ pending: string;
+ approved: string;
+ rejected: string;
+ spam: string;
+ };
+ targetType: {
+ product: string;
+ review: string;
+ customer: string;
+ category: string;
+ unknown: string;
+ };
+ reportStatus: {
+ open: string;
+ resolved: string;
+ dismissed: string;
+ };
+ };
adminOrders: {
back: string;
search: string;
@@ -1550,6 +1621,7 @@ export interface Translations {
customers: string;
transactions: string;
reviews: string;
+ moderation: string;
reports: string;
content: string;
mediaLibrary: string;
@@ -1588,6 +1660,9 @@ export interface Translations {
orderDetail: { title: string; description: string };
customers: { title: string; description: string };
customerDetail: { title: string; description: string };
+ moderation: { title: string; description: string };
+ reviewDetail: { title: string; description: string };
+ reportsQueue: { title: string; description: string };
transactions: { title: string; description: string };
media: { title: string; description: string };
users: { title: string; description: string };