9 Commits

Author SHA1 Message Date
sdarbinyan
5d47101714 merge: B2B into main
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 02:48:32 +04:00
sdarbinyan
7a2f2a452f refactor: migrate cart payment modals to shared app-dialog primitive
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Third attempt, done properly this time - first two were reverted
(one stopped cleanly on real conflicts, one botched sequencing and
deleted the old focus-trap before finishing the swap).

DialogComponent gains closeOnEscape/closeOnBackdropClick (default true,
backward-compatible with its 13 other call sites) and ariaLabel (for
dialogs with no visible title header). FOCUSABLE_SELECTOR now includes
iframe 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 tied to the bank
popup's open state, so Escape closes the nested bank iframe first and
falls back to the QR view - matches the original priority exactly.

Original geometry (500px QR modal/40px padding, 960x760 bank modal/
56-16-16 padding, both mobile breakpoints) preserved via :host ::ng-deep
overrides scoped per dialog instance - same pattern already used by
product-carousel-widget.component.ts.

cart.component.ts loses ~90 lines of hand-rolled ViewChild/HostListener/
focus-trap code - app-dialog owns all of it now.

Verified live in browser: dialog sizing/padding/aria-label correct at
mobile+desktop, backdrop-click confirmed inert, Escape-priority confirmed
(bank closes first, then QR), initial focus lands on close button.
83/83 tests pass, tsc/build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 11:13:33 +04:00
sdarbinyan
6d075fc5b9 perf: drop @lucide/angular, hand-roll used icons - initial bundle 13.68MB -> 2.64MB
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
@lucide/angular shipped its entire ~1500-icon set in the initial bundle
despite the app only using 85 named-imported icons - confirmed upstream
tree-shaking failure (sideEffects:false, clean named imports, single
non-splittable fesm file). Replaced icon-registry.ts/icon.component.ts
with hand-rolled inline SVG rendering of just the 85 used icons,
transcribed from lucide's own node data for pixel-identical output.
Zero call-site changes - AppIconName and app-icon's public API unchanged.

Also: karma-coverage wired (npm run test:coverage), baseline captured
in docs/SPRINT-PLAN-NEXT.md (32% statements / 18.5% branches).

Cart-modal -> app-dialog migration was attempted and reverted - real
conflicts (backdrop-close, nested-modal escape priority, iframe sizing),
documented in docs/FUTURE_FEATURES.md for a properly scoped follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 10:55:00 +04:00
sdarbinyan
a95ca37a4b docs: quantify the initial-bundle icon-set bloat finding
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
11MB of the ~14MB initial bundle is the full @lucide/angular icon set
despite clean named imports for ~85 icons - confirmed by build inspection,
previously undocumented (existing note only covered the two lazy chunks).
Root cause is upstream tree-shaking, not app code. Real fixes (package
upgrade or dropping the dependency) need sign-off before touching.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:53:05 +04:00
sdarbinyan
ce63931bc2 feat: dead-config sweep, test suite foundation, widget settingsSchema validation
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Sprint G: audited every BootstrapConfig field for a real runtime consumer
(docs/DEAD-CONFIG-AUDIT.md). Wired 3 previously-dead editable fields:
footer.logoUrl, company.address.street/contacts.phone, catalog.suggestionsEnabled.
Remaining dead fields needing a business/design decision tracked in
PRODUCT_BACKLOG.md/KNOWN-ISSUES.md, not silently left.

Sprint H: 6 new spec files (test count 57 -> 83), covering ProjectEditorFacade
(undo/redo, draft persistence, publish gating), AdminAnalyticsFacade
(never-fabricate-a-number contract), and regression coverage for this
session's carousel/hero/profile-toggle fixes.

Sprint I: widget settingsSchema (declared in widget-manifest.json, never
validated) now enforced via a new lightweight schema check in
ProjectValidator, surfaced through the existing issuesByField pipeline.
Same check reused in diagnostics so editor and diagnostics can't disagree.

Verification: tsc clean, ng build clean, 83/83 tests pass, barry-cache
validate clean (2 pre-existing unrelated warnings only).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 20:47:13 +04:00
sdarbinyan
6f9401fa8f docs: sprint plan for dead-config sweep, test suite foundation, widget schema enforcement
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 19:32:48 +04:00
sdarbinyan
55b379bd6d fix: manifest-aware layout picker, real carousel items-per-page, hero arrows/swipe/2-panel
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Sprint E: homepage section editor now filters the layout-strategy picker
to each widget's widget-manifest.json supportedLayouts instead of always
showing all 5 strategies. columns field gated to widgets that read it
(hero, product-collection carousel).

Sprint F: closes client bug report (no items-per-page control, hero
carousel not manually/automatically scrollable, no 1-2 slide big-carousel
option). Product carousel item width now driven by layout.columns
(reused, was already editable but dead). Hero widget gains prev/next
arrows, touch swipe, and 1-2 panel mode via the same field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 19:25:34 +04:00
sdarbinyan
3e3185cb6e docs: mark GLOBAL-SPRINT-PLAN housekeeping checklist complete
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 18:53:18 +04:00
sdarbinyan
48bcffa22c feat: close stub-page gaps - profile login/logout, admin Reports/Settings, Help/Docs links
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Sprint A: storefront header profile control (login/logout only, no menu),
wired to existing customer Telegram auth (AuthService).

Sprint B: backoffice/reports page, reuses AdminAnalyticsFacade (Sales,
Top Products, Marketplace Health cards + CSV export).

Sprint C: backoffice/settings page, admin UI density preference
(comfortable/compact), localStorage-persisted, applied to app-table
across all admin list pages.

Sprint D: admin bottom-nav Help -> mailto using existing supportEmail,
Documentation -> external link via new TenantConfig.documentationUrl.
AdminNavLink gains externalHref for non-routerLink nav entries.

Docs: docs/GLOBAL-SPRINT-PLAN.md tracks the full sprint breakdown.
docs/COMING-SOON-AUDIT.md removed, folded into docs/KNOWN-ISSUES.md.
docs/BACKEND.md updated with the new documentationUrl bootstrap field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 17:48:50 +04:00
62 changed files with 2221 additions and 401 deletions

View File

@@ -168,8 +168,12 @@ Each nested field, with its source model file under
- **`tenant`** (`TenantConfig`, `tenant.model.ts`) — - **`tenant`** (`TenantConfig`, `tenant.model.ts`) —
`{ id (UUID), slug, code, host, name, websiteBaseUrl, builderBaseUrl, `{ id (UUID), slug, code, host, name, websiteBaseUrl, builderBaseUrl,
backofficeBaseUrl, defaultLocale, supportedLocales[], defaultCurrency, backofficeBaseUrl, defaultLocale, supportedLocales[], defaultCurrency,
supportedCurrencies[], timezone }`. Identifies the tenant and its per-surface supportedCurrencies[], timezone, documentationUrl? }`. Identifies the tenant
base URLs, locale/currency sets, and timezone. 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`) — - **`branding`** (`BrandingConfig`, `branding.model.ts`) —
`{ brandName, legalName, slogan?, logoUrl, logoCompactUrl?, faviconUrl, `{ brandName, legalName, slogan?, logoUrl, logoCompactUrl?, faviconUrl,
appIconUrl?, socialImageUrl?, galleryUrls?, supportEmail?, supportPhone? }`. appIconUrl?, socialImageUrl?, galleryUrls?, supportEmail?, supportPhone? }`.
@@ -304,7 +308,8 @@ trimmed for length; full versions in that file):
"supportedLocales": ["ru", "en", "hy"], "supportedLocales": ["ru", "en", "hy"],
"defaultCurrency": "RUB", "defaultCurrency": "RUB",
"supportedCurrencies": ["RUB", "USD", "EUR", "AMD"], "supportedCurrencies": ["RUB", "USD", "EUR", "AMD"],
"timezone": "Europe/Moscow" "timezone": "Europe/Moscow",
"documentationUrl": "https://docs.marketplace.local"
}, },
"branding": { "branding": {
"brandName": "Marketplace", "brandName": "Marketplace",

68
docs/DEAD-CONFIG-AUDIT.md Normal file
View File

@@ -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.

View File

@@ -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) ### 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. - **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`.
- **`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` 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.
- **`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. - ~~`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)".

View File

@@ -4,7 +4,13 @@ Nice-to-have, non-blocking work — no client decision needed, just not worth do
## Cart payment modal → `app-dialog` migration ## 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 ## Angular 22 upgrade
@@ -12,7 +18,9 @@ Researched, not executed. Estimated ~23.5 days, needs the `barry-cache` depen
## Bundle splitting ## 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` (~330375 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 ## Homepage hero-to-categories spacing investigation

View File

@@ -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, `<app-telegram-login />` 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 `<a>` 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 `<a>` 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)

View File

@@ -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 - Found: 2026-07-26, Backend Finalization Sprint documentation pass (traced while
writing `docs/BACKEND.md` §4 Authentication / §6 Error Model). 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) ## Fixed (this cycle)
Condensed — full detail in commit history and `docs/RELEASE_REPORT.md`. 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. - `primeng`/`primeicons` unused dependency — removed.
- Builder static-page body editor hidden inside a mislabeled collapsed section — un-hidden, relabeled. - 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. - 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`.

View File

@@ -20,4 +20,4 @@ Wire real error tracking/APM and a real event source for the admin Monitoring pa
## Phase 5 — Version 2 ideas ## 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)".

View File

@@ -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. **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 ## 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. 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.

94
docs/SPRINT-PLAN-NEXT.md Normal file
View File

@@ -0,0 +1,94 @@
# Sprint Plan — Next Wave (G onward)
Continues the sprint lettering from `docs/GLOBAL-SPRINT-PLAN.md` (Sprints AF, 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, ~23.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.

View File

@@ -12,6 +12,7 @@ module.exports = function (config) {
require('karma-jasmine'), require('karma-jasmine'),
require('karma-chrome-launcher'), require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'), require('karma-jasmine-html-reporter'),
require('karma-coverage'),
], ],
browsers: ['ChromeHeadlessNoSandbox'], browsers: ['ChromeHeadlessNoSandbox'],
customLaunchers: { customLaunchers: {
@@ -20,7 +21,12 @@ module.exports = function (config) {
flags: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'], 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, restartOnFileChange: true,
}); });
}; };

160
package-lock.json generated
View File

@@ -17,7 +17,6 @@
"@angular/platform-browser": "22.0.8", "@angular/platform-browser": "22.0.8",
"@angular/router": "22.0.8", "@angular/router": "22.0.8",
"@angular/service-worker": "22.0.8", "@angular/service-worker": "22.0.8",
"@lucide/angular": "^1.25.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.8.0", "tslib": "^2.8.0",
"zone.js": "~0.16.0" "zone.js": "~0.16.0"
@@ -32,6 +31,7 @@
"jasmine-core": "~5.5.0", "jasmine-core": "~5.5.0",
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",
"karma-coverage": "^2.2.1",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~6.0.3" "typescript": "~6.0.3"
@@ -1968,19 +1968,6 @@
"win32" "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": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@@ -5459,6 +5446,16 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/has-symbols": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -5534,6 +5531,13 @@
"node": "20 || >=22" "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": { "node_modules/htmlparser2": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -5934,6 +5938,60 @@
"node": ">=10" "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": { "node_modules/jasmine-core": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.5.0.tgz", "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.5.0.tgz",
@@ -6097,6 +6155,51 @@
"which": "bin/which" "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": { "node_modules/karma-jasmine": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz", "resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz",
@@ -6610,6 +6713,22 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/make-fetch-happen": {
"version": "15.0.6", "version": "15.0.6",
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", "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" "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": { "node_modules/tar": {
"version": "7.5.22", "version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",

View File

@@ -9,6 +9,7 @@
"build": "ng build", "build": "ng build",
"build:dexar": "ng build --configuration=production", "build:dexar": "ng build --configuration=production",
"test": "ng test --watch=false --browsers=ChromeHeadlessNoSandbox", "test": "ng test --watch=false --browsers=ChromeHeadlessNoSandbox",
"test:coverage": "ng test --watch=false --browsers=ChromeHeadlessNoSandbox --code-coverage",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"arch:check:boundaries": "node tools/architecture/check-boundaries.mjs", "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", "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/platform-browser": "22.0.8",
"@angular/router": "22.0.8", "@angular/router": "22.0.8",
"@angular/service-worker": "22.0.8", "@angular/service-worker": "22.0.8",
"@lucide/angular": "^1.25.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.8.0", "tslib": "^2.8.0",
"zone.js": "~0.16.0" "zone.js": "~0.16.0"
@@ -45,6 +45,7 @@
"jasmine-core": "~5.5.0", "jasmine-core": "~5.5.0",
"karma": "~6.4.0", "karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0", "karma-chrome-launcher": "~3.2.0",
"karma-coverage": "^2.2.1",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0", "karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~6.0.3" "typescript": "~6.0.3"

View File

@@ -254,6 +254,24 @@ const coreRoutes: Routes = [
breadcrumb: [{ labelKey: 'adminShell.nav.analytics' }] 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', path: 'partners/seller-management',
loadComponent: () => import('./features/admin/seller-management/pages/admin-seller-management-page.component').then(m => m.AdminSellerManagementPageComponent), loadComponent: () => import('./features/admin/seller-management/pages/admin-seller-management-page.component').then(m => m.AdminSellerManagementPageComponent),

View File

@@ -2,7 +2,7 @@
<div class="app-footer__container page-container"> <div class="app-footer__container page-container">
<div class="app-footer__top"> <div class="app-footer__top">
<div class="app-footer__brand"> <div class="app-footer__brand">
<app-logo /> <app-logo [srcOverride]="footerLogoUrl()" />
<p>{{ 'footer.description' | translate }}</p> <p>{{ 'footer.description' | translate }}</p>
</div> </div>
@@ -46,6 +46,12 @@
@if (contactEmail) { @if (contactEmail) {
<a [href]="'mailto:' + contactEmail">{{ contactEmail }}</a> <a [href]="'mailto:' + contactEmail">{{ contactEmail }}</a>
} }
@if (contactPhone) {
<a [href]="'tel:' + contactPhone">{{ contactPhone }}</a>
}
@if (companyAddress) {
<span class="app-footer__address">{{ companyAddress }}</span>
}
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -21,6 +21,7 @@ export class FooterComponent {
readonly footerGroups = signal<FooterResolvedGroup[]>([]); readonly footerGroups = signal<FooterResolvedGroup[]>([]);
readonly paymentIcons = signal<FooterPaymentIcon[]>([]); readonly paymentIcons = signal<FooterPaymentIcon[]>([]);
readonly copyrightText = signal(''); readonly copyrightText = signal('');
readonly footerLogoUrl = signal<string | undefined>(undefined);
private readonly configService = inject(ConfigService); private readonly configService = inject(ConfigService);
@@ -35,6 +36,7 @@ export class FooterComponent {
this.footerGroups.set([]); this.footerGroups.set([]);
this.paymentIcons.set([]); this.paymentIcons.set([]);
this.copyrightText.set(''); this.copyrightText.set('');
this.footerLogoUrl.set(undefined);
return; return;
} }
@@ -42,6 +44,7 @@ export class FooterComponent {
this.footerGroups.set(model.groups); this.footerGroups.set(model.groups);
this.paymentIcons.set(model.paymentIcons); this.paymentIcons.set(model.paymentIcons);
this.copyrightText.set(model.copyrightText); this.copyrightText.set(model.copyrightText);
this.footerLogoUrl.set(model.logoUrl);
}); });
this.configService.loadBootstrap().subscribe(); this.configService.loadBootstrap().subscribe();
@@ -56,4 +59,12 @@ export class FooterComponent {
get contactEmail(): string { get contactEmail(): string {
return this.uiRuntime.contactEmail(); return this.uiRuntime.contactEmail();
} }
get contactPhone(): string {
return this.uiRuntime.contactPhone();
}
get companyAddress(): string {
return this.uiRuntime.companyAddress();
}
} }

View File

@@ -80,6 +80,19 @@
</a> </a>
} }
<!-- Profile (login/logout only) -->
@if (headerConfig().showProfile) {
@if (isAuthenticated()) {
<button type="button" class="platform-ux-btn" (click)="logout()" [attr.aria-label]="'header.logout' | translate">
<app-icon name="logOut" [size]="16" class="platform-ux-icon" />
</button>
} @else {
<button type="button" class="platform-ux-btn" (click)="login()" [attr.aria-label]="'header.login' | translate">
<app-icon name="user" [size]="16" class="platform-ux-icon" />
</button>
}
}
<!-- Region Selector (desktop only) --> <!-- Region Selector (desktop only) -->
@if (headerConfig().showRegion) { @if (headerConfig().showRegion) {
<div class="platform-region-selector platform-lang-desktop"> <div class="platform-region-selector platform-lang-desktop">
@@ -147,4 +160,6 @@
</div> </div>
</div> </div>
<app-telegram-login />

View File

@@ -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();
});
});

View File

@@ -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 { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
import { StaticPageResolverService } from '../../core/config/static-page-resolver.service'; import { StaticPageResolverService } from '../../core/config/static-page-resolver.service';
import { IconComponent } from '../../shared/ui/icon/icon.component'; import { IconComponent } from '../../shared/ui/icon/icon.component';
import { AuthService } from '../../services/auth.service';
import { TelegramLoginComponent } from '../telegram-login/telegram-login.component';
@Component({ @Component({
selector: 'app-header', 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', templateUrl: './header.component.html',
styleUrls: ['./header.component.scss'], styleUrls: ['./header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -35,7 +37,9 @@ export class HeaderComponent {
private configService = inject(ConfigService); private configService = inject(ConfigService);
private featureConfig = inject(FeatureConfigService); private featureConfig = inject(FeatureConfigService);
private staticPageResolver = inject(StaticPageResolverService); private staticPageResolver = inject(StaticPageResolverService);
private authService = inject(AuthService);
readonly isAuthenticated = this.authService.isAuthenticated;
readonly wishlistCount = this.uxFacade.wishlistCount; readonly wishlistCount = this.uxFacade.wishlistCount;
readonly compareCount = this.uxFacade.compareCount; readonly compareCount = this.uxFacade.compareCount;
readonly userExperienceConfig = computed(() => this.resolveUserExperienceConfig()); readonly userExperienceConfig = computed(() => this.resolveUserExperienceConfig());
@@ -118,6 +122,14 @@ export class HeaderComponent {
this.router.navigate([`/${lang}/compare`]); this.router.navigate([`/${lang}/compare`]);
} }
login(): void {
this.authService.requestLogin();
}
logout(): void {
this.authService.logout();
}
navigateToStatic(route: string): void { navigateToStatic(route: string): void {
this.closeMenu(); this.closeMenu();
const lang = this.langService.currentLanguage(); const lang = this.langService.currentLanguage();

View File

@@ -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'; import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
@Component({ @Component({
@@ -17,6 +17,9 @@ import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class LogoComponent { export class LogoComponent {
/** Overrides the default (branding.logoUrl) logo, e.g. footer.logoUrl. */
@Input() srcOverride?: string;
constructor(private readonly uiRuntime: UiRuntimeFacade) {} constructor(private readonly uiRuntime: UiRuntimeFacade) {}
get brandName(): string { get brandName(): string {
@@ -24,6 +27,6 @@ export class LogoComponent {
} }
get logoPath(): string { get logoPath(): string {
return this.uiRuntime.logoUrl(); return this.srcOverride || this.uiRuntime.logoUrl();
} }
} }

View File

@@ -37,6 +37,7 @@ export interface FooterResolvedModel {
groups: FooterResolvedGroup[]; groups: FooterResolvedGroup[];
paymentIcons: FooterPaymentIcon[]; paymentIcons: FooterPaymentIcon[];
copyrightText: string; copyrightText: string;
logoUrl?: string;
} }
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -57,7 +58,8 @@ export class FooterResolverService {
return { return {
groups: this.resolveFooterGroups(bootstrap), groups: this.resolveFooterGroups(bootstrap),
paymentIcons: this.resolvePaymentIcons(bootstrap.footer), paymentIcons: this.resolvePaymentIcons(bootstrap.footer),
copyrightText: this.resolveCopyrightText(bootstrap) copyrightText: this.resolveCopyrightText(bootstrap),
logoUrl: bootstrap.footer?.logoUrl
}; };
} }

View File

@@ -7,6 +7,8 @@ interface UiRuntimeState {
marketplaceDisplayName: string; marketplaceDisplayName: string;
logoUrl: string; logoUrl: string;
contactEmail: string; contactEmail: string;
contactPhone: string;
companyAddress: string;
themeId: string; themeId: string;
} }
@@ -17,6 +19,8 @@ export class UiRuntimeFacade {
marketplaceDisplayName: '', marketplaceDisplayName: '',
logoUrl: '', logoUrl: '',
contactEmail: '', contactEmail: '',
contactPhone: '',
companyAddress: '',
themeId: '' themeId: ''
}); });
@@ -38,6 +42,8 @@ export class UiRuntimeFacade {
marketplaceDisplayName: bootstrap.branding.brandName, marketplaceDisplayName: bootstrap.branding.brandName,
logoUrl: bootstrap.branding.logoUrl, logoUrl: bootstrap.branding.logoUrl,
contactEmail: bootstrap.branding.supportEmail ?? bootstrap.company?.contacts?.email ?? '', 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 themeId: bootstrap.theme.themeId
}); });
} }
@@ -58,6 +64,14 @@ export class UiRuntimeFacade {
return this.state().contactEmail; return this.state().contactEmail;
} }
contactPhone(): string {
return this.state().contactPhone;
}
companyAddress(): string {
return this.state().companyAddress;
}
themeId(): string { themeId(): string {
return this.state().themeId; return this.state().themeId;
} }

View File

@@ -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> = {}): 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<AdminOrdersLocalGateway>;
let productsGateway: jasmine.SpyObj<AdminProductsLocalGateway>;
let categoriesGateway: jasmine.SpyObj<{ loadCategories: () => unknown }>;
let moderationGateway: jasmine.SpyObj<AdminModerationLocalGateway>;
let dashboardFacade: jasmine.SpyObj<AdminDashboardFacade>;
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');
});
});

View File

@@ -31,7 +31,7 @@ const SHORTCUTS: AdminDashboardShortcut[] = [
{ id: 'static-pages', icon: 'edit', labelKey: 'dashboard.actionStaticPages', route: ['backoffice', 'static-pages'] }, { id: 'static-pages', icon: 'edit', labelKey: 'dashboard.actionStaticPages', route: ['backoffice', 'static-pages'] },
{ id: 'orders', icon: 'cart', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] }, { id: 'orders', icon: 'cart', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] },
{ id: 'users', icon: 'users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] }, { 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: 'media-library', icon: 'images', labelKey: 'dashboard.actionMediaLibrary', route: ['backoffice', 'media'] },
{ id: 'content', icon: 'alignLeft', labelKey: 'dashboard.shortcutContent', route: ['edit', 'static-pages'] }, { id: 'content', icon: 'alignLeft', labelKey: 'dashboard.shortcutContent', route: ['edit', 'static-pages'] },
]; ];

View File

@@ -0,0 +1,32 @@
<section class="admin-reports-page">
@if (facade.loading()) {
<div class="report-grid" role="status" aria-live="polite" aria-busy="true">
@for (i of [1,2,3]; track i) {
<app-skeleton shape="rect" height="140px" />
}
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else {
<div class="report-grid">
<div class="report-card">
<h2>{{ 'adminReports.sales' | translate }}</h2>
@if (facade.summary(); as summary) {
<p class="report-summary">{{ summary.revenueTotal }} {{ summary.currency }} &middot; {{ summary.ordersCount }} {{ 'adminAnalytics.orders' | translate }}</p>
}
<app-button variant="secondary" size="sm" (click)="exportSalesCsv()">{{ 'adminOrders.export' | translate }}</app-button>
</div>
<div class="report-card">
<h2>{{ 'adminReports.topProducts' | translate }}</h2>
<p class="report-summary">{{ facade.topProducts().length }} {{ 'adminAnalytics.topProducts' | translate }}</p>
<app-button variant="secondary" size="sm" (click)="exportTopProductsCsv()">{{ 'adminOrders.export' | translate }}</app-button>
</div>
<div class="report-card">
<h2>{{ 'adminReports.marketplaceHealth' | translate }}</h2>
<p class="report-summary">{{ facade.healthCompletionPercent() }}% {{ 'adminMarketplaceHealth.complete' | translate }}</p>
<app-button variant="secondary" size="sm" (click)="exportHealthCsv()">{{ 'adminOrders.export' | translate }}</app-button>
</div>
</div>
}
</section>

View File

@@ -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; }
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,14 @@
<section class="admin-settings-page">
<div class="settings-card">
<h2>{{ 'adminSettings.density' | translate }}</h2>
<p class="settings-explain">{{ 'adminSettings.densityExplain' | translate }}</p>
<label class="toggle-row">
<app-toggle
[ngModel]="preferences.density() === 'compact'"
(ngModelChange)="onCompactToggle($event)"
[ariaLabel]="'adminSettings.densityCompact' | translate"
/>
<span>{{ 'adminSettings.densityCompact' | translate }}</span>
</label>
</div>
</section>

View File

@@ -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); }

View File

@@ -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');
}
}

View File

@@ -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<AdminUiDensity>(this.readStoredDensity());
readonly density: Signal<AdminUiDensity> = 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';
}
}

View File

@@ -44,8 +44,20 @@
</ul> </ul>
<ul class="admin-layout__nav-list admin-layout__nav-list--bottom"> <ul class="admin-layout__nav-list admin-layout__nav-list--bottom">
@for (entry of navBottom; track $index) { @for (entry of navBottom(); track $index) {
@if (entry.type === 'link' && entry.comingSoon) { @if (entry.type === 'link' && entry.externalHref) {
<li>
<a
class="admin-layout__nav-link"
[href]="entry.externalHref"
[attr.target]="entry.externalHref.startsWith('mailto:') ? null : '_blank'"
[attr.rel]="entry.externalHref.startsWith('mailto:') ? null : 'noopener'"
>
<app-icon [name]="entry.icon" [size]="18" />
<span class="admin-layout__nav-label">{{ entry.labelKey | translate }}</span>
</a>
</li>
} @else if (entry.type === 'link' && entry.comingSoon) {
<li> <li>
<button type="button" class="admin-layout__nav-link admin-layout__nav-link--disabled" disabled> <button type="button" class="admin-layout__nav-link admin-layout__nav-link--disabled" disabled>
<app-icon [name]="entry.icon" [size]="18" /> <app-icon [name]="entry.icon" [size]="18" />
@@ -151,7 +163,7 @@
</div> </div>
</header> </header>
<main id="admin-content" class="admin-layout__content" tabindex="-1"> <main id="admin-content" class="admin-layout__content" [class.admin-density-compact]="density() === 'compact'" tabindex="-1">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>

View File

@@ -9,6 +9,9 @@ import { LanguageService } from '../../../services/language.service';
import { AdminAuthService } from '../../../core/admin-auth/admin-auth.service'; import { AdminAuthService } from '../../../core/admin-auth/admin-auth.service';
import { ADMIN_NAV_BOTTOM, ADMIN_NAV_PRIMARY, AdminBreadcrumbEntry, AdminNavEntry } from './admin-nav.model'; import { ADMIN_NAV_BOTTOM, ADMIN_NAV_PRIMARY, AdminBreadcrumbEntry, AdminNavEntry } from './admin-nav.model';
import { IconComponent } from '../../../shared/ui/icon/icon.component'; import { IconComponent } from '../../../shared/ui/icon/icon.component';
import { AdminPreferencesService } from '../settings/services/admin-preferences.service';
import { UiRuntimeFacade } from '../../../facades/runtime/ui-runtime.facade';
import { ConfigService } from '../../../core/config/config.service';
@Component({ @Component({
selector: 'app-admin-layout', selector: 'app-admin-layout',
@@ -25,12 +28,35 @@ export class AdminLayoutComponent {
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
private readonly adminAuth = inject(AdminAuthService); private readonly adminAuth = inject(AdminAuthService);
private readonly preferences = inject(AdminPreferencesService);
private readonly uiRuntime = inject(UiRuntimeFacade);
private readonly configService = inject(ConfigService);
readonly density = this.preferences.density;
private readonly drawerEl = viewChild<ElementRef<HTMLElement>>('drawer'); private readonly drawerEl = viewChild<ElementRef<HTMLElement>>('drawer');
private readonly menuToggleEl = viewChild<ElementRef<HTMLElement>>('menuToggle'); private readonly menuToggleEl = viewChild<ElementRef<HTMLElement>>('menuToggle');
readonly navPrimary: AdminNavEntry[] = ADMIN_NAV_PRIMARY; readonly navPrimary: AdminNavEntry[] = ADMIN_NAV_PRIMARY;
readonly navBottom: AdminNavEntry[] = ADMIN_NAV_BOTTOM;
readonly navBottom = computed<AdminNavEntry[]>(() => {
this.configService.bootstrapRevision();
const supportEmail = this.uiRuntime.contactEmail();
const documentationUrl = this.configService.getBootstrapSnapshot()?.tenant?.documentationUrl;
return ADMIN_NAV_BOTTOM.map(entry => {
if (entry.type !== 'link') {
return entry;
}
if (entry.id === 'help' && supportEmail) {
return { ...entry, comingSoon: false, externalHref: `mailto:${supportEmail}` };
}
if (entry.id === 'documentation' && documentationUrl) {
return { ...entry, comingSoon: false, externalHref: documentationUrl };
}
return entry;
});
});
readonly mobileDrawerOpen = signal(false); readonly mobileDrawerOpen = signal(false);
readonly notificationsOpen = signal(false); readonly notificationsOpen = signal(false);

View File

@@ -11,6 +11,8 @@ export interface AdminNavLink {
absolutePath?: string[]; absolutePath?: string[];
/** No route exists yet - rendered as a disabled item with a "coming soon" badge. */ /** No route exists yet - rendered as a disabled item with a "coming soon" badge. */
comingSoon?: boolean; comingSoon?: boolean;
/** External URL (mailto: or https:). Mutually exclusive with path/absolutePath/comingSoon. */
externalHref?: string;
} }
export interface AdminNavGroup { export interface AdminNavGroup {
@@ -36,18 +38,24 @@ export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
{ type: 'link', id: 'customers', icon: 'user', labelKey: 'adminShell.nav.customers', path: ['customers'] }, { type: 'link', id: 'customers', icon: 'user', labelKey: 'adminShell.nav.customers', path: ['customers'] },
{ type: 'link', id: 'transactions', icon: 'creditCard', labelKey: 'adminShell.nav.transactions', path: ['transactions'] }, { type: 'link', id: 'transactions', icon: 'creditCard', labelKey: 'adminShell.nav.transactions', path: ['transactions'] },
{ type: 'link', id: 'moderation', icon: 'star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] }, { type: 'link', id: 'moderation', icon: 'star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] },
{ type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', comingSoon: true }, { type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', path: ['reports'] },
{ type: 'group', labelKey: 'adminShell.nav.partnersGroup' }, { type: 'group', labelKey: 'adminShell.nav.partnersGroup' },
{ type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] }, { type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] },
{ type: 'link', id: 'content', icon: 'edit', labelKey: 'adminShell.nav.content', absolutePath: ['edit', 'static-pages'] }, { type: 'link', id: 'content', icon: 'edit', labelKey: 'adminShell.nav.content', absolutePath: ['edit', 'static-pages'] },
{ type: 'link', id: 'media', icon: 'images', labelKey: 'adminShell.nav.mediaLibrary', path: ['media'] }, { type: 'link', id: 'media', icon: 'images', labelKey: 'adminShell.nav.mediaLibrary', path: ['media'] },
{ type: 'link', id: 'marketplace-builder', icon: 'network', labelKey: 'adminShell.nav.marketplaceBuilder', absolutePath: ['edit'] }, { type: 'link', id: 'marketplace-builder', icon: 'network', labelKey: 'adminShell.nav.marketplaceBuilder', absolutePath: ['edit'] },
{ type: 'link', id: 'users', icon: 'users', labelKey: 'adminShell.nav.users', path: ['users'] }, { type: 'link', id: 'users', icon: 'users', labelKey: 'adminShell.nav.users', path: ['users'] },
{ type: 'link', id: 'settings', icon: 'settings', labelKey: 'adminShell.nav.settings', comingSoon: true }, { type: 'link', id: 'settings', icon: 'settings', labelKey: 'adminShell.nav.settings', path: ['settings'] },
{ type: 'link', id: 'monitoring', icon: 'monitor', labelKey: 'adminShell.nav.monitoring', path: ['monitoring'] }, { type: 'link', id: 'monitoring', icon: 'monitor', labelKey: 'adminShell.nav.monitoring', path: ['monitoring'] },
{ type: 'link', id: 'analytics', icon: 'chartLine', labelKey: 'adminShell.nav.analytics', path: ['analytics'] }, { type: 'link', id: 'analytics', icon: 'chartLine', labelKey: 'adminShell.nav.analytics', path: ['analytics'] },
]; ];
/**
* `documentation`/`help` externalHref is resolved at runtime from bootstrap
* config (tenant.documentationUrl / branding.supportEmail) — see
* `AdminLayoutComponent.navBottom`. Static comingSoon here is the fallback
* when that data is absent.
*/
export const ADMIN_NAV_BOTTOM: AdminNavEntry[] = [ export const ADMIN_NAV_BOTTOM: AdminNavEntry[] = [
{ type: 'link', id: 'documentation', icon: 'book', labelKey: 'adminShell.nav.documentation', comingSoon: true }, { type: 'link', id: 'documentation', icon: 'book', labelKey: 'adminShell.nav.documentation', comingSoon: true },
{ type: 'link', id: 'help', icon: 'help', labelKey: 'adminShell.nav.help', comingSoon: true }, { type: 'link', id: 'help', icon: 'help', labelKey: 'adminShell.nav.help', comingSoon: true },

View File

@@ -1,6 +1,7 @@
import { TranslateService } from '../../../i18n/translate.service'; import { TranslateService } from '../../../i18n/translate.service';
import { BootstrapConfig } from '../../../shared/models/config'; import { BootstrapConfig } from '../../../shared/models/config';
import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract'; import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract';
import { validateAgainstSchemaLite } from '../../project-editor/schema/validators/primitives';
import { DiagnosticEntry } from '../models/diagnostics.model'; import { DiagnosticEntry } from '../models/diagnostics.model';
const KNOWN_LAYOUTS = new Set(['default', 'sidebar-left', 'carousel-home', 'minimal']); const KNOWN_LAYOUTS = new Set(['default', 'sidebar-left', 'carousel-home', 'minimal']);
@@ -28,6 +29,7 @@ export class BootstrapDiagnosticsValidator {
this.validateRequiredProperties(bootstrap, entries); this.validateRequiredProperties(bootstrap, entries);
this.validateUnknownWidgetTypes(bootstrap, manifest, entries); this.validateUnknownWidgetTypes(bootstrap, manifest, entries);
this.validateWidgetSettingsSchema(bootstrap, manifest, entries);
this.validateDuplicateIds(bootstrap, entries); this.validateDuplicateIds(bootstrap, entries);
this.validateLayouts(bootstrap, entries); this.validateLayouts(bootstrap, entries);
this.validateFeatureFlags(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 { private validateDuplicateIds(bootstrap: BootstrapConfig, entries: DiagnosticEntry[]): void {
const pageIds = new Set<string>(); const pageIds = new Set<string>();

View File

@@ -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<PlatformRuntimeService>;
beforeEach(() => {
localStorage.clear();
runtimeSpy = jasmine.createSpyObj<PlatformRuntimeService>('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();
});
});
});

View File

@@ -4,6 +4,7 @@ import {
isValidHexColor, isValidHexColor,
isValidHttpUrl, isValidHttpUrl,
normalizeRoute, normalizeRoute,
validateAgainstSchemaLite,
validateCss, validateCss,
validateHtml, validateHtml,
validateJson, 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', () => { describe('validateHtml', () => {
it('accepts well-formed markup with nested tags', () => { it('accepts well-formed markup with nested tags', () => {
expect(validateHtml('<p>Hello <strong>world</strong></p><ul><li>one</li></ul>').ok).toBeTrue(); expect(validateHtml('<p>Hello <strong>world</strong></p><ul><li>one</li></ul>').ok).toBeTrue();

View File

@@ -80,6 +80,54 @@ export function normalizeRoute(route: string): string {
return (route ?? '').trim().replace(/^\/+|\/+$/g, '').toLowerCase(); 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<string, unknown>;
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<string, unknown>, 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. */ /** HTML void elements per the WHATWG spec: never require (or accept) a closing tag. */
const VOID_ELEMENTS = new Set([ const VOID_ELEMENTS = new Set([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',

View File

@@ -38,12 +38,13 @@
[usedByLabel]="'builder.usedByLabel' | translate" [usedByLabel]="'builder.usedByLabel' | translate"
> >
<app-visual-layout-picker <app-visual-layout-picker
[options]="layoutStrategyPickerOptions" [options]="layoutOptionsFor(section)"
[ariaLabel]="'builder.layoutStrategyLabel' | translate" [ariaLabel]="'builder.layoutStrategyLabel' | translate"
[ngModel]="section.layout?.strategy || 'stack'" [ngModel]="section.layout?.strategy || 'stack'"
(ngModelChange)="updateLayout(section.id, 'strategy', $event)" (ngModelChange)="updateLayout(section.id, 'strategy', $event)"
/> />
</app-form-field> </app-form-field>
@if (showColumnsFor(section)) {
<app-form-field <app-form-field
appHighlightSource="spacing" appHighlightSource="spacing"
[label]="'builder.columns' | translate" [label]="'builder.columns' | translate"
@@ -53,6 +54,7 @@
> >
<app-input type="number" [ngModel]="section.layout?.columns || 1" (ngModelChange)="updateLayout(section.id, 'columns', $event)" /> <app-input type="number" [ngModel]="section.layout?.columns || 1" (ngModelChange)="updateLayout(section.id, 'columns', $event)" />
</app-form-field> </app-form-field>
}
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop'; import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../facade/project-editor.facade'; import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../i18n/translate.pipe';
@@ -16,6 +17,8 @@ import { ConfirmDialogComponent } from '../../../shared/ui/confirm-dialog/confir
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component'; import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
import { VisualLayoutPickerComponent, VisualLayoutOption } from '../../../shared/ui/visual-layout-picker/visual-layout-picker.component'; import { VisualLayoutPickerComponent, VisualLayoutOption } from '../../../shared/ui/visual-layout-picker/visual-layout-picker.component';
import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive'; import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive';
import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
import { WidgetLayoutSupport, WidgetManifestEntry } from '../../../widgets/contracts/widget-manifest.contract';
export interface LayoutStrategyOption { export interface LayoutStrategyOption {
value: 'stack' | 'grid' | 'hero' | 'carousel' | 'split'; value: 'stack' | 'grid' | 'hero' | 'carousel' | 'split';
@@ -45,6 +48,9 @@ const BLOCK_CATALOG: BlockCatalogEntry[] = [
const BLOCK_BY_TYPE = new Map(BLOCK_CATALOG.map(entry => [entry.type, entry])); const BLOCK_BY_TYPE = new Map(BLOCK_CATALOG.map(entry => [entry.type, entry]));
/** Widget componentKeys (widget-manifest.json) that read `section.layout.columns`. Keep in sync with the widget components that actually consume it - see hero-widget.component.ts and product-carousel-widget.component.ts. */
const COMPONENT_KEYS_READING_COLUMNS = new Set(['hero', 'product-collection']);
@Component({ @Component({
selector: 'app-project-editor-homepage-section', selector: 'app-project-editor-homepage-section',
standalone: true, standalone: true,
@@ -56,7 +62,11 @@ const BLOCK_BY_TYPE = new Map(BLOCK_CATALOG.map(entry => [entry.type, entry]));
export class ProjectEditorHomepageSectionComponent { export class ProjectEditorHomepageSectionComponent {
private readonly facade = inject(ProjectEditorFacade); private readonly facade = inject(ProjectEditorFacade);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly widgetManifest = inject(WidgetManifestService);
readonly homePage = this.facade.homepagePage; readonly homePage = this.facade.homepagePage;
private readonly manifestEntries = toSignal(this.widgetManifest.getWidgets(), { initialValue: [] as WidgetManifestEntry[] });
private readonly manifestByType = computed(() => new Map(this.manifestEntries().map(entry => [entry.type, entry])));
readonly fieldError = (key: string): string | null => { readonly fieldError = (key: string): string | null => {
const messageKey = this.facade.fieldError(key); const messageKey = this.facade.fieldError(key);
return messageKey ? this.translate.t(messageKey) : null; return messageKey ? this.translate.t(messageKey) : null;
@@ -89,6 +99,35 @@ export class ProjectEditorHomepageSectionComponent {
icon: this.layoutStrategyIcons[option.value], icon: this.layoutStrategyIcons[option.value],
})); }));
/** Layout strategy options filtered to what the section's bound widget actually supports (widget-manifest.json `supportedLayouts`). Keeps a stale/unsupported saved value selectable instead of rendering a picker with no active card. */
layoutOptionsFor(section: SectionConfig): VisualLayoutOption[] {
const supported = this.manifestByType().get(section.type)?.supportedLayouts;
if (!supported || supported.length === 0) {
return this.layoutStrategyPickerOptions;
}
const options = this.layoutStrategyPickerOptions.filter(option => supported.includes(option.value as WidgetLayoutSupport));
const current = section.layout?.strategy;
if (current && !options.some(option => option.value === current)) {
const currentOption = this.layoutStrategyPickerOptions.find(option => option.value === current);
if (currentOption) {
return [...options, currentOption];
}
}
return options;
}
/** Whether the section's bound widget consumes `layout.columns` (see COMPONENT_KEYS_READING_COLUMNS). Product carousel only uses it in carousel strategy - grid mode ignores it. */
showColumnsFor(section: SectionConfig): boolean {
const componentKey = this.manifestByType().get(section.type)?.componentKey;
if (!componentKey || !COMPONENT_KEYS_READING_COLUMNS.has(componentKey)) {
return false;
}
if (componentKey === 'product-collection') {
return (section.layout?.strategy ?? 'stack') === 'carousel';
}
return true;
}
blockLabel(type: string): string { blockLabel(type: string): string {
const entry = BLOCK_BY_TYPE.get(type); const entry = BLOCK_BY_TYPE.get(type);
return entry ? this.translate.t(entry.labelKey) : type; return entry ? this.translate.t(entry.labelKey) : type;

View File

@@ -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 { 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'; import { ProjectValidator } from './project-validator.service';
function makeBootstrap(): BootstrapConfig { function makeBootstrap(): BootstrapConfig {
@@ -45,7 +50,8 @@ describe('ProjectValidator', () => {
let validator: ProjectValidator; let validator: ProjectValidator;
beforeEach(() => { beforeEach(() => {
validator = new ProjectValidator(); TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
validator = TestBed.inject(ProjectValidator);
}); });
it('reports no issues for a valid baseline config', () => { 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'); const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-url');
expect(issue?.fieldKey).toBe('tenant.websiteBaseUrl'); 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();
});
});
}); });

View File

@@ -1,12 +1,14 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { BootstrapConfig } from '../../../shared/models/config'; import { BootstrapConfig } from '../../../shared/models/config';
import { ProjectEditorSectionId } from '../models/project-editor.model'; import { ProjectEditorSectionId } from '../models/project-editor.model';
import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
import { import {
extractStyleBlocks, extractStyleBlocks,
isValidEmail, isValidEmail,
isValidHexColor, isValidHexColor,
isValidHttpUrl, isValidHttpUrl,
normalizeRoute, normalizeRoute,
validateAgainstSchemaLite,
validateCss, validateCss,
} from '../schema/validators/primitives'; } from '../schema/validators/primitives';
@@ -43,6 +45,8 @@ function warning(code: string, message: string, section: ProjectEditorSectionId,
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ProjectValidator { export class ProjectValidator {
private readonly widgetManifest = inject(WidgetManifestService);
validate(bootstrap: BootstrapConfig): ProjectValidationIssue[] { validate(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
return [ return [
...this.brandingIssues(bootstrap), ...this.brandingIssues(bootstrap),
@@ -52,6 +56,7 @@ export class ProjectValidator {
...this.duplicateRouteIssues(bootstrap), ...this.duplicateRouteIssues(bootstrap),
...this.homepageIssues(bootstrap), ...this.homepageIssues(bootstrap),
...this.widgetConfigIssues(bootstrap), ...this.widgetConfigIssues(bootstrap),
...this.widgetSettingsSchemaIssues(bootstrap),
...this.navigationIssues(bootstrap), ...this.navigationIssues(bootstrap),
...this.colorIssues(bootstrap), ...this.colorIssues(bootstrap),
...this.cssIssues(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[] { private navigationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const keyOf = (item: { label?: string | Record<string, string>; route?: string }): string => const keyOf = (item: { label?: string | Record<string, string>; route?: string }): string =>
`${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`; `${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`;

View File

@@ -3,6 +3,7 @@ import { ParamMap, Params } from '@angular/router';
import { Observable, Subject, of } from 'rxjs'; import { Observable, Subject, of } from 'rxjs';
import { debounceTime, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
import { Category } from '../../../core/categories/models/category-domain.model'; 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 { Product, ProductListResult } from '../../../core/products/models/product-domain.model';
import { CategoryFacade } from '../../../facades/platform/category.facade'; import { CategoryFacade } from '../../../facades/platform/category.facade';
import { ProductFacade } from '../../../facades/platform/product.facade'; import { ProductFacade } from '../../../facades/platform/product.facade';
@@ -42,6 +43,7 @@ export class SearchFacade {
private readonly trendingService = inject(SearchTrendingService); private readonly trendingService = inject(SearchTrendingService);
private readonly cacheService = inject(SearchCacheService); private readonly cacheService = inject(SearchCacheService);
private readonly store = inject(SearchStore); private readonly store = inject(SearchStore);
private readonly configService = inject(ConfigService);
private readonly metadataMemo = new Map<string, FilterGroup[]>(); private readonly metadataMemo = new Map<string, FilterGroup[]>();
private readonly autocompleteInput$ = new Subject<{ private readonly autocompleteInput$ = new Subject<{
@@ -123,6 +125,10 @@ export class SearchFacade {
autocomplete(query: string, products: Product[], categories: Category[] = [], limit = 10): void { autocomplete(query: string, products: Product[], categories: Category[] = [], limit = 10): void {
this.store.setQuery(query); this.store.setQuery(query);
if (this.configService.getBootstrapSnapshot()?.catalog?.suggestionsEnabled === false) {
this.store.setSuggestions([]);
return;
}
this.autocompleteInput$.next({ query, products, categories, limit }); this.autocompleteInput$.next({ query, products, categories, limit });
} }

View File

@@ -12,6 +12,8 @@ export const en: Translations = {
compare: 'Compare', compare: 'Compare',
openMenu: 'Open menu', openMenu: 'Open menu',
closeMenu: 'Close menu', closeMenu: 'Close menu',
login: 'Log in',
logout: 'Log out',
}, },
footer: { footer: {
description: 'A modern marketplace for comfortable shopping', description: 'A modern marketplace for comfortable shopping',
@@ -563,6 +565,7 @@ export const en: Translations = {
validationInvalidCss: 'A static page contains invalid CSS.', validationInvalidCss: 'A static page contains invalid CSS.',
validationDuplicateRoutes: 'Two or more pages share the same route.', validationDuplicateRoutes: 'Two or more pages share the same route.',
validationInvalidWidgetConfig: 'A widget is missing a required field (id, type, version, or props).', 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.', validationInvalidContactEmail: 'The company contact email is not a valid email address.',
validationInvalidSocialLinkUrl: 'One or more footer social links have an invalid URL.', validationInvalidSocialLinkUrl: 'One or more footer social links have an invalid URL.',
validationIncompletePaymentIcon: 'A footer payment icon is missing its image or alt text.', validationIncompletePaymentIcon: 'A footer payment icon is missing its image or alt text.',
@@ -1114,6 +1117,8 @@ export const en: Translations = {
slidesLabel: 'Slides', slidesLabel: 'Slides',
previousProducts: 'Previous products', previousProducts: 'Previous products',
nextProducts: 'Next products', nextProducts: 'Next products',
previousSlide: 'Previous slide',
nextSlide: 'Next slide',
closeDialog: 'Close dialog', closeDialog: 'Close dialog',
dismiss: 'Dismiss', dismiss: 'Dismiss',
qrCode: 'QR Code', qrCode: 'QR Code',
@@ -1220,6 +1225,9 @@ export const en: Translations = {
unknownWidgetTypeTitle: 'Unknown widget type', unknownWidgetTypeTitle: 'Unknown widget type',
unknownWidgetTypeDescription: 'Widget type is not present in widget manifest.', unknownWidgetTypeDescription: 'Widget type is not present in widget manifest.',
unknownWidgetTypeResolution: 'Register widget in manifest or remove invalid widget config.', 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', duplicateIdTitle: 'Duplicate identifier',
duplicatePageIdDescription: 'Duplicate page id detected.', duplicatePageIdDescription: 'Duplicate page id detected.',
duplicateSectionIdDescription: 'Duplicate section id detected.', duplicateSectionIdDescription: 'Duplicate section id detected.',
@@ -1909,6 +1917,16 @@ export const en: Translations = {
lastPublished: 'Last published', lastPublished: 'Last published',
syncStatus: 'Sync', syncStatus: 'Sync',
}, },
adminReports: {
sales: 'Sales',
topProducts: 'Top Products',
marketplaceHealth: 'Marketplace Health',
},
adminSettings: {
density: 'Table density',
densityExplain: 'Reduce row padding across backoffice list pages for a more compact view.',
densityCompact: 'Compact rows',
},
adminAnalytics: { adminAnalytics: {
topProductsEmptyTitle: 'No product sales in this period', topProductsEmptyTitle: 'No product sales in this period',
topProductsEmptyDescription: 'Try a wider date range.', topProductsEmptyDescription: 'Try a wider date range.',
@@ -2060,6 +2078,8 @@ export const en: Translations = {
monitoring: { title: 'Monitoring', description: 'Queue, webhook, and system event status' }, monitoring: { title: 'Monitoring', description: 'Queue, webhook, and system event status' },
analytics: { title: 'Analytics', description: 'Sales, products, and shopper behavior' }, analytics: { title: 'Analytics', description: 'Sales, products, and shopper behavior' },
sellerManagement: { title: 'Seller Management', description: 'Onboard and manage independent sellers on your marketplace' }, sellerManagement: { title: 'Seller Management', description: 'Onboard and manage independent sellers on your marketplace' },
reports: { title: 'Reports', description: 'Export sales, product, and marketplace health reports' },
settings: { title: 'Settings', description: 'Backoffice display preferences' },
}, },
}, },
adminSellerManagement: { adminSellerManagement: {

View File

@@ -12,6 +12,8 @@ export const hy: Translations = {
compare: 'Համեմատում', compare: 'Համեմատում',
openMenu: 'Բացել մենյուն', openMenu: 'Բացել մենյուն',
closeMenu: 'Փակել մենյուն', closeMenu: 'Փակել մենյուն',
login: 'Մուտք',
logout: 'Ելք',
}, },
footer: { footer: {
description: 'Ժամանակակից մարքեթփլեյս հարմար գնումների համար', description: 'Ժամանակակից մարքեթփլեյս հարմար գնումների համար',
@@ -563,6 +565,7 @@ export const hy: Translations = {
validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։', validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։',
validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։', validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։',
validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։', validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։',
validationInvalidWidgetSettings: 'Վիջեթի կարգավորումները չեն համապատասխանում իր տիպի սպասվող ձևաչափին (դաշտ բացակայում է կամ սխալ տիպի է)։',
validationInvalidContactEmail: 'Ընկերության կոնտակտային էլ. փոստը վավեր չէ։', validationInvalidContactEmail: 'Ընկերության կոնտակտային էլ. փոստը վավեր չէ։',
validationInvalidSocialLinkUrl: 'Ֆուտերի սոցիալական հղումներից մեկը կամ մի քանիսը վավեր URL չունեն։', validationInvalidSocialLinkUrl: 'Ֆուտերի սոցիալական հղումներից մեկը կամ մի քանիսը վավեր URL չունեն։',
validationIncompletePaymentIcon: 'Ֆուտերի վճարային պատկերակին բացակայում է պատկերը կամ alt տեքստը։', validationIncompletePaymentIcon: 'Ֆուտերի վճարային պատկերակին բացակայում է պատկերը կամ alt տեքստը։',
@@ -1114,6 +1117,8 @@ export const hy: Translations = {
slidesLabel: 'Սլայդներ', slidesLabel: 'Սլայդներ',
previousProducts: 'Նախորդ ապրանքները', previousProducts: 'Նախորդ ապրանքները',
nextProducts: 'Հաջորդ ապրանքները', nextProducts: 'Հաջորդ ապրանքները',
previousSlide: 'Նախորդ սլայդը',
nextSlide: 'Հաջորդ սլայդը',
closeDialog: 'Փակել պատուհանը', closeDialog: 'Փակել պատուհանը',
dismiss: 'Փակել', dismiss: 'Փակել',
qrCode: 'QR կոդ', qrCode: 'QR կոդ',
@@ -1215,6 +1220,9 @@ export const hy: Translations = {
unknownWidgetTypeTitle: 'Անհայտ widget type', unknownWidgetTypeTitle: 'Անհայտ widget type',
unknownWidgetTypeDescription: 'Widget type-ը չկա widget manifest-ում։', unknownWidgetTypeDescription: 'Widget type-ը չկա widget manifest-ում։',
unknownWidgetTypeResolution: 'Ավելացրեք widget-ը manifest-ում կամ հեռացրեք սխալ config-ը։', unknownWidgetTypeResolution: 'Ավելացրեք widget-ը manifest-ում կամ հեռացրեք սխալ config-ը։',
widgetSettingsSchemaMismatchTitle: 'Վիջեթի կարգավորումները չեն համապատասխանում manifest-ի սխեմային',
widgetSettingsSchemaMismatchDescription: 'Վիջեթի props-ը չի բավարարում այս տիպի համար հայտարարված settingsSchema-ին։',
widgetSettingsSchemaMismatchResolution: 'Ուղղեք վիջեթի props-ը խմբագրիչում՝ պարտադիր դաշտերին/տիպերին համապատասխան, կամ թարմացրեք manifest-ի սխեման։',
duplicateIdTitle: 'Կրկնվող ID', duplicateIdTitle: 'Կրկնվող ID',
duplicatePageIdDescription: 'Գտնվել է կրկնվող page id։', duplicatePageIdDescription: 'Գտնվել է կրկնվող page id։',
duplicateSectionIdDescription: 'Գտնվել է կրկնվող section id։', duplicateSectionIdDescription: 'Գտնվել է կրկնվող section id։',
@@ -1904,6 +1912,16 @@ export const hy: Translations = {
lastPublished: 'Վերջին հրապարակում', lastPublished: 'Վերջին հրապարակում',
syncStatus: 'Համաժամացում', syncStatus: 'Համաժամացում',
}, },
adminReports: {
sales: 'Վաճառքներ',
topProducts: 'Լավագույն ապրանքներ',
marketplaceHealth: 'Մարքեթփլեյսի վիճակ',
},
adminSettings: {
density: 'Աղյուսակի խտություն',
densityExplain: 'Փոքրացնել տողերի հեռավորությունը ադմինիստրատիվ վահանակի ցուցակներում՝ ավելի կոմպակտ տեսքի համար։',
densityCompact: 'Կոմպակտ տողեր',
},
adminAnalytics: { adminAnalytics: {
topProductsEmptyTitle: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա', topProductsEmptyTitle: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա',
topProductsEmptyDescription: 'Փորձեք ընտրել ավելի լայն ամսաթվերի միջակայք։', topProductsEmptyDescription: 'Փորձեք ընտրել ավելի լայն ամսաթվերի միջակայք։',
@@ -2055,6 +2073,8 @@ export const hy: Translations = {
monitoring: { title: 'Մոնիտորինգ', description: 'Հերթերի, webhook-ների և համակարգային իրադարձությունների վիճակը' }, monitoring: { title: 'Մոնիտորինգ', description: 'Հերթերի, webhook-ների և համակարգային իրադարձությունների վիճակը' },
analytics: { title: 'Վերլուծություն', description: 'Վաճառքներ, ապրանքներ և գնորդների վարքագիծ' }, analytics: { title: 'Վերլուծություն', description: 'Վաճառքներ, ապրանքներ և գնորդների վարքագիծ' },
sellerManagement: { title: 'Վաճառողների կառավարում', description: 'Թույլ տվեք անկախ վաճառողներին միանալ ձեր մարքեթփլեյսին՝ պահպանելով կենտրոնացված կառավարումը' }, sellerManagement: { title: 'Վաճառողների կառավարում', description: 'Թույլ տվեք անկախ վաճառողներին միանալ ձեր մարքեթփլեյսին՝ պահպանելով կենտրոնացված կառավարումը' },
reports: { title: 'Հաշվետվություններ', description: 'Արտահանել վաճառքների, ապրանքների և մարքեթփլեյսի վիճակի հաշվետվություններ' },
settings: { title: 'Կարգավորումներ', description: 'Ադմինիստրատիվ վահանակի ցուցադրման նախապատվություններ' },
}, },
}, },
adminSellerManagement: { adminSellerManagement: {

View File

@@ -12,6 +12,8 @@ export const ru: Translations = {
compare: 'Сравнение', compare: 'Сравнение',
openMenu: 'Открыть меню', openMenu: 'Открыть меню',
closeMenu: 'Закрыть меню', closeMenu: 'Закрыть меню',
login: 'Войти',
logout: 'Выйти',
}, },
footer: { footer: {
description: 'Современный маркетплейс для комфортных покупок', description: 'Современный маркетплейс для комфортных покупок',
@@ -563,6 +565,7 @@ export const ru: Translations = {
validationInvalidCss: 'Статическая страница содержит недопустимый CSS.', validationInvalidCss: 'Статическая страница содержит недопустимый CSS.',
validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.', validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.',
validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).', validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).',
validationInvalidWidgetSettings: 'Настройки виджета не соответствуют ожидаемым для его типа (поле отсутствует или неверного типа).',
validationInvalidContactEmail: 'Контактный email компании указан некорректно.', validationInvalidContactEmail: 'Контактный email компании указан некорректно.',
validationInvalidSocialLinkUrl: 'У одной или нескольких социальных ссылок в футере некорректный URL.', validationInvalidSocialLinkUrl: 'У одной или нескольких социальных ссылок в футере некорректный URL.',
validationIncompletePaymentIcon: 'У иконки оплаты в футере отсутствует изображение или альтернативный текст.', validationIncompletePaymentIcon: 'У иконки оплаты в футере отсутствует изображение или альтернативный текст.',
@@ -1114,6 +1117,8 @@ export const ru: Translations = {
slidesLabel: 'Слайды', slidesLabel: 'Слайды',
previousProducts: 'Предыдущие товары', previousProducts: 'Предыдущие товары',
nextProducts: 'Следующие товары', nextProducts: 'Следующие товары',
previousSlide: 'Предыдущий слайд',
nextSlide: 'Следующий слайд',
closeDialog: 'Закрыть диалог', closeDialog: 'Закрыть диалог',
dismiss: 'Скрыть', dismiss: 'Скрыть',
qrCode: 'QR-код', qrCode: 'QR-код',
@@ -1215,6 +1220,9 @@ export const ru: Translations = {
unknownWidgetTypeTitle: 'Неизвестный тип виджета', unknownWidgetTypeTitle: 'Неизвестный тип виджета',
unknownWidgetTypeDescription: 'Тип виджета отсутствует в widget manifest.', unknownWidgetTypeDescription: 'Тип виджета отсутствует в widget manifest.',
unknownWidgetTypeResolution: 'Добавьте виджет в manifest или удалите неверную конфигурацию.', unknownWidgetTypeResolution: 'Добавьте виджет в manifest или удалите неверную конфигурацию.',
widgetSettingsSchemaMismatchTitle: 'Настройки виджета не соответствуют схеме manifest',
widgetSettingsSchemaMismatchDescription: 'Props виджета не соответствуют settingsSchema, заданной для этого типа виджета.',
widgetSettingsSchemaMismatchResolution: 'Исправьте props виджета в редакторе в соответствии с обязательными полями/типами, либо обновите схему в manifest.',
duplicateIdTitle: 'Дублирующийся идентификатор', duplicateIdTitle: 'Дублирующийся идентификатор',
duplicatePageIdDescription: 'Обнаружен дублирующийся id страницы.', duplicatePageIdDescription: 'Обнаружен дублирующийся id страницы.',
duplicateSectionIdDescription: 'Обнаружен дублирующийся id секции.', duplicateSectionIdDescription: 'Обнаружен дублирующийся id секции.',
@@ -1904,6 +1912,16 @@ export const ru: Translations = {
lastPublished: 'Последняя публикация', lastPublished: 'Последняя публикация',
syncStatus: 'Синхронизация', syncStatus: 'Синхронизация',
}, },
adminReports: {
sales: 'Продажи',
topProducts: 'Топ товаров',
marketplaceHealth: 'Состояние маркетплейса',
},
adminSettings: {
density: 'Плотность таблиц',
densityExplain: 'Уменьшить отступы строк в списках панели управления для более компактного вида.',
densityCompact: 'Компактные строки',
},
adminAnalytics: { adminAnalytics: {
topProductsEmptyTitle: 'Нет продаж товаров за этот период', topProductsEmptyTitle: 'Нет продаж товаров за этот период',
topProductsEmptyDescription: 'Попробуйте выбрать более широкий диапазон дат.', topProductsEmptyDescription: 'Попробуйте выбрать более широкий диапазон дат.',
@@ -2055,6 +2073,8 @@ export const ru: Translations = {
monitoring: { title: 'Мониторинг', description: 'Состояние очередей, вебхуков и системных событий' }, monitoring: { title: 'Мониторинг', description: 'Состояние очередей, вебхуков и системных событий' },
analytics: { title: 'Аналитика', description: 'Продажи, товары и поведение покупателей' }, analytics: { title: 'Аналитика', description: 'Продажи, товары и поведение покупателей' },
sellerManagement: { title: 'Управление продавцами', description: 'Подключайте независимых продавцов к вашему маркетплейсу с централизованным администрированием' }, sellerManagement: { title: 'Управление продавцами', description: 'Подключайте независимых продавцов к вашему маркетплейсу с централизованным администрированием' },
reports: { title: 'Отчёты', description: 'Экспорт отчётов по продажам, товарам и состоянию маркетплейса' },
settings: { title: 'Настройки', description: 'Настройки отображения панели управления' },
}, },
}, },
adminSellerManagement: { adminSellerManagement: {

View File

@@ -10,6 +10,8 @@ export interface Translations {
compare: string; compare: string;
openMenu: string; openMenu: string;
closeMenu: string; closeMenu: string;
login: string;
logout: string;
}; };
footer: { footer: {
description: string; description: string;
@@ -561,6 +563,7 @@ export interface Translations {
validationInvalidCss: string; validationInvalidCss: string;
validationDuplicateRoutes: string; validationDuplicateRoutes: string;
validationInvalidWidgetConfig: string; validationInvalidWidgetConfig: string;
validationInvalidWidgetSettings: string;
validationInvalidContactEmail: string; validationInvalidContactEmail: string;
validationInvalidSocialLinkUrl: string; validationInvalidSocialLinkUrl: string;
validationIncompletePaymentIcon: string; validationIncompletePaymentIcon: string;
@@ -1113,6 +1116,8 @@ export interface Translations {
slidesLabel: string; slidesLabel: string;
previousProducts: string; previousProducts: string;
nextProducts: string; nextProducts: string;
previousSlide: string;
nextSlide: string;
closeDialog: string; closeDialog: string;
dismiss: string; dismiss: string;
qrCode: string; qrCode: string;
@@ -1219,6 +1224,9 @@ export interface Translations {
unknownWidgetTypeTitle: string; unknownWidgetTypeTitle: string;
unknownWidgetTypeDescription: string; unknownWidgetTypeDescription: string;
unknownWidgetTypeResolution: string; unknownWidgetTypeResolution: string;
widgetSettingsSchemaMismatchTitle: string;
widgetSettingsSchemaMismatchDescription: string;
widgetSettingsSchemaMismatchResolution: string;
duplicateIdTitle: string; duplicateIdTitle: string;
duplicatePageIdDescription: string; duplicatePageIdDescription: string;
duplicateSectionIdDescription: string; duplicateSectionIdDescription: string;
@@ -1917,6 +1925,16 @@ export interface Translations {
lastPublished: string; lastPublished: string;
syncStatus: string; syncStatus: string;
}; };
adminReports: {
sales: string;
topProducts: string;
marketplaceHealth: string;
};
adminSettings: {
density: string;
densityExplain: string;
densityCompact: string;
};
adminAnalytics: { adminAnalytics: {
topProductsEmptyTitle: string; topProductsEmptyTitle: string;
topProductsEmptyDescription: string; topProductsEmptyDescription: string;
@@ -2068,6 +2086,8 @@ export interface Translations {
monitoring: { title: string; description: string }; monitoring: { title: string; description: string };
analytics: { title: string; description: string }; analytics: { title: string; description: string };
sellerManagement: { title: string; description: string }; sellerManagement: { title: string; description: string };
reports: { title: string; description: string };
settings: { title: string; description: string };
}; };
}; };
adminSellerManagement: { adminSellerManagement: {

View File

@@ -195,9 +195,15 @@
</div> </div>
<!-- Payment Popup Modal --> <!-- Payment Popup Modal -->
@if (showPaymentPopup()) { <app-dialog
<div class="payment-modal-overlay"> class="payment-dialog"
<div class="payment-modal" #paymentModalPanel role="dialog" aria-modal="true" [attr.aria-label]="'cart.checkout' | translate" tabindex="-1"> [open]="showPaymentPopup()"
size="md"
[ariaLabel]="'cart.checkout' | translate"
[closeOnBackdropClick]="false"
[closeOnEscape]="!showBankPaymentPopup()"
(closed)="closePaymentPopup()"
>
<button class="close-modal-btn" (click)="closePaymentPopup()" [attr.aria-label]="'cart.close' | translate"> <button class="close-modal-btn" (click)="closePaymentPopup()" [attr.aria-label]="'cart.close' | translate">
<app-icon name="x" [size]="20" /> <app-icon name="x" [size]="20" />
</button> </button>
@@ -276,19 +282,22 @@
<p class="auto-close">{{ 'cart.autoClose' | translate }}</p> <p class="auto-close">{{ 'cart.autoClose' | translate }}</p>
</div> </div>
} }
</div> </app-dialog>
@if (showBankPaymentPopup() && bankPaymentFrameUrl()) { @if (showBankPaymentPopup() && bankPaymentFrameUrl()) {
<div class="bank-payment-modal-overlay"> <app-dialog
<div class="bank-payment-modal" #bankPaymentModalPanel role="dialog" aria-modal="true" [attr.aria-label]="'cart.payViaCard' | translate" tabindex="-1"> class="bank-payment-dialog"
[open]="showBankPaymentPopup()"
size="lg"
[ariaLabel]="'cart.payViaCard' | translate"
[closeOnBackdropClick]="false"
(closed)="closeBankPaymentPopup()"
>
<button class="close-modal-btn" (click)="closeBankPaymentPopup()" [attr.aria-label]="'cart.close' | translate"> <button class="close-modal-btn" (click)="closeBankPaymentPopup()" [attr.aria-label]="'cart.close' | translate">
<app-icon name="x" [size]="20" /> <app-icon name="x" [size]="20" />
</button> </button>
<iframe [src]="bankPaymentFrameUrl()" class="bank-payment-frame" [attr.title]="'common.bankCardPayment' | translate"></iframe> <iframe [src]="bankPaymentFrameUrl()" class="bank-payment-frame" [attr.title]="'common.bankCardPayment' | translate"></iframe>
</div> </app-dialog>
</div>
}
</div>
} }
<app-telegram-login /> <app-telegram-login />

View File

@@ -640,32 +640,20 @@
} }
} }
// Payment modal styles // Payment / bank-payment modals render through the shared app-dialog
.payment-modal-overlay { // primitive (focus-trap, Escape, backdrop-click, ARIA all owned there —
position: fixed; // see closeOnEscape/closeOnBackdropClick usage in the template). These
top: 0; // ::ng-deep overrides restore the exact panel geometry the old hand-rolled
left: 0; // modals had, which app-dialog's generic size presets/padding don't match.
right: 0; :host ::ng-deep {
bottom: 0; app-dialog.payment-dialog .app-dialog-panel {
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.payment-modal {
background: var(--bg-primary);
border-radius: var(--radius-lg, 13px);
max-width: 500px; max-width: 500px;
width: 100%;
padding: 40px;
position: relative;
max-height: 90vh;
overflow-y: auto;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
box-shadow: var(--shadow-lg); }
app-dialog.payment-dialog .app-dialog-panel__body {
padding: 40px;
}
} }
// Deduplicated: this rule previously appeared twice (identical) - kept a single definition. // Deduplicated: this rule previously appeared twice (identical) - kept a single definition.
@@ -970,25 +958,23 @@
} }
.bank-payment-modal-overlay { :host ::ng-deep {
position: fixed; app-dialog.bank-payment-dialog .app-dialog-backdrop {
inset: 0;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(17, 24, 39, 0.55); background: rgba(17, 24, 39, 0.55);
} }
.bank-payment-modal { app-dialog.bank-payment-dialog .app-dialog-panel {
position: relative;
width: min(960px, 92vw); width: min(960px, 92vw);
max-width: min(960px, 92vw);
height: min(760px, 86vh); height: min(760px, 86vh);
padding: 56px 16px 16px;
background: var(--bg-primary);
border-radius: var(--radius-lg, 13px);
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28); box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28);
}
app-dialog.bank-payment-dialog .app-dialog-panel__body {
height: 100%;
box-sizing: border-box;
padding: 56px 16px 16px;
}
} }
.bank-payment-frame { .bank-payment-frame {
@@ -1041,9 +1027,9 @@
display: flex; display: flex;
} }
.payment-modal { :host ::ng-deep app-dialog.payment-dialog .app-dialog-panel__body {
padding: 24px; padding: 24px;
padding-top: 56px; // <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> padding-top: 56px;
} }
.close-modal-btn { .close-modal-btn {
@@ -1078,15 +1064,21 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.bank-payment-modal-overlay { :host ::ng-deep {
app-dialog.bank-payment-dialog .app-dialog-backdrop {
padding: 12px; padding: 12px;
} }
.bank-payment-modal { app-dialog.bank-payment-dialog .app-dialog-panel {
width: 94vw; width: 94vw;
max-width: 94vw;
height: 82vh; height: 82vh;
}
app-dialog.bank-payment-dialog .app-dialog-panel__body {
padding: 52px 10px 10px; padding: 52px 10px 10px;
} }
}
.payment-active h2 { .payment-active h2 {
font-size: var(--font-size-2xl, 1.25rem); font-size: var(--font-size-2xl, 1.25rem);

View File

@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, signal, OnDestroy, inject, ElementRef, ViewChild, HostListener, effect } from '@angular/core'; import { Component, ChangeDetectionStrategy, signal, OnDestroy, inject } from '@angular/core';
import { DecimalPipe } from '@angular/common'; import { DecimalPipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -21,15 +21,13 @@ import { ConfigService } from '../../core/config/config.service';
import { TenantResolverService } from '../../core/config/tenant-resolver.service'; import { TenantResolverService } from '../../core/config/tenant-resolver.service';
import { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service'; import { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service';
import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component'; import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component';
import { DialogComponent } from '../../shared/ui/dialog/dialog.component';
type PaymentMethod = 'qr' | 'card'; type PaymentMethod = 'qr' | 'card';
const MODAL_FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])';
@Component({ @Component({
selector: 'app-cart', selector: 'app-cart',
imports: [DecimalPipe, RouterLink, FormsModule, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe, IconComponent, EmptyStateComponent, ButtonComponent, ConfirmDialogComponent], imports: [DecimalPipe, RouterLink, FormsModule, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe, IconComponent, EmptyStateComponent, ButtonComponent, ConfirmDialogComponent, DialogComponent],
templateUrl: './cart.component.html', templateUrl: './cart.component.html',
styleUrls: ['./cart.component.scss'], styleUrls: ['./cart.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -82,13 +80,6 @@ export class CartComponent implements OnDestroy {
private configService = inject(ConfigService); private configService = inject(ConfigService);
private tenantResolver = inject(TenantResolverService); private tenantResolver = inject(TenantResolverService);
// Focus management for the custom payment/bank-payment modals (not
// app-dialog — they own a real multi-step state machine). Mirrors
// app-dialog's confirmed-correct focus-trap/Escape/return-focus pattern.
@ViewChild('paymentModalPanel') private paymentModalPanel?: ElementRef<HTMLElement>;
@ViewChild('bankPaymentModalPanel') private bankPaymentModalPanel?: ElementRef<HTMLElement>;
private previouslyFocusedBeforeModal: HTMLElement | null = null;
constructor( constructor(
private cartService: CartService, private cartService: CartService,
private apiService: ApiService, private apiService: ApiService,
@@ -103,78 +94,6 @@ export class CartComponent implements OnDestroy {
this.totalWithDelivery = this.cartService.totalWithDelivery; this.totalWithDelivery = this.cartService.totalWithDelivery;
this.hasDeliveryPrice = this.cartService.hasDeliveryPrice; this.hasDeliveryPrice = this.cartService.hasDeliveryPrice;
this.allRequiredDeliveriesSelected = this.cartService.allRequiredDeliveriesSelected; this.allRequiredDeliveriesSelected = this.cartService.allRequiredDeliveriesSelected;
effect(() => {
const isOpen = this.showPaymentPopup();
if (isOpen) {
this.previouslyFocusedBeforeModal ??= document.activeElement as HTMLElement | null;
queueMicrotask(() => this.focusActiveModalPanel());
} else if (this.previouslyFocusedBeforeModal) {
this.previouslyFocusedBeforeModal.focus();
this.previouslyFocusedBeforeModal = null;
}
});
effect(() => {
if (this.showBankPaymentPopup()) {
queueMicrotask(() => this.focusActiveModalPanel());
}
});
}
@HostListener('document:keydown', ['$event'])
protected handleModalKeydown(event: KeyboardEvent): void {
if (!this.showPaymentPopup()) {
return;
}
if (event.key === 'Escape') {
if (this.showBankPaymentPopup()) {
this.closeBankPaymentPopup();
} else {
this.closePaymentPopup();
}
return;
}
if (event.key === 'Tab') {
this.trapModalFocus(event);
}
}
private activeModalPanel(): HTMLElement | undefined {
return this.showBankPaymentPopup()
? this.bankPaymentModalPanel?.nativeElement
: this.paymentModalPanel?.nativeElement;
}
private focusActiveModalPanel(): void {
const panel = this.activeModalPanel();
if (!panel) {
return;
}
const focusable = panel.querySelectorAll<HTMLElement>(MODAL_FOCUSABLE_SELECTOR);
(focusable[0] ?? panel).focus();
}
private trapModalFocus(event: KeyboardEvent): void {
const panel = this.activeModalPanel();
if (!panel) {
return;
}
const focusable = Array.from(panel.querySelectorAll<HTMLElement>(MODAL_FOCUSABLE_SELECTOR));
if (focusable.length === 0) {
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
} }
requestLogin(): void { requestLogin(): void {

View File

@@ -14,4 +14,5 @@ export interface TenantConfig {
defaultCurrency: string; defaultCurrency: string;
supportedCurrencies: string[]; supportedCurrencies: string[];
timezone: string; timezone: string;
documentationUrl?: string;
} }

View File

@@ -8,7 +8,7 @@
[class.app-dialog-panel--lg]="size() === 'lg'" [class.app-dialog-panel--lg]="size() === 'lg'"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
[attr.aria-label]="titleText()" [attr.aria-label]="titleText() ?? ariaLabel()"
tabindex="-1" tabindex="-1"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >

View File

@@ -11,6 +11,7 @@
} }
.app-dialog-panel { .app-dialog-panel {
position: relative;
width: 100%; width: 100%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;

View File

@@ -15,7 +15,7 @@ import { TranslatePipe } from '../../../i18n/translate.pipe';
export type DialogSize = 'sm' | 'md' | 'lg'; export type DialogSize = 'sm' | 'md' | 'lg';
const FOCUSABLE_SELECTOR = const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])';
@Component({ @Component({
selector: 'app-dialog', selector: 'app-dialog',
@@ -28,7 +28,14 @@ const FOCUSABLE_SELECTOR =
export class DialogComponent implements OnChanges, AfterViewInit { export class DialogComponent implements OnChanges, AfterViewInit {
readonly open = input(false); readonly open = input(false);
readonly titleText = input<string | null>(null); readonly titleText = input<string | null>(null);
// Accessible name for dialogs that skip the visible titled header (a
// consumer-rendered close button inside the content instead). Ignored
// when titleText is set - the header already supplies the name.
readonly ariaLabel = input<string | null>(null);
readonly size = input<DialogSize>('md'); readonly size = input<DialogSize>('md');
// Both default true (existing behavior for every current consumer).
readonly closeOnEscape = input(true);
readonly closeOnBackdropClick = input(true);
readonly closed = output<void>(); readonly closed = output<void>();
@@ -60,7 +67,9 @@ export class DialogComponent implements OnChanges, AfterViewInit {
return; return;
} }
if (event.key === 'Escape') { if (event.key === 'Escape') {
if (this.closeOnEscape()) {
this.requestClose(); this.requestClose();
}
return; return;
} }
if (event.key === 'Tab') { if (event.key === 'Tab') {
@@ -73,8 +82,10 @@ export class DialogComponent implements OnChanges, AfterViewInit {
} }
protected handleBackdropClick(): void { protected handleBackdropClick(): void {
if (this.closeOnBackdropClick()) {
this.requestClose(); this.requestClose();
} }
}
private focusPanel(): void { private focusPanel(): void {
const panel = this.panelRef?.nativeElement; const panel = this.panelRef?.nativeElement;

View File

@@ -1,179 +1,414 @@
import {
LucideAlignLeft as AlignLeft,
LucideArrowDown as ArrowDown,
LucideArrowLeft as ArrowLeft,
LucideArrowRight as ArrowRight,
LucideArrowUp as ArrowUp,
LucideBadgeCheck as BadgeCheck,
LucideBarChart3 as BarChart3,
LucideBell as Bell,
LucideBook as Book,
LucideBoxes as Boxes,
LucideBuilding2 as Building2,
LucideCalendar as Calendar,
LucideCheck as Check,
LucideCheckCircle2 as CheckCircle2,
LucideChevronDown as ChevronDown,
LucideChevronLeft as ChevronLeft,
LucideChevronRight as ChevronRight,
LucideChevronUp as ChevronUp,
LucideCircle as Circle,
LucideClock as Clock,
LucideCode2 as Code2,
LucideCompass as Compass,
LucideCopy as Copy,
LucideCreditCard as CreditCard,
LucideDatabase as Database,
LucideDownload as Download,
LucideFilter as Filter,
LucideFlag as Flag,
LucideFolder as Folder,
LucideGlobe as Globe,
LucideGripVertical as GripVertical,
LucideHeart as Heart,
LucideHelpCircle as HelpCircle,
LucideHistory as History,
LucideHome as Home,
LucideImage as Image,
LucideImages as Images,
LucideInfo as Info,
LucideLayoutGrid as LayoutGrid,
LucideLineChart as LineChart,
LucideLink as Link,
LucideList as List,
LucideLoader2 as Loader2,
LucideLocate as Locate,
LucideLock as Lock,
LucideLogOut as LogOut,
LucideMapPin as MapPin,
LucideMegaphone as Megaphone,
LucideMenu as Menu,
LucideMinus as Minus,
LucideMonitor as Monitor,
LucideNetwork as Network,
LucidePackage as Package,
LucidePalette as Palette,
LucidePencil as Pencil,
LucidePlus as Plus,
LucidePlusCircle as PlusCircle,
LucideRefreshCw as RefreshCw,
LucideSave as Save,
LucideScale as Scale,
LucideSearch as Search,
LucideSettings as Settings,
LucideShield as Shield,
LucideShoppingCart as ShoppingCart,
LucideSlidersHorizontal as SlidersHorizontal,
LucideSquare as Square,
LucideStar as Star,
LucideStore as Store,
LucideTable as Table,
LucideTag as Tag,
LucideTags as Tags,
LucideThumbsDown as ThumbsDown,
LucideThumbsUp as ThumbsUp,
LucideTrash2 as Trash2,
LucideTrendingUp as TrendingUp,
LucideTriangleAlert as TriangleAlert,
LucideUpload as Upload,
LucideUser as User,
LucideUsers as Users,
LucideVideo as Video,
LucideX as X,
LucideXCircle as XCircle,
LucideZap as Zap
} from '@lucide/angular';
/** /**
* Canonical name -> Lucide icon map for the whole app (storefront + builder + backoffice). * Canonical name -> raw Lucide icon node data map for the whole app
* One entry per concept so every screen renders the same icon for the same meaning * (storefront + builder + backoffice). One entry per concept so every screen
* (RC UI Sprint - Icon & Visual Language Audit). Extend this list rather than * renders the same icon for the same meaning (RC UI Sprint - Icon & Visual
* importing Lucide icons ad hoc in individual components. * Language Audit). Extend this list rather than importing icon data ad hoc in
* individual components.
*
* Node data is transcribed directly from @lucide/angular's icon set
* (fesm2022/lucide-angular.mjs) rather than importing the package, because
* the package ships as a single non-tree-shakeable bundle that would pull
* ~1500 unused icon classes into the initial JS bundle. See IconComponent
* for the renderer that consumes this data.
*/ */
export type LucideIconNode = readonly [string, Readonly<Record<string, string>>];
export const APP_ICONS = { export const APP_ICONS = {
home: Home, home: [
store: Store, ["path", { "d": "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" }],
cart: ShoppingCart, ["path", { "d": "M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" }]
package: Package, ],
boxes: Boxes, store: [
folder: Folder, ["path", { "d": "M15 21v-5a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v5" }],
tag: Tag, ["path", { "d": "M17.774 10.31a1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.451 0 1.12 1.12 0 0 0-1.548 0 2.5 2.5 0 0 1-3.452 0 1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.77-3.248l2.889-4.184A2 2 0 0 1 7 2h10a2 2 0 0 1 1.653.873l2.895 4.192a2.5 2.5 0 0 1-3.774 3.244" }],
tags: Tags, ["path", { "d": "M4 10.95V19a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8.05" }]
users: Users, ],
user: User, cart: [
settings: Settings, ["circle", { "cx": "8", "cy": "21", "r": "1" }],
chartBar: BarChart3, ["circle", { "cx": "19", "cy": "21", "r": "1" }],
chartLine: LineChart, ["path", { "d": "M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12" }]
search: Search, ],
filter: Filter, package: [
heart: Heart, ["path", { "d": "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" }],
scale: Scale, ["path", { "d": "M12 22V12" }],
bell: Bell, ["polyline", { "points": "3.29 7 12 12 20.71 7" }],
image: Image, ["path", { "d": "m7.5 4.27 9 5.15" }]
images: Images, ],
palette: Palette, boxes: [
monitor: Monitor, ["path", { "d": "M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" }],
shield: Shield, ["path", { "d": "m7 16.5-4.74-2.85" }],
lock: Lock, ["path", { "d": "m7 16.5 5-3" }],
database: Database, ["path", { "d": "M7 16.5v5.17" }],
upload: Upload, ["path", { "d": "M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" }],
download: Download, ["path", { "d": "m17 16.5-5-3" }],
edit: Pencil, ["path", { "d": "m17 16.5 4.74-2.85" }],
trash: Trash2, ["path", { "d": "M17 16.5v5.17" }],
copy: Copy, ["path", { "d": "M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z" }],
save: Save, ["path", { "d": "M12 8 7.26 5.15" }],
plus: Plus, ["path", { "d": "m12 8 4.74-2.85" }],
plusCircle: PlusCircle, ["path", { "d": "M12 13.5V8" }]
minus: Minus, ],
refresh: RefreshCw, folder: [
check: Check, ["path", { "d": "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" }]
checkCircle: CheckCircle2, ],
x: X, tag: [
xCircle: XCircle, ["path", { "d": "M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" }],
arrowUp: ArrowUp, ["circle", { "cx": "7.5", "cy": "7.5", "r": ".5", "fill": "currentColor" }]
arrowDown: ArrowDown, ],
arrowLeft: ArrowLeft, tags: [
arrowRight: ArrowRight, ["path", { "d": "M13.172 2a2 2 0 0 1 1.414.586l6.71 6.71a2.4 2.4 0 0 1 0 3.408l-4.592 4.592a2.4 2.4 0 0 1-3.408 0l-6.71-6.71A2 2 0 0 1 6 9.172V3a1 1 0 0 1 1-1z" }],
chevronUp: ChevronUp, ["path", { "d": "M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193" }],
chevronDown: ChevronDown, ["circle", { "cx": "10.5", "cy": "6.5", "r": ".5", "fill": "currentColor" }]
chevronLeft: ChevronLeft, ],
chevronRight: ChevronRight, users: [
info: Info, ["path", { "d": "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" }],
help: HelpCircle, ["path", { "d": "M16 3.128a4 4 0 0 1 0 7.744" }],
warning: TriangleAlert, ["path", { "d": "M22 21v-2a4 4 0 0 0-3-3.87" }],
clock: Clock, ["circle", { "cx": "9", "cy": "7", "r": "4" }]
calendar: Calendar, ],
menu: Menu, user: [
alignLeft: AlignLeft, ["path", { "d": "M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" }],
book: Book, ["circle", { "cx": "12", "cy": "7", "r": "4" }]
building: Building2, ],
code: Code2, settings: [
compass: Compass, ["path", { "d": "M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" }],
creditCard: CreditCard, ["circle", { "cx": "12", "cy": "12", "r": "3" }]
flag: Flag, ],
globe: Globe, chartBar: [
grip: GripVertical, ["path", { "d": "M3 3v16a2 2 0 0 0 2 2h16" }],
history: History, ["path", { "d": "M18 17V9" }],
link: Link, ["path", { "d": "M13 17V5" }],
list: List, ["path", { "d": "M8 17v-3" }]
locate: Locate, ],
mapPin: MapPin, chartLine: [
megaphone: Megaphone, ["path", { "d": "M3 3v16a2 2 0 0 0 2 2h16" }],
logOut: LogOut, ["path", { "d": "m19 9-5 5-4-4-3 3" }]
network: Network, ],
slidersHorizontal: SlidersHorizontal, search: [
spinner: Loader2, ["path", { "d": "m21 21-4.34-4.34" }],
star: Star, ["circle", { "cx": "11", "cy": "11", "r": "8" }]
stop: Square, ],
table: Table, filter: [
layoutGrid: LayoutGrid, ["path", { "d": "M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z" }]
thumbsUp: ThumbsUp, ],
thumbsDown: ThumbsDown, heart: [
verified: BadgeCheck, ["path", { "d": "M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5" }]
video: Video, ],
circle: Circle, scale: [
zap: Zap, ["path", { "d": "M12 3v18" }],
trendingUp: TrendingUp ["path", { "d": "m19 8 3 8a5 5 0 0 1-6 0zV7" }],
} as const; ["path", { "d": "M3 7h1a17 17 0 0 0 8-2 17 17 0 0 0 8 2h1" }],
["path", { "d": "m5 8 3 8a5 5 0 0 1-6 0zV7" }],
["path", { "d": "M7 21h10" }]
],
bell: [
["path", { "d": "M10.268 21a2 2 0 0 0 3.464 0" }],
["path", { "d": "M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" }]
],
image: [
["rect", { "width": "18", "height": "18", "x": "3", "y": "3", "rx": "2", "ry": "2" }],
["circle", { "cx": "9", "cy": "9", "r": "2" }],
["path", { "d": "m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" }]
],
images: [
["path", { "d": "m22 11-1.296-1.296a2.4 2.4 0 0 0-3.408 0L11 16" }],
["path", { "d": "M4 8a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2" }],
["circle", { "cx": "13", "cy": "7", "r": "1", "fill": "currentColor" }],
["rect", { "x": "8", "y": "2", "width": "14", "height": "14", "rx": "2" }]
],
palette: [
["path", { "d": "M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z" }],
["circle", { "cx": "13.5", "cy": "6.5", "r": ".5", "fill": "currentColor" }],
["circle", { "cx": "17.5", "cy": "10.5", "r": ".5", "fill": "currentColor" }],
["circle", { "cx": "6.5", "cy": "12.5", "r": ".5", "fill": "currentColor" }],
["circle", { "cx": "8.5", "cy": "7.5", "r": ".5", "fill": "currentColor" }]
],
monitor: [
["rect", { "width": "20", "height": "14", "x": "2", "y": "3", "rx": "2" }],
["line", { "x1": "8", "x2": "16", "y1": "21", "y2": "21" }],
["line", { "x1": "12", "x2": "12", "y1": "17", "y2": "21" }]
],
shield: [
["path", { "d": "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" }]
],
lock: [
["rect", { "width": "18", "height": "11", "x": "3", "y": "11", "rx": "2", "ry": "2" }],
["path", { "d": "M7 11V7a5 5 0 0 1 10 0v4" }]
],
database: [
["ellipse", { "cx": "12", "cy": "5", "rx": "9", "ry": "3" }],
["path", { "d": "M3 5V19A9 3 0 0 0 21 19V5" }],
["path", { "d": "M3 12A9 3 0 0 0 21 12" }]
],
upload: [
["path", { "d": "M12 3v12" }],
["path", { "d": "m17 8-5-5-5 5" }],
["path", { "d": "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }]
],
download: [
["path", { "d": "M12 15V3" }],
["path", { "d": "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }],
["path", { "d": "m7 10 5 5 5-5" }]
],
edit: [
["path", { "d": "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" }],
["path", { "d": "m15 5 4 4" }]
],
trash: [
["path", { "d": "M10 11v6" }],
["path", { "d": "M14 11v6" }],
["path", { "d": "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" }],
["path", { "d": "M3 6h18" }],
["path", { "d": "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }]
],
copy: [
["rect", { "width": "14", "height": "14", "x": "8", "y": "8", "rx": "2", "ry": "2" }],
["path", { "d": "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" }]
],
save: [
["path", { "d": "M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" }],
["path", { "d": "M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7" }],
["path", { "d": "M7 3v4a1 1 0 0 0 1 1h7" }]
],
plus: [
["path", { "d": "M5 12h14" }],
["path", { "d": "M12 5v14" }]
],
plusCircle: [
["circle", { "cx": "12", "cy": "12", "r": "10" }],
["path", { "d": "M8 12h8" }],
["path", { "d": "M12 8v8" }]
],
minus: [
["path", { "d": "M5 12h14" }]
],
refresh: [
["path", { "d": "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" }],
["path", { "d": "M21 3v5h-5" }],
["path", { "d": "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" }],
["path", { "d": "M8 16H3v5" }]
],
check: [
["path", { "d": "M20 6 9 17l-5-5" }]
],
checkCircle: [
["circle", { "cx": "12", "cy": "12", "r": "10" }],
["path", { "d": "m9 12 2 2 4-4" }]
],
x: [
["path", { "d": "M18 6 6 18" }],
["path", { "d": "m6 6 12 12" }]
],
xCircle: [
["circle", { "cx": "12", "cy": "12", "r": "10" }],
["path", { "d": "m15 9-6 6" }],
["path", { "d": "m9 9 6 6" }]
],
arrowUp: [
["path", { "d": "m5 12 7-7 7 7" }],
["path", { "d": "M12 19V5" }]
],
arrowDown: [
["path", { "d": "M12 5v14" }],
["path", { "d": "m19 12-7 7-7-7" }]
],
arrowLeft: [
["path", { "d": "m12 19-7-7 7-7" }],
["path", { "d": "M19 12H5" }]
],
arrowRight: [
["path", { "d": "M5 12h14" }],
["path", { "d": "m12 5 7 7-7 7" }]
],
chevronUp: [
["path", { "d": "m18 15-6-6-6 6" }]
],
chevronDown: [
["path", { "d": "m6 9 6 6 6-6" }]
],
chevronLeft: [
["path", { "d": "m15 18-6-6 6-6" }]
],
chevronRight: [
["path", { "d": "m9 18 6-6-6-6" }]
],
info: [
["circle", { "cx": "12", "cy": "12", "r": "10" }],
["path", { "d": "M12 16v-4" }],
["path", { "d": "M12 8h.01" }]
],
help: [
["circle", { "cx": "12", "cy": "12", "r": "10" }],
["path", { "d": "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" }],
["path", { "d": "M12 17h.01" }]
],
warning: [
["path", { "d": "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }],
["path", { "d": "M12 9v4" }],
["path", { "d": "M12 17h.01" }]
],
clock: [
["circle", { "cx": "12", "cy": "12", "r": "10" }],
["path", { "d": "M12 6v6l4 2" }]
],
calendar: [
["path", { "d": "M8 2v4" }],
["path", { "d": "M16 2v4" }],
["rect", { "width": "18", "height": "18", "x": "3", "y": "4", "rx": "2" }],
["path", { "d": "M3 10h18" }]
],
menu: [
["path", { "d": "M4 5h16" }],
["path", { "d": "M4 12h16" }],
["path", { "d": "M4 19h16" }]
],
alignLeft: [
["path", { "d": "M21 5H3" }],
["path", { "d": "M15 12H3" }],
["path", { "d": "M17 19H3" }]
],
book: [
["path", { "d": "M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" }]
],
building: [
["path", { "d": "M10 12h4" }],
["path", { "d": "M10 8h4" }],
["path", { "d": "M14 21v-3a2 2 0 0 0-4 0v3" }],
["path", { "d": "M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2" }],
["path", { "d": "M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16" }]
],
code: [
["path", { "d": "m18 16 4-4-4-4" }],
["path", { "d": "m6 8-4 4 4 4" }],
["path", { "d": "m14.5 4-5 16" }]
],
compass: [
["circle", { "cx": "12", "cy": "12", "r": "10" }],
["path", { "d": "m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z" }]
],
creditCard: [
["rect", { "width": "20", "height": "14", "x": "2", "y": "5", "rx": "2" }],
["line", { "x1": "2", "x2": "22", "y1": "10", "y2": "10" }]
],
flag: [
["path", { "d": "M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528" }]
],
globe: [
["circle", { "cx": "12", "cy": "12", "r": "10" }],
["path", { "d": "M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" }],
["path", { "d": "M2 12h20" }]
],
grip: [
["circle", { "cx": "9", "cy": "12", "r": "1" }],
["circle", { "cx": "9", "cy": "5", "r": "1" }],
["circle", { "cx": "9", "cy": "19", "r": "1" }],
["circle", { "cx": "15", "cy": "12", "r": "1" }],
["circle", { "cx": "15", "cy": "5", "r": "1" }],
["circle", { "cx": "15", "cy": "19", "r": "1" }]
],
history: [
["path", { "d": "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }],
["path", { "d": "M3 3v5h5" }],
["path", { "d": "M12 7v5l4 2" }]
],
link: [
["path", { "d": "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" }],
["path", { "d": "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" }]
],
list: [
["path", { "d": "M3 5h.01" }],
["path", { "d": "M3 12h.01" }],
["path", { "d": "M3 19h.01" }],
["path", { "d": "M8 5h13" }],
["path", { "d": "M8 12h13" }],
["path", { "d": "M8 19h13" }]
],
locate: [
["line", { "x1": "2", "x2": "5", "y1": "12", "y2": "12" }],
["line", { "x1": "19", "x2": "22", "y1": "12", "y2": "12" }],
["line", { "x1": "12", "x2": "12", "y1": "2", "y2": "5" }],
["line", { "x1": "12", "x2": "12", "y1": "19", "y2": "22" }],
["circle", { "cx": "12", "cy": "12", "r": "7" }]
],
mapPin: [
["path", { "d": "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" }],
["circle", { "cx": "12", "cy": "10", "r": "3" }]
],
megaphone: [
["path", { "d": "M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z" }],
["path", { "d": "M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14" }],
["path", { "d": "M8 6v8" }]
],
logOut: [
["path", { "d": "m16 17 5-5-5-5" }],
["path", { "d": "M21 12H9" }],
["path", { "d": "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }]
],
network: [
["rect", { "x": "16", "y": "16", "width": "6", "height": "6", "rx": "1" }],
["rect", { "x": "2", "y": "16", "width": "6", "height": "6", "rx": "1" }],
["rect", { "x": "9", "y": "2", "width": "6", "height": "6", "rx": "1" }],
["path", { "d": "M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" }],
["path", { "d": "M12 12V8" }]
],
slidersHorizontal: [
["path", { "d": "M10 5H3" }],
["path", { "d": "M12 19H3" }],
["path", { "d": "M14 3v4" }],
["path", { "d": "M16 17v4" }],
["path", { "d": "M21 12h-9" }],
["path", { "d": "M21 19h-5" }],
["path", { "d": "M21 5h-7" }],
["path", { "d": "M8 10v4" }],
["path", { "d": "M8 12H3" }]
],
spinner: [
["path", { "d": "M21 12a9 9 0 1 1-6.219-8.56" }]
],
star: [
["path", { "d": "M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" }]
],
stop: [
["rect", { "width": "18", "height": "18", "x": "3", "y": "3", "rx": "2" }]
],
table: [
["path", { "d": "M12 3v18" }],
["rect", { "width": "18", "height": "18", "x": "3", "y": "3", "rx": "2" }],
["path", { "d": "M3 9h18" }],
["path", { "d": "M3 15h18" }]
],
layoutGrid: [
["rect", { "width": "7", "height": "7", "x": "3", "y": "3", "rx": "1" }],
["rect", { "width": "7", "height": "7", "x": "14", "y": "3", "rx": "1" }],
["rect", { "width": "7", "height": "7", "x": "14", "y": "14", "rx": "1" }],
["rect", { "width": "7", "height": "7", "x": "3", "y": "14", "rx": "1" }]
],
thumbsUp: [
["path", { "d": "M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" }],
["path", { "d": "M7 10v12" }]
],
thumbsDown: [
["path", { "d": "M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" }],
["path", { "d": "M17 14V2" }]
],
verified: [
["path", { "d": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z" }],
["path", { "d": "m9 12 2 2 4-4" }]
],
video: [
["path", { "d": "m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" }],
["rect", { "x": "2", "y": "6", "width": "14", "height": "12", "rx": "2" }]
],
circle: [
["circle", { "cx": "12", "cy": "12", "r": "10" }]
],
zap: [
["path", { "d": "M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" }]
],
trendingUp: [
["path", { "d": "M16 7h6v6" }],
["path", { "d": "m22 7-8.5 8.5-5-5L2 17" }]
],
} as const satisfies Record<string, readonly LucideIconNode[]>;
export type AppIconName = keyof typeof APP_ICONS; export type AppIconName = keyof typeof APP_ICONS;

View File

@@ -1,12 +1,16 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { LucideDynamicIcon } from '@lucide/angular'; import { APP_ICONS, type AppIconName, type LucideIconNode } from './icon-registry';
import { APP_ICONS, type AppIconName } from './icon-registry';
/** /**
* Single entry point for every icon in the app. Renders a Lucide icon by * Single entry point for every icon in the app. Renders a Lucide icon by
* canonical name (see icon-registry.ts) so every screen uses one icon family * canonical name (see icon-registry.ts) so every screen uses one icon family
* at one consistent size/stroke-width by default. * at one consistent size/stroke-width by default.
* *
* Renders directly from transcribed Lucide node data (see icon-registry.ts)
* instead of using @lucide/angular's runtime, matching the shape switch and
* host attributes of Lucide's own LucideIconBase/LucideDynamicIcon so output
* is pixel-identical.
*
* Decorative by default (aria-hidden). Pass `ariaLabel` only when the icon * Decorative by default (aria-hidden). Pass `ariaLabel` only when the icon
* itself carries meaning with no adjacent text (e.g. inside an icon-only * itself carries meaning with no adjacent text (e.g. inside an icon-only
* button) - the label belongs on the interactive element when there is one. * button) - the label belongs on the interactive element when there is one.
@@ -14,18 +18,59 @@ import { APP_ICONS, type AppIconName } from './icon-registry';
@Component({ @Component({
selector: 'app-icon', selector: 'app-icon',
standalone: true, standalone: true,
imports: [LucideDynamicIcon],
template: ` template: `
<svg <svg
[lucideIcon]="iconData()" xmlns="http://www.w3.org/2000/svg"
[size]="size()" viewBox="0 0 24 24"
[color]="color()" fill="none"
[strokeWidth]="strokeWidth()" stroke-linecap="round"
[title]="ariaLabel()" stroke-linejoin="round"
class="lucide"
[attr.width]="size()"
[attr.height]="size()"
[attr.stroke]="color()"
[attr.stroke-width]="strokeWidth()"
[attr.role]="ariaLabel() ? 'img' : null" [attr.role]="ariaLabel() ? 'img' : null"
[attr.aria-label]="ariaLabel()" [attr.aria-label]="ariaLabel()"
[attr.aria-hidden]="ariaLabel() ? null : 'true'" [attr.aria-hidden]="ariaLabel() ? null : 'true'"
></svg> >
@if (ariaLabel(); as label) {
<title>{{ label }}</title>
}
@for (node of nodes(); track $index) {
@let attrs = node[1];
@switch (node[0]) {
@case ('path') {
<path [attr.d]="attrs['d']" [attr.fill]="attrs['fill']" />
}
@case ('line') {
<line [attr.x1]="attrs['x1']" [attr.x2]="attrs['x2']" [attr.y1]="attrs['y1']" [attr.y2]="attrs['y2']" />
}
@case ('polygon') {
<polygon [attr.points]="attrs['points']" />
}
@case ('polyline') {
<polyline [attr.points]="attrs['points']" />
}
@case ('circle') {
<circle [attr.cx]="attrs['cx']" [attr.cy]="attrs['cy']" [attr.r]="attrs['r']" [attr.fill]="attrs['fill']" />
}
@case ('ellipse') {
<ellipse [attr.cx]="attrs['cx']" [attr.cy]="attrs['cy']" [attr.rx]="attrs['rx']" [attr.ry]="attrs['ry']" />
}
@case ('rect') {
<rect
[attr.x]="attrs['x']"
[attr.y]="attrs['y']"
[attr.width]="attrs['width']"
[attr.height]="attrs['height']"
[attr.rx]="attrs['rx']"
[attr.ry]="attrs['ry']"
/>
}
}
}
</svg>
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { host: {
@@ -40,5 +85,10 @@ export class IconComponent {
readonly strokeWidth = input<number>(2); readonly strokeWidth = input<number>(2);
readonly ariaLabel = input<string | null>(null); readonly ariaLabel = input<string | null>(null);
protected readonly iconData = computed(() => APP_ICONS[this.name()]); // `APP_ICONS` is declared `as const satisfies ...` so per-icon tuple types stay
// literal (keeps `AppIconName` a precise key union for callers); widen here at
// the single consumption point instead of loosening the registry's own typing.
protected readonly nodes = computed(
() => APP_ICONS[this.name()] as readonly LucideIconNode[]
);
} }

View File

@@ -50,3 +50,9 @@ app-table {
background: var(--bg-secondary, #f4f4f5); background: var(--bg-secondary, #f4f4f5);
} }
} }
.admin-density-compact .app-table {
th, td {
padding: var(--space-xs, 0.25rem) var(--space-md, 1rem);
}
}

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; 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 { WidgetManifestEntry, WidgetManifestFile } from '../contracts/widget-manifest.contract';
import { ConfigService } from '../../core/config/config.service'; import { ConfigService } from '../../core/config/config.service';
@@ -8,6 +8,8 @@ import { ConfigService } from '../../core/config/config.service';
export class WidgetManifestService { export class WidgetManifestService {
private readonly fallbackManifestUrl = '/assets/mock/bootstrap/widget-manifest.json'; private readonly fallbackManifestUrl = '/assets/mock/bootstrap/widget-manifest.json';
private readonly manifestByUrl = new Map<string, Observable<WidgetManifestFile>>(); private readonly manifestByUrl = new Map<string, Observable<WidgetManifestFile>>();
/** 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( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
@@ -23,6 +25,7 @@ export class WidgetManifestService {
} }
const manifest$ = this.http.get<WidgetManifestFile>(manifestUrl).pipe( const manifest$ = this.http.get<WidgetManifestFile>(manifestUrl).pipe(
tap(manifest => { this.manifestSnapshot = manifest; }),
shareReplay({ bufferSize: 1, refCount: true }), shareReplay({ bufferSize: 1, refCount: true }),
catchError(() => of({ widgets: [] })) catchError(() => of({ widgets: [] }))
); );
@@ -41,6 +44,11 @@ export class WidgetManifestService {
return this.getWidgets().pipe(map((widgets) => widgets.find((widget) => widget.type === type))); 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<string> { private resolveManifestUrl(): Observable<string> {
const snapshotUrl = this.configService.getBootstrapSnapshot()?.widgetRegistry?.manifestUrl; const snapshotUrl = this.configService.getBootstrapSnapshot()?.widgetRegistry?.manifestUrl;
if (snapshotUrl) { if (snapshotUrl) {

View File

@@ -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);
});
});

View File

@@ -5,6 +5,7 @@ import { HeroSlideData, HeroWidgetData } from '../contracts/widget-data.contract
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
const AUTOPLAY_INTERVAL_MS = 5000; const AUTOPLAY_INTERVAL_MS = 5000;
const SWIPE_THRESHOLD_PX = 50;
@Component({ @Component({
selector: 'app-hero-widget', selector: 'app-hero-widget',
@@ -12,14 +13,40 @@ const AUTOPLAY_INTERVAL_MS = 5000;
imports: [CommonModule, TranslatePipe], imports: [CommonModule, TranslatePipe],
template: ` template: `
<section class="hero-widget"> <section class="hero-widget">
@if (activeSlide(); as slide) { @if (visibleSlides().length) {
<h1 class="hero-widget__title">{{ slide.title }}</h1> <div class="hero-widget__nav">
@if (allSlides().length > 1) {
<button
type="button"
class="hero-widget__arrow hero-widget__arrow--prev"
[attr.aria-label]="'common.previousSlide' | translate"
(click)="prevSlide()"
>&larr;</button>
}
<div class="hero-widget__slides" (touchstart)="onSwipeStart($event)" (touchend)="onSwipeEnd($event)">
@for (slide of visibleSlides(); track $index) {
<article class="hero-widget__slide">
<h2 class="hero-widget__title">{{ slide.title }}</h2>
@if (slide.subtitle) { @if (slide.subtitle) {
<p class="hero-widget__subtitle">{{ slide.subtitle }}</p> <p class="hero-widget__subtitle">{{ slide.subtitle }}</p>
} }
@if (slide.ctaLabel) { @if (slide.ctaLabel) {
<button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ slide.ctaLabel }}</button> <button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ slide.ctaLabel }}</button>
} }
</article>
}
</div>
@if (allSlides().length > 1) {
<button
type="button"
class="hero-widget__arrow hero-widget__arrow--next"
[attr.aria-label]="'common.nextSlide' | translate"
(click)="nextSlide()"
>&rarr;</button>
}
</div>
@if (allSlides().length > 1) { @if (allSlides().length > 1) {
<div class="hero-widget__dots" role="tablist" [attr.aria-label]="'common.slidesLabel' | translate"> <div class="hero-widget__dots" role="tablist" [attr.aria-label]="'common.slidesLabel' | translate">
@@ -49,6 +76,40 @@ const AUTOPLAY_INTERVAL_MS = 5000;
animation: hero-widget-in 420ms ease-out both; animation: hero-widget-in 420ms ease-out both;
} }
.hero-widget__nav {
display: flex;
align-items: stretch;
gap: var(--space-sm, 8px);
}
.hero-widget__slides {
flex: 1;
min-width: 0;
display: flex;
gap: var(--space-lg, 24px);
}
.hero-widget__slide {
flex: 1 1 0;
min-width: 0;
}
.hero-widget__arrow {
flex-shrink: 0;
align-self: center;
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--border-color, #d3dad9);
background: #fff;
color: var(--text-primary, #1e3c38);
cursor: pointer;
font-size: 1rem;
&:hover { background: var(--primary-color, #497671); color: #fff; }
&:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; }
}
.hero-widget__title { .hero-widget__title {
margin: 0 0 var(--space-sm, 8px); margin: 0 0 var(--space-sm, 8px);
font-size: 2rem; font-size: 2rem;
@@ -140,6 +201,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
readonly activeIndex = signal(0); readonly activeIndex = signal(0);
private readonly dataSignal = signal<HeroWidgetData | null>(null); private readonly dataSignal = signal<HeroWidgetData | null>(null);
private autoplayHandle: ReturnType<typeof setInterval> | null = null; private autoplayHandle: ReturnType<typeof setInterval> | null = null;
private swipeStartX: number | null = null;
readonly allSlides = computed<HeroSlideData[]>(() => { readonly allSlides = computed<HeroSlideData[]>(() => {
const current = this.dataSignal(); const current = this.dataSignal();
@@ -152,6 +214,25 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
readonly activeSlide = computed<HeroSlideData | null>(() => this.allSlides()[this.activeIndex()] ?? null); readonly activeSlide = computed<HeroSlideData | null>(() => this.allSlides()[this.activeIndex()] ?? null);
/** 1 or 2, reusing `layout.columns` (no dedicated "slides per page" field). 2 has nothing to show a second panel with when there's only one slide. */
get panelCount(): number {
const requested = this.section?.layout?.columns === 2 ? 2 : 1;
return requested === 2 && this.allSlides().length > 1 ? 2 : 1;
}
visibleSlides(): HeroSlideData[] {
const slides = this.allSlides();
if (slides.length === 0) {
return [];
}
if (this.panelCount === 2) {
const nextIndex = (this.activeIndex() + 1) % slides.length;
return [slides[this.activeIndex()], slides[nextIndex]];
}
const current = slides[this.activeIndex()];
return current ? [current] : [];
}
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes['data']) { if (changes['data']) {
this.dataSignal.set(this.data); this.dataSignal.set(this.data);
@@ -169,6 +250,41 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
this.setupAutoplay(); this.setupAutoplay();
} }
prevSlide(): void {
const total = this.allSlides().length;
if (total <= 1) {
return;
}
this.goTo((this.activeIndex() - 1 + total) % total);
}
nextSlide(): void {
const total = this.allSlides().length;
if (total <= 1) {
return;
}
this.goTo((this.activeIndex() + 1) % total);
}
onSwipeStart(event: TouchEvent): void {
this.swipeStartX = event.touches[0]?.clientX ?? null;
}
onSwipeEnd(event: TouchEvent): void {
const startX = this.swipeStartX;
this.swipeStartX = null;
if (startX === null) {
return;
}
const endX = event.changedTouches[0]?.clientX ?? startX;
const diff = startX - endX;
if (diff > SWIPE_THRESHOLD_PX) {
this.nextSlide();
} else if (diff < -SWIPE_THRESHOLD_PX) {
this.prevSlide();
}
}
onCtaClick(): void { onCtaClick(): void {
this.ctaClicked.emit(); this.ctaClicked.emit();
} }

View File

@@ -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');
});
});

View File

@@ -10,7 +10,7 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
standalone: true, standalone: true,
imports: [CommonModule, CatalogProductGridComponent, TranslatePipe], imports: [CommonModule, CatalogProductGridComponent, TranslatePipe],
template: ` template: `
<section class="product-carousel-widget"> <section class="product-carousel-widget" [style.--items-per-page]="itemsPerPage()">
@if (data?.title) { @if (data?.title) {
<h2 class="product-carousel-widget__title">{{ data?.title }}</h2> <h2 class="product-carousel-widget__title">{{ data?.title }}</h2>
} }
@@ -83,7 +83,8 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
::ng-deep .catalog-product-shell { ::ng-deep .catalog-product-shell {
flex: 0 0 auto; flex: 0 0 auto;
width: 220px; width: calc((100% - (var(--items-per-page, 4) - 1) * var(--space-md, 16px)) / var(--items-per-page, 4));
min-width: 160px;
} }
} }
@@ -121,6 +122,12 @@ export class ProductCarouselWidgetComponent {
return this.section?.layout?.strategy === 'carousel'; return this.section?.layout?.strategy === 'carousel';
} }
/** Items visible per page - reuses `layout.columns` (default 4, min 1) rather than a dedicated field. */
itemsPerPage(): number {
const columns = this.section?.layout?.columns;
return columns && columns >= 1 ? Math.floor(columns) : 4;
}
scrollBy(direction: -1 | 1): void { scrollBy(direction: -1 | 1): void {
const el = this.scroller?.nativeElement; const el = this.scroller?.nativeElement;
if (!el) { if (!el) {

View File

@@ -14,7 +14,8 @@
"supportedLocales": ["ru", "en", "hy"], "supportedLocales": ["ru", "en", "hy"],
"defaultCurrency": "RUB", "defaultCurrency": "RUB",
"supportedCurrencies": ["RUB", "USD", "EUR", "AMD"], "supportedCurrencies": ["RUB", "USD", "EUR", "AMD"],
"timezone": "Europe/Moscow" "timezone": "Europe/Moscow",
"documentationUrl": "https://docs.marketplace.local"
}, },
"branding": { "branding": {
"brandName": "Marketplace", "brandName": "Marketplace",