# 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             -> AdminCategoriesListPageComponent
/:lang/backoffice/categories/create      -> AdminCategoryEditorPageComponent
/:lang/backoffice/categories/:id/edit    -> AdminCategoryEditorPageComponent
/: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()`).

## Sprint 20 - Category Management

`features/admin/categories/` (model/gateway/facade/pages/components), same
container/facade/service split as `admin/products` and `admin/dashboard`:

```text
src/app/features/admin/categories/
  models/    admin-category.model.ts
  services/  admin-categories-gateway.interface.ts
             admin-categories-local.gateway.ts
             admin-categories-form.factory.ts
  facade/    admin-categories.facade.ts
  guards/    admin-category-dirty.guard.ts
  components/ admin-categories-list.component.*
              admin-category-form.component.*
  pages/     admin-categories-list-page.component.ts
             admin-category-editor-page.component.ts
```

- **Hierarchy**: `AdminCategory.parentId` (nullable). List page renders a
  flattened, indented tree (`AdminCategoriesFacade.rootCategories()` /
  `childrenOf(id)`); the editor's parent `<select>` excludes the category
  itself and its descendants to prevent cycles.
- **Reordering**: native HTML5 drag-and-drop in
  `admin-categories-list.component.ts` (`draggable`, `dragstart`/`drop`),
  persists via `AdminCategoriesFacade.reorder()` which just rewrites `order`.
- **Delete/restore**: soft delete (`deletedAt` timestamp). Blocked
  client-side (`facade.canDelete()`) if the category has children or
  `itemsCount > 0`; list has an "include deleted" filter with a Restore
  action for soft-deleted rows.
- **Draft/publish**: `status: 'draft' | 'published'`, set by the editor's
  "Save Draft" vs "Publish" buttons (`AdminCategoriesFacade.saveDraft(publish)`).
- **Local draft recovery + unsaved-changes guard**: every `updateDraft()`
  call persists the in-progress category to `localStorage` under
  `admin-category-draft:<id>` (via the existing `LocalStorageService`,
  same pattern as Project Editor autosave); the editor reloads that draft
  ahead of the saved value if present, and is cleared on save.
  `adminCategoryDirtyGuard` (mirrors `projectEditorDirtyGuard`) blocks
  navigation away from an unsaved edit with `window.confirm`.
- **Image**: reuses the existing `MediaPickerComponent` (same one used by
  Media Manager) rather than a free-text URL field.
- **Seed data**: `AdminCategoriesLocalGateway` seeds its in-memory cache from
  `BackofficeDataService.loadCategories()` (`CategoryCardConfig`, currently
  flat/no hierarchy) - same swappable-provider pattern as
  `AdminProductsLocalGateway`.
- **Not yet wired**: `admin/products`' category `<select>` still uses
  `AdminProductsGateway.loadCategories()` (its own `AdminProductCategoryOption`
  seed), not `AdminCategoriesGateway` - unifying them is Sprint 21 scope
  (`docs/SPRINT-PLAN.md`).

## Sprint 21 - Product Management completion

- **Categories now real**: `AdminProductsLocalGateway` seeds its category dropdown from `AdminCategoriesLocalGateway.loadCategories()` (Sprint 20) instead of raw `BackofficeDataService.loadCategories()` - product `categoryId` now points at real admin-managed categories.
- **Archive/restore**: `AdminProduct.archived` (soft, distinct from `visible`). List has an "include archived" filter + per-row Archive/Restore action; archived products excluded by default (mirrors categories' `deletedAt`/restore pattern).
- **Barcode**: added alongside `sku`.
- **Variants**: lightweight `AdminProductVariant[]` (`name`/`price`/`quantity`), edited as `name|price|quantity` lines (same textarea-parse convention as `specifications`/`attributes`). Not a full options-matrix variant system - scoped to what the model/backend contract actually needs today.
- **Related products**: `relatedProductIds: string[]`, checkbox picker in the editor sourced from `AdminProductFormComponent`'s `allProducts` input - which is `AdminProductsFacade.products()`, i.e. whatever page is currently loaded in the facade (usually primed by navigating from the list). Not a full catalog search; fine for the current mock-data scale, worth revisiting if `AdminProductsLocalGateway` is ever swapped for a real API with more than a page of products.
- **Gallery**: `media.gallery` now built via the shared `MediaPickerComponent` (add/remove thumbnails) instead of a raw URL textarea; `media.images`/`media.videos` unchanged (still textarea, out of this ticket's scope).
- **Preview**: simple read-only line in the editor showing computed discounted price.
- **Infinite scroll**: `AdminProductsFacade.infiniteScroll` toggle - when on, `loadMore()` appends the next page to `products()` instead of replacing it; pagination UI swaps for a "Load more" button. Off by default (existing paginated behavior unchanged).

## Sprint 22 - Media System hardening

`core/media/` (`MediaRepository` abstraction, `MockMediaRepository` IndexedDB
implementation) + `features/backoffice/media/` + the shared
`shared/media/media-picker/`:

- **Folders**: flat `folder?: string` tag on `MediaAsset` (no nesting) -
  "New folder" just sets the active filter to a name typed via
  `window.prompt` (mirrors the `window.confirm` pattern already used for
  destructive actions elsewhere); the folder is created implicitly the next
  time something uploads into it. `MediaRepository.listFolders()` derives
  the folder list from existing records rather than a separate folder
  entity - intentionally light, matches the flat-storage reality of an
  IndexedDB mock.
- **Tags**: already existed on `MediaAsset`; added an edit affordance
  (`window.prompt`, comma-separated) and `MediaLibraryFacade.updateTags()`.
- **Validation**: `MockMediaRepository.validateFile()` rejects anything over
  10MB or outside the allow-list (`jpeg/png/webp/gif/svg+xml/pdf`); errors
  now propagate as real messages through `MediaLibraryFacade.error` (both
  `media-library-page` and `media-picker` display it - previously upload
  failures were swallowed into a generic string).
- **SVG sanitization**: `sanitizeSvg()` strips `<script>` tags and
  `on*="..."` attributes from uploaded SVG markup before storing it, since
  SVG is the one accepted format that can carry inline script.
- **Compression/resize**: raster images (not SVG/GIF) are downscaled to a
  2000px max dimension and re-encoded (JPEG/PNG, quality 0.85) via
  `<canvas>` before being stored - client-side only, no crop UI. A full
  interactive cropper was out of scope for this ticket; revisit if a real
  design need for manual cropping shows up.
- **Reuse confirmed**: `MediaPickerComponent` is now wired into Category
  images (Sprint 20), Product gallery (Sprint 21), and Project Editor
  branding (logo / compact logo / favicon, this sprint) - one media library
  for the whole platform, per the sprint goal. Static Pages editor has no
  image fields to wire (confirmed, not a gap). Hero image: no dedicated
  hero-image field exists in `BootstrapConfig` today - nothing to wire.
- **Storage abstraction**: already existed via `MediaRepository` (abstract
  class + DI token `providedIn: 'root'` on `MockMediaRepository`) - swapping
  to a real CDN/backend means implementing `MediaRepository` against a real
  API and rebinding the provider; no consumer (`MediaLibraryFacade`,
  `MediaPickerComponent`, or any of the pickers above) changes.

## Sprint 22 - Media System hardening

`core/media/` (`MediaRepository` abstraction, `MockMediaRepository` IndexedDB
implementation) + `features/backoffice/media/` + the shared
`shared/media/media-picker/`:

- **Folders**: flat `folder?: string` tag on `MediaAsset` (no nesting) -
  "New folder" just sets the active filter to a name typed via
  `window.prompt` (mirrors the `window.confirm` pattern already used for
  destructive actions elsewhere); the folder is created implicitly the next
  time something uploads into it. `MediaRepository.listFolders()` derives
  the folder list from existing records rather than a separate folder
  entity - intentionally light, matches the flat-storage reality of an
  IndexedDB mock.
- **Tags**: already existed on `MediaAsset`; added an edit affordance
  (`window.prompt`, comma-separated) and `MediaLibraryFacade.updateTags()`.
- **Validation**: `MockMediaRepository.validateFile()` rejects anything over
  10MB or outside the allow-list (`jpeg/png/webp/gif/svg+xml/pdf`); errors
  now propagate as real messages through `MediaLibraryFacade.error` (both
  `media-library-page` and `media-picker` display it - previously upload
  failures were swallowed into a generic string).
- **SVG sanitization**: `sanitizeSvg()` strips `<script>` tags and
  `on*="..."` attributes from uploaded SVG markup before storing it, since
  SVG is the one accepted format that can carry inline script.
- **Compression/resize**: raster images (not SVG/GIF) are downscaled to a
  2000px max dimension and re-encoded (JPEG/PNG, quality 0.85) via
  `<canvas>` before being stored - client-side only, no crop UI. A full
  interactive cropper was out of scope for this ticket; revisit if a real
  design need for manual cropping shows up.
- **Reuse confirmed**: `MediaPickerComponent` is now wired into Category
  images (Sprint 20), Product gallery (Sprint 21), and Project Editor
  branding (logo / compact logo / favicon, this sprint) - one media library
  for the whole platform, per the sprint goal. Static Pages editor has no
  image fields to wire (confirmed, not a gap). Hero image: no dedicated
  hero-image field exists in `BootstrapConfig` today ('hero' only appears
  as a `SectionLayoutStrategy` enum value) - nothing to wire.
- **Storage abstraction**: already existed via `MediaRepository` (abstract
  class + DI token `providedIn: 'root'` on `MockMediaRepository`) - swapping
  to a real CDN/backend means implementing `MediaRepository` against a real
  API and rebinding the provider; no consumer (`MediaLibraryFacade`,
  `MediaPickerComponent`, or any of the pickers above) changes.

## Sprint 23 - Orders (mock/local)

`features/admin/orders/` (model/gateway/facade/pages), same
container/facade/service split as the rest of `admin/*`:

- **No real data source exists for orders anywhere in this repo** (already
  called out in Sprint 19's dashboard gap and `docs/backend/BACKEND-INTEGRATION.md#611-backoffice--orders-planned`) -
  `AdminOrdersLocalGateway` seeds 24 deterministic synthetic orders in
  memory (cycling through all statuses/customers) rather than reading from
  `BackofficeDataService`, since there is nothing there to read. This is
  explicitly a placeholder to unblock the admin UI, not a real mock of
  production order volume.
- List: search (order number/customer/email), status filter, pagination,
  CSV export (client-side `Blob` download, no server round-trip).
- Detail: customer/payment/shipping info, itemized line items + total,
  status timeline, change-status dropdown, refund request and cancel
  (both `window.confirm`-gated), customer-visible notes vs internal-only
  notes (two separate free-text logs), print invoice via `window.print()`
  with a `@media print` rule hiding all non-invoice chrome (`.no-print`) -
  no PDF generation library, deliberately minimal.
- Wired into `/​:lang/backoffice/orders` and `/​:lang/backoffice/orders/:id`,
  replacing the coming-soon placeholder.

## Sprint 24 - Transactions (mock/local)

`features/admin/transactions/`. `AdminTransactionsLocalGateway` derives its
mock data from `AdminOrdersLocalGateway`'s 24 seeded orders (one
transaction per order, deterministic type/status/method assignment) rather
than a separate synthetic dataset - keeps order numbers/totals consistent
between the two mock feature areas.

- List: search, status filter, type filter (payment/refund/qr_payment),
  pagination, CSV export.
- Retry failed transactions (`status: 'failed' -> 'retried'`, appends an
  audit entry).
- Fraud flag toggle per transaction.
- Audit log: each transaction carries its own `audit: AdminTransactionAuditEntry[]`
  (creation, retries, fraud-flag changes), viewed via a dialog - this is a
  per-transaction audit trail, not the system-wide audit/security log
  planned for Sprint 26 (Monitoring); the two are intentionally separate
  scopes.
- Wired into `/​:lang/backoffice/transactions`, replacing the coming-soon
  placeholder.

## Sprint 25 - Users & Roles (mock/local)

`features/admin/users/`. Single consolidated page (`admin-users-page`) at
`/​:lang/backoffice/users` - not previously in the Quick Actions list or
routes at all, this is a net-new admin section.

- **Users**: name, Telegram username, `scope` (`marketplace` vs `office`
  admin - distinguishes tenant-level owners/admins from internal staff),
  role, status (`active`/`invited`/`suspended`), last login. Role change is
  an inline `<select>`; suspend/reactivate is confirm-gated for suspend
  only.
- **Roles/permissions**: 4 built-in roles (`owner`/`admin`/`editor`/`viewer`)
  with a flat permission-string list (`products.manage`, `*` for owner,
  etc.) - a real permission catalog and custom-role creation don't exist,
  intentionally scoped down to what's needed to demonstrate the model.
- **Invitations**: email + role + scope form, pending list with revoke.
  No email actually sends - `AdminUsersLocalGateway.inviteUser()` only
  creates the local record.
- **Passwordless login**: already existed before this sprint -
  `AdminAuthService`'s Telegram QR flow (`docs/ADMIN.md`'s existing admin
  login section, `docs/backend/BACKEND-INTEGRATION.md#25-the-admin-authorization-gap-critical--security-relevant-unresolved`). This sprint's Users page links
  to it via a hint, doesn't reimplement it.
- **Session manager / device manager**: per-user session list (device, IP,
  last active, current-session badge) with per-session revoke, mocked
  (`AdminUsersLocalGateway.loadSessions()` fabricates 2 sessions per user
  on first view) - the real `AdminAuthService`/session-cookie flow only
  ever tracks the *current* browser's session, so multi-device session
  listing has no real backend counterpart yet (see `docs/backend/BACKEND-INTEGRATION.md#25-the-admin-authorization-gap-critical--security-relevant-unresolved`).
- **Audit**: per-user audit log (role/status changes), same dialog pattern
  as Sprint 24's per-transaction audit - not the system-wide security/audit
  log planned for Sprint 26.
- Wired into `AdminDashboardFacade`'s Quick Actions list (`dashboard.actionUsers`
  -> `/​:lang/backoffice/users`).

## Sprint 26 - Monitoring (mock/local, health reuses real data)

`features/admin/monitoring/`, single page at `/​:lang/backoffice/monitoring`
(new Dashboard Quick Action).

- **Health**: reuses `AdminDashboardFacade.healthChecks` directly (the same
  real, non-mocked bootstrap-validation checks from Sprint 19's dashboard)
  instead of duplicating the logic - this is the one section on this page
  backed by real data.
- **Audit / security / login / failed-login / API / error / warning
  events**: one unified `AdminMonitoringEvent` feed (`category` + `level`
  discriminators) with category filter + search, seeded with 40
  deterministic synthetic entries by `AdminMonitoringLocalGateway` - no
  logging backend exists anywhere in this system, so there is nothing real
  to read from.
- **Queue monitoring**: 3 mock named queues with depth + status.
- **Webhook monitoring**: mock delivery log (endpoint/event/status/time).
- This is deliberately a separate, system-wide log from the two
  narrower-scoped audit trails added earlier: Sprint 24's per-transaction
  audit and Sprint 25's per-user audit. No consolidation attempted - they
  track different things.

## Sprint 27 - Analytics

`features/admin/analytics/`, net-new `/​:lang/backoffice/analytics` route
+ Dashboard Quick Action.

- **Real, derived data**: revenue/orders/avg-order-value/sales-over-time
  chart/top-products are computed by composing the existing
  `AdminOrdersLocalGateway` (Sprint 23's seeded mock orders) - not a
  separate fabricated dataset. Products/Categories counts come from
  `AdminProductsLocalGateway`/`AdminCategoriesLocalGateway`. All of this is
  still ultimately backed by mock order/product/category data (per those
  sprints), but the *aggregation* is real arithmetic over that data, not
  invented numbers.
- **Visitors, funnels, heatmaps**: no analytics/tracking pipeline exists
  anywhere in this codebase, so these render an explicit
  "Awaiting backend integration" (`pending-backend`) badge, same convention
  as the Sprint 19 dashboard's Orders/Revenue cards before Sprint 23 -
  not fabricated numbers, not a generic empty state.
- **Chart**: a plain inline `<div>`-bar chart driven by `[style.height.%]`,
  no charting library pulled in - reasonable for one sales-over-time series
  at this scale; revisit if more chart types are actually needed.
- **Date ranges**: 7/30/90-day toggle filters orders by `createdAt`.
- **Export**: CSV of the sales series (client-side `Blob` download, same
  pattern as Orders/Transactions).

## Sprint 28 - Marketplace Polish

Full scope per `docs/SPRINT-PLAN.md`: Lighthouse/a11y sweep, animations,
skeleton/empty/error state consistency, responsive fixes, SEO/meta/social
preview/robots/sitemap. Landed across two commits in the same session (an
earlier, narrower "admin/*-only" pass, then this session's follow-up
completing the rest of the brief) - this section describes the combined,
final result, not just the later commit.

- **Design-system consistency (skeleton/empty states)**: audited every
  admin section built in Sprints 20-27 against the shared `app-skeleton` /
  `app-empty-state` primitives (`shared/ui/skeleton`, `shared/ui/empty-state`,
  see their own "add reusable ... primitive" commits). Before this sprint,
  `admin/products`, `admin/users`, `admin/monitoring`, and `admin/analytics`
  had a `loading` facade signal that was never read in the template (blank
  table during fetch, no empty-state fallback); `admin/categories`,
  `admin/orders`, `admin/transactions`, and the media library already had
  `app-empty-state` but no loading skeleton; `admin/dashboard`'s card
  component used a hand-rolled shimmer `<div>` + ad-hoc `<p>` text that
  pre-dated the shared primitives. Fixed: all eight now show `app-skeleton`
  rows/cards while `loading()` is true, then either `app-empty-state` (new
  `adminProducts.emptyTitle`/`adminUsers.emptyTitle`/
  `adminMonitoring.eventsEmptyTitle`/`adminAnalytics.topProductsEmptyTitle`
  + description keys added to `translations.ts`/`en.ts`/`ru.ts`/`hy.ts`) or
  the populated table. `admin-dashboard-card.component.html`'s loading case
  now renders `<app-skeleton shape="rect" height="24px" width="60%" />`
  instead of its own shimmer CSS (removed the now-dead
  `dashboard-card__skeleton` rule + keyframes). Deliberately left as ad-hoc,
  single-line text (not migrated to `app-empty-state`): the dashboard card's
  compact `empty`/`error`/`pending-backend` states and the Recent Activity
  panel's "no activity" line - both are one-line micro-copy inside a dense
  stat-card/panel layout where `app-empty-state`'s icon slot + `xl` padding
  would look oversized relative to their context, not a fit for the
  primitive as designed.
- **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. Manual audit otherwise: `DialogComponent` (`shared/ui/dialog/`)
  already had a real focus trap, Escape-to-close, `aria-modal`, and
  `aria-label` from an earlier sprint - no changes needed. Every `<img>` in
  `src/app/**` was checked for missing `alt` (grepped for `<img` without an
  `alt`/`[alt]`/`[attr.alt]` binding) - none found; all images already have
  real or bound alt text.
- **Animations**: added a global `prefers-reduced-motion: reduce` override
  in `src/styles.scss` that neutralizes animation/transition durations and
  smooth-scroll everywhere, so the many existing hover transforms
  (`.card:hover`, `.btn:hover`, `.product-card:hover`), the `.section`
  fade-in, and every skeleton shimmer respect the OS accessibility setting
  in one place, rather than requiring each component to opt in individually
  (a few, like `shared/ui/skeleton`, already had their own local override).
- **SEO**: `SeoService.resetToDefaults()` (`src/app/services/seo.service.ts`)
  previously hardcoded the site-wide `<title>`/description/OG/Twitter
  defaults (including a reference to a nonexistent `/og-image.jpg`)
  regardless of tenant. It now reads the real `bootstrap.seo.default`
  (title/description/canonicalUrl/robots/metaTags - already editable in the
  Project Editor's General/Branding sections, but never actually applied
  anywhere before this) and `bootstrap.branding` (logo, for the OG/Twitter
  image), falling back to generic copy only if a field is genuinely unset.
  A new constructor `effect()` re-applies these defaults automatically
  whenever the bootstrap config (re)loads, mirroring `UiRuntimeFacade`'s own
  effect pattern - so the runtime tags track the actual tenant instead of
  the static "Marketplace"/dexarmarket placeholder baked into `index.html`
  (which remains as the pre-JS/no-JS-crawler fallback only, unavoidable
  without SSR).
- **Sitemap/robots**: added `public/sitemap.xml` (new) with the statically-
  known top-level marketplace routes (home/catalog/search/wishlist/compare)
  for the default `ru` locale segment, referenced from a new `Sitemap:`
  directive in `public/robots.txt` (which also now blocks
  `/*/backoffice`, `/*/edit`, `/*/project-editor`, and `/__diagnostics`
  from crawling). Documented limitation (not faked): this is a config-driven,
  multi-tenant platform - locales/categories/products/static pages are only
  known at runtime per tenant, not enumerable client-side at build time. A
  real per-tenant sitemap needs a backend/build-time generator - see
  `docs/backend/BACKEND-INTEGRATION.md#619-sitemap-future--static-baseline-only-today`.
- **Responsive**: spot-checked the admin backoffice and customer-facing
  marketplace at mobile/tablet/desktop widths. `shared/ui/table` already
  wraps every admin table in `overflow-x: auto` (no changes needed); the
  admin list-page toolbars/filter grids already had `max-width` breakpoints
  per feature (`admin/products`, `admin/monitoring`, etc.) - added the same
  `.skeleton-rows` grid class alongside those existing breakpoints rather
  than introducing a new layout system.
- **Lighthouse**: no live browser/Lighthouse run in this environment (same
  constraint noted in every prior sprint's admin verification - the guarded
  admin route is blocked from live click-through here); the SEO/a11y/
  animation items above are the manual-audit equivalent of what a
  Lighthouse pass would flag (missing meta tags, missing alt text, motion
  without a reduced-motion fallback, missing loading feedback).
- **Bundle size**: the `700 kB` initial-bundle budget warning (~198 kB over,
  configured in `angular.json`'s production budgets) predates every admin
  sprint in this plan - 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 this pass; would need a main-bundle/core-module audit (Sprint 29's
  "optimize imports/bundle" item) to actually fix.
- **Found but deferred to Sprint 29** (see `docs/KNOWN-ISSUES.md`): almost
  every string across `admin/products`/`admin/categories`/`admin/orders`/
  `admin/transactions`/`admin/users`/`admin/monitoring`/`admin/analytics`
  (~178 distinct `adminXxx.*` translate-pipe keys) has no corresponding
  entry in `translations.ts`/`en.ts`/`ru.ts`/`hy.ts` and renders as a raw
  key string - the same bug class as the dashboard Quick Actions fix in
  `1db63ac`, at much larger scale. Sprint 28 only adds the small number of
  new keys its own empty-state work introduces (see above); authoring the
  full ~178-key backfill is Sprint 29's explicit "translation validation"
  scope, not squeezed into this polish pass.

## Bug-hunt audit pass (2026-07-17)

Same method as the project-editor audit (`docs/EDITOR.md`'s "Bug-hunt audit
pass" section): read `admin/products` and `admin/categories` end to end
(facades, gateways, form factories, guards, presentational components), find
real reproducible bugs (not cosmetic nitpicks), reproduce each live via
`window.ng.getComponent()` on `/:lang/backoffice/{products,categories}/...
?devBypassAdmin=true` before fixing, re-verify after. The real backend is
unreachable in this environment (same constraint as every prior admin
sprint's verification note) - `AdminCategoriesLocalGateway`/
`AdminProductsLocalGateway` sit behind `BackofficeDataService`, which itself
calls out to an HTTP provider that 404s here, so both facades' `loadList()`
error handlers reset to an empty array. Where the list couldn't populate via
the real click-through, bugs were reproduced by seeding
`facade.categories.set([...])`/`facade.products.set([...])` directly with
synthetic rows and driving the exact same facade methods the UI calls - the
gateway calls captured are identical either way, since the facade doesn't
branch on how its signals got populated.

Found 2 real bugs in `admin/categories`, both fixed:

- **Create-category draft recovery was permanently dead + leaked
  `localStorage` forever.** `AdminCategoriesFacade.startCreate()` generated
  the draft's id via `category-${Date.now()}` and read/wrote its autosave
  entry under `admin-category-draft:<that id>`. Since the id is different
  every single call, a draft written during one "create category" visit can
  never be found by a later `startCreate()` call (even seconds later, same
  tab) - the recovery feature the sprint 20 changelog describes ("the editor
  reloads that draft ahead of the saved value if present") never actually
  triggered for new categories, only for edits (stable real `id`). Every
  abandoned create attempt also left an orphaned, never-cleaned
  `localStorage` entry. Fixed by tracking a `draftStorageKey` field on the
  facade, set to a fixed `admin-category-draft:new` key in create mode
  (stable across calls) and to `admin-category-draft:<id>` in edit mode
  (unchanged, already correct); `saveDraft()`/`discardDraftRecovery()` clear
  whichever key is current instead of re-deriving it from the (possibly
  stale) draft id.
  - Verified live: `updateDraft({title})` -> localStorage key
    `admin-category-draft:category-<ts1>`; calling `startCreate()` again
    (simulating navigate-away/back) generated `category-<ts2>` and recovered
    nothing (`title` reset to `''`, `dirty=false`, old key orphaned). After
    the fix, the same sequence recovers the title/dirty state correctly under
    the stable key, and `saveDraft()` clears it.
- **Drag-and-drop category reordering silently did nothing (or moved items
  to the wrong spot) because it wrote duplicate `order` values instead of
  repositioning.** `AdminCategoriesFacade.reorder(id, targetOrder)` took the
  dropped-on row's numeric `order` and wrote that exact value onto the
  dragged category - leaving two siblings tied on the same `order` instead of
  actually reordering. `AdminCategoriesLocalGateway.loadCategories()` sorts
  by `left.order - right.order` using `Array.prototype.sort` (stable), so
  ties break by original array position, not by drop intent - some drags
  silently no-op. Worse, every seeded category starts at `order: 0`
  (`AdminCategoriesLocalGateway.toAdminCategory`), so on fresh data *every*
  drag was a no-op: dropping item C onto item A sent `{ id: 'c', order: 0 }`,
  which was already A's (and C's) value. Fixed by changing the drag payload
  to carry the target's `id` (not its ambiguous/duplicable `order` value);
  `reorder(id, targetId)` now computes the full same-parent sibling sequence
  with the dragged item spliced into the target's position and persists
  sequential `0..n-1` order values for every sibling whose order actually
  changed. Cross-parent drops (`dragged.parentId !== target.parentId`) are a
  no-op, matching the tree UI's existing scope (no reparent-via-drag support
  before or after this fix). Updated end to end:
  `AdminCategoriesListComponent`'s `reorder` output now emits
  `{ id, targetId }` instead of `{ id, targetOrder }`; the list page binding
  follows.
  - Verified live: seeded 3 siblings with distinct orders (0/1/2), dragged
    the last onto the first. Before the fix, the gateway only received
    `{ id: 'c', order: 0 }` (tying `a` and `c`). After the fix, the gateway
    receives the correct 3-way reshuffle: `c:0, a:1, b:2`.

A third bug, found in the same pass, was fixed in a follow-up commit:
`admin-product-form.component` and `admin-category-form.component` both
hardcoded their translation-tab locales to `['en', 'ru', 'hy']` instead of
reading the tenant's actual configured `supportedLocales` (which live on
`ProjectEditorFacade.bootstrap()`, the same source
`static-pages-editor.component.ts` already reads correctly) - neither
`AdminProductsFacade` nor `AdminCategoriesFacade` depended on project-editor
state at all before this. Fixed by giving both facades a `supportedLocales`
computed (`bootstrap()?.localization.supportedLocales ?? ['en']`) and an
`ensureLocalesLoaded()` that calls `ProjectEditorFacade.loadBootstrap()` if
it hasn't loaded yet (same lazy-load pattern
`AdminDashboardFacade.ensureLoaded()` already uses for the same dependency);
both editor pages call it in their constructor and pass
`[locales]="facade.supportedLocales()"` down to the form components, which
now iterate a `locales: string[]` `@Input()` instead of the literal array.
Verified live: both `facade.supportedLocales()` and the form's bound
`locales` changed from the hardcoded `['en','ru','hy']` to the real tenant
order `['ru','en','hy']` (default locale first), confirmed by the rendered
tab order in both editors.

The already-documented, deliberately-scoped-down items from earlier sprints
(related-products picker limited to the current page, not a full catalog
search; the ~178 untranslated `adminXxx.*` i18n keys) were re-confirmed
during this pass and are unchanged - see their existing sections above and
`docs/KNOWN-ISSUES.md`.

## 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.
