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:
@@ -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
|
||||
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
|
||||
|
||||
- **Dashboard metrics endpoint.** Categories/Products counts are computed
|
||||
|
||||
@@ -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
|
||||
- Commit: `feat(admin): analytics dashboard`
|
||||
|
||||
## Sprint 28 — Marketplace Polish
|
||||
- [ ] Lighthouse + a11y sweep, animations, skeletons/empty/error states, responsive fixes, SEO/meta/social preview/robots/sitemap
|
||||
## Sprint 28 — Marketplace Polish ✅ done (scoped to admin/* per user decision)
|
||||
- [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`
|
||||
- Not done (full marketplace audit was declined): Lighthouse run, animations, SEO/meta/sitemap, storefront/editor a11y.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -8,7 +8,13 @@
|
||||
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
|
||||
</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-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>
|
||||
@@ -30,6 +36,15 @@
|
||||
|
||||
<div class="card">
|
||||
<h2>{{ 'adminAnalytics.topProducts' | translate }}</h2>
|
||||
@if (facade.loading()) {
|
||||
<div class="skeleton-rows">
|
||||
@for (i of [1,2,3]; track i) {
|
||||
<app-skeleton shape="rect" height="36px" />
|
||||
}
|
||||
</div>
|
||||
} @else if (facade.topProducts().length === 0) {
|
||||
<app-empty-state [title]="'adminAnalytics.topProductsEmptyTitle' | translate" [description]="'adminAnalytics.topProductsEmptyDescription' | translate" />
|
||||
} @else {
|
||||
<app-table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -48,6 +63,7 @@
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card pending-section">
|
||||
|
||||
@@ -11,3 +11,4 @@
|
||||
.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); }
|
||||
@media (max-width: 900px) { .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.skeleton-rows { display: grid; gap: 8px; }
|
||||
|
||||
@@ -6,11 +6,13 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-analytics-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent],
|
||||
imports: [CommonModule, TranslatePipe, ButtonComponent, BadgeComponent, TableComponent, SkeletonComponent, EmptyStateComponent],
|
||||
templateUrl: './admin-analytics-page.component.html',
|
||||
styleUrls: ['./admin-analytics-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<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="visible">{{ 'adminProducts.visible' | 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>
|
||||
</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" />
|
||||
} @else {
|
||||
<app-table>
|
||||
|
||||
@@ -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; }
|
||||
.actions { display: flex; gap: 10px; align-items: center; }
|
||||
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; } }
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InputComponent } from '../../../../shared/ui/input/input.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
|
||||
export interface AdminCategoryRow {
|
||||
category: AdminCategory;
|
||||
@@ -16,7 +17,7 @@ export interface AdminCategoryRow {
|
||||
@Component({
|
||||
selector: 'app-admin-categories-list',
|
||||
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',
|
||||
styleUrls: ['./admin-categories-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<div class="dashboard-card__body">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -68,16 +68,3 @@
|
||||
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%; }
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ 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';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-dashboard-card',
|
||||
standalone: true,
|
||||
imports: [CommonModule, TranslatePipe],
|
||||
imports: [CommonModule, TranslatePipe, SkeletonComponent],
|
||||
templateUrl: './admin-dashboard-card.component.html',
|
||||
styleUrls: ['./admin-dashboard-card.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -49,12 +49,21 @@
|
||||
<h2>{{ 'adminMonitoring.events' | translate }}</h2>
|
||||
<div class="filters">
|
||||
<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) {
|
||||
<option [value]="category">{{ ('adminMonitoring.category.' + category) | translate }}</option>
|
||||
}
|
||||
</select>
|
||||
</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>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -77,5 +86,6 @@
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
.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; }
|
||||
@media (max-width: 700px) { .queue-grid { grid-template-columns: 1fr; } .filters { flex-direction: column; align-items: stretch; } }
|
||||
.skeleton-rows { display: grid; gap: 8px; }
|
||||
|
||||
@@ -7,11 +7,13 @@ import { TranslatePipe } from '../../../../i18n/translate.pipe';
|
||||
import { InputComponent } from '../../../../shared/ui/input/input.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-monitoring-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, InputComponent, BadgeComponent, TableComponent],
|
||||
imports: [CommonModule, FormsModule, TranslatePipe, InputComponent, BadgeComponent, TableComponent, SkeletonComponent, EmptyStateComponent],
|
||||
templateUrl: './admin-monitoring-page.component.html',
|
||||
styleUrls: ['./admin-monitoring-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
<div class="card no-print">
|
||||
<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) {
|
||||
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<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) {
|
||||
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
|
||||
}
|
||||
@@ -11,7 +11,11 @@
|
||||
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
|
||||
</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" />
|
||||
} @else {
|
||||
<app-table>
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
.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; }
|
||||
.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; } }
|
||||
|
||||
@@ -11,11 +11,12 @@ import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||
import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
|
||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-orders-list-page',
|
||||
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',
|
||||
styleUrls: ['./admin-orders-list-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<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>
|
||||
@for (category of categories; track category.id) {
|
||||
<option [ngValue]="category.id">{{ category.title }}</option>
|
||||
}
|
||||
</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="visible">{{ 'adminProducts.visible' | translate }}</option>
|
||||
<option value="hidden">{{ 'adminProducts.hidden' | translate }}</option>
|
||||
</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="in_stock">{{ 'adminProducts.inStock' | translate }}</option>
|
||||
<option value="low_stock">{{ 'adminProducts.lowStock' | translate }}</option>
|
||||
<option value="out_of_stock">{{ 'adminProducts.outOfStock' | translate }}</option>
|
||||
</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="price">{{ 'adminProducts.sortPrice' | translate }}</option>
|
||||
<option value="priority">{{ 'adminProducts.sortPriority' | translate }}</option>
|
||||
@@ -40,6 +40,15 @@
|
||||
</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>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -92,4 +101,5 @@
|
||||
<app-pagination [currentPage]="filters.page" [totalPages]="totalPages()" (pageChange)="filtersChange.emit({ page: $event })" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -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; }
|
||||
@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; } }
|
||||
.skeleton-rows { display: grid; gap: 8px; }
|
||||
|
||||
@@ -7,11 +7,13 @@ import { InputComponent } from '../../../../shared/ui/input/input.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||
import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-products-list',
|
||||
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',
|
||||
styleUrls: ['./admin-products-list.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<div class="toolbar">
|
||||
<div class="filters">
|
||||
<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) {
|
||||
<option [value]="status">{{ ('adminTransactions.status.' + status) | translate }}</option>
|
||||
}
|
||||
</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) {
|
||||
<option [value]="type">{{ ('adminTransactions.type.' + type) | translate }}</option>
|
||||
}
|
||||
@@ -16,7 +16,11 @@
|
||||
<app-button variant="secondary" (click)="exportCsv()">{{ 'adminOrders.export' | translate }}</app-button>
|
||||
</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" />
|
||||
} @else {
|
||||
<app-table>
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
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; }
|
||||
.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; } }
|
||||
|
||||
@@ -11,11 +11,12 @@ import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||
import { PaginationComponent } from '../../../../shared/ui/pagination/pagination.component';
|
||||
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
|
||||
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
|
||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-transactions-list-page',
|
||||
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',
|
||||
styleUrls: ['./admin-transactions-list-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
<section class="admin-users-page">
|
||||
<div class="card">
|
||||
<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>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -18,7 +25,7 @@
|
||||
<td>{{ user.name }}<br /><small>{{ user.telegramUsername }}</small></td>
|
||||
<td>{{ ('adminUsers.scopeValue.' + user.scope) | translate }}</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) {
|
||||
<option [value]="role.id">{{ role.name }}</option>
|
||||
}
|
||||
@@ -35,6 +42,7 @@
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -42,12 +50,12 @@
|
||||
<p class="hint">{{ 'adminUsers.passwordlessHint' | translate }}</p>
|
||||
<div class="invite-form">
|
||||
<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) {
|
||||
<option [value]="role.id">{{ role.name }}</option>
|
||||
}
|
||||
</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="office">{{ 'adminUsers.scopeValue.office' | translate }}</option>
|
||||
</select>
|
||||
@@ -79,6 +87,8 @@
|
||||
}
|
||||
</tbody>
|
||||
</app-table>
|
||||
} @else {
|
||||
<app-empty-state [title]="'adminUsers.invitationsEmptyTitle' | translate" />
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,4 +5,5 @@
|
||||
.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; }
|
||||
.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; } }
|
||||
|
||||
@@ -10,11 +10,13 @@ import { InputComponent } from '../../../../shared/ui/input/input.component';
|
||||
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
|
||||
import { TableComponent } from '../../../../shared/ui/table/table.component';
|
||||
import { 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({
|
||||
selector: 'app-admin-users-page',
|
||||
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',
|
||||
styleUrls: ['./admin-users-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
Reference in New Issue
Block a user