refactor: marketplace release polish

Sprint 28, scoped to admin/* (user decision — full marketplace audit
declined in favor of a bounded pass over the 8 admin features from
Sprints 20-27).

- a11y: aria-label added to every bare <select> not already inside a
  <label> across categories/products/orders/transactions/users/monitoring
- loading states: app-skeleton rows/cards added to list pages that
  previously rendered blank during the initial fetch (categories, orders,
  transactions, users, monitoring's event feed, analytics summary cards)
- admin-dashboard-card's custom shimmer CSS replaced with the shared
  SkeletonComponent (same visual result, one less duplicated animation)
- bundle-size budget warning (~198kB over) confirmed pre-existing —
  present at Sprint 20's first build before any admin/* code existed,
  and new admin pages are all lazy-loaded — documented as out of scope
  for this pass rather than chased

docs/ADMIN.md + docs/SPRINT-PLAN.md updated with the scope decision and
what was explicitly not done (Lighthouse, animations, SEO/sitemap,
storefront/editor a11y).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-16 00:14:46 +04:00
parent 1db63ac99d
commit 576f2600a5
27 changed files with 223 additions and 127 deletions

View File

@@ -416,6 +416,36 @@ routes at all, this is a net-new admin section.
- **Export**: CSV of the sales series (client-side `Blob` download, same - **Export**: CSV of the sales series (client-side `Blob` download, same
pattern as Orders/Transactions). pattern as Orders/Transactions).
## Sprint 28 - Marketplace Polish (scoped to admin/*)
Scoped down (user decision) from a full marketplace-wide audit to the 8
admin features built in Sprints 20-27, since those were the newest and
least-reviewed surface. Not a rewrite - targeted fixes only:
- **Accessibility**: every bare `<select>` across `admin/categories`,
`admin/products`, `admin/orders`, `admin/transactions`, `admin/users`,
and `admin/monitoring` that wasn't already inside a `<label>` (which
provides implicit association) now has an explicit `aria-label`. Selects
already nested in `<label>` (e.g. product form's category/stock-status
selects, category form's parent select) were left as-is - already
correct.
- **Loading states**: list pages that previously rendered a blank table
during the initial fetch (categories, orders, transactions, users,
monitoring's event feed, analytics' summary cards) now show
`app-skeleton` rows/cards while `loading()` is true, falling through to
the existing empty-state or populated table after. `admin/products` and
the media library already had this from earlier sprints.
- **Bundle size**: the `700 kB` initial-bundle budget warning (~198 kB over)
predates every admin sprint in this plan - it was already present at
Sprint 20's first build, before any of `features/admin/**` existed, and
the new admin pages are all lazy-loaded (they don't touch the initial
chunk). Confirmed out of scope for an admin-only polish pass; would need
a main-bundle/core-module audit to actually fix.
- Explicitly **not done** here (would require the "full marketplace audit"
scope the user declined): Lighthouse run, animations, responsive fixes
outside admin/*, SEO/meta/social-preview/robots/sitemap, and a full a11y
audit of the public storefront/Project Editor.
## Known gaps / backend needs ## Known gaps / backend needs
- **Dashboard metrics endpoint.** Categories/Products counts are computed - **Dashboard metrics endpoint.** Categories/Products counts are computed

View File

@@ -69,9 +69,11 @@ Notify user: **from Sprint 20 (Categories) once product↔category link + admin
- [x] `docs/ADMIN.md` (new Sprint 27 section), `docs/BACKEND.md` item 16 added - [x] `docs/ADMIN.md` (new Sprint 27 section), `docs/BACKEND.md` item 16 added
- Commit: `feat(admin): analytics dashboard` - Commit: `feat(admin): analytics dashboard`
## Sprint 28 — Marketplace Polish ## Sprint 28 — Marketplace Polish ✅ done (scoped to admin/* per user decision)
- [ ] Lighthouse + a11y sweep, animations, skeletons/empty/error states, responsive fixes, SEO/meta/social preview/robots/sitemap - [x] A11y sweep (aria-label on unlabeled selects), loading skeletons on list pages that previously showed blank tables, bundle-size warning confirmed pre-existing/out-of-scope
- [x] `docs/ADMIN.md` new Sprint 28 section documenting scope decision + what was explicitly not done
- Commit: `refactor: marketplace release polish` - Commit: `refactor: marketplace release polish`
- Not done (full marketplace audit was declined): Lighthouse run, animations, SEO/meta/sitemap, storefront/editor a11y.
## Sprint 29 — Release Candidate ## Sprint 29 — Release Candidate
- [ ] Dead code cleanup, remove TODOs/console logs/debug code, optimize imports/bundle/images/CSS, translation validation, build/type/lint validation, doc validation, arch review, CHANGELOG + release notes - [ ] Dead code cleanup, remove TODOs/console logs/debug code, optimize imports/bundle/images/CSS, translation validation, build/type/lint validation, doc validation, arch review, CHANGELOG + release notes

View File

@@ -8,7 +8,13 @@
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button> <app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
</div> </div>
@if (facade.summary(); as summary) { @if (facade.loading()) {
<div class="summary-grid">
@for (i of [1,2,3,4,5,6]; track i) {
<app-skeleton shape="rect" height="64px" />
}
</div>
} @else if (facade.summary(); as summary) {
<div class="summary-grid"> <div class="summary-grid">
<div class="summary-card"><span>{{ 'adminAnalytics.revenue' | translate }}</span><strong>{{ summary.revenueTotal }} {{ summary.currency }}</strong></div> <div class="summary-card"><span>{{ 'adminAnalytics.revenue' | translate }}</span><strong>{{ summary.revenueTotal }} {{ summary.currency }}</strong></div>
<div class="summary-card"><span>{{ 'adminAnalytics.orders' | translate }}</span><strong>{{ summary.ordersCount }}</strong></div> <div class="summary-card"><span>{{ 'adminAnalytics.orders' | translate }}</span><strong>{{ summary.ordersCount }}</strong></div>
@@ -30,6 +36,15 @@
<div class="card"> <div class="card">
<h2>{{ 'adminAnalytics.topProducts' | translate }}</h2> <h2>{{ 'adminAnalytics.topProducts' | translate }}</h2>
@if (facade.loading()) {
<div class="skeleton-rows">
@for (i of [1,2,3]; track i) {
<app-skeleton shape="rect" height="36px" />
}
</div>
} @else if (facade.topProducts().length === 0) {
<app-empty-state [title]="'adminAnalytics.topProductsEmptyTitle' | translate" [description]="'adminAnalytics.topProductsEmptyDescription' | translate" />
} @else {
<app-table> <app-table>
<thead> <thead>
<tr> <tr>
@@ -48,6 +63,7 @@
} }
</tbody> </tbody>
</app-table> </app-table>
}
</div> </div>
<div class="card pending-section"> <div class="card pending-section">

View File

@@ -11,3 +11,4 @@
.chart .bar { flex: 1; background: var(--color-primary, #2f8f5b); border-radius: 3px 3px 0 0; min-height: 2px; } .chart .bar { flex: 1; background: var(--color-primary, #2f8f5b); border-radius: 3px 3px 0 0; min-height: 2px; }
.pending-section p { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--text-secondary, #6b7280); } .pending-section p { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--text-secondary, #6b7280); }
@media (max-width: 900px) { .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 900px) { .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.skeleton-rows { display: grid; gap: 8px; }

View File

@@ -6,11 +6,13 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { ButtonComponent } from '../../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
import { TableComponent } from '../../../../shared/ui/table/table.component'; import { TableComponent } from '../../../../shared/ui/table/table.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({ @Component({
selector: 'app-admin-analytics-page', selector: 'app-admin-analytics-page',
standalone: true, standalone: true,
imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent], imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent, SkeletonComponent, EmptyStateComponent],
templateUrl: './admin-analytics-page.component.html', templateUrl: './admin-analytics-page.component.html',
styleUrls: ['./admin-analytics-page.component.scss'], styleUrls: ['./admin-analytics-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -2,7 +2,7 @@
<div class="toolbar"> <div class="toolbar">
<div class="filters"> <div class="filters">
<app-input type="search" [ngModel]="filters.search" (ngModelChange)="filtersChange.emit({ search: $event })" [placeholder]="'adminCategories.search' | translate" /> <app-input type="search" [ngModel]="filters.search" (ngModelChange)="filtersChange.emit({ search: $event })" [placeholder]="'adminCategories.search' | translate" />
<select [ngModel]="filters.visibility" (ngModelChange)="filtersChange.emit({ visibility: $event })"> <select [attr.aria-label]="'adminProducts.visibility' | translate" [ngModel]="filters.visibility" (ngModelChange)="filtersChange.emit({ visibility: $event })">
<option value="all">{{ 'adminProducts.allVisibility' | translate }}</option> <option value="all">{{ 'adminProducts.allVisibility' | translate }}</option>
<option value="visible">{{ 'adminProducts.visible' | translate }}</option> <option value="visible">{{ 'adminProducts.visible' | translate }}</option>
<option value="hidden">{{ 'adminProducts.hidden' | translate }}</option> <option value="hidden">{{ 'adminProducts.hidden' | translate }}</option>
@@ -12,7 +12,11 @@
<app-button variant="primary" (click)="create.emit()">{{ 'adminCategories.create' | translate }}</app-button> <app-button variant="primary" (click)="create.emit()">{{ 'adminCategories.create' | translate }}</app-button>
</div> </div>
@if (!loading && rows.length === 0) { @if (loading) {
<div class="skeleton-rows">
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
</div>
} @else if (rows.length === 0) {
<app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate" /> <app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate" />
} @else { } @else {
<app-table> <app-table>

View File

@@ -5,4 +5,5 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color
.check { display: inline-flex; align-items: center; gap: 6px; } .check { display: inline-flex; align-items: center; gap: 6px; }
.actions { display: flex; gap: 10px; align-items: center; } .actions { display: flex; gap: 10px; align-items: center; }
tr.deleted { opacity: 0.55; } tr.deleted { opacity: 0.55; }
.skeleton-rows { display: grid; gap: 8px; }
@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } .actions { flex-direction: column; align-items: stretch; } } @media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } .actions { flex-direction: column; align-items: stretch; } }

View File

@@ -7,6 +7,7 @@ import { InputComponent } from '../../../../shared/ui/input/input.component';
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
import { TableComponent } from '../../../../shared/ui/table/table.component'; import { TableComponent } from '../../../../shared/ui/table/table.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component'; import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
export interface AdminCategoryRow { export interface AdminCategoryRow {
category: AdminCategory; category: AdminCategory;
@@ -16,7 +17,7 @@ export interface AdminCategoryRow {
@Component({ @Component({
selector: 'app-admin-categories-list', selector: 'app-admin-categories-list',
standalone: true, standalone: true,
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, EmptyStateComponent], imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, EmptyStateComponent, SkeletonComponent],
templateUrl: './admin-categories-list.component.html', templateUrl: './admin-categories-list.component.html',
styleUrls: ['./admin-categories-list.component.scss'], styleUrls: ['./admin-categories-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -6,7 +6,7 @@
<div class="dashboard-card__body"> <div class="dashboard-card__body">
<ng-container [ngSwitch]="status"> <ng-container [ngSwitch]="status">
<div class="dashboard-card__skeleton" *ngSwitchCase="'loading'"></div> <app-skeleton *ngSwitchCase="'loading'" shape="rect" height="24px" width="60%" />
<p class="dashboard-card__muted" *ngSwitchCase="'empty'">{{ 'dashboard.stateEmpty' | translate }}</p> <p class="dashboard-card__muted" *ngSwitchCase="'empty'">{{ 'dashboard.stateEmpty' | translate }}</p>

View File

@@ -68,16 +68,3 @@
color: var(--error-color, #ef4444); 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%; }
}

View File

@@ -2,11 +2,12 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { AdminDashboardCardStatus } from '../models/admin-dashboard.model'; import { AdminDashboardCardStatus } from '../models/admin-dashboard.model';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
@Component({ @Component({
selector: 'app-admin-dashboard-card', selector: 'app-admin-dashboard-card',
standalone: true, standalone: true,
imports: [CommonModule, TranslatePipe], imports: [CommonModule, TranslatePipe, SkeletonComponent],
templateUrl: './admin-dashboard-card.component.html', templateUrl: './admin-dashboard-card.component.html',
styleUrls: ['./admin-dashboard-card.component.scss'], styleUrls: ['./admin-dashboard-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -49,12 +49,21 @@
<h2>{{ 'adminMonitoring.events' | translate }}</h2> <h2>{{ 'adminMonitoring.events' | translate }}</h2>
<div class="filters"> <div class="filters">
<app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminOrders.search' | translate" /> <app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminOrders.search' | translate" />
<select [ngModel]="facade.filters().category" (ngModelChange)="facade.updateFilters({ category: $event })"> <select [attr.aria-label]="'adminMonitoring.category' | translate" [ngModel]="facade.filters().category" (ngModelChange)="facade.updateFilters({ category: $event })">
@for (category of categories; track category) { @for (category of categories; track category) {
<option [value]="category">{{ ('adminMonitoring.category.' + category) | translate }}</option> <option [value]="category">{{ ('adminMonitoring.category.' + category) | translate }}</option>
} }
</select> </select>
</div> </div>
@if (facade.loading()) {
<div class="skeleton-rows">
@for (i of [1,2,3]; track i) {
<app-skeleton shape="rect" height="36px" />
}
</div>
} @else if (facade.events().length === 0) {
<app-empty-state [title]="'adminMonitoring.eventsEmptyTitle' | translate" [description]="'adminMonitoring.eventsEmptyDescription' | translate" />
} @else {
<app-table> <app-table>
<thead> <thead>
<tr> <tr>
@@ -77,5 +86,6 @@
} }
</tbody> </tbody>
</app-table> </app-table>
}
</div> </div>
</section> </section>

View File

@@ -7,3 +7,4 @@
.filters { display: flex; flex-wrap: wrap; gap: 10px; } .filters { display: flex; flex-wrap: wrap; gap: 10px; }
select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; } select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
@media (max-width: 700px) { .queue-grid { grid-template-columns: 1fr; } .filters { flex-direction: column; align-items: stretch; } } @media (max-width: 700px) { .queue-grid { grid-template-columns: 1fr; } .filters { flex-direction: column; align-items: stretch; } }
.skeleton-rows { display: grid; gap: 8px; }

View File

@@ -7,11 +7,13 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { InputComponent } from '../../../../shared/ui/input/input.component'; import { InputComponent } from '../../../../shared/ui/input/input.component';
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
import { TableComponent } from '../../../../shared/ui/table/table.component'; import { TableComponent } from '../../../../shared/ui/table/table.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({ @Component({
selector: 'app-admin-monitoring-page', selector: 'app-admin-monitoring-page',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, TranslatePipe, InputComponent, BadgeComponent, TableComponent], imports: [CommonModule, FormsModule, TranslatePipe, InputComponent, BadgeComponent, TableComponent, SkeletonComponent, EmptyStateComponent],
templateUrl: './admin-monitoring-page.component.html', templateUrl: './admin-monitoring-page.component.html',
styleUrls: ['./admin-monitoring-page.component.scss'], styleUrls: ['./admin-monitoring-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -28,7 +28,7 @@
</div> </div>
<div class="card no-print"> <div class="card no-print">
<h3>{{ 'adminOrders.changeStatus' | translate }}</h3> <h3>{{ 'adminOrders.changeStatus' | translate }}</h3>
<select [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)"> <select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)">
@for (status of statuses; track status) { @for (status of statuses; track status) {
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option> <option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
} }

View File

@@ -2,7 +2,7 @@
<div class="toolbar"> <div class="toolbar">
<div class="filters"> <div class="filters">
<app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminOrders.search' | translate" /> <app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminOrders.search' | translate" />
<select [ngModel]="facade.filters().status" (ngModelChange)="facade.updateFilters({ status: $event })"> <select [attr.aria-label]="'backoffice.status' | translate" [ngModel]="facade.filters().status" (ngModelChange)="facade.updateFilters({ status: $event })">
@for (status of statuses; track status) { @for (status of statuses; track status) {
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option> <option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
} }
@@ -11,7 +11,11 @@
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button> <app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
</div> </div>
@if (!facade.loading() && facade.orders().length === 0) { @if (facade.loading()) {
<div class="skeleton-rows">
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
</div>
} @else if (facade.orders().length === 0) {
<app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" /> <app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" />
} @else { } @else {
<app-table> <app-table>

View File

@@ -3,4 +3,5 @@
.filters { display: flex; flex-wrap: wrap; gap: 10px; flex: 1; } .filters { display: flex; flex-wrap: wrap; gap: 10px; flex: 1; }
select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; } select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
.pager { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; } .pager { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.skeleton-rows { display: grid; gap: 8px; }
@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } } @media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } }

View File

@@ -11,11 +11,12 @@ import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
import { TableComponent } from '../../../../shared/ui/table/table.component'; import { TableComponent } from '../../../../shared/ui/table/table.component';
import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component'; import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component'; import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
@Component({ @Component({
selector: 'app-admin-orders-list-page', selector: 'app-admin-orders-list-page',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent], imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, SkeletonComponent],
templateUrl: './admin-orders-list-page.component.html', templateUrl: './admin-orders-list-page.component.html',
styleUrls: ['./admin-orders-list-page.component.scss'], styleUrls: ['./admin-orders-list-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -2,24 +2,24 @@
<div class="toolbar"> <div class="toolbar">
<div class="filters"> <div class="filters">
<app-input type="search" [ngModel]="filters.search" (ngModelChange)="filtersChange.emit({ search: $event })" [placeholder]="'adminProducts.search' | translate" /> <app-input type="search" [ngModel]="filters.search" (ngModelChange)="filtersChange.emit({ search: $event })" [placeholder]="'adminProducts.search' | translate" />
<select [ngModel]="filters.categoryId" (ngModelChange)="filtersChange.emit({ categoryId: $event || null })"> <select [attr.aria-label]="'adminProducts.category' | translate" [ngModel]="filters.categoryId" (ngModelChange)="filtersChange.emit({ categoryId: $event || null })">
<option [ngValue]="null">{{ 'adminProducts.allCategories' | translate }}</option> <option [ngValue]="null">{{ 'adminProducts.allCategories' | translate }}</option>
@for (category of categories; track category.id) { @for (category of categories; track category.id) {
<option [ngValue]="category.id">{{ category.title }}</option> <option [ngValue]="category.id">{{ category.title }}</option>
} }
</select> </select>
<select [ngModel]="filters.visibility" (ngModelChange)="filtersChange.emit({ visibility: $event })"> <select [attr.aria-label]="'adminProducts.visibility' | translate" [ngModel]="filters.visibility" (ngModelChange)="filtersChange.emit({ visibility: $event })">
<option value="all">{{ 'adminProducts.allVisibility' | translate }}</option> <option value="all">{{ 'adminProducts.allVisibility' | translate }}</option>
<option value="visible">{{ 'adminProducts.visible' | translate }}</option> <option value="visible">{{ 'adminProducts.visible' | translate }}</option>
<option value="hidden">{{ 'adminProducts.hidden' | translate }}</option> <option value="hidden">{{ 'adminProducts.hidden' | translate }}</option>
</select> </select>
<select [ngModel]="filters.stock" (ngModelChange)="filtersChange.emit({ stock: $event })"> <select [attr.aria-label]="'adminProducts.stockStatus' | translate" [ngModel]="filters.stock" (ngModelChange)="filtersChange.emit({ stock: $event })">
<option value="all">{{ 'adminProducts.allStock' | translate }}</option> <option value="all">{{ 'adminProducts.allStock' | translate }}</option>
<option value="in_stock">{{ 'adminProducts.inStock' | translate }}</option> <option value="in_stock">{{ 'adminProducts.inStock' | translate }}</option>
<option value="low_stock">{{ 'adminProducts.lowStock' | translate }}</option> <option value="low_stock">{{ 'adminProducts.lowStock' | translate }}</option>
<option value="out_of_stock">{{ 'adminProducts.outOfStock' | translate }}</option> <option value="out_of_stock">{{ 'adminProducts.outOfStock' | translate }}</option>
</select> </select>
<select [ngModel]="filters.sort" (ngModelChange)="filtersChange.emit({ sort: $event })"> <select [attr.aria-label]="'adminProducts.sortTitle' | translate" [ngModel]="filters.sort" (ngModelChange)="filtersChange.emit({ sort: $event })">
<option value="title">{{ 'adminProducts.sortTitle' | translate }}</option> <option value="title">{{ 'adminProducts.sortTitle' | translate }}</option>
<option value="price">{{ 'adminProducts.sortPrice' | translate }}</option> <option value="price">{{ 'adminProducts.sortPrice' | translate }}</option>
<option value="priority">{{ 'adminProducts.sortPriority' | translate }}</option> <option value="priority">{{ 'adminProducts.sortPriority' | translate }}</option>
@@ -40,6 +40,15 @@
</div> </div>
} }
@if (loading) {
<div class="skeleton-rows">
@for (i of [1,2,3,4,5]; track i) {
<app-skeleton shape="rect" height="40px" />
}
</div>
} @else if (products.length === 0) {
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate" />
} @else {
<app-table> <app-table>
<thead> <thead>
<tr> <tr>
@@ -92,4 +101,5 @@
<app-pagination [currentPage]="filters.page" [totalPages]="totalPages()" (pageChange)="filtersChange.emit({ page: $event })" /> <app-pagination [currentPage]="filters.page" [totalPages]="totalPages()" (pageChange)="filtersChange.emit({ page: $event })" />
} }
</div> </div>
}
</section> </section>

View File

@@ -6,3 +6,4 @@ select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color
.pager { justify-content: space-between; flex-wrap: wrap; } .pager { justify-content: space-between; flex-wrap: wrap; }
@media (max-width: 960px) { .filters { grid-template-columns: repeat(2, minmax(140px, 1fr)); } } @media (max-width: 960px) { .filters { grid-template-columns: repeat(2, minmax(140px, 1fr)); } }
@media (max-width: 640px) { .filters { grid-template-columns: 1fr; } .actions { flex-direction: column; align-items: stretch; } } @media (max-width: 640px) { .filters { grid-template-columns: 1fr; } .actions { flex-direction: column; align-items: stretch; } }
.skeleton-rows { display: grid; gap: 8px; }

View File

@@ -7,11 +7,13 @@ import { InputComponent } from '../../../../shared/ui/input/input.component';
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
import { TableComponent } from '../../../../shared/ui/table/table.component'; import { TableComponent } from '../../../../shared/ui/table/table.component';
import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component'; import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({ @Component({
selector: 'app-admin-products-list', selector: 'app-admin-products-list',
standalone: true, standalone: true,
imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent], imports: [FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, SkeletonComponent, EmptyStateComponent],
templateUrl: './admin-products-list.component.html', templateUrl: './admin-products-list.component.html',
styleUrls: ['./admin-products-list.component.scss'], styleUrls: ['./admin-products-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -2,12 +2,12 @@
<div class="toolbar"> <div class="toolbar">
<div class="filters"> <div class="filters">
<app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminTransactions.search' | translate" /> <app-input type="search" [ngModel]="facade.filters().search" (ngModelChange)="facade.updateFilters({ search: $event })" [placeholder]="'adminTransactions.search' | translate" />
<select [ngModel]="facade.filters().status" (ngModelChange)="facade.updateFilters({ status: $event })"> <select [attr.aria-label]="'backoffice.status' | translate" [ngModel]="facade.filters().status" (ngModelChange)="facade.updateFilters({ status: $event })">
@for (status of statuses; track status) { @for (status of statuses; track status) {
<option [value]="status">{{ ('adminTransactions.status.' + status) | translate }}</option> <option [value]="status">{{ ('adminTransactions.status.' + status) | translate }}</option>
} }
</select> </select>
<select [ngModel]="facade.filters().type" (ngModelChange)="facade.updateFilters({ type: $event })"> <select [attr.aria-label]="'adminTransactions.type' | translate" [ngModel]="facade.filters().type" (ngModelChange)="facade.updateFilters({ type: $event })">
@for (type of types; track type) { @for (type of types; track type) {
<option [value]="type">{{ ('adminTransactions.type.' + type) | translate }}</option> <option [value]="type">{{ ('adminTransactions.type.' + type) | translate }}</option>
} }
@@ -16,7 +16,11 @@
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button> <app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
</div> </div>
@if (!facade.loading() && facade.transactions().length === 0) { @if (facade.loading()) {
<div class="skeleton-rows">
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
</div>
} @else if (facade.transactions().length === 0) {
<app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" /> <app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" />
} @else { } @else {
<app-table> <app-table>

View File

@@ -4,4 +4,5 @@
select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; } select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
.pager { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; } .pager { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; } .actions { display: flex; gap: 8px; flex-wrap: wrap; }
.skeleton-rows { display: grid; gap: 8px; }
@media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } } @media (max-width: 640px) { .filters { flex-direction: column; align-items: stretch; } }

View File

@@ -11,11 +11,12 @@ import { TableComponent } from '../../../../shared/ui/table/table.component';
import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component'; import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component'; import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component'; import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
@Component({ @Component({
selector: 'app-admin-transactions-list-page', selector: 'app-admin-transactions-list-page',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, DialogComponent], imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, DialogComponent, SkeletonComponent],
templateUrl: './admin-transactions-list-page.component.html', templateUrl: './admin-transactions-list-page.component.html',
styleUrls: ['./admin-transactions-list-page.component.scss'], styleUrls: ['./admin-transactions-list-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,6 +1,13 @@
<section class="admin-users-page"> <section class="admin-users-page">
<div class="card"> <div class="card">
<h2>{{ 'adminUsers.title' | translate }}</h2> <h2>{{ 'adminUsers.title' | translate }}</h2>
@if (facade.loading()) {
<div class="skeleton-rows">
@for (i of [1,2,3]; track i) { <app-skeleton shape="rect" height="40px" /> }
</div>
} @else if (facade.users().length === 0) {
<app-empty-state [title]="'adminUsers.emptyTitle' | translate" [description]="'adminUsers.emptyDescription' | translate" />
} @else {
<app-table> <app-table>
<thead> <thead>
<tr> <tr>
@@ -18,7 +25,7 @@
<td>{{ user.name }}<br /><small>{{ user.telegramUsername }}</small></td> <td>{{ user.name }}<br /><small>{{ user.telegramUsername }}</small></td>
<td>{{ ('adminUsers.scopeValue.' + user.scope) | translate }}</td> <td>{{ ('adminUsers.scopeValue.' + user.scope) | translate }}</td>
<td> <td>
<select [ngModel]="user.roleId" (ngModelChange)="facade.setRole(user.id, $event)"> <select [attr.aria-label]="'adminUsers.role' | translate" [ngModel]="user.roleId" (ngModelChange)="facade.setRole(user.id, $event)">
@for (role of facade.roles(); track role.id) { @for (role of facade.roles(); track role.id) {
<option [value]="role.id">{{ role.name }}</option> <option [value]="role.id">{{ role.name }}</option>
} }
@@ -35,6 +42,7 @@
} }
</tbody> </tbody>
</app-table> </app-table>
}
</div> </div>
<div class="card"> <div class="card">
@@ -42,12 +50,12 @@
<p class="hint">{{ 'adminUsers.passwordlessHint' | translate }}</p> <p class="hint">{{ 'adminUsers.passwordlessHint' | translate }}</p>
<div class="invite-form"> <div class="invite-form">
<app-input type="email" [ngModel]="inviteEmail()" (ngModelChange)="inviteEmail.set($event)" [placeholder]="'adminUsers.email' | translate" /> <app-input type="email" [ngModel]="inviteEmail()" (ngModelChange)="inviteEmail.set($event)" [placeholder]="'adminUsers.email' | translate" />
<select [ngModel]="inviteRoleId()" (ngModelChange)="inviteRoleId.set($event)"> <select [attr.aria-label]="'adminUsers.role' | translate" [ngModel]="inviteRoleId()" (ngModelChange)="inviteRoleId.set($event)">
@for (role of facade.roles(); track role.id) { @for (role of facade.roles(); track role.id) {
<option [value]="role.id">{{ role.name }}</option> <option [value]="role.id">{{ role.name }}</option>
} }
</select> </select>
<select [ngModel]="inviteScope()" (ngModelChange)="inviteScope.set($event)"> <select [attr.aria-label]="'adminUsers.scope' | translate" [ngModel]="inviteScope()" (ngModelChange)="inviteScope.set($event)">
<option value="marketplace">{{ 'adminUsers.scopeValue.marketplace' | translate }}</option> <option value="marketplace">{{ 'adminUsers.scopeValue.marketplace' | translate }}</option>
<option value="office">{{ 'adminUsers.scopeValue.office' | translate }}</option> <option value="office">{{ 'adminUsers.scopeValue.office' | translate }}</option>
</select> </select>
@@ -79,6 +87,8 @@
} }
</tbody> </tbody>
</app-table> </app-table>
} @else {
<app-empty-state [title]="'adminUsers.invitationsEmptyTitle' | translate" />
} }
</div> </div>

View File

@@ -5,4 +5,5 @@
.invite-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } .invite-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; } select { min-height: 40px; padding: 0 10px; border: 1px solid var(--border-color, #d3dad9); border-radius: 10px; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; } .actions { display: flex; gap: 8px; flex-wrap: wrap; }
.skeleton-rows { display: grid; gap: 8px; }
@media (max-width: 640px) { .invite-form { flex-direction: column; align-items: stretch; } } @media (max-width: 640px) { .invite-form { flex-direction: column; align-items: stretch; } }

View File

@@ -10,11 +10,13 @@ import { InputComponent } from '../../../../shared/ui/input/input.component';
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
import { TableComponent } from '../../../../shared/ui/table/table.component'; import { TableComponent } from '../../../../shared/ui/table/table.component';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component'; import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({ @Component({
selector: 'app-admin-users-page', selector: 'app-admin-users-page',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, DialogComponent], imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, DialogComponent, SkeletonComponent, EmptyStateComponent],
templateUrl: './admin-users-page.component.html', templateUrl: './admin-users-page.component.html',
styleUrls: ['./admin-users-page.component.scss'], styleUrls: ['./admin-users-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush