Files
marketplaces/docs/ADMIN.md

630 lines
37 KiB
Markdown
Raw Normal View History

# 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.md` item 7) -
`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.md` item 1). 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.md` item 1).
- **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.md` item 17.
- **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`.
One real gap was found but **not** fixed inline (real feature work, not a
wiring bug - see `docs/KNOWN-ISSUES.md`): both `admin-product-form.component`
and `admin-category-form.component` hardcode their translation-tab locales to
`['en', 'ru', 'hy']` rather than reading the tenant's actual configured
`supportedLocales` (which live on `ProjectEditorFacade.bootstrap()`, the same
source `static-pages-editor.component.ts` already reads correctly). Wiring
this up means threading `supportedLocales` from `ProjectEditorFacade` through
`AdminProductsFacade`/`AdminCategoriesFacade` (neither currently depends on
project-editor state at all) down to two presentational form components -
real cross-feature plumbing, not a bounded bug fix.
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.