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.
This commit is contained in:
sdarbinyan
2026-07-18 22:01:16 +04:00
parent 097927f124
commit 3c9b425203
26 changed files with 1369 additions and 1 deletions

View File

@@ -0,0 +1,24 @@
<div class="moderation-dashboard">
<div class="moderation-dashboard__metrics">
<app-dashboard-metric labelKey="adminModeration.status.pending" [value]="stats().pending.toString()" />
<app-dashboard-metric labelKey="adminModeration.status.approved" [value]="stats().approved.toString()" />
<app-dashboard-metric labelKey="adminModeration.status.rejected" [value]="stats().rejected.toString()" />
<app-dashboard-metric labelKey="adminModeration.reportedCount" [value]="stats().reported.toString()" />
<app-dashboard-metric labelKey="adminModeration.status.spam" [value]="stats().spam.toString()" />
<app-dashboard-metric labelKey="adminModeration.averageRating" [value]="stats().averageRating !== null ? stats().averageRating!.toString() : ('adminModeration.notAvailable' | translate)" />
<app-dashboard-metric labelKey="adminModeration.moderationHealth" [value]="stats().moderationHealthPercent + '%'" />
</div>
<app-card padding="sm" class="moderation-dashboard__activity">
<h4>{{ 'adminOrders.recentActivity' | translate }}</h4>
@if (stats().recentActivity.length === 0) {
<p>{{ 'adminOrders.noRecentActivity' | translate }}</p>
} @else {
<ul>
@for (entry of stats().recentActivity; track entry.timestamp + entry.reviewId) {
<li>{{ entry.timestamp | date:'short' }} — {{ entry.productName }} — {{ entry.action }}</li>
}
</ul>
}
</app-card>
</div>

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
<div class="review-health" [class.review-health--compact]="compact()">
<div class="review-health__bar"><div class="review-health__bar-fill" [style.width.%]="completionPercent()"></div></div>
<span class="review-health__percent">{{ completionPercent() }}%</span>
@if (!compact()) {
<ul class="review-health__list">
@for (item of items(); track item.labelKey) {
<li [class.review-health__list-item--done]="item.done">
<span aria-hidden="true">{{ item.done ? '✓' : '○' }}</span>
<span>{{ item.labelKey | translate }}</span>
</li>
}
</ul>
}
</div>

View File

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

View File

@@ -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<ReviewHealthItem[]>();
readonly completionPercent = input.required<number>();
readonly compact = input(false);
}

View File

@@ -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<AdminReviewListFilters>({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 10 });
readonly reviews = signal<AdminReview[]>([]);
readonly total = signal(0);
readonly loading = signal(false);
readonly selected = signal<AdminReview | null>(null);
readonly reports = signal<AdminReport[]>([]);
readonly viewMode = signal<AdminModerationViewMode>((this.localStorage.getItem(VIEW_MODE_KEY) as AdminModerationViewMode) || 'table');
readonly density = signal<AdminModerationDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminModerationDensity) || 'comfortable');
readonly visibleColumns = signal<AdminReviewColumn[]>(this.localStorage.getJSON<AdminReviewColumn[]>(COLUMNS_KEY) ?? [...ALL_REVIEW_COLUMNS]);
readonly selectedIds = signal<string[]>([]);
readonly dashboardStats = signal<AdminModerationDashboardStats | null>(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<AdminReviewListFilters>): 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,
};
}
}

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
<section class="admin-reports-card">
<header class="toolbar">
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminOrders.back' | translate }}</app-button>
<h1>{{ 'adminModeration.reportsQueue' | translate }}</h1>
</header>
@if (facade.reports().length === 0) {
<app-empty-state [title]="'adminModeration.reportsEmptyTitle' | translate" [description]="'adminModeration.reportsEmptyDescription' | translate" />
} @else {
<app-table>
<thead>
<tr>
<th>{{ 'adminModeration.reportTarget' | translate }}</th>
<th>{{ 'adminModeration.reportType' | translate }}</th>
<th>{{ 'adminModeration.reportReason' | translate }}</th>
<th>{{ 'backoffice.status' | translate }}</th>
<th>{{ 'adminOrders.createdAt' | translate }}</th>
<th>{{ 'adminProducts.actions' | translate }}</th>
</tr>
</thead>
<tbody>
@for (report of facade.reports(); track report.id) {
<tr>
<td>
@if (canOpen(report.targetType, report.targetId)) {
<button type="button" class="link-button" (click)="openTarget(report.targetType, report.targetId)">{{ report.targetLabel }}</button>
} @else {
{{ report.targetLabel || ('adminModeration.notAvailable' | translate) }}
}
</td>
<td>{{ ('adminModeration.targetType.' + report.targetType) | translate }}</td>
<td>{{ report.reason }}</td>
<td><app-badge [variant]="report.status === 'open' ? 'warning' : report.status === 'resolved' ? 'success' : 'neutral'">{{ ('adminModeration.reportStatus.' + report.status) | translate }}</app-badge></td>
<td>{{ report.createdAt | date:'short' }}</td>
<td class="actions">
@if (report.status === 'open') {
<app-button variant="secondary" size="sm" (click)="facade.setReportStatus(report.id, 'resolved')">{{ 'adminModeration.resolve' | translate }}</app-button>
<app-button variant="ghost" size="sm" (click)="facade.setReportStatus(report.id, 'dismissed')">{{ 'adminModeration.dismiss' | translate }}</app-button>
}
</td>
</tr>
}
</tbody>
</app-table>
}
</section>

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
@if (facade.selected(); as review) {
<main class="review-detail">
<header class="toolbar">
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminOrders.back' | translate }}</app-button>
<h1>{{ review.customerName }} — {{ review.rating }}★</h1>
<app-badge [variant]="review.status === 'approved' ? 'success' : review.status === 'pending' ? 'neutral' : review.status === 'spam' ? 'danger' : 'warning'">{{ ('adminModeration.status.' + review.status) | translate }}</app-badge>
@if (review.reportCount > 0) {
<app-badge variant="danger">{{ review.reportCount }} {{ 'adminModeration.reportsColumn' | translate }}</app-badge>
}
</header>
<section class="grid two">
<div class="card">
<h3>{{ 'adminModeration.customer' | translate }}</h3>
<p>{{ review.customerName }}</p>
<p>{{ review.customerEmail }}</p>
</div>
<div class="card">
<h3>{{ 'adminModeration.product' | translate }}</h3>
<p>{{ review.productName }}</p>
</div>
</section>
<section class="card">
<h3>{{ 'adminModeration.reviewText' | translate }}</h3>
<p>{{ review.text }}</p>
<h4>{{ 'adminModeration.photos' | translate }}</h4>
@if (review.photos.length === 0) {
<p class="muted">{{ 'adminModeration.notAvailable' | translate }}</p>
} @else {
<div class="photo-row">
@for (photo of review.photos; track photo) { <img [src]="photo" alt="" loading="lazy" /> }
</div>
}
</section>
<section class="card">
<h3>{{ 'adminModeration.workflow' | translate }}</h3>
<div class="workflow-actions">
<app-button variant="secondary" size="sm" [disabled]="review.status === 'approved'" (click)="facade.setStatus(review.id, 'approved')">{{ 'adminModeration.approve' | translate }}</app-button>
<app-button variant="secondary" size="sm" [disabled]="review.status === 'rejected'" (click)="facade.setStatus(review.id, 'rejected')">{{ 'adminModeration.reject' | translate }}</app-button>
<app-button variant="secondary" size="sm" [disabled]="review.status === 'spam'" (click)="facade.setStatus(review.id, 'spam')">{{ 'adminModeration.markSpam' | translate }}</app-button>
@if (review.visible) {
<app-button variant="secondary" size="sm" (click)="facade.setVisible(review.id, false)">{{ 'adminModeration.hide' | translate }}</app-button>
} @else {
<app-button variant="secondary" size="sm" (click)="facade.setVisible(review.id, true)">{{ 'adminModeration.restore' | translate }}</app-button>
}
<app-button variant="secondary" size="sm" (click)="facade.setPinned(review.id, !review.pinned)">{{ (review.pinned ? 'adminModeration.unpin' : 'adminModeration.pin') | translate }}</app-button>
<app-button variant="secondary" size="sm" [disabled]="review.status !== 'approved'" [attr.title]="review.status !== 'approved' ? ('adminModeration.featureDisabledHint' | translate) : null" (click)="facade.setFeatured(review.id, !review.featured)">{{ (review.featured ? 'adminModeration.unfeature' : 'adminModeration.feature') | translate }}</app-button>
</div>
</section>
<section class="card">
<h3>{{ 'adminOrders.timeline' | translate }}</h3>
<app-order-timeline [entries]="timelineEntries()" />
</section>
<section class="card">
<h3>{{ 'adminModeration.moderatorNotes' | translate }}</h3>
@for (line of review.moderatorNotes.split('\n'); track $index) { @if (line) { <p>{{ line }}</p> } }
<textarea rows="2" [ngModel]="noteDraft()" (ngModelChange)="noteDraft.set($event)"></textarea>
<app-button variant="secondary" size="sm" (click)="submitNote(review.id)">{{ 'adminOrders.addNote' | translate }}</app-button>
</section>
</main>
} @else {
<p>{{ 'common.loading' | translate }}</p>
}

View File

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

View File

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

View File

@@ -0,0 +1,133 @@
<section class="admin-reviews-card">
@if (facade.dashboardStats(); as stats) {
<app-moderation-dashboard [stats]="stats" />
}
<div class="toolbar">
<div class="filters">
<app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminModeration.search' | translate" />
<select [attr.aria-label]="'backoffice.status' | translate" [ngModel]="facade.filters().status" (ngModelChange)="facade.updateFilters({ status: $event })">
@for (status of statuses; track status) {
<option [value]="status">{{ status === 'all' ? ('adminModeration.allStatuses' | translate) : ('adminModeration.status.' + status | translate) }}</option>
}
</select>
<select [attr.aria-label]="'adminModeration.rating' | translate" [ngModel]="facade.filters().rating" (ngModelChange)="facade.updateFilters({ rating: $event })">
@for (rating of ratings; track rating) {
<option [ngValue]="rating">{{ rating === 'all' ? ('adminModeration.allRatings' | translate) : rating + '★' }}</option>
}
</select>
</div>
<div class="toolbar__view-controls">
<div class="view-toggle" role="group" [attr.aria-label]="'adminProducts.viewMode' | translate">
<app-button variant="ghost" size="sm" [attr.aria-pressed]="facade.viewMode() === 'table'" (click)="facade.setViewMode('table')">{{ 'adminProducts.viewTable' | translate }}</app-button>
<app-button variant="ghost" size="sm" [attr.aria-pressed]="facade.viewMode() === 'cards'" (click)="facade.setViewMode('cards')">{{ 'adminProducts.viewGrid' | translate }}</app-button>
</div>
<div class="view-toggle" role="group" [attr.aria-label]="'adminProducts.density' | translate">
<app-button variant="ghost" size="sm" [attr.aria-pressed]="facade.density() === 'comfortable'" (click)="facade.setDensity('comfortable')">{{ 'adminProducts.densityComfortable' | translate }}</app-button>
<app-button variant="ghost" size="sm" [attr.aria-pressed]="facade.density() === 'compact'" (click)="facade.setDensity('compact')">{{ 'adminProducts.densityCompact' | translate }}</app-button>
</div>
<app-button variant="ghost" size="sm" (click)="columnsPanelOpen.set(!columnsPanelOpen())">{{ 'adminProducts.columns' | translate }}</app-button>
<app-button variant="secondary" (click)="viewReports()">{{ 'adminModeration.reportsQueue' | translate }}</app-button>
</div>
</div>
@if (columnsPanelOpen()) {
<app-card padding="sm" class="columns-panel">
@for (column of allColumns; track column) {
<label class="check">
<input type="checkbox" [checked]="isColumnVisible(column)" (change)="facade.setColumnVisible(column, $any($event.target).checked)" />
<span>{{ ('adminModeration.column_' + column) | translate }}</span>
</label>
}
</app-card>
}
@if (facade.selectedIds().length > 0) {
<div class="bulk-actions">
<span>{{ facade.selectedIds().length }} {{ 'adminProducts.selectedCount' | translate }}</span>
<select [attr.aria-label]="'adminModeration.changeStatus' | translate" [ngModel]="bulkStatusValue()" (ngModelChange)="bulkStatusValue.set($event)">
<option value="approved">{{ 'adminModeration.status.approved' | translate }}</option>
<option value="rejected">{{ 'adminModeration.status.rejected' | translate }}</option>
<option value="spam">{{ 'adminModeration.status.spam' | translate }}</option>
</select>
<app-button variant="secondary" size="sm" (click)="applyBulkStatus()">{{ 'adminOrders.applyStatus' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="facade.applyBulkVisible(false)">{{ 'adminModeration.hideSelected' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="facade.exportSelectedAsCsv()">{{ 'adminProducts.bulkExportAction' | translate }}</app-button>
<app-button variant="danger" size="sm" (click)="facade.applyBulkDelete()">{{ 'adminModeration.archiveSelected' | translate }}</app-button>
</div>
}
@if (facade.loading()) {
<div class="skeleton-rows">
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
</div>
} @else if (facade.reviews().length === 0) {
<app-empty-state [title]="'adminModeration.emptyTitle' | translate" [description]="'adminModeration.emptyDescription' | translate" />
<p class="admin-reviews-card__guide">{{ 'adminModeration.emptyGuide' | translate }}</p>
} @else if (facade.viewMode() === 'table') {
<div class="table-scroll" [class.density-compact]="facade.density() === 'compact'">
<app-table>
<thead>
<tr>
<th><input type="checkbox" (change)="facade.toggleAll($any($event.target).checked)" /></th>
<th>{{ 'adminModeration.customer' | translate }}</th>
@if (isColumnVisible('product')) { <th>{{ 'adminModeration.product' | translate }}</th> }
@if (isColumnVisible('rating')) { <th>{{ 'adminModeration.rating' | translate }}</th> }
@if (isColumnVisible('status')) { <th>{{ 'backoffice.status' | translate }}</th> }
@if (isColumnVisible('reports')) { <th>{{ 'adminModeration.reportsColumn' | translate }}</th> }
@if (isColumnVisible('created')) { <th>{{ 'adminOrders.createdAt' | translate }}</th> }
<th>{{ 'adminModeration.health' | translate }}</th>
<th>{{ 'adminProducts.actions' | translate }}</th>
</tr>
</thead>
<tbody>
@for (review of facade.reviews(); track review.id) {
<tr>
<td><input type="checkbox" [checked]="isSelected(review.id)" (change)="facade.toggleSelection(review.id, $any($event.target).checked)" /></td>
<td>{{ review.customerName }}</td>
@if (isColumnVisible('product')) { <td>{{ review.productName }}</td> }
@if (isColumnVisible('rating')) { <td>{{ review.rating }}★</td> }
@if (isColumnVisible('status')) {
<td><app-badge [variant]="review.status === 'approved' ? 'success' : review.status === 'pending' ? 'neutral' : review.status === 'spam' ? 'danger' : 'warning'">{{ ('adminModeration.status.' + review.status) | translate }}</app-badge></td>
}
@if (isColumnVisible('reports')) {
<td>@if (review.reportCount > 0) { <app-badge variant="danger">{{ review.reportCount }}</app-badge> } @else { — }</td>
}
@if (isColumnVisible('created')) { <td>{{ review.createdAt | date:'short' }}</td> }
<td class="health-cell"><app-review-health-widget [items]="healthItems(review)" [completionPercent]="facade.health(review).completionPercent" [compact]="true" /></td>
<td class="actions">
<app-button variant="secondary" size="sm" (click)="view(review.id)">{{ 'adminOrders.view' | translate }}</app-button>
</td>
</tr>
}
</tbody>
</app-table>
</div>
} @else {
<div class="review-grid" [class.review-grid--compact]="facade.density() === 'compact'">
@for (review of facade.reviews(); track review.id) {
<app-card padding="sm" class="review-grid__item">
<label class="review-grid__select">
<input type="checkbox" [checked]="isSelected(review.id)" (change)="facade.toggleSelection(review.id, $any($event.target).checked)" [attr.aria-label]="review.customerName" />
</label>
<div class="review-grid__meta">
<strong>{{ review.customerName }}</strong>
<span>{{ review.rating }}★</span>
</div>
<p class="review-grid__product">{{ review.productName }}</p>
<p class="review-grid__text">{{ review.text }}</p>
<app-badge [variant]="review.status === 'approved' ? 'success' : review.status === 'pending' ? 'neutral' : review.status === 'spam' ? 'danger' : 'warning'">{{ ('adminModeration.status.' + review.status) | translate }}</app-badge>
<app-review-health-widget [items]="healthItems(review)" [completionPercent]="facade.health(review).completionPercent" [compact]="true" />
<app-button variant="secondary" size="sm" (click)="view(review.id)">{{ 'adminOrders.view' | translate }}</app-button>
</app-card>
}
</div>
}
@if (facade.reviews().length > 0) {
<div class="pager">
<span>{{ facade.total() }} {{ 'adminProducts.items' | translate }}</span>
<app-pagination [currentPage]="facade.filters().page" [totalPages]="totalPages()" (pageChange)="facade.updateFilters({ page: $event })" />
</div>
}
</section>

View File

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

View File

@@ -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<AdminReviewStatus>('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<AdminModerationFacade['health']>[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());
}
}

View File

@@ -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<AdminReviewsListResult>;
loadReview(id: string): Observable<AdminReview | null>;
setReviewStatus(id: string, status: AdminReviewStatus, note: string): Observable<AdminReview | null>;
setReviewVisible(id: string, visible: boolean): Observable<AdminReview | null>;
setReviewPinned(id: string, pinned: boolean): Observable<AdminReview | null>;
setReviewFeatured(id: string, featured: boolean): Observable<AdminReview | null>;
addModeratorNote(id: string, note: string): Observable<AdminReview | null>;
deleteReview(id: string): Observable<void>;
loadReports(): Observable<AdminReport[]>;
setReportStatus(id: string, status: AdminReportStatus): Observable<AdminReport | null>;
}

View File

@@ -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<AdminReviewsListResult> {
return new Observable<AdminReviewsListResult>(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<AdminReview | null> {
return new Observable<AdminReview | null>(subscriber => {
this.ensureData().then(() => {
subscriber.next((this.cache ?? []).find(review => review.id === id) ?? null);
subscriber.complete();
});
});
}
setReviewStatus(id: string, status: AdminReviewStatus, note: string): Observable<AdminReview | null> {
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<AdminReview | null> {
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<AdminReview | null> {
return this.mutate(id, review => ({ ...review, pinned, updatedAt: new Date().toISOString() }));
}
setReviewFeatured(id: string, featured: boolean): Observable<AdminReview | null> {
return this.mutate(id, review => ({ ...review, featured, updatedAt: new Date().toISOString() }));
}
addModeratorNote(id: string, note: string): Observable<AdminReview | null> {
return this.mutate(id, review => ({
...review,
moderatorNotes: review.moderatorNotes ? `${review.moderatorNotes}\n${note}` : note,
updatedAt: new Date().toISOString(),
}));
}
deleteReview(id: string): Observable<void> {
this.cache = (this.cache ?? []).filter(review => review.id !== id);
return of(void 0).pipe(delay(50));
}
loadReports(): Observable<AdminReport[]> {
return new Observable<AdminReport[]>(subscriber => {
this.ensureData().then(() => {
subscriber.next(this.reportsCache ?? []);
subscriber.complete();
});
});
}
setReportStatus(id: string, status: AdminReportStatus): Observable<AdminReport | null> {
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<AdminReview | null> {
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<void> {
if (this.cache && this.reportsCache) {
return;
}
this.products = await new Promise<ProductCardConfig[]>(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<AdminReport['targetType']> = ['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(),
};
});
}
}

View File

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