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

{{ 'adminReports.sales' | translate }}

+ @if (facade.summary(); as summary) { +

{{ summary.revenueTotal }} {{ summary.currency }} · {{ summary.ordersCount }} {{ 'adminAnalytics.orders' | translate }}

+ } + {{ 'adminOrders.export' | translate }} +
+ +
+

{{ 'adminReports.topProducts' | translate }}

+

{{ facade.topProducts().length }} {{ 'adminAnalytics.topProducts' | translate }}

+ {{ 'adminOrders.export' | translate }} +
+ +
+

{{ 'adminReports.marketplaceHealth' | translate }}

+

{{ facade.healthCompletionPercent() }}% {{ 'adminMarketplaceHealth.complete' | translate }}

+ {{ 'adminOrders.export' | translate }} +
+
+ } +
diff --git a/src/app/features/admin/reports/pages/admin-reports-page.component.scss b/src/app/features/admin/reports/pages/admin-reports-page.component.scss new file mode 100644 index 0000000..39a8432 --- /dev/null +++ b/src/app/features/admin/reports/pages/admin-reports-page.component.scss @@ -0,0 +1,19 @@ +.admin-reports-page { display: grid; gap: 16px; padding: 16px; max-width: 1100px; margin: 0 auto; } + +.report-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } + +.report-card { + display: grid; + gap: 10px; + align-content: start; + padding: 16px; + border: 1px solid var(--border-color, #d3dad9); + border-radius: var(--radius-md); + background: var(--bg-primary, #fff); +} +.report-card h2 { margin: 0; font-size: var(--font-size-xl, 1.125rem); } +.report-summary { margin: 0; color: var(--text-secondary, #6b7280); font-size: var(--font-size-sm, 0.8125rem); } + +@media (max-width: 900px) { + .report-grid { grid-template-columns: 1fr; } +} diff --git a/src/app/features/admin/reports/pages/admin-reports-page.component.ts b/src/app/features/admin/reports/pages/admin-reports-page.component.ts new file mode 100644 index 0000000..cc5e6ef --- /dev/null +++ b/src/app/features/admin/reports/pages/admin-reports-page.component.ts @@ -0,0 +1,43 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { AdminAnalyticsFacade } from '../../analytics/facade/admin-analytics.facade'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { ButtonComponent } from '../../../../shared/ui/button/button.component'; +import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component'; + +@Component({ + selector: 'app-admin-reports-page', + standalone: true, + imports: [TranslatePipe, ButtonComponent, SkeletonComponent], + templateUrl: './admin-reports-page.component.html', + styleUrls: ['./admin-reports-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AdminReportsPageComponent { + readonly facade = inject(AdminAnalyticsFacade); + + constructor() { + this.facade.load(); + } + + exportSalesCsv(): void { + this.download(this.facade.exportCsv(), 'sales-report.csv'); + } + + exportTopProductsCsv(): void { + this.download(this.facade.exportTopProductsCsv(), 'top-products-report.csv'); + } + + exportHealthCsv(): void { + this.download(this.facade.exportHealthCsv(), 'marketplace-health-report.csv'); + } + + private download(csv: string, filename: string): void { + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); + } +} diff --git a/src/app/features/admin/settings/pages/admin-settings-page.component.html b/src/app/features/admin/settings/pages/admin-settings-page.component.html new file mode 100644 index 0000000..619a953 --- /dev/null +++ b/src/app/features/admin/settings/pages/admin-settings-page.component.html @@ -0,0 +1,14 @@ +
+
+

{{ 'adminSettings.density' | translate }}

+

{{ 'adminSettings.densityExplain' | translate }}

+ +
+
diff --git a/src/app/features/admin/settings/pages/admin-settings-page.component.scss b/src/app/features/admin/settings/pages/admin-settings-page.component.scss new file mode 100644 index 0000000..67d3c18 --- /dev/null +++ b/src/app/features/admin/settings/pages/admin-settings-page.component.scss @@ -0,0 +1,14 @@ +.admin-settings-page { display: grid; gap: 16px; padding: 16px; max-width: 720px; margin: 0 auto; } + +.settings-card { + display: grid; + gap: 10px; + padding: 16px; + border: 1px solid var(--border-color, #d3dad9); + border-radius: var(--radius-md); + background: var(--bg-primary, #fff); +} +.settings-card h2 { margin: 0; font-size: var(--font-size-xl, 1.125rem); } +.settings-explain { margin: 0; color: var(--text-secondary, #6b7280); font-size: var(--font-size-sm, 0.8125rem); } + +.toggle-row { display: flex; align-items: center; gap: 8px; font-weight: var(--font-weight-normal, 400); } diff --git a/src/app/features/admin/settings/pages/admin-settings-page.component.ts b/src/app/features/admin/settings/pages/admin-settings-page.component.ts new file mode 100644 index 0000000..de90235 --- /dev/null +++ b/src/app/features/admin/settings/pages/admin-settings-page.component.ts @@ -0,0 +1,21 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { AdminPreferencesService } from '../services/admin-preferences.service'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component'; + +@Component({ + selector: 'app-admin-settings-page', + standalone: true, + imports: [FormsModule, TranslatePipe, ToggleComponent], + templateUrl: './admin-settings-page.component.html', + styleUrls: ['./admin-settings-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AdminSettingsPageComponent { + readonly preferences = inject(AdminPreferencesService); + + onCompactToggle(compact: boolean): void { + this.preferences.setDensity(compact ? 'compact' : 'comfortable'); + } +} diff --git a/src/app/features/admin/settings/services/admin-preferences.service.ts b/src/app/features/admin/settings/services/admin-preferences.service.ts new file mode 100644 index 0000000..1c2c272 --- /dev/null +++ b/src/app/features/admin/settings/services/admin-preferences.service.ts @@ -0,0 +1,24 @@ +import { Injectable, Signal, inject, signal } from '@angular/core'; +import { LocalStorageService } from '../../../../core/storage/local-storage.service'; + +export type AdminUiDensity = 'comfortable' | 'compact'; + +const DENSITY_KEY = 'adminPreferences.density.v1'; + +@Injectable({ providedIn: 'root' }) +export class AdminPreferencesService { + private readonly localStorage = inject(LocalStorageService); + + private readonly densitySignal = signal(this.readStoredDensity()); + + readonly density: Signal = this.densitySignal.asReadonly(); + + setDensity(value: AdminUiDensity): void { + this.densitySignal.set(value); + this.localStorage.setItem(DENSITY_KEY, value); + } + + private readStoredDensity(): AdminUiDensity { + return this.localStorage.getItem(DENSITY_KEY) === 'compact' ? 'compact' : 'comfortable'; + } +} diff --git a/src/app/features/admin/shell/admin-layout.component.html b/src/app/features/admin/shell/admin-layout.component.html index f94a950..9396f75 100644 --- a/src/app/features/admin/shell/admin-layout.component.html +++ b/src/app/features/admin/shell/admin-layout.component.html @@ -44,8 +44,20 @@
    - @for (entry of navBottom; track $index) { - @if (entry.type === 'link' && entry.comingSoon) { + @for (entry of navBottom(); track $index) { + @if (entry.type === 'link' && entry.externalHref) { +
  • + + + {{ entry.labelKey | translate }} + +
  • + } @else if (entry.type === 'link' && entry.comingSoon) {
  • @@ -276,19 +282,22 @@

    {{ 'cart.autoClose' | translate }}

    } - + - @if (showBankPaymentPopup() && bankPaymentFrameUrl()) { -
    - -
    - } - +@if (showBankPaymentPopup() && bankPaymentFrameUrl()) { + + + + } diff --git a/src/app/pages/cart/cart.component.scss b/src/app/pages/cart/cart.component.scss index 882c39b..3f1c608 100644 --- a/src/app/pages/cart/cart.component.scss +++ b/src/app/pages/cart/cart.component.scss @@ -640,32 +640,20 @@ } } -// Payment modal styles -.payment-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - padding: 20px; -} +// Payment / bank-payment modals render through the shared app-dialog +// primitive (focus-trap, Escape, backdrop-click, ARIA all owned there — +// see closeOnEscape/closeOnBackdropClick usage in the template). These +// ::ng-deep overrides restore the exact panel geometry the old hand-rolled +// modals had, which app-dialog's generic size presets/padding don't match. +:host ::ng-deep { + app-dialog.payment-dialog .app-dialog-panel { + max-width: 500px; + border: 1px solid var(--border-color); + } -.payment-modal { - background: var(--bg-primary); - border-radius: var(--radius-lg, 13px); - max-width: 500px; - width: 100%; - padding: 40px; - position: relative; - max-height: 90vh; - overflow-y: auto; - 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. @@ -970,25 +958,23 @@ } -.bank-payment-modal-overlay { - position: fixed; - inset: 0; - z-index: 1001; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; - background: rgba(17, 24, 39, 0.55); -} +:host ::ng-deep { + app-dialog.bank-payment-dialog .app-dialog-backdrop { + background: rgba(17, 24, 39, 0.55); + } -.bank-payment-modal { - position: relative; - width: min(960px, 92vw); - 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); + app-dialog.bank-payment-dialog .app-dialog-panel { + width: min(960px, 92vw); + max-width: min(960px, 92vw); + height: min(760px, 86vh); + 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 { @@ -1041,9 +1027,9 @@ display: flex; } - .payment-modal { + :host ::ng-deep app-dialog.payment-dialog .app-dialog-panel__body { padding: 24px; - padding-top: 56px; // �������������� ������ ������ ��� ������ �������� + padding-top: 56px; } .close-modal-btn { @@ -1078,14 +1064,20 @@ grid-template-columns: 1fr; } - .bank-payment-modal-overlay { - padding: 12px; - } + :host ::ng-deep { + app-dialog.bank-payment-dialog .app-dialog-backdrop { + padding: 12px; + } - .bank-payment-modal { - width: 94vw; - height: 82vh; - padding: 52px 10px 10px; + app-dialog.bank-payment-dialog .app-dialog-panel { + width: 94vw; + max-width: 94vw; + height: 82vh; + } + + app-dialog.bank-payment-dialog .app-dialog-panel__body { + padding: 52px 10px 10px; + } } .payment-active h2 { diff --git a/src/app/pages/cart/cart.component.ts b/src/app/pages/cart/cart.component.ts index 9e71d84..cf00c2e 100644 --- a/src/app/pages/cart/cart.component.ts +++ b/src/app/pages/cart/cart.component.ts @@ -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 { Router, RouterLink } from '@angular/router'; 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 { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service'; import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component'; +import { DialogComponent } from '../../shared/ui/dialog/dialog.component'; 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({ 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', styleUrls: ['./cart.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -82,13 +80,6 @@ export class CartComponent implements OnDestroy { private configService = inject(ConfigService); 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; - @ViewChild('bankPaymentModalPanel') private bankPaymentModalPanel?: ElementRef; - private previouslyFocusedBeforeModal: HTMLElement | null = null; - constructor( private cartService: CartService, private apiService: ApiService, @@ -103,78 +94,6 @@ export class CartComponent implements OnDestroy { this.totalWithDelivery = this.cartService.totalWithDelivery; this.hasDeliveryPrice = this.cartService.hasDeliveryPrice; 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(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(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 { diff --git a/src/app/shared/models/config/tenant.model.ts b/src/app/shared/models/config/tenant.model.ts index c0bc1e2..47f16a9 100644 --- a/src/app/shared/models/config/tenant.model.ts +++ b/src/app/shared/models/config/tenant.model.ts @@ -14,4 +14,5 @@ export interface TenantConfig { defaultCurrency: string; supportedCurrencies: string[]; timezone: string; + documentationUrl?: string; } diff --git a/src/app/shared/ui/dialog/dialog.component.html b/src/app/shared/ui/dialog/dialog.component.html index 24408c7..7f0a7c7 100644 --- a/src/app/shared/ui/dialog/dialog.component.html +++ b/src/app/shared/ui/dialog/dialog.component.html @@ -8,7 +8,7 @@ [class.app-dialog-panel--lg]="size() === 'lg'" role="dialog" aria-modal="true" - [attr.aria-label]="titleText()" + [attr.aria-label]="titleText() ?? ariaLabel()" tabindex="-1" (click)="$event.stopPropagation()" > diff --git a/src/app/shared/ui/dialog/dialog.component.scss b/src/app/shared/ui/dialog/dialog.component.scss index 2b8500b..069b85f 100644 --- a/src/app/shared/ui/dialog/dialog.component.scss +++ b/src/app/shared/ui/dialog/dialog.component.scss @@ -11,6 +11,7 @@ } .app-dialog-panel { + position: relative; width: 100%; max-height: 90vh; overflow-y: auto; diff --git a/src/app/shared/ui/dialog/dialog.component.ts b/src/app/shared/ui/dialog/dialog.component.ts index 88032eb..876630d 100644 --- a/src/app/shared/ui/dialog/dialog.component.ts +++ b/src/app/shared/ui/dialog/dialog.component.ts @@ -15,7 +15,7 @@ import { TranslatePipe } from '../../../i18n/translate.pipe'; export type DialogSize = 'sm' | 'md' | 'lg'; 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({ selector: 'app-dialog', @@ -28,7 +28,14 @@ const FOCUSABLE_SELECTOR = export class DialogComponent implements OnChanges, AfterViewInit { readonly open = input(false); readonly titleText = input(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(null); readonly size = input('md'); + // Both default true (existing behavior for every current consumer). + readonly closeOnEscape = input(true); + readonly closeOnBackdropClick = input(true); readonly closed = output(); @@ -60,7 +67,9 @@ export class DialogComponent implements OnChanges, AfterViewInit { return; } if (event.key === 'Escape') { - this.requestClose(); + if (this.closeOnEscape()) { + this.requestClose(); + } return; } if (event.key === 'Tab') { @@ -73,7 +82,9 @@ export class DialogComponent implements OnChanges, AfterViewInit { } protected handleBackdropClick(): void { - this.requestClose(); + if (this.closeOnBackdropClick()) { + this.requestClose(); + } } private focusPanel(): void { diff --git a/src/app/shared/ui/icon/icon-registry.ts b/src/app/shared/ui/icon/icon-registry.ts index 1acc8fe..d09ef04 100644 --- a/src/app/shared/ui/icon/icon-registry.ts +++ b/src/app/shared/ui/icon/icon-registry.ts @@ -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). - * One entry per concept so every screen renders the same icon for the same meaning - * (RC UI Sprint - Icon & Visual Language Audit). Extend this list rather than - * importing Lucide icons ad hoc in individual components. + * Canonical name -> raw Lucide icon node data map for the whole app + * (storefront + builder + backoffice). One entry per concept so every screen + * renders the same icon for the same meaning (RC UI Sprint - Icon & Visual + * 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>]; + export const APP_ICONS = { - home: Home, - store: Store, - cart: ShoppingCart, - package: Package, - boxes: Boxes, - folder: Folder, - tag: Tag, - tags: Tags, - users: Users, - user: User, - settings: Settings, - chartBar: BarChart3, - chartLine: LineChart, - search: Search, - filter: Filter, - heart: Heart, - scale: Scale, - bell: Bell, - image: Image, - images: Images, - palette: Palette, - monitor: Monitor, - shield: Shield, - lock: Lock, - database: Database, - upload: Upload, - download: Download, - edit: Pencil, - trash: Trash2, - copy: Copy, - save: Save, - plus: Plus, - plusCircle: PlusCircle, - minus: Minus, - refresh: RefreshCw, - check: Check, - checkCircle: CheckCircle2, - x: X, - xCircle: XCircle, - arrowUp: ArrowUp, - arrowDown: ArrowDown, - arrowLeft: ArrowLeft, - arrowRight: ArrowRight, - chevronUp: ChevronUp, - chevronDown: ChevronDown, - chevronLeft: ChevronLeft, - chevronRight: ChevronRight, - info: Info, - help: HelpCircle, - warning: TriangleAlert, - clock: Clock, - calendar: Calendar, - menu: Menu, - alignLeft: AlignLeft, - book: Book, - building: Building2, - code: Code2, - compass: Compass, - creditCard: CreditCard, - flag: Flag, - globe: Globe, - grip: GripVertical, - history: History, - link: Link, - list: List, - locate: Locate, - mapPin: MapPin, - megaphone: Megaphone, - logOut: LogOut, - network: Network, - slidersHorizontal: SlidersHorizontal, - spinner: Loader2, - star: Star, - stop: Square, - table: Table, - layoutGrid: LayoutGrid, - thumbsUp: ThumbsUp, - thumbsDown: ThumbsDown, - verified: BadgeCheck, - video: Video, - circle: Circle, - zap: Zap, - trendingUp: TrendingUp -} as const; + home: [ + ["path", { "d": "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" }], + ["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" }] + ], + store: [ + ["path", { "d": "M15 21v-5a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v5" }], + ["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" }], + ["path", { "d": "M4 10.95V19a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8.05" }] + ], + cart: [ + ["circle", { "cx": "8", "cy": "21", "r": "1" }], + ["circle", { "cx": "19", "cy": "21", "r": "1" }], + ["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" }] + ], + package: [ + ["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" }], + ["path", { "d": "M12 22V12" }], + ["polyline", { "points": "3.29 7 12 12 20.71 7" }], + ["path", { "d": "m7.5 4.27 9 5.15" }] + ], + boxes: [ + ["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" }], + ["path", { "d": "m7 16.5-4.74-2.85" }], + ["path", { "d": "m7 16.5 5-3" }], + ["path", { "d": "M7 16.5v5.17" }], + ["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" }], + ["path", { "d": "m17 16.5-5-3" }], + ["path", { "d": "m17 16.5 4.74-2.85" }], + ["path", { "d": "M17 16.5v5.17" }], + ["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" }], + ["path", { "d": "M12 8 7.26 5.15" }], + ["path", { "d": "m12 8 4.74-2.85" }], + ["path", { "d": "M12 13.5V8" }] + ], + folder: [ + ["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" }] + ], + tag: [ + ["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" }], + ["circle", { "cx": "7.5", "cy": "7.5", "r": ".5", "fill": "currentColor" }] + ], + tags: [ + ["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" }], + ["path", { "d": "M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193" }], + ["circle", { "cx": "10.5", "cy": "6.5", "r": ".5", "fill": "currentColor" }] + ], + users: [ + ["path", { "d": "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" }], + ["path", { "d": "M16 3.128a4 4 0 0 1 0 7.744" }], + ["path", { "d": "M22 21v-2a4 4 0 0 0-3-3.87" }], + ["circle", { "cx": "9", "cy": "7", "r": "4" }] + ], + user: [ + ["path", { "d": "M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" }], + ["circle", { "cx": "12", "cy": "7", "r": "4" }] + ], + settings: [ + ["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" }], + ["circle", { "cx": "12", "cy": "12", "r": "3" }] + ], + chartBar: [ + ["path", { "d": "M3 3v16a2 2 0 0 0 2 2h16" }], + ["path", { "d": "M18 17V9" }], + ["path", { "d": "M13 17V5" }], + ["path", { "d": "M8 17v-3" }] + ], + chartLine: [ + ["path", { "d": "M3 3v16a2 2 0 0 0 2 2h16" }], + ["path", { "d": "m19 9-5 5-4-4-3 3" }] + ], + search: [ + ["path", { "d": "m21 21-4.34-4.34" }], + ["circle", { "cx": "11", "cy": "11", "r": "8" }] + ], + filter: [ + ["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" }] + ], + heart: [ + ["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" }] + ], + scale: [ + ["path", { "d": "M12 3v18" }], + ["path", { "d": "m19 8 3 8a5 5 0 0 1-6 0zV7" }], + ["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; export type AppIconName = keyof typeof APP_ICONS; diff --git a/src/app/shared/ui/icon/icon.component.ts b/src/app/shared/ui/icon/icon.component.ts index 698ad6f..4ce2119 100644 --- a/src/app/shared/ui/icon/icon.component.ts +++ b/src/app/shared/ui/icon/icon.component.ts @@ -1,12 +1,16 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; -import { LucideDynamicIcon } from '@lucide/angular'; -import { APP_ICONS, type AppIconName } from './icon-registry'; +import { APP_ICONS, type AppIconName, type LucideIconNode } from './icon-registry'; /** * 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 * 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 * 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. @@ -14,18 +18,59 @@ import { APP_ICONS, type AppIconName } from './icon-registry'; @Component({ selector: 'app-icon', standalone: true, - imports: [LucideDynamicIcon], template: ` + > + @if (ariaLabel(); as label) { + {{ label }} + } + @for (node of nodes(); track $index) { + @let attrs = node[1]; + @switch (node[0]) { + @case ('path') { + + } + @case ('line') { + + } + @case ('polygon') { + + } + @case ('polyline') { + + } + @case ('circle') { + + } + @case ('ellipse') { + + } + @case ('rect') { + + } + } + } + `, changeDetection: ChangeDetectionStrategy.OnPush, host: { @@ -40,5 +85,10 @@ export class IconComponent { readonly strokeWidth = input(2); readonly ariaLabel = input(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[] + ); } diff --git a/src/app/shared/ui/table/table.component.scss b/src/app/shared/ui/table/table.component.scss index d7f7e08..ca6ec68 100644 --- a/src/app/shared/ui/table/table.component.scss +++ b/src/app/shared/ui/table/table.component.scss @@ -50,3 +50,9 @@ app-table { background: var(--bg-secondary, #f4f4f5); } } + +.admin-density-compact .app-table { + th, td { + padding: var(--space-xs, 0.25rem) var(--space-md, 1rem); + } +} diff --git a/src/app/widgets/registry/widget-manifest.service.ts b/src/app/widgets/registry/widget-manifest.service.ts index e040bd0..33ffd5d 100644 --- a/src/app/widgets/registry/widget-manifest.service.ts +++ b/src/app/widgets/registry/widget-manifest.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable, catchError, map, of, shareReplay, switchMap, take } from 'rxjs'; +import { Observable, catchError, map, of, shareReplay, switchMap, take, tap } from 'rxjs'; import { WidgetManifestEntry, WidgetManifestFile } from '../contracts/widget-manifest.contract'; import { ConfigService } from '../../core/config/config.service'; @@ -8,6 +8,8 @@ import { ConfigService } from '../../core/config/config.service'; export class WidgetManifestService { private readonly fallbackManifestUrl = '/assets/mock/bootstrap/widget-manifest.json'; private readonly manifestByUrl = new Map>(); + /** Last manifest resolved by getManifest(), for synchronous readers (e.g. ProjectValidator) that can't await an Observable. Mirrors ConfigService.getBootstrapSnapshot(). */ + private manifestSnapshot: WidgetManifestFile | null = null; constructor( private readonly http: HttpClient, @@ -23,6 +25,7 @@ export class WidgetManifestService { } const manifest$ = this.http.get(manifestUrl).pipe( + tap(manifest => { this.manifestSnapshot = manifest; }), shareReplay({ bufferSize: 1, refCount: true }), catchError(() => of({ widgets: [] })) ); @@ -41,6 +44,11 @@ export class WidgetManifestService { return this.getWidgets().pipe(map((widgets) => widgets.find((widget) => widget.type === type))); } + /** Synchronous accessor for the last-resolved manifest, or null before it's loaded once. */ + getManifestSnapshot(): WidgetManifestFile | null { + return this.manifestSnapshot; + } + private resolveManifestUrl(): Observable { const snapshotUrl = this.configService.getBootstrapSnapshot()?.widgetRegistry?.manifestUrl; if (snapshotUrl) { diff --git a/src/app/widgets/ui/hero-widget.component.spec.ts b/src/app/widgets/ui/hero-widget.component.spec.ts new file mode 100644 index 0000000..ae3c89f --- /dev/null +++ b/src/app/widgets/ui/hero-widget.component.spec.ts @@ -0,0 +1,52 @@ +import { TestBed } from '@angular/core/testing'; +import { SectionConfig } from '../../shared/models/config'; +import { HeroWidgetData } from '../contracts/widget-data.contract'; +import { HeroWidgetComponent } from './hero-widget.component'; + +function makeSection(columns?: number): SectionConfig { + return { id: 's1', type: 'hero', order: 0, layout: { columns }, widgets: [] } as unknown as SectionConfig; +} + +function makeData(): HeroWidgetData { + return { + title: 'First slide', + subtitle: 'sub', + ctaLabel: 'Shop now', + autoplay: false, + slides: [{ title: 'Second slide' }], + } as unknown as HeroWidgetData; +} + +describe('HeroWidgetComponent panel count regression (layout.columns)', () => { + it('shows 1 panel when layout.columns is 1 (or unset)', () => { + const fixture = TestBed.createComponent(HeroWidgetComponent); + fixture.componentInstance.section = makeSection(1); + fixture.componentInstance.data = makeData(); + fixture.componentInstance.ngOnChanges({ data: {} as any }); + fixture.detectChanges(); + + expect(fixture.componentInstance.panelCount).toBe(1); + expect(fixture.componentInstance.visibleSlides().length).toBe(1); + }); + + it('shows 2 panels when layout.columns is 2 and more than one slide exists', () => { + const fixture = TestBed.createComponent(HeroWidgetComponent); + fixture.componentInstance.section = makeSection(2); + fixture.componentInstance.data = makeData(); + fixture.componentInstance.ngOnChanges({ data: {} as any }); + fixture.detectChanges(); + + expect(fixture.componentInstance.panelCount).toBe(2); + expect(fixture.componentInstance.visibleSlides().length).toBe(2); + }); + + it('falls back to 1 panel when columns is 2 but there is only a single slide', () => { + const fixture = TestBed.createComponent(HeroWidgetComponent); + fixture.componentInstance.section = makeSection(2); + fixture.componentInstance.data = { title: 'Only slide', autoplay: false } as unknown as HeroWidgetData; + fixture.componentInstance.ngOnChanges({ data: {} as any }); + fixture.detectChanges(); + + expect(fixture.componentInstance.panelCount).toBe(1); + }); +}); diff --git a/src/app/widgets/ui/hero-widget.component.ts b/src/app/widgets/ui/hero-widget.component.ts index 3fb10eb..72aa132 100644 --- a/src/app/widgets/ui/hero-widget.component.ts +++ b/src/app/widgets/ui/hero-widget.component.ts @@ -5,6 +5,7 @@ import { HeroSlideData, HeroWidgetData } from '../contracts/widget-data.contract import { TranslatePipe } from '../../i18n/translate.pipe'; const AUTOPLAY_INTERVAL_MS = 5000; +const SWIPE_THRESHOLD_PX = 50; @Component({ selector: 'app-hero-widget', @@ -12,14 +13,40 @@ const AUTOPLAY_INTERVAL_MS = 5000; imports: [CommonModule, TranslatePipe], template: `
    - @if (activeSlide(); as slide) { -

    {{ slide.title }}

    - @if (slide.subtitle) { -

    {{ slide.subtitle }}

    - } - @if (slide.ctaLabel) { - - } + @if (visibleSlides().length) { +
    + @if (allSlides().length > 1) { + + } + +
    + @for (slide of visibleSlides(); track $index) { +
    +

    {{ slide.title }}

    + @if (slide.subtitle) { +

    {{ slide.subtitle }}

    + } + @if (slide.ctaLabel) { + + } +
    + } +
    + + @if (allSlides().length > 1) { + + } +
    @if (allSlides().length > 1) {
    @@ -49,6 +76,40 @@ const AUTOPLAY_INTERVAL_MS = 5000; 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 { margin: 0 0 var(--space-sm, 8px); font-size: 2rem; @@ -140,6 +201,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy { readonly activeIndex = signal(0); private readonly dataSignal = signal(null); private autoplayHandle: ReturnType | null = null; + private swipeStartX: number | null = null; readonly allSlides = computed(() => { const current = this.dataSignal(); @@ -152,6 +214,25 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy { readonly activeSlide = computed(() => 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 { if (changes['data']) { this.dataSignal.set(this.data); @@ -169,6 +250,41 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy { 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 { this.ctaClicked.emit(); } diff --git a/src/app/widgets/ui/product-carousel-widget.component.spec.ts b/src/app/widgets/ui/product-carousel-widget.component.spec.ts new file mode 100644 index 0000000..4c1b71c --- /dev/null +++ b/src/app/widgets/ui/product-carousel-widget.component.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { SectionConfig } from '../../shared/models/config'; +import { ProductCollectionWidgetData } from '../contracts/widget-data.contract'; +import { ProductCarouselWidgetComponent } from './product-carousel-widget.component'; + +function makeSection(columns?: number): SectionConfig { + return { id: 's1', type: 'product-carousel', order: 0, layout: { columns }, widgets: [] } as unknown as SectionConfig; +} + +describe('ProductCarouselWidgetComponent sizing regression (layout.columns)', () => { + it('defaults to 4 items per page when layout.columns is unset', () => { + const fixture = TestBed.createComponent(ProductCarouselWidgetComponent); + fixture.componentInstance.section = makeSection(undefined); + fixture.detectChanges(); + + expect(fixture.componentInstance.itemsPerPage()).toBe(4); + }); + + it('reflects layout.columns when set', () => { + const fixture = TestBed.createComponent(ProductCarouselWidgetComponent); + fixture.componentInstance.section = makeSection(3); + fixture.detectChanges(); + + expect(fixture.componentInstance.itemsPerPage()).toBe(3); + }); + + it('applies the resolved value as the --items-per-page CSS custom property', () => { + const fixture = TestBed.createComponent(ProductCarouselWidgetComponent); + fixture.componentInstance.section = makeSection(2); + fixture.detectChanges(); + + const host = (fixture.nativeElement as HTMLElement).querySelector('.product-carousel-widget') as HTMLElement; + expect(host.style.getPropertyValue('--items-per-page').trim()).toBe('2'); + }); +}); diff --git a/src/app/widgets/ui/product-carousel-widget.component.ts b/src/app/widgets/ui/product-carousel-widget.component.ts index 353e035..4cdd37e 100644 --- a/src/app/widgets/ui/product-carousel-widget.component.ts +++ b/src/app/widgets/ui/product-carousel-widget.component.ts @@ -10,7 +10,7 @@ import { TranslatePipe } from '../../i18n/translate.pipe'; standalone: true, imports: [CommonModule, CatalogProductGridComponent, TranslatePipe], template: ` -