Files
marketplaces/docs/ADMIN.md
sdarbinyan d853ecb1da
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
changes
2026-07-19 15:28:35 +04:00

38 KiB
Raw Blame 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):

/: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

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:

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.