Step 1-2 (audit + plan): classified 35 project markdown files into Core/Architecture/ADR/Temporary-audit/Sprint-report/Generated-review/ Duplicate/Obsolete/Historical. Agent-tooling files (.agents/skills/**, .superpowers/**, docs/context/**, CLAUDE.md/GEMINI.md/AGENTS.md/ .github/copilot-instructions.md) explicitly out of scope — intentional per-tool duplication, not documentation debt. Step 3 (merge, no information lost): - docs/PROJECT.md -> docs/PROJECT_INDEX.md, rewritten as the single entry point: system overview, living-doc index, archive pointer, current status, and a critical-finding callout up top. - docs/backend/BACKEND-INTEGRATION.md -> docs/BACKEND_API.md, docs/backend/REMAINING-BACKEND-WORK.md -> docs/BACKEND_API_REMAINING_WORK.md (also folded in a legitimate uncommitted status update that had been sitting unstaged all session: categories marked DONE, order-creation endpoint noted done). - RELEASE-NOTES.md merged into CHANGELOG.md (was a near-duplicate of the same release content in friendlier prose), then deleted. - KNOWN-ISSUES.md: added item 13 (see below) and item 14 (missing canDeactivate on admin/products edit, from the archived PROJECT-STATE audit, re-verified still true); added a correction note to Fixed item 7. - All cross-references to renamed/moved files fixed across every kept doc (grep+sed pass, then verified with a link-existence check across all 58 in-scope markdown files -> 0 broken links). Step 4 (archive, nothing deleted without merging first): created docs/archive/, moved 19 files there (3 root sprint reports, 1 platform report, SPRINT-PLAN.md, and 14 one-off audit/review/report docs). Added correction headers to the 3 archived docs whose conclusions were affected by the finding below, rather than silently leaving them misleading. Step 5: docs/PROJECT_INDEX.md rewritten per the mission brief - someone opening the repo should understand the whole system from it. IMPORTANT FINDING (surfaced during this audit, not the mission's primary goal but too significant to bury): pages/category/*, pages/search/*, pages/item-detail/*, pages/info/**, pages/legal/** (40+ files) are entirely unrouted dead code - app.routes.ts's cmsContentRoutes is a literal empty array, and category/search/product routes redirect to CatalogContainerComponent/ ProductDetailsContainerComponent, not these files. Confirmed against app.routes.ts directly and cross-checked against FRONTEND.md's own routing description. This means several fixes from earlier this cycle (RC-Premium-01, RC STORE-01) and the dead-code cleanup sprint's conclusion that these files were live were all wrong - documented as KNOWN-ISSUES.md item 13, flagged at the top of PROJECT_INDEX.md, and noted on the 3 archived docs whose conclusions it affects. No application code was changed to fix this (out of scope per this session's 'documentation only' constraint) - it needs a wire-it-up-or- delete-it decision first. Verification: tsc --noEmit clean, npm run build green, all markdown links across 58 in-scope files resolve (checked programmatically). No application/Angular/backend code modified. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
639 lines
38 KiB
Markdown
639 lines
38 KiB
Markdown
# 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_API.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_API.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_API.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_API.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.
|