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:
@@ -185,6 +185,33 @@ const coreRoutes: Routes = [
|
|||||||
breadcrumb: [{ labelKey: 'adminShell.nav.customers', path: ['customers'] }, { labelKey: 'adminShell.pages.customerDetail.title' }]
|
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',
|
path: 'media',
|
||||||
loadComponent: () => import('./features/backoffice/media/media-library-page.component').then(m => m.MediaLibraryPageComponent),
|
loadComponent: () => import('./features/backoffice/media/media-library-page.component').then(m => m.MediaLibraryPageComponent),
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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); }
|
||||||
@@ -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>();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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; } }
|
||||||
@@ -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('');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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; } }
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: '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: '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: '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: '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: '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'] },
|
{ type: 'link', id: 'media', icon: 'pi-images', labelKey: 'adminShell.nav.mediaLibrary', path: ['media'] },
|
||||||
|
|||||||
@@ -1140,6 +1140,77 @@ export const en: Translations = {
|
|||||||
optionalDataMissingDescription: 'Optional configuration data is absent.',
|
optionalDataMissingDescription: 'Optional configuration data is absent.',
|
||||||
optionalDataMissingResolution: 'Add optional value if UX depends on it.',
|
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: {
|
adminOrders: {
|
||||||
back: 'Back to orders',
|
back: 'Back to orders',
|
||||||
search: 'Search by order number, name, email, or phone…',
|
search: 'Search by order number, name, email, or phone…',
|
||||||
@@ -1542,6 +1613,7 @@ export const en: Translations = {
|
|||||||
orders: 'Orders',
|
orders: 'Orders',
|
||||||
customers: 'Customers',
|
customers: 'Customers',
|
||||||
transactions: 'Transactions',
|
transactions: 'Transactions',
|
||||||
|
moderation: 'Reviews & Moderation',
|
||||||
reviews: 'Reviews',
|
reviews: 'Reviews',
|
||||||
reports: 'Reports',
|
reports: 'Reports',
|
||||||
content: 'Content',
|
content: 'Content',
|
||||||
@@ -1581,6 +1653,9 @@ export const en: Translations = {
|
|||||||
orderDetail: { title: 'Order', description: 'Order details and status' },
|
orderDetail: { title: 'Order', description: 'Order details and status' },
|
||||||
customers: { title: 'Customers', description: 'Everyone who has placed an order' },
|
customers: { title: 'Customers', description: 'Everyone who has placed an order' },
|
||||||
customerDetail: { title: 'Customer', description: 'Customer profile, orders, and activity' },
|
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' },
|
transactions: { title: 'Transactions', description: 'Payments, refunds, and fraud review' },
|
||||||
media: { title: 'Media Library', description: 'Images and files for products and pages' },
|
media: { title: 'Media Library', description: 'Images and files for products and pages' },
|
||||||
users: { title: 'Users', description: 'Admin team members and permissions' },
|
users: { title: 'Users', description: 'Admin team members and permissions' },
|
||||||
|
|||||||
@@ -1135,6 +1135,77 @@ export const hy: Translations = {
|
|||||||
optionalDataMissingDescription: 'Optional config արժեքը բացակայում է։',
|
optionalDataMissingDescription: 'Optional config արժեքը բացակայում է։',
|
||||||
optionalDataMissingResolution: 'Ավելացրեք արժեքը, եթե UX-ը կախված է դրանից։',
|
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: {
|
adminOrders: {
|
||||||
back: 'Վերադառնալ պատվերներին',
|
back: 'Վերադառնալ պատվերներին',
|
||||||
search: 'Փնտրել ըստ պատվերի համարի, անվան, էլ. փոստի կամ հեռախոսի…',
|
search: 'Փնտրել ըստ պատվերի համարի, անվան, էլ. փոստի կամ հեռախոսի…',
|
||||||
@@ -1536,6 +1607,7 @@ export const hy: Translations = {
|
|||||||
categories: 'Կատեգորիաներ',
|
categories: 'Կատեգորիաներ',
|
||||||
orders: 'Պատվերներ',
|
orders: 'Պատվերներ',
|
||||||
customers: 'Հաճախորդներ',
|
customers: 'Հաճախորդներ',
|
||||||
|
moderation: 'Կարծիքներ և մոդերացիա',
|
||||||
transactions: 'Գործարքներ',
|
transactions: 'Գործարքներ',
|
||||||
reviews: 'Կարծիքներ',
|
reviews: 'Կարծիքներ',
|
||||||
reports: 'Հաշվետվություններ',
|
reports: 'Հաշվետվություններ',
|
||||||
@@ -1576,6 +1648,9 @@ export const hy: Translations = {
|
|||||||
orderDetail: { title: 'Պատվեր', description: 'Պատվերի մանրամասներն ու կարգավիճակը' },
|
orderDetail: { title: 'Պատվեր', description: 'Պատվերի մանրամասներն ու կարգավիճակը' },
|
||||||
customers: { title: 'Հաճախորդներ', description: 'Բոլորը, ովքեր պատվեր են կատարել' },
|
customers: { title: 'Հաճախորդներ', description: 'Բոլորը, ովքեր պատվեր են կատարել' },
|
||||||
customerDetail: { title: 'Հաճախորդ', description: 'Հաճախորդի պրոֆիլ, պատվերներ և ակտիվություն' },
|
customerDetail: { title: 'Հաճախորդ', description: 'Հաճախորդի պրոֆիլ, պատվերներ և ակտիվություն' },
|
||||||
|
moderation: { title: 'Կարծիքներ և մոդերացիա', description: 'Դիտեք և մոդերացրեք հաճախորդների կարծիքները' },
|
||||||
|
reviewDetail: { title: 'Կարծիք', description: 'Կարծիքի մանրամասներ և մոդերացիայի գործողություններ' },
|
||||||
|
reportsQueue: { title: 'Բողոքների հերթ', description: 'Հաճախորդների կողմից բողոքարկված բովանդակություն' },
|
||||||
transactions: { title: 'Գործարքներ', description: 'Վճարումներ, վերադարձներ և կեղծիքի ստուգում' },
|
transactions: { title: 'Գործարքներ', description: 'Վճարումներ, վերադարձներ և կեղծիքի ստուգում' },
|
||||||
media: { title: 'Մեդիադարան', description: 'Ապրանքների և էջերի պատկերներ ու ֆայլեր' },
|
media: { title: 'Մեդիադարան', description: 'Ապրանքների և էջերի պատկերներ ու ֆայլեր' },
|
||||||
users: { title: 'Օգտատերեր', description: 'Ադմինիստրատորների թիմը և իրավունքները' },
|
users: { title: 'Օգտատերեր', description: 'Ադմինիստրատորների թիմը և իրավունքները' },
|
||||||
|
|||||||
@@ -1135,6 +1135,77 @@ export const ru: Translations = {
|
|||||||
optionalDataMissingDescription: 'Необязательное конфигурационное значение отсутствует.',
|
optionalDataMissingDescription: 'Необязательное конфигурационное значение отсутствует.',
|
||||||
optionalDataMissingResolution: 'Добавьте значение, если оно нужно UX.',
|
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: {
|
adminOrders: {
|
||||||
back: 'Назад к заказам',
|
back: 'Назад к заказам',
|
||||||
search: 'Поиск по номеру заказа, имени, email или телефону…',
|
search: 'Поиск по номеру заказа, имени, email или телефону…',
|
||||||
@@ -1536,6 +1607,7 @@ export const ru: Translations = {
|
|||||||
categories: 'Категории',
|
categories: 'Категории',
|
||||||
orders: 'Заказы',
|
orders: 'Заказы',
|
||||||
customers: 'Клиенты',
|
customers: 'Клиенты',
|
||||||
|
moderation: 'Отзывы и модерация',
|
||||||
transactions: 'Транзакции',
|
transactions: 'Транзакции',
|
||||||
reviews: 'Отзывы',
|
reviews: 'Отзывы',
|
||||||
reports: 'Отчёты',
|
reports: 'Отчёты',
|
||||||
@@ -1576,6 +1648,9 @@ export const ru: Translations = {
|
|||||||
orderDetail: { title: 'Заказ', description: 'Детали и статус заказа' },
|
orderDetail: { title: 'Заказ', description: 'Детали и статус заказа' },
|
||||||
customers: { title: 'Клиенты', description: 'Все, кто оформлял заказ' },
|
customers: { title: 'Клиенты', description: 'Все, кто оформлял заказ' },
|
||||||
customerDetail: { title: 'Клиент', description: 'Профиль клиента, заказы и активность' },
|
customerDetail: { title: 'Клиент', description: 'Профиль клиента, заказы и активность' },
|
||||||
|
moderation: { title: 'Отзывы и модерация', description: 'Проверяйте и модерируйте отзывы клиентов' },
|
||||||
|
reviewDetail: { title: 'Отзыв', description: 'Детали отзыва и действия модерации' },
|
||||||
|
reportsQueue: { title: 'Очередь жалоб', description: 'Контент, на который пожаловались клиенты' },
|
||||||
transactions: { title: 'Транзакции', description: 'Платежи, возвраты и проверка мошенничества' },
|
transactions: { title: 'Транзакции', description: 'Платежи, возвраты и проверка мошенничества' },
|
||||||
media: { title: 'Медиатека', description: 'Изображения и файлы для товаров и страниц' },
|
media: { title: 'Медиатека', description: 'Изображения и файлы для товаров и страниц' },
|
||||||
users: { title: 'Пользователи', description: 'Команда администраторов и права доступа' },
|
users: { title: 'Пользователи', description: 'Команда администраторов и права доступа' },
|
||||||
|
|||||||
@@ -1138,6 +1138,77 @@ export interface Translations {
|
|||||||
optionalDataMissingDescription: string;
|
optionalDataMissingDescription: string;
|
||||||
optionalDataMissingResolution: 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: {
|
adminOrders: {
|
||||||
back: string;
|
back: string;
|
||||||
search: string;
|
search: string;
|
||||||
@@ -1550,6 +1621,7 @@ export interface Translations {
|
|||||||
customers: string;
|
customers: string;
|
||||||
transactions: string;
|
transactions: string;
|
||||||
reviews: string;
|
reviews: string;
|
||||||
|
moderation: string;
|
||||||
reports: string;
|
reports: string;
|
||||||
content: string;
|
content: string;
|
||||||
mediaLibrary: string;
|
mediaLibrary: string;
|
||||||
@@ -1588,6 +1660,9 @@ export interface Translations {
|
|||||||
orderDetail: { title: string; description: string };
|
orderDetail: { title: string; description: string };
|
||||||
customers: { title: string; description: string };
|
customers: { title: string; description: string };
|
||||||
customerDetail: { 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 };
|
transactions: { title: string; description: string };
|
||||||
media: { title: string; description: string };
|
media: { title: string; description: string };
|
||||||
users: { title: string; description: string };
|
users: { title: string; description: string };
|
||||||
|
|||||||
Reference in New Issue
Block a user