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 <noreply@anthropic.com>
This commit is contained in:
163
docs/ADMIN.md
Normal file
163
docs/ADMIN.md
Normal file
@@ -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.
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { languageGuard } from './guards/language.guard';
|
import { languageGuard } from './guards/language.guard';
|
||||||
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.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';
|
import { environment } from '../environments/environment';
|
||||||
|
|
||||||
// Core routes (same across all brands)
|
// 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),
|
loadComponent: () => import('./features/project-editor/pages/project-editor-page.component').then(m => m.ProjectEditorPageComponent),
|
||||||
canDeactivate: [projectEditorDirtyGuard]
|
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',
|
path: 'edit',
|
||||||
redirectTo: 'edit/general',
|
redirectTo: 'edit/general',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Component, ChangeDetectionStrategy, Input, Injector, Signal, inject, effect, OnDestroy, OnInit } from '@angular/core';
|
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 { AuthService } from '../../services/auth.service';
|
||||||
import { AdminAuthService } from '../../core/admin-auth/admin-auth.service';
|
import { AdminAuthService } from '../../core/admin-auth/admin-auth.service';
|
||||||
|
import { LanguageService } from '../../services/language.service';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine';
|
import { QrLoginEngine } from '../../shared/qr-login/qr-login.engine';
|
||||||
import { QrLoginAdapter, QrLoginStatus } from '../../shared/qr-login/qr-login.model';
|
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 customerAuth = inject(AuthService);
|
||||||
private readonly adminAuth = inject(AdminAuthService);
|
private readonly adminAuth = inject(AdminAuthService);
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
private engine!: QrLoginEngine<AuthSession>;
|
private engine!: QrLoginEngine<AuthSession>;
|
||||||
|
|
||||||
@@ -49,7 +53,10 @@ export class TelegramLoginComponent implements OnInit, OnDestroy {
|
|||||||
checkSessionOnce: id => this.adminAuth.checkSessionOnce(id),
|
checkSessionOnce: id => this.adminAuth.checkSessionOnce(id),
|
||||||
isSessionActive: session => !!session?.active,
|
isSessionActive: session => !!session?.active,
|
||||||
getAppLoginUrl: id => this.adminAuth.getAdminAppLoginUrl(id),
|
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(),
|
createSession: () => this.customerAuth.createWebSession(),
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<section class="dashboard-activity">
|
||||||
|
<h2 class="dashboard-activity__title">{{ 'dashboard.activityTitle' | translate }}</h2>
|
||||||
|
|
||||||
|
<p class="dashboard-activity__empty" *ngIf="entries.length === 0">{{ 'dashboard.activityEmpty' | translate }}</p>
|
||||||
|
|
||||||
|
<ul class="dashboard-activity__list" *ngIf="entries.length > 0">
|
||||||
|
<li class="dashboard-activity__item" *ngFor="let entry of entries">
|
||||||
|
<span class="dashboard-activity__label">{{ entry.labelKey | translate }}</span>
|
||||||
|
<span class="dashboard-activity__time">{{ entry.timeText }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[] = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<article class="dashboard-card" [class.dashboard-card--error]="status === 'error'" [class.dashboard-card--pending]="status === 'pending-backend'">
|
||||||
|
<header class="dashboard-card__header">
|
||||||
|
<span class="dashboard-card__icon" *ngIf="icon" aria-hidden="true">{{ icon }}</span>
|
||||||
|
<h3 class="dashboard-card__title">{{ title }}</h3>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="dashboard-card__body">
|
||||||
|
<ng-container [ngSwitch]="status">
|
||||||
|
<div class="dashboard-card__skeleton" *ngSwitchCase="'loading'"></div>
|
||||||
|
|
||||||
|
<p class="dashboard-card__muted" *ngSwitchCase="'empty'">{{ 'dashboard.stateEmpty' | translate }}</p>
|
||||||
|
|
||||||
|
<p class="dashboard-card__muted dashboard-card__muted--error" *ngSwitchCase="'error'">{{ 'dashboard.stateError' | translate }}</p>
|
||||||
|
|
||||||
|
<p class="dashboard-card__muted" *ngSwitchCase="'pending-backend'">{{ 'dashboard.statePendingBackend' | translate }}</p>
|
||||||
|
|
||||||
|
<ng-container *ngSwitchDefault>
|
||||||
|
<p class="dashboard-card__value">{{ value }}</p>
|
||||||
|
<p class="dashboard-card__subtitle" *ngIf="subtitle">{{ subtitle }}</p>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
@@ -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%; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<section class="dashboard-health" [class.dashboard-health--warning]="hasIssues">
|
||||||
|
<h2 class="dashboard-health__title">{{ 'dashboard.healthTitle' | translate }}</h2>
|
||||||
|
|
||||||
|
<p class="dashboard-health__all-clear" *ngIf="!hasIssues">{{ 'dashboard.healthAllClear' | translate }}</p>
|
||||||
|
|
||||||
|
<ul class="dashboard-health__list">
|
||||||
|
<li class="dashboard-health__item" *ngFor="let check of checks" [class.dashboard-health__item--warning]="!check.healthy">
|
||||||
|
<span class="dashboard-health__dot" [class.dashboard-health__dot--warning]="!check.healthy" aria-hidden="true"></span>
|
||||||
|
<span>{{ check.labelKey | translate }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<section class="dashboard-quick-actions">
|
||||||
|
<h2 class="dashboard-quick-actions__title">{{ 'dashboard.quickActionsTitle' | translate }}</h2>
|
||||||
|
<div class="dashboard-quick-actions__grid">
|
||||||
|
<a
|
||||||
|
*ngFor="let action of actions"
|
||||||
|
class="dashboard-quick-actions__item"
|
||||||
|
[routerLink]="action.route"
|
||||||
|
>{{ action.labelKey | translate }}</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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[] = [];
|
||||||
|
}
|
||||||
@@ -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<AdminDashboardCardState<AdminDashboardMetrics>>({ 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<AdminDashboardHealthCheck[]>(() => {
|
||||||
|
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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
export type AdminDashboardCardStatus = 'loading' | 'ready' | 'empty' | 'error' | 'pending-backend';
|
||||||
|
|
||||||
|
export interface AdminDashboardCardState<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<div class="dashboard-page">
|
||||||
|
<header class="dashboard-page__header">
|
||||||
|
<h1 class="dashboard-page__title">{{ 'dashboard.title' | translate }}</h1>
|
||||||
|
<p class="dashboard-page__subtitle">{{ 'dashboard.subtitle' | translate }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="dashboard-page__cards">
|
||||||
|
<app-admin-dashboard-card
|
||||||
|
*ngFor="let card of cards()"
|
||||||
|
[title]="cardTitle(card.titleKey)"
|
||||||
|
[status]="card.status"
|
||||||
|
[value]="card.value"
|
||||||
|
[subtitle]="card.subtitle"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-admin-dashboard-quick-actions class="dashboard-page__section" [actions]="quickActions()" />
|
||||||
|
|
||||||
|
<div class="dashboard-page__row">
|
||||||
|
<app-admin-dashboard-activity class="dashboard-page__section" [entries]="activityEntries()" />
|
||||||
|
<app-admin-dashboard-health class="dashboard-page__section" [checks]="healthChecks()" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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<AdminDashboardQuickAction[]>(() => {
|
||||||
|
const lang = this.languageService.currentLanguage();
|
||||||
|
return this.facade.quickActions.map(action => ({
|
||||||
|
...action,
|
||||||
|
route: ['/', lang, ...action.route.filter(segment => segment !== '')],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly activityEntries = computed<AdminDashboardActivityViewEntry[]>(() =>
|
||||||
|
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<DashboardCardViewModel[]>(() => {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AdminDashboardMetricsGateway>('ADMIN_DASHBOARD_METRICS_GATEWAY', {
|
||||||
|
providedIn: 'root',
|
||||||
|
factory: () => inject(AdminDashboardMetricsLocalGateway),
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AdminDashboardMetrics } from '../models/admin-dashboard.model';
|
||||||
|
|
||||||
|
export interface AdminDashboardMetricsGateway {
|
||||||
|
loadMetrics(): Observable<AdminDashboardMetrics>;
|
||||||
|
}
|
||||||
@@ -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<AdminDashboardMetrics> {
|
||||||
|
return forkJoin({
|
||||||
|
categories: this.backofficeData.loadCategories(),
|
||||||
|
products: this.backofficeData.loadProducts(),
|
||||||
|
}).pipe(
|
||||||
|
map(({ categories, products }) => ({
|
||||||
|
categoriesCount: categories.length,
|
||||||
|
productsCount: products.length,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router';
|
|||||||
import { AdminProductsFacade } from '../facade/admin-products.facade';
|
import { AdminProductsFacade } from '../facade/admin-products.facade';
|
||||||
import { AdminProductFormComponent } from '../components/admin-product-form.component';
|
import { AdminProductFormComponent } from '../components/admin-product-form.component';
|
||||||
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||||
|
import { LanguageService } from '../../../../services/language.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-product-editor-page',
|
selector: 'app-admin-product-editor-page',
|
||||||
@@ -16,6 +17,7 @@ export class AdminProductEditorPageComponent {
|
|||||||
readonly facade = inject(AdminProductsFacade);
|
readonly facade = inject(AdminProductsFacade);
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
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');
|
readonly title = computed(() => this.facade.editorMode() === 'create' ? 'adminProducts.create' : this.facade.editorMode() === 'duplicate' ? 'adminProducts.duplicate' : 'adminProducts.edit');
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -31,6 +33,6 @@ export class AdminProductEditorPageComponent {
|
|||||||
|
|
||||||
save(): void {
|
save(): void {
|
||||||
this.facade.saveDraft();
|
this.facade.saveDraft();
|
||||||
void this.router.navigate(['ru/backoffice/products']);
|
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { AdminProductsFacade } from '../facade/admin-products.facade';
|
import { AdminProductsFacade } from '../facade/admin-products.facade';
|
||||||
import { AdminProductsListComponent } from '../components/admin-products-list.component';
|
import { AdminProductsListComponent } from '../components/admin-products-list.component';
|
||||||
|
import { LanguageService } from '../../../../services/language.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-products-list-page',
|
selector: 'app-admin-products-list-page',
|
||||||
@@ -28,13 +29,18 @@ import { AdminProductsListComponent } from '../components/admin-products-list.co
|
|||||||
export class AdminProductsListPageComponent {
|
export class AdminProductsListPageComponent {
|
||||||
readonly facade = inject(AdminProductsFacade);
|
readonly facade = inject(AdminProductsFacade);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
private readonly languageService = inject(LanguageService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.facade.loadCategories();
|
this.facade.loadCategories();
|
||||||
this.facade.loadList();
|
this.facade.loadList();
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void { this.facade.startCreate(); void this.router.navigate(['ru/backoffice/products/create']); }
|
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(['ru/backoffice/products', id, 'edit']); }
|
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(['ru/backoffice/products', id, 'duplicate']); }
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="coming-soon">
|
||||||
|
<h1 class="coming-soon__title">{{ titleKey | translate }}</h1>
|
||||||
|
<p class="coming-soon__description">{{ 'dashboard.comingSoonDescription' | translate }}</p>
|
||||||
|
<a class="coming-soon__link" [routerLink]="['../dashboard']">{{ 'dashboard.backToDashboard' | translate }}</a>
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ export class ProjectEditorFacade {
|
|||||||
status: 'draft',
|
status: 'draft',
|
||||||
lastSavedBootstrap: null,
|
lastSavedBootstrap: null,
|
||||||
lastSavedAt: null,
|
lastSavedAt: null,
|
||||||
|
lastPublishedAt: null,
|
||||||
draftRestored: false,
|
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 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 status = computed(() => this.state().status);
|
||||||
readonly lastSavedAt = computed(() => this.state().lastSavedAt);
|
readonly lastSavedAt = computed(() => this.state().lastSavedAt);
|
||||||
|
readonly lastPublishedAt = computed(() => this.state().lastPublishedAt);
|
||||||
readonly draftRestored = computed(() => this.state().draftRestored);
|
readonly draftRestored = computed(() => this.state().draftRestored);
|
||||||
readonly validationIssues = computed(() => {
|
readonly validationIssues = computed(() => {
|
||||||
const current = this.bootstrap();
|
const current = this.bootstrap();
|
||||||
@@ -182,6 +184,7 @@ export class ProjectEditorFacade {
|
|||||||
lastSavedBootstrap: JSON.parse(JSON.stringify(current)),
|
lastSavedBootstrap: JSON.parse(JSON.stringify(current)),
|
||||||
originalBootstrap: JSON.parse(JSON.stringify(current)),
|
originalBootstrap: JSON.parse(JSON.stringify(current)),
|
||||||
lastSavedAt: savedAt,
|
lastSavedAt: savedAt,
|
||||||
|
lastPublishedAt: savedAt,
|
||||||
}));
|
}));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface ProjectEditorState {
|
|||||||
status: 'draft' | 'published';
|
status: 'draft' | 'published';
|
||||||
lastSavedBootstrap: BootstrapConfig | null;
|
lastSavedBootstrap: BootstrapConfig | null;
|
||||||
lastSavedAt: number | null;
|
lastSavedAt: number | null;
|
||||||
|
lastPublishedAt: number | null;
|
||||||
draftRestored: boolean;
|
draftRestored: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface ProjectValidationIssue {
|
|||||||
|
|
||||||
const HEX_COLOR = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
const HEX_COLOR = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||||
const HTTP_URL = /^https?:\/\/\S+$/;
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ProjectValidator {
|
export class ProjectValidator {
|
||||||
@@ -20,6 +22,8 @@ export class ProjectValidator {
|
|||||||
...this.homepageIssues(bootstrap),
|
...this.homepageIssues(bootstrap),
|
||||||
...this.navigationIssues(bootstrap),
|
...this.navigationIssues(bootstrap),
|
||||||
...this.colorIssues(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));
|
const invalid = Object.values(bootstrap.theme.palette).some(value => !HEX_COLOR.test(value));
|
||||||
return invalid ? [{ code: 'invalid-colors', message: 'builder.validationInvalidColors' }] : [];
|
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' }] : [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,6 +512,8 @@ export const en: Translations = {
|
|||||||
validationMissingWidget: 'A homepage section has a widget with no type.',
|
validationMissingWidget: 'A homepage section has a widget with no type.',
|
||||||
validationDuplicateNavLinks: 'Two or more header navigation links are duplicates.',
|
validationDuplicateNavLinks: 'Two or more header navigation links are duplicates.',
|
||||||
validationInvalidColors: 'One or more theme colors are not valid hex colors.',
|
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',
|
statusDraft: 'Draft',
|
||||||
statusPublished: 'Published',
|
statusPublished: 'Published',
|
||||||
unsavedChanges: 'Unsaved changes',
|
unsavedChanges: 'Unsaved changes',
|
||||||
@@ -530,6 +532,52 @@ export const en: Translations = {
|
|||||||
confirmResetDraft: 'This discards all unpublished changes and restores the last published configuration. Continue?',
|
confirmResetDraft: 'This discards all unpublished changes and restores the last published configuration. Continue?',
|
||||||
confirmResetSection: 'This discards unpublished changes in this section only. 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: {
|
staticPages: {
|
||||||
notFound: 'Page not found',
|
notFound: 'Page not found',
|
||||||
backHome: 'Back to home',
|
backHome: 'Back to home',
|
||||||
|
|||||||
@@ -512,6 +512,8 @@ export const hy: Translations = {
|
|||||||
validationMissingWidget: 'Գլխավոր էջի սեկցիաներից մեկն ունի վիջեթ առանց տիպի։',
|
validationMissingWidget: 'Գլխավոր էջի սեկցիաներից մեկն ունի վիջեթ առանց տիպի։',
|
||||||
validationDuplicateNavLinks: 'Վերնագրի նավիգացիայում կան կրկնվող հղումներ։',
|
validationDuplicateNavLinks: 'Վերնագրի նավիգացիայում կան կրկնվող հղումներ։',
|
||||||
validationInvalidColors: 'Թեմայի գույներից մեկը կամ մի քանիսը վավեր hex գույն չեն։',
|
validationInvalidColors: 'Թեմայի գույներից մեկը կամ մի քանիսը վավեր hex գույն չեն։',
|
||||||
|
validationMissingTranslations: 'Որոշ բովանդակություն թարգմանված չէ սատարվող լեզուներից մեկով։',
|
||||||
|
validationInvalidLayouts: 'Մեկ կամ մի քանի դասավորություններ հղում են անհայտ տեսակի։',
|
||||||
statusDraft: 'Սևագիր',
|
statusDraft: 'Սևագիր',
|
||||||
statusPublished: 'Հրապարակված',
|
statusPublished: 'Հրապարակված',
|
||||||
unsavedChanges: 'Չպահված փոփոխություններ',
|
unsavedChanges: 'Չպահված փոփոխություններ',
|
||||||
@@ -530,6 +532,52 @@ export const hy: Translations = {
|
|||||||
confirmResetDraft: 'Սա կչեղարկի բոլոր չհրապարակված փոփոխությունները և կվերականգնի վերջին հրապարակված կոնֆիգուրացիան։ Շարունակե՞լ։',
|
confirmResetDraft: 'Սա կչեղարկի բոլոր չհրապարակված փոփոխությունները և կվերականգնի վերջին հրապարակված կոնֆիգուրացիան։ Շարունակե՞լ։',
|
||||||
confirmResetSection: 'Սա կչեղարկի չհրապարակված փոփոխությունները միայն այս սեկցիայում։ Շարունակե՞լ։',
|
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: {
|
staticPages: {
|
||||||
notFound: 'Էջը չի գտնվել',
|
notFound: 'Էջը չի գտնվել',
|
||||||
backHome: 'Վերադառնալ գլխավոր',
|
backHome: 'Վերադառնալ գլխավոր',
|
||||||
|
|||||||
@@ -512,6 +512,8 @@ export const ru: Translations = {
|
|||||||
validationMissingWidget: 'В секции главной страницы есть виджет без типа.',
|
validationMissingWidget: 'В секции главной страницы есть виджет без типа.',
|
||||||
validationDuplicateNavLinks: 'Две или более ссылки в навигации шапки дублируются.',
|
validationDuplicateNavLinks: 'Две или более ссылки в навигации шапки дублируются.',
|
||||||
validationInvalidColors: 'Один или несколько цветов темы указаны некорректно.',
|
validationInvalidColors: 'Один или несколько цветов темы указаны некорректно.',
|
||||||
|
validationMissingTranslations: 'Часть контента не переведена на один из поддерживаемых языков.',
|
||||||
|
validationInvalidLayouts: 'Один или несколько макетов ссылаются на неизвестный тип раскладки.',
|
||||||
statusDraft: 'Черновик',
|
statusDraft: 'Черновик',
|
||||||
statusPublished: 'Опубликовано',
|
statusPublished: 'Опубликовано',
|
||||||
unsavedChanges: 'Есть несохранённые изменения',
|
unsavedChanges: 'Есть несохранённые изменения',
|
||||||
@@ -530,6 +532,52 @@ export const ru: Translations = {
|
|||||||
confirmResetDraft: 'Это отменит все неопубликованные изменения и восстановит последнюю опубликованную конфигурацию. Продолжить?',
|
confirmResetDraft: 'Это отменит все неопубликованные изменения и восстановит последнюю опубликованную конфигурацию. Продолжить?',
|
||||||
confirmResetSection: 'Это отменит неопубликованные изменения только в этой секции. Продолжить?',
|
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: {
|
staticPages: {
|
||||||
notFound: 'Страница не найдена',
|
notFound: 'Страница не найдена',
|
||||||
backHome: 'На главную',
|
backHome: 'На главную',
|
||||||
|
|||||||
@@ -510,6 +510,8 @@ export interface Translations {
|
|||||||
validationMissingWidget: string;
|
validationMissingWidget: string;
|
||||||
validationDuplicateNavLinks: string;
|
validationDuplicateNavLinks: string;
|
||||||
validationInvalidColors: string;
|
validationInvalidColors: string;
|
||||||
|
validationMissingTranslations: string;
|
||||||
|
validationInvalidLayouts: string;
|
||||||
statusDraft: string;
|
statusDraft: string;
|
||||||
statusPublished: string;
|
statusPublished: string;
|
||||||
unsavedChanges: string;
|
unsavedChanges: string;
|
||||||
@@ -528,6 +530,52 @@ export interface Translations {
|
|||||||
confirmResetDraft: string;
|
confirmResetDraft: string;
|
||||||
confirmResetSection: 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: {
|
staticPages: {
|
||||||
notFound: string;
|
notFound: string;
|
||||||
backHome: string;
|
backHome: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user