feat(admin): implement analytics and monitoring center
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> - Business Dashboard: revenue/orders/customers/AOV/top products/low stock/recent activity/warnings/completion% (Overview tab) - Marketplace Health: real completion checks (images, SEO, categories, reviews, orders, translations, homepage, backend, performance) with clickable recommendations - Product Analytics: top selling/most reviewed/worst rated/hidden/archived counts; top-viewed honestly marked Unknown (no view tracking exists) - Customer Analytics: new/returning customers, average spend, retention - derived from real order data - Search & Traffic tabs: honest 'Unknown - available after backend integration' placeholders, no fabricated numbers - Recommendations engine: actionable cards (missing images/SEO/category, empty categories, incomplete homepage, unpublished static pages) linking to the relevant admin page - System Monitoring: added real draft/publish/sync/backend-connectivity state from AdminDashboardFacade - CSV export extended to top products and health checks; added print action - New adminAnalytics.* and adminMarketplaceHealth.* i18n namespaces (en/ru/hy), no raw i18n keys - Reused existing shared UI (app-table/app-badge/app-skeleton/app-empty-state) and content-health-widget completion-meter pattern - no new backend APIs, no storage changes
This commit is contained in:
@@ -1,48 +1,113 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { take } from 'rxjs/operators';
|
import { take } from 'rxjs/operators';
|
||||||
import { AdminAnalyticsDateRange, AdminAnalyticsSeriesPoint, AdminAnalyticsSummary, AdminAnalyticsTopProduct } from '../models/admin-analytics.model';
|
import {
|
||||||
|
AdminAnalyticsDateRange,
|
||||||
|
AdminAnalyticsSeriesPoint,
|
||||||
|
AdminAnalyticsSummary,
|
||||||
|
AdminAnalyticsTopProduct,
|
||||||
|
AdminCustomerAnalytics,
|
||||||
|
AdminLowStockProduct,
|
||||||
|
AdminMarketplaceHealthCheck,
|
||||||
|
AdminProductAnalytics,
|
||||||
|
AdminRecentActivityEntry,
|
||||||
|
AdminRecommendationCard,
|
||||||
|
} from '../models/admin-analytics.model';
|
||||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||||
import { AdminProductsLocalGateway } from '../../products/services/admin-products-local.gateway';
|
import { AdminProductsLocalGateway } from '../../products/services/admin-products-local.gateway';
|
||||||
import { AdminCategoriesLocalGateway } from '../../categories/services/admin-categories-local.gateway';
|
import { AdminCategoriesLocalGateway } from '../../categories/services/admin-categories-local.gateway';
|
||||||
|
import { AdminModerationLocalGateway } from '../../moderation/services/admin-moderation-local.gateway';
|
||||||
|
import { AdminDashboardFacade } from '../../dashboard/facade/admin-dashboard.facade';
|
||||||
import { AdminOrder } from '../../orders/models/admin-order.model';
|
import { AdminOrder } from '../../orders/models/admin-order.model';
|
||||||
|
import { AdminProduct } from '../../products/models/admin-product.model';
|
||||||
|
import { AdminCategory } from '../../categories/models/admin-category.model';
|
||||||
|
import { AdminReview } from '../../moderation/models/admin-review.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates existing gateways (orders/products/categories/reviews) plus the dashboard facade's
|
||||||
|
* bootstrap/validation state into merchant-facing analytics. Never fabricates a number: anything
|
||||||
|
* with no real data source (visitor traffic, search queries, product views) stays null/'unknown'
|
||||||
|
* and is rendered honestly rather than guessed, per Sprint 12 scope.
|
||||||
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AdminAnalyticsFacade {
|
export class AdminAnalyticsFacade {
|
||||||
private readonly ordersGateway = inject(AdminOrdersLocalGateway);
|
private readonly ordersGateway = inject(AdminOrdersLocalGateway);
|
||||||
private readonly productsGateway = inject(AdminProductsLocalGateway);
|
private readonly productsGateway = inject(AdminProductsLocalGateway);
|
||||||
private readonly categoriesGateway = inject(AdminCategoriesLocalGateway);
|
private readonly categoriesGateway = inject(AdminCategoriesLocalGateway);
|
||||||
|
private readonly moderationGateway = inject(AdminModerationLocalGateway);
|
||||||
|
private readonly dashboardFacade = inject(AdminDashboardFacade);
|
||||||
|
|
||||||
readonly dateRange = signal<AdminAnalyticsDateRange>(30);
|
readonly dateRange = signal<AdminAnalyticsDateRange>(30);
|
||||||
readonly loading = signal(false);
|
readonly loading = signal(false);
|
||||||
readonly summary = signal<AdminAnalyticsSummary | null>(null);
|
readonly summary = signal<AdminAnalyticsSummary | null>(null);
|
||||||
readonly salesSeries = signal<AdminAnalyticsSeriesPoint[]>([]);
|
readonly salesSeries = signal<AdminAnalyticsSeriesPoint[]>([]);
|
||||||
readonly topProducts = signal<AdminAnalyticsTopProduct[]>([]);
|
readonly topProducts = signal<AdminAnalyticsTopProduct[]>([]);
|
||||||
|
readonly lowStockProducts = signal<AdminLowStockProduct[]>([]);
|
||||||
|
readonly recentActivity = signal<AdminRecentActivityEntry[]>([]);
|
||||||
|
readonly marketplaceHealth = signal<AdminMarketplaceHealthCheck[]>([]);
|
||||||
|
readonly productAnalytics = signal<AdminProductAnalytics | null>(null);
|
||||||
|
readonly customerAnalytics = signal<AdminCustomerAnalytics | null>(null);
|
||||||
|
readonly recommendations = signal<AdminRecommendationCard[]>([]);
|
||||||
|
|
||||||
readonly maxSeriesValue = computed(() => Math.max(1, ...this.salesSeries().map(point => point.value)));
|
readonly maxSeriesValue = computed(() => Math.max(1, ...this.salesSeries().map(point => point.value)));
|
||||||
|
|
||||||
|
readonly healthCompletionPercent = computed(() => {
|
||||||
|
const known = this.marketplaceHealth().filter(check => check.status !== 'unknown');
|
||||||
|
if (known.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const healthy = known.filter(check => check.status === 'healthy').length;
|
||||||
|
return Math.round((healthy / known.length) * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly warnings = computed(() => this.recommendations().filter(card => card.severity !== 'info'));
|
||||||
|
|
||||||
load(): void {
|
load(): void {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 1000 }).pipe(take(1)).subscribe(orderResult => {
|
this.dashboardFacade.ensureLoaded();
|
||||||
|
this.recentActivity.set(
|
||||||
|
this.dashboardFacade.activityEntries().map(entry => ({
|
||||||
|
id: entry.id,
|
||||||
|
labelKey: entry.type === 'published' ? 'adminAnalytics.activityPublished' : 'adminAnalytics.activityDraftSaved',
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe(orderResult => {
|
||||||
const cutoff = Date.now() - this.dateRange() * 24 * 60 * 60 * 1000;
|
const cutoff = Date.now() - this.dateRange() * 24 * 60 * 60 * 1000;
|
||||||
const inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff);
|
const inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff);
|
||||||
|
|
||||||
this.salesSeries.set(this.buildSeries(inRange, this.dateRange()));
|
this.salesSeries.set(this.buildSeries(inRange, this.dateRange()));
|
||||||
this.topProducts.set(this.buildTopProducts(inRange));
|
this.topProducts.set(this.buildTopProducts(inRange));
|
||||||
|
this.customerAnalytics.set(this.buildCustomerAnalytics(orderResult.items, inRange, this.dateRange()));
|
||||||
|
|
||||||
const revenueTotal = inRange.reduce((sum, order) => sum + order.total, 0);
|
const revenueTotal = inRange.reduce((sum, order) => sum + order.total, 0);
|
||||||
const ordersCount = inRange.length;
|
const ordersCount = inRange.length;
|
||||||
|
const uniqueCustomers = new Set(inRange.map(order => order.customer.email)).size;
|
||||||
|
|
||||||
this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 1 }).pipe(take(1)).subscribe(productResult => {
|
this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe(productResult => {
|
||||||
this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe(categories => {
|
this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe(categories => {
|
||||||
this.summary.set({
|
this.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe(reviewResult => {
|
||||||
revenueTotal,
|
const products = productResult.items;
|
||||||
currency: inRange[0]?.currency ?? 'RUB',
|
const reviews = reviewResult.items;
|
||||||
ordersCount,
|
|
||||||
avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0,
|
this.summary.set({
|
||||||
productsCount: productResult.total,
|
revenueTotal,
|
||||||
categoriesCount: categories.length,
|
currency: inRange[0]?.currency ?? 'RUB',
|
||||||
|
ordersCount,
|
||||||
|
avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0,
|
||||||
|
productsCount: products.length,
|
||||||
|
categoriesCount: categories.length,
|
||||||
|
customersCount: uniqueCustomers,
|
||||||
|
conversionRate: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.lowStockProducts.set(this.buildLowStock(products));
|
||||||
|
this.productAnalytics.set(this.buildProductAnalytics(products));
|
||||||
|
this.marketplaceHealth.set(this.buildMarketplaceHealth(products, categories, reviews, orderResult.items));
|
||||||
|
this.recommendations.set(this.buildRecommendations(products, categories));
|
||||||
|
|
||||||
|
this.loading.set(false);
|
||||||
});
|
});
|
||||||
this.loading.set(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -59,6 +124,18 @@ export class AdminAnalyticsFacade {
|
|||||||
return [header, ...rows].join('\n');
|
return [header, ...rows].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exportTopProductsCsv(): string {
|
||||||
|
const header = 'Product,Quantity Sold,Revenue';
|
||||||
|
const rows = this.topProducts().map(product => `${product.name},${product.quantity},${product.revenue}`);
|
||||||
|
return [header, ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
exportHealthCsv(): string {
|
||||||
|
const header = 'Check,Status,Value';
|
||||||
|
const rows = this.marketplaceHealth().map(check => `${check.labelKey},${check.status},${check.displayValue ?? ''}`);
|
||||||
|
return [header, ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
private buildSeries(orders: AdminOrder[], days: number): AdminAnalyticsSeriesPoint[] {
|
private buildSeries(orders: AdminOrder[], days: number): AdminAnalyticsSeriesPoint[] {
|
||||||
const buckets = new Map<string, number>();
|
const buckets = new Map<string, number>();
|
||||||
for (let i = days - 1; i >= 0; i--) {
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
@@ -86,4 +163,176 @@ export class AdminAnalyticsFacade {
|
|||||||
}
|
}
|
||||||
return [...map.values()].sort((left, right) => right.revenue - left.revenue).slice(0, 5);
|
return [...map.values()].sort((left, right) => right.revenue - left.revenue).slice(0, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildLowStock(products: AdminProduct[]): AdminLowStockProduct[] {
|
||||||
|
return products
|
||||||
|
.filter(product => !product.archived && (product.stockStatus === 'low_stock' || product.stockStatus === 'out_of_stock'))
|
||||||
|
.sort((left, right) => left.quantity - right.quantity)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map(product => ({ productId: product.id, name: product.name, quantity: product.quantity, stockStatus: product.stockStatus as 'low_stock' | 'out_of_stock' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCustomerAnalytics(allOrders: AdminOrder[], inRangeOrders: AdminOrder[], days: number): AdminCustomerAnalytics {
|
||||||
|
const firstOrderByEmail = new Map<string, number>();
|
||||||
|
for (const order of allOrders) {
|
||||||
|
const email = order.customer.email;
|
||||||
|
const time = new Date(order.createdAt).getTime();
|
||||||
|
const existing = firstOrderByEmail.get(email);
|
||||||
|
if (existing === undefined || time < existing) {
|
||||||
|
firstOrderByEmail.set(email, time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||||
|
const emailsInRange = new Set(inRangeOrders.map(order => order.customer.email));
|
||||||
|
let newCustomers = 0;
|
||||||
|
let returningCustomers = 0;
|
||||||
|
for (const email of emailsInRange) {
|
||||||
|
const firstOrderAt = firstOrderByEmail.get(email) ?? Date.now();
|
||||||
|
if (firstOrderAt >= cutoff) {
|
||||||
|
newCustomers += 1;
|
||||||
|
} else {
|
||||||
|
returningCustomers += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const revenueInRange = inRangeOrders.reduce((sum, order) => sum + order.total, 0);
|
||||||
|
const uniqueCustomersInRange = emailsInRange.size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
newCustomers,
|
||||||
|
returningCustomers,
|
||||||
|
averageSpend: uniqueCustomersInRange > 0 ? Math.round(revenueInRange / uniqueCustomersInRange) : 0,
|
||||||
|
currency: inRangeOrders[0]?.currency ?? 'RUB',
|
||||||
|
retentionPercent: uniqueCustomersInRange > 0 ? Math.round((returningCustomers / uniqueCustomersInRange) * 100) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildProductAnalytics(products: AdminProduct[]): AdminProductAnalytics {
|
||||||
|
const mostReviewed = [...products]
|
||||||
|
.filter(product => product.reviews.length > 0)
|
||||||
|
.sort((left, right) => right.reviews.length - left.reviews.length)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(product => ({ productId: product.id, name: product.name, value: product.reviews.length }));
|
||||||
|
|
||||||
|
const worstRated = [...products]
|
||||||
|
.filter(product => product.reviews.length > 0)
|
||||||
|
.map(product => ({
|
||||||
|
productId: product.id,
|
||||||
|
name: product.name,
|
||||||
|
value: Math.round((product.reviews.reduce((sum, review) => sum + review.rating, 0) / product.reviews.length) * 10) / 10,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.value - right.value)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
topSelling: this.topProducts(),
|
||||||
|
mostReviewed,
|
||||||
|
worstRated,
|
||||||
|
hiddenCount: products.filter(product => !product.visible && !product.archived).length,
|
||||||
|
archivedCount: products.filter(product => product.archived).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildMarketplaceHealth(products: AdminProduct[], categories: AdminCategory[], reviews: AdminReview[], allOrders: AdminOrder[]): AdminMarketplaceHealthCheck[] {
|
||||||
|
const activeProducts = products.filter(product => !product.archived);
|
||||||
|
const withImages = activeProducts.filter(product => product.media.images.length > 0).length;
|
||||||
|
const withSeo = activeProducts.filter(product => product.seo.metaTitle && product.seo.metaDescription).length;
|
||||||
|
const categoriesWithProducts = categories.filter(category => category.itemsCount > 0).length;
|
||||||
|
const moderatedReviews = reviews.filter(review => review.status !== 'pending').length;
|
||||||
|
const processedOrders = allOrders.filter(order => order.status !== 'pending').length;
|
||||||
|
const bootstrap = this.dashboardFacade.bootstrap();
|
||||||
|
const issues = new Set(this.dashboardFacade.validationIssues().map(issue => issue.code));
|
||||||
|
|
||||||
|
const percentStatus = (done: number, total: number) => {
|
||||||
|
if (total === 0) return 'unknown' as const;
|
||||||
|
const ratio = done / total;
|
||||||
|
if (ratio === 1) return 'healthy' as const;
|
||||||
|
if (ratio >= 0.5) return 'attention' as const;
|
||||||
|
return 'unhealthy' as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
code: 'products-with-images', labelKey: 'adminMarketplaceHealth.checkImages',
|
||||||
|
status: percentStatus(withImages, activeProducts.length),
|
||||||
|
displayValue: activeProducts.length ? `${withImages}/${activeProducts.length}` : null,
|
||||||
|
actionRoute: ['backoffice', 'products'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'products-with-seo', labelKey: 'adminMarketplaceHealth.checkSeo',
|
||||||
|
status: percentStatus(withSeo, activeProducts.length),
|
||||||
|
displayValue: activeProducts.length ? `${withSeo}/${activeProducts.length}` : null,
|
||||||
|
actionRoute: ['backoffice', 'products'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'categories-with-products', labelKey: 'adminMarketplaceHealth.checkCategories',
|
||||||
|
status: percentStatus(categoriesWithProducts, categories.length),
|
||||||
|
displayValue: categories.length ? `${categoriesWithProducts}/${categories.length}` : null,
|
||||||
|
actionRoute: ['backoffice', 'categories'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'reviews-moderated', labelKey: 'adminMarketplaceHealth.checkReviews',
|
||||||
|
status: percentStatus(moderatedReviews, reviews.length),
|
||||||
|
displayValue: reviews.length ? `${moderatedReviews}/${reviews.length}` : null,
|
||||||
|
actionRoute: ['backoffice', 'moderation'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'orders-processed', labelKey: 'adminMarketplaceHealth.checkOrders',
|
||||||
|
status: percentStatus(processedOrders, allOrders.length),
|
||||||
|
displayValue: allOrders.length ? `${processedOrders}/${allOrders.length}` : null,
|
||||||
|
actionRoute: ['backoffice', 'orders'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'translations-complete', labelKey: 'adminMarketplaceHealth.checkTranslations',
|
||||||
|
status: !bootstrap ? 'unknown' : issues.has('missing-translations') ? 'unhealthy' : 'healthy',
|
||||||
|
actionRoute: ['edit'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'homepage-configured', labelKey: 'adminMarketplaceHealth.checkHomepage',
|
||||||
|
status: !bootstrap ? 'unknown' : this.dashboardFacade.enabledWidgetsCount() > 0 ? 'healthy' : 'attention',
|
||||||
|
actionRoute: ['edit', 'homepage'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'backend-connectivity', labelKey: 'adminMarketplaceHealth.checkBackend',
|
||||||
|
status: bootstrap ? 'healthy' : 'unknown',
|
||||||
|
actionRoute: ['backoffice', 'monitoring'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'performance', labelKey: 'adminMarketplaceHealth.checkPerformance',
|
||||||
|
status: 'unknown',
|
||||||
|
actionRoute: ['backoffice', 'monitoring'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRecommendations(products: AdminProduct[], categories: AdminCategory[]): AdminRecommendationCard[] {
|
||||||
|
const cards: AdminRecommendationCard[] = [];
|
||||||
|
const activeProducts = products.filter(product => !product.archived);
|
||||||
|
const missingImages = activeProducts.filter(product => product.media.images.length === 0).length;
|
||||||
|
const missingSeo = activeProducts.filter(product => !product.seo.metaTitle || !product.seo.metaDescription).length;
|
||||||
|
const missingCategory = activeProducts.filter(product => !product.categoryId).length;
|
||||||
|
const emptyCategories = categories.filter(category => category.itemsCount === 0).length;
|
||||||
|
|
||||||
|
if (missingImages > 0) {
|
||||||
|
cards.push({ id: 'missing-images', labelKey: 'adminMarketplaceHealth.recommendImages', descriptionKey: 'adminMarketplaceHealth.recommendImagesCount', severity: 'warning', route: ['backoffice', 'products'] });
|
||||||
|
}
|
||||||
|
if (missingSeo > 0) {
|
||||||
|
cards.push({ id: 'missing-seo', labelKey: 'adminMarketplaceHealth.recommendSeo', descriptionKey: 'adminMarketplaceHealth.recommendSeoCount', severity: 'warning', route: ['backoffice', 'products'] });
|
||||||
|
}
|
||||||
|
if (missingCategory > 0) {
|
||||||
|
cards.push({ id: 'missing-category', labelKey: 'adminMarketplaceHealth.recommendCategory', descriptionKey: 'adminMarketplaceHealth.recommendCategoryCount', severity: 'warning', route: ['backoffice', 'products'] });
|
||||||
|
}
|
||||||
|
if (emptyCategories > 0) {
|
||||||
|
cards.push({ id: 'empty-categories', labelKey: 'adminMarketplaceHealth.recommendEmptyCategories', descriptionKey: 'adminMarketplaceHealth.recommendEmptyCategoriesCount', severity: 'info', route: ['backoffice', 'categories'] });
|
||||||
|
}
|
||||||
|
if (this.dashboardFacade.bootstrap() && this.dashboardFacade.enabledWidgetsCount() === 0) {
|
||||||
|
cards.push({ id: 'homepage-incomplete', labelKey: 'adminMarketplaceHealth.recommendHomepage', severity: 'critical', route: ['edit', 'homepage'] });
|
||||||
|
}
|
||||||
|
if (this.dashboardFacade.bootstrap() && this.dashboardFacade.staticPagesUnpublishedCount() > 0) {
|
||||||
|
cards.push({ id: 'static-pages-unpublished', labelKey: 'adminMarketplaceHealth.recommendStaticPages', severity: 'info', route: ['backoffice', 'static-pages'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export interface AdminAnalyticsSummary {
|
|||||||
avgOrderValue: number;
|
avgOrderValue: number;
|
||||||
productsCount: number;
|
productsCount: number;
|
||||||
categoriesCount: number;
|
categoriesCount: number;
|
||||||
|
customersCount: number;
|
||||||
|
/** null = unknown - no visitor/traffic tracking exists yet. Never fabricated. */
|
||||||
|
conversionRate: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminAnalyticsSeriesPoint {
|
export interface AdminAnalyticsSeriesPoint {
|
||||||
@@ -20,3 +23,59 @@ export interface AdminAnalyticsTopProduct {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminLowStockProduct {
|
||||||
|
productId: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
stockStatus: 'low_stock' | 'out_of_stock';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminRecentActivityEntry {
|
||||||
|
id: string;
|
||||||
|
labelKey: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminMarketplaceHealthStatus = 'healthy' | 'attention' | 'unhealthy' | 'unknown';
|
||||||
|
|
||||||
|
export interface AdminMarketplaceHealthCheck {
|
||||||
|
code: string;
|
||||||
|
labelKey: string;
|
||||||
|
status: AdminMarketplaceHealthStatus;
|
||||||
|
displayValue?: string | null;
|
||||||
|
actionRoute?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminProductAnalyticsRow {
|
||||||
|
productId: string;
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminProductAnalytics {
|
||||||
|
topSelling: AdminAnalyticsTopProduct[];
|
||||||
|
mostReviewed: AdminProductAnalyticsRow[];
|
||||||
|
worstRated: AdminProductAnalyticsRow[];
|
||||||
|
hiddenCount: number;
|
||||||
|
archivedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminCustomerAnalytics {
|
||||||
|
newCustomers: number;
|
||||||
|
returningCustomers: number;
|
||||||
|
averageSpend: number;
|
||||||
|
currency: string;
|
||||||
|
/** null = unknown when there are no customers in range to compute a rate from. */
|
||||||
|
retentionPercent: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AdminRecommendationSeverity = 'info' | 'warning' | 'critical';
|
||||||
|
|
||||||
|
export interface AdminRecommendationCard {
|
||||||
|
id: string;
|
||||||
|
labelKey: string;
|
||||||
|
descriptionKey?: string;
|
||||||
|
severity: AdminRecommendationSeverity;
|
||||||
|
route: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,69 +5,242 @@
|
|||||||
<app-button [variant]="facade.dateRange() === range ? 'primary' : 'secondary'" size="sm" (click)="facade.setDateRange(range)">{{ range }}d</app-button>
|
<app-button [variant]="facade.dateRange() === range ? 'primary' : 'secondary'" size="sm" (click)="facade.setDateRange(range)">{{ range }}d</app-button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
|
<div class="toolbar-actions">
|
||||||
</div>
|
<app-button variant="secondary" size="sm" (click)="print()">{{ 'adminAnalytics.print' | translate }}</app-button>
|
||||||
|
<app-button variant="secondary" size="sm" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
|
||||||
@if (facade.loading()) {
|
|
||||||
<div class="summary-grid">
|
|
||||||
@for (i of [1,2,3,4,5,6]; track i) {
|
|
||||||
<app-skeleton shape="rect" height="64px" />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
} @else if (facade.summary(); as summary) {
|
|
||||||
<div class="summary-grid">
|
|
||||||
<div class="summary-card"><span>{{ 'adminAnalytics.revenue' | translate }}</span><strong>{{ summary.revenueTotal }} {{ summary.currency }}</strong></div>
|
|
||||||
<div class="summary-card"><span>{{ 'adminAnalytics.orders' | translate }}</span><strong>{{ summary.ordersCount }}</strong></div>
|
|
||||||
<div class="summary-card"><span>{{ 'adminAnalytics.avgOrderValue' | translate }}</span><strong>{{ summary.avgOrderValue }} {{ summary.currency }}</strong></div>
|
|
||||||
<div class="summary-card"><span>{{ 'adminAnalytics.products' | translate }}</span><strong>{{ summary.productsCount }}</strong></div>
|
|
||||||
<div class="summary-card"><span>{{ 'adminAnalytics.categories' | translate }}</span><strong>{{ summary.categoriesCount }}</strong></div>
|
|
||||||
<div class="summary-card pending"><span>{{ 'adminAnalytics.visitors' | translate }}</span><app-badge variant="neutral">{{ 'adminAnalytics.pendingBackend' | translate }}</app-badge></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>{{ 'adminAnalytics.salesChart' | translate }}</h2>
|
|
||||||
<div class="chart">
|
|
||||||
@for (point of facade.salesSeries(); track point.date) {
|
|
||||||
<div class="bar" [style.height.%]="barHeight(point.value)" [title]="point.date + ': ' + point.value"></div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="tabs" role="tablist" [attr.aria-label]="'adminAnalytics.tabsLabel' | translate">
|
||||||
<h2>{{ 'adminAnalytics.topProducts' | translate }}</h2>
|
@for (tab of tabs; track tab) {
|
||||||
@if (facade.loading()) {
|
<button
|
||||||
<div class="skeleton-rows">
|
type="button"
|
||||||
@for (i of [1,2,3]; track i) {
|
role="tab"
|
||||||
<app-skeleton shape="rect" height="36px" />
|
class="tab"
|
||||||
}
|
[class.tab--active]="activeTab() === tab"
|
||||||
</div>
|
[attr.aria-selected]="activeTab() === tab"
|
||||||
} @else if (facade.topProducts().length === 0) {
|
[attr.id]="'tab-' + tab"
|
||||||
<app-empty-state [title]="'adminAnalytics.topProductsEmptyTitle' | translate" [description]="'adminAnalytics.topProductsEmptyDescription' | translate" />
|
[attr.aria-controls]="'panel-' + tab"
|
||||||
} @else {
|
(click)="selectTab(tab)"
|
||||||
<app-table>
|
>{{ 'adminAnalytics.tab.' + tab | translate }}</button>
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>{{ 'adminProducts.name' | translate }}</th>
|
|
||||||
<th>{{ 'adminAnalytics.quantitySold' | translate }}</th>
|
|
||||||
<th>{{ 'adminAnalytics.revenue' | translate }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (product of facade.topProducts(); track product.productId) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ product.name }}</td>
|
|
||||||
<td>{{ product.quantity }}</td>
|
|
||||||
<td>{{ product.revenue }}</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</app-table>
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card pending-section">
|
@if (activeTab() === 'overview') {
|
||||||
<h2>{{ 'adminAnalytics.funnelsHeatmaps' | translate }}</h2>
|
<div class="panel" role="tabpanel" id="panel-overview" aria-labelledby="tab-overview">
|
||||||
<p><app-badge variant="neutral">{{ 'adminAnalytics.pendingBackend' | translate }}</app-badge> {{ 'adminAnalytics.pendingBackendHint' | translate }}</p>
|
@if (facade.loading()) {
|
||||||
</div>
|
<div class="summary-grid">
|
||||||
|
@for (i of [1,2,3,4,5,6]; track i) {
|
||||||
|
<app-skeleton shape="rect" height="64px" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (facade.summary(); as summary) {
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-card"><span>{{ 'adminAnalytics.revenue' | translate }}</span><strong>{{ summary.revenueTotal }} {{ summary.currency }}</strong></div>
|
||||||
|
<div class="summary-card"><span>{{ 'adminAnalytics.orders' | translate }}</span><strong>{{ summary.ordersCount }}</strong></div>
|
||||||
|
<div class="summary-card"><span>{{ 'adminAnalytics.customers' | translate }}</span><strong>{{ summary.customersCount }}</strong></div>
|
||||||
|
<div class="summary-card"><span>{{ 'adminAnalytics.avgOrderValue' | translate }}</span><strong>{{ summary.avgOrderValue }} {{ summary.currency }}</strong></div>
|
||||||
|
<div class="summary-card pending"><span>{{ 'adminAnalytics.conversion' | translate }}</span><app-badge variant="neutral">{{ 'adminAnalytics.unknown' | translate }}</app-badge></div>
|
||||||
|
<div class="summary-card"><span>{{ 'adminAnalytics.completionPercent' | translate }}</span><strong>{{ facade.healthCompletionPercent() }}%</strong></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>{{ 'adminAnalytics.salesChart' | translate }}</h2>
|
||||||
|
<div class="chart">
|
||||||
|
@for (point of facade.salesSeries(); track point.date) {
|
||||||
|
<div class="bar" [style.height.%]="barHeight(point.value)" [title]="point.date + ': ' + point.value"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<h2>{{ 'adminAnalytics.topProducts' | translate }}</h2>
|
||||||
|
@if (facade.loading()) {
|
||||||
|
<div class="skeleton-rows">
|
||||||
|
@for (i of [1,2,3]; track i) { <app-skeleton shape="rect" height="36px" /> }
|
||||||
|
</div>
|
||||||
|
} @else if (facade.topProducts().length === 0) {
|
||||||
|
<app-empty-state [title]="'adminAnalytics.topProductsEmptyTitle' | translate" [description]="'adminAnalytics.topProductsEmptyDescription' | translate" />
|
||||||
|
} @else {
|
||||||
|
<app-table>
|
||||||
|
<thead><tr><th>{{ 'adminProducts.name' | translate }}</th><th>{{ 'adminAnalytics.quantitySold' | translate }}</th><th>{{ 'adminAnalytics.revenue' | translate }}</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@for (product of facade.topProducts(); track product.productId) {
|
||||||
|
<tr><td>{{ product.name }}</td><td>{{ product.quantity }}</td><td>{{ product.revenue }}</td></tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</app-table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>{{ 'adminAnalytics.lowStock' | translate }}</h2>
|
||||||
|
@if (facade.lowStockProducts().length === 0) {
|
||||||
|
<app-empty-state [title]="'adminAnalytics.lowStockEmptyTitle' | translate" />
|
||||||
|
} @else {
|
||||||
|
<app-table>
|
||||||
|
<thead><tr><th>{{ 'adminProducts.name' | translate }}</th><th>{{ 'adminAnalytics.quantity' | translate }}</th><th>{{ 'adminAnalytics.status' | translate }}</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@for (product of facade.lowStockProducts(); track product.productId) {
|
||||||
|
<tr>
|
||||||
|
<td>{{ product.name }}</td>
|
||||||
|
<td>{{ product.quantity }}</td>
|
||||||
|
<td><app-badge [variant]="product.stockStatus === 'out_of_stock' ? 'danger' : 'warning'">{{ ('adminProducts.' + (product.stockStatus === 'out_of_stock' ? 'outOfStock' : 'lowStock')) | translate }}</app-badge></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</app-table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<h2>{{ 'adminAnalytics.recentActivity' | translate }}</h2>
|
||||||
|
@if (facade.recentActivity().length === 0) {
|
||||||
|
<app-empty-state [title]="'adminAnalytics.recentActivityEmptyTitle' | translate" />
|
||||||
|
} @else {
|
||||||
|
<ul class="activity-list">
|
||||||
|
@for (entry of facade.recentActivity(); track entry.id) {
|
||||||
|
<li>{{ entry.labelKey | translate }} <span class="muted">{{ entry.timestamp | date:'short' }}</span></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>{{ 'adminAnalytics.warnings' | translate }}</h2>
|
||||||
|
@if (facade.warnings().length === 0) {
|
||||||
|
<app-empty-state [title]="'adminMarketplaceHealth.recommendNone' | translate" />
|
||||||
|
} @else {
|
||||||
|
<ul class="warning-list">
|
||||||
|
@for (card of facade.warnings(); track card.id) {
|
||||||
|
<li><app-badge [variant]="severityVariant(card.severity)">{{ card.labelKey | translate }}</app-badge></li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeTab() === 'health') {
|
||||||
|
<div class="panel" role="tabpanel" id="panel-health" aria-labelledby="tab-health">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>{{ 'adminMarketplaceHealth.title' | translate }}</h2>
|
||||||
|
<app-button variant="secondary" size="sm" (click)="exportHealthCsv()">{{ 'adminOrders.export' | translate }}</app-button>
|
||||||
|
</div>
|
||||||
|
<div class="health-widget__bar" role="progressbar" [attr.aria-valuenow]="facade.healthCompletionPercent()" aria-valuemin="0" aria-valuemax="100">
|
||||||
|
<div class="health-widget__bar-fill" [style.width.%]="facade.healthCompletionPercent()"></div>
|
||||||
|
</div>
|
||||||
|
<p class="health-percent">{{ facade.healthCompletionPercent() }}% {{ 'adminMarketplaceHealth.complete' | translate }}</p>
|
||||||
|
<ul class="health-checks">
|
||||||
|
@for (check of facade.marketplaceHealth(); track check.code) {
|
||||||
|
<li>
|
||||||
|
<app-badge [variant]="healthStatusVariant(check.status)">{{ check.status === 'unknown' ? ('adminAnalytics.unknown' | translate) : (check.displayValue ?? check.status) }}</app-badge>
|
||||||
|
<a [routerLink]="check.actionRoute">{{ check.labelKey | translate }}</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeTab() === 'products') {
|
||||||
|
<div class="panel" role="tabpanel" id="panel-products" aria-labelledby="tab-products">
|
||||||
|
@if (facade.productAnalytics(); as pa) {
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-card pending"><span>{{ 'adminAnalytics.topViewed' | translate }}</span><app-badge variant="neutral">{{ 'adminAnalytics.unknown' | translate }}</app-badge></div>
|
||||||
|
<div class="summary-card"><span>{{ 'adminAnalytics.hiddenProducts' | translate }}</span><strong>{{ pa.hiddenCount }}</strong></div>
|
||||||
|
<div class="summary-card"><span>{{ 'adminAnalytics.archivedProducts' | translate }}</span><strong>{{ pa.archivedCount }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<h2>{{ 'adminAnalytics.mostReviewed' | translate }}</h2>
|
||||||
|
@if (pa.mostReviewed.length === 0) {
|
||||||
|
<app-empty-state [title]="'adminAnalytics.noReviewedProducts' | translate" />
|
||||||
|
} @else {
|
||||||
|
<app-table>
|
||||||
|
<thead><tr><th>{{ 'adminProducts.name' | translate }}</th><th>{{ 'adminAnalytics.reviewCount' | translate }}</th></tr></thead>
|
||||||
|
<tbody>@for (row of pa.mostReviewed; track row.productId) { <tr><td>{{ row.name }}</td><td>{{ row.value }}</td></tr> }</tbody>
|
||||||
|
</app-table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>{{ 'adminAnalytics.worstRated' | translate }}</h2>
|
||||||
|
@if (pa.worstRated.length === 0) {
|
||||||
|
<app-empty-state [title]="'adminAnalytics.noReviewedProducts' | translate" />
|
||||||
|
} @else {
|
||||||
|
<app-table>
|
||||||
|
<thead><tr><th>{{ 'adminProducts.name' | translate }}</th><th>{{ 'adminAnalytics.avgRating' | translate }}</th></tr></thead>
|
||||||
|
<tbody>@for (row of pa.worstRated; track row.productId) { <tr><td>{{ row.name }}</td><td>{{ row.value }}</td></tr> }</tbody>
|
||||||
|
</app-table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeTab() === 'customers') {
|
||||||
|
<div class="panel" role="tabpanel" id="panel-customers" aria-labelledby="tab-customers">
|
||||||
|
@if (facade.customerAnalytics(); as ca) {
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="summary-card"><span>{{ 'adminAnalytics.newCustomers' | translate }}</span><strong>{{ ca.newCustomers }}</strong></div>
|
||||||
|
<div class="summary-card"><span>{{ 'adminAnalytics.returningCustomers' | translate }}</span><strong>{{ ca.returningCustomers }}</strong></div>
|
||||||
|
<div class="summary-card"><span>{{ 'adminAnalytics.averageSpend' | translate }}</span><strong>{{ ca.averageSpend }} {{ ca.currency }}</strong></div>
|
||||||
|
<div class="summary-card">
|
||||||
|
<span>{{ 'adminAnalytics.retention' | translate }}</span>
|
||||||
|
@if (ca.retentionPercent === null) {
|
||||||
|
<app-badge variant="neutral">{{ 'adminAnalytics.unknown' | translate }}</app-badge>
|
||||||
|
} @else {
|
||||||
|
<strong>{{ ca.retentionPercent }}%</strong>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeTab() === 'search') {
|
||||||
|
<div class="panel" role="tabpanel" id="panel-search" aria-labelledby="tab-search">
|
||||||
|
<div class="card pending-section">
|
||||||
|
<h2>{{ 'adminAnalytics.searchAnalytics' | translate }}</h2>
|
||||||
|
<p><app-badge variant="neutral">{{ 'adminAnalytics.unknown' | translate }}</app-badge> {{ 'adminAnalytics.pendingBackendHint' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeTab() === 'traffic') {
|
||||||
|
<div class="panel" role="tabpanel" id="panel-traffic" aria-labelledby="tab-traffic">
|
||||||
|
<div class="card pending-section">
|
||||||
|
<h2>{{ 'adminAnalytics.trafficAnalytics' | translate }}</h2>
|
||||||
|
<p><app-badge variant="neutral">{{ 'adminAnalytics.unknown' | translate }}</app-badge> {{ 'adminAnalytics.availableAfterBackend' | translate }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeTab() === 'recommendations') {
|
||||||
|
<div class="panel" role="tabpanel" id="panel-recommendations" aria-labelledby="tab-recommendations">
|
||||||
|
<div class="card">
|
||||||
|
<h2>{{ 'adminMarketplaceHealth.recommendationsTitle' | translate }}</h2>
|
||||||
|
@if (facade.recommendations().length === 0) {
|
||||||
|
<app-empty-state [title]="'adminMarketplaceHealth.recommendNone' | translate" />
|
||||||
|
} @else {
|
||||||
|
<ul class="recommendation-list">
|
||||||
|
@for (card of facade.recommendations(); track card.id) {
|
||||||
|
<li>
|
||||||
|
<app-badge [variant]="severityVariant(card.severity)">{{ 'adminMarketplaceHealth.severity.' + card.severity | translate }}</app-badge>
|
||||||
|
<a [routerLink]="card.route">{{ card.labelKey | translate }}</a>
|
||||||
|
@if (card.descriptionKey) { <span class="muted">{{ card.descriptionKey | translate }}</span> }
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,14 +1,57 @@
|
|||||||
.admin-analytics-page { display: grid; gap: 16px; padding: 16px; max-width: 1100px; margin: 0 auto; }
|
.admin-analytics-page { display: grid; gap: 16px; padding: 16px; max-width: 1100px; margin: 0 auto; }
|
||||||
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
|
.toolbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||||
|
.toolbar-actions { display: flex; gap: 8px; }
|
||||||
.ranges { display: flex; gap: 6px; }
|
.ranges { display: flex; gap: 6px; }
|
||||||
|
|
||||||
|
.tabs { display: flex; flex-wrap: wrap; gap: 4px; border-bottom: 1px solid var(--border-color, #d3dad9); }
|
||||||
|
.tab {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text-primary, #1e3c38); }
|
||||||
|
.tab:focus-visible { outline: 2px solid var(--color-primary, #2f8f5b); outline-offset: 2px; }
|
||||||
|
.tab--active { color: var(--color-primary, #2f8f5b); border-bottom-color: var(--color-primary, #2f8f5b); }
|
||||||
|
|
||||||
|
.panel { display: grid; gap: 16px; }
|
||||||
|
|
||||||
.summary-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; }
|
.summary-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; }
|
||||||
.summary-card { border: 1px solid var(--border-color, #d3dad9); border-radius: 12px; padding: 12px; display: grid; gap: 4px; background: #fff; }
|
.summary-card { border: 1px solid var(--border-color, #d3dad9); border-radius: 12px; padding: 12px; display: grid; gap: 4px; background: #fff; }
|
||||||
.summary-card span { font-size: 0.75rem; color: var(--text-secondary, #6b7280); }
|
.summary-card span { font-size: 0.75rem; color: var(--text-secondary, #6b7280); }
|
||||||
.summary-card strong { font-size: 1.1rem; }
|
.summary-card strong { font-size: 1.1rem; }
|
||||||
|
|
||||||
.card { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; }
|
.card { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--border-color, #d3dad9); border-radius: 16px; background: #fff; }
|
||||||
|
.card-head { display: flex; justify-content: space-between; align-items: center; }
|
||||||
.card h2 { margin: 0; font-size: 1.1rem; }
|
.card h2 { margin: 0; font-size: 1.1rem; }
|
||||||
.chart { display: flex; align-items: flex-end; gap: 3px; height: 140px; }
|
.chart { display: flex; align-items: flex-end; gap: 3px; height: 140px; }
|
||||||
.chart .bar { flex: 1; background: var(--color-primary, #2f8f5b); border-radius: 3px 3px 0 0; min-height: 2px; }
|
.chart .bar { flex: 1; background: var(--color-primary, #2f8f5b); border-radius: 3px 3px 0 0; min-height: 2px; }
|
||||||
.pending-section p { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--text-secondary, #6b7280); }
|
.pending-section p { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--text-secondary, #6b7280); }
|
||||||
@media (max-width: 900px) { .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
|
||||||
.skeleton-rows { display: grid; gap: 8px; }
|
.skeleton-rows { display: grid; gap: 8px; }
|
||||||
|
|
||||||
|
.grid-2 { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||||
|
|
||||||
|
.activity-list, .warning-list, .recommendation-list, .health-checks { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; font-size: 0.85rem; }
|
||||||
|
.activity-list li { display: flex; justify-content: space-between; gap: 8px; }
|
||||||
|
.warning-list li, .recommendation-list li, .health-checks li { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.recommendation-list a, .health-checks a { color: var(--text-primary, #1e3c38); text-decoration: underline; }
|
||||||
|
.recommendation-list a:focus-visible, .health-checks a:focus-visible { outline: 2px solid var(--color-primary, #2f8f5b); outline-offset: 2px; }
|
||||||
|
.muted { color: var(--text-secondary, #6b7280); font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.health-widget__bar { height: 8px; border-radius: 999px; background: var(--surface-muted, #eef2f0); overflow: hidden; }
|
||||||
|
.health-widget__bar-fill { height: 100%; background: var(--brand-primary, #1e8a6e); transition: width 0.3s ease; }
|
||||||
|
.health-percent { margin: 0; font-size: 0.85rem; color: var(--text-secondary, #6b7280); }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.grid-2 { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.summary-grid { grid-template-columns: 1fr; }
|
||||||
|
.tabs { overflow-x: auto; flex-wrap: nowrap; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
import { AdminAnalyticsFacade } from '../facade/admin-analytics.facade';
|
import { AdminAnalyticsFacade } from '../facade/admin-analytics.facade';
|
||||||
import { AdminAnalyticsDateRange } from '../models/admin-analytics.model';
|
import { AdminAnalyticsDateRange } from '../models/admin-analytics.model';
|
||||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
@@ -9,10 +10,14 @@ import { TableComponent } from '../../../../shared/ui/table/table.component';
|
|||||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||||
|
|
||||||
|
export type AdminAnalyticsTab = 'overview' | 'health' | 'products' | 'customers' | 'search' | 'traffic' | 'recommendations';
|
||||||
|
|
||||||
|
const TABS: AdminAnalyticsTab[] = ['overview', 'health', 'products', 'customers', 'search', 'traffic', 'recommendations'];
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-analytics-page',
|
selector: 'app-admin-analytics-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent, SkeletonComponent, EmptyStateComponent],
|
imports: [CommonModule, RouterLink, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent, SkeletonComponent, EmptyStateComponent],
|
||||||
templateUrl: './admin-analytics-page.component.html',
|
templateUrl: './admin-analytics-page.component.html',
|
||||||
styleUrls: ['./admin-analytics-page.component.scss'],
|
styleUrls: ['./admin-analytics-page.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -21,22 +26,56 @@ export class AdminAnalyticsPageComponent {
|
|||||||
readonly facade = inject(AdminAnalyticsFacade);
|
readonly facade = inject(AdminAnalyticsFacade);
|
||||||
|
|
||||||
readonly ranges: AdminAnalyticsDateRange[] = [7, 30, 90];
|
readonly ranges: AdminAnalyticsDateRange[] = [7, 30, 90];
|
||||||
|
readonly tabs = TABS;
|
||||||
|
readonly activeTab = signal<AdminAnalyticsTab>('overview');
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.facade.load();
|
this.facade.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectTab(tab: AdminAnalyticsTab): void {
|
||||||
|
this.activeTab.set(tab);
|
||||||
|
}
|
||||||
|
|
||||||
barHeight(value: number): number {
|
barHeight(value: number): number {
|
||||||
return Math.max(2, Math.round((value / this.facade.maxSeriesValue()) * 100));
|
return Math.max(2, Math.round((value / this.facade.maxSeriesValue()) * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
healthStatusVariant(status: string): 'success' | 'warning' | 'danger' | 'neutral' {
|
||||||
|
switch (status) {
|
||||||
|
case 'healthy': return 'success';
|
||||||
|
case 'attention': return 'warning';
|
||||||
|
case 'unhealthy': return 'danger';
|
||||||
|
default: return 'neutral';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
severityVariant(severity: string): 'warning' | 'danger' | 'info' {
|
||||||
|
return severity === 'critical' ? 'danger' : severity === 'warning' ? 'warning' : 'info';
|
||||||
|
}
|
||||||
|
|
||||||
exportCsv(): void {
|
exportCsv(): void {
|
||||||
const csv = this.facade.exportCsv();
|
this.download(this.facade.exportCsv(), 'sales.csv');
|
||||||
|
}
|
||||||
|
|
||||||
|
exportTopProductsCsv(): void {
|
||||||
|
this.download(this.facade.exportTopProductsCsv(), 'top-products.csv');
|
||||||
|
}
|
||||||
|
|
||||||
|
exportHealthCsv(): void {
|
||||||
|
this.download(this.facade.exportHealthCsv(), 'marketplace-health.csv');
|
||||||
|
}
|
||||||
|
|
||||||
|
print(): void {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
private download(csv: string, filename: string): void {
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = url;
|
link.href = url;
|
||||||
link.download = 'sales.csv';
|
link.download = filename;
|
||||||
link.click();
|
link.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
<section class="admin-monitoring-page">
|
<section class="admin-monitoring-page">
|
||||||
|
<div class="card">
|
||||||
|
<h2>{{ 'adminMonitoring.systemState' | translate }}</h2>
|
||||||
|
<div class="health-grid">
|
||||||
|
<app-badge [variant]="dashboardFacade.bootstrap() ? 'success' : 'neutral'">{{ 'adminMonitoring.backendConnectivity' | translate }}: {{ dashboardFacade.bootstrap() ? ('adminMonitoring.connected' | translate) : ('adminMonitoring.loading' | translate) }}</app-badge>
|
||||||
|
<app-badge [variant]="dashboardFacade.dirty() ? 'warning' : 'success'">{{ 'adminMonitoring.draftState' | translate }}: {{ dashboardFacade.dirty() ? ('adminMonitoring.unsavedChanges' | translate) : ('adminMonitoring.noDraft' | translate) }}</app-badge>
|
||||||
|
<app-badge variant="neutral">{{ 'adminMonitoring.lastSaved' | translate }}: {{ dashboardFacade.lastSavedAt() ? (dashboardFacade.lastSavedAt() | date:'short') : ('adminAnalytics.unknown' | translate) }}</app-badge>
|
||||||
|
<app-badge variant="neutral">{{ 'adminMonitoring.lastPublished' | translate }}: {{ dashboardFacade.lastPublishedAt() ? (dashboardFacade.lastPublishedAt() | date:'short') : ('adminAnalytics.unknown' | translate) }}</app-badge>
|
||||||
|
<app-badge variant="neutral">{{ 'adminMonitoring.syncStatus' | translate }}: {{ 'adminAnalytics.unknown' | translate }}</app-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>{{ 'adminMonitoring.health' | translate }}</h2>
|
<h2>{{ 'adminMonitoring.health' | translate }}</h2>
|
||||||
<div class="health-grid">
|
<div class="health-grid">
|
||||||
|
|||||||
@@ -1594,10 +1594,100 @@ export const en: Translations = {
|
|||||||
adminMonitoring: {
|
adminMonitoring: {
|
||||||
eventsEmptyTitle: 'No events found',
|
eventsEmptyTitle: 'No events found',
|
||||||
eventsEmptyDescription: 'Try a different category filter or search term.',
|
eventsEmptyDescription: 'Try a different category filter or search term.',
|
||||||
|
systemState: 'System state',
|
||||||
|
backendConnectivity: 'Backend',
|
||||||
|
connected: 'Connected',
|
||||||
|
loading: 'Loading',
|
||||||
|
draftState: 'Draft',
|
||||||
|
unsavedChanges: 'Unsaved changes',
|
||||||
|
noDraft: 'No draft',
|
||||||
|
lastSaved: 'Last saved',
|
||||||
|
lastPublished: 'Last published',
|
||||||
|
syncStatus: 'Sync',
|
||||||
},
|
},
|
||||||
adminAnalytics: {
|
adminAnalytics: {
|
||||||
topProductsEmptyTitle: 'No product sales in this period',
|
topProductsEmptyTitle: 'No product sales in this period',
|
||||||
topProductsEmptyDescription: 'Try a wider date range.',
|
topProductsEmptyDescription: 'Try a wider date range.',
|
||||||
|
print: 'Print',
|
||||||
|
tabsLabel: 'Analytics sections',
|
||||||
|
tab: {
|
||||||
|
overview: 'Overview',
|
||||||
|
health: 'Health',
|
||||||
|
products: 'Products',
|
||||||
|
customers: 'Customers',
|
||||||
|
search: 'Search',
|
||||||
|
traffic: 'Traffic',
|
||||||
|
recommendations: 'Recommendations',
|
||||||
|
},
|
||||||
|
revenue: 'Revenue',
|
||||||
|
orders: 'Orders',
|
||||||
|
customers: 'Customers',
|
||||||
|
avgOrderValue: 'Avg. Order Value',
|
||||||
|
conversion: 'Conversion',
|
||||||
|
completionPercent: 'Completion',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
salesChart: 'Sales',
|
||||||
|
quantitySold: 'Qty sold',
|
||||||
|
lowStock: 'Low stock',
|
||||||
|
lowStockEmptyTitle: 'No low-stock products',
|
||||||
|
quantity: 'Quantity',
|
||||||
|
status: 'Status',
|
||||||
|
recentActivity: 'Recent activity',
|
||||||
|
recentActivityEmptyTitle: 'No recent activity',
|
||||||
|
activityDraftSaved: 'Draft saved',
|
||||||
|
activityPublished: 'Published',
|
||||||
|
warnings: 'Warnings',
|
||||||
|
topViewed: 'Top viewed',
|
||||||
|
hiddenProducts: 'Hidden products',
|
||||||
|
archivedProducts: 'Archived products',
|
||||||
|
mostReviewed: 'Most reviewed',
|
||||||
|
worstRated: 'Worst rated',
|
||||||
|
noReviewedProducts: 'No reviewed products yet',
|
||||||
|
reviewCount: 'Reviews',
|
||||||
|
avgRating: 'Avg. rating',
|
||||||
|
newCustomers: 'New customers',
|
||||||
|
returningCustomers: 'Returning customers',
|
||||||
|
averageSpend: 'Average spend',
|
||||||
|
retention: 'Retention',
|
||||||
|
searchAnalytics: 'Search analytics',
|
||||||
|
trafficAnalytics: 'Traffic',
|
||||||
|
availableAfterBackend: 'Available after backend integration.',
|
||||||
|
pendingBackend: 'Pending backend',
|
||||||
|
pendingBackendHint: 'No search-query tracking exists yet.',
|
||||||
|
funnelsHeatmaps: 'Funnels & heatmaps',
|
||||||
|
visitors: 'Visitors',
|
||||||
|
products: 'Products',
|
||||||
|
categories: 'Categories',
|
||||||
|
},
|
||||||
|
adminMarketplaceHealth: {
|
||||||
|
title: 'Marketplace health',
|
||||||
|
complete: 'complete',
|
||||||
|
recommendationsTitle: 'Recommendations',
|
||||||
|
recommendNone: 'Everything looks good.',
|
||||||
|
checkImages: 'Products have images',
|
||||||
|
checkSeo: 'Products have SEO metadata',
|
||||||
|
checkCategories: 'Categories have products',
|
||||||
|
checkReviews: 'Reviews are moderated',
|
||||||
|
checkOrders: 'Orders are processed',
|
||||||
|
checkTranslations: 'Translations are complete',
|
||||||
|
checkHomepage: 'Homepage is configured',
|
||||||
|
checkBackend: 'Backend connectivity',
|
||||||
|
checkPerformance: 'Performance',
|
||||||
|
recommendImages: 'Add images to products missing them',
|
||||||
|
recommendImagesCount: 'Products without images hurt conversion.',
|
||||||
|
recommendSeo: 'Add SEO metadata to products missing it',
|
||||||
|
recommendSeoCount: 'Missing SEO titles/descriptions hurt search visibility.',
|
||||||
|
recommendCategory: 'Assign a category to uncategorized products',
|
||||||
|
recommendCategoryCount: 'Products without a category are harder to discover.',
|
||||||
|
recommendEmptyCategories: 'Categories without products',
|
||||||
|
recommendEmptyCategoriesCount: 'Consider merging or removing empty categories.',
|
||||||
|
recommendHomepage: 'Homepage is incomplete',
|
||||||
|
recommendStaticPages: 'Static pages are unpublished',
|
||||||
|
severity: {
|
||||||
|
info: 'Info',
|
||||||
|
warning: 'Warning',
|
||||||
|
critical: 'Critical',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
adminShell: {
|
adminShell: {
|
||||||
skipToContent: 'Skip to content',
|
skipToContent: 'Skip to content',
|
||||||
|
|||||||
@@ -1589,10 +1589,100 @@ export const hy: Translations = {
|
|||||||
adminMonitoring: {
|
adminMonitoring: {
|
||||||
eventsEmptyTitle: 'Իրադարձություններ չեն գտնվել',
|
eventsEmptyTitle: 'Իրադարձություններ չեն գտնվել',
|
||||||
eventsEmptyDescription: 'Փորձեք այլ կատեգորիայի ֆիլտր կամ որոնման բառ։',
|
eventsEmptyDescription: 'Փորձեք այլ կատեգորիայի ֆիլտր կամ որոնման բառ։',
|
||||||
|
systemState: 'Համակարգի վիճակ',
|
||||||
|
backendConnectivity: 'Բեքենդ',
|
||||||
|
connected: 'Կապակցված է',
|
||||||
|
loading: 'Բեռնվում է',
|
||||||
|
draftState: 'Սևագիր',
|
||||||
|
unsavedChanges: 'Չպահպանված փոփոխություններ',
|
||||||
|
noDraft: 'Սևագիր չկա',
|
||||||
|
lastSaved: 'Վերջին պահպանում',
|
||||||
|
lastPublished: 'Վերջին հրապարակում',
|
||||||
|
syncStatus: 'Համաժամացում',
|
||||||
},
|
},
|
||||||
adminAnalytics: {
|
adminAnalytics: {
|
||||||
topProductsEmptyTitle: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա',
|
topProductsEmptyTitle: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա',
|
||||||
topProductsEmptyDescription: 'Փորձեք ընտրել ավելի լայն ամսաթվերի միջակայք։',
|
topProductsEmptyDescription: 'Փորձեք ընտրել ավելի լայն ամսաթվերի միջակայք։',
|
||||||
|
print: 'Տպել',
|
||||||
|
tabsLabel: 'Վերլուծության բաժիններ',
|
||||||
|
tab: {
|
||||||
|
overview: 'Ընդհանուր',
|
||||||
|
health: 'Առողջություն',
|
||||||
|
products: 'Ապրանքներ',
|
||||||
|
customers: 'Հաճախորդներ',
|
||||||
|
search: 'Որոնում',
|
||||||
|
traffic: 'Թրաֆիք',
|
||||||
|
recommendations: 'Առաջարկություններ',
|
||||||
|
},
|
||||||
|
revenue: 'Եկամուտ',
|
||||||
|
orders: 'Պատվերներ',
|
||||||
|
customers: 'Հաճախորդներ',
|
||||||
|
avgOrderValue: 'Միջին պատվեր',
|
||||||
|
conversion: 'Փոխարկում',
|
||||||
|
completionPercent: 'Ավարտվածություն',
|
||||||
|
unknown: 'Անհայտ',
|
||||||
|
salesChart: 'Վաճառքներ',
|
||||||
|
quantitySold: 'Վաճառված քանակ',
|
||||||
|
lowStock: 'Ցածր պաշար',
|
||||||
|
lowStockEmptyTitle: 'Ցածր պաշարով ապրանքներ չկան',
|
||||||
|
quantity: 'Քանակ',
|
||||||
|
status: 'Կարգավիճակ',
|
||||||
|
recentActivity: 'Վերջին ակտիվություն',
|
||||||
|
recentActivityEmptyTitle: 'Վերջին ակտիվություն չկա',
|
||||||
|
activityDraftSaved: 'Սևագիրը պահպանվեց',
|
||||||
|
activityPublished: 'Հրապարակվեց',
|
||||||
|
warnings: 'Զգուշացումներ',
|
||||||
|
topViewed: 'Ամենադիտված',
|
||||||
|
hiddenProducts: 'Թաքցված ապրանքներ',
|
||||||
|
archivedProducts: 'Արխիվացված ապրանքներ',
|
||||||
|
mostReviewed: 'Ամենաշատ գնահատված',
|
||||||
|
worstRated: 'Ամենացածր վարկանիշ',
|
||||||
|
noReviewedProducts: 'Դեռ գնահատված ապրանքներ չկան',
|
||||||
|
reviewCount: 'Կարծիքներ',
|
||||||
|
avgRating: 'Միջին վարկանիշ',
|
||||||
|
newCustomers: 'Նոր հաճախորդներ',
|
||||||
|
returningCustomers: 'Կրկնվող հաճախորդներ',
|
||||||
|
averageSpend: 'Միջին ծախս',
|
||||||
|
retention: 'Պահպանում',
|
||||||
|
searchAnalytics: 'Որոնման վերլուծություն',
|
||||||
|
trafficAnalytics: 'Թրաֆիք',
|
||||||
|
availableAfterBackend: 'Հասանելի կլինի բեքենդի ինտեգրումից հետո։',
|
||||||
|
pendingBackend: 'Սպասում է բեքենդին',
|
||||||
|
pendingBackendHint: 'Որոնման հարցումների հետագծում դեռ չկա։',
|
||||||
|
funnelsHeatmaps: 'Ձագարներ և ջերմային քարտեզներ',
|
||||||
|
visitors: 'Այցելուներ',
|
||||||
|
products: 'Ապրանքներ',
|
||||||
|
categories: 'Կատեգորիաներ',
|
||||||
|
},
|
||||||
|
adminMarketplaceHealth: {
|
||||||
|
title: 'Շուկայի առողջություն',
|
||||||
|
complete: 'ավարտված',
|
||||||
|
recommendationsTitle: 'Առաջարկություններ',
|
||||||
|
recommendNone: 'Ամեն ինչ կարգին է։',
|
||||||
|
checkImages: 'Ապրանքներն ունեն նկարներ',
|
||||||
|
checkSeo: 'Ապրանքներն ունեն SEO տվյալներ',
|
||||||
|
checkCategories: 'Կատեգորիաներն ունեն ապրանքներ',
|
||||||
|
checkReviews: 'Կարծիքները մոդերացվում են',
|
||||||
|
checkOrders: 'Պատվերները մշակվում են',
|
||||||
|
checkTranslations: 'Թարգմանությունները լրացված են',
|
||||||
|
checkHomepage: 'Գլխավոր էջը կարգավորված է',
|
||||||
|
checkBackend: 'Կապ բեքենդի հետ',
|
||||||
|
checkPerformance: 'Արտադրողականություն',
|
||||||
|
recommendImages: 'Ավելացրեք նկարներ դրանք չունեցող ապրանքներին',
|
||||||
|
recommendImagesCount: 'Նկարներ չունեցող ապրանքները նվազեցնում են փոխարկումը։',
|
||||||
|
recommendSeo: 'Լրացրեք SEO տվյալները դրանք չունեցող ապրանքների համար',
|
||||||
|
recommendSeoCount: 'SEO-ի բացակայությունը վատացնում է որոնման տեսանելիությունը։',
|
||||||
|
recommendCategory: 'Նշանակեք կատեգորիա առանց կատեգորիայի ապրանքներին',
|
||||||
|
recommendCategoryCount: 'Կատեգորիա չունեցող ապրանքներն ավելի դժվար են գտնվում։',
|
||||||
|
recommendEmptyCategories: 'Ապրանք չունեցող կատեգորիաներ',
|
||||||
|
recommendEmptyCategoriesCount: 'Դիտարկեք դատարկ կատեգորիաների միավորումը կամ հեռացումը։',
|
||||||
|
recommendHomepage: 'Գլխավոր էջը թերի է',
|
||||||
|
recommendStaticPages: 'Կան չհրապարակված ստատիկ էջեր',
|
||||||
|
severity: {
|
||||||
|
info: 'Տեղեկատվություն',
|
||||||
|
warning: 'Ուշադրություն',
|
||||||
|
critical: 'Կրիտիկական',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
adminShell: {
|
adminShell: {
|
||||||
skipToContent: 'Անցնել բովանդակությանը',
|
skipToContent: 'Անցնել բովանդակությանը',
|
||||||
|
|||||||
@@ -1589,10 +1589,100 @@ export const ru: Translations = {
|
|||||||
adminMonitoring: {
|
adminMonitoring: {
|
||||||
eventsEmptyTitle: 'События не найдены',
|
eventsEmptyTitle: 'События не найдены',
|
||||||
eventsEmptyDescription: 'Попробуйте изменить фильтр категории или поисковый запрос.',
|
eventsEmptyDescription: 'Попробуйте изменить фильтр категории или поисковый запрос.',
|
||||||
|
systemState: 'Состояние системы',
|
||||||
|
backendConnectivity: 'Бэкенд',
|
||||||
|
connected: 'Подключено',
|
||||||
|
loading: 'Загрузка',
|
||||||
|
draftState: 'Черновик',
|
||||||
|
unsavedChanges: 'Есть несохранённые изменения',
|
||||||
|
noDraft: 'Черновика нет',
|
||||||
|
lastSaved: 'Последнее сохранение',
|
||||||
|
lastPublished: 'Последняя публикация',
|
||||||
|
syncStatus: 'Синхронизация',
|
||||||
},
|
},
|
||||||
adminAnalytics: {
|
adminAnalytics: {
|
||||||
topProductsEmptyTitle: 'Нет продаж товаров за этот период',
|
topProductsEmptyTitle: 'Нет продаж товаров за этот период',
|
||||||
topProductsEmptyDescription: 'Попробуйте выбрать более широкий диапазон дат.',
|
topProductsEmptyDescription: 'Попробуйте выбрать более широкий диапазон дат.',
|
||||||
|
print: 'Печать',
|
||||||
|
tabsLabel: 'Разделы аналитики',
|
||||||
|
tab: {
|
||||||
|
overview: 'Обзор',
|
||||||
|
health: 'Здоровье',
|
||||||
|
products: 'Товары',
|
||||||
|
customers: 'Клиенты',
|
||||||
|
search: 'Поиск',
|
||||||
|
traffic: 'Трафик',
|
||||||
|
recommendations: 'Рекомендации',
|
||||||
|
},
|
||||||
|
revenue: 'Выручка',
|
||||||
|
orders: 'Заказы',
|
||||||
|
customers: 'Клиенты',
|
||||||
|
avgOrderValue: 'Средний чек',
|
||||||
|
conversion: 'Конверсия',
|
||||||
|
completionPercent: 'Готовность',
|
||||||
|
unknown: 'Неизвестно',
|
||||||
|
salesChart: 'Продажи',
|
||||||
|
quantitySold: 'Продано, шт.',
|
||||||
|
lowStock: 'Мало на складе',
|
||||||
|
lowStockEmptyTitle: 'Нет товаров с низким остатком',
|
||||||
|
quantity: 'Количество',
|
||||||
|
status: 'Статус',
|
||||||
|
recentActivity: 'Недавняя активность',
|
||||||
|
recentActivityEmptyTitle: 'Нет недавней активности',
|
||||||
|
activityDraftSaved: 'Черновик сохранён',
|
||||||
|
activityPublished: 'Опубликовано',
|
||||||
|
warnings: 'Предупреждения',
|
||||||
|
topViewed: 'Самые просматриваемые',
|
||||||
|
hiddenProducts: 'Скрытые товары',
|
||||||
|
archivedProducts: 'Архивные товары',
|
||||||
|
mostReviewed: 'Больше всего отзывов',
|
||||||
|
worstRated: 'Худший рейтинг',
|
||||||
|
noReviewedProducts: 'Пока нет товаров с отзывами',
|
||||||
|
reviewCount: 'Отзывы',
|
||||||
|
avgRating: 'Средний рейтинг',
|
||||||
|
newCustomers: 'Новые клиенты',
|
||||||
|
returningCustomers: 'Повторные клиенты',
|
||||||
|
averageSpend: 'Средние траты',
|
||||||
|
retention: 'Удержание',
|
||||||
|
searchAnalytics: 'Аналитика поиска',
|
||||||
|
trafficAnalytics: 'Трафик',
|
||||||
|
availableAfterBackend: 'Будет доступно после интеграции с бэкендом.',
|
||||||
|
pendingBackend: 'Ожидает бэкенд',
|
||||||
|
pendingBackendHint: 'Отслеживание поисковых запросов пока не реализовано.',
|
||||||
|
funnelsHeatmaps: 'Воронки и тепловые карты',
|
||||||
|
visitors: 'Посетители',
|
||||||
|
products: 'Товары',
|
||||||
|
categories: 'Категории',
|
||||||
|
},
|
||||||
|
adminMarketplaceHealth: {
|
||||||
|
title: 'Здоровье маркетплейса',
|
||||||
|
complete: 'готово',
|
||||||
|
recommendationsTitle: 'Рекомендации',
|
||||||
|
recommendNone: 'Всё в порядке.',
|
||||||
|
checkImages: 'У товаров есть изображения',
|
||||||
|
checkSeo: 'У товаров заполнено SEO',
|
||||||
|
checkCategories: 'В категориях есть товары',
|
||||||
|
checkReviews: 'Отзывы модерируются',
|
||||||
|
checkOrders: 'Заказы обрабатываются',
|
||||||
|
checkTranslations: 'Переводы заполнены',
|
||||||
|
checkHomepage: 'Главная страница настроена',
|
||||||
|
checkBackend: 'Связь с бэкендом',
|
||||||
|
checkPerformance: 'Производительность',
|
||||||
|
recommendImages: 'Добавьте изображения товарам без них',
|
||||||
|
recommendImagesCount: 'Товары без изображений снижают конверсию.',
|
||||||
|
recommendSeo: 'Заполните SEO для товаров без него',
|
||||||
|
recommendSeoCount: 'Отсутствие SEO ухудшает видимость в поиске.',
|
||||||
|
recommendCategory: 'Назначьте категорию товарам без категории',
|
||||||
|
recommendCategoryCount: 'Товары без категории труднее найти.',
|
||||||
|
recommendEmptyCategories: 'Категории без товаров',
|
||||||
|
recommendEmptyCategoriesCount: 'Рассмотрите объединение или удаление пустых категорий.',
|
||||||
|
recommendHomepage: 'Главная страница не заполнена',
|
||||||
|
recommendStaticPages: 'Есть неопубликованные статические страницы',
|
||||||
|
severity: {
|
||||||
|
info: 'Инфо',
|
||||||
|
warning: 'Внимание',
|
||||||
|
critical: 'Критично',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
adminShell: {
|
adminShell: {
|
||||||
skipToContent: 'Перейти к содержимому',
|
skipToContent: 'Перейти к содержимому',
|
||||||
|
|||||||
@@ -1601,10 +1601,100 @@ export interface Translations {
|
|||||||
adminMonitoring: {
|
adminMonitoring: {
|
||||||
eventsEmptyTitle: string;
|
eventsEmptyTitle: string;
|
||||||
eventsEmptyDescription: string;
|
eventsEmptyDescription: string;
|
||||||
|
systemState: string;
|
||||||
|
backendConnectivity: string;
|
||||||
|
connected: string;
|
||||||
|
loading: string;
|
||||||
|
draftState: string;
|
||||||
|
unsavedChanges: string;
|
||||||
|
noDraft: string;
|
||||||
|
lastSaved: string;
|
||||||
|
lastPublished: string;
|
||||||
|
syncStatus: string;
|
||||||
};
|
};
|
||||||
adminAnalytics: {
|
adminAnalytics: {
|
||||||
topProductsEmptyTitle: string;
|
topProductsEmptyTitle: string;
|
||||||
topProductsEmptyDescription: string;
|
topProductsEmptyDescription: string;
|
||||||
|
print: string;
|
||||||
|
tabsLabel: string;
|
||||||
|
tab: {
|
||||||
|
overview: string;
|
||||||
|
health: string;
|
||||||
|
products: string;
|
||||||
|
customers: string;
|
||||||
|
search: string;
|
||||||
|
traffic: string;
|
||||||
|
recommendations: string;
|
||||||
|
};
|
||||||
|
revenue: string;
|
||||||
|
orders: string;
|
||||||
|
customers: string;
|
||||||
|
avgOrderValue: string;
|
||||||
|
conversion: string;
|
||||||
|
completionPercent: string;
|
||||||
|
unknown: string;
|
||||||
|
salesChart: string;
|
||||||
|
quantitySold: string;
|
||||||
|
lowStock: string;
|
||||||
|
lowStockEmptyTitle: string;
|
||||||
|
quantity: string;
|
||||||
|
status: string;
|
||||||
|
recentActivity: string;
|
||||||
|
recentActivityEmptyTitle: string;
|
||||||
|
activityDraftSaved: string;
|
||||||
|
activityPublished: string;
|
||||||
|
warnings: string;
|
||||||
|
topViewed: string;
|
||||||
|
hiddenProducts: string;
|
||||||
|
archivedProducts: string;
|
||||||
|
mostReviewed: string;
|
||||||
|
worstRated: string;
|
||||||
|
noReviewedProducts: string;
|
||||||
|
reviewCount: string;
|
||||||
|
avgRating: string;
|
||||||
|
newCustomers: string;
|
||||||
|
returningCustomers: string;
|
||||||
|
averageSpend: string;
|
||||||
|
retention: string;
|
||||||
|
searchAnalytics: string;
|
||||||
|
trafficAnalytics: string;
|
||||||
|
availableAfterBackend: string;
|
||||||
|
pendingBackend: string;
|
||||||
|
pendingBackendHint: string;
|
||||||
|
funnelsHeatmaps: string;
|
||||||
|
visitors: string;
|
||||||
|
products: string;
|
||||||
|
categories: string;
|
||||||
|
};
|
||||||
|
adminMarketplaceHealth: {
|
||||||
|
title: string;
|
||||||
|
complete: string;
|
||||||
|
recommendationsTitle: string;
|
||||||
|
recommendNone: string;
|
||||||
|
checkImages: string;
|
||||||
|
checkSeo: string;
|
||||||
|
checkCategories: string;
|
||||||
|
checkReviews: string;
|
||||||
|
checkOrders: string;
|
||||||
|
checkTranslations: string;
|
||||||
|
checkHomepage: string;
|
||||||
|
checkBackend: string;
|
||||||
|
checkPerformance: string;
|
||||||
|
recommendImages: string;
|
||||||
|
recommendImagesCount: string;
|
||||||
|
recommendSeo: string;
|
||||||
|
recommendSeoCount: string;
|
||||||
|
recommendCategory: string;
|
||||||
|
recommendCategoryCount: string;
|
||||||
|
recommendEmptyCategories: string;
|
||||||
|
recommendEmptyCategoriesCount: string;
|
||||||
|
recommendHomepage: string;
|
||||||
|
recommendStaticPages: string;
|
||||||
|
severity: {
|
||||||
|
info: string;
|
||||||
|
warning: string;
|
||||||
|
critical: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
adminShell: {
|
adminShell: {
|
||||||
skipToContent: string;
|
skipToContent: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user