Adds docs/ADMIN.md's "Bug-hunt audit pass (2026-07-17)" section (mirrors docs/EDITOR.md's) covering both fixes from this session (dead create-category draft recovery, duplicate-order drag-reorder) with repro and live-verification detail, plus the one deferred finding (hardcoded en/ru/hy translation-tab locales in both admin form components instead of the tenant's configured supportedLocales - real cross-feature plumbing, not a bounded fix). Mirrors the same summary into docs/KNOWN-ISSUES.md: both bugs into Fixed, the locale-hardcoding gap into Open as item 6.
37 KiB
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(newInjectionToken, same swap pattern asBACKOFFICE_DATA_PROVIDER) - defaults toAdminDashboardMetricsLocalGateway, which composesBackofficeDataService.loadCategories()/loadProducts()(already used byAdminProductsLocalGateway) into counts. Backs Categories Count and Products Count. Swapping to a real dashboard-metrics endpoint later means implementingAdminDashboardMetricsGatewayand rebinding the token - the facade and cards don't change.AdminDashboardHistoryService(new) - localStorage-backed activity log, scoped per tenant, same pattern asProjectEditorDraftStorageService. The facade appends an entry wheneverlastSavedAt/lastPublishedAtchange (detected via aneffect(), 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-pagetranslationsentry.layoutIssues()- flagsbootstrap.layout.typeor any section'slayout.strategythat 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 viaAdminCategoriesFacade.reorder()which just rewritesorder. - Delete/restore: soft delete (
deletedAttimestamp). Blocked client-side (facade.canDelete()) if the category has children oritemsCount > 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 tolocalStorageunderadmin-category-draft:<id>(via the existingLocalStorageService, same pattern as Project Editor autosave); the editor reloads that draft ahead of the saved value if present, and is cleared on save.adminCategoryDirtyGuard(mirrorsprojectEditorDirtyGuard) blocks navigation away from an unsaved edit withwindow.confirm. - Image: reuses the existing
MediaPickerComponent(same one used by Media Manager) rather than a free-text URL field. - Seed data:
AdminCategoriesLocalGatewayseeds its in-memory cache fromBackofficeDataService.loadCategories()(CategoryCardConfig, currently flat/no hierarchy) - same swappable-provider pattern asAdminProductsLocalGateway. - Not yet wired:
admin/products' category<select>still usesAdminProductsGateway.loadCategories()(its ownAdminProductCategoryOptionseed), notAdminCategoriesGateway- unifying them is Sprint 21 scope (docs/SPRINT-PLAN.md).
Sprint 21 - Product Management completion
- Categories now real:
AdminProductsLocalGatewayseeds its category dropdown fromAdminCategoriesLocalGateway.loadCategories()(Sprint 20) instead of rawBackofficeDataService.loadCategories()- productcategoryIdnow points at real admin-managed categories. - Archive/restore:
AdminProduct.archived(soft, distinct fromvisible). 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 asname|price|quantitylines (same textarea-parse convention asspecifications/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 fromAdminProductFormComponent'sallProductsinput - which isAdminProductsFacade.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 ifAdminProductsLocalGatewayis ever swapped for a real API with more than a page of products. - Gallery:
media.gallerynow built via the sharedMediaPickerComponent(add/remove thumbnails) instead of a raw URL textarea;media.images/media.videosunchanged (still textarea, out of this ticket's scope). - Preview: simple read-only line in the editor showing computed discounted price.
- Infinite scroll:
AdminProductsFacade.infiniteScrolltoggle - when on,loadMore()appends the next page toproducts()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?: stringtag onMediaAsset(no nesting) - "New folder" just sets the active filter to a name typed viawindow.prompt(mirrors thewindow.confirmpattern 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) andMediaLibraryFacade.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 throughMediaLibraryFacade.error(bothmedia-library-pageandmedia-pickerdisplay it - previously upload failures were swallowed into a generic string). - SVG sanitization:
sanitizeSvg()strips<script>tags andon*="..."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:
MediaPickerComponentis 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 inBootstrapConfigtoday - nothing to wire. - Storage abstraction: already existed via
MediaRepository(abstract class + DI tokenprovidedIn: 'root'onMockMediaRepository) - swapping to a real CDN/backend means implementingMediaRepositoryagainst 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?: stringtag onMediaAsset(no nesting) - "New folder" just sets the active filter to a name typed viawindow.prompt(mirrors thewindow.confirmpattern 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) andMediaLibraryFacade.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 throughMediaLibraryFacade.error(bothmedia-library-pageandmedia-pickerdisplay it - previously upload failures were swallowed into a generic string). - SVG sanitization:
sanitizeSvg()strips<script>tags andon*="..."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:
MediaPickerComponentis 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 inBootstrapConfigtoday ('hero' only appears as aSectionLayoutStrategyenum value) - nothing to wire. - Storage abstraction: already existed via
MediaRepository(abstract class + DI tokenprovidedIn: 'root'onMockMediaRepository) - swapping to a real CDN/backend means implementingMediaRepositoryagainst 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.mditem 7) -AdminOrdersLocalGatewayseeds 24 deterministic synthetic orders in memory (cycling through all statuses/customers) rather than reading fromBackofficeDataService, 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
Blobdownload, 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 viawindow.print()with a@media printrule hiding all non-invoice chrome (.no-print) - no PDF generation library, deliberately minimal. - Wired into
/:lang/backoffice/ordersand/: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(marketplacevsofficeadmin - 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.mditem 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 realAdminAuthService/session-cookie flow only ever tracks the current browser's session, so multi-device session listing has no real backend counterpart yet (seedocs/BACKEND.mditem 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.healthChecksdirectly (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
AdminMonitoringEventfeed (category+leveldiscriminators) with category filter + search, seeded with 40 deterministic synthetic entries byAdminMonitoringLocalGateway- 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 fromAdminProductsLocalGateway/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
Blobdownload, 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-stateprimitives (shared/ui/skeleton,shared/ui/empty-state, see their own "add reusable ... primitive" commits). Before this sprint,admin/products,admin/users,admin/monitoring, andadmin/analyticshad aloadingfacade 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 hadapp-empty-statebut 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 showapp-skeletonrows/cards whileloading()is true, then eitherapp-empty-state(newadminProducts.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-deaddashboard-card__skeletonrule + keyframes). Deliberately left as ad-hoc, single-line text (not migrated toapp-empty-state): the dashboard card's compactempty/error/pending-backendstates and the Recent Activity panel's "no activity" line - both are one-line micro-copy inside a dense stat-card/panel layout whereapp-empty-state's icon slot +xlpadding would look oversized relative to their context, not a fit for the primitive as designed.
- description keys added to
- Accessibility: every bare
<select>acrossadmin/categories,admin/products,admin/orders,admin/transactions,admin/users, andadmin/monitoringthat wasn't already inside a<label>(which provides implicit association) now has an explicitaria-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, andaria-labelfrom an earlier sprint - no changes needed. Every<img>insrc/app/**was checked for missingalt(grepped for<imgwithout analt/[alt]/[attr.alt]binding) - none found; all images already have real or bound alt text. - Animations: added a global
prefers-reduced-motion: reduceoverride insrc/styles.scssthat neutralizes animation/transition durations and smooth-scroll everywhere, so the many existing hover transforms (.card:hover,.btn:hover,.product-card:hover), the.sectionfade-in, and every skeleton shimmer respect the OS accessibility setting in one place, rather than requiring each component to opt in individually (a few, likeshared/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 realbootstrap.seo.default(title/description/canonicalUrl/robots/metaTags - already editable in the Project Editor's General/Branding sections, but never actually applied anywhere before this) andbootstrap.branding(logo, for the OG/Twitter image), falling back to generic copy only if a field is genuinely unset. A new constructoreffect()re-applies these defaults automatically whenever the bootstrap config (re)loads, mirroringUiRuntimeFacade's own effect pattern - so the runtime tags track the actual tenant instead of the static "Marketplace"/dexarmarket placeholder baked intoindex.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 defaultrulocale segment, referenced from a newSitemap:directive inpublic/robots.txt(which also now blocks/*/backoffice,/*/edit,/*/project-editor, and/__diagnosticsfrom 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 - seedocs/BACKEND.mditem 17. - Responsive: spot-checked the admin backoffice and customer-facing
marketplace at mobile/tablet/desktop widths.
shared/ui/tablealready wraps every admin table inoverflow-x: auto(no changes needed); the admin list-page toolbars/filter grids already hadmax-widthbreakpoints per feature (admin/products,admin/monitoring, etc.) - added the same.skeleton-rowsgrid 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 kBinitial-bundle budget warning (~198 kB over, configured inangular.json's production budgets) predates every admin sprint in this plan - already present at Sprint 20's first build, before any offeatures/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 acrossadmin/products/admin/categories/admin/orders/admin/transactions/admin/users/admin/monitoring/admin/analytics(~178 distinctadminXxx.*translate-pipe keys) has no corresponding entry intranslations.ts/en.ts/ru.ts/hy.tsand renders as a raw key string - the same bug class as the dashboard Quick Actions fix in1db63ac, 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
localStorageforever.AdminCategoriesFacade.startCreate()generated the draft's id viacategory-${Date.now()}and read/wrote its autosave entry underadmin-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 laterstartCreate()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 realid). Every abandoned create attempt also left an orphaned, never-cleanedlocalStorageentry. Fixed by tracking adraftStorageKeyfield on the facade, set to a fixedadmin-category-draft:newkey in create mode (stable across calls) and toadmin-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 keyadmin-category-draft:category-<ts1>; callingstartCreate()again (simulating navigate-away/back) generatedcategory-<ts2>and recovered nothing (titlereset to'',dirty=false, old key orphaned). After the fix, the same sequence recovers the title/dirty state correctly under the stable key, andsaveDraft()clears it.
- Verified live:
- Drag-and-drop category reordering silently did nothing (or moved items
to the wrong spot) because it wrote duplicate
ordervalues instead of repositioning.AdminCategoriesFacade.reorder(id, targetOrder)took the dropped-on row's numericorderand wrote that exact value onto the dragged category - leaving two siblings tied on the sameorderinstead of actually reordering.AdminCategoriesLocalGateway.loadCategories()sorts byleft.order - right.orderusingArray.prototype.sort(stable), so ties break by original array position, not by drop intent - some drags silently no-op. Worse, every seeded category starts atorder: 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'sid(not its ambiguous/duplicableordervalue);reorder(id, targetId)now computes the full same-parent sibling sequence with the dragged item spliced into the target's position and persists sequential0..n-1order 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'sreorderoutput 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 }(tyingaandc). After the fix, the gateway receives the correct 3-way reshuffle:c:0, a:1, b:2.
- Verified live: seeded 3 siblings with distinct orders (0/1/2), dragged
the last onto the first. Before the fix, the gateway only received
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 viaBACKOFFICE_DATA_PROVIDER). A dedicated/builder/dashboard/summary-style endpoint would letAdminDashboardMetricsGatewayreturn 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.