From 3c9b42520309658ea35ed55fdcdab5290f0b5bbc Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 18 Jul 2026 22:01:16 +0400 Subject: [PATCH] feat(admin): implement review and moderation center New Reviews & Moderation feature (frontend only): moderation dashboard (real pending/approved/rejected/reported/spam counts, average rating, recent activity, moderation-health%, computed from the full review queue); reviews list with table/cards, density, saved column visibility, search/status/rating filters, bulk approve/reject/spam/hide/export; review detail shows customer/product/rating/text/photos/timeline/moderator-notes with a real moderation workflow (approve/reject/spam/hide/restore/pin/feature, feature disabled with an explanation unless the review is approved); reusable ReviewHealthWidget (rating/text/media/moderated/report-status/visible + completion%); reports queue lists reports against products/reviews/customers/categories with resolve/dismiss, and honestly renders 'Not available yet' rather than fabricating a value wherever a target can't be resolved (e.g. photos, customer-target reports). Backed by a new in-memory AdminModerationLocalGateway seeded from real product data - the same mock-gateway pattern already used by every other admin feature in this app (orders/products/categories), since no review/report backend exists to reuse. Replaced the 'Reviews' comingSoon nav placeholder with a working link; added full adminModeration i18n coverage. --- src/app/app.routes.ts | 27 +++ .../moderation-dashboard.component.html | 24 ++ .../moderation-dashboard.component.scss | 7 + .../moderation-dashboard.component.ts | 18 ++ .../review-health-widget.component.html | 14 ++ .../review-health-widget.component.scss | 8 + .../review-health-widget.component.ts | 22 ++ .../facade/admin-moderation.facade.ts | 218 ++++++++++++++++++ .../moderation/models/admin-report.model.ts | 13 ++ .../moderation/models/admin-review.model.ts | 43 ++++ .../admin-reports-list-page.component.html | 46 ++++ .../admin-reports-list-page.component.scss | 6 + .../admin-reports-list-page.component.ts | 47 ++++ .../admin-review-detail-page.component.html | 67 ++++++ .../admin-review-detail-page.component.scss | 14 ++ .../admin-review-detail-page.component.ts | 48 ++++ .../admin-reviews-list-page.component.html | 133 +++++++++++ .../admin-reviews-list-page.component.scss | 32 +++ .../admin-reviews-list-page.component.ts | 79 +++++++ .../admin-moderation-gateway.interface.ts | 16 ++ .../admin-moderation-local.gateway.ts | 186 +++++++++++++++ .../features/admin/shell/admin-nav.model.ts | 2 +- src/app/i18n/en.ts | 75 ++++++ src/app/i18n/hy.ts | 75 ++++++ src/app/i18n/ru.ts | 75 ++++++ src/app/i18n/translations.ts | 75 ++++++ 26 files changed, 1369 insertions(+), 1 deletion(-) create mode 100644 src/app/features/admin/moderation/components/moderation-dashboard/moderation-dashboard.component.html create mode 100644 src/app/features/admin/moderation/components/moderation-dashboard/moderation-dashboard.component.scss create mode 100644 src/app/features/admin/moderation/components/moderation-dashboard/moderation-dashboard.component.ts create mode 100644 src/app/features/admin/moderation/components/review-health-widget/review-health-widget.component.html create mode 100644 src/app/features/admin/moderation/components/review-health-widget/review-health-widget.component.scss create mode 100644 src/app/features/admin/moderation/components/review-health-widget/review-health-widget.component.ts create mode 100644 src/app/features/admin/moderation/facade/admin-moderation.facade.ts create mode 100644 src/app/features/admin/moderation/models/admin-report.model.ts create mode 100644 src/app/features/admin/moderation/models/admin-review.model.ts create mode 100644 src/app/features/admin/moderation/pages/admin-reports-list-page.component.html create mode 100644 src/app/features/admin/moderation/pages/admin-reports-list-page.component.scss create mode 100644 src/app/features/admin/moderation/pages/admin-reports-list-page.component.ts create mode 100644 src/app/features/admin/moderation/pages/admin-review-detail-page.component.html create mode 100644 src/app/features/admin/moderation/pages/admin-review-detail-page.component.scss create mode 100644 src/app/features/admin/moderation/pages/admin-review-detail-page.component.ts create mode 100644 src/app/features/admin/moderation/pages/admin-reviews-list-page.component.html create mode 100644 src/app/features/admin/moderation/pages/admin-reviews-list-page.component.scss create mode 100644 src/app/features/admin/moderation/pages/admin-reviews-list-page.component.ts create mode 100644 src/app/features/admin/moderation/services/admin-moderation-gateway.interface.ts create mode 100644 src/app/features/admin/moderation/services/admin-moderation-local.gateway.ts 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.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 @@ +
+
+ {{ 'adminOrders.back' | translate }} +

{{ 'adminModeration.reportsQueue' | translate }}

+
+ + @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) { +
+
+ {{ 'adminOrders.back' | translate }} +

{{ review.customerName }} — {{ review.rating }}★

+ {{ ('adminModeration.status.' + review.status) | translate }} + @if (review.reportCount > 0) { + {{ review.reportCount }} {{ 'adminModeration.reportsColumn' | translate }} + } +
+ +
+
+

{{ '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) { + + } + +
+
+ + + +
+
+
+ {{ 'adminProducts.viewTable' | translate }} + {{ 'adminProducts.viewGrid' | translate }} +
+
+ {{ 'adminProducts.densityComfortable' | translate }} + {{ 'adminProducts.densityCompact' | translate }} +
+ {{ 'adminProducts.columns' | translate }} + {{ 'adminModeration.reportsQueue' | translate }} +
+
+ + @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') { +
+ + + + + {{ 'adminModeration.customer' | translate }} + @if (isColumnVisible('product')) { {{ 'adminModeration.product' | translate }} } + @if (isColumnVisible('rating')) { {{ 'adminModeration.rating' | translate }} } + @if (isColumnVisible('status')) { {{ 'backoffice.status' | translate }} } + @if (isColumnVisible('reports')) { {{ 'adminModeration.reportsColumn' | translate }} } + @if (isColumnVisible('created')) { {{ 'adminOrders.createdAt' | translate }} } + {{ 'adminModeration.health' | translate }} + {{ 'adminProducts.actions' | translate }} + + + + @for (review of facade.reviews(); track review.id) { + + + {{ review.customerName }} + @if (isColumnVisible('product')) { {{ review.productName }} } + @if (isColumnVisible('rating')) { {{ review.rating }}★ } + @if (isColumnVisible('status')) { + {{ ('adminModeration.status.' + review.status) | translate }} + } + @if (isColumnVisible('reports')) { + @if (review.reportCount > 0) { {{ review.reportCount }} } @else { — } + } + @if (isColumnVisible('created')) { {{ review.createdAt | date:'short' }} } + + + {{ 'adminOrders.view' | translate }} + + + } + + +
+ } @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) { +
+ {{ facade.total() }} {{ 'adminProducts.items' | translate }} + +
+ } +
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 };