diff --git a/docs/BACKEND.md b/docs/BACKEND.md
index 4eefb80..f347041 100644
--- a/docs/BACKEND.md
+++ b/docs/BACKEND.md
@@ -168,8 +168,12 @@ Each nested field, with its source model file under
- **`tenant`** (`TenantConfig`, `tenant.model.ts`) —
`{ id (UUID), slug, code, host, name, websiteBaseUrl, builderBaseUrl,
backofficeBaseUrl, defaultLocale, supportedLocales[], defaultCurrency,
- supportedCurrencies[], timezone }`. Identifies the tenant and its per-surface
- base URLs, locale/currency sets, and timezone.
+ supportedCurrencies[], timezone, documentationUrl? }`. Identifies the tenant
+ and its per-surface base URLs, locale/currency sets, and timezone.
+ `documentationUrl` (optional string) is the external docs link rendered by
+ the backoffice "Documentation" nav item — when absent, that nav item shows
+ as a disabled `comingSoon` entry instead of a link (frontend-only fallback,
+ no backend action required beyond optionally sending the field).
- **`branding`** (`BrandingConfig`, `branding.model.ts`) —
`{ brandName, legalName, slogan?, logoUrl, logoCompactUrl?, faviconUrl,
appIconUrl?, socialImageUrl?, galleryUrls?, supportEmail?, supportPhone? }`.
@@ -304,7 +308,8 @@ trimmed for length; full versions in that file):
"supportedLocales": ["ru", "en", "hy"],
"defaultCurrency": "RUB",
"supportedCurrencies": ["RUB", "USD", "EUR", "AMD"],
- "timezone": "Europe/Moscow"
+ "timezone": "Europe/Moscow",
+ "documentationUrl": "https://docs.marketplace.local"
},
"branding": {
"brandName": "Marketplace",
diff --git a/docs/DEAD-CONFIG-AUDIT.md b/docs/DEAD-CONFIG-AUDIT.md
new file mode 100644
index 0000000..630ad80
--- /dev/null
+++ b/docs/DEAD-CONFIG-AUDIT.md
@@ -0,0 +1,68 @@
+# Dead-Config Audit (Sprint G)
+
+Mechanical sweep of every field in `BootstrapConfig` and its sub-models
+(`src/app/shared/models/config/*.model.ts`), cross-referenced against
+`src/app/features/project-editor/schema/editor-schema.ts` (`SECTION_FIELD_SCHEMAS`)
+to find fields that are editable in the Project Editor but have no real runtime
+consumer — the same bug class as `HeaderConfig.showProfile` and `layout.columns`
+(both fixed earlier this cycle). Non-editable fields are listed for completeness
+but were not a priority (nothing in the editor lets a client set them, so there's
+no ghost-setting UX to fix).
+
+Status legend: **live** (read, has effect) / **dead** (never read outside the
+editor) / **inert** (read, but the effect is unreachable or a stub) / **n/a**
+(not client-editable today, lower priority per sprint scope).
+
+## Editable fields (client-facing — checked first)
+
+| Field | Status | Recommendation | Outcome |
+|---|---|---|---|
+| `header.show*` (8 flags) | live | none | `header.component.html` reads every one |
+| `theme.palette.*` (12 colors) | live | none | `theme-css-vars.mapper.ts` |
+| `theme.mode` | inert | needs decision | already tracked in `PRODUCT_BACKLOG.md` |
+| `layout.type` ("Site Layout") | **dead** | needs decision | see below — not fixed this pass |
+| `branding.brandName/logoUrl/logoCompactUrl/faviconUrl` | live | none | header/footer/meta consumers |
+| `seo.default.title/description` | live | none | `seo.service.ts` |
+| `localization.defaultLocale/supportedLocales` | live | none | language switching |
+| `tenant.host/websiteBaseUrl` | inert by design | none | frontend never resolves its own tenant (ADR-001) — this is backend routing metadata, not something the SPA is meant to read back |
+| `company.companyName` | **dead** | needs decision | see below — not fixed this pass |
+| `company.address.street` | **dead → fixed** | wire | now shown in footer bottom bar |
+| `company.contacts.phone` | **dead → fixed** | wire | now shown in footer bottom bar (`tel:` link) |
+| `company.contacts.email` | live | none | `ui-runtime.facade.ts` fallback chain |
+| `footer.copyrightText/paymentIcons/socialLinks/columns` | live | none | `footer-resolver.service.ts` |
+| `footer.logoUrl` | **dead → fixed** | wire | `LogoComponent` gained `srcOverride`, footer passes it |
+| `catalog.navigationMode` | inert (deliberate placeholder) | leave as-is | renders a labeled placeholder card + `catalog.navigationPlaceholder` i18n string; the alternate nav UIs (mega-menu, top-carousel, left-nav) don't exist yet — building them is a real feature, not a wiring fix |
+| `catalog.suggestionsEnabled` | **dead → fixed** | wire | `SearchFacade.autocomplete()` now short-circuits to no suggestions when false |
+| `catalog.searchHistoryEnabled` | live | none | `catalog-container.component.ts` |
+| `productPage.questions.*` | live | none | `product-details-container.component.ts` |
+| `userExperience.recentlyViewed.enabled` | live | none | multiple consumers |
+| `navigation.header` | **dead** | needs decision | see below — not fixed this pass |
+| `navigation.footer` | live | none | `footer-resolver.service.ts` fallback tier |
+| `pages` / `staticPages` | live | none | core rendering pipeline |
+
+## Non-editable fields (lower priority — `n/a`)
+
+`branding.legalName/slogan/supportPhone/appIconUrl/galleryUrls`,
+`company.registrationNumber/taxId`, `tenant.defaultCurrency/supportedCurrencies/timezone`,
+`featureFlags.blog/chat/coupons/loyalty/giftCards/invoices`,
+`features.brands/manufacturers`, `permissions.definitions/roles` (used elsewhere,
+not via this config path), `userExperience.recentlyViewed.widgetEnabled`,
+`catalog.showBreadcrumbs/showCategoryBanner/showSubcategoryChips/enabledFilters/availableSorts/defaultSort`
+— none of these have an editor control today, so no client can create a false
+expectation by setting them. Flagged here for completeness; no action taken.
+
+## Fixed this pass (trivially wireable)
+
+1. **`footer.logoUrl`** — `LogoComponent` (`src/app/components/logo/logo.component.ts`) gained an optional `srcOverride` input; `FooterResolverService`/`FooterComponent` now resolve and pass `footer.logoUrl`, falling back to the brand logo exactly as before when unset.
+2. **`company.address.street` / `company.contacts.phone`** — `UiRuntimeFacade` gained `contactPhone()`/`companyAddress()` (same fallback pattern as the existing `contactEmail()`); footer bottom bar now renders a `tel:` link and the address next to the existing email link when present.
+3. **`catalog.suggestionsEnabled`** — `SearchFacade.autocomplete()` now reads the bootstrap snapshot and returns no suggestions when the flag is `false`, instead of always running autocomplete regardless of the toggle.
+
+## Left dead, tracked (needs a decision, not a mechanical fix)
+
+- **`layout.type`** ("Site Layout" selector, Theme section) — top-level `BootstrapConfig.layout` is edited but never applied to any page; page layout comes entirely from each `PageConfig.layout` (see `SectionEngineService.resolveLayoutType`), which this global selector doesn't touch. Wiring it requires deciding *which* page(s) it should drive (homepage only? every page without its own override?) — a product decision, not a mechanical fix. Tracked in `docs/PRODUCT_BACKLOG.md`.
+- **`company.companyName`** — Footer editor has a "Company Name" field with zero runtime consumers. The footer already has a copyright fallback (`© {year} {brandName}`, `footer.component.html`) using `branding.brandName`, not `company.companyName` — these are meant to be distinct (brand vs. legal entity name), so blindly reusing one for the other would be a content decision, not a safe mechanical fix. Tracked in `docs/PRODUCT_BACKLOG.md`.
+- **`navigation.header`** — editable list of header nav items in the Navigation section, but `HeaderComponent` never reads `NavigationConfig.header` at all; the header's own category menu comes from `CategoryFacade`, not this list. Rendering an actual configurable top-nav (positioning, active-state, children/dropdowns) is real feature work, not a one-line wire. Tracked in `docs/KNOWN-ISSUES.md`.
+
+## Not touched
+
+`theme.mode` (dark mode) stays exactly as already tracked in `docs/PRODUCT_BACKLOG.md` — no new information found, confirmed still inert.
diff --git a/docs/EDITOR.md b/docs/EDITOR.md
index 0fdd25b..d9169ed 100644
--- a/docs/EDITOR.md
+++ b/docs/EDITOR.md
@@ -158,6 +158,6 @@ A section-by-section correctness audit (not a feature pass) — for each section
### Known gaps found but not fixed (real, out of scope for this pass)
-- **Theme Mode has no runtime effect.** `theme-section`'s light/dark/system selector correctly saves and sets a `data-theme-mode` attribute (`theme-engine.service.ts`), but zero CSS anywhere in the app reads that attribute — picking Dark or System currently changes nothing visually. (Theme palette colors *are* live — real CSS custom properties consumed throughout the stylesheets — only the mode switch is dead.) Fixing this is a real dark-mode implementation project (dark palette + CSS strategy + `matchMedia` for "system"), not a wiring fix.
-- **`layout.type` (Site Layout) and the homepage section's `type` field both feed a rendering pipeline that was never wired up.** `src/app/dynamic-renderer/` has services/models for page/section/widget rendering but zero components or templates (every directory has only a `.gitkeep`) — the storefront homepage renders through a separate, older path that ignores both fields. `homepage-section.component.ts`'s `updateSection(id, 'type', ...)` has no UI calling it because of this; not built, since building UI for a field nothing reads would be inventing dead controls.
-- **`HeaderConfig.showProfile`** is a real toggle in `header-section` with no corresponding profile/account menu anywhere in `header.component.html` — the toggle currently does nothing. Building the actual menu is a feature (needs an auth-system check first), not an editor-wiring fix.
+- **Theme Mode has no runtime effect.** `theme-section`'s light/dark/system selector correctly saves and sets a `data-theme-mode` attribute (`theme-engine.service.ts`), but zero CSS anywhere in the app reads that attribute — picking Dark or System currently changes nothing visually. (Theme palette colors *are* live — real CSS custom properties consumed throughout the stylesheets — only the mode switch is dead.) Fixing this is a real dark-mode implementation project (dark palette + CSS strategy + `matchMedia` for "system"), not a wiring fix. Tracked: `docs/PRODUCT_BACKLOG.md`.
+- ~~`HeaderConfig.showProfile` has no corresponding profile/account menu~~ — **fixed**: `header.component.html`/`.ts` now render a login/logout-only control (no dropdown, no account links) gated by this toggle, reusing the customer Telegram `AuthService`. See `docs/KNOWN-ISSUES.md` "Fixed (this cycle)" and `docs/GLOBAL-SPRINT-PLAN.md` Sprint A.
+- ~~`layout.type`/homepage `type` field feed an unwired `dynamic-renderer/`~~ — **stale, corrected**: `dynamic-renderer/` (`PageRendererService`/`SectionRendererService`/`WidgetHostService`) is the live homepage rendering pipeline, wired through `dynamic-page-layout.component.ts`. Verified fixed/non-issue in `docs/KNOWN-ISSUES.md` "Fixed (this cycle)".
diff --git a/docs/FUTURE_FEATURES.md b/docs/FUTURE_FEATURES.md
index c95218f..0c2b146 100644
--- a/docs/FUTURE_FEATURES.md
+++ b/docs/FUTURE_FEATURES.md
@@ -4,7 +4,13 @@ Nice-to-have, non-blocking work — no client decision needed, just not worth do
## Cart payment modal → `app-dialog` migration
-`.bank-payment-modal` on the cart page is a custom overlay component with its own focus-trap (added during the WCAG audit) rather than the shared `app-dialog` primitive. Functionally and accessibly complete as-is — migrating it to the shared primitive is a composition cleanup, deliberately deferred across every polish pass so far because it touches multi-step payment state.
+**Done, 2026-08-06.** `.payment-modal`/`.bank-payment-modal` on the cart page now render through the shared `app-dialog` primitive instead of hand-rolled overlays. Two earlier same-session attempts were reverted before landing (one stopped cleanly after finding real conflicts, one botched the sequencing — deleted the old focus-trap before finishing the swap); this pass fixed the actual API gaps first, then migrated, then verified live in a browser before shipping:
+
+- `DialogComponent` gained `closeOnEscape`/`closeOnBackdropClick` inputs (default `true`, backward-compatible with its other 13 call sites) and an `ariaLabel` input (for dialogs with no visible title header — cart's modals render their own close button in content instead). `FOCUSABLE_SELECTOR` now includes `iframe` (needed for the bank-payment panel's focus trap).
+- Cart wires `[closeOnBackdropClick]="false"` on both dialogs (in-flight payment shouldn't cancel on a stray click) and `[closeOnEscape]="!showBankPaymentPopup()"` on the QR/status dialog (so Escape closes the bank iframe first, falls back to the QR view, matches the original nested-modal priority).
+- Exact original geometry (500px QR modal, 40px padding; 960×760 bank iframe modal, 56/16/16 padding, both mobile breakpoints) preserved via `:host ::ng-deep` overrides on `.app-dialog-panel`/`.app-dialog-panel__body`/`.app-dialog-backdrop`, scoped per-instance via `.payment-dialog`/`.bank-payment-dialog` host classes — same `::ng-deep` pattern already used by `product-carousel-widget.component.ts`.
+- `cart.component.ts` lost its hand-rolled `@ViewChild`/`@HostListener`/focus-trap methods (~90 lines) — `app-dialog` owns all of that now.
+- Verified live: both dialogs render at correct size/padding/aria-label at mobile and desktop breakpoints, backdrop-click confirmed inert, Escape-priority confirmed (closes bank first, then QR), initial focus confirmed landing on the close button. 83/83 tests pass, tsc/build clean.
## Angular 22 upgrade
@@ -12,7 +18,9 @@ Researched, not executed. Estimated ~2–3.5 days, needs the `barry-cache` depen
## Bundle splitting
-Two lazy chunks are large: `project-editor` (320 kB), `catalog-container` (126 kB). No mechanical split found yet — needs a dedicated profiling task.
+**Initial (eagerly-loaded) bundle carries an ~11 MB chunk that is the entire `@lucide/angular` icon set**, confirmed 2026-08-05 by inspecting build output — `app-icon`/`IconComponent` only ever needs the ~85 icons named in `icon-registry.ts`, but esbuild is not eliminating the other ~1500+ unused icon classes from `@lucide/angular`'s single-file `fesm2022/lucide-angular.mjs` bundle, despite the package declaring `sideEffects: false` and every usage in this codebase being clean named imports (no wildcard imports found). Root cause not fully diagnosed — likely each icon's Angular component metadata assignment isn't PURE-annotated in that build, so esbuild can't drop unreferenced classes within the single shared module even though it can drop unreferenced *exports*. The package ships no per-icon deep-import path as a workaround (single fesm file only). Real fix options, neither attempted here (touches a dependency, needs sign-off): (a) check for a newer `@lucide/angular` release with better tree-shaking, (b) drop the dependency and hand-roll inline SVG path data for just the ~85 used icons (removes a dependency, matches this repo's minimal-deps convention, but is real work — extracting/verifying 85 icon paths). This alone is roughly **6x the size of the two lazy chunks below combined** and, unlike them, ships to every visitor on first load.
+
+Two lazy chunks are also large: `project-editor` (~1.0 MB), `catalog-container` (~330–375 kB, varies by build). No mechanical split found yet for either — needs a dedicated profiling task, ideally under real backend latency per `docs/NEXT_PHASE.md` Phase 3.
## Homepage hero-to-categories spacing investigation
diff --git a/docs/GLOBAL-SPRINT-PLAN.md b/docs/GLOBAL-SPRINT-PLAN.md
new file mode 100644
index 0000000..f4d1335
--- /dev/null
+++ b/docs/GLOBAL-SPRINT-PLAN.md
@@ -0,0 +1,76 @@
+# Global Sprint Plan — "Coming Soon" Stub Closure
+
+Supersedes `docs/COMING-SOON-AUDIT.md` §5 sprint breakdown. One consolidated tracker for the four stub-closure sprints. Approved decisions (from AskUserQuestion): Reports/Settings ship as minimal real pages (not fake data, not empty shells); Documentation/Help nav uses an external-link approach; `docs/COMING-SOON-AUDIT.md` is deleted once all sprints land, folded into `docs/KNOWN-ISSUES.md`. Profile control constraint: **login/logout only — no dropdown, no account links.**
+
+## Sprint A — Profile menu (storefront header)
+
+- [x] i18n: `header.login` / `header.logout` keys in en/ru/hy (`translations.ts` type already updated)
+- [x] `header.component.ts`: inject `AuthService`, expose `isAuthenticated`, add `login()`/`logout()`
+- [x] `header.component.ts`: import `TelegramLoginComponent`
+- [x] `header.component.html`: profile control gated by `headerConfig().showProfile`, login/logout only, `` rendered once
+- [x] SCSS matches existing header button conventions (reused `.platform-ux-btn`, no new SCSS needed)
+
+**What shipped:** Header profile control wired to the customer `AuthService` (Telegram QR login). Gated by `headerConfig().showProfile` (already a real toggle in Project Editor, previously dead). Logged-out shows a login button (`user` icon), logged-in shows a logout button (`logOut` icon) — no dropdown, no account links, per the explicit constraint.
+
+## Sprint B — Admin Reports page
+
+- [x] `admin-reports-page.component.ts/.html/.scss` (mirrors `admin-analytics-page` structure), reuses `AdminAnalyticsFacade`
+- [x] Report cards: Sales, Top Products, Marketplace Health
+- [x] CSV export wired to existing facade export methods / existing download helper (same Blob pattern as `admin-analytics-page.component.ts`)
+- [x] Route `backoffice/reports` in `app.routes.ts`, i18n keys `adminShell.pages.reports.*` + new `adminReports.*` block
+- [x] Remove `comingSoon: true` from `reports` nav entry
+
+**What shipped:** Minimal real Reports page with 3 cards (Sales, Top Products, Marketplace Health), each showing a live summary from `AdminAnalyticsFacade` and a CSV export button. Orders card was scoped out — see final report for why (reuse would require mutating a shared singleton facade's pagination state).
+
+## Sprint C — Admin Settings page
+
+- [x] `AdminPreferencesService` (density signal, localStorage-backed, key `adminPreferences.density.v1`)
+- [x] `admin-layout.component` applies `admin-density-compact` class to `#admin-content` shell wrapper
+- [x] `admin-settings-page.component.ts/.html/.scss` — density toggle (`app-toggle`), auto-persists on change, no separate Save button
+- [x] Route `backoffice/settings`, i18n keys `adminShell.pages.settings.*` + `adminSettings.*` block
+- [x] Remove `comingSoon: true` from nav entry AND dashboard shortcut; shortcut route → `['backoffice','settings']`
+- [x] Compact-density CSS rule added to the shared `app-table` component stylesheet (`.admin-density-compact .app-table th/td`) — applies to every admin list page built on `app-table` (orders, products, categories, etc.), not just one
+
+**What shipped:** Genuinely real, backend-independent UI density preference. No maintenance-mode toggle built (explicitly deferred per `docs/NEXT_PHASE.md` Phase 4).
+
+## Sprint D — Documentation / Help nav
+
+- [x] Help: `mailto:` using existing `supportEmail` read path (`UiRuntimeFacade.contactEmail()`, same one `header.component.ts` already uses for `bootstrap.branding.supportEmail`)
+- [x] `AdminNavLink` gains optional `externalHref?: string`; nav renderer renders `` branch (bottom nav)
+- [x] Documentation: added `tenant.documentationUrl?: string` to `TenantConfig`, populated mock with `https://docs.marketplace.local`
+- [x] `help`/`documentation` resolved dynamically in `admin-layout.component.ts` (`navBottom` computed) — real `` when bootstrap data present, static `comingSoon: true` entries kept as defensive fallback for the (currently unreachable, since mock always has both fields) case where the backend omits them
+
+**What shipped:** Both Help and Documentation wired to real external links, not just Help. `comingSoon: true` remains in `admin-nav.model.ts` source as a fallback flag only — it is overridden to `false` at render time whenever bootstrap actually has the data, which it does today.
+
+## Sprint E — Widget layout config correctness (manifest-aware editor)
+
+Root cause confirmed 2026-08-05: `widget-manifest.json` already declares `supportedLayouts` per widget type (`hero`→`[hero, split]`, `categories`→`[grid]`, `product-collection`→`[carousel, grid]`), but `homepage-section.component.ts`'s `layoutStrategyPickerOptions` is a static 5-option list (`stack/grid/hero/carousel/split`) shown identically for every homepage section regardless of which widget backs it — it never reads the manifest. The `columns` field (`homepage-section.component.html:49`) is shown for every section too, but **no widget component reads `layout.columns`** — it is currently dead everywhere.
+
+- [x] `homepage-section.component.ts`: resolve each section's widget type (via its bound widget id → `widget-registry`/manifest lookup) and filter `layoutStrategyPickerOptions` down to that widget's `supportedLayouts` before rendering the picker
+- [x] Hide/disable the `columns` field for any section whose resolved widget doesn't consume it (only `product-collection` and, after Sprint F, `hero` will)
+- [x] No behavior change for widgets that already worked (categories/recently-viewed/footer-nav keep their single valid layout, picker just stops offering the other 4 nonsensically)
+
+**What shipped:** `homepage-section.component.ts` now injects `WidgetManifestService`, resolves each section's manifest entry directly by `section.type` (confirmed identical to the manifest `type` key — no separate widget-id lookup needed), and derives `layoutOptionsFor(section)` by filtering the static option list down to that entry's `supportedLayouts`. A stale/unsupported saved `strategy` value is appended back into the options list rather than dropped, so `app-visual-layout-picker` never renders with no active card. `showColumnsFor(section)` gates the `columns` field to the two componentKeys that actually read it (`hero` always, `product-collection` only in `carousel` strategy — grid mode ignores it). One correction to the plan's assumption: `recently-viewed`'s actual manifest entry declares `supportedLayouts: ["stack", "grid", "carousel"]` (3 options, not 1) — the picker now correctly reflects that per the manifest rather than the plan's guess.
+
+## Sprint F — Carousel items-per-page (closes the client bug report)
+
+Confirmed real, reported by a client, not fixed anywhere: neither carousel widget has an "items/slides per page" concept. Design: reuse the existing (currently dead) `layout.columns` field rather than inventing a new one — it is already editable in the Homepage section editor once Sprint E gates it to the right widgets.
+
+- [x] `ProductCarouselWidgetComponent`: read `section.layout.columns` (default 4, min 1) to size `.catalog-product-shell` width as a fraction of the scroller instead of the hardcoded `220px` — gives real "items per page" control, arrows/scroll logic unchanged (already works)
+- [x] `HeroWidgetComponent`: add manual prev/next arrows (parity with the product carousel's arrow buttons) in addition to the existing dots — closes "not scrollable manually"
+- [x] `HeroWidgetComponent`: add swipe/drag (pointer events) support for touch — closes "not scrollable manually" on mobile
+- [x] `HeroWidgetComponent`: support `layout.columns` = 1 or 2 to show one or two slide panels at once ("big carousel one or two slides per page") — 2-panel mode shows the active slide plus the next one side by side
+- [x] Verify autoplay (`props.autoplay`, already exists, editor toggle already exists per `widgets-section.component.html:61`) still functions correctly alongside the new manual controls (manual interaction should not fight the autoplay timer — reset/pause timer on manual nav, matching common carousel UX)
+- [x] i18n: any new aria-labels for the new hero arrows (reuse `common.previousProducts`/`common.nextProducts` keys if wording fits, or add `common.previousSlide`/`common.nextSlide`)
+
+**What shipped:** `ProductCarouselWidgetComponent` sets `--items-per-page` as a CSS custom property (`[style.--items-per-page]`) driven by `itemsPerPage()` (default 4, min 1, floored), and `.catalog-product-shell` width is now `calc((100% - (var(--items-per-page, 4) - 1) * var(--space-md, 16px)) / var(--items-per-page, 4))` instead of a fixed `220px`. `HeroWidgetComponent` gained prev/next arrow buttons (same circular/bordered visual language as the product carousel's arrows), touch-event swipe (same threshold-based approach as `cart.component.ts`'s `onSwipeStart`, 50px threshold, left swipe = next, right swipe = prev), and 2-panel support via `layout.columns` (defaults to 1; `columns === 2` shows the active slide plus the next one side by side, falling back to 1 panel when there's only one slide total). All manual navigation (arrows, swipe, dots) routes through the existing `goTo()`, which already clears+restarts the autoplay timer, so no duplicate timer logic was needed. New i18n keys `common.previousSlide` / `common.nextSlide` added to `translations.ts`, `en.ts`, `ru.ts`, `hy.ts`.
+
+Verification: `npx tsc --noEmit` and `npx ng build --configuration=development` both clean. Visually verified in the browser preview (`ng serve` on port 4200) by temporarily patching the embedded home-page sections in `src/assets/mock/bootstrap/bootstrap.json` (the actual runtime source for `/` — `src/assets/mock/bootstrap/homepage.json` is a separate, unused-by-this-route file) to `columns: 2` + a second slide for hero and `columns: 3` for the product carousel, confirming via DOM/computed-style inspection: hero rendered 2 slide panels with 2 working arrows, arrow clicks and simulated touch swipe both advanced/reversed the active dot correctly, and the carousel's `--items-per-page` CSS var read `3` with each `.catalog-product-shell` measuring ~348px (vs. the fixed 1110px/220px before). All temporary mock-data edits were reverted afterward (`git checkout`) — `bootstrap.json` and `homepage.json` are unchanged in the final diff. The Sprint E manifest-aware picker itself could only be verified by code inspection, not live in the browser — `/edit/:section` requires Telegram admin login, which cannot be completed in this environment.
+
+## Housekeeping
+
+- [x] Delete `docs/COMING-SOON-AUDIT.md`
+- [x] Fold summary into `docs/KNOWN-ISSUES.md` "Fixed (this cycle)"; remove the `HeaderConfig.showProfile` dead-toggle entry from "Open"
+- [x] Update `docs/BACKEND.md` (`tenant.documentationUrl` field added §1.3; no `docs/backend/BACKEND-INTEGRATION.md` exists in this repo)
+- [x] `npm run barry -- validate` (clean, only pre-existing unrelated warnings)
+- [x] Typecheck touched files (`tsc --noEmit` + full `ng build` both clean)
diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md
index a3883e7..0e9c2cd 100644
--- a/docs/KNOWN-ISSUES.md
+++ b/docs/KNOWN-ISSUES.md
@@ -23,6 +23,17 @@ Real, reproducible, currently-open frontend bugs only. Everything that needed a
- Found: 2026-07-26, Backend Finalization Sprint documentation pass (traced while
writing `docs/BACKEND.md` §4 Authentication / §6 Error Model).
+2. **`NavigationConfig.header` dead editable field — top nav links list has no renderer.**
+ The Navigation editor section lets a client edit a list of header nav items
+ (`navigation.header`), but `HeaderComponent` never reads `NavigationConfig.header`
+ anywhere — its category menu comes from `CategoryFacade` instead. Editing this
+ list currently has zero visible effect on the storefront.
+ - **Fix requires real feature work**, not a wiring change: rendering a
+ configurable top-nav means deciding positioning relative to the existing
+ category menu, active-route styling, and whether `children` (dropdowns) are
+ supported — out of scope for a mechanical fix.
+ - Found: 2026-08-05, Sprint G dead-config sweep (`docs/DEAD-CONFIG-AUDIT.md`).
+
## Fixed (this cycle)
Condensed — full detail in commit history and `docs/RELEASE_REPORT.md`.
@@ -39,3 +50,8 @@ Condensed — full detail in commit history and `docs/RELEASE_REPORT.md`.
- `primeng`/`primeicons` unused dependency — removed.
- Builder static-page body editor hidden inside a mislabeled collapsed section — un-hidden, relabeled.
- Several project-editor/admin-categories correctness bugs (footer icon id collisions, features toggle only driving one flag, languages silent duplicate no-op, static-pages slug collision, branding `socialImageUrl` never read, media-picker facade filter leakage between dialogs, categories draft-recovery/drag-reorder bugs, hardcoded locale-tab order) — see git history for the full per-bug list.
+- `HeaderConfig.showProfile` dead toggle — wired up (login/logout only, no dropdown), reuses the existing customer Telegram `AuthService`.
+- Admin `reports` nav stub — real page (`backoffice/reports`), reuses `AdminAnalyticsFacade` for Sales/Top Products/Marketplace Health cards with CSV export.
+- Admin `settings` nav stub — real page (`backoffice/settings`), UI density preference (comfortable/compact), persisted to `localStorage`, applied to admin list tables.
+- Admin `documentation`/`help` nav stubs — both wired to real external links (`mailto:` support email, `tenant.documentationUrl`).
+- Sprint G dead-config sweep: `footer.logoUrl`, `company.address.street`, `company.contacts.phone`, `catalog.suggestionsEnabled` were editable with no runtime consumer — all four wired up. Full findings table in `docs/DEAD-CONFIG-AUDIT.md`.
diff --git a/docs/NEXT_PHASE.md b/docs/NEXT_PHASE.md
index 28b4036..89e5e26 100644
--- a/docs/NEXT_PHASE.md
+++ b/docs/NEXT_PHASE.md
@@ -20,4 +20,4 @@ Wire real error tracking/APM and a real event source for the admin Monitoring pa
## Phase 5 — Version 2 ideas
-Everything in `docs/PRODUCT_BACKLOG.md` (dark mode, brand-color contrast decision, advanced analytics, additional payment providers, Contacts page content) and `docs/FUTURE_FEATURES.md` (Angular 22 upgrade, cart-modal composition cleanup) — none of it scheduled, all of it deliberately deferred past initial launch.
+Everything in `docs/PRODUCT_BACKLOG.md` (dark mode, brand-color contrast decision, advanced analytics, additional payment providers, Contacts page content) and `docs/FUTURE_FEATURES.md` (Angular 22 upgrade, cart-modal composition cleanup) — none of it scheduled, all of it deliberately deferred past initial launch. The former stub-page/dead-toggle inventory (profile menu, admin Reports, admin Settings, Documentation/Help) is closed — see `docs/GLOBAL-SPRINT-PLAN.md` and `docs/KNOWN-ISSUES.md` "Fixed (this cycle)".
diff --git a/docs/PRODUCT_BACKLOG.md b/docs/PRODUCT_BACKLOG.md
index 89ffa16..1c7490c 100644
--- a/docs/PRODUCT_BACKLOG.md
+++ b/docs/PRODUCT_BACKLOG.md
@@ -20,6 +20,29 @@ Items that need a client/business decision before any code is written — not bl
**Decision needed:** add a token for this exact shade, or intentionally reuse an existing token (visual shift either way) — needs a design-system owner's call, not an engineering guess.
+## `layout.type` ("Site Layout" selector) — dead editable field
+
+The Theme section's "Site Layout" dropdown edits top-level `BootstrapConfig.layout.type`,
+but page rendering (`SectionEngineService.resolveLayoutType`) only ever reads each
+individual `PageConfig.layout`, never the top-level `bootstrap.layout` — so the
+selector has no visible effect regardless of what's chosen.
+
+**Decision needed:** which page(s) should this selector actually drive — only the
+homepage, or every page that doesn't set its own `layout`? That decision determines
+the wiring, not an engineering guess. Found: Sprint G dead-config sweep, `docs/DEAD-CONFIG-AUDIT.md`.
+
+## `company.companyName` — dead editable field, needs a copyright-fallback decision
+
+The Footer section's "Company Name" field has no runtime consumer. The footer
+already has a copyright fallback (`© {year} {brandName}`) using `branding.brandName`
+when `footer.copyrightText` is empty — reusing `company.companyName` there instead
+(or in addition) is a content/legal-wording decision (brand name vs. legal entity
+name are intentionally different fields), not a safe mechanical fix.
+
+**Decision needed:** should the copyright fallback use the legal company name
+instead of (or alongside) the brand name? Found: Sprint G dead-config sweep,
+`docs/DEAD-CONFIG-AUDIT.md`.
+
## Footer "Contacts" page content
The footer's "Contacts" link (`footer-contacts` / `nav.contacts`) has no static-page content in the bootstrap mock data at all — unlike "About" (which was a route-name mismatch, already fixed), there's simply nothing written for Contacts.
diff --git a/docs/SPRINT-PLAN-NEXT.md b/docs/SPRINT-PLAN-NEXT.md
new file mode 100644
index 0000000..6567898
--- /dev/null
+++ b/docs/SPRINT-PLAN-NEXT.md
@@ -0,0 +1,94 @@
+# Sprint Plan — Next Wave (G onward)
+
+Continues the sprint lettering from `docs/GLOBAL-SPRINT-PLAN.md` (Sprints A–F, all closed 2026-08-05: stub-page closure + widget layout/carousel fixes). Created 2026-08-05.
+
+**Relationship to `docs/NEXT_PHASE.md`:** that file stays the one *phase-level* roadmap and owns the backend-integration sequencing. This file is the *task-level* tracker for work that is actionable now, plus an explicit parking list for what is blocked and on what. Where the two overlap, `NEXT_PHASE.md` wins on ordering.
+
+---
+
+## Tier 1 — Actionable now (nothing blocks these)
+
+### Sprint G — Dead-config sweep
+
+**Why:** This is a config-driven multi-tenant product, so "setting exists in the editor, nothing reads it at runtime" is the signature failure mode — and it reaches clients directly. Three instances were found *by accident* during other work: theme mode (`data-theme-mode` set, no CSS reads it), `HeaderConfig.showProfile` (fixed, Sprint A), `layout.columns` (fixed, Sprint F, and was the root cause of a real client bug report). A mechanical sweep finds the rest in one pass instead of one complaint at a time.
+
+- [x] Enumerate every field in `BootstrapConfig` and its sub-models (`src/app/shared/models/config/*.model.ts`) — produce the full field inventory as a working list
+- [x] For each field, grep for a real runtime consumer (a component/service that reads it and changes behavior), distinguishing: **live** (read + has effect), **dead** (never read), **inert** (read but effect is unreachable/no-op — the `data-theme-mode` case)
+- [x] Cross-check against the editor: which dead/inert fields are *user-editable* today (those are the client-facing ones, highest priority)
+- [x] Produce a findings table: field → status → editable? → recommendation (wire it / hide the control / delete the field)
+- [x] Fix the trivially-wireable ones in the same pass (a field with an obvious consumer that was simply never connected)
+- [x] For each remaining dead field, either hide its editor control or open a scoped follow-up — do **not** leave an editable control for a field nothing reads
+- [x] Record findings in `docs/KNOWN-ISSUES.md` (real defects) / `docs/PRODUCT_BACKLOG.md` (needs a decision), matching how the earlier audit was folded in
+
+**Known starting points (already confirmed dead/inert):** theme mode (`PRODUCT_BACKLOG.md`, needs a dark-mode decision — not a wiring fix). Verify no others in `HeaderConfig`, `FooterConfig`, `CatalogConfig`, `ProductPageConfig`, `UserExperienceConfig`, `FeatureFlags`, `SeoConfig`.
+
+**What shipped:** Full findings table in `docs/DEAD-CONFIG-AUDIT.md`. Fixed and wired: `footer.logoUrl` (new `LogoComponent.srcOverride` input), `company.address.street` + `company.contacts.phone` (new `UiRuntimeFacade.companyAddress()`/`contactPhone()`, rendered in footer bottom bar), `catalog.suggestionsEnabled` (`SearchFacade.autocomplete()` now gates on it). Left dead but tracked (needs a business/design decision, not a mechanical fix): `layout.type` site-layout selector, `company.companyName` copyright-fallback wording (both → `PRODUCT_BACKLOG.md`), `navigation.header` top-nav rendering (→ `KNOWN-ISSUES.md`). `catalog.navigationMode` confirmed intentionally inert (labeled placeholder card, not a bug). No editor control was hidden — every remaining dead field's saved value stays visible and none risked losing already-saved client data.
+
+### Sprint H — Test suite foundation
+
+**Why:** 5 `.spec.ts` files exist in the entire repository. Project standards mandate 80% coverage and a TDD workflow; neither is happening. `NEXT_PHASE.md` Phase 2 defers testing until after backend integration — **this sprint deliberately front-runs part of that**, on the argument that tests written against the *current mock gateways* lock in today's behavior and make the eventual real-gateway swap far safer. Post-backend E2E work stays in Phase 2 where it is.
+
+- [x] Confirm the test runner actually works end to end (`npm test` → `ng test --watch=false --browsers=ChromeHeadlessNoSandbox`) and fix the harness if it doesn't
+- [x] Establish the house pattern with one exemplar spec per layer, so later tests have something to copy: a pure util, a service, a facade, a component
+- [x] Facade-level tests against existing mock gateways for the highest-risk domains first: `ProjectEditorFacade` (undo/redo, draft persistence, validation gating on publish), `AdminAnalyticsFacade` (the never-fabricate-a-number contract)
+- [x] Unit tests for the pure validator primitives (`project-editor/schema/validators/primitives.ts`) — zero-dependency, highest value per line of test
+- [x] Regression tests for the bugs fixed this cycle so they cannot silently return (carousel `layout.columns` sizing, hero `layout.columns` panel count, header profile login/logout gating)
+- [x] Wire coverage reporting — **done**: installed `karma-coverage` as a devDependency, added it to `karma.conf.js` (`coverage` reporter + `coverageReporter` block emitting `text-summary`, `html`, and `lcovonly` into `coverage/`). `npx ng test --watch=false --code-coverage` runs clean (83/83 specs pass). Baseline: Statements 32.02% (1025/3201), Branches 18.53% (353/1904), Functions 21.73% (220/1012), Lines 32.76% (946/2887).
+- [x] Decide whether to gate CI on it — **no, not yet**: 11 spec files is a foundation, not the coverage floor CI gating implies; gate once coverage reporting exists and a real floor number can be set, not before.
+
+**What shipped:** Harness confirmed working (`npm test` was already green, 57/57). Added 6 new spec files (test count 57 → 75): `ProjectEditorFacade` facade spec (undo/redo, draft-persistence round-trip via a second facade instance reading the same localStorage draft, publish blocked/allowed on `hasBlockingIssues()`) mocking `CONFIG_PROVIDER` as the gateway boundary; `AdminAnalyticsFacade` facade spec asserting `summary().conversionRate` stays `null` and `performance`/`backend-connectivity` health checks stay `'unknown'` rather than being guessed, mocking all 4 gateways + `AdminDashboardFacade`; `HeroWidgetComponent` and `ProductCarouselWidgetComponent` component specs regression-covering `layout.columns` (panel count / items-per-page); `HeaderComponent` component spec regression-covering the login/logout profile toggle (asserts on icon name, not translated aria-label text, since Russian is the default active language in tests). `primitives.ts` and the pure-util/service exemplar layers were already covered by pre-existing specs — verified, not re-done. Cart/checkout facade tests and a "manifest-filtered layout options" regression were scoped out to stay within this sprint's time budget — breadth across the 4 required layers (util/service/facade/component) was prioritized over a 5th facade.
+
+### Sprint I — Widget `settingsSchema` enforcement
+
+**Why:** Same disease Sprint E cured for `supportedLayouts`. Every widget in `widget-manifest.json` declares a JSON Schema for its props under `settingsSchema`, and **nothing reads it** — verified: only `supportedDataSources` is consumed anywhere (and only by a diagnostics validator, not the editor). Consequences: widget props are never validated against their own declared contract, and unknown widget types fall back to raw JSON editing in the Widgets section. (`enabled` *is* honored correctly — `widget-registry.bootstrap.service.ts` filters on it.)
+
+- [x] Read `settingsSchema` in the Widgets editor section and validate widget props against it, surfacing failures through the existing `ProjectValidator` issue pipeline (`fieldKey`/`section`/`severity`) rather than a parallel mechanism
+- [x] Add a `widgetSettingsSchema` validator alongside the existing `widgetConfig` check in `project-validator.service.ts`
+- [x] Evaluate replacing the raw-JSON fallback editor with schema-generated fields for widget types that have no hand-authored editor — scope this honestly; if the schemas are too thin to generate a decent UI, keep the JSON fallback and just add validation on top
+- [x] Confirm the diagnostics page (`features/diagnostics/`) reflects schema violations too, since it already consumes the manifest
+
+**What shipped:** `validateAgainstSchemaLite(value, schema)` (`schema/validators/primitives.ts`) — a shallow, dependency-free type+required checker (no nested schemas/enums/$ref; checked first, no existing schema-validation utility or library in the repo). `ProjectValidator.widgetSettingsSchemaIssues()` runs it against every widget's `props` vs. its manifest entry's `settingsSchema`, added to the same `validate()` composition as a `widgets`-section warning tagged `fieldKey: 'pages'` — it surfaces automatically through the existing `fieldError('pages')` call already in `widgets-section.component.html`, no template changes needed. `WidgetManifestService` gained a synchronous `getManifestSnapshot()` (same pattern as `ConfigService.getBootstrapSnapshot()`) since `ProjectValidator.validate()` is called synchronously and can't await the manifest HTTP fetch; the check no-ops (matching `RuntimeDiagnosticsValidator`'s existing null-manifest convention) until the manifest has loaded once elsewhere in the app (it always has, by the time a user reaches the editor). Schema-generated form fields were evaluated and explicitly skipped: every widget's `settingsSchema.properties` tops out at 7 flat string/number fields with zero `required` arrays and zero enums/nesting across all 10 widget types in `widget-manifest.json` — too thin to justify generated UI over the existing JSON fallback (`widgets-section.component.ts`'s `updateJson`/`widgetJsonError`), so the JSON editor stays and only gets the new validation layered on top. Diagnostics: `BootstrapDiagnosticsValidator` gained a sibling `validateWidgetSettingsSchema()` next to its existing `validateUnknownWidgetTypes()`, reusing the identical `validateAgainstSchemaLite` call so the editor and diagnostics page can never disagree about what counts as a violation — one check, two surfaces, not a parallel one.
+
+---
+
+## Tier 2 — Blocked on backend
+
+Sequencing is owned by `docs/BACKEND.md` §9 and `docs/NEXT_PHASE.md` Phase 1. Not re-planned here — that checklist is already the authoritative task list. Frontend-side items that unblock the moment backend lands:
+
+- [ ] **Ed25519 auth error codes** (`docs/KNOWN-ISSUES.md` Open #1) — `session-expired` and `invalid-signature` recovery screens are built and wired but permanently unreachable, because `toAuthErrorShape()` derives the code purely from HTTP status and never reads a body-level code. Needs: backend returning a distinguishable `error.code` (`BACKEND.md` §6), then a small frontend change to prefer it over the status fallback.
+- [ ] **Swap every mock gateway for its real counterpart** behind the existing DI tokens, in the dependency order `BACKEND.md` §8 specifies
+- [ ] **Maintenance-mode frontend UI** (`BACKEND.md` §10 flags full-page takeover, per-module banners, scheduled countdown as not existing) — deliberately not built during Sprint C for exactly this reason
+- [ ] **Real Monitoring data source** — page currently renders mock activity (`NEXT_PHASE.md` Phase 4)
+- [ ] **Re-profile performance under real latency** (`NEXT_PHASE.md` Phase 3) — mock responses are instant, real ones won't be; loading/skeleton timing is untested against reality
+
+## Tier 3 — Blocked on a business decision
+
+No engineering work should start on these until answered. Full detail in `docs/PRODUCT_BACKLOG.md`.
+
+- [ ] **Dark mode** — does the client want it? If yes it's a real project (dark palette + CSS strategy + `matchMedia` for "system"), not a wiring fix. Blocks the theme-mode selector, which is inert today.
+- [ ] **Brand color contrast (WCAG AA)** — `--border-color` fails 3:1 in every theme; several status colors fail 4.5:1 as text. Fixing means visibly changing the brand — needs theme-owner sign-off.
+- [ ] **Stars rating glyph token** — literal hex with no matching design token; add a token or reuse an existing one (visual shift either way).
+- [ ] **Contacts page content** — nothing written for it at all. Content question.
+- [ ] **Advanced analytics** — no data source exists for traffic/funnels/heatmaps. Build vs. buy, and launch vs. later.
+- [ ] **Additional payment providers** — which ones, if any, before integration work starts.
+
+## Tier 4 — Deferred, non-blocking
+
+From `docs/FUTURE_FEATURES.md`. No decision needed, just not worth doing now.
+
+- [ ] **Angular 22 upgrade** — researched, ~2–3.5 days, needs a dependency fix and Node bump first. Plan: `docs/ANGULAR22_PLAN.md`. **Run as its own dedicated session** — framework upgrades don't share a session with feature work.
+- [ ] **Bundle splitting** — `project-editor` (~896 kB) and `catalog-container` (~330 kB) lazy chunks are large; no mechanical split found, needs a dedicated profiling task, ideally under real backend latency
+- [ ] **Cart payment modal → `app-dialog`** — composition cleanup, functionally and accessibly complete as-is
+- [ ] **Homepage hero-to-categories spacing** — traces to mock fixture padding values, not a confirmed defect; needs reproduction with real tenant data before it's worth investigating
+
+## Tier 5 — Infrastructure
+
+- [ ] **Server deploy** — no deploy pipeline exists in this repo (only `.github/workflows/architecture-governance.yml`). Deploys are currently manual/out-of-band. Worth deciding whether a real pipeline should exist; separately, SSH from the agent harness is blocked, so agent-driven deploys need either a permission rule or a different mechanism.
+
+---
+
+## Suggested order
+
+**G → H → I.** Sprint G is cheap, mechanical, and directly prevents more client-reported ghost settings (it is the same class of bug as the one already reported). Sprint H is the highest-value thing available that isn't blocked on anything, and it gets more valuable the earlier it lands, since every later change rides on it. Sprint I is real but narrower — it hardens an editor path rather than fixing something users hit today.
+
+Tier 2 starts the moment backend Phase 1 lands. Tier 3 needs answers, not engineering. Tier 4 is genuinely optional.
diff --git a/karma.conf.js b/karma.conf.js
index 5ae2780..2e544fc 100644
--- a/karma.conf.js
+++ b/karma.conf.js
@@ -12,6 +12,7 @@ module.exports = function (config) {
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
+ require('karma-coverage'),
],
browsers: ['ChromeHeadlessNoSandbox'],
customLaunchers: {
@@ -20,7 +21,12 @@ module.exports = function (config) {
flags: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
},
},
- reporters: ['progress'],
+ reporters: ['progress', 'coverage'],
+ coverageReporter: {
+ dir: require('path').join(__dirname, 'coverage'),
+ subdir: '.',
+ reporters: [{ type: 'text-summary' }, { type: 'html' }, { type: 'lcovonly' }],
+ },
restartOnFileChange: true,
});
};
diff --git a/package-lock.json b/package-lock.json
index 449d028..0bc3dad 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,7 +17,6 @@
"@angular/platform-browser": "22.0.8",
"@angular/router": "22.0.8",
"@angular/service-worker": "22.0.8",
- "@lucide/angular": "^1.25.0",
"rxjs": "~7.8.0",
"tslib": "^2.8.0",
"zone.js": "~0.16.0"
@@ -32,6 +31,7 @@
"jasmine-core": "~5.5.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
+ "karma-coverage": "^2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~6.0.3"
@@ -1968,19 +1968,6 @@
"win32"
]
},
- "node_modules/@lucide/angular": {
- "version": "1.25.0",
- "resolved": "https://registry.npmjs.org/@lucide/angular/-/angular-1.25.0.tgz",
- "integrity": "sha512-Hu1eHlGIeyamhSCFEO5YmiUlWn/h4y9BOXMDndY0HRu05CNeCt5BENkfBoeE/bzggMGGbYG+SOBkQUG939tFXw==",
- "license": "ISC",
- "dependencies": {
- "tslib": "^2.3.0"
- },
- "peerDependencies": {
- "@angular/common": ">=17.0.0",
- "@angular/core": ">=17.0.0"
- }
- },
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -5459,6 +5446,16 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -5534,6 +5531,13 @@
"node": "20 || >=22"
}
},
+ "node_modules/html-escaper": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -5934,6 +5938,60 @@
"node": ">=10"
}
},
+ "node_modules/istanbul-lib-report": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
+ "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.0.0",
+ "make-dir": "^4.0.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-source-maps": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
+ "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "istanbul-lib-coverage": "^3.0.0",
+ "source-map": "^0.6.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/istanbul-lib-source-maps/node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/istanbul-reports": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "html-escaper": "^2.0.0",
+ "istanbul-lib-report": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/jasmine-core": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.5.0.tgz",
@@ -6097,6 +6155,51 @@
"which": "bin/which"
}
},
+ "node_modules/karma-coverage": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz",
+ "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "istanbul-lib-coverage": "^3.2.0",
+ "istanbul-lib-instrument": "^5.1.0",
+ "istanbul-lib-report": "^3.0.0",
+ "istanbul-lib-source-maps": "^4.0.1",
+ "istanbul-reports": "^3.0.5",
+ "minimatch": "^3.0.4"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/karma-coverage/node_modules/istanbul-lib-instrument": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz",
+ "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@babel/core": "^7.12.3",
+ "@babel/parser": "^7.14.7",
+ "@istanbuljs/schema": "^0.1.2",
+ "istanbul-lib-coverage": "^3.2.0",
+ "semver": "^6.3.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/karma-coverage/node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/karma-jasmine": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz",
@@ -6610,6 +6713,22 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
+ "node_modules/make-dir": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
+ "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/make-fetch-happen": {
"version": "15.0.6",
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz",
@@ -8563,6 +8682,19 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/tar": {
"version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
diff --git a/package.json b/package.json
index 6b44d2f..d50b6a8 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"build": "ng build",
"build:dexar": "ng build --configuration=production",
"test": "ng test --watch=false --browsers=ChromeHeadlessNoSandbox",
+ "test:coverage": "ng test --watch=false --browsers=ChromeHeadlessNoSandbox --code-coverage",
"watch": "ng build --watch --configuration development",
"arch:check:boundaries": "node tools/architecture/check-boundaries.mjs",
"arch:check:cycles": "npx --yes madge --circular --extensions ts src/app --ts-config tsconfig.app.json",
@@ -30,7 +31,6 @@
"@angular/platform-browser": "22.0.8",
"@angular/router": "22.0.8",
"@angular/service-worker": "22.0.8",
- "@lucide/angular": "^1.25.0",
"rxjs": "~7.8.0",
"tslib": "^2.8.0",
"zone.js": "~0.16.0"
@@ -45,8 +45,9 @@
"jasmine-core": "~5.5.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
+ "karma-coverage": "^2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~6.0.3"
}
-}
\ No newline at end of file
+}
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index f4bf1a5..1d5ba92 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -254,6 +254,24 @@ const coreRoutes: Routes = [
breadcrumb: [{ labelKey: 'adminShell.nav.analytics' }]
}
},
+ {
+ path: 'reports',
+ loadComponent: () => import('./features/admin/reports/pages/admin-reports-page.component').then(m => m.AdminReportsPageComponent),
+ data: {
+ titleKey: 'adminShell.pages.reports.title',
+ descriptionKey: 'adminShell.pages.reports.description',
+ breadcrumb: [{ labelKey: 'adminShell.nav.reports' }]
+ }
+ },
+ {
+ path: 'settings',
+ loadComponent: () => import('./features/admin/settings/pages/admin-settings-page.component').then(m => m.AdminSettingsPageComponent),
+ data: {
+ titleKey: 'adminShell.pages.settings.title',
+ descriptionKey: 'adminShell.pages.settings.description',
+ breadcrumb: [{ labelKey: 'adminShell.nav.settings' }]
+ }
+ },
{
path: 'partners/seller-management',
loadComponent: () => import('./features/admin/seller-management/pages/admin-seller-management-page.component').then(m => m.AdminSellerManagementPageComponent),
diff --git a/src/app/components/footer/footer.component.html b/src/app/components/footer/footer.component.html
index 2e635b2..e7fac0b 100644
--- a/src/app/components/footer/footer.component.html
+++ b/src/app/components/footer/footer.component.html
@@ -2,7 +2,7 @@
diff --git a/src/app/components/footer/footer.component.ts b/src/app/components/footer/footer.component.ts
index 29b5c63..4cf9ff3 100644
--- a/src/app/components/footer/footer.component.ts
+++ b/src/app/components/footer/footer.component.ts
@@ -21,6 +21,7 @@ export class FooterComponent {
readonly footerGroups = signal([]);
readonly paymentIcons = signal([]);
readonly copyrightText = signal('');
+ readonly footerLogoUrl = signal(undefined);
private readonly configService = inject(ConfigService);
@@ -35,6 +36,7 @@ export class FooterComponent {
this.footerGroups.set([]);
this.paymentIcons.set([]);
this.copyrightText.set('');
+ this.footerLogoUrl.set(undefined);
return;
}
@@ -42,6 +44,7 @@ export class FooterComponent {
this.footerGroups.set(model.groups);
this.paymentIcons.set(model.paymentIcons);
this.copyrightText.set(model.copyrightText);
+ this.footerLogoUrl.set(model.logoUrl);
});
this.configService.loadBootstrap().subscribe();
@@ -56,4 +59,12 @@ export class FooterComponent {
get contactEmail(): string {
return this.uiRuntime.contactEmail();
}
+
+ get contactPhone(): string {
+ return this.uiRuntime.contactPhone();
+ }
+
+ get companyAddress(): string {
+ return this.uiRuntime.companyAddress();
+ }
}
diff --git a/src/app/components/header/header.component.html b/src/app/components/header/header.component.html
index 556a79f..6884ed9 100644
--- a/src/app/components/header/header.component.html
+++ b/src/app/components/header/header.component.html
@@ -80,6 +80,19 @@
}
+
+ @if (headerConfig().showProfile) {
+ @if (isAuthenticated()) {
+
+ } @else {
+
+ }
+ }
+
@if (headerConfig().showRegion) {
@@ -147,4 +160,6 @@
+
+
diff --git a/src/app/components/header/header.component.spec.ts b/src/app/components/header/header.component.spec.ts
new file mode 100644
index 0000000..c425ae1
--- /dev/null
+++ b/src/app/components/header/header.component.spec.ts
@@ -0,0 +1,86 @@
+import { TestBed } from '@angular/core/testing';
+import { provideRouter } from '@angular/router';
+import { provideHttpClient } from '@angular/common/http';
+import { provideHttpClientTesting } from '@angular/common/http/testing';
+import { of } from 'rxjs';
+import { BootstrapConfig } from '../../shared/models/config';
+import { CONFIG_PROVIDER } from '../../core/config/config-provider.token';
+import { ConfigService } from '../../core/config/config.service';
+import { AuthService } from '../../services/auth.service';
+import { HeaderComponent } from './header.component';
+
+function makeBootstrap(): BootstrapConfig {
+ return {
+ schemaVersion: '1', generatedAt: new Date().toISOString(),
+ tenant: { id: 't1', slug: 't1', code: 't1', host: 'dexar.market', name: 'Dexar', websiteBaseUrl: 'https://dexar.market', builderBaseUrl: 'https://dexar.market', backofficeBaseUrl: 'https://dexar.market', defaultLocale: 'en', supportedLocales: ['en'], defaultCurrency: 'USD', supportedCurrencies: ['USD'], timezone: 'UTC' },
+ branding: { brandName: 'Dexar', legalName: 'Dexar LLC', logoUrl: 'logo.png', faviconUrl: 'favicon.png' },
+ theme: { themeId: 'default', mode: 'light', palette: {} as any, typography: {} as any, spacing: {} as any, borderRadiusScale: {}, shadows: {}, iconSet: 'default' },
+ company: { companyName: 'Dexar LLC', address: { country: 'US', city: 'NY' }, contacts: { email: 'sales@dexar.market' } },
+ featureFlags: {} as any,
+ apiEndpoints: {} as any,
+ localization: { defaultLocale: 'en', supportedLocales: ['en'], currencyByLocale: {}, dictionaries: [] },
+ seo: { default: { title: 'Dexar', description: 'Dexar' }, byPageKey: {} },
+ permissions: { definitions: [], roles: [] },
+ header: { showLogo: true, showSearch: true, showCategories: true, showLanguages: true, showCart: true, showProfile: true, showWishlist: true, showCompare: true, showRegion: true, sticky: true, layout: 'default' },
+ navigation: { header: [], footer: [] },
+ pages: [],
+ } as unknown as BootstrapConfig;
+}
+
+describe('HeaderComponent profile control (login/logout gating regression)', () => {
+ function configure(isAuthenticated: boolean): void {
+ // Full fake - TelegramLoginComponent (rendered inside the profile control) reads
+ // several signals/methods off AuthService directly, not just isAuthenticated.
+ const fakeAuth = {
+ session: () => null,
+ status: () => (isAuthenticated ? 'authenticated' : 'unauthenticated'),
+ isAuthenticated: () => isAuthenticated,
+ showLoginDialog: () => false,
+ displayName: () => null,
+ requestLogin: jasmine.createSpy('requestLogin'),
+ logout: jasmine.createSpy('logout'),
+ hideLogin: jasmine.createSpy('hideLogin'),
+ createWebSession: () => of({ webSessionID: 'x', botLoginUrl: '' }),
+ checkSessionOnce: () => of(null),
+ getTelegramAppLoginUrl: () => '',
+ onTelegramLoginComplete: jasmine.createSpy('onTelegramLoginComplete'),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ provideRouter([]),
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ { provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
+ { provide: AuthService, useValue: fakeAuth },
+ ],
+ });
+
+ // Deterministically prime the bootstrap snapshot before component creation -
+ // resolveHeaderConfig() reads getBootstrapSnapshot() synchronously and falls
+ // back to DEFAULT_HEADER_CONFIG (showProfile: false) if it isn't populated yet.
+ TestBed.inject(ConfigService).loadBootstrap().subscribe();
+ }
+
+ it('shows a login button (not logout) when logged out', () => {
+ configure(false);
+ const fixture = TestBed.createComponent(HeaderComponent);
+ fixture.detectChanges();
+
+ // Aria-labels render translated text (Russian by default), so assert on the
+ // icon name attribute instead - stable regardless of active language.
+ const compiled = fixture.nativeElement as HTMLElement;
+ expect(compiled.querySelector('app-icon[name="user"]')).toBeTruthy();
+ expect(compiled.querySelector('app-icon[name="logOut"]')).toBeFalsy();
+ });
+
+ it('shows a logout button (not login) when logged in - never both', () => {
+ configure(true);
+ const fixture = TestBed.createComponent(HeaderComponent);
+ fixture.detectChanges();
+
+ const compiled = fixture.nativeElement as HTMLElement;
+ expect(compiled.querySelector('app-icon[name="logOut"]')).toBeTruthy();
+ expect(compiled.querySelector('app-icon[name="user"]')).toBeFalsy();
+ });
+});
diff --git a/src/app/components/header/header.component.ts b/src/app/components/header/header.component.ts
index 142f5ed..0e4ed93 100644
--- a/src/app/components/header/header.component.ts
+++ b/src/app/components/header/header.component.ts
@@ -14,10 +14,12 @@ import { FeatureConfigService } from '../../core/config/feature-config.service';
import { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
import { StaticPageResolverService } from '../../core/config/static-page-resolver.service';
import { IconComponent } from '../../shared/ui/icon/icon.component';
+import { AuthService } from '../../services/auth.service';
+import { TelegramLoginComponent } from '../telegram-login/telegram-login.component';
@Component({
selector: 'app-header',
- imports: [RouterLink, RouterLinkActive, LogoComponent, LanguageSelectorComponent, RegionSelectorComponent, LangRoutePipe, TranslatePipe, IconComponent],
+ imports: [RouterLink, RouterLinkActive, LogoComponent, LanguageSelectorComponent, RegionSelectorComponent, LangRoutePipe, TranslatePipe, IconComponent, TelegramLoginComponent],
templateUrl: './header.component.html',
styleUrls: ['./header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -35,7 +37,9 @@ export class HeaderComponent {
private configService = inject(ConfigService);
private featureConfig = inject(FeatureConfigService);
private staticPageResolver = inject(StaticPageResolverService);
+ private authService = inject(AuthService);
+ readonly isAuthenticated = this.authService.isAuthenticated;
readonly wishlistCount = this.uxFacade.wishlistCount;
readonly compareCount = this.uxFacade.compareCount;
readonly userExperienceConfig = computed(() => this.resolveUserExperienceConfig());
@@ -118,6 +122,14 @@ export class HeaderComponent {
this.router.navigate([`/${lang}/compare`]);
}
+ login(): void {
+ this.authService.requestLogin();
+ }
+
+ logout(): void {
+ this.authService.logout();
+ }
+
navigateToStatic(route: string): void {
this.closeMenu();
const lang = this.langService.currentLanguage();
diff --git a/src/app/components/logo/logo.component.ts b/src/app/components/logo/logo.component.ts
index 984ee00..ef43a77 100644
--- a/src/app/components/logo/logo.component.ts
+++ b/src/app/components/logo/logo.component.ts
@@ -1,4 +1,4 @@
-import { Component, ChangeDetectionStrategy } from '@angular/core';
+import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
@Component({
@@ -17,6 +17,9 @@ import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class LogoComponent {
+ /** Overrides the default (branding.logoUrl) logo, e.g. footer.logoUrl. */
+ @Input() srcOverride?: string;
+
constructor(private readonly uiRuntime: UiRuntimeFacade) {}
get brandName(): string {
@@ -24,6 +27,6 @@ export class LogoComponent {
}
get logoPath(): string {
- return this.uiRuntime.logoUrl();
+ return this.srcOverride || this.uiRuntime.logoUrl();
}
}
diff --git a/src/app/core/config/footer-resolver.service.ts b/src/app/core/config/footer-resolver.service.ts
index a7998b1..d285bb7 100644
--- a/src/app/core/config/footer-resolver.service.ts
+++ b/src/app/core/config/footer-resolver.service.ts
@@ -37,6 +37,7 @@ export interface FooterResolvedModel {
groups: FooterResolvedGroup[];
paymentIcons: FooterPaymentIcon[];
copyrightText: string;
+ logoUrl?: string;
}
@Injectable({ providedIn: 'root' })
@@ -57,7 +58,8 @@ export class FooterResolverService {
return {
groups: this.resolveFooterGroups(bootstrap),
paymentIcons: this.resolvePaymentIcons(bootstrap.footer),
- copyrightText: this.resolveCopyrightText(bootstrap)
+ copyrightText: this.resolveCopyrightText(bootstrap),
+ logoUrl: bootstrap.footer?.logoUrl
};
}
diff --git a/src/app/facades/runtime/ui-runtime.facade.ts b/src/app/facades/runtime/ui-runtime.facade.ts
index 07b89b8..4954a30 100644
--- a/src/app/facades/runtime/ui-runtime.facade.ts
+++ b/src/app/facades/runtime/ui-runtime.facade.ts
@@ -7,6 +7,8 @@ interface UiRuntimeState {
marketplaceDisplayName: string;
logoUrl: string;
contactEmail: string;
+ contactPhone: string;
+ companyAddress: string;
themeId: string;
}
@@ -17,6 +19,8 @@ export class UiRuntimeFacade {
marketplaceDisplayName: '',
logoUrl: '',
contactEmail: '',
+ contactPhone: '',
+ companyAddress: '',
themeId: ''
});
@@ -38,6 +42,8 @@ export class UiRuntimeFacade {
marketplaceDisplayName: bootstrap.branding.brandName,
logoUrl: bootstrap.branding.logoUrl,
contactEmail: bootstrap.branding.supportEmail ?? bootstrap.company?.contacts?.email ?? '',
+ contactPhone: bootstrap.branding.supportPhone ?? bootstrap.company?.contacts?.phone ?? '',
+ companyAddress: bootstrap.company?.address?.street ?? '',
themeId: bootstrap.theme.themeId
});
}
@@ -58,6 +64,14 @@ export class UiRuntimeFacade {
return this.state().contactEmail;
}
+ contactPhone(): string {
+ return this.state().contactPhone;
+ }
+
+ companyAddress(): string {
+ return this.state().companyAddress;
+ }
+
themeId(): string {
return this.state().themeId;
}
diff --git a/src/app/features/admin/analytics/facade/admin-analytics.facade.spec.ts b/src/app/features/admin/analytics/facade/admin-analytics.facade.spec.ts
new file mode 100644
index 0000000..275eea5
--- /dev/null
+++ b/src/app/features/admin/analytics/facade/admin-analytics.facade.spec.ts
@@ -0,0 +1,98 @@
+import { TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
+import { AdminAnalyticsFacade } from './admin-analytics.facade';
+import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
+import { AdminProductsLocalGateway } from '../../products/services/admin-products-local.gateway';
+import { ADMIN_CATEGORIES_GATEWAY } from '../../categories/services/admin-categories-gateway.token';
+import { AdminModerationLocalGateway } from '../../moderation/services/admin-moderation-local.gateway';
+import { AdminDashboardFacade } from '../../dashboard/facade/admin-dashboard.facade';
+import { AdminOrder } from '../../orders/models/admin-order.model';
+
+function makeOrder(overrides: Partial = {}): AdminOrder {
+ return {
+ id: 'order-1',
+ createdAt: new Date().toISOString(),
+ status: 'completed',
+ total: 100,
+ currency: 'RUB',
+ customer: { email: 'buyer@example.com' },
+ items: [{ productId: 'p1', name: 'Widget', quantity: 1, price: 100 }],
+ ...overrides,
+ } as unknown as AdminOrder;
+}
+
+describe('AdminAnalyticsFacade (never-fabricate-a-number contract)', () => {
+ let facade: AdminAnalyticsFacade;
+ let ordersGateway: jasmine.SpyObj;
+ let productsGateway: jasmine.SpyObj;
+ let categoriesGateway: jasmine.SpyObj<{ loadCategories: () => unknown }>;
+ let moderationGateway: jasmine.SpyObj;
+ let dashboardFacade: jasmine.SpyObj;
+
+ function configure(bootstrapPresent: boolean): void {
+ ordersGateway = jasmine.createSpyObj('AdminOrdersLocalGateway', ['loadOrders']);
+ productsGateway = jasmine.createSpyObj('AdminProductsLocalGateway', ['loadProducts']);
+ categoriesGateway = jasmine.createSpyObj('ADMIN_CATEGORIES_GATEWAY', ['loadCategories']);
+ moderationGateway = jasmine.createSpyObj('AdminModerationLocalGateway', ['loadReviews']);
+ dashboardFacade = jasmine.createSpyObj('AdminDashboardFacade', [
+ 'ensureLoaded', 'activityEntries', 'bootstrap', 'validationIssues', 'enabledWidgetsCount', 'staticPagesUnpublishedCount',
+ ]);
+
+ ordersGateway.loadOrders.and.returnValue(of({ items: [makeOrder()], total: 1 } as any));
+ productsGateway.loadProducts.and.returnValue(of({ items: [], total: 0 } as any));
+ categoriesGateway.loadCategories.and.returnValue(of([]));
+ moderationGateway.loadReviews.and.returnValue(of({ items: [], total: 0 } as any));
+ dashboardFacade.activityEntries.and.returnValue([]);
+ dashboardFacade.bootstrap.and.returnValue(bootstrapPresent ? ({ schemaVersion: '1', tenant: { id: 't1' } } as any) : null);
+ dashboardFacade.validationIssues.and.returnValue([]);
+ dashboardFacade.enabledWidgetsCount.and.returnValue(0);
+ dashboardFacade.staticPagesUnpublishedCount.and.returnValue(0);
+
+ TestBed.configureTestingModule({
+ providers: [
+ { provide: AdminOrdersLocalGateway, useValue: ordersGateway },
+ { provide: AdminProductsLocalGateway, useValue: productsGateway },
+ { provide: ADMIN_CATEGORIES_GATEWAY, useValue: categoriesGateway },
+ { provide: AdminModerationLocalGateway, useValue: moderationGateway },
+ { provide: AdminDashboardFacade, useValue: dashboardFacade },
+ ],
+ });
+
+ facade = TestBed.inject(AdminAnalyticsFacade);
+ }
+
+ it('never fabricates conversionRate - stays null even with real order data', () => {
+ configure(true);
+ facade.load();
+
+ expect(facade.summary()?.conversionRate).toBeNull();
+ expect(facade.summary()?.revenueTotal).toBe(100);
+ expect(facade.summary()?.ordersCount).toBe(1);
+ });
+
+ it('never fabricates the "performance" health check - always unknown (no real data source)', () => {
+ configure(true);
+ facade.load();
+
+ const performance = facade.marketplaceHealth().find(check => check.code === 'performance');
+ expect(performance?.status).toBe('unknown');
+ });
+
+ it('reports backend-connectivity and homepage-configured as unknown when bootstrap has not loaded, instead of guessing', () => {
+ configure(false);
+ facade.load();
+
+ const backend = facade.marketplaceHealth().find(check => check.code === 'backend-connectivity');
+ const homepage = facade.marketplaceHealth().find(check => check.code === 'homepage-configured');
+ expect(backend?.status).toBe('unknown');
+ expect(homepage?.status).toBe('unknown');
+ });
+
+ it('reports backend-connectivity as healthy only once bootstrap is actually present', () => {
+ configure(true);
+ facade.load();
+
+ const backend = facade.marketplaceHealth().find(check => check.code === 'backend-connectivity');
+ expect(backend?.status).toBe('healthy');
+ });
+});
diff --git a/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts b/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts
index 526eeca..37c60c9 100644
--- a/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts
+++ b/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts
@@ -31,7 +31,7 @@ const SHORTCUTS: AdminDashboardShortcut[] = [
{ id: 'static-pages', icon: 'edit', labelKey: 'dashboard.actionStaticPages', route: ['backoffice', 'static-pages'] },
{ id: 'orders', icon: 'cart', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] },
{ id: 'users', icon: 'users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] },
- { id: 'settings', icon: 'settings', labelKey: 'dashboard.shortcutSettings', route: [], comingSoon: true },
+ { id: 'settings', icon: 'settings', labelKey: 'dashboard.shortcutSettings', route: ['backoffice', 'settings'] },
{ id: 'media-library', icon: 'images', labelKey: 'dashboard.actionMediaLibrary', route: ['backoffice', 'media'] },
{ id: 'content', icon: 'alignLeft', labelKey: 'dashboard.shortcutContent', route: ['edit', 'static-pages'] },
];
diff --git a/src/app/features/admin/reports/pages/admin-reports-page.component.html b/src/app/features/admin/reports/pages/admin-reports-page.component.html
new file mode 100644
index 0000000..84e69c6
--- /dev/null
+++ b/src/app/features/admin/reports/pages/admin-reports-page.component.html
@@ -0,0 +1,32 @@
+
+ @if (facade.loading()) {
+
+ @for (i of [1,2,3]; track i) {
+
+ }
+
{{ 'common.loading' | translate }}
+
+ } @else {
+
+
+
{{ 'adminReports.sales' | translate }}
+ @if (facade.summary(); as summary) {
+
{{ summary.revenueTotal }} {{ summary.currency }} · {{ summary.ordersCount }} {{ 'adminAnalytics.orders' | translate }}
+ }
+
{{ 'adminOrders.export' | translate }}
+
+
+
+
{{ 'adminReports.topProducts' | translate }}
+
{{ facade.topProducts().length }} {{ 'adminAnalytics.topProducts' | translate }}
+
{{ 'adminOrders.export' | translate }}
+
+
+
+
{{ 'adminReports.marketplaceHealth' | translate }}
+
{{ facade.healthCompletionPercent() }}% {{ 'adminMarketplaceHealth.complete' | translate }}
+
{{ 'adminOrders.export' | translate }}
+
+
+ }
+
diff --git a/src/app/features/admin/reports/pages/admin-reports-page.component.scss b/src/app/features/admin/reports/pages/admin-reports-page.component.scss
new file mode 100644
index 0000000..39a8432
--- /dev/null
+++ b/src/app/features/admin/reports/pages/admin-reports-page.component.scss
@@ -0,0 +1,19 @@
+.admin-reports-page { display: grid; gap: 16px; padding: 16px; max-width: 1100px; margin: 0 auto; }
+
+.report-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
+
+.report-card {
+ display: grid;
+ gap: 10px;
+ align-content: start;
+ padding: 16px;
+ border: 1px solid var(--border-color, #d3dad9);
+ border-radius: var(--radius-md);
+ background: var(--bg-primary, #fff);
+}
+.report-card h2 { margin: 0; font-size: var(--font-size-xl, 1.125rem); }
+.report-summary { margin: 0; color: var(--text-secondary, #6b7280); font-size: var(--font-size-sm, 0.8125rem); }
+
+@media (max-width: 900px) {
+ .report-grid { grid-template-columns: 1fr; }
+}
diff --git a/src/app/features/admin/reports/pages/admin-reports-page.component.ts b/src/app/features/admin/reports/pages/admin-reports-page.component.ts
new file mode 100644
index 0000000..cc5e6ef
--- /dev/null
+++ b/src/app/features/admin/reports/pages/admin-reports-page.component.ts
@@ -0,0 +1,43 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { AdminAnalyticsFacade } from '../../analytics/facade/admin-analytics.facade';
+import { TranslatePipe } from '../../../../i18n/translate.pipe';
+import { ButtonComponent } from '../../../../shared/ui/button/button.component';
+import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
+
+@Component({
+ selector: 'app-admin-reports-page',
+ standalone: true,
+ imports: [TranslatePipe, ButtonComponent, SkeletonComponent],
+ templateUrl: './admin-reports-page.component.html',
+ styleUrls: ['./admin-reports-page.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class AdminReportsPageComponent {
+ readonly facade = inject(AdminAnalyticsFacade);
+
+ constructor() {
+ this.facade.load();
+ }
+
+ exportSalesCsv(): void {
+ this.download(this.facade.exportCsv(), 'sales-report.csv');
+ }
+
+ exportTopProductsCsv(): void {
+ this.download(this.facade.exportTopProductsCsv(), 'top-products-report.csv');
+ }
+
+ exportHealthCsv(): void {
+ this.download(this.facade.exportHealthCsv(), 'marketplace-health-report.csv');
+ }
+
+ private download(csv: string, filename: string): void {
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ link.click();
+ URL.revokeObjectURL(url);
+ }
+}
diff --git a/src/app/features/admin/settings/pages/admin-settings-page.component.html b/src/app/features/admin/settings/pages/admin-settings-page.component.html
new file mode 100644
index 0000000..619a953
--- /dev/null
+++ b/src/app/features/admin/settings/pages/admin-settings-page.component.html
@@ -0,0 +1,14 @@
+
+
+
{{ 'adminSettings.density' | translate }}
+
{{ 'adminSettings.densityExplain' | translate }}
+
+
+
diff --git a/src/app/features/admin/settings/pages/admin-settings-page.component.scss b/src/app/features/admin/settings/pages/admin-settings-page.component.scss
new file mode 100644
index 0000000..67d3c18
--- /dev/null
+++ b/src/app/features/admin/settings/pages/admin-settings-page.component.scss
@@ -0,0 +1,14 @@
+.admin-settings-page { display: grid; gap: 16px; padding: 16px; max-width: 720px; margin: 0 auto; }
+
+.settings-card {
+ display: grid;
+ gap: 10px;
+ padding: 16px;
+ border: 1px solid var(--border-color, #d3dad9);
+ border-radius: var(--radius-md);
+ background: var(--bg-primary, #fff);
+}
+.settings-card h2 { margin: 0; font-size: var(--font-size-xl, 1.125rem); }
+.settings-explain { margin: 0; color: var(--text-secondary, #6b7280); font-size: var(--font-size-sm, 0.8125rem); }
+
+.toggle-row { display: flex; align-items: center; gap: 8px; font-weight: var(--font-weight-normal, 400); }
diff --git a/src/app/features/admin/settings/pages/admin-settings-page.component.ts b/src/app/features/admin/settings/pages/admin-settings-page.component.ts
new file mode 100644
index 0000000..de90235
--- /dev/null
+++ b/src/app/features/admin/settings/pages/admin-settings-page.component.ts
@@ -0,0 +1,21 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { AdminPreferencesService } from '../services/admin-preferences.service';
+import { TranslatePipe } from '../../../../i18n/translate.pipe';
+import { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component';
+
+@Component({
+ selector: 'app-admin-settings-page',
+ standalone: true,
+ imports: [FormsModule, TranslatePipe, ToggleComponent],
+ templateUrl: './admin-settings-page.component.html',
+ styleUrls: ['./admin-settings-page.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class AdminSettingsPageComponent {
+ readonly preferences = inject(AdminPreferencesService);
+
+ onCompactToggle(compact: boolean): void {
+ this.preferences.setDensity(compact ? 'compact' : 'comfortable');
+ }
+}
diff --git a/src/app/features/admin/settings/services/admin-preferences.service.ts b/src/app/features/admin/settings/services/admin-preferences.service.ts
new file mode 100644
index 0000000..1c2c272
--- /dev/null
+++ b/src/app/features/admin/settings/services/admin-preferences.service.ts
@@ -0,0 +1,24 @@
+import { Injectable, Signal, inject, signal } from '@angular/core';
+import { LocalStorageService } from '../../../../core/storage/local-storage.service';
+
+export type AdminUiDensity = 'comfortable' | 'compact';
+
+const DENSITY_KEY = 'adminPreferences.density.v1';
+
+@Injectable({ providedIn: 'root' })
+export class AdminPreferencesService {
+ private readonly localStorage = inject(LocalStorageService);
+
+ private readonly densitySignal = signal(this.readStoredDensity());
+
+ readonly density: Signal = this.densitySignal.asReadonly();
+
+ setDensity(value: AdminUiDensity): void {
+ this.densitySignal.set(value);
+ this.localStorage.setItem(DENSITY_KEY, value);
+ }
+
+ private readStoredDensity(): AdminUiDensity {
+ return this.localStorage.getItem(DENSITY_KEY) === 'compact' ? 'compact' : 'comfortable';
+ }
+}
diff --git a/src/app/features/admin/shell/admin-layout.component.html b/src/app/features/admin/shell/admin-layout.component.html
index f94a950..9396f75 100644
--- a/src/app/features/admin/shell/admin-layout.component.html
+++ b/src/app/features/admin/shell/admin-layout.component.html
@@ -44,8 +44,20 @@
- @for (entry of navBottom; track $index) {
- @if (entry.type === 'link' && entry.comingSoon) {
+ @for (entry of navBottom(); track $index) {
+ @if (entry.type === 'link' && entry.externalHref) {
+ -
+
+
+ {{ entry.labelKey | translate }}
+
+
+ } @else if (entry.type === 'link' && entry.comingSoon) {
-