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/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md
index 286b9c6..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`.
@@ -43,3 +54,4 @@ Condensed — full detail in commit history and `docs/RELEASE_REPORT.md`.
- 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/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
index 9df3fb6..f4e0ea0 100644
--- a/docs/SPRINT-PLAN-NEXT.md
+++ b/docs/SPRINT-PLAN-NEXT.md
@@ -12,36 +12,42 @@ Continues the sprint lettering from `docs/GLOBAL-SPRINT-PLAN.md` (Sprints A–F,
**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.
-- [ ] 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
-- [ ] 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)
-- [ ] Cross-check against the editor: which dead/inert fields are *user-editable* today (those are the client-facing ones, highest priority)
-- [ ] Produce a findings table: field → status → editable? → recommendation (wire it / hide the control / delete the field)
-- [ ] Fix the trivially-wireable ones in the same pass (a field with an obvious consumer that was simply never connected)
-- [ ] 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
-- [ ] Record findings in `docs/KNOWN-ISSUES.md` (real defects) / `docs/PRODUCT_BACKLOG.md` (needs a decision), matching how the earlier audit was folded in
+- [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.
-- [ ] 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
-- [ ] 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
-- [ ] 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), cart/checkout state
-- [ ] Unit tests for the pure validator primitives (`project-editor/schema/validators/primitives.ts`) — zero-dependency, highest value per line of test
-- [ ] Regression tests for the bugs fixed this cycle so they cannot silently return (carousel `layout.columns` sizing, manifest-filtered layout options, header profile login/logout gating)
-- [ ] Wire coverage reporting; set a realistic starting floor and ratchet it up rather than declaring 80% on day one
-- [ ] Decide whether to gate CI on it (`.github/workflows/architecture-governance.yml` already exists as the integration point)
+- [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)
+- [ ] Wire coverage reporting — **not done**: `ng test --code-coverage` fails (`Can not load reporter "coverage", it is not registered!`) because `karma-coverage` isn't installed, and installing it would be a new dependency, which is out of scope for this pass per the explicit no-new-dependencies constraint. Left for a follow-up that also gets sign-off on adding the package.
+- [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.)
-- [ ] 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
-- [ ] Add a `widgetSettingsSchema` validator alongside the existing `widgetConfig` check in `project-validator.service.ts`
-- [ ] 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
-- [ ] Confirm the diagnostics page (`features/diagnostics/`) reflects schema violations too, since it already consumes the manifest
+- [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.
---
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.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/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/diagnostics/validators/bootstrap-diagnostics.validator.ts b/src/app/features/diagnostics/validators/bootstrap-diagnostics.validator.ts
index 0e95c3d..2f84ec6 100644
--- a/src/app/features/diagnostics/validators/bootstrap-diagnostics.validator.ts
+++ b/src/app/features/diagnostics/validators/bootstrap-diagnostics.validator.ts
@@ -1,6 +1,7 @@
import { TranslateService } from '../../../i18n/translate.service';
import { BootstrapConfig } from '../../../shared/models/config';
import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract';
+import { validateAgainstSchemaLite } from '../../project-editor/schema/validators/primitives';
import { DiagnosticEntry } from '../models/diagnostics.model';
const KNOWN_LAYOUTS = new Set(['default', 'sidebar-left', 'carousel-home', 'minimal']);
@@ -28,6 +29,7 @@ export class BootstrapDiagnosticsValidator {
this.validateRequiredProperties(bootstrap, entries);
this.validateUnknownWidgetTypes(bootstrap, manifest, entries);
+ this.validateWidgetSettingsSchema(bootstrap, manifest, entries);
this.validateDuplicateIds(bootstrap, entries);
this.validateLayouts(bootstrap, entries);
this.validateFeatureFlags(bootstrap, entries);
@@ -85,6 +87,36 @@ export class BootstrapDiagnosticsValidator {
}
}
+ /**
+ * Reuses the same shallow schema check as ProjectValidator.widgetSettingsSchemaIssues
+ * (`validateAgainstSchemaLite`) so the editor and the diagnostics page never
+ * disagree about what "valid widget settings" means - one check, two surfaces.
+ */
+ private validateWidgetSettingsSchema(bootstrap: BootstrapConfig, manifest: WidgetManifestFile | null, entries: DiagnosticEntry[]): void {
+ const manifestByType = new Map((manifest?.widgets ?? []).map(widget => [widget.type, widget]));
+ for (const page of bootstrap.pages ?? []) {
+ for (const section of page.sections ?? []) {
+ for (const widget of section.widgets ?? []) {
+ const entry = manifestByType.get(widget.type);
+ if (!entry?.settingsSchema || !widget.props || typeof widget.props !== 'object') {
+ continue;
+ }
+ const errors = validateAgainstSchemaLite(widget.props, entry.settingsSchema);
+ if (errors.length > 0) {
+ entries.push({
+ code: 'BOOTSTRAP_WIDGET_SETTINGS_SCHEMA_MISMATCH',
+ severity: 'warning',
+ title: 'diagnostics.widgetSettingsSchemaMismatchTitle',
+ description: `diagnostics.widgetSettingsSchemaMismatchDescription:${widget.type}(${errors.join(', ')})`,
+ affectedComponent: `page:${page.key}/section:${section.id}/widget:${widget.id}`,
+ suggestedResolution: 'diagnostics.widgetSettingsSchemaMismatchResolution'
+ });
+ }
+ }
+ }
+ }
+ }
+
private validateDuplicateIds(bootstrap: BootstrapConfig, entries: DiagnosticEntry[]): void {
const pageIds = new Set();
diff --git a/src/app/features/project-editor/facade/project-editor.facade.spec.ts b/src/app/features/project-editor/facade/project-editor.facade.spec.ts
new file mode 100644
index 0000000..47bca22
--- /dev/null
+++ b/src/app/features/project-editor/facade/project-editor.facade.spec.ts
@@ -0,0 +1,149 @@
+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 { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
+import { ProjectEditorFacade } from './project-editor.facade';
+
+function makeBootstrap(): BootstrapConfig {
+ return {
+ tenant: { id: 'tenant-1', defaultLocale: 'en', supportedLocales: ['en'], websiteBaseUrl: 'https://dexar.market' },
+ branding: { logoUrl: 'logo.png' },
+ localization: { supportedLocales: ['en'], defaultLocale: 'en', currencyByLocale: {}, dictionaries: [] },
+ staticPages: {},
+ pages: [
+ {
+ key: 'home',
+ route: { path: '/' },
+ sections: [
+ {
+ order: 0,
+ layout: { strategy: 'stack' },
+ widgets: [{ id: 'w1', type: 'hero', version: '1', props: {} }],
+ },
+ ],
+ },
+ ],
+ navigation: { header: [], footer: [] },
+ theme: {
+ palette: {
+ primary: '#497671', secondary: '#a1b4b5', accent: '#a7ceca',
+ success: '#10b981', warning: '#f59e0b', danger: '#ef4444', info: '#3b82f6',
+ textPrimary: '#1e3c38', textSecondary: '#667a77',
+ backgroundPrimary: '#ffffff', backgroundSecondary: '#f5f5f5', border: '#d3dad9',
+ },
+ },
+ layout: { type: 'default' },
+ } as unknown as BootstrapConfig;
+}
+
+describe('ProjectEditorFacade', () => {
+ let facade: ProjectEditorFacade;
+ let runtimeSpy: jasmine.SpyObj;
+
+ beforeEach(() => {
+ localStorage.clear();
+ runtimeSpy = jasmine.createSpyObj('PlatformRuntimeService', ['reloadFromBootstrap']);
+
+ TestBed.configureTestingModule({
+ providers: [
+ provideRouter([]), provideHttpClient(), provideHttpClientTesting(),
+ { provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
+ { provide: PlatformRuntimeService, useValue: runtimeSpy },
+ ],
+ });
+
+ facade = TestBed.inject(ProjectEditorFacade);
+ facade.loadBootstrap();
+ });
+
+ afterEach(() => localStorage.clear());
+
+ it('loads the bootstrap and starts with a clean undo/redo stack', () => {
+ expect(facade.bootstrap()).toBeTruthy();
+ expect(facade.canUndo()).toBeFalse();
+ expect(facade.canRedo()).toBeFalse();
+ });
+
+ describe('undo/redo', () => {
+ it('undo reverts the last edit and enables redo', (done) => {
+ facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'new-logo.png' } }));
+
+ // updateBootstrap debounces the history commit - wait past HISTORY_DEBOUNCE_MS (300ms).
+ setTimeout(() => {
+ expect(facade.bootstrap()?.branding.logoUrl).toBe('new-logo.png');
+ expect(facade.canUndo()).toBeTrue();
+
+ facade.undo();
+ expect(facade.bootstrap()?.branding.logoUrl).toBe('logo.png');
+ expect(facade.canUndo()).toBeFalse();
+ expect(facade.canRedo()).toBeTrue();
+
+ facade.redo();
+ expect(facade.bootstrap()?.branding.logoUrl).toBe('new-logo.png');
+ expect(facade.canRedo()).toBeFalse();
+ done();
+ }, 350);
+ });
+
+ it('a fresh edit after undo discards the redo stack', (done) => {
+ facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'v2.png' } }));
+
+ setTimeout(() => {
+ facade.undo();
+ expect(facade.canRedo()).toBeTrue();
+
+ facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'v3.png' } }));
+ expect(facade.canRedo()).toBeFalse();
+ done();
+ }, 350);
+ });
+ });
+
+ describe('draft persistence round-trip', () => {
+ it('persists edits to the draft store and restores them on next load', (done) => {
+ facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'draft-logo.png' } }));
+
+ setTimeout(() => {
+ // Simulate a fresh facade instance (e.g. page reload) picking up the persisted draft.
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ providers: [
+ provideRouter([]), provideHttpClient(), provideHttpClientTesting(),
+ { provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
+ { provide: PlatformRuntimeService, useValue: runtimeSpy },
+ ],
+ });
+ const reloaded = TestBed.inject(ProjectEditorFacade);
+ reloaded.loadBootstrap();
+
+ expect(reloaded.draftRestored()).toBeTrue();
+ expect(reloaded.bootstrap()?.branding.logoUrl).toBe('draft-logo.png');
+ done();
+ }, 350);
+ });
+ });
+
+ describe('publish gating', () => {
+ it('publishes when there are no blocking validation issues', () => {
+ expect(facade.hasBlockingIssues()).toBeFalse();
+ const result = facade.publish();
+ expect(result).toBeTrue();
+ expect(facade.status()).toBe('published');
+ expect(runtimeSpy.reloadFromBootstrap).toHaveBeenCalled();
+ });
+
+ it('refuses to publish while a blocking issue is present', () => {
+ facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: '' } }));
+
+ expect(facade.hasBlockingIssues()).toBeTrue();
+ const result = facade.publish();
+ expect(result).toBeFalse();
+ expect(facade.status()).not.toBe('published');
+ expect(runtimeSpy.reloadFromBootstrap).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/app/features/project-editor/schema/validators/primitives.spec.ts b/src/app/features/project-editor/schema/validators/primitives.spec.ts
index f28e6c2..5261dd2 100644
--- a/src/app/features/project-editor/schema/validators/primitives.spec.ts
+++ b/src/app/features/project-editor/schema/validators/primitives.spec.ts
@@ -4,6 +4,7 @@ import {
isValidHexColor,
isValidHttpUrl,
normalizeRoute,
+ validateAgainstSchemaLite,
validateCss,
validateHtml,
validateJson,
@@ -79,6 +80,36 @@ describe('validation primitives', () => {
});
});
+ describe('validateAgainstSchemaLite', () => {
+ const schema = {
+ type: 'object' as const,
+ properties: { title: { type: 'string' }, count: { type: 'number' } },
+ required: ['title'],
+ };
+
+ it('accepts a value matching required fields and property types', () => {
+ expect(validateAgainstSchemaLite({ title: 'Hero', count: 3 }, schema)).toEqual([]);
+ });
+
+ it('flags a missing required property', () => {
+ const errors = validateAgainstSchemaLite({ count: 3 }, schema);
+ expect(errors.some(e => e.includes('title'))).toBeTrue();
+ });
+
+ it('flags a property with the wrong type', () => {
+ const errors = validateAgainstSchemaLite({ title: 'Hero', count: 'three' }, schema);
+ expect(errors.some(e => e.includes('count'))).toBeTrue();
+ });
+
+ it('ignores properties not declared in the schema', () => {
+ expect(validateAgainstSchemaLite({ title: 'Hero', extra: 'ignored' }, schema)).toEqual([]);
+ });
+
+ it('ignores an undeclared-optional property that is simply absent', () => {
+ expect(validateAgainstSchemaLite({ title: 'Hero' }, schema)).toEqual([]);
+ });
+ });
+
describe('validateHtml', () => {
it('accepts well-formed markup with nested tags', () => {
expect(validateHtml('Hello world
').ok).toBeTrue();
diff --git a/src/app/features/project-editor/schema/validators/primitives.ts b/src/app/features/project-editor/schema/validators/primitives.ts
index 26ce8a3..f637845 100644
--- a/src/app/features/project-editor/schema/validators/primitives.ts
+++ b/src/app/features/project-editor/schema/validators/primitives.ts
@@ -80,6 +80,54 @@ export function normalizeRoute(route: string): string {
return (route ?? '').trim().replace(/^\/+|\/+$/g, '').toLowerCase();
}
+export interface JsonSchemaLite {
+ type?: 'object';
+ /** Loosely typed to accept the wider `WidgetManifestEntry.settingsSchema` shape as-is - each entry's `type` is read defensively below. */
+ properties?: Record;
+ required?: string[];
+}
+
+/**
+ * Basic (type + required) JSON Schema check against an object - not a full
+ * JSON Schema engine (no nested schemas, enums, formats, or $ref). Enough to
+ * catch a widget's `props` missing a required field or holding the wrong
+ * primitive type for a declared property, without pulling in a schema
+ * validation dependency for a check this shallow.
+ */
+export function validateAgainstSchemaLite(value: Record, schema: JsonSchemaLite): string[] {
+ const errors: string[] = [];
+
+ for (const key of schema.required ?? []) {
+ if (value[key] === undefined || value[key] === null || value[key] === '') {
+ errors.push(`missing required property "${key}"`);
+ }
+ }
+
+ for (const [key, propSchema] of Object.entries(schema.properties ?? {})) {
+ const propValue = value[key];
+ const propType = propSchema && typeof propSchema === 'object' ? (propSchema as { type?: string }).type : undefined;
+ if (propValue === undefined || !propType) {
+ continue;
+ }
+ if (!matchesJsonSchemaType(propValue, propType)) {
+ errors.push(`property "${key}" should be ${propType}`);
+ }
+ }
+
+ return errors;
+}
+
+function matchesJsonSchemaType(value: unknown, type: string): boolean {
+ switch (type) {
+ case 'string': return typeof value === 'string';
+ case 'number': case 'integer': return typeof value === 'number';
+ case 'boolean': return typeof value === 'boolean';
+ case 'array': return Array.isArray(value);
+ case 'object': return typeof value === 'object' && value !== null && !Array.isArray(value);
+ default: return true;
+ }
+}
+
/** HTML void elements per the WHATWG spec: never require (or accept) a closing tag. */
const VOID_ELEMENTS = new Set([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
diff --git a/src/app/features/project-editor/services/project-validator.service.spec.ts b/src/app/features/project-editor/services/project-validator.service.spec.ts
index 38d780f..7c1eb86 100644
--- a/src/app/features/project-editor/services/project-validator.service.spec.ts
+++ b/src/app/features/project-editor/services/project-validator.service.spec.ts
@@ -1,4 +1,9 @@
+import { TestBed } from '@angular/core/testing';
+import { provideHttpClient } from '@angular/common/http';
+import { provideHttpClientTesting } from '@angular/common/http/testing';
import { BootstrapConfig } from '../../../shared/models/config';
+import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
+import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract';
import { ProjectValidator } from './project-validator.service';
function makeBootstrap(): BootstrapConfig {
@@ -45,7 +50,8 @@ describe('ProjectValidator', () => {
let validator: ProjectValidator;
beforeEach(() => {
- validator = new ProjectValidator();
+ TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
+ validator = TestBed.inject(ProjectValidator);
});
it('reports no issues for a valid baseline config', () => {
@@ -95,4 +101,45 @@ describe('ProjectValidator', () => {
const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-url');
expect(issue?.fieldKey).toBe('tenant.websiteBaseUrl');
});
+
+ describe('widgetSettingsSchema (against widget-manifest settingsSchema)', () => {
+ const manifest: WidgetManifestFile = {
+ widgets: [
+ {
+ type: 'hero',
+ version: '1',
+ componentKey: 'hero',
+ supportedLayouts: ['hero'],
+ supportedDataSources: [],
+ settingsSchema: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] },
+ defaultSettings: {},
+ },
+ ],
+ };
+
+ it('does nothing before the manifest has ever loaded (getManifestSnapshot() is null)', () => {
+ const bootstrap = makeBootstrap();
+ bootstrap.pages[0].sections[0].widgets[0].props = {};
+ expect(validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings')).toBeUndefined();
+ });
+
+ it('flags a widget missing a required settingsSchema property once the manifest is loaded', () => {
+ spyOn(TestBed.inject(WidgetManifestService), 'getManifestSnapshot').and.returnValue(manifest);
+ const bootstrap = makeBootstrap();
+ bootstrap.pages[0].sections[0].widgets[0].props = {};
+
+ const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings');
+ expect(issue).toBeDefined();
+ expect(issue?.section).toBe('widgets');
+ expect(issue?.severity).toBe('warning');
+ });
+
+ it('passes when the widget props satisfy the manifest settingsSchema', () => {
+ spyOn(TestBed.inject(WidgetManifestService), 'getManifestSnapshot').and.returnValue(manifest);
+ const bootstrap = makeBootstrap();
+ bootstrap.pages[0].sections[0].widgets[0].props = { title: 'Welcome' };
+
+ expect(validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings')).toBeUndefined();
+ });
+ });
});
diff --git a/src/app/features/project-editor/services/project-validator.service.ts b/src/app/features/project-editor/services/project-validator.service.ts
index da4b69f..841716b 100644
--- a/src/app/features/project-editor/services/project-validator.service.ts
+++ b/src/app/features/project-editor/services/project-validator.service.ts
@@ -1,12 +1,14 @@
-import { Injectable } from '@angular/core';
+import { Injectable, inject } from '@angular/core';
import { BootstrapConfig } from '../../../shared/models/config';
import { ProjectEditorSectionId } from '../models/project-editor.model';
+import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
import {
extractStyleBlocks,
isValidEmail,
isValidHexColor,
isValidHttpUrl,
normalizeRoute,
+ validateAgainstSchemaLite,
validateCss,
} from '../schema/validators/primitives';
@@ -43,6 +45,8 @@ function warning(code: string, message: string, section: ProjectEditorSectionId,
*/
@Injectable({ providedIn: 'root' })
export class ProjectValidator {
+ private readonly widgetManifest = inject(WidgetManifestService);
+
validate(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
return [
...this.brandingIssues(bootstrap),
@@ -52,6 +56,7 @@ export class ProjectValidator {
...this.duplicateRouteIssues(bootstrap),
...this.homepageIssues(bootstrap),
...this.widgetConfigIssues(bootstrap),
+ ...this.widgetSettingsSchemaIssues(bootstrap),
...this.navigationIssues(bootstrap),
...this.colorIssues(bootstrap),
...this.cssIssues(bootstrap),
@@ -162,6 +167,37 @@ export class ProjectValidator {
: [];
}
+ /**
+ * Validates each widget's `props` against its manifest entry's `settingsSchema`
+ * (basic type/required checks - see validateAgainstSchemaLite). No-ops until
+ * the manifest has loaded once (same early-return convention as
+ * RuntimeDiagnosticsValidator.validateDatasourceCoverage) rather than blocking
+ * publish on a manifest fetch race.
+ */
+ private widgetSettingsSchemaIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
+ const manifest = this.widgetManifest.getManifestSnapshot();
+ if (!manifest) {
+ return [];
+ }
+ const manifestByType = new Map(manifest.widgets.map(entry => [entry.type, entry]));
+
+ const hasInvalidSettings = bootstrap.pages.some(page =>
+ page.sections.some(section =>
+ section.widgets.some(widget => {
+ const entry = manifestByType.get(widget.type);
+ if (!entry?.settingsSchema || !widget.props || typeof widget.props !== 'object') {
+ return false;
+ }
+ return validateAgainstSchemaLite(widget.props, entry.settingsSchema).length > 0;
+ }),
+ ),
+ );
+
+ return hasInvalidSettings
+ ? [warning('invalid-widget-settings', 'builder.validationInvalidWidgetSettings', 'widgets', 'pages')]
+ : [];
+ }
+
private navigationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const keyOf = (item: { label?: string | Record; route?: string }): string =>
`${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`;
diff --git a/src/app/features/search/facade/search.facade.ts b/src/app/features/search/facade/search.facade.ts
index 34ea5bf..3a57c2f 100644
--- a/src/app/features/search/facade/search.facade.ts
+++ b/src/app/features/search/facade/search.facade.ts
@@ -3,6 +3,7 @@ import { ParamMap, Params } from '@angular/router';
import { Observable, Subject, of } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
import { Category } from '../../../core/categories/models/category-domain.model';
+import { ConfigService } from '../../../core/config/config.service';
import { Product, ProductListResult } from '../../../core/products/models/product-domain.model';
import { CategoryFacade } from '../../../facades/platform/category.facade';
import { ProductFacade } from '../../../facades/platform/product.facade';
@@ -42,6 +43,7 @@ export class SearchFacade {
private readonly trendingService = inject(SearchTrendingService);
private readonly cacheService = inject(SearchCacheService);
private readonly store = inject(SearchStore);
+ private readonly configService = inject(ConfigService);
private readonly metadataMemo = new Map();
private readonly autocompleteInput$ = new Subject<{
@@ -123,6 +125,10 @@ export class SearchFacade {
autocomplete(query: string, products: Product[], categories: Category[] = [], limit = 10): void {
this.store.setQuery(query);
+ if (this.configService.getBootstrapSnapshot()?.catalog?.suggestionsEnabled === false) {
+ this.store.setSuggestions([]);
+ return;
+ }
this.autocompleteInput$.next({ query, products, categories, limit });
}
diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts
index a044fbc..179d298 100644
--- a/src/app/i18n/en.ts
+++ b/src/app/i18n/en.ts
@@ -565,6 +565,7 @@ export const en: Translations = {
validationInvalidCss: 'A static page contains invalid CSS.',
validationDuplicateRoutes: 'Two or more pages share the same route.',
validationInvalidWidgetConfig: 'A widget is missing a required field (id, type, version, or props).',
+ validationInvalidWidgetSettings: "A widget's settings don't match what its type expects (missing or wrong-type field).",
validationInvalidContactEmail: 'The company contact email is not a valid email address.',
validationInvalidSocialLinkUrl: 'One or more footer social links have an invalid URL.',
validationIncompletePaymentIcon: 'A footer payment icon is missing its image or alt text.',
@@ -1224,6 +1225,9 @@ export const en: Translations = {
unknownWidgetTypeTitle: 'Unknown widget type',
unknownWidgetTypeDescription: 'Widget type is not present in widget manifest.',
unknownWidgetTypeResolution: 'Register widget in manifest or remove invalid widget config.',
+ widgetSettingsSchemaMismatchTitle: 'Widget settings do not match manifest schema',
+ widgetSettingsSchemaMismatchDescription: 'Widget props do not satisfy the settingsSchema declared for this widget type.',
+ widgetSettingsSchemaMismatchResolution: 'Fix the widget props in the editor to match the required fields/types, or update the manifest schema.',
duplicateIdTitle: 'Duplicate identifier',
duplicatePageIdDescription: 'Duplicate page id detected.',
duplicateSectionIdDescription: 'Duplicate section id detected.',
diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts
index 14bfb63..5ceef52 100644
--- a/src/app/i18n/hy.ts
+++ b/src/app/i18n/hy.ts
@@ -565,6 +565,7 @@ export const hy: Translations = {
validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։',
validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։',
validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։',
+ validationInvalidWidgetSettings: 'Վիջեթի կարգավորումները չեն համապատասխանում իր տիպի սպասվող ձևաչափին (դաշտ բացակայում է կամ սխալ տիպի է)։',
validationInvalidContactEmail: 'Ընկերության կոնտակտային էլ. փոստը վավեր չէ։',
validationInvalidSocialLinkUrl: 'Ֆուտերի սոցիալական հղումներից մեկը կամ մի քանիսը վավեր URL չունեն։',
validationIncompletePaymentIcon: 'Ֆուտերի վճարային պատկերակին բացակայում է պատկերը կամ alt տեքստը։',
@@ -1219,6 +1220,9 @@ export const hy: Translations = {
unknownWidgetTypeTitle: 'Անհայտ widget type',
unknownWidgetTypeDescription: 'Widget type-ը չկա widget manifest-ում։',
unknownWidgetTypeResolution: 'Ավելացրեք widget-ը manifest-ում կամ հեռացրեք սխալ config-ը։',
+ widgetSettingsSchemaMismatchTitle: 'Վիջեթի կարգավորումները չեն համապատասխանում manifest-ի սխեմային',
+ widgetSettingsSchemaMismatchDescription: 'Վիջեթի props-ը չի բավարարում այս տիպի համար հայտարարված settingsSchema-ին։',
+ widgetSettingsSchemaMismatchResolution: 'Ուղղեք վիջեթի props-ը խմբագրիչում՝ պարտադիր դաշտերին/տիպերին համապատասխան, կամ թարմացրեք manifest-ի սխեման։',
duplicateIdTitle: 'Կրկնվող ID',
duplicatePageIdDescription: 'Գտնվել է կրկնվող page id։',
duplicateSectionIdDescription: 'Գտնվել է կրկնվող section id։',
diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts
index 1b92cbc..912a823 100644
--- a/src/app/i18n/ru.ts
+++ b/src/app/i18n/ru.ts
@@ -565,6 +565,7 @@ export const ru: Translations = {
validationInvalidCss: 'Статическая страница содержит недопустимый CSS.',
validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.',
validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).',
+ validationInvalidWidgetSettings: 'Настройки виджета не соответствуют ожидаемым для его типа (поле отсутствует или неверного типа).',
validationInvalidContactEmail: 'Контактный email компании указан некорректно.',
validationInvalidSocialLinkUrl: 'У одной или нескольких социальных ссылок в футере некорректный URL.',
validationIncompletePaymentIcon: 'У иконки оплаты в футере отсутствует изображение или альтернативный текст.',
@@ -1219,6 +1220,9 @@ export const ru: Translations = {
unknownWidgetTypeTitle: 'Неизвестный тип виджета',
unknownWidgetTypeDescription: 'Тип виджета отсутствует в widget manifest.',
unknownWidgetTypeResolution: 'Добавьте виджет в manifest или удалите неверную конфигурацию.',
+ widgetSettingsSchemaMismatchTitle: 'Настройки виджета не соответствуют схеме manifest',
+ widgetSettingsSchemaMismatchDescription: 'Props виджета не соответствуют settingsSchema, заданной для этого типа виджета.',
+ widgetSettingsSchemaMismatchResolution: 'Исправьте props виджета в редакторе в соответствии с обязательными полями/типами, либо обновите схему в manifest.',
duplicateIdTitle: 'Дублирующийся идентификатор',
duplicatePageIdDescription: 'Обнаружен дублирующийся id страницы.',
duplicateSectionIdDescription: 'Обнаружен дублирующийся id секции.',
diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts
index 46ccc1e..1b855e8 100644
--- a/src/app/i18n/translations.ts
+++ b/src/app/i18n/translations.ts
@@ -563,6 +563,7 @@ export interface Translations {
validationInvalidCss: string;
validationDuplicateRoutes: string;
validationInvalidWidgetConfig: string;
+ validationInvalidWidgetSettings: string;
validationInvalidContactEmail: string;
validationInvalidSocialLinkUrl: string;
validationIncompletePaymentIcon: string;
@@ -1223,6 +1224,9 @@ export interface Translations {
unknownWidgetTypeTitle: string;
unknownWidgetTypeDescription: string;
unknownWidgetTypeResolution: string;
+ widgetSettingsSchemaMismatchTitle: string;
+ widgetSettingsSchemaMismatchDescription: string;
+ widgetSettingsSchemaMismatchResolution: string;
duplicateIdTitle: string;
duplicatePageIdDescription: string;
duplicateSectionIdDescription: string;
diff --git a/src/app/widgets/registry/widget-manifest.service.ts b/src/app/widgets/registry/widget-manifest.service.ts
index e040bd0..33ffd5d 100644
--- a/src/app/widgets/registry/widget-manifest.service.ts
+++ b/src/app/widgets/registry/widget-manifest.service.ts
@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
-import { Observable, catchError, map, of, shareReplay, switchMap, take } from 'rxjs';
+import { Observable, catchError, map, of, shareReplay, switchMap, take, tap } from 'rxjs';
import { WidgetManifestEntry, WidgetManifestFile } from '../contracts/widget-manifest.contract';
import { ConfigService } from '../../core/config/config.service';
@@ -8,6 +8,8 @@ import { ConfigService } from '../../core/config/config.service';
export class WidgetManifestService {
private readonly fallbackManifestUrl = '/assets/mock/bootstrap/widget-manifest.json';
private readonly manifestByUrl = new Map>();
+ /** Last manifest resolved by getManifest(), for synchronous readers (e.g. ProjectValidator) that can't await an Observable. Mirrors ConfigService.getBootstrapSnapshot(). */
+ private manifestSnapshot: WidgetManifestFile | null = null;
constructor(
private readonly http: HttpClient,
@@ -23,6 +25,7 @@ export class WidgetManifestService {
}
const manifest$ = this.http.get(manifestUrl).pipe(
+ tap(manifest => { this.manifestSnapshot = manifest; }),
shareReplay({ bufferSize: 1, refCount: true }),
catchError(() => of({ widgets: [] }))
);
@@ -41,6 +44,11 @@ export class WidgetManifestService {
return this.getWidgets().pipe(map((widgets) => widgets.find((widget) => widget.type === type)));
}
+ /** Synchronous accessor for the last-resolved manifest, or null before it's loaded once. */
+ getManifestSnapshot(): WidgetManifestFile | null {
+ return this.manifestSnapshot;
+ }
+
private resolveManifestUrl(): Observable {
const snapshotUrl = this.configService.getBootstrapSnapshot()?.widgetRegistry?.manifestUrl;
if (snapshotUrl) {
diff --git a/src/app/widgets/ui/hero-widget.component.spec.ts b/src/app/widgets/ui/hero-widget.component.spec.ts
new file mode 100644
index 0000000..ae3c89f
--- /dev/null
+++ b/src/app/widgets/ui/hero-widget.component.spec.ts
@@ -0,0 +1,52 @@
+import { TestBed } from '@angular/core/testing';
+import { SectionConfig } from '../../shared/models/config';
+import { HeroWidgetData } from '../contracts/widget-data.contract';
+import { HeroWidgetComponent } from './hero-widget.component';
+
+function makeSection(columns?: number): SectionConfig {
+ return { id: 's1', type: 'hero', order: 0, layout: { columns }, widgets: [] } as unknown as SectionConfig;
+}
+
+function makeData(): HeroWidgetData {
+ return {
+ title: 'First slide',
+ subtitle: 'sub',
+ ctaLabel: 'Shop now',
+ autoplay: false,
+ slides: [{ title: 'Second slide' }],
+ } as unknown as HeroWidgetData;
+}
+
+describe('HeroWidgetComponent panel count regression (layout.columns)', () => {
+ it('shows 1 panel when layout.columns is 1 (or unset)', () => {
+ const fixture = TestBed.createComponent(HeroWidgetComponent);
+ fixture.componentInstance.section = makeSection(1);
+ fixture.componentInstance.data = makeData();
+ fixture.componentInstance.ngOnChanges({ data: {} as any });
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.panelCount).toBe(1);
+ expect(fixture.componentInstance.visibleSlides().length).toBe(1);
+ });
+
+ it('shows 2 panels when layout.columns is 2 and more than one slide exists', () => {
+ const fixture = TestBed.createComponent(HeroWidgetComponent);
+ fixture.componentInstance.section = makeSection(2);
+ fixture.componentInstance.data = makeData();
+ fixture.componentInstance.ngOnChanges({ data: {} as any });
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.panelCount).toBe(2);
+ expect(fixture.componentInstance.visibleSlides().length).toBe(2);
+ });
+
+ it('falls back to 1 panel when columns is 2 but there is only a single slide', () => {
+ const fixture = TestBed.createComponent(HeroWidgetComponent);
+ fixture.componentInstance.section = makeSection(2);
+ fixture.componentInstance.data = { title: 'Only slide', autoplay: false } as unknown as HeroWidgetData;
+ fixture.componentInstance.ngOnChanges({ data: {} as any });
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.panelCount).toBe(1);
+ });
+});
diff --git a/src/app/widgets/ui/product-carousel-widget.component.spec.ts b/src/app/widgets/ui/product-carousel-widget.component.spec.ts
new file mode 100644
index 0000000..4c1b71c
--- /dev/null
+++ b/src/app/widgets/ui/product-carousel-widget.component.spec.ts
@@ -0,0 +1,35 @@
+import { TestBed } from '@angular/core/testing';
+import { SectionConfig } from '../../shared/models/config';
+import { ProductCollectionWidgetData } from '../contracts/widget-data.contract';
+import { ProductCarouselWidgetComponent } from './product-carousel-widget.component';
+
+function makeSection(columns?: number): SectionConfig {
+ return { id: 's1', type: 'product-carousel', order: 0, layout: { columns }, widgets: [] } as unknown as SectionConfig;
+}
+
+describe('ProductCarouselWidgetComponent sizing regression (layout.columns)', () => {
+ it('defaults to 4 items per page when layout.columns is unset', () => {
+ const fixture = TestBed.createComponent(ProductCarouselWidgetComponent);
+ fixture.componentInstance.section = makeSection(undefined);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.itemsPerPage()).toBe(4);
+ });
+
+ it('reflects layout.columns when set', () => {
+ const fixture = TestBed.createComponent(ProductCarouselWidgetComponent);
+ fixture.componentInstance.section = makeSection(3);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.itemsPerPage()).toBe(3);
+ });
+
+ it('applies the resolved value as the --items-per-page CSS custom property', () => {
+ const fixture = TestBed.createComponent(ProductCarouselWidgetComponent);
+ fixture.componentInstance.section = makeSection(2);
+ fixture.detectChanges();
+
+ const host = (fixture.nativeElement as HTMLElement).querySelector('.product-carousel-widget') as HTMLElement;
+ expect(host.style.getPropertyValue('--items-per-page').trim()).toBe('2');
+ });
+});