From 325dc179119a122b64f5d3f21f61f120bd6315bc Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Tue, 14 Jul 2026 12:21:33 +0400 Subject: [PATCH] feat(admin): sprint 19 admin dashboard, routing, i18n - Add admin dashboard feature (models/gateway/facade/components/page) - Wire admin/products routes and backoffice coming-soon placeholders - Add lastPublishedAt to ProjectEditorFacade/state - Add dashboard i18n keys (en/ru/hy) and docs/ADMIN.md Co-Authored-By: Claude Sonnet 5 --- docs/ADMIN.md | 163 +++++++++++++++++ src/app/app.routes.ts | 54 ++++++ .../telegram-login.component.ts | 9 +- .../admin-dashboard-activity.component.html | 12 ++ .../admin-dashboard-activity.component.scss | 42 +++++ .../admin-dashboard-activity.component.ts | 21 +++ .../admin-dashboard-card.component.html | 23 +++ .../admin-dashboard-card.component.scss | 83 +++++++++ .../admin-dashboard-card.component.ts | 20 ++ .../admin-dashboard-health.component.html | 12 ++ .../admin-dashboard-health.component.scss | 51 ++++++ .../admin-dashboard-health.component.ts | 20 ++ ...min-dashboard-quick-actions.component.html | 10 + ...min-dashboard-quick-actions.component.scss | 43 +++++ ...admin-dashboard-quick-actions.component.ts | 17 ++ .../facade/admin-dashboard.facade.ts | 125 +++++++++++++ .../dashboard/models/admin-dashboard.model.ts | 42 +++++ .../pages/admin-dashboard-page.component.html | 23 +++ .../pages/admin-dashboard-page.component.scss | 64 +++++++ .../pages/admin-dashboard-page.component.ts | 171 ++++++++++++++++++ .../admin-dashboard-history.service.ts | 45 +++++ .../admin-dashboard-metrics-gateway.token.ts | 9 + ...min-dashboard-metrics.gateway.interface.ts | 6 + .../admin-dashboard-metrics.local.gateway.ts | 23 +++ .../admin-product-editor-page.component.ts | 4 +- .../admin-products-list-page.component.ts | 12 +- ...backoffice-coming-soon-page.component.html | 5 + ...backoffice-coming-soon-page.component.scss | 32 ++++ .../backoffice-coming-soon-page.component.ts | 19 ++ .../facade/project-editor.facade.ts | 3 + .../models/project-editor.model.ts | 1 + .../services/project-validator.service.ts | 35 ++++ src/app/i18n/en.ts | 48 +++++ src/app/i18n/hy.ts | 48 +++++ src/app/i18n/ru.ts | 48 +++++ src/app/i18n/translations.ts | 48 +++++ 36 files changed, 1386 insertions(+), 5 deletions(-) create mode 100644 docs/ADMIN.md create mode 100644 src/app/features/admin/dashboard/components/admin-dashboard-activity.component.html create mode 100644 src/app/features/admin/dashboard/components/admin-dashboard-activity.component.scss create mode 100644 src/app/features/admin/dashboard/components/admin-dashboard-activity.component.ts create mode 100644 src/app/features/admin/dashboard/components/admin-dashboard-card.component.html create mode 100644 src/app/features/admin/dashboard/components/admin-dashboard-card.component.scss create mode 100644 src/app/features/admin/dashboard/components/admin-dashboard-card.component.ts create mode 100644 src/app/features/admin/dashboard/components/admin-dashboard-health.component.html create mode 100644 src/app/features/admin/dashboard/components/admin-dashboard-health.component.scss create mode 100644 src/app/features/admin/dashboard/components/admin-dashboard-health.component.ts create mode 100644 src/app/features/admin/dashboard/components/admin-dashboard-quick-actions.component.html create mode 100644 src/app/features/admin/dashboard/components/admin-dashboard-quick-actions.component.scss create mode 100644 src/app/features/admin/dashboard/components/admin-dashboard-quick-actions.component.ts create mode 100644 src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts create mode 100644 src/app/features/admin/dashboard/models/admin-dashboard.model.ts create mode 100644 src/app/features/admin/dashboard/pages/admin-dashboard-page.component.html create mode 100644 src/app/features/admin/dashboard/pages/admin-dashboard-page.component.scss create mode 100644 src/app/features/admin/dashboard/pages/admin-dashboard-page.component.ts create mode 100644 src/app/features/admin/dashboard/services/admin-dashboard-history.service.ts create mode 100644 src/app/features/admin/dashboard/services/admin-dashboard-metrics-gateway.token.ts create mode 100644 src/app/features/admin/dashboard/services/admin-dashboard-metrics.gateway.interface.ts create mode 100644 src/app/features/admin/dashboard/services/admin-dashboard-metrics.local.gateway.ts create mode 100644 src/app/features/backoffice/shared/backoffice-coming-soon-page.component.html create mode 100644 src/app/features/backoffice/shared/backoffice-coming-soon-page.component.scss create mode 100644 src/app/features/backoffice/shared/backoffice-coming-soon-page.component.ts diff --git a/docs/ADMIN.md b/docs/ADMIN.md new file mode 100644 index 0000000..a31bad2 --- /dev/null +++ b/docs/ADMIN.md @@ -0,0 +1,163 @@ +# Marketplace Admin Dashboard - Sprint 19 + +## Scope + +Sprint 19 adds the production Admin Dashboard and makes it the default landing +page for the admin area. It also wires the previously-unrouted `admin/products` +feature and adds route placeholders for backoffice sections that don't have a +feature built yet. + +## Routing + +All admin routes live under `/:lang/backoffice/**` (`app.routes.ts`), guarded +by the existing `adminAuthGuard` (`core/admin-auth/admin-auth.guard.ts`): + +```text +/:lang/backoffice -> redirects to dashboard +/:lang/backoffice/dashboard -> AdminDashboardPageComponent +/:lang/backoffice/products -> AdminProductsListPageComponent +/:lang/backoffice/products/create -> AdminProductEditorPageComponent +/:lang/backoffice/products/:id/edit -> AdminProductEditorPageComponent +/:lang/backoffice/products/:id/duplicate -> AdminProductEditorPageComponent +/:lang/backoffice/categories -> BackofficeComingSoonPageComponent +/:lang/backoffice/static-pages -> BackofficeComingSoonPageComponent +/:lang/backoffice/transactions -> BackofficeComingSoonPageComponent +/:lang/backoffice/orders -> BackofficeComingSoonPageComponent +/:lang/backoffice/media -> BackofficeComingSoonPageComponent +``` + +`admin/products` (`features/admin/products/`) was already fully implemented +in an earlier sprint but was never wired into `app.routes.ts` and its internal +navigation hardcoded the `ru` locale segment. Both are fixed in this sprint: +routes are wired, and `admin-products-list-page.component.ts` / +`admin-product-editor-page.component.ts` now build the locale segment from +`LanguageService.currentLanguage()`. + +**Dashboard as default admin page:** on successful admin Telegram QR login, +`TelegramLoginComponent` (`mode="admin"`) navigates to +`/:lang/backoffice/dashboard` (`components/telegram-login/telegram-login.component.ts`). +The `backoffice` route's empty path also redirects to `dashboard`, so any bare +`/:lang/backoffice` link lands there too. + +## Architecture + +```text +src/app/features/admin/dashboard/ + models/ admin-dashboard.model.ts + services/ admin-dashboard-metrics.gateway.interface.ts + admin-dashboard-metrics.local.gateway.ts + admin-dashboard-metrics-gateway.token.ts + admin-dashboard-history.service.ts + facade/ admin-dashboard.facade.ts + components/ admin-dashboard-card.component.* + admin-dashboard-quick-actions.component.* + admin-dashboard-activity.component.* + admin-dashboard-health.component.* + pages/ admin-dashboard-page.component.* + +src/app/features/backoffice/shared/ + backoffice-coming-soon-page.component.* +``` + +Follows the existing container/facade/service split (ADR-006, ADR-007): +`AdminDashboardPageComponent` is the container, `AdminDashboardFacade` owns +orchestration, presentational card/quick-actions/activity/health components +take only `@Input()`s and have no HttpClient/localStorage/route access. + +### Data sources (future-ready) + +Cards never read `ConfigService`, `localStorage`, or an HTTP client directly - +everything routes through `AdminDashboardFacade`, which composes: + +- **`ProjectEditorFacade`** (already existed) - `bootstrap`, `status`, + `lastSavedAt`, `lastPublishedAt` (new, see below), `validationIssues`, + `homepageWidgets`. Backs Marketplace Status, Project Name, Current Theme, + Languages, Last Publish, Last Draft Save, Bootstrap Version, Active Layout, + Enabled Widgets, and the System Health checks. +- **`ADMIN_DASHBOARD_METRICS_GATEWAY`** (new `InjectionToken`, same swap + pattern as `BACKOFFICE_DATA_PROVIDER`) - defaults to + `AdminDashboardMetricsLocalGateway`, which composes + `BackofficeDataService.loadCategories()/loadProducts()` (already used by + `AdminProductsLocalGateway`) into counts. Backs Categories Count and + Products Count. Swapping to a real dashboard-metrics endpoint later means + implementing `AdminDashboardMetricsGateway` and rebinding the token - the + facade and cards don't change. +- **`AdminDashboardHistoryService`** (new) - localStorage-backed activity log, + scoped per tenant, same pattern as `ProjectEditorDraftStorageService`. The + facade appends an entry whenever `lastSavedAt`/`lastPublishedAt` change + (detected via an `effect()`, primed on first read so the initial bootstrap + load doesn't get logged as an activity event). Backs Recent Activity. + +### Orders / Revenue + +No backend or local data model exists for orders or revenue anywhere in the +codebase (`features/backoffice/orders` is an empty placeholder folder). These +two cards render an honest **`pending-backend`** card state ("Awaiting backend +integration") rather than fabricated numbers - not a "no data" empty state, +since the gap is structural, not a temporarily-empty dataset. + +### Card states + +`AdminDashboardCardComponent` (`components/admin-dashboard-card.component.ts`) +renders one of: `loading` (skeleton), `empty`, `error`, `pending-backend`, or +the ready value + optional subtitle. The container computes each card's status +per data source (bootstrap not yet loaded -> `loading`; metrics gateway error +-> `error`; no supported locales -> `empty`; Orders/Revenue -> always +`pending-backend`). + +### System Health + +`ProjectValidator` (`features/project-editor/services/project-validator.service.ts`) +already covered 5 of the 6 required checks. This sprint added two more: + +- `translationIssues()` - flags a supported non-default locale missing a + header nav label translation or a static-page `translations` entry. +- `layoutIssues()` - flags `bootstrap.layout.type` or any section's + `layout.strategy` that isn't one of the known enum values + (`PlatformLayoutType` / `SectionLayoutStrategy`). Runtime validation matters + here because bootstrap JSON isn't type-checked at load time. + +Dashboard mapping (`AdminDashboardFacade.healthChecks`): + +| Dashboard label | Validator code | +|---|---| +| Bootstrap valid | structural: `bootstrap !== null && schemaVersion` set | +| Configuration valid | no validation issues at all | +| Missing translations | `missing-translations` (new) | +| Invalid colors | `invalid-colors` (existing) | +| Invalid widget references | `missing-widget` (existing - a homepage widget with no `type`) | +| Invalid layouts | `invalid-layouts` (new) | + +### Quick Actions + +Static list in `AdminDashboardFacade` (`route` arrays relative to the lang +root); the page component prefixes the current locale +(`LanguageService.currentLanguage()`) before binding `routerLink`. Categories, +Static Pages, Transactions, Orders, and Media Library currently land on +`BackofficeComingSoonPageComponent` since those features aren't built yet - +this is a routing placeholder, not a dashboard card placeholder. + +### `lastPublishedAt` (ProjectEditorFacade change) + +Before this sprint, `publish()` only updated `lastSavedAt`, so "last draft +save" and "last publish" were indistinguishable after a publish. Added +`lastPublishedAt: number | null` to `ProjectEditorState` / +`ProjectEditorFacade`, set only inside `publish()`. `lastSavedAt` behavior is +unchanged (still updated by both `save()` and `publish()`). + +## Known gaps / backend needs + +- **Dashboard metrics endpoint.** Categories/Products counts are computed + client-side from `BackofficeDataService` (itself mock/API-switchable via + `BACKOFFICE_DATA_PROVIDER`). A dedicated `/builder/dashboard/summary`-style + endpoint would let `AdminDashboardMetricsGateway` return richer data + (real-time counts, trend deltas) without touching the facade or cards. +- **Orders/Revenue have no backend at all** (see above) - needs an order + domain and revenue aggregation before these cards can show real data. +- **Recent Activity is local-only**, scoped to the browser/tenant via + localStorage (`adminDashboard.activityHistory.v1`), same limitation as the + existing draft-save local storage. It will not show another editor's + activity until a real audit-log endpoint exists. +- **Admin authorization is still not enforced server-side** (see + `Project-Editor.md` - "Admin Authentication" section); this sprint does not + change that. Nothing new here beyond routing/dashboard. diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 4c6ae9b..dd94511 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -1,6 +1,7 @@ import { Routes } from '@angular/router'; import { languageGuard } from './guards/language.guard'; import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard'; +import { adminAuthGuard } from './core/admin-auth/admin-auth.guard'; import { environment } from '../environments/environment'; // Core routes (same across all brands) @@ -45,6 +46,59 @@ const coreRoutes: Routes = [ loadComponent: () => import('./features/project-editor/pages/project-editor-page.component').then(m => m.ProjectEditorPageComponent), canDeactivate: [projectEditorDirtyGuard] }, + { + path: 'backoffice', + canActivate: [adminAuthGuard], + children: [ + { path: '', redirectTo: 'dashboard', pathMatch: 'full' }, + { + path: 'dashboard', + loadComponent: () => import('./features/admin/dashboard/pages/admin-dashboard-page.component').then(m => m.AdminDashboardPageComponent) + }, + { + path: 'products', + loadComponent: () => import('./features/admin/products/pages/admin-products-list-page.component').then(m => m.AdminProductsListPageComponent) + }, + { + path: 'products/create', + loadComponent: () => import('./features/admin/products/pages/admin-product-editor-page.component').then(m => m.AdminProductEditorPageComponent) + }, + { + path: 'products/:id/edit', + loadComponent: () => import('./features/admin/products/pages/admin-product-editor-page.component').then(m => m.AdminProductEditorPageComponent) + }, + { + path: 'products/:id/duplicate', + loadComponent: () => import('./features/admin/products/pages/admin-product-editor-page.component').then(m => m.AdminProductEditorPageComponent) + }, + { + path: 'categories', + loadComponent: () => import('./features/backoffice/shared/backoffice-coming-soon-page.component').then(m => m.BackofficeComingSoonPageComponent), + data: { titleKey: 'dashboard.actionCategories' } + }, + { + path: 'static-pages', + loadComponent: () => import('./features/backoffice/shared/backoffice-coming-soon-page.component').then(m => m.BackofficeComingSoonPageComponent), + data: { titleKey: 'dashboard.actionStaticPages' } + }, + { + path: 'transactions', + loadComponent: () => import('./features/backoffice/shared/backoffice-coming-soon-page.component').then(m => m.BackofficeComingSoonPageComponent), + data: { titleKey: 'dashboard.actionTransactions' } + }, + { + path: 'orders', + loadComponent: () => import('./features/backoffice/shared/backoffice-coming-soon-page.component').then(m => m.BackofficeComingSoonPageComponent), + data: { titleKey: 'dashboard.actionOrders' } + }, + { + path: 'media', + loadComponent: () => import('./features/backoffice/shared/backoffice-coming-soon-page.component').then(m => m.BackofficeComingSoonPageComponent), + data: { titleKey: 'dashboard.actionMediaLibrary' } + }, + { path: '**', redirectTo: 'dashboard' } + ] + }, { path: 'edit', redirectTo: 'edit/general', diff --git a/src/app/components/telegram-login/telegram-login.component.ts b/src/app/components/telegram-login/telegram-login.component.ts index 8d9ac51..61d6f2e 100644 --- a/src/app/components/telegram-login/telegram-login.component.ts +++ b/src/app/components/telegram-login/telegram-login.component.ts @@ -1,6 +1,8 @@ import { Component, ChangeDetectionStrategy, Input, Injector, Signal, inject, effect, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; import { AuthService } from '../../services/auth.service'; import { AdminAuthService } from '../../core/admin-auth/admin-auth.service'; +import { LanguageService } from '../../services/language.service'; import { TranslatePipe } from '../../i18n/translate.pipe'; import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine'; import { QrLoginAdapter, QrLoginStatus } from '../../shared/qr-login/qr-login.model'; @@ -27,6 +29,8 @@ export class TelegramLoginComponent implements OnInit, OnDestroy { private readonly customerAuth = inject(AuthService); private readonly adminAuth = inject(AdminAuthService); private readonly injector = inject(Injector); + private readonly router = inject(Router); + private readonly languageService = inject(LanguageService); private engine!: QrLoginEngine; @@ -49,7 +53,10 @@ export class TelegramLoginComponent implements OnInit, OnDestroy { checkSessionOnce: id => this.adminAuth.checkSessionOnce(id), isSessionActive: session => !!session?.active, getAppLoginUrl: id => this.adminAuth.getAdminAppLoginUrl(id), - onLoginComplete: () => this.adminAuth.onLoginComplete(), + onLoginComplete: () => { + this.adminAuth.onLoginComplete(); + void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'dashboard']); + }, } : { createSession: () => this.customerAuth.createWebSession(), diff --git a/src/app/features/admin/dashboard/components/admin-dashboard-activity.component.html b/src/app/features/admin/dashboard/components/admin-dashboard-activity.component.html new file mode 100644 index 0000000..2a0f798 --- /dev/null +++ b/src/app/features/admin/dashboard/components/admin-dashboard-activity.component.html @@ -0,0 +1,12 @@ +
+

{{ 'dashboard.activityTitle' | translate }}

+ +

{{ 'dashboard.activityEmpty' | translate }}

+ +
    +
  • + {{ entry.labelKey | translate }} + {{ entry.timeText }} +
  • +
+
diff --git a/src/app/features/admin/dashboard/components/admin-dashboard-activity.component.scss b/src/app/features/admin/dashboard/components/admin-dashboard-activity.component.scss new file mode 100644 index 0000000..fb0ab89 --- /dev/null +++ b/src/app/features/admin/dashboard/components/admin-dashboard-activity.component.scss @@ -0,0 +1,42 @@ +.dashboard-activity__title { + margin: 0 0 12px; + font-size: 16px; + font-weight: 700; + color: var(--text-primary, #1e3c38); +} + +.dashboard-activity__empty { + margin: 0; + font-size: 14px; + color: var(--text-light, #828e8d); +} + +.dashboard-activity__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.dashboard-activity__item { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--border-color, #d3dad9); + border-radius: var(--radius-sm, 8px); + background: var(--bg-secondary, #f5f5f5); + font-size: 14px; +} + +.dashboard-activity__label { + color: var(--text-primary, #1e3c38); + font-weight: 600; +} + +.dashboard-activity__time { + color: var(--text-light, #828e8d); + white-space: nowrap; +} diff --git a/src/app/features/admin/dashboard/components/admin-dashboard-activity.component.ts b/src/app/features/admin/dashboard/components/admin-dashboard-activity.component.ts new file mode 100644 index 0000000..8db9362 --- /dev/null +++ b/src/app/features/admin/dashboard/components/admin-dashboard-activity.component.ts @@ -0,0 +1,21 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; + +export interface AdminDashboardActivityViewEntry { + id: string; + labelKey: string; + timeText: string; +} + +@Component({ + selector: 'app-admin-dashboard-activity', + standalone: true, + imports: [CommonModule, TranslatePipe], + templateUrl: './admin-dashboard-activity.component.html', + styleUrls: ['./admin-dashboard-activity.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AdminDashboardActivityComponent { + @Input() entries: AdminDashboardActivityViewEntry[] = []; +} diff --git a/src/app/features/admin/dashboard/components/admin-dashboard-card.component.html b/src/app/features/admin/dashboard/components/admin-dashboard-card.component.html new file mode 100644 index 0000000..502ec6b --- /dev/null +++ b/src/app/features/admin/dashboard/components/admin-dashboard-card.component.html @@ -0,0 +1,23 @@ +
+
+ +

{{ title }}

+
+ +
+ +
+ +

{{ 'dashboard.stateEmpty' | translate }}

+ +

{{ 'dashboard.stateError' | translate }}

+ +

{{ 'dashboard.statePendingBackend' | translate }}

+ + +

{{ value }}

+

{{ subtitle }}

+
+
+
+
diff --git a/src/app/features/admin/dashboard/components/admin-dashboard-card.component.scss b/src/app/features/admin/dashboard/components/admin-dashboard-card.component.scss new file mode 100644 index 0000000..48b87f5 --- /dev/null +++ b/src/app/features/admin/dashboard/components/admin-dashboard-card.component.scss @@ -0,0 +1,83 @@ +.dashboard-card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 18px; + border: 1px solid var(--border-color, #d3dad9); + border-radius: var(--radius-md, 12px); + background: var(--bg-primary, #fff); + box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.06)); + min-height: 108px; +} + +.dashboard-card--error { + border-color: var(--error-color, #ef4444); +} + +.dashboard-card--pending { + opacity: 0.75; +} + +.dashboard-card__header { + display: flex; + align-items: center; + gap: 8px; +} + +.dashboard-card__icon { + font-size: 18px; + line-height: 1; +} + +.dashboard-card__title { + margin: 0; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary, #667a77); + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.dashboard-card__body { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dashboard-card__value { + margin: 0; + font-size: 24px; + font-weight: 700; + color: var(--text-primary, #1e3c38); + word-break: break-word; +} + +.dashboard-card__subtitle { + margin: 0; + font-size: 13px; + color: var(--text-light, #828e8d); +} + +.dashboard-card__muted { + margin: 0; + font-size: 14px; + color: var(--text-light, #828e8d); +} + +.dashboard-card__muted--error { + color: var(--error-color, #ef4444); +} + +.dashboard-card__skeleton { + height: 24px; + width: 60%; + border-radius: var(--radius-sm, 8px); + background: linear-gradient(90deg, var(--bg-secondary, #f5f5f5) 25%, var(--bg-tertiary, #f0f0f0) 37%, var(--bg-secondary, #f5f5f5) 63%); + background-size: 400% 100%; + animation: dashboard-card-shimmer 1.4s ease infinite; +} + +@keyframes dashboard-card-shimmer { + 0% { background-position: 100% 50%; } + 100% { background-position: 0 50%; } +} diff --git a/src/app/features/admin/dashboard/components/admin-dashboard-card.component.ts b/src/app/features/admin/dashboard/components/admin-dashboard-card.component.ts new file mode 100644 index 0000000..d689db5 --- /dev/null +++ b/src/app/features/admin/dashboard/components/admin-dashboard-card.component.ts @@ -0,0 +1,20 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { AdminDashboardCardStatus } from '../models/admin-dashboard.model'; + +@Component({ + selector: 'app-admin-dashboard-card', + standalone: true, + imports: [CommonModule, TranslatePipe], + templateUrl: './admin-dashboard-card.component.html', + styleUrls: ['./admin-dashboard-card.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AdminDashboardCardComponent { + @Input() title = ''; + @Input() status: AdminDashboardCardStatus = 'ready'; + @Input() value: string | null = null; + @Input() subtitle: string | null = null; + @Input() icon: string | null = null; +} diff --git a/src/app/features/admin/dashboard/components/admin-dashboard-health.component.html b/src/app/features/admin/dashboard/components/admin-dashboard-health.component.html new file mode 100644 index 0000000..4f63c83 --- /dev/null +++ b/src/app/features/admin/dashboard/components/admin-dashboard-health.component.html @@ -0,0 +1,12 @@ +
+

{{ 'dashboard.healthTitle' | translate }}

+ +

{{ 'dashboard.healthAllClear' | translate }}

+ +
    +
  • + + {{ check.labelKey | translate }} +
  • +
+
diff --git a/src/app/features/admin/dashboard/components/admin-dashboard-health.component.scss b/src/app/features/admin/dashboard/components/admin-dashboard-health.component.scss new file mode 100644 index 0000000..ca35a52 --- /dev/null +++ b/src/app/features/admin/dashboard/components/admin-dashboard-health.component.scss @@ -0,0 +1,51 @@ +.dashboard-health__title { + margin: 0 0 12px; + font-size: 16px; + font-weight: 700; + color: var(--text-primary, #1e3c38); +} + +.dashboard-health__all-clear { + margin: 0 0 8px; + font-size: 14px; + color: var(--success-color, #10b981); + font-weight: 600; +} + +.dashboard-health__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 8px 16px; +} + +.dashboard-health__item { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--text-primary, #1e3c38); +} + +.dashboard-health__item--warning { + color: var(--warning-color, #f59e0b); + font-weight: 600; +} + +.dashboard-health__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success-color, #10b981); + flex: none; +} + +.dashboard-health__dot--warning { + background: var(--warning-color, #f59e0b); +} + +@media (max-width: 640px) { + .dashboard-health__list { grid-template-columns: 1fr; } +} diff --git a/src/app/features/admin/dashboard/components/admin-dashboard-health.component.ts b/src/app/features/admin/dashboard/components/admin-dashboard-health.component.ts new file mode 100644 index 0000000..d2a01c9 --- /dev/null +++ b/src/app/features/admin/dashboard/components/admin-dashboard-health.component.ts @@ -0,0 +1,20 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { AdminDashboardHealthCheck } from '../models/admin-dashboard.model'; + +@Component({ + selector: 'app-admin-dashboard-health', + standalone: true, + imports: [CommonModule, TranslatePipe], + templateUrl: './admin-dashboard-health.component.html', + styleUrls: ['./admin-dashboard-health.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AdminDashboardHealthComponent { + @Input() checks: AdminDashboardHealthCheck[] = []; + + get hasIssues(): boolean { + return this.checks.some(check => !check.healthy); + } +} diff --git a/src/app/features/admin/dashboard/components/admin-dashboard-quick-actions.component.html b/src/app/features/admin/dashboard/components/admin-dashboard-quick-actions.component.html new file mode 100644 index 0000000..81eb093 --- /dev/null +++ b/src/app/features/admin/dashboard/components/admin-dashboard-quick-actions.component.html @@ -0,0 +1,10 @@ +
+

{{ 'dashboard.quickActionsTitle' | translate }}

+ +
diff --git a/src/app/features/admin/dashboard/components/admin-dashboard-quick-actions.component.scss b/src/app/features/admin/dashboard/components/admin-dashboard-quick-actions.component.scss new file mode 100644 index 0000000..057bebd --- /dev/null +++ b/src/app/features/admin/dashboard/components/admin-dashboard-quick-actions.component.scss @@ -0,0 +1,43 @@ +.dashboard-quick-actions__title { + margin: 0 0 12px; + font-size: 16px; + font-weight: 700; + color: var(--text-primary, #1e3c38); +} + +.dashboard-quick-actions__grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +.dashboard-quick-actions__item { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + min-height: 56px; + padding: 10px 12px; + border: 1px solid var(--border-color, #d3dad9); + border-radius: var(--radius-md, 12px); + background: var(--bg-primary, #fff); + color: var(--text-primary, #1e3c38); + font-weight: 600; + font-size: 14px; + text-decoration: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.dashboard-quick-actions__item:hover, +.dashboard-quick-actions__item:focus-visible { + border-color: var(--primary-color, #497671); + box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.1)); +} + +@media (max-width: 960px) { + .dashboard-quick-actions__grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 560px) { + .dashboard-quick-actions__grid { grid-template-columns: 1fr; } +} diff --git a/src/app/features/admin/dashboard/components/admin-dashboard-quick-actions.component.ts b/src/app/features/admin/dashboard/components/admin-dashboard-quick-actions.component.ts new file mode 100644 index 0000000..534e97a --- /dev/null +++ b/src/app/features/admin/dashboard/components/admin-dashboard-quick-actions.component.ts @@ -0,0 +1,17 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { AdminDashboardQuickAction } from '../models/admin-dashboard.model'; + +@Component({ + selector: 'app-admin-dashboard-quick-actions', + standalone: true, + imports: [CommonModule, RouterLink, TranslatePipe], + templateUrl: './admin-dashboard-quick-actions.component.html', + styleUrls: ['./admin-dashboard-quick-actions.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AdminDashboardQuickActionsComponent { + @Input() actions: AdminDashboardQuickAction[] = []; +} diff --git a/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts b/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts new file mode 100644 index 0000000..3196a04 --- /dev/null +++ b/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts @@ -0,0 +1,125 @@ +import { Injectable, computed, effect, inject, signal } from '@angular/core'; +import { take } from 'rxjs/operators'; +import { ProjectEditorFacade } from '../../../project-editor/facade/project-editor.facade'; +import { ADMIN_DASHBOARD_METRICS_GATEWAY } from '../services/admin-dashboard-metrics-gateway.token'; +import { AdminDashboardHistoryService } from '../services/admin-dashboard-history.service'; +import { + AdminDashboardCardState, + AdminDashboardHealthCheck, + AdminDashboardMetrics, + AdminDashboardQuickAction, +} from '../models/admin-dashboard.model'; + +const QUICK_ACTIONS: AdminDashboardQuickAction[] = [ + { id: 'edit-project', labelKey: 'dashboard.actionEditProject', route: ['edit', 'general'] }, + { id: 'categories', labelKey: 'dashboard.actionCategories', route: ['backoffice', 'categories'] }, + { id: 'products', labelKey: 'dashboard.actionProducts', route: ['backoffice', 'products'] }, + { id: 'static-pages', labelKey: 'dashboard.actionStaticPages', route: ['backoffice', 'static-pages'] }, + { id: 'transactions', labelKey: 'dashboard.actionTransactions', route: ['backoffice', 'transactions'] }, + { id: 'orders', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] }, + { id: 'media-library', labelKey: 'dashboard.actionMediaLibrary', route: ['backoffice', 'media'] }, + { id: 'preview-marketplace', labelKey: 'dashboard.actionPreviewMarketplace', route: [''] }, +]; + +/** + * Composes ProjectEditorFacade (bootstrap/status/save-publish timestamps/validation) + * with dashboard-only metrics/history so the page component stays presentational. + * Cards read through here, never directly from ConfigService or localStorage - + * swapping local sources for real backend endpoints only touches this facade + * and the gateways it calls, per ADR-006/007. + */ +@Injectable({ providedIn: 'root' }) +export class AdminDashboardFacade { + private readonly projectEditor = inject(ProjectEditorFacade); + private readonly metricsGateway = inject(ADMIN_DASHBOARD_METRICS_GATEWAY); + private readonly history = inject(AdminDashboardHistoryService); + + private readonly metricsState = signal>({ status: 'loading', value: null }); + private readonly activityTick = signal(0); + + readonly bootstrap = this.projectEditor.bootstrap; + readonly status = this.projectEditor.status; + readonly lastSavedAt = this.projectEditor.lastSavedAt; + readonly lastPublishedAt = this.projectEditor.lastPublishedAt; + readonly validationIssues = this.projectEditor.validationIssues; + readonly homepageWidgets = this.projectEditor.homepageWidgets; + readonly metrics = this.metricsState.asReadonly(); + + readonly quickActions: AdminDashboardQuickAction[] = QUICK_ACTIONS; + + readonly enabledWidgetsCount = computed(() => this.homepageWidgets().length); + + readonly activityEntries = computed(() => { + this.activityTick(); + const tenantId = this.bootstrap()?.tenant.id; + return tenantId ? this.history.list(tenantId) : []; + }); + + readonly healthChecks = computed(() => { + const current = this.bootstrap(); + const issues = new Set(this.validationIssues().map(issue => issue.code)); + return [ + { code: 'bootstrap-valid', labelKey: 'dashboard.healthBootstrapValid', healthy: !!current?.schemaVersion }, + { code: 'configuration-valid', labelKey: 'dashboard.healthConfigurationValid', healthy: issues.size === 0 }, + { code: 'missing-translations', labelKey: 'dashboard.healthMissingTranslations', healthy: !issues.has('missing-translations') }, + { code: 'invalid-colors', labelKey: 'dashboard.healthInvalidColors', healthy: !issues.has('invalid-colors') }, + { code: 'invalid-widget-references', labelKey: 'dashboard.healthInvalidWidgetReferences', healthy: !issues.has('missing-widget') }, + { code: 'invalid-layouts', labelKey: 'dashboard.healthInvalidLayouts', healthy: !issues.has('invalid-layouts') }, + ]; + }); + + private lastRecordedSavedAt: number | null = null; + private lastRecordedPublishedAt: number | null = null; + private historyPrimed = false; + + constructor() { + effect(() => { + const tenantId = this.bootstrap()?.tenant.id; + const savedAt = this.lastSavedAt(); + const publishedAt = this.lastPublishedAt(); + if (!tenantId) { + return; + } + + if (!this.historyPrimed) { + this.historyPrimed = true; + this.lastRecordedSavedAt = savedAt; + this.lastRecordedPublishedAt = publishedAt; + return; + } + + let recorded = false; + if (savedAt !== null && savedAt !== this.lastRecordedSavedAt) { + this.lastRecordedSavedAt = savedAt; + this.history.record(tenantId, 'draft-saved', savedAt); + recorded = true; + } + if (publishedAt !== null && publishedAt !== this.lastRecordedPublishedAt) { + this.lastRecordedPublishedAt = publishedAt; + this.history.record(tenantId, 'published', publishedAt); + recorded = true; + } + if (recorded) { + this.activityTick.update(tick => tick + 1); + } + }); + } + + ensureLoaded(): void { + if (!this.bootstrap()) { + this.projectEditor.loadBootstrap(); + } + this.loadMetrics(); + } + + loadMetrics(): void { + this.metricsState.set({ status: 'loading', value: null }); + this.metricsGateway.loadMetrics().pipe(take(1)).subscribe({ + next: metrics => this.metricsState.set({ + status: metrics.categoriesCount === 0 && metrics.productsCount === 0 ? 'empty' : 'ready', + value: metrics, + }), + error: () => this.metricsState.set({ status: 'error', value: null }), + }); + } +} diff --git a/src/app/features/admin/dashboard/models/admin-dashboard.model.ts b/src/app/features/admin/dashboard/models/admin-dashboard.model.ts new file mode 100644 index 0000000..b7c8013 --- /dev/null +++ b/src/app/features/admin/dashboard/models/admin-dashboard.model.ts @@ -0,0 +1,42 @@ +export type AdminDashboardCardStatus = 'loading' | 'ready' | 'empty' | 'error' | 'pending-backend'; + +export interface AdminDashboardCardState { + status: AdminDashboardCardStatus; + value: T | null; +} + +export interface AdminDashboardMetrics { + categoriesCount: number; + productsCount: number; +} + +export type AdminDashboardQuickActionId = + | 'edit-project' + | 'categories' + | 'products' + | 'static-pages' + | 'transactions' + | 'orders' + | 'media-library' + | 'preview-marketplace'; + +export interface AdminDashboardQuickAction { + id: AdminDashboardQuickActionId; + labelKey: string; + route: string[]; + external?: boolean; +} + +export type AdminDashboardActivityType = 'draft-saved' | 'published'; + +export interface AdminDashboardActivityEntry { + id: string; + type: AdminDashboardActivityType; + timestamp: number; +} + +export interface AdminDashboardHealthCheck { + code: string; + labelKey: string; + healthy: boolean; +} diff --git a/src/app/features/admin/dashboard/pages/admin-dashboard-page.component.html b/src/app/features/admin/dashboard/pages/admin-dashboard-page.component.html new file mode 100644 index 0000000..0f7c750 --- /dev/null +++ b/src/app/features/admin/dashboard/pages/admin-dashboard-page.component.html @@ -0,0 +1,23 @@ +
+
+

{{ 'dashboard.title' | translate }}

+

{{ 'dashboard.subtitle' | translate }}

+
+ +
+ +
+ + + +
+ + +
+
diff --git a/src/app/features/admin/dashboard/pages/admin-dashboard-page.component.scss b/src/app/features/admin/dashboard/pages/admin-dashboard-page.component.scss new file mode 100644 index 0000000..f448564 --- /dev/null +++ b/src/app/features/admin/dashboard/pages/admin-dashboard-page.component.scss @@ -0,0 +1,64 @@ +.dashboard-page { + display: flex; + flex-direction: column; + gap: 24px; + padding: 20px; + max-width: 1280px; + margin: 0 auto; +} + +.dashboard-page__header { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dashboard-page__title { + margin: 0; + font-size: 26px; + font-weight: 800; + color: var(--text-primary, #1e3c38); +} + +.dashboard-page__subtitle { + margin: 0; + font-size: 14px; + color: var(--text-secondary, #667a77); +} + +.dashboard-page__cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +.dashboard-page__section { + display: block; + padding: 18px; + border: 1px solid var(--border-color, #d3dad9); + border-radius: var(--radius-md, 12px); + background: var(--bg-primary, #fff); +} + +.dashboard-page__row { + display: grid; + grid-template-columns: 1.4fr 1fr; + gap: 16px; +} + +@media (max-width: 1100px) { + .dashboard-page__cards { grid-template-columns: repeat(3, 1fr); } +} + +@media (max-width: 900px) { + .dashboard-page__row { grid-template-columns: 1fr; } +} + +@media (max-width: 720px) { + .dashboard-page__cards { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 480px) { + .dashboard-page { padding: 12px; gap: 16px; } + .dashboard-page__cards { grid-template-columns: 1fr; } +} diff --git a/src/app/features/admin/dashboard/pages/admin-dashboard-page.component.ts b/src/app/features/admin/dashboard/pages/admin-dashboard-page.component.ts new file mode 100644 index 0000000..b941293 --- /dev/null +++ b/src/app/features/admin/dashboard/pages/admin-dashboard-page.component.ts @@ -0,0 +1,171 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TranslateService } from '../../../../i18n/translate.service'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { LanguageService } from '../../../../services/language.service'; +import { AdminDashboardFacade } from '../facade/admin-dashboard.facade'; +import { AdminDashboardCardComponent } from '../components/admin-dashboard-card.component'; +import { AdminDashboardQuickActionsComponent } from '../components/admin-dashboard-quick-actions.component'; +import { AdminDashboardActivityComponent, AdminDashboardActivityViewEntry } from '../components/admin-dashboard-activity.component'; +import { AdminDashboardHealthComponent } from '../components/admin-dashboard-health.component'; +import { AdminDashboardCardStatus, AdminDashboardQuickAction } from '../models/admin-dashboard.model'; + +interface DashboardCardViewModel { + id: string; + titleKey: string; + status: AdminDashboardCardStatus; + value: string | null; + subtitle: string | null; +} + +@Component({ + selector: 'app-admin-dashboard-page', + standalone: true, + imports: [ + CommonModule, + TranslatePipe, + AdminDashboardCardComponent, + AdminDashboardQuickActionsComponent, + AdminDashboardActivityComponent, + AdminDashboardHealthComponent, + ], + templateUrl: './admin-dashboard-page.component.html', + styleUrls: ['./admin-dashboard-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AdminDashboardPageComponent { + readonly facade = inject(AdminDashboardFacade); + private readonly translate = inject(TranslateService); + private readonly languageService = inject(LanguageService); + + readonly quickActions = computed(() => { + const lang = this.languageService.currentLanguage(); + return this.facade.quickActions.map(action => ({ + ...action, + route: ['/', lang, ...action.route.filter(segment => segment !== '')], + })); + }); + + readonly activityEntries = computed(() => + this.facade.activityEntries().map(entry => ({ + id: entry.id, + labelKey: entry.type === 'published' ? 'dashboard.activityPublished' : 'dashboard.activityDraftSaved', + timeText: new Date(entry.timestamp).toLocaleString(), + })), + ); + + readonly healthChecks = this.facade.healthChecks; + + readonly cards = computed(() => { + const bootstrap = this.facade.bootstrap(); + const bootstrapLoading: AdminDashboardCardStatus = bootstrap ? 'ready' : 'loading'; + const metrics = this.facade.metrics(); + const locales = bootstrap?.localization.supportedLocales ?? []; + + return [ + { + id: 'marketplace-status', + titleKey: 'dashboard.cardMarketplaceStatus', + status: bootstrapLoading, + value: bootstrap ? this.translate.t(this.facade.status() === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') : null, + subtitle: null, + }, + { + id: 'project-name', + titleKey: 'dashboard.cardProjectName', + status: bootstrapLoading, + value: bootstrap?.tenant.name ?? null, + subtitle: null, + }, + { + id: 'current-theme', + titleKey: 'dashboard.cardCurrentTheme', + status: bootstrapLoading, + value: bootstrap?.theme.themeId ?? null, + subtitle: null, + }, + { + id: 'languages', + titleKey: 'dashboard.cardLanguages', + status: bootstrapLoading === 'loading' ? 'loading' : (locales.length === 0 ? 'empty' : 'ready'), + value: locales.length ? locales.join(', ').toUpperCase() : null, + subtitle: null, + }, + { + id: 'categories-count', + titleKey: 'dashboard.cardCategoriesCount', + status: metrics.status, + value: metrics.value ? String(metrics.value.categoriesCount) : null, + subtitle: null, + }, + { + id: 'products-count', + titleKey: 'dashboard.cardProductsCount', + status: metrics.status, + value: metrics.value ? String(metrics.value.productsCount) : null, + subtitle: null, + }, + { + id: 'orders', + titleKey: 'dashboard.cardOrders', + status: 'pending-backend', + value: null, + subtitle: null, + }, + { + id: 'revenue', + titleKey: 'dashboard.cardRevenue', + status: 'pending-backend', + value: null, + subtitle: null, + }, + { + id: 'last-publish', + titleKey: 'dashboard.cardLastPublish', + status: bootstrapLoading, + value: bootstrap ? this.formatTimestamp(this.facade.lastPublishedAt()) : null, + subtitle: null, + }, + { + id: 'last-draft-save', + titleKey: 'dashboard.cardLastDraftSave', + status: bootstrapLoading, + value: bootstrap ? this.formatTimestamp(this.facade.lastSavedAt()) : null, + subtitle: null, + }, + { + id: 'bootstrap-version', + titleKey: 'dashboard.cardBootstrapVersion', + status: bootstrapLoading, + value: bootstrap?.schemaVersion ?? null, + subtitle: null, + }, + { + id: 'active-layout', + titleKey: 'dashboard.cardActiveLayout', + status: bootstrapLoading, + value: bootstrap ? (bootstrap.layout?.type ?? 'default') : null, + subtitle: null, + }, + { + id: 'enabled-widgets', + titleKey: 'dashboard.cardEnabledWidgets', + status: bootstrapLoading, + value: bootstrap ? String(this.facade.enabledWidgetsCount()) : null, + subtitle: null, + }, + ]; + }); + + constructor() { + this.facade.ensureLoaded(); + } + + cardTitle(titleKey: string): string { + return this.translate.t(titleKey); + } + + private formatTimestamp(timestamp: number | null): string { + return timestamp ? new Date(timestamp).toLocaleString() : this.translate.t('dashboard.never'); + } +} diff --git a/src/app/features/admin/dashboard/services/admin-dashboard-history.service.ts b/src/app/features/admin/dashboard/services/admin-dashboard-history.service.ts new file mode 100644 index 0000000..be63f2d --- /dev/null +++ b/src/app/features/admin/dashboard/services/admin-dashboard-history.service.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; +import { AdminDashboardActivityEntry, AdminDashboardActivityType } from '../models/admin-dashboard.model'; + +const HISTORY_STORAGE_KEY = 'adminDashboard.activityHistory.v1'; +const MAX_ENTRIES = 20; + +interface StoredHistory { + tenantId: string; + entries: AdminDashboardActivityEntry[]; +} + +/** Local publish/save activity log, scoped per tenant. Same localStorage pattern as ProjectEditorDraftStorageService - a real backend can later replace the read side without changing callers. */ +@Injectable({ providedIn: 'root' }) +export class AdminDashboardHistoryService { + record(tenantId: string, type: AdminDashboardActivityType, timestamp: number): void { + const entries = [ + { id: `${type}-${timestamp}`, type, timestamp }, + ...this.list(tenantId), + ].slice(0, MAX_ENTRIES); + + this.persist(tenantId, entries); + } + + list(tenantId: string): AdminDashboardActivityEntry[] { + try { + const raw = localStorage.getItem(HISTORY_STORAGE_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw) as StoredHistory; + return parsed.tenantId === tenantId ? parsed.entries : []; + } catch { + return []; + } + } + + private persist(tenantId: string, entries: AdminDashboardActivityEntry[]): void { + try { + const payload: StoredHistory = { tenantId, entries }; + localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(payload)); + } catch { + // storage unavailable (private mode / quota) - history simply won't persist + } + } +} diff --git a/src/app/features/admin/dashboard/services/admin-dashboard-metrics-gateway.token.ts b/src/app/features/admin/dashboard/services/admin-dashboard-metrics-gateway.token.ts new file mode 100644 index 0000000..554acc8 --- /dev/null +++ b/src/app/features/admin/dashboard/services/admin-dashboard-metrics-gateway.token.ts @@ -0,0 +1,9 @@ +import { InjectionToken, inject } from '@angular/core'; +import { AdminDashboardMetricsGateway } from './admin-dashboard-metrics.gateway.interface'; +import { AdminDashboardMetricsLocalGateway } from './admin-dashboard-metrics.local.gateway'; + +/** Swap point for a future dedicated dashboard-metrics backend endpoint - today it composes existing backoffice data sources. */ +export const ADMIN_DASHBOARD_METRICS_GATEWAY = new InjectionToken('ADMIN_DASHBOARD_METRICS_GATEWAY', { + providedIn: 'root', + factory: () => inject(AdminDashboardMetricsLocalGateway), +}); diff --git a/src/app/features/admin/dashboard/services/admin-dashboard-metrics.gateway.interface.ts b/src/app/features/admin/dashboard/services/admin-dashboard-metrics.gateway.interface.ts new file mode 100644 index 0000000..95ca3e3 --- /dev/null +++ b/src/app/features/admin/dashboard/services/admin-dashboard-metrics.gateway.interface.ts @@ -0,0 +1,6 @@ +import { Observable } from 'rxjs'; +import { AdminDashboardMetrics } from '../models/admin-dashboard.model'; + +export interface AdminDashboardMetricsGateway { + loadMetrics(): Observable; +} diff --git a/src/app/features/admin/dashboard/services/admin-dashboard-metrics.local.gateway.ts b/src/app/features/admin/dashboard/services/admin-dashboard-metrics.local.gateway.ts new file mode 100644 index 0000000..209c030 --- /dev/null +++ b/src/app/features/admin/dashboard/services/admin-dashboard-metrics.local.gateway.ts @@ -0,0 +1,23 @@ +import { Injectable, inject } from '@angular/core'; +import { Observable, forkJoin, map } from 'rxjs'; +import { BackofficeDataService } from '../../../../core/backoffice/backoffice-data.service'; +import { AdminDashboardMetrics } from '../models/admin-dashboard.model'; +import { AdminDashboardMetricsGateway } from './admin-dashboard-metrics.gateway.interface'; + +/** Local composition of existing backoffice data sources. Replace with an HTTP gateway once the backend exposes dashboard metrics endpoints - the facade contract stays the same. */ +@Injectable({ providedIn: 'root' }) +export class AdminDashboardMetricsLocalGateway implements AdminDashboardMetricsGateway { + private readonly backofficeData = inject(BackofficeDataService); + + loadMetrics(): Observable { + return forkJoin({ + categories: this.backofficeData.loadCategories(), + products: this.backofficeData.loadProducts(), + }).pipe( + map(({ categories, products }) => ({ + categoriesCount: categories.length, + productsCount: products.length, + })), + ); + } +} diff --git a/src/app/features/admin/products/pages/admin-product-editor-page.component.ts b/src/app/features/admin/products/pages/admin-product-editor-page.component.ts index eccc007..e7572ab 100644 --- a/src/app/features/admin/products/pages/admin-product-editor-page.component.ts +++ b/src/app/features/admin/products/pages/admin-product-editor-page.component.ts @@ -3,6 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router'; import { AdminProductsFacade } from '../facade/admin-products.facade'; import { AdminProductFormComponent } from '../components/admin-product-form.component'; import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { LanguageService } from '../../../../services/language.service'; @Component({ selector: 'app-admin-product-editor-page', @@ -16,6 +17,7 @@ export class AdminProductEditorPageComponent { readonly facade = inject(AdminProductsFacade); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + private readonly languageService = inject(LanguageService); readonly title = computed(() => this.facade.editorMode() === 'create' ? 'adminProducts.create' : this.facade.editorMode() === 'duplicate' ? 'adminProducts.duplicate' : 'adminProducts.edit'); constructor() { @@ -31,6 +33,6 @@ export class AdminProductEditorPageComponent { save(): void { this.facade.saveDraft(); - void this.router.navigate(['ru/backoffice/products']); + void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']); } } diff --git a/src/app/features/admin/products/pages/admin-products-list-page.component.ts b/src/app/features/admin/products/pages/admin-products-list-page.component.ts index 81c1a06..785d9bc 100644 --- a/src/app/features/admin/products/pages/admin-products-list-page.component.ts +++ b/src/app/features/admin/products/pages/admin-products-list-page.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { Router } from '@angular/router'; import { AdminProductsFacade } from '../facade/admin-products.facade'; import { AdminProductsListComponent } from '../components/admin-products-list.component'; +import { LanguageService } from '../../../../services/language.service'; @Component({ selector: 'app-admin-products-list-page', @@ -28,13 +29,18 @@ import { AdminProductsListComponent } from '../components/admin-products-list.co export class AdminProductsListPageComponent { readonly facade = inject(AdminProductsFacade); private readonly router = inject(Router); + private readonly languageService = inject(LanguageService); constructor() { this.facade.loadCategories(); this.facade.loadList(); } - create(): void { this.facade.startCreate(); void this.router.navigate(['ru/backoffice/products/create']); } - edit(id: string): void { this.facade.loadForEdit(id, 'edit'); void this.router.navigate(['ru/backoffice/products', id, 'edit']); } - duplicate(id: string): void { this.facade.loadForEdit(id, 'duplicate'); void this.router.navigate(['ru/backoffice/products', id, 'duplicate']); } + create(): void { this.facade.startCreate(); void this.router.navigate([this.lang(), 'backoffice', 'products', 'create']); } + edit(id: string): void { this.facade.loadForEdit(id, 'edit'); void this.router.navigate([this.lang(), 'backoffice', 'products', id, 'edit']); } + duplicate(id: string): void { this.facade.loadForEdit(id, 'duplicate'); void this.router.navigate([this.lang(), 'backoffice', 'products', id, 'duplicate']); } + + private lang(): string { + return this.languageService.currentLanguage(); + } } diff --git a/src/app/features/backoffice/shared/backoffice-coming-soon-page.component.html b/src/app/features/backoffice/shared/backoffice-coming-soon-page.component.html new file mode 100644 index 0000000..c2f9d4a --- /dev/null +++ b/src/app/features/backoffice/shared/backoffice-coming-soon-page.component.html @@ -0,0 +1,5 @@ +
+

{{ titleKey | translate }}

+

{{ 'dashboard.comingSoonDescription' | translate }}

+ {{ 'dashboard.backToDashboard' | translate }} +
diff --git a/src/app/features/backoffice/shared/backoffice-coming-soon-page.component.scss b/src/app/features/backoffice/shared/backoffice-coming-soon-page.component.scss new file mode 100644 index 0000000..4c90f07 --- /dev/null +++ b/src/app/features/backoffice/shared/backoffice-coming-soon-page.component.scss @@ -0,0 +1,32 @@ +.coming-soon { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 12px; + max-width: 640px; + margin: 40px auto; + padding: 24px; + text-align: left; +} + +.coming-soon__title { + margin: 0; + font-size: 24px; + font-weight: 800; + color: var(--text-primary, #1e3c38); +} + +.coming-soon__description { + margin: 0; + color: var(--text-secondary, #667a77); +} + +.coming-soon__link { + color: var(--primary-color, #497671); + font-weight: 600; + text-decoration: none; +} + +.coming-soon__link:hover { + text-decoration: underline; +} diff --git a/src/app/features/backoffice/shared/backoffice-coming-soon-page.component.ts b/src/app/features/backoffice/shared/backoffice-coming-soon-page.component.ts new file mode 100644 index 0000000..c4e6eb4 --- /dev/null +++ b/src/app/features/backoffice/shared/backoffice-coming-soon-page.component.ts @@ -0,0 +1,19 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { TranslatePipe } from '../../../i18n/translate.pipe'; + +/** Landing page for backoffice sections not built yet (categories, static pages, transactions, orders, media). Route `data.titleKey` sets the section name; falls back to the generic "coming soon" title. */ +@Component({ + selector: 'app-backoffice-coming-soon-page', + standalone: true, + imports: [CommonModule, RouterLink, TranslatePipe], + templateUrl: './backoffice-coming-soon-page.component.html', + styleUrls: ['./backoffice-coming-soon-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BackofficeComingSoonPageComponent { + private readonly route = inject(ActivatedRoute); + + readonly titleKey: string = this.route.snapshot.data['titleKey'] ?? 'dashboard.comingSoonTitle'; +} diff --git a/src/app/features/project-editor/facade/project-editor.facade.ts b/src/app/features/project-editor/facade/project-editor.facade.ts index 71418c2..8062bd0 100644 --- a/src/app/features/project-editor/facade/project-editor.facade.ts +++ b/src/app/features/project-editor/facade/project-editor.facade.ts @@ -30,6 +30,7 @@ export class ProjectEditorFacade { status: 'draft', lastSavedBootstrap: null, lastSavedAt: null, + lastPublishedAt: null, draftRestored: false, }); @@ -40,6 +41,7 @@ export class ProjectEditorFacade { readonly homepageWidgets = computed(() => this.homepagePage()?.sections.flatMap(section => section.widgets.map(widget => ({ sectionId: section.id, sectionType: section.type, widget }))) ?? []); readonly status = computed(() => this.state().status); readonly lastSavedAt = computed(() => this.state().lastSavedAt); + readonly lastPublishedAt = computed(() => this.state().lastPublishedAt); readonly draftRestored = computed(() => this.state().draftRestored); readonly validationIssues = computed(() => { const current = this.bootstrap(); @@ -182,6 +184,7 @@ export class ProjectEditorFacade { lastSavedBootstrap: JSON.parse(JSON.stringify(current)), originalBootstrap: JSON.parse(JSON.stringify(current)), lastSavedAt: savedAt, + lastPublishedAt: savedAt, })); return true; } diff --git a/src/app/features/project-editor/models/project-editor.model.ts b/src/app/features/project-editor/models/project-editor.model.ts index 971dd07..39d4db3 100644 --- a/src/app/features/project-editor/models/project-editor.model.ts +++ b/src/app/features/project-editor/models/project-editor.model.ts @@ -22,6 +22,7 @@ export interface ProjectEditorState { status: 'draft' | 'published'; lastSavedBootstrap: BootstrapConfig | null; lastSavedAt: number | null; + lastPublishedAt: number | null; draftRestored: boolean; } diff --git a/src/app/features/project-editor/services/project-validator.service.ts b/src/app/features/project-editor/services/project-validator.service.ts index 725219a..99ba250 100644 --- a/src/app/features/project-editor/services/project-validator.service.ts +++ b/src/app/features/project-editor/services/project-validator.service.ts @@ -8,6 +8,8 @@ export interface ProjectValidationIssue { const HEX_COLOR = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; const HTTP_URL = /^https?:\/\/\S+$/; +const KNOWN_PLATFORM_LAYOUT_TYPES = new Set(['default', 'sidebar-left', 'carousel-home', 'minimal']); +const KNOWN_SECTION_LAYOUT_STRATEGIES = new Set(['stack', 'grid', 'hero', 'carousel', 'split']); @Injectable({ providedIn: 'root' }) export class ProjectValidator { @@ -20,6 +22,8 @@ export class ProjectValidator { ...this.homepageIssues(bootstrap), ...this.navigationIssues(bootstrap), ...this.colorIssues(bootstrap), + ...this.translationIssues(bootstrap), + ...this.layoutIssues(bootstrap), ]; } @@ -69,4 +73,35 @@ export class ProjectValidator { const invalid = Object.values(bootstrap.theme.palette).some(value => !HEX_COLOR.test(value)); return invalid ? [{ code: 'invalid-colors', message: 'builder.validationInvalidColors' }] : []; } + + private translationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] { + const otherLocales = bootstrap.localization.supportedLocales.filter(locale => locale !== bootstrap.localization.defaultLocale); + if (otherLocales.length === 0) { + return []; + } + + const headerMissing = bootstrap.navigation.header.some(item => { + const label = item.label; + if (!label || typeof label !== 'object') { + return false; + } + return otherLocales.some(locale => !label[locale]?.trim()); + }); + + const staticPages = bootstrap.staticPages; + const staticPagesMissing = staticPages && !Array.isArray(staticPages) + ? Object.values(staticPages).some(page => page.translations && otherLocales.some(locale => !page.translations![locale])) + : false; + + return headerMissing || staticPagesMissing ? [{ code: 'missing-translations', message: 'builder.validationMissingTranslations' }] : []; + } + + private layoutIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] { + const invalidPlatformLayout = !!bootstrap.layout?.type && !KNOWN_PLATFORM_LAYOUT_TYPES.has(bootstrap.layout.type); + const invalidSectionLayout = bootstrap.pages.some(page => + page.sections.some(section => !!section.layout?.strategy && !KNOWN_SECTION_LAYOUT_STRATEGIES.has(section.layout.strategy)), + ); + + return invalidPlatformLayout || invalidSectionLayout ? [{ code: 'invalid-layouts', message: 'builder.validationInvalidLayouts' }] : []; + } } diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index c53b52b..13d0912 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -512,6 +512,8 @@ export const en: Translations = { validationMissingWidget: 'A homepage section has a widget with no type.', validationDuplicateNavLinks: 'Two or more header navigation links are duplicates.', validationInvalidColors: 'One or more theme colors are not valid hex colors.', + validationMissingTranslations: 'Some content is missing translations for a supported language.', + validationInvalidLayouts: 'One or more layouts reference an unknown layout type.', statusDraft: 'Draft', statusPublished: 'Published', unsavedChanges: 'Unsaved changes', @@ -530,6 +532,52 @@ export const en: Translations = { confirmResetDraft: 'This discards all unpublished changes and restores the last published configuration. Continue?', confirmResetSection: 'This discards unpublished changes in this section only. Continue?', }, + dashboard: { + title: 'Dashboard', + subtitle: 'Overview of your marketplace', + cardMarketplaceStatus: 'Marketplace Status', + cardProjectName: 'Project Name', + cardCurrentTheme: 'Current Theme', + cardLanguages: 'Languages', + cardCategoriesCount: 'Categories', + cardProductsCount: 'Products', + cardOrders: 'Orders', + cardRevenue: 'Revenue', + cardLastPublish: 'Last Publish', + cardLastDraftSave: 'Last Draft Save', + cardBootstrapVersion: 'Bootstrap Version', + cardActiveLayout: 'Active Layout', + cardEnabledWidgets: 'Enabled Widgets', + stateLoading: 'Loading...', + stateEmpty: 'No data yet', + stateError: 'Could not load this card', + statePendingBackend: 'Awaiting backend integration', + never: 'Never', + quickActionsTitle: 'Quick Actions', + actionEditProject: 'Edit Project', + actionCategories: 'Categories', + actionProducts: 'Products', + actionStaticPages: 'Static Pages', + actionTransactions: 'Transactions', + actionOrders: 'Orders', + actionMediaLibrary: 'Media Library', + actionPreviewMarketplace: 'Preview Marketplace', + activityTitle: 'Recent Activity', + activityEmpty: 'No activity yet. Save or publish a change to see it here.', + activityDraftSaved: 'Draft saved', + activityPublished: 'Published', + healthTitle: 'System Health', + healthAllClear: 'All checks passed', + healthBootstrapValid: 'Bootstrap valid', + healthConfigurationValid: 'Configuration valid', + healthMissingTranslations: 'Missing translations', + healthInvalidColors: 'Invalid colors', + healthInvalidWidgetReferences: 'Invalid widget references', + healthInvalidLayouts: 'Invalid layouts', + comingSoonTitle: 'Coming soon', + comingSoonDescription: 'This section is not built yet. Check back in a future update.', + backToDashboard: 'Back to dashboard', + }, staticPages: { notFound: 'Page not found', backHome: 'Back to home', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 0747ff4..a868e53 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -512,6 +512,8 @@ export const hy: Translations = { validationMissingWidget: 'Գլխավոր էջի սեկցիաներից մեկն ունի վիջեթ առանց տիպի։', validationDuplicateNavLinks: 'Վերնագրի նավիգացիայում կան կրկնվող հղումներ։', validationInvalidColors: 'Թեմայի գույներից մեկը կամ մի քանիսը վավեր hex գույն չեն։', + validationMissingTranslations: 'Որոշ բովանդակություն թարգմանված չէ սատարվող լեզուներից մեկով։', + validationInvalidLayouts: 'Մեկ կամ մի քանի դասավորություններ հղում են անհայտ տեսակի։', statusDraft: 'Սևագիր', statusPublished: 'Հրապարակված', unsavedChanges: 'Չպահված փոփոխություններ', @@ -530,6 +532,52 @@ export const hy: Translations = { confirmResetDraft: 'Սա կչեղարկի բոլոր չհրապարակված փոփոխությունները և կվերականգնի վերջին հրապարակված կոնֆիգուրացիան։ Շարունակե՞լ։', confirmResetSection: 'Սա կչեղարկի չհրապարակված փոփոխությունները միայն այս սեկցիայում։ Շարունակե՞լ։', }, + dashboard: { + title: 'Կառավարման վահանակ', + subtitle: 'Ձեր մարքեթփլեյսի ընդհանուր տեսք', + cardMarketplaceStatus: 'Մարքեթփլեյսի կարգավիճակ', + cardProjectName: 'Նախագծի անվանում', + cardCurrentTheme: 'Ընթացիկ թեմա', + cardLanguages: 'Լեզուներ', + cardCategoriesCount: 'Կատեգորիաներ', + cardProductsCount: 'Ապրանքներ', + cardOrders: 'Պատվերներ', + cardRevenue: 'Եկամուտ', + cardLastPublish: 'Վերջին հրապարակում', + cardLastDraftSave: 'Վերջին սևագրի պահպանում', + cardBootstrapVersion: 'Bootstrap-ի տարբերակ', + cardActiveLayout: 'Ակտիվ դասավորություն', + cardEnabledWidgets: 'Միացված վիջեթներ', + stateLoading: 'Բեռնվում է...', + stateEmpty: 'Դեռ տվյալներ չկան', + stateError: 'Չհաջողվեց բեռնել այս քարտը', + statePendingBackend: 'Սպասվում է backend ինտեգրման', + never: 'Երբեք', + quickActionsTitle: 'Արագ գործողություններ', + actionEditProject: 'Խմբագրել նախագիծը', + actionCategories: 'Կատեգորիաներ', + actionProducts: 'Ապրանքներ', + actionStaticPages: 'Ստատիկ էջեր', + actionTransactions: 'Գործարքներ', + actionOrders: 'Պատվերներ', + actionMediaLibrary: 'Մեդիագրադարան', + actionPreviewMarketplace: 'Դիտել մարքեթփլեյսը', + activityTitle: 'Վերջին ակտիվություն', + activityEmpty: 'Դեռ ակտիվություն չկա։ Պահպանեք կամ հրապարակեք փոփոխություն՝ այն այստեղ տեսնելու համար։', + activityDraftSaved: 'Սևագիրը պահպանվեց', + activityPublished: 'Հրապարակվեց', + healthTitle: 'Համակարգի վիճակ', + healthAllClear: 'Բոլոր ստուգումներն անցել են', + healthBootstrapValid: 'Bootstrap-ը վավեր է', + healthConfigurationValid: 'Կոնֆիգուրացիան վավեր է', + healthMissingTranslations: 'Բացակայող թարգմանություններ', + healthInvalidColors: 'Անվավեր գույներ', + healthInvalidWidgetReferences: 'Անվավեր վիջեթ հղումներ', + healthInvalidLayouts: 'Անվավեր դասավորություններ', + comingSoonTitle: 'Շուտով', + comingSoonDescription: 'Այս բաժինը դեռ կառուցված չէ։ Ստուգեք ավելի ուշ։', + backToDashboard: 'Վերադառնալ վահանակ', + }, staticPages: { notFound: 'Էջը չի գտնվել', backHome: 'Վերադառնալ գլխավոր', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 32db53c..ba1c241 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -512,6 +512,8 @@ export const ru: Translations = { validationMissingWidget: 'В секции главной страницы есть виджет без типа.', validationDuplicateNavLinks: 'Две или более ссылки в навигации шапки дублируются.', validationInvalidColors: 'Один или несколько цветов темы указаны некорректно.', + validationMissingTranslations: 'Часть контента не переведена на один из поддерживаемых языков.', + validationInvalidLayouts: 'Один или несколько макетов ссылаются на неизвестный тип раскладки.', statusDraft: 'Черновик', statusPublished: 'Опубликовано', unsavedChanges: 'Есть несохранённые изменения', @@ -530,6 +532,52 @@ export const ru: Translations = { confirmResetDraft: 'Это отменит все неопубликованные изменения и восстановит последнюю опубликованную конфигурацию. Продолжить?', confirmResetSection: 'Это отменит неопубликованные изменения только в этой секции. Продолжить?', }, + dashboard: { + title: 'Панель управления', + subtitle: 'Обзор вашего маркетплейса', + cardMarketplaceStatus: 'Статус маркетплейса', + cardProjectName: 'Название проекта', + cardCurrentTheme: 'Текущая тема', + cardLanguages: 'Языки', + cardCategoriesCount: 'Категории', + cardProductsCount: 'Товары', + cardOrders: 'Заказы', + cardRevenue: 'Выручка', + cardLastPublish: 'Последняя публикация', + cardLastDraftSave: 'Последнее сохранение черновика', + cardBootstrapVersion: 'Версия конфигурации', + cardActiveLayout: 'Активный макет', + cardEnabledWidgets: 'Активные виджеты', + stateLoading: 'Загрузка...', + stateEmpty: 'Пока нет данных', + stateError: 'Не удалось загрузить карточку', + statePendingBackend: 'Ожидает интеграции с бэкендом', + never: 'Никогда', + quickActionsTitle: 'Быстрые действия', + actionEditProject: 'Редактировать проект', + actionCategories: 'Категории', + actionProducts: 'Товары', + actionStaticPages: 'Статические страницы', + actionTransactions: 'Транзакции', + actionOrders: 'Заказы', + actionMediaLibrary: 'Медиатека', + actionPreviewMarketplace: 'Просмотр маркетплейса', + activityTitle: 'Недавняя активность', + activityEmpty: 'Пока нет активности. Сохраните или опубликуйте изменение, чтобы увидеть его здесь.', + activityDraftSaved: 'Черновик сохранён', + activityPublished: 'Опубликовано', + healthTitle: 'Состояние системы', + healthAllClear: 'Все проверки пройдены', + healthBootstrapValid: 'Bootstrap корректен', + healthConfigurationValid: 'Конфигурация корректна', + healthMissingTranslations: 'Отсутствуют переводы', + healthInvalidColors: 'Некорректные цвета', + healthInvalidWidgetReferences: 'Некорректные ссылки виджетов', + healthInvalidLayouts: 'Некорректные макеты', + comingSoonTitle: 'Скоро', + comingSoonDescription: 'Этот раздел ещё не реализован. Загляните позже.', + backToDashboard: 'Вернуться на панель управления', + }, staticPages: { notFound: 'Страница не найдена', backHome: 'На главную', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 89d1a0d..619c41c 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -510,6 +510,8 @@ export interface Translations { validationMissingWidget: string; validationDuplicateNavLinks: string; validationInvalidColors: string; + validationMissingTranslations: string; + validationInvalidLayouts: string; statusDraft: string; statusPublished: string; unsavedChanges: string; @@ -528,6 +530,52 @@ export interface Translations { confirmResetDraft: string; confirmResetSection: string; }; + dashboard: { + title: string; + subtitle: string; + cardMarketplaceStatus: string; + cardProjectName: string; + cardCurrentTheme: string; + cardLanguages: string; + cardCategoriesCount: string; + cardProductsCount: string; + cardOrders: string; + cardRevenue: string; + cardLastPublish: string; + cardLastDraftSave: string; + cardBootstrapVersion: string; + cardActiveLayout: string; + cardEnabledWidgets: string; + stateLoading: string; + stateEmpty: string; + stateError: string; + statePendingBackend: string; + never: string; + quickActionsTitle: string; + actionEditProject: string; + actionCategories: string; + actionProducts: string; + actionStaticPages: string; + actionTransactions: string; + actionOrders: string; + actionMediaLibrary: string; + actionPreviewMarketplace: string; + activityTitle: string; + activityEmpty: string; + activityDraftSaved: string; + activityPublished: string; + healthTitle: string; + healthAllClear: string; + healthBootstrapValid: string; + healthConfigurationValid: string; + healthMissingTranslations: string; + healthInvalidColors: string; + healthInvalidWidgetReferences: string; + healthInvalidLayouts: string; + comingSoonTitle: string; + comingSoonDescription: string; + backToDashboard: string; + }; staticPages: { notFound: string; backHome: string;