Compare commits
9 Commits
65c6d6f5d1
...
5d47101714
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d47101714 | ||
|
|
7a2f2a452f | ||
|
|
6d075fc5b9 | ||
|
|
a95ca37a4b | ||
|
|
ce63931bc2 | ||
|
|
6f9401fa8f | ||
|
|
55b379bd6d | ||
|
|
3e3185cb6e | ||
|
|
48bcffa22c |
@@ -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",
|
||||
|
||||
68
docs/DEAD-CONFIG-AUDIT.md
Normal file
68
docs/DEAD-CONFIG-AUDIT.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Dead-Config Audit (Sprint G)
|
||||
|
||||
Mechanical sweep of every field in `BootstrapConfig` and its sub-models
|
||||
(`src/app/shared/models/config/*.model.ts`), cross-referenced against
|
||||
`src/app/features/project-editor/schema/editor-schema.ts` (`SECTION_FIELD_SCHEMAS`)
|
||||
to find fields that are editable in the Project Editor but have no real runtime
|
||||
consumer — the same bug class as `HeaderConfig.showProfile` and `layout.columns`
|
||||
(both fixed earlier this cycle). Non-editable fields are listed for completeness
|
||||
but were not a priority (nothing in the editor lets a client set them, so there's
|
||||
no ghost-setting UX to fix).
|
||||
|
||||
Status legend: **live** (read, has effect) / **dead** (never read outside the
|
||||
editor) / **inert** (read, but the effect is unreachable or a stub) / **n/a**
|
||||
(not client-editable today, lower priority per sprint scope).
|
||||
|
||||
## Editable fields (client-facing — checked first)
|
||||
|
||||
| Field | Status | Recommendation | Outcome |
|
||||
|---|---|---|---|
|
||||
| `header.show*` (8 flags) | live | none | `header.component.html` reads every one |
|
||||
| `theme.palette.*` (12 colors) | live | none | `theme-css-vars.mapper.ts` |
|
||||
| `theme.mode` | inert | needs decision | already tracked in `PRODUCT_BACKLOG.md` |
|
||||
| `layout.type` ("Site Layout") | **dead** | needs decision | see below — not fixed this pass |
|
||||
| `branding.brandName/logoUrl/logoCompactUrl/faviconUrl` | live | none | header/footer/meta consumers |
|
||||
| `seo.default.title/description` | live | none | `seo.service.ts` |
|
||||
| `localization.defaultLocale/supportedLocales` | live | none | language switching |
|
||||
| `tenant.host/websiteBaseUrl` | inert by design | none | frontend never resolves its own tenant (ADR-001) — this is backend routing metadata, not something the SPA is meant to read back |
|
||||
| `company.companyName` | **dead** | needs decision | see below — not fixed this pass |
|
||||
| `company.address.street` | **dead → fixed** | wire | now shown in footer bottom bar |
|
||||
| `company.contacts.phone` | **dead → fixed** | wire | now shown in footer bottom bar (`tel:` link) |
|
||||
| `company.contacts.email` | live | none | `ui-runtime.facade.ts` fallback chain |
|
||||
| `footer.copyrightText/paymentIcons/socialLinks/columns` | live | none | `footer-resolver.service.ts` |
|
||||
| `footer.logoUrl` | **dead → fixed** | wire | `LogoComponent` gained `srcOverride`, footer passes it |
|
||||
| `catalog.navigationMode` | inert (deliberate placeholder) | leave as-is | renders a labeled placeholder card + `catalog.navigationPlaceholder` i18n string; the alternate nav UIs (mega-menu, top-carousel, left-nav) don't exist yet — building them is a real feature, not a wiring fix |
|
||||
| `catalog.suggestionsEnabled` | **dead → fixed** | wire | `SearchFacade.autocomplete()` now short-circuits to no suggestions when false |
|
||||
| `catalog.searchHistoryEnabled` | live | none | `catalog-container.component.ts` |
|
||||
| `productPage.questions.*` | live | none | `product-details-container.component.ts` |
|
||||
| `userExperience.recentlyViewed.enabled` | live | none | multiple consumers |
|
||||
| `navigation.header` | **dead** | needs decision | see below — not fixed this pass |
|
||||
| `navigation.footer` | live | none | `footer-resolver.service.ts` fallback tier |
|
||||
| `pages` / `staticPages` | live | none | core rendering pipeline |
|
||||
|
||||
## Non-editable fields (lower priority — `n/a`)
|
||||
|
||||
`branding.legalName/slogan/supportPhone/appIconUrl/galleryUrls`,
|
||||
`company.registrationNumber/taxId`, `tenant.defaultCurrency/supportedCurrencies/timezone`,
|
||||
`featureFlags.blog/chat/coupons/loyalty/giftCards/invoices`,
|
||||
`features.brands/manufacturers`, `permissions.definitions/roles` (used elsewhere,
|
||||
not via this config path), `userExperience.recentlyViewed.widgetEnabled`,
|
||||
`catalog.showBreadcrumbs/showCategoryBanner/showSubcategoryChips/enabledFilters/availableSorts/defaultSort`
|
||||
— none of these have an editor control today, so no client can create a false
|
||||
expectation by setting them. Flagged here for completeness; no action taken.
|
||||
|
||||
## Fixed this pass (trivially wireable)
|
||||
|
||||
1. **`footer.logoUrl`** — `LogoComponent` (`src/app/components/logo/logo.component.ts`) gained an optional `srcOverride` input; `FooterResolverService`/`FooterComponent` now resolve and pass `footer.logoUrl`, falling back to the brand logo exactly as before when unset.
|
||||
2. **`company.address.street` / `company.contacts.phone`** — `UiRuntimeFacade` gained `contactPhone()`/`companyAddress()` (same fallback pattern as the existing `contactEmail()`); footer bottom bar now renders a `tel:` link and the address next to the existing email link when present.
|
||||
3. **`catalog.suggestionsEnabled`** — `SearchFacade.autocomplete()` now reads the bootstrap snapshot and returns no suggestions when the flag is `false`, instead of always running autocomplete regardless of the toggle.
|
||||
|
||||
## Left dead, tracked (needs a decision, not a mechanical fix)
|
||||
|
||||
- **`layout.type`** ("Site Layout" selector, Theme section) — top-level `BootstrapConfig.layout` is edited but never applied to any page; page layout comes entirely from each `PageConfig.layout` (see `SectionEngineService.resolveLayoutType`), which this global selector doesn't touch. Wiring it requires deciding *which* page(s) it should drive (homepage only? every page without its own override?) — a product decision, not a mechanical fix. Tracked in `docs/PRODUCT_BACKLOG.md`.
|
||||
- **`company.companyName`** — Footer editor has a "Company Name" field with zero runtime consumers. The footer already has a copyright fallback (`© {year} {brandName}`, `footer.component.html`) using `branding.brandName`, not `company.companyName` — these are meant to be distinct (brand vs. legal entity name), so blindly reusing one for the other would be a content decision, not a safe mechanical fix. Tracked in `docs/PRODUCT_BACKLOG.md`.
|
||||
- **`navigation.header`** — editable list of header nav items in the Navigation section, but `HeaderComponent` never reads `NavigationConfig.header` at all; the header's own category menu comes from `CategoryFacade`, not this list. Rendering an actual configurable top-nav (positioning, active-state, children/dropdowns) is real feature work, not a one-line wire. Tracked in `docs/KNOWN-ISSUES.md`.
|
||||
|
||||
## Not touched
|
||||
|
||||
`theme.mode` (dark mode) stays exactly as already tracked in `docs/PRODUCT_BACKLOG.md` — no new information found, confirmed still inert.
|
||||
@@ -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)".
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
76
docs/GLOBAL-SPRINT-PLAN.md
Normal file
76
docs/GLOBAL-SPRINT-PLAN.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Global Sprint Plan — "Coming Soon" Stub Closure
|
||||
|
||||
Supersedes `docs/COMING-SOON-AUDIT.md` §5 sprint breakdown. One consolidated tracker for the four stub-closure sprints. Approved decisions (from AskUserQuestion): Reports/Settings ship as minimal real pages (not fake data, not empty shells); Documentation/Help nav uses an external-link approach; `docs/COMING-SOON-AUDIT.md` is deleted once all sprints land, folded into `docs/KNOWN-ISSUES.md`. Profile control constraint: **login/logout only — no dropdown, no account links.**
|
||||
|
||||
## Sprint A — Profile menu (storefront header)
|
||||
|
||||
- [x] i18n: `header.login` / `header.logout` keys in en/ru/hy (`translations.ts` type already updated)
|
||||
- [x] `header.component.ts`: inject `AuthService`, expose `isAuthenticated`, add `login()`/`logout()`
|
||||
- [x] `header.component.ts`: import `TelegramLoginComponent`
|
||||
- [x] `header.component.html`: profile control gated by `headerConfig().showProfile`, login/logout only, `<app-telegram-login />` rendered once
|
||||
- [x] SCSS matches existing header button conventions (reused `.platform-ux-btn`, no new SCSS needed)
|
||||
|
||||
**What shipped:** Header profile control wired to the customer `AuthService` (Telegram QR login). Gated by `headerConfig().showProfile` (already a real toggle in Project Editor, previously dead). Logged-out shows a login button (`user` icon), logged-in shows a logout button (`logOut` icon) — no dropdown, no account links, per the explicit constraint.
|
||||
|
||||
## Sprint B — Admin Reports page
|
||||
|
||||
- [x] `admin-reports-page.component.ts/.html/.scss` (mirrors `admin-analytics-page` structure), reuses `AdminAnalyticsFacade`
|
||||
- [x] Report cards: Sales, Top Products, Marketplace Health
|
||||
- [x] CSV export wired to existing facade export methods / existing download helper (same Blob pattern as `admin-analytics-page.component.ts`)
|
||||
- [x] Route `backoffice/reports` in `app.routes.ts`, i18n keys `adminShell.pages.reports.*` + new `adminReports.*` block
|
||||
- [x] Remove `comingSoon: true` from `reports` nav entry
|
||||
|
||||
**What shipped:** Minimal real Reports page with 3 cards (Sales, Top Products, Marketplace Health), each showing a live summary from `AdminAnalyticsFacade` and a CSV export button. Orders card was scoped out — see final report for why (reuse would require mutating a shared singleton facade's pagination state).
|
||||
|
||||
## Sprint C — Admin Settings page
|
||||
|
||||
- [x] `AdminPreferencesService` (density signal, localStorage-backed, key `adminPreferences.density.v1`)
|
||||
- [x] `admin-layout.component` applies `admin-density-compact` class to `#admin-content` shell wrapper
|
||||
- [x] `admin-settings-page.component.ts/.html/.scss` — density toggle (`app-toggle`), auto-persists on change, no separate Save button
|
||||
- [x] Route `backoffice/settings`, i18n keys `adminShell.pages.settings.*` + `adminSettings.*` block
|
||||
- [x] Remove `comingSoon: true` from nav entry AND dashboard shortcut; shortcut route → `['backoffice','settings']`
|
||||
- [x] Compact-density CSS rule added to the shared `app-table` component stylesheet (`.admin-density-compact .app-table th/td`) — applies to every admin list page built on `app-table` (orders, products, categories, etc.), not just one
|
||||
|
||||
**What shipped:** Genuinely real, backend-independent UI density preference. No maintenance-mode toggle built (explicitly deferred per `docs/NEXT_PHASE.md` Phase 4).
|
||||
|
||||
## Sprint D — Documentation / Help nav
|
||||
|
||||
- [x] Help: `mailto:` using existing `supportEmail` read path (`UiRuntimeFacade.contactEmail()`, same one `header.component.ts` already uses for `bootstrap.branding.supportEmail`)
|
||||
- [x] `AdminNavLink` gains optional `externalHref?: string`; nav renderer renders `<a>` branch (bottom nav)
|
||||
- [x] Documentation: added `tenant.documentationUrl?: string` to `TenantConfig`, populated mock with `https://docs.marketplace.local`
|
||||
- [x] `help`/`documentation` resolved dynamically in `admin-layout.component.ts` (`navBottom` computed) — real `<a>` when bootstrap data present, static `comingSoon: true` entries kept as defensive fallback for the (currently unreachable, since mock always has both fields) case where the backend omits them
|
||||
|
||||
**What shipped:** Both Help and Documentation wired to real external links, not just Help. `comingSoon: true` remains in `admin-nav.model.ts` source as a fallback flag only — it is overridden to `false` at render time whenever bootstrap actually has the data, which it does today.
|
||||
|
||||
## Sprint E — Widget layout config correctness (manifest-aware editor)
|
||||
|
||||
Root cause confirmed 2026-08-05: `widget-manifest.json` already declares `supportedLayouts` per widget type (`hero`→`[hero, split]`, `categories`→`[grid]`, `product-collection`→`[carousel, grid]`), but `homepage-section.component.ts`'s `layoutStrategyPickerOptions` is a static 5-option list (`stack/grid/hero/carousel/split`) shown identically for every homepage section regardless of which widget backs it — it never reads the manifest. The `columns` field (`homepage-section.component.html:49`) is shown for every section too, but **no widget component reads `layout.columns`** — it is currently dead everywhere.
|
||||
|
||||
- [x] `homepage-section.component.ts`: resolve each section's widget type (via its bound widget id → `widget-registry`/manifest lookup) and filter `layoutStrategyPickerOptions` down to that widget's `supportedLayouts` before rendering the picker
|
||||
- [x] Hide/disable the `columns` field for any section whose resolved widget doesn't consume it (only `product-collection` and, after Sprint F, `hero` will)
|
||||
- [x] No behavior change for widgets that already worked (categories/recently-viewed/footer-nav keep their single valid layout, picker just stops offering the other 4 nonsensically)
|
||||
|
||||
**What shipped:** `homepage-section.component.ts` now injects `WidgetManifestService`, resolves each section's manifest entry directly by `section.type` (confirmed identical to the manifest `type` key — no separate widget-id lookup needed), and derives `layoutOptionsFor(section)` by filtering the static option list down to that entry's `supportedLayouts`. A stale/unsupported saved `strategy` value is appended back into the options list rather than dropped, so `app-visual-layout-picker` never renders with no active card. `showColumnsFor(section)` gates the `columns` field to the two componentKeys that actually read it (`hero` always, `product-collection` only in `carousel` strategy — grid mode ignores it). One correction to the plan's assumption: `recently-viewed`'s actual manifest entry declares `supportedLayouts: ["stack", "grid", "carousel"]` (3 options, not 1) — the picker now correctly reflects that per the manifest rather than the plan's guess.
|
||||
|
||||
## Sprint F — Carousel items-per-page (closes the client bug report)
|
||||
|
||||
Confirmed real, reported by a client, not fixed anywhere: neither carousel widget has an "items/slides per page" concept. Design: reuse the existing (currently dead) `layout.columns` field rather than inventing a new one — it is already editable in the Homepage section editor once Sprint E gates it to the right widgets.
|
||||
|
||||
- [x] `ProductCarouselWidgetComponent`: read `section.layout.columns` (default 4, min 1) to size `.catalog-product-shell` width as a fraction of the scroller instead of the hardcoded `220px` — gives real "items per page" control, arrows/scroll logic unchanged (already works)
|
||||
- [x] `HeroWidgetComponent`: add manual prev/next arrows (parity with the product carousel's arrow buttons) in addition to the existing dots — closes "not scrollable manually"
|
||||
- [x] `HeroWidgetComponent`: add swipe/drag (pointer events) support for touch — closes "not scrollable manually" on mobile
|
||||
- [x] `HeroWidgetComponent`: support `layout.columns` = 1 or 2 to show one or two slide panels at once ("big carousel one or two slides per page") — 2-panel mode shows the active slide plus the next one side by side
|
||||
- [x] Verify autoplay (`props.autoplay`, already exists, editor toggle already exists per `widgets-section.component.html:61`) still functions correctly alongside the new manual controls (manual interaction should not fight the autoplay timer — reset/pause timer on manual nav, matching common carousel UX)
|
||||
- [x] i18n: any new aria-labels for the new hero arrows (reuse `common.previousProducts`/`common.nextProducts` keys if wording fits, or add `common.previousSlide`/`common.nextSlide`)
|
||||
|
||||
**What shipped:** `ProductCarouselWidgetComponent` sets `--items-per-page` as a CSS custom property (`[style.--items-per-page]`) driven by `itemsPerPage()` (default 4, min 1, floored), and `.catalog-product-shell` width is now `calc((100% - (var(--items-per-page, 4) - 1) * var(--space-md, 16px)) / var(--items-per-page, 4))` instead of a fixed `220px`. `HeroWidgetComponent` gained prev/next arrow buttons (same circular/bordered visual language as the product carousel's arrows), touch-event swipe (same threshold-based approach as `cart.component.ts`'s `onSwipeStart`, 50px threshold, left swipe = next, right swipe = prev), and 2-panel support via `layout.columns` (defaults to 1; `columns === 2` shows the active slide plus the next one side by side, falling back to 1 panel when there's only one slide total). All manual navigation (arrows, swipe, dots) routes through the existing `goTo()`, which already clears+restarts the autoplay timer, so no duplicate timer logic was needed. New i18n keys `common.previousSlide` / `common.nextSlide` added to `translations.ts`, `en.ts`, `ru.ts`, `hy.ts`.
|
||||
|
||||
Verification: `npx tsc --noEmit` and `npx ng build --configuration=development` both clean. Visually verified in the browser preview (`ng serve` on port 4200) by temporarily patching the embedded home-page sections in `src/assets/mock/bootstrap/bootstrap.json` (the actual runtime source for `/` — `src/assets/mock/bootstrap/homepage.json` is a separate, unused-by-this-route file) to `columns: 2` + a second slide for hero and `columns: 3` for the product carousel, confirming via DOM/computed-style inspection: hero rendered 2 slide panels with 2 working arrows, arrow clicks and simulated touch swipe both advanced/reversed the active dot correctly, and the carousel's `--items-per-page` CSS var read `3` with each `.catalog-product-shell` measuring ~348px (vs. the fixed 1110px/220px before). All temporary mock-data edits were reverted afterward (`git checkout`) — `bootstrap.json` and `homepage.json` are unchanged in the final diff. The Sprint E manifest-aware picker itself could only be verified by code inspection, not live in the browser — `/edit/:section` requires Telegram admin login, which cannot be completed in this environment.
|
||||
|
||||
## Housekeeping
|
||||
|
||||
- [x] Delete `docs/COMING-SOON-AUDIT.md`
|
||||
- [x] Fold summary into `docs/KNOWN-ISSUES.md` "Fixed (this cycle)"; remove the `HeaderConfig.showProfile` dead-toggle entry from "Open"
|
||||
- [x] Update `docs/BACKEND.md` (`tenant.documentationUrl` field added §1.3; no `docs/backend/BACKEND-INTEGRATION.md` exists in this repo)
|
||||
- [x] `npm run barry -- validate` (clean, only pre-existing unrelated warnings)
|
||||
- [x] Typecheck touched files (`tsc --noEmit` + full `ng build` both clean)
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)".
|
||||
|
||||
@@ -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.
|
||||
|
||||
94
docs/SPRINT-PLAN-NEXT.md
Normal file
94
docs/SPRINT-PLAN-NEXT.md
Normal file
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
160
package-lock.json
generated
160
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="app-footer__container page-container">
|
||||
<div class="app-footer__top">
|
||||
<div class="app-footer__brand">
|
||||
<app-logo />
|
||||
<app-logo [srcOverride]="footerLogoUrl()" />
|
||||
<p>{{ 'footer.description' | translate }}</p>
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
@if (contactEmail) {
|
||||
<a [href]="'mailto:' + contactEmail">{{ contactEmail }}</a>
|
||||
}
|
||||
@if (contactPhone) {
|
||||
<a [href]="'tel:' + contactPhone">{{ contactPhone }}</a>
|
||||
}
|
||||
@if (companyAddress) {
|
||||
<span class="app-footer__address">{{ companyAddress }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -21,6 +21,7 @@ export class FooterComponent {
|
||||
readonly footerGroups = signal<FooterResolvedGroup[]>([]);
|
||||
readonly paymentIcons = signal<FooterPaymentIcon[]>([]);
|
||||
readonly copyrightText = signal('');
|
||||
readonly footerLogoUrl = signal<string | undefined>(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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,19 @@
|
||||
</a>
|
||||
}
|
||||
|
||||
<!-- Profile (login/logout only) -->
|
||||
@if (headerConfig().showProfile) {
|
||||
@if (isAuthenticated()) {
|
||||
<button type="button" class="platform-ux-btn" (click)="logout()" [attr.aria-label]="'header.logout' | translate">
|
||||
<app-icon name="logOut" [size]="16" class="platform-ux-icon" />
|
||||
</button>
|
||||
} @else {
|
||||
<button type="button" class="platform-ux-btn" (click)="login()" [attr.aria-label]="'header.login' | translate">
|
||||
<app-icon name="user" [size]="16" class="platform-ux-icon" />
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Region Selector (desktop only) -->
|
||||
@if (headerConfig().showRegion) {
|
||||
<div class="platform-region-selector platform-lang-desktop">
|
||||
@@ -147,4 +160,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-telegram-login />
|
||||
|
||||
|
||||
|
||||
86
src/app/components/header/header.component.spec.ts
Normal file
86
src/app/components/header/header.component.spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { BootstrapConfig } from '../../shared/models/config';
|
||||
import { CONFIG_PROVIDER } from '../../core/config/config-provider.token';
|
||||
import { ConfigService } from '../../core/config/config.service';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { HeaderComponent } from './header.component';
|
||||
|
||||
function makeBootstrap(): BootstrapConfig {
|
||||
return {
|
||||
schemaVersion: '1', generatedAt: new Date().toISOString(),
|
||||
tenant: { id: 't1', slug: 't1', code: 't1', host: 'dexar.market', name: 'Dexar', websiteBaseUrl: 'https://dexar.market', builderBaseUrl: 'https://dexar.market', backofficeBaseUrl: 'https://dexar.market', defaultLocale: 'en', supportedLocales: ['en'], defaultCurrency: 'USD', supportedCurrencies: ['USD'], timezone: 'UTC' },
|
||||
branding: { brandName: 'Dexar', legalName: 'Dexar LLC', logoUrl: 'logo.png', faviconUrl: 'favicon.png' },
|
||||
theme: { themeId: 'default', mode: 'light', palette: {} as any, typography: {} as any, spacing: {} as any, borderRadiusScale: {}, shadows: {}, iconSet: 'default' },
|
||||
company: { companyName: 'Dexar LLC', address: { country: 'US', city: 'NY' }, contacts: { email: 'sales@dexar.market' } },
|
||||
featureFlags: {} as any,
|
||||
apiEndpoints: {} as any,
|
||||
localization: { defaultLocale: 'en', supportedLocales: ['en'], currencyByLocale: {}, dictionaries: [] },
|
||||
seo: { default: { title: 'Dexar', description: 'Dexar' }, byPageKey: {} },
|
||||
permissions: { definitions: [], roles: [] },
|
||||
header: { showLogo: true, showSearch: true, showCategories: true, showLanguages: true, showCart: true, showProfile: true, showWishlist: true, showCompare: true, showRegion: true, sticky: true, layout: 'default' },
|
||||
navigation: { header: [], footer: [] },
|
||||
pages: [],
|
||||
} as unknown as BootstrapConfig;
|
||||
}
|
||||
|
||||
describe('HeaderComponent profile control (login/logout gating regression)', () => {
|
||||
function configure(isAuthenticated: boolean): void {
|
||||
// Full fake - TelegramLoginComponent (rendered inside the profile control) reads
|
||||
// several signals/methods off AuthService directly, not just isAuthenticated.
|
||||
const fakeAuth = {
|
||||
session: () => null,
|
||||
status: () => (isAuthenticated ? 'authenticated' : 'unauthenticated'),
|
||||
isAuthenticated: () => isAuthenticated,
|
||||
showLoginDialog: () => false,
|
||||
displayName: () => null,
|
||||
requestLogin: jasmine.createSpy('requestLogin'),
|
||||
logout: jasmine.createSpy('logout'),
|
||||
hideLogin: jasmine.createSpy('hideLogin'),
|
||||
createWebSession: () => of({ webSessionID: 'x', botLoginUrl: '' }),
|
||||
checkSessionOnce: () => of(null),
|
||||
getTelegramAppLoginUrl: () => '',
|
||||
onTelegramLoginComplete: jasmine.createSpy('onTelegramLoginComplete'),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
{ provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
|
||||
{ provide: AuthService, useValue: fakeAuth },
|
||||
],
|
||||
});
|
||||
|
||||
// Deterministically prime the bootstrap snapshot before component creation -
|
||||
// resolveHeaderConfig() reads getBootstrapSnapshot() synchronously and falls
|
||||
// back to DEFAULT_HEADER_CONFIG (showProfile: false) if it isn't populated yet.
|
||||
TestBed.inject(ConfigService).loadBootstrap().subscribe();
|
||||
}
|
||||
|
||||
it('shows a login button (not logout) when logged out', () => {
|
||||
configure(false);
|
||||
const fixture = TestBed.createComponent(HeaderComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
// Aria-labels render translated text (Russian by default), so assert on the
|
||||
// icon name attribute instead - stable regardless of active language.
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('app-icon[name="user"]')).toBeTruthy();
|
||||
expect(compiled.querySelector('app-icon[name="logOut"]')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('shows a logout button (not login) when logged in - never both', () => {
|
||||
configure(true);
|
||||
const fixture = TestBed.createComponent(HeaderComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
expect(compiled.querySelector('app-icon[name="logOut"]')).toBeTruthy();
|
||||
expect(compiled.querySelector('app-icon[name="user"]')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { AdminAnalyticsFacade } from './admin-analytics.facade';
|
||||
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
|
||||
import { AdminProductsLocalGateway } from '../../products/services/admin-products-local.gateway';
|
||||
import { ADMIN_CATEGORIES_GATEWAY } from '../../categories/services/admin-categories-gateway.token';
|
||||
import { AdminModerationLocalGateway } from '../../moderation/services/admin-moderation-local.gateway';
|
||||
import { AdminDashboardFacade } from '../../dashboard/facade/admin-dashboard.facade';
|
||||
import { AdminOrder } from '../../orders/models/admin-order.model';
|
||||
|
||||
function makeOrder(overrides: Partial<AdminOrder> = {}): AdminOrder {
|
||||
return {
|
||||
id: 'order-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'completed',
|
||||
total: 100,
|
||||
currency: 'RUB',
|
||||
customer: { email: 'buyer@example.com' },
|
||||
items: [{ productId: 'p1', name: 'Widget', quantity: 1, price: 100 }],
|
||||
...overrides,
|
||||
} as unknown as AdminOrder;
|
||||
}
|
||||
|
||||
describe('AdminAnalyticsFacade (never-fabricate-a-number contract)', () => {
|
||||
let facade: AdminAnalyticsFacade;
|
||||
let ordersGateway: jasmine.SpyObj<AdminOrdersLocalGateway>;
|
||||
let productsGateway: jasmine.SpyObj<AdminProductsLocalGateway>;
|
||||
let categoriesGateway: jasmine.SpyObj<{ loadCategories: () => unknown }>;
|
||||
let moderationGateway: jasmine.SpyObj<AdminModerationLocalGateway>;
|
||||
let dashboardFacade: jasmine.SpyObj<AdminDashboardFacade>;
|
||||
|
||||
function configure(bootstrapPresent: boolean): void {
|
||||
ordersGateway = jasmine.createSpyObj('AdminOrdersLocalGateway', ['loadOrders']);
|
||||
productsGateway = jasmine.createSpyObj('AdminProductsLocalGateway', ['loadProducts']);
|
||||
categoriesGateway = jasmine.createSpyObj('ADMIN_CATEGORIES_GATEWAY', ['loadCategories']);
|
||||
moderationGateway = jasmine.createSpyObj('AdminModerationLocalGateway', ['loadReviews']);
|
||||
dashboardFacade = jasmine.createSpyObj('AdminDashboardFacade', [
|
||||
'ensureLoaded', 'activityEntries', 'bootstrap', 'validationIssues', 'enabledWidgetsCount', 'staticPagesUnpublishedCount',
|
||||
]);
|
||||
|
||||
ordersGateway.loadOrders.and.returnValue(of({ items: [makeOrder()], total: 1 } as any));
|
||||
productsGateway.loadProducts.and.returnValue(of({ items: [], total: 0 } as any));
|
||||
categoriesGateway.loadCategories.and.returnValue(of([]));
|
||||
moderationGateway.loadReviews.and.returnValue(of({ items: [], total: 0 } as any));
|
||||
dashboardFacade.activityEntries.and.returnValue([]);
|
||||
dashboardFacade.bootstrap.and.returnValue(bootstrapPresent ? ({ schemaVersion: '1', tenant: { id: 't1' } } as any) : null);
|
||||
dashboardFacade.validationIssues.and.returnValue([]);
|
||||
dashboardFacade.enabledWidgetsCount.and.returnValue(0);
|
||||
dashboardFacade.staticPagesUnpublishedCount.and.returnValue(0);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: AdminOrdersLocalGateway, useValue: ordersGateway },
|
||||
{ provide: AdminProductsLocalGateway, useValue: productsGateway },
|
||||
{ provide: ADMIN_CATEGORIES_GATEWAY, useValue: categoriesGateway },
|
||||
{ provide: AdminModerationLocalGateway, useValue: moderationGateway },
|
||||
{ provide: AdminDashboardFacade, useValue: dashboardFacade },
|
||||
],
|
||||
});
|
||||
|
||||
facade = TestBed.inject(AdminAnalyticsFacade);
|
||||
}
|
||||
|
||||
it('never fabricates conversionRate - stays null even with real order data', () => {
|
||||
configure(true);
|
||||
facade.load();
|
||||
|
||||
expect(facade.summary()?.conversionRate).toBeNull();
|
||||
expect(facade.summary()?.revenueTotal).toBe(100);
|
||||
expect(facade.summary()?.ordersCount).toBe(1);
|
||||
});
|
||||
|
||||
it('never fabricates the "performance" health check - always unknown (no real data source)', () => {
|
||||
configure(true);
|
||||
facade.load();
|
||||
|
||||
const performance = facade.marketplaceHealth().find(check => check.code === 'performance');
|
||||
expect(performance?.status).toBe('unknown');
|
||||
});
|
||||
|
||||
it('reports backend-connectivity and homepage-configured as unknown when bootstrap has not loaded, instead of guessing', () => {
|
||||
configure(false);
|
||||
facade.load();
|
||||
|
||||
const backend = facade.marketplaceHealth().find(check => check.code === 'backend-connectivity');
|
||||
const homepage = facade.marketplaceHealth().find(check => check.code === 'homepage-configured');
|
||||
expect(backend?.status).toBe('unknown');
|
||||
expect(homepage?.status).toBe('unknown');
|
||||
});
|
||||
|
||||
it('reports backend-connectivity as healthy only once bootstrap is actually present', () => {
|
||||
configure(true);
|
||||
facade.load();
|
||||
|
||||
const backend = facade.marketplaceHealth().find(check => check.code === 'backend-connectivity');
|
||||
expect(backend?.status).toBe('healthy');
|
||||
});
|
||||
});
|
||||
@@ -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'] },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<section class="admin-reports-page">
|
||||
@if (facade.loading()) {
|
||||
<div class="report-grid" role="status" aria-live="polite" aria-busy="true">
|
||||
@for (i of [1,2,3]; track i) {
|
||||
<app-skeleton shape="rect" height="140px" />
|
||||
}
|
||||
<span class="sr-only">{{ 'common.loading' | translate }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="report-grid">
|
||||
<div class="report-card">
|
||||
<h2>{{ 'adminReports.sales' | translate }}</h2>
|
||||
@if (facade.summary(); as summary) {
|
||||
<p class="report-summary">{{ summary.revenueTotal }} {{ summary.currency }} · {{ summary.ordersCount }} {{ 'adminAnalytics.orders' | translate }}</p>
|
||||
}
|
||||
<app-button variant="secondary" size="sm" (click)="exportSalesCsv()">{{ 'adminOrders.export' | translate }}</app-button>
|
||||
</div>
|
||||
|
||||
<div class="report-card">
|
||||
<h2>{{ 'adminReports.topProducts' | translate }}</h2>
|
||||
<p class="report-summary">{{ facade.topProducts().length }} {{ 'adminAnalytics.topProducts' | translate }}</p>
|
||||
<app-button variant="secondary" size="sm" (click)="exportTopProductsCsv()">{{ 'adminOrders.export' | translate }}</app-button>
|
||||
</div>
|
||||
|
||||
<div class="report-card">
|
||||
<h2>{{ 'adminReports.marketplaceHealth' | translate }}</h2>
|
||||
<p class="report-summary">{{ facade.healthCompletionPercent() }}% {{ 'adminMarketplaceHealth.complete' | translate }}</p>
|
||||
<app-button variant="secondary" size="sm" (click)="exportHealthCsv()">{{ 'adminOrders.export' | translate }}</app-button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<section class="admin-settings-page">
|
||||
<div class="settings-card">
|
||||
<h2>{{ 'adminSettings.density' | translate }}</h2>
|
||||
<p class="settings-explain">{{ 'adminSettings.densityExplain' | translate }}</p>
|
||||
<label class="toggle-row">
|
||||
<app-toggle
|
||||
[ngModel]="preferences.density() === 'compact'"
|
||||
(ngModelChange)="onCompactToggle($event)"
|
||||
[ariaLabel]="'adminSettings.densityCompact' | translate"
|
||||
/>
|
||||
<span>{{ 'adminSettings.densityCompact' | translate }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
@@ -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); }
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Injectable, Signal, inject, signal } from '@angular/core';
|
||||
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
|
||||
|
||||
export type AdminUiDensity = 'comfortable' | 'compact';
|
||||
|
||||
const DENSITY_KEY = 'adminPreferences.density.v1';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminPreferencesService {
|
||||
private readonly localStorage = inject(LocalStorageService);
|
||||
|
||||
private readonly densitySignal = signal<AdminUiDensity>(this.readStoredDensity());
|
||||
|
||||
readonly density: Signal<AdminUiDensity> = this.densitySignal.asReadonly();
|
||||
|
||||
setDensity(value: AdminUiDensity): void {
|
||||
this.densitySignal.set(value);
|
||||
this.localStorage.setItem(DENSITY_KEY, value);
|
||||
}
|
||||
|
||||
private readStoredDensity(): AdminUiDensity {
|
||||
return this.localStorage.getItem(DENSITY_KEY) === 'compact' ? 'compact' : 'comfortable';
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,20 @@
|
||||
</ul>
|
||||
|
||||
<ul class="admin-layout__nav-list admin-layout__nav-list--bottom">
|
||||
@for (entry of navBottom; track $index) {
|
||||
@if (entry.type === 'link' && entry.comingSoon) {
|
||||
@for (entry of navBottom(); track $index) {
|
||||
@if (entry.type === 'link' && entry.externalHref) {
|
||||
<li>
|
||||
<a
|
||||
class="admin-layout__nav-link"
|
||||
[href]="entry.externalHref"
|
||||
[attr.target]="entry.externalHref.startsWith('mailto:') ? null : '_blank'"
|
||||
[attr.rel]="entry.externalHref.startsWith('mailto:') ? null : 'noopener'"
|
||||
>
|
||||
<app-icon [name]="entry.icon" [size]="18" />
|
||||
<span class="admin-layout__nav-label">{{ entry.labelKey | translate }}</span>
|
||||
</a>
|
||||
</li>
|
||||
} @else if (entry.type === 'link' && entry.comingSoon) {
|
||||
<li>
|
||||
<button type="button" class="admin-layout__nav-link admin-layout__nav-link--disabled" disabled>
|
||||
<app-icon [name]="entry.icon" [size]="18" />
|
||||
@@ -151,7 +163,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="admin-content" class="admin-layout__content" tabindex="-1">
|
||||
<main id="admin-content" class="admin-layout__content" [class.admin-density-compact]="density() === 'compact'" tabindex="-1">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import { LanguageService } from '../../../services/language.service';
|
||||
import { AdminAuthService } from '../../../core/admin-auth/admin-auth.service';
|
||||
import { ADMIN_NAV_BOTTOM, ADMIN_NAV_PRIMARY, AdminBreadcrumbEntry, AdminNavEntry } from './admin-nav.model';
|
||||
import { IconComponent } from '../../../shared/ui/icon/icon.component';
|
||||
import { AdminPreferencesService } from '../settings/services/admin-preferences.service';
|
||||
import { UiRuntimeFacade } from '../../../facades/runtime/ui-runtime.facade';
|
||||
import { ConfigService } from '../../../core/config/config.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-layout',
|
||||
@@ -25,12 +28,35 @@ export class AdminLayoutComponent {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageService = inject(LanguageService);
|
||||
private readonly adminAuth = inject(AdminAuthService);
|
||||
private readonly preferences = inject(AdminPreferencesService);
|
||||
private readonly uiRuntime = inject(UiRuntimeFacade);
|
||||
private readonly configService = inject(ConfigService);
|
||||
|
||||
readonly density = this.preferences.density;
|
||||
|
||||
private readonly drawerEl = viewChild<ElementRef<HTMLElement>>('drawer');
|
||||
private readonly menuToggleEl = viewChild<ElementRef<HTMLElement>>('menuToggle');
|
||||
|
||||
readonly navPrimary: AdminNavEntry[] = ADMIN_NAV_PRIMARY;
|
||||
readonly navBottom: AdminNavEntry[] = ADMIN_NAV_BOTTOM;
|
||||
|
||||
readonly navBottom = computed<AdminNavEntry[]>(() => {
|
||||
this.configService.bootstrapRevision();
|
||||
const supportEmail = this.uiRuntime.contactEmail();
|
||||
const documentationUrl = this.configService.getBootstrapSnapshot()?.tenant?.documentationUrl;
|
||||
|
||||
return ADMIN_NAV_BOTTOM.map(entry => {
|
||||
if (entry.type !== 'link') {
|
||||
return entry;
|
||||
}
|
||||
if (entry.id === 'help' && supportEmail) {
|
||||
return { ...entry, comingSoon: false, externalHref: `mailto:${supportEmail}` };
|
||||
}
|
||||
if (entry.id === 'documentation' && documentationUrl) {
|
||||
return { ...entry, comingSoon: false, externalHref: documentationUrl };
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
});
|
||||
|
||||
readonly mobileDrawerOpen = signal(false);
|
||||
readonly notificationsOpen = signal(false);
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface AdminNavLink {
|
||||
absolutePath?: string[];
|
||||
/** No route exists yet - rendered as a disabled item with a "coming soon" badge. */
|
||||
comingSoon?: boolean;
|
||||
/** External URL (mailto: or https:). Mutually exclusive with path/absolutePath/comingSoon. */
|
||||
externalHref?: string;
|
||||
}
|
||||
|
||||
export interface AdminNavGroup {
|
||||
@@ -36,18 +38,24 @@ export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
|
||||
{ type: 'link', id: 'customers', icon: 'user', labelKey: 'adminShell.nav.customers', path: ['customers'] },
|
||||
{ type: 'link', id: 'transactions', icon: 'creditCard', labelKey: 'adminShell.nav.transactions', path: ['transactions'] },
|
||||
{ type: 'link', id: 'moderation', icon: 'star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] },
|
||||
{ type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', comingSoon: true },
|
||||
{ type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', path: ['reports'] },
|
||||
{ type: 'group', labelKey: 'adminShell.nav.partnersGroup' },
|
||||
{ type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] },
|
||||
{ type: 'link', id: 'content', icon: 'edit', labelKey: 'adminShell.nav.content', absolutePath: ['edit', 'static-pages'] },
|
||||
{ type: 'link', id: 'media', icon: 'images', labelKey: 'adminShell.nav.mediaLibrary', path: ['media'] },
|
||||
{ type: 'link', id: 'marketplace-builder', icon: 'network', labelKey: 'adminShell.nav.marketplaceBuilder', absolutePath: ['edit'] },
|
||||
{ type: 'link', id: 'users', icon: 'users', labelKey: 'adminShell.nav.users', path: ['users'] },
|
||||
{ type: 'link', id: 'settings', icon: 'settings', labelKey: 'adminShell.nav.settings', comingSoon: true },
|
||||
{ type: 'link', id: 'settings', icon: 'settings', labelKey: 'adminShell.nav.settings', path: ['settings'] },
|
||||
{ type: 'link', id: 'monitoring', icon: 'monitor', labelKey: 'adminShell.nav.monitoring', path: ['monitoring'] },
|
||||
{ type: 'link', id: 'analytics', icon: 'chartLine', labelKey: 'adminShell.nav.analytics', path: ['analytics'] },
|
||||
];
|
||||
|
||||
/**
|
||||
* `documentation`/`help` externalHref is resolved at runtime from bootstrap
|
||||
* config (tenant.documentationUrl / branding.supportEmail) — see
|
||||
* `AdminLayoutComponent.navBottom`. Static comingSoon here is the fallback
|
||||
* when that data is absent.
|
||||
*/
|
||||
export const ADMIN_NAV_BOTTOM: AdminNavEntry[] = [
|
||||
{ type: 'link', id: 'documentation', icon: 'book', labelKey: 'adminShell.nav.documentation', comingSoon: true },
|
||||
{ type: 'link', id: 'help', icon: 'help', labelKey: 'adminShell.nav.help', comingSoon: true },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TranslateService } from '../../../i18n/translate.service';
|
||||
import { BootstrapConfig } from '../../../shared/models/config';
|
||||
import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract';
|
||||
import { validateAgainstSchemaLite } from '../../project-editor/schema/validators/primitives';
|
||||
import { DiagnosticEntry } from '../models/diagnostics.model';
|
||||
|
||||
const KNOWN_LAYOUTS = new Set(['default', 'sidebar-left', 'carousel-home', 'minimal']);
|
||||
@@ -28,6 +29,7 @@ export class BootstrapDiagnosticsValidator {
|
||||
|
||||
this.validateRequiredProperties(bootstrap, entries);
|
||||
this.validateUnknownWidgetTypes(bootstrap, manifest, entries);
|
||||
this.validateWidgetSettingsSchema(bootstrap, manifest, entries);
|
||||
this.validateDuplicateIds(bootstrap, entries);
|
||||
this.validateLayouts(bootstrap, entries);
|
||||
this.validateFeatureFlags(bootstrap, entries);
|
||||
@@ -85,6 +87,36 @@ export class BootstrapDiagnosticsValidator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reuses the same shallow schema check as ProjectValidator.widgetSettingsSchemaIssues
|
||||
* (`validateAgainstSchemaLite`) so the editor and the diagnostics page never
|
||||
* disagree about what "valid widget settings" means - one check, two surfaces.
|
||||
*/
|
||||
private validateWidgetSettingsSchema(bootstrap: BootstrapConfig, manifest: WidgetManifestFile | null, entries: DiagnosticEntry[]): void {
|
||||
const manifestByType = new Map((manifest?.widgets ?? []).map(widget => [widget.type, widget]));
|
||||
for (const page of bootstrap.pages ?? []) {
|
||||
for (const section of page.sections ?? []) {
|
||||
for (const widget of section.widgets ?? []) {
|
||||
const entry = manifestByType.get(widget.type);
|
||||
if (!entry?.settingsSchema || !widget.props || typeof widget.props !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const errors = validateAgainstSchemaLite(widget.props, entry.settingsSchema);
|
||||
if (errors.length > 0) {
|
||||
entries.push({
|
||||
code: 'BOOTSTRAP_WIDGET_SETTINGS_SCHEMA_MISMATCH',
|
||||
severity: 'warning',
|
||||
title: 'diagnostics.widgetSettingsSchemaMismatchTitle',
|
||||
description: `diagnostics.widgetSettingsSchemaMismatchDescription:${widget.type}(${errors.join(', ')})`,
|
||||
affectedComponent: `page:${page.key}/section:${section.id}/widget:${widget.id}`,
|
||||
suggestedResolution: 'diagnostics.widgetSettingsSchemaMismatchResolution'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateDuplicateIds(bootstrap: BootstrapConfig, entries: DiagnosticEntry[]): void {
|
||||
const pageIds = new Set<string>();
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { of } from 'rxjs';
|
||||
import { BootstrapConfig } from '../../../shared/models/config';
|
||||
import { CONFIG_PROVIDER } from '../../../core/config/config-provider.token';
|
||||
import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
|
||||
import { ProjectEditorFacade } from './project-editor.facade';
|
||||
|
||||
function makeBootstrap(): BootstrapConfig {
|
||||
return {
|
||||
tenant: { id: 'tenant-1', defaultLocale: 'en', supportedLocales: ['en'], websiteBaseUrl: 'https://dexar.market' },
|
||||
branding: { logoUrl: 'logo.png' },
|
||||
localization: { supportedLocales: ['en'], defaultLocale: 'en', currencyByLocale: {}, dictionaries: [] },
|
||||
staticPages: {},
|
||||
pages: [
|
||||
{
|
||||
key: 'home',
|
||||
route: { path: '/' },
|
||||
sections: [
|
||||
{
|
||||
order: 0,
|
||||
layout: { strategy: 'stack' },
|
||||
widgets: [{ id: 'w1', type: 'hero', version: '1', props: {} }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
navigation: { header: [], footer: [] },
|
||||
theme: {
|
||||
palette: {
|
||||
primary: '#497671', secondary: '#a1b4b5', accent: '#a7ceca',
|
||||
success: '#10b981', warning: '#f59e0b', danger: '#ef4444', info: '#3b82f6',
|
||||
textPrimary: '#1e3c38', textSecondary: '#667a77',
|
||||
backgroundPrimary: '#ffffff', backgroundSecondary: '#f5f5f5', border: '#d3dad9',
|
||||
},
|
||||
},
|
||||
layout: { type: 'default' },
|
||||
} as unknown as BootstrapConfig;
|
||||
}
|
||||
|
||||
describe('ProjectEditorFacade', () => {
|
||||
let facade: ProjectEditorFacade;
|
||||
let runtimeSpy: jasmine.SpyObj<PlatformRuntimeService>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
runtimeSpy = jasmine.createSpyObj<PlatformRuntimeService>('PlatformRuntimeService', ['reloadFromBootstrap']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]), provideHttpClient(), provideHttpClientTesting(),
|
||||
{ provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
|
||||
{ provide: PlatformRuntimeService, useValue: runtimeSpy },
|
||||
],
|
||||
});
|
||||
|
||||
facade = TestBed.inject(ProjectEditorFacade);
|
||||
facade.loadBootstrap();
|
||||
});
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('loads the bootstrap and starts with a clean undo/redo stack', () => {
|
||||
expect(facade.bootstrap()).toBeTruthy();
|
||||
expect(facade.canUndo()).toBeFalse();
|
||||
expect(facade.canRedo()).toBeFalse();
|
||||
});
|
||||
|
||||
describe('undo/redo', () => {
|
||||
it('undo reverts the last edit and enables redo', (done) => {
|
||||
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'new-logo.png' } }));
|
||||
|
||||
// updateBootstrap debounces the history commit - wait past HISTORY_DEBOUNCE_MS (300ms).
|
||||
setTimeout(() => {
|
||||
expect(facade.bootstrap()?.branding.logoUrl).toBe('new-logo.png');
|
||||
expect(facade.canUndo()).toBeTrue();
|
||||
|
||||
facade.undo();
|
||||
expect(facade.bootstrap()?.branding.logoUrl).toBe('logo.png');
|
||||
expect(facade.canUndo()).toBeFalse();
|
||||
expect(facade.canRedo()).toBeTrue();
|
||||
|
||||
facade.redo();
|
||||
expect(facade.bootstrap()?.branding.logoUrl).toBe('new-logo.png');
|
||||
expect(facade.canRedo()).toBeFalse();
|
||||
done();
|
||||
}, 350);
|
||||
});
|
||||
|
||||
it('a fresh edit after undo discards the redo stack', (done) => {
|
||||
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'v2.png' } }));
|
||||
|
||||
setTimeout(() => {
|
||||
facade.undo();
|
||||
expect(facade.canRedo()).toBeTrue();
|
||||
|
||||
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'v3.png' } }));
|
||||
expect(facade.canRedo()).toBeFalse();
|
||||
done();
|
||||
}, 350);
|
||||
});
|
||||
});
|
||||
|
||||
describe('draft persistence round-trip', () => {
|
||||
it('persists edits to the draft store and restores them on next load', (done) => {
|
||||
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: 'draft-logo.png' } }));
|
||||
|
||||
setTimeout(() => {
|
||||
// Simulate a fresh facade instance (e.g. page reload) picking up the persisted draft.
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([]), provideHttpClient(), provideHttpClientTesting(),
|
||||
{ provide: CONFIG_PROVIDER, useValue: { loadBootstrap: () => of(makeBootstrap()) } },
|
||||
{ provide: PlatformRuntimeService, useValue: runtimeSpy },
|
||||
],
|
||||
});
|
||||
const reloaded = TestBed.inject(ProjectEditorFacade);
|
||||
reloaded.loadBootstrap();
|
||||
|
||||
expect(reloaded.draftRestored()).toBeTrue();
|
||||
expect(reloaded.bootstrap()?.branding.logoUrl).toBe('draft-logo.png');
|
||||
done();
|
||||
}, 350);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish gating', () => {
|
||||
it('publishes when there are no blocking validation issues', () => {
|
||||
expect(facade.hasBlockingIssues()).toBeFalse();
|
||||
const result = facade.publish();
|
||||
expect(result).toBeTrue();
|
||||
expect(facade.status()).toBe('published');
|
||||
expect(runtimeSpy.reloadFromBootstrap).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to publish while a blocking issue is present', () => {
|
||||
facade.updateBootstrap(current => ({ ...current, branding: { ...current.branding, logoUrl: '' } }));
|
||||
|
||||
expect(facade.hasBlockingIssues()).toBeTrue();
|
||||
const result = facade.publish();
|
||||
expect(result).toBeFalse();
|
||||
expect(facade.status()).not.toBe('published');
|
||||
expect(runtimeSpy.reloadFromBootstrap).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
isValidHexColor,
|
||||
isValidHttpUrl,
|
||||
normalizeRoute,
|
||||
validateAgainstSchemaLite,
|
||||
validateCss,
|
||||
validateHtml,
|
||||
validateJson,
|
||||
@@ -79,6 +80,36 @@ describe('validation primitives', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAgainstSchemaLite', () => {
|
||||
const schema = {
|
||||
type: 'object' as const,
|
||||
properties: { title: { type: 'string' }, count: { type: 'number' } },
|
||||
required: ['title'],
|
||||
};
|
||||
|
||||
it('accepts a value matching required fields and property types', () => {
|
||||
expect(validateAgainstSchemaLite({ title: 'Hero', count: 3 }, schema)).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a missing required property', () => {
|
||||
const errors = validateAgainstSchemaLite({ count: 3 }, schema);
|
||||
expect(errors.some(e => e.includes('title'))).toBeTrue();
|
||||
});
|
||||
|
||||
it('flags a property with the wrong type', () => {
|
||||
const errors = validateAgainstSchemaLite({ title: 'Hero', count: 'three' }, schema);
|
||||
expect(errors.some(e => e.includes('count'))).toBeTrue();
|
||||
});
|
||||
|
||||
it('ignores properties not declared in the schema', () => {
|
||||
expect(validateAgainstSchemaLite({ title: 'Hero', extra: 'ignored' }, schema)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores an undeclared-optional property that is simply absent', () => {
|
||||
expect(validateAgainstSchemaLite({ title: 'Hero' }, schema)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateHtml', () => {
|
||||
it('accepts well-formed markup with nested tags', () => {
|
||||
expect(validateHtml('<p>Hello <strong>world</strong></p><ul><li>one</li></ul>').ok).toBeTrue();
|
||||
|
||||
@@ -80,6 +80,54 @@ export function normalizeRoute(route: string): string {
|
||||
return (route ?? '').trim().replace(/^\/+|\/+$/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
export interface JsonSchemaLite {
|
||||
type?: 'object';
|
||||
/** Loosely typed to accept the wider `WidgetManifestEntry.settingsSchema` shape as-is - each entry's `type` is read defensively below. */
|
||||
properties?: Record<string, unknown>;
|
||||
required?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic (type + required) JSON Schema check against an object - not a full
|
||||
* JSON Schema engine (no nested schemas, enums, formats, or $ref). Enough to
|
||||
* catch a widget's `props` missing a required field or holding the wrong
|
||||
* primitive type for a declared property, without pulling in a schema
|
||||
* validation dependency for a check this shallow.
|
||||
*/
|
||||
export function validateAgainstSchemaLite(value: Record<string, unknown>, schema: JsonSchemaLite): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const key of schema.required ?? []) {
|
||||
if (value[key] === undefined || value[key] === null || value[key] === '') {
|
||||
errors.push(`missing required property "${key}"`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, propSchema] of Object.entries(schema.properties ?? {})) {
|
||||
const propValue = value[key];
|
||||
const propType = propSchema && typeof propSchema === 'object' ? (propSchema as { type?: string }).type : undefined;
|
||||
if (propValue === undefined || !propType) {
|
||||
continue;
|
||||
}
|
||||
if (!matchesJsonSchemaType(propValue, propType)) {
|
||||
errors.push(`property "${key}" should be ${propType}`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function matchesJsonSchemaType(value: unknown, type: string): boolean {
|
||||
switch (type) {
|
||||
case 'string': return typeof value === 'string';
|
||||
case 'number': case 'integer': return typeof value === 'number';
|
||||
case 'boolean': return typeof value === 'boolean';
|
||||
case 'array': return Array.isArray(value);
|
||||
case 'object': return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** HTML void elements per the WHATWG spec: never require (or accept) a closing tag. */
|
||||
const VOID_ELEMENTS = new Set([
|
||||
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
||||
|
||||
@@ -38,21 +38,23 @@
|
||||
[usedByLabel]="'builder.usedByLabel' | translate"
|
||||
>
|
||||
<app-visual-layout-picker
|
||||
[options]="layoutStrategyPickerOptions"
|
||||
[options]="layoutOptionsFor(section)"
|
||||
[ariaLabel]="'builder.layoutStrategyLabel' | translate"
|
||||
[ngModel]="section.layout?.strategy || 'stack'"
|
||||
(ngModelChange)="updateLayout(section.id, 'strategy', $event)"
|
||||
/>
|
||||
</app-form-field>
|
||||
<app-form-field
|
||||
appHighlightSource="spacing"
|
||||
[label]="'builder.columns' | translate"
|
||||
[hint]="'builder.sectionColumnsDesc' | translate"
|
||||
[usedBy]="[blockLabel(section.type)]"
|
||||
[usedByLabel]="'builder.usedByLabel' | translate"
|
||||
>
|
||||
<app-input type="number" [ngModel]="section.layout?.columns || 1" (ngModelChange)="updateLayout(section.id, 'columns', $event)" />
|
||||
</app-form-field>
|
||||
@if (showColumnsFor(section)) {
|
||||
<app-form-field
|
||||
appHighlightSource="spacing"
|
||||
[label]="'builder.columns' | translate"
|
||||
[hint]="'builder.sectionColumnsDesc' | translate"
|
||||
[usedBy]="[blockLabel(section.type)]"
|
||||
[usedByLabel]="'builder.usedByLabel' | translate"
|
||||
>
|
||||
<app-input type="number" [ngModel]="section.layout?.columns || 1" (ngModelChange)="updateLayout(section.id, 'columns', $event)" />
|
||||
</app-form-field>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||
@@ -16,6 +17,8 @@ import { ConfirmDialogComponent } from '../../../shared/ui/confirm-dialog/confir
|
||||
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
|
||||
import { VisualLayoutPickerComponent, VisualLayoutOption } from '../../../shared/ui/visual-layout-picker/visual-layout-picker.component';
|
||||
import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive';
|
||||
import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
|
||||
import { WidgetLayoutSupport, WidgetManifestEntry } from '../../../widgets/contracts/widget-manifest.contract';
|
||||
|
||||
export interface LayoutStrategyOption {
|
||||
value: 'stack' | 'grid' | 'hero' | 'carousel' | 'split';
|
||||
@@ -45,6 +48,9 @@ const BLOCK_CATALOG: BlockCatalogEntry[] = [
|
||||
|
||||
const BLOCK_BY_TYPE = new Map(BLOCK_CATALOG.map(entry => [entry.type, entry]));
|
||||
|
||||
/** Widget componentKeys (widget-manifest.json) that read `section.layout.columns`. Keep in sync with the widget components that actually consume it - see hero-widget.component.ts and product-carousel-widget.component.ts. */
|
||||
const COMPONENT_KEYS_READING_COLUMNS = new Set(['hero', 'product-collection']);
|
||||
|
||||
@Component({
|
||||
selector: 'app-project-editor-homepage-section',
|
||||
standalone: true,
|
||||
@@ -56,7 +62,11 @@ const BLOCK_BY_TYPE = new Map(BLOCK_CATALOG.map(entry => [entry.type, entry]));
|
||||
export class ProjectEditorHomepageSectionComponent {
|
||||
private readonly facade = inject(ProjectEditorFacade);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly widgetManifest = inject(WidgetManifestService);
|
||||
readonly homePage = this.facade.homepagePage;
|
||||
|
||||
private readonly manifestEntries = toSignal(this.widgetManifest.getWidgets(), { initialValue: [] as WidgetManifestEntry[] });
|
||||
private readonly manifestByType = computed(() => new Map(this.manifestEntries().map(entry => [entry.type, entry])));
|
||||
readonly fieldError = (key: string): string | null => {
|
||||
const messageKey = this.facade.fieldError(key);
|
||||
return messageKey ? this.translate.t(messageKey) : null;
|
||||
@@ -89,6 +99,35 @@ export class ProjectEditorHomepageSectionComponent {
|
||||
icon: this.layoutStrategyIcons[option.value],
|
||||
}));
|
||||
|
||||
/** Layout strategy options filtered to what the section's bound widget actually supports (widget-manifest.json `supportedLayouts`). Keeps a stale/unsupported saved value selectable instead of rendering a picker with no active card. */
|
||||
layoutOptionsFor(section: SectionConfig): VisualLayoutOption[] {
|
||||
const supported = this.manifestByType().get(section.type)?.supportedLayouts;
|
||||
if (!supported || supported.length === 0) {
|
||||
return this.layoutStrategyPickerOptions;
|
||||
}
|
||||
const options = this.layoutStrategyPickerOptions.filter(option => supported.includes(option.value as WidgetLayoutSupport));
|
||||
const current = section.layout?.strategy;
|
||||
if (current && !options.some(option => option.value === current)) {
|
||||
const currentOption = this.layoutStrategyPickerOptions.find(option => option.value === current);
|
||||
if (currentOption) {
|
||||
return [...options, currentOption];
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Whether the section's bound widget consumes `layout.columns` (see COMPONENT_KEYS_READING_COLUMNS). Product carousel only uses it in carousel strategy - grid mode ignores it. */
|
||||
showColumnsFor(section: SectionConfig): boolean {
|
||||
const componentKey = this.manifestByType().get(section.type)?.componentKey;
|
||||
if (!componentKey || !COMPONENT_KEYS_READING_COLUMNS.has(componentKey)) {
|
||||
return false;
|
||||
}
|
||||
if (componentKey === 'product-collection') {
|
||||
return (section.layout?.strategy ?? 'stack') === 'carousel';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
blockLabel(type: string): string {
|
||||
const entry = BLOCK_BY_TYPE.get(type);
|
||||
return entry ? this.translate.t(entry.labelKey) : type;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { BootstrapConfig } from '../../../shared/models/config';
|
||||
import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
|
||||
import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract';
|
||||
import { ProjectValidator } from './project-validator.service';
|
||||
|
||||
function makeBootstrap(): BootstrapConfig {
|
||||
@@ -45,7 +50,8 @@ describe('ProjectValidator', () => {
|
||||
let validator: ProjectValidator;
|
||||
|
||||
beforeEach(() => {
|
||||
validator = new ProjectValidator();
|
||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||
validator = TestBed.inject(ProjectValidator);
|
||||
});
|
||||
|
||||
it('reports no issues for a valid baseline config', () => {
|
||||
@@ -95,4 +101,45 @@ describe('ProjectValidator', () => {
|
||||
const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-url');
|
||||
expect(issue?.fieldKey).toBe('tenant.websiteBaseUrl');
|
||||
});
|
||||
|
||||
describe('widgetSettingsSchema (against widget-manifest settingsSchema)', () => {
|
||||
const manifest: WidgetManifestFile = {
|
||||
widgets: [
|
||||
{
|
||||
type: 'hero',
|
||||
version: '1',
|
||||
componentKey: 'hero',
|
||||
supportedLayouts: ['hero'],
|
||||
supportedDataSources: [],
|
||||
settingsSchema: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] },
|
||||
defaultSettings: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it('does nothing before the manifest has ever loaded (getManifestSnapshot() is null)', () => {
|
||||
const bootstrap = makeBootstrap();
|
||||
bootstrap.pages[0].sections[0].widgets[0].props = {};
|
||||
expect(validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('flags a widget missing a required settingsSchema property once the manifest is loaded', () => {
|
||||
spyOn(TestBed.inject(WidgetManifestService), 'getManifestSnapshot').and.returnValue(manifest);
|
||||
const bootstrap = makeBootstrap();
|
||||
bootstrap.pages[0].sections[0].widgets[0].props = {};
|
||||
|
||||
const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings');
|
||||
expect(issue).toBeDefined();
|
||||
expect(issue?.section).toBe('widgets');
|
||||
expect(issue?.severity).toBe('warning');
|
||||
});
|
||||
|
||||
it('passes when the widget props satisfy the manifest settingsSchema', () => {
|
||||
spyOn(TestBed.inject(WidgetManifestService), 'getManifestSnapshot').and.returnValue(manifest);
|
||||
const bootstrap = makeBootstrap();
|
||||
bootstrap.pages[0].sections[0].widgets[0].props = { title: 'Welcome' };
|
||||
|
||||
expect(validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { BootstrapConfig } from '../../../shared/models/config';
|
||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||||
import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
|
||||
import {
|
||||
extractStyleBlocks,
|
||||
isValidEmail,
|
||||
isValidHexColor,
|
||||
isValidHttpUrl,
|
||||
normalizeRoute,
|
||||
validateAgainstSchemaLite,
|
||||
validateCss,
|
||||
} from '../schema/validators/primitives';
|
||||
|
||||
@@ -43,6 +45,8 @@ function warning(code: string, message: string, section: ProjectEditorSectionId,
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProjectValidator {
|
||||
private readonly widgetManifest = inject(WidgetManifestService);
|
||||
|
||||
validate(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||
return [
|
||||
...this.brandingIssues(bootstrap),
|
||||
@@ -52,6 +56,7 @@ export class ProjectValidator {
|
||||
...this.duplicateRouteIssues(bootstrap),
|
||||
...this.homepageIssues(bootstrap),
|
||||
...this.widgetConfigIssues(bootstrap),
|
||||
...this.widgetSettingsSchemaIssues(bootstrap),
|
||||
...this.navigationIssues(bootstrap),
|
||||
...this.colorIssues(bootstrap),
|
||||
...this.cssIssues(bootstrap),
|
||||
@@ -162,6 +167,37 @@ export class ProjectValidator {
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates each widget's `props` against its manifest entry's `settingsSchema`
|
||||
* (basic type/required checks - see validateAgainstSchemaLite). No-ops until
|
||||
* the manifest has loaded once (same early-return convention as
|
||||
* RuntimeDiagnosticsValidator.validateDatasourceCoverage) rather than blocking
|
||||
* publish on a manifest fetch race.
|
||||
*/
|
||||
private widgetSettingsSchemaIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||
const manifest = this.widgetManifest.getManifestSnapshot();
|
||||
if (!manifest) {
|
||||
return [];
|
||||
}
|
||||
const manifestByType = new Map(manifest.widgets.map(entry => [entry.type, entry]));
|
||||
|
||||
const hasInvalidSettings = bootstrap.pages.some(page =>
|
||||
page.sections.some(section =>
|
||||
section.widgets.some(widget => {
|
||||
const entry = manifestByType.get(widget.type);
|
||||
if (!entry?.settingsSchema || !widget.props || typeof widget.props !== 'object') {
|
||||
return false;
|
||||
}
|
||||
return validateAgainstSchemaLite(widget.props, entry.settingsSchema).length > 0;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return hasInvalidSettings
|
||||
? [warning('invalid-widget-settings', 'builder.validationInvalidWidgetSettings', 'widgets', 'pages')]
|
||||
: [];
|
||||
}
|
||||
|
||||
private navigationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||
const keyOf = (item: { label?: string | Record<string, string>; route?: string }): string =>
|
||||
`${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ParamMap, Params } from '@angular/router';
|
||||
import { Observable, Subject, of } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
|
||||
import { Category } from '../../../core/categories/models/category-domain.model';
|
||||
import { ConfigService } from '../../../core/config/config.service';
|
||||
import { Product, ProductListResult } from '../../../core/products/models/product-domain.model';
|
||||
import { CategoryFacade } from '../../../facades/platform/category.facade';
|
||||
import { ProductFacade } from '../../../facades/platform/product.facade';
|
||||
@@ -42,6 +43,7 @@ export class SearchFacade {
|
||||
private readonly trendingService = inject(SearchTrendingService);
|
||||
private readonly cacheService = inject(SearchCacheService);
|
||||
private readonly store = inject(SearchStore);
|
||||
private readonly configService = inject(ConfigService);
|
||||
|
||||
private readonly metadataMemo = new Map<string, FilterGroup[]>();
|
||||
private readonly autocompleteInput$ = new Subject<{
|
||||
@@ -123,6 +125,10 @@ export class SearchFacade {
|
||||
|
||||
autocomplete(query: string, products: Product[], categories: Category[] = [], limit = 10): void {
|
||||
this.store.setQuery(query);
|
||||
if (this.configService.getBootstrapSnapshot()?.catalog?.suggestionsEnabled === false) {
|
||||
this.store.setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
this.autocompleteInput$.next({ query, products, categories, limit });
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ export const en: Translations = {
|
||||
compare: 'Compare',
|
||||
openMenu: 'Open menu',
|
||||
closeMenu: 'Close menu',
|
||||
login: 'Log in',
|
||||
logout: 'Log out',
|
||||
},
|
||||
footer: {
|
||||
description: 'A modern marketplace for comfortable shopping',
|
||||
@@ -563,6 +565,7 @@ export const en: Translations = {
|
||||
validationInvalidCss: 'A static page contains invalid CSS.',
|
||||
validationDuplicateRoutes: 'Two or more pages share the same route.',
|
||||
validationInvalidWidgetConfig: 'A widget is missing a required field (id, type, version, or props).',
|
||||
validationInvalidWidgetSettings: "A widget's settings don't match what its type expects (missing or wrong-type field).",
|
||||
validationInvalidContactEmail: 'The company contact email is not a valid email address.',
|
||||
validationInvalidSocialLinkUrl: 'One or more footer social links have an invalid URL.',
|
||||
validationIncompletePaymentIcon: 'A footer payment icon is missing its image or alt text.',
|
||||
@@ -1114,6 +1117,8 @@ export const en: Translations = {
|
||||
slidesLabel: 'Slides',
|
||||
previousProducts: 'Previous products',
|
||||
nextProducts: 'Next products',
|
||||
previousSlide: 'Previous slide',
|
||||
nextSlide: 'Next slide',
|
||||
closeDialog: 'Close dialog',
|
||||
dismiss: 'Dismiss',
|
||||
qrCode: 'QR Code',
|
||||
@@ -1220,6 +1225,9 @@ export const en: Translations = {
|
||||
unknownWidgetTypeTitle: 'Unknown widget type',
|
||||
unknownWidgetTypeDescription: 'Widget type is not present in widget manifest.',
|
||||
unknownWidgetTypeResolution: 'Register widget in manifest or remove invalid widget config.',
|
||||
widgetSettingsSchemaMismatchTitle: 'Widget settings do not match manifest schema',
|
||||
widgetSettingsSchemaMismatchDescription: 'Widget props do not satisfy the settingsSchema declared for this widget type.',
|
||||
widgetSettingsSchemaMismatchResolution: 'Fix the widget props in the editor to match the required fields/types, or update the manifest schema.',
|
||||
duplicateIdTitle: 'Duplicate identifier',
|
||||
duplicatePageIdDescription: 'Duplicate page id detected.',
|
||||
duplicateSectionIdDescription: 'Duplicate section id detected.',
|
||||
@@ -1909,6 +1917,16 @@ export const en: Translations = {
|
||||
lastPublished: 'Last published',
|
||||
syncStatus: 'Sync',
|
||||
},
|
||||
adminReports: {
|
||||
sales: 'Sales',
|
||||
topProducts: 'Top Products',
|
||||
marketplaceHealth: 'Marketplace Health',
|
||||
},
|
||||
adminSettings: {
|
||||
density: 'Table density',
|
||||
densityExplain: 'Reduce row padding across backoffice list pages for a more compact view.',
|
||||
densityCompact: 'Compact rows',
|
||||
},
|
||||
adminAnalytics: {
|
||||
topProductsEmptyTitle: 'No product sales in this period',
|
||||
topProductsEmptyDescription: 'Try a wider date range.',
|
||||
@@ -2060,6 +2078,8 @@ export const en: Translations = {
|
||||
monitoring: { title: 'Monitoring', description: 'Queue, webhook, and system event status' },
|
||||
analytics: { title: 'Analytics', description: 'Sales, products, and shopper behavior' },
|
||||
sellerManagement: { title: 'Seller Management', description: 'Onboard and manage independent sellers on your marketplace' },
|
||||
reports: { title: 'Reports', description: 'Export sales, product, and marketplace health reports' },
|
||||
settings: { title: 'Settings', description: 'Backoffice display preferences' },
|
||||
},
|
||||
},
|
||||
adminSellerManagement: {
|
||||
|
||||
@@ -12,6 +12,8 @@ export const hy: Translations = {
|
||||
compare: 'Համեմատում',
|
||||
openMenu: 'Բացել մենյուն',
|
||||
closeMenu: 'Փակել մենյուն',
|
||||
login: 'Մուտք',
|
||||
logout: 'Ելք',
|
||||
},
|
||||
footer: {
|
||||
description: 'Ժամանակակից մարքեթփլեյս հարմար գնումների համար',
|
||||
@@ -563,6 +565,7 @@ export const hy: Translations = {
|
||||
validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։',
|
||||
validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։',
|
||||
validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։',
|
||||
validationInvalidWidgetSettings: 'Վիջեթի կարգավորումները չեն համապատասխանում իր տիպի սպասվող ձևաչափին (դաշտ բացակայում է կամ սխալ տիպի է)։',
|
||||
validationInvalidContactEmail: 'Ընկերության կոնտակտային էլ. փոստը վավեր չէ։',
|
||||
validationInvalidSocialLinkUrl: 'Ֆուտերի սոցիալական հղումներից մեկը կամ մի քանիսը վավեր URL չունեն։',
|
||||
validationIncompletePaymentIcon: 'Ֆուտերի վճարային պատկերակին բացակայում է պատկերը կամ alt տեքստը։',
|
||||
@@ -1114,6 +1117,8 @@ export const hy: Translations = {
|
||||
slidesLabel: 'Սլայդներ',
|
||||
previousProducts: 'Նախորդ ապրանքները',
|
||||
nextProducts: 'Հաջորդ ապրանքները',
|
||||
previousSlide: 'Նախորդ սլայդը',
|
||||
nextSlide: 'Հաջորդ սլայդը',
|
||||
closeDialog: 'Փակել պատուհանը',
|
||||
dismiss: 'Փակել',
|
||||
qrCode: 'QR կոդ',
|
||||
@@ -1215,6 +1220,9 @@ export const hy: Translations = {
|
||||
unknownWidgetTypeTitle: 'Անհայտ widget type',
|
||||
unknownWidgetTypeDescription: 'Widget type-ը չկա widget manifest-ում։',
|
||||
unknownWidgetTypeResolution: 'Ավելացրեք widget-ը manifest-ում կամ հեռացրեք սխալ config-ը։',
|
||||
widgetSettingsSchemaMismatchTitle: 'Վիջեթի կարգավորումները չեն համապատասխանում manifest-ի սխեմային',
|
||||
widgetSettingsSchemaMismatchDescription: 'Վիջեթի props-ը չի բավարարում այս տիպի համար հայտարարված settingsSchema-ին։',
|
||||
widgetSettingsSchemaMismatchResolution: 'Ուղղեք վիջեթի props-ը խմբագրիչում՝ պարտադիր դաշտերին/տիպերին համապատասխան, կամ թարմացրեք manifest-ի սխեման։',
|
||||
duplicateIdTitle: 'Կրկնվող ID',
|
||||
duplicatePageIdDescription: 'Գտնվել է կրկնվող page id։',
|
||||
duplicateSectionIdDescription: 'Գտնվել է կրկնվող section id։',
|
||||
@@ -1904,6 +1912,16 @@ export const hy: Translations = {
|
||||
lastPublished: 'Վերջին հրապարակում',
|
||||
syncStatus: 'Համաժամացում',
|
||||
},
|
||||
adminReports: {
|
||||
sales: 'Վաճառքներ',
|
||||
topProducts: 'Լավագույն ապրանքներ',
|
||||
marketplaceHealth: 'Մարքեթփլեյսի վիճակ',
|
||||
},
|
||||
adminSettings: {
|
||||
density: 'Աղյուսակի խտություն',
|
||||
densityExplain: 'Փոքրացնել տողերի հեռավորությունը ադմինիստրատիվ վահանակի ցուցակներում՝ ավելի կոմպակտ տեսքի համար։',
|
||||
densityCompact: 'Կոմպակտ տողեր',
|
||||
},
|
||||
adminAnalytics: {
|
||||
topProductsEmptyTitle: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա',
|
||||
topProductsEmptyDescription: 'Փորձեք ընտրել ավելի լայն ամսաթվերի միջակայք։',
|
||||
@@ -2055,6 +2073,8 @@ export const hy: Translations = {
|
||||
monitoring: { title: 'Մոնիտորինգ', description: 'Հերթերի, webhook-ների և համակարգային իրադարձությունների վիճակը' },
|
||||
analytics: { title: 'Վերլուծություն', description: 'Վաճառքներ, ապրանքներ և գնորդների վարքագիծ' },
|
||||
sellerManagement: { title: 'Վաճառողների կառավարում', description: 'Թույլ տվեք անկախ վաճառողներին միանալ ձեր մարքեթփլեյսին՝ պահպանելով կենտրոնացված կառավարումը' },
|
||||
reports: { title: 'Հաշվետվություններ', description: 'Արտահանել վաճառքների, ապրանքների և մարքեթփլեյսի վիճակի հաշվետվություններ' },
|
||||
settings: { title: 'Կարգավորումներ', description: 'Ադմինիստրատիվ վահանակի ցուցադրման նախապատվություններ' },
|
||||
},
|
||||
},
|
||||
adminSellerManagement: {
|
||||
|
||||
@@ -12,6 +12,8 @@ export const ru: Translations = {
|
||||
compare: 'Сравнение',
|
||||
openMenu: 'Открыть меню',
|
||||
closeMenu: 'Закрыть меню',
|
||||
login: 'Войти',
|
||||
logout: 'Выйти',
|
||||
},
|
||||
footer: {
|
||||
description: 'Современный маркетплейс для комфортных покупок',
|
||||
@@ -563,6 +565,7 @@ export const ru: Translations = {
|
||||
validationInvalidCss: 'Статическая страница содержит недопустимый CSS.',
|
||||
validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.',
|
||||
validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).',
|
||||
validationInvalidWidgetSettings: 'Настройки виджета не соответствуют ожидаемым для его типа (поле отсутствует или неверного типа).',
|
||||
validationInvalidContactEmail: 'Контактный email компании указан некорректно.',
|
||||
validationInvalidSocialLinkUrl: 'У одной или нескольких социальных ссылок в футере некорректный URL.',
|
||||
validationIncompletePaymentIcon: 'У иконки оплаты в футере отсутствует изображение или альтернативный текст.',
|
||||
@@ -1114,6 +1117,8 @@ export const ru: Translations = {
|
||||
slidesLabel: 'Слайды',
|
||||
previousProducts: 'Предыдущие товары',
|
||||
nextProducts: 'Следующие товары',
|
||||
previousSlide: 'Предыдущий слайд',
|
||||
nextSlide: 'Следующий слайд',
|
||||
closeDialog: 'Закрыть диалог',
|
||||
dismiss: 'Скрыть',
|
||||
qrCode: 'QR-код',
|
||||
@@ -1215,6 +1220,9 @@ export const ru: Translations = {
|
||||
unknownWidgetTypeTitle: 'Неизвестный тип виджета',
|
||||
unknownWidgetTypeDescription: 'Тип виджета отсутствует в widget manifest.',
|
||||
unknownWidgetTypeResolution: 'Добавьте виджет в manifest или удалите неверную конфигурацию.',
|
||||
widgetSettingsSchemaMismatchTitle: 'Настройки виджета не соответствуют схеме manifest',
|
||||
widgetSettingsSchemaMismatchDescription: 'Props виджета не соответствуют settingsSchema, заданной для этого типа виджета.',
|
||||
widgetSettingsSchemaMismatchResolution: 'Исправьте props виджета в редакторе в соответствии с обязательными полями/типами, либо обновите схему в manifest.',
|
||||
duplicateIdTitle: 'Дублирующийся идентификатор',
|
||||
duplicatePageIdDescription: 'Обнаружен дублирующийся id страницы.',
|
||||
duplicateSectionIdDescription: 'Обнаружен дублирующийся id секции.',
|
||||
@@ -1904,6 +1912,16 @@ export const ru: Translations = {
|
||||
lastPublished: 'Последняя публикация',
|
||||
syncStatus: 'Синхронизация',
|
||||
},
|
||||
adminReports: {
|
||||
sales: 'Продажи',
|
||||
topProducts: 'Топ товаров',
|
||||
marketplaceHealth: 'Состояние маркетплейса',
|
||||
},
|
||||
adminSettings: {
|
||||
density: 'Плотность таблиц',
|
||||
densityExplain: 'Уменьшить отступы строк в списках панели управления для более компактного вида.',
|
||||
densityCompact: 'Компактные строки',
|
||||
},
|
||||
adminAnalytics: {
|
||||
topProductsEmptyTitle: 'Нет продаж товаров за этот период',
|
||||
topProductsEmptyDescription: 'Попробуйте выбрать более широкий диапазон дат.',
|
||||
@@ -2055,6 +2073,8 @@ export const ru: Translations = {
|
||||
monitoring: { title: 'Мониторинг', description: 'Состояние очередей, вебхуков и системных событий' },
|
||||
analytics: { title: 'Аналитика', description: 'Продажи, товары и поведение покупателей' },
|
||||
sellerManagement: { title: 'Управление продавцами', description: 'Подключайте независимых продавцов к вашему маркетплейсу с централизованным администрированием' },
|
||||
reports: { title: 'Отчёты', description: 'Экспорт отчётов по продажам, товарам и состоянию маркетплейса' },
|
||||
settings: { title: 'Настройки', description: 'Настройки отображения панели управления' },
|
||||
},
|
||||
},
|
||||
adminSellerManagement: {
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface Translations {
|
||||
compare: string;
|
||||
openMenu: string;
|
||||
closeMenu: string;
|
||||
login: string;
|
||||
logout: string;
|
||||
};
|
||||
footer: {
|
||||
description: string;
|
||||
@@ -561,6 +563,7 @@ export interface Translations {
|
||||
validationInvalidCss: string;
|
||||
validationDuplicateRoutes: string;
|
||||
validationInvalidWidgetConfig: string;
|
||||
validationInvalidWidgetSettings: string;
|
||||
validationInvalidContactEmail: string;
|
||||
validationInvalidSocialLinkUrl: string;
|
||||
validationIncompletePaymentIcon: string;
|
||||
@@ -1113,6 +1116,8 @@ export interface Translations {
|
||||
slidesLabel: string;
|
||||
previousProducts: string;
|
||||
nextProducts: string;
|
||||
previousSlide: string;
|
||||
nextSlide: string;
|
||||
closeDialog: string;
|
||||
dismiss: string;
|
||||
qrCode: string;
|
||||
@@ -1219,6 +1224,9 @@ export interface Translations {
|
||||
unknownWidgetTypeTitle: string;
|
||||
unknownWidgetTypeDescription: string;
|
||||
unknownWidgetTypeResolution: string;
|
||||
widgetSettingsSchemaMismatchTitle: string;
|
||||
widgetSettingsSchemaMismatchDescription: string;
|
||||
widgetSettingsSchemaMismatchResolution: string;
|
||||
duplicateIdTitle: string;
|
||||
duplicatePageIdDescription: string;
|
||||
duplicateSectionIdDescription: string;
|
||||
@@ -1917,6 +1925,16 @@ export interface Translations {
|
||||
lastPublished: string;
|
||||
syncStatus: string;
|
||||
};
|
||||
adminReports: {
|
||||
sales: string;
|
||||
topProducts: string;
|
||||
marketplaceHealth: string;
|
||||
};
|
||||
adminSettings: {
|
||||
density: string;
|
||||
densityExplain: string;
|
||||
densityCompact: string;
|
||||
};
|
||||
adminAnalytics: {
|
||||
topProductsEmptyTitle: string;
|
||||
topProductsEmptyDescription: string;
|
||||
@@ -2068,6 +2086,8 @@ export interface Translations {
|
||||
monitoring: { title: string; description: string };
|
||||
analytics: { title: string; description: string };
|
||||
sellerManagement: { title: string; description: string };
|
||||
reports: { title: string; description: string };
|
||||
settings: { title: string; description: string };
|
||||
};
|
||||
};
|
||||
adminSellerManagement: {
|
||||
|
||||
@@ -195,9 +195,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Payment Popup Modal -->
|
||||
@if (showPaymentPopup()) {
|
||||
<div class="payment-modal-overlay">
|
||||
<div class="payment-modal" #paymentModalPanel role="dialog" aria-modal="true" [attr.aria-label]="'cart.checkout' | translate" tabindex="-1">
|
||||
<app-dialog
|
||||
class="payment-dialog"
|
||||
[open]="showPaymentPopup()"
|
||||
size="md"
|
||||
[ariaLabel]="'cart.checkout' | translate"
|
||||
[closeOnBackdropClick]="false"
|
||||
[closeOnEscape]="!showBankPaymentPopup()"
|
||||
(closed)="closePaymentPopup()"
|
||||
>
|
||||
<button class="close-modal-btn" (click)="closePaymentPopup()" [attr.aria-label]="'cart.close' | translate">
|
||||
<app-icon name="x" [size]="20" />
|
||||
</button>
|
||||
@@ -276,19 +282,22 @@
|
||||
<p class="auto-close">{{ 'cart.autoClose' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</app-dialog>
|
||||
|
||||
@if (showBankPaymentPopup() && bankPaymentFrameUrl()) {
|
||||
<div class="bank-payment-modal-overlay">
|
||||
<div class="bank-payment-modal" #bankPaymentModalPanel role="dialog" aria-modal="true" [attr.aria-label]="'cart.payViaCard' | translate" tabindex="-1">
|
||||
<button class="close-modal-btn" (click)="closeBankPaymentPopup()" [attr.aria-label]="'cart.close' | translate">
|
||||
<app-icon name="x" [size]="20" />
|
||||
</button>
|
||||
<iframe [src]="bankPaymentFrameUrl()" class="bank-payment-frame" [attr.title]="'common.bankCardPayment' | translate"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (showBankPaymentPopup() && bankPaymentFrameUrl()) {
|
||||
<app-dialog
|
||||
class="bank-payment-dialog"
|
||||
[open]="showBankPaymentPopup()"
|
||||
size="lg"
|
||||
[ariaLabel]="'cart.payViaCard' | translate"
|
||||
[closeOnBackdropClick]="false"
|
||||
(closed)="closeBankPaymentPopup()"
|
||||
>
|
||||
<button class="close-modal-btn" (click)="closeBankPaymentPopup()" [attr.aria-label]="'cart.close' | translate">
|
||||
<app-icon name="x" [size]="20" />
|
||||
</button>
|
||||
<iframe [src]="bankPaymentFrameUrl()" class="bank-payment-frame" [attr.title]="'common.bankCardPayment' | translate"></iframe>
|
||||
</app-dialog>
|
||||
}
|
||||
|
||||
<app-telegram-login />
|
||||
|
||||
@@ -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; // <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
padding-top: 56px;
|
||||
}
|
||||
|
||||
.close-modal-btn {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<HTMLElement>;
|
||||
@ViewChild('bankPaymentModalPanel') private bankPaymentModalPanel?: ElementRef<HTMLElement>;
|
||||
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<HTMLElement>(MODAL_FOCUSABLE_SELECTOR);
|
||||
(focusable[0] ?? panel).focus();
|
||||
}
|
||||
|
||||
private trapModalFocus(event: KeyboardEvent): void {
|
||||
const panel = this.activeModalPanel();
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
const focusable = Array.from(panel.querySelectorAll<HTMLElement>(MODAL_FOCUSABLE_SELECTOR));
|
||||
if (focusable.length === 0) {
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
requestLogin(): void {
|
||||
|
||||
@@ -14,4 +14,5 @@ export interface TenantConfig {
|
||||
defaultCurrency: string;
|
||||
supportedCurrencies: string[];
|
||||
timezone: string;
|
||||
documentationUrl?: string;
|
||||
}
|
||||
|
||||
@@ -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()"
|
||||
>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
}
|
||||
|
||||
.app-dialog-panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
// Accessible name for dialogs that skip the visible titled header (a
|
||||
// consumer-rendered close button inside the content instead). Ignored
|
||||
// when titleText is set - the header already supplies the name.
|
||||
readonly ariaLabel = input<string | null>(null);
|
||||
readonly size = input<DialogSize>('md');
|
||||
// Both default true (existing behavior for every current consumer).
|
||||
readonly closeOnEscape = input(true);
|
||||
readonly closeOnBackdropClick = input(true);
|
||||
|
||||
readonly closed = output<void>();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Record<string, string>>];
|
||||
|
||||
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<string, readonly LucideIconNode[]>;
|
||||
|
||||
export type AppIconName = keyof typeof APP_ICONS;
|
||||
|
||||
@@ -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: `
|
||||
<svg
|
||||
[lucideIcon]="iconData()"
|
||||
[size]="size()"
|
||||
[color]="color()"
|
||||
[strokeWidth]="strokeWidth()"
|
||||
[title]="ariaLabel()"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide"
|
||||
[attr.width]="size()"
|
||||
[attr.height]="size()"
|
||||
[attr.stroke]="color()"
|
||||
[attr.stroke-width]="strokeWidth()"
|
||||
[attr.role]="ariaLabel() ? 'img' : null"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
[attr.aria-hidden]="ariaLabel() ? null : 'true'"
|
||||
></svg>
|
||||
>
|
||||
@if (ariaLabel(); as label) {
|
||||
<title>{{ label }}</title>
|
||||
}
|
||||
@for (node of nodes(); track $index) {
|
||||
@let attrs = node[1];
|
||||
@switch (node[0]) {
|
||||
@case ('path') {
|
||||
<path [attr.d]="attrs['d']" [attr.fill]="attrs['fill']" />
|
||||
}
|
||||
@case ('line') {
|
||||
<line [attr.x1]="attrs['x1']" [attr.x2]="attrs['x2']" [attr.y1]="attrs['y1']" [attr.y2]="attrs['y2']" />
|
||||
}
|
||||
@case ('polygon') {
|
||||
<polygon [attr.points]="attrs['points']" />
|
||||
}
|
||||
@case ('polyline') {
|
||||
<polyline [attr.points]="attrs['points']" />
|
||||
}
|
||||
@case ('circle') {
|
||||
<circle [attr.cx]="attrs['cx']" [attr.cy]="attrs['cy']" [attr.r]="attrs['r']" [attr.fill]="attrs['fill']" />
|
||||
}
|
||||
@case ('ellipse') {
|
||||
<ellipse [attr.cx]="attrs['cx']" [attr.cy]="attrs['cy']" [attr.rx]="attrs['rx']" [attr.ry]="attrs['ry']" />
|
||||
}
|
||||
@case ('rect') {
|
||||
<rect
|
||||
[attr.x]="attrs['x']"
|
||||
[attr.y]="attrs['y']"
|
||||
[attr.width]="attrs['width']"
|
||||
[attr.height]="attrs['height']"
|
||||
[attr.rx]="attrs['rx']"
|
||||
[attr.ry]="attrs['ry']"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</svg>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
@@ -40,5 +85,10 @@ export class IconComponent {
|
||||
readonly strokeWidth = input<number>(2);
|
||||
readonly ariaLabel = input<string | null>(null);
|
||||
|
||||
protected readonly iconData = computed(() => APP_ICONS[this.name()]);
|
||||
// `APP_ICONS` is declared `as const satisfies ...` so per-icon tuple types stay
|
||||
// literal (keeps `AppIconName` a precise key union for callers); widen here at
|
||||
// the single consumption point instead of loosening the registry's own typing.
|
||||
protected readonly nodes = computed(
|
||||
() => APP_ICONS[this.name()] as readonly LucideIconNode[]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, Observable<WidgetManifestFile>>();
|
||||
/** Last manifest resolved by getManifest(), for synchronous readers (e.g. ProjectValidator) that can't await an Observable. Mirrors ConfigService.getBootstrapSnapshot(). */
|
||||
private manifestSnapshot: WidgetManifestFile | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
@@ -23,6 +25,7 @@ export class WidgetManifestService {
|
||||
}
|
||||
|
||||
const manifest$ = this.http.get<WidgetManifestFile>(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<string> {
|
||||
const snapshotUrl = this.configService.getBootstrapSnapshot()?.widgetRegistry?.manifestUrl;
|
||||
if (snapshotUrl) {
|
||||
|
||||
52
src/app/widgets/ui/hero-widget.component.spec.ts
Normal file
52
src/app/widgets/ui/hero-widget.component.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SectionConfig } from '../../shared/models/config';
|
||||
import { HeroWidgetData } from '../contracts/widget-data.contract';
|
||||
import { HeroWidgetComponent } from './hero-widget.component';
|
||||
|
||||
function makeSection(columns?: number): SectionConfig {
|
||||
return { id: 's1', type: 'hero', order: 0, layout: { columns }, widgets: [] } as unknown as SectionConfig;
|
||||
}
|
||||
|
||||
function makeData(): HeroWidgetData {
|
||||
return {
|
||||
title: 'First slide',
|
||||
subtitle: 'sub',
|
||||
ctaLabel: 'Shop now',
|
||||
autoplay: false,
|
||||
slides: [{ title: 'Second slide' }],
|
||||
} as unknown as HeroWidgetData;
|
||||
}
|
||||
|
||||
describe('HeroWidgetComponent panel count regression (layout.columns)', () => {
|
||||
it('shows 1 panel when layout.columns is 1 (or unset)', () => {
|
||||
const fixture = TestBed.createComponent(HeroWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(1);
|
||||
fixture.componentInstance.data = makeData();
|
||||
fixture.componentInstance.ngOnChanges({ data: {} as any });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.panelCount).toBe(1);
|
||||
expect(fixture.componentInstance.visibleSlides().length).toBe(1);
|
||||
});
|
||||
|
||||
it('shows 2 panels when layout.columns is 2 and more than one slide exists', () => {
|
||||
const fixture = TestBed.createComponent(HeroWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(2);
|
||||
fixture.componentInstance.data = makeData();
|
||||
fixture.componentInstance.ngOnChanges({ data: {} as any });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.panelCount).toBe(2);
|
||||
expect(fixture.componentInstance.visibleSlides().length).toBe(2);
|
||||
});
|
||||
|
||||
it('falls back to 1 panel when columns is 2 but there is only a single slide', () => {
|
||||
const fixture = TestBed.createComponent(HeroWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(2);
|
||||
fixture.componentInstance.data = { title: 'Only slide', autoplay: false } as unknown as HeroWidgetData;
|
||||
fixture.componentInstance.ngOnChanges({ data: {} as any });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.panelCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -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: `
|
||||
<section class="hero-widget">
|
||||
@if (activeSlide(); as slide) {
|
||||
<h1 class="hero-widget__title">{{ slide.title }}</h1>
|
||||
@if (slide.subtitle) {
|
||||
<p class="hero-widget__subtitle">{{ slide.subtitle }}</p>
|
||||
}
|
||||
@if (slide.ctaLabel) {
|
||||
<button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ slide.ctaLabel }}</button>
|
||||
}
|
||||
@if (visibleSlides().length) {
|
||||
<div class="hero-widget__nav">
|
||||
@if (allSlides().length > 1) {
|
||||
<button
|
||||
type="button"
|
||||
class="hero-widget__arrow hero-widget__arrow--prev"
|
||||
[attr.aria-label]="'common.previousSlide' | translate"
|
||||
(click)="prevSlide()"
|
||||
>←</button>
|
||||
}
|
||||
|
||||
<div class="hero-widget__slides" (touchstart)="onSwipeStart($event)" (touchend)="onSwipeEnd($event)">
|
||||
@for (slide of visibleSlides(); track $index) {
|
||||
<article class="hero-widget__slide">
|
||||
<h2 class="hero-widget__title">{{ slide.title }}</h2>
|
||||
@if (slide.subtitle) {
|
||||
<p class="hero-widget__subtitle">{{ slide.subtitle }}</p>
|
||||
}
|
||||
@if (slide.ctaLabel) {
|
||||
<button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ slide.ctaLabel }}</button>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (allSlides().length > 1) {
|
||||
<button
|
||||
type="button"
|
||||
class="hero-widget__arrow hero-widget__arrow--next"
|
||||
[attr.aria-label]="'common.nextSlide' | translate"
|
||||
(click)="nextSlide()"
|
||||
>→</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (allSlides().length > 1) {
|
||||
<div class="hero-widget__dots" role="tablist" [attr.aria-label]="'common.slidesLabel' | translate">
|
||||
@@ -49,6 +76,40 @@ const AUTOPLAY_INTERVAL_MS = 5000;
|
||||
animation: hero-widget-in 420ms ease-out both;
|
||||
}
|
||||
|
||||
.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<HeroWidgetData | null>(null);
|
||||
private autoplayHandle: ReturnType<typeof setInterval> | null = null;
|
||||
private swipeStartX: number | null = null;
|
||||
|
||||
readonly allSlides = computed<HeroSlideData[]>(() => {
|
||||
const current = this.dataSignal();
|
||||
@@ -152,6 +214,25 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
||||
|
||||
readonly activeSlide = computed<HeroSlideData | null>(() => this.allSlides()[this.activeIndex()] ?? null);
|
||||
|
||||
/** 1 or 2, reusing `layout.columns` (no dedicated "slides per page" field). 2 has nothing to show a second panel with when there's only one slide. */
|
||||
get panelCount(): number {
|
||||
const requested = this.section?.layout?.columns === 2 ? 2 : 1;
|
||||
return requested === 2 && this.allSlides().length > 1 ? 2 : 1;
|
||||
}
|
||||
|
||||
visibleSlides(): HeroSlideData[] {
|
||||
const slides = this.allSlides();
|
||||
if (slides.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (this.panelCount === 2) {
|
||||
const nextIndex = (this.activeIndex() + 1) % slides.length;
|
||||
return [slides[this.activeIndex()], slides[nextIndex]];
|
||||
}
|
||||
const current = slides[this.activeIndex()];
|
||||
return current ? [current] : [];
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
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();
|
||||
}
|
||||
|
||||
35
src/app/widgets/ui/product-carousel-widget.component.spec.ts
Normal file
35
src/app/widgets/ui/product-carousel-widget.component.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { SectionConfig } from '../../shared/models/config';
|
||||
import { ProductCollectionWidgetData } from '../contracts/widget-data.contract';
|
||||
import { ProductCarouselWidgetComponent } from './product-carousel-widget.component';
|
||||
|
||||
function makeSection(columns?: number): SectionConfig {
|
||||
return { id: 's1', type: 'product-carousel', order: 0, layout: { columns }, widgets: [] } as unknown as SectionConfig;
|
||||
}
|
||||
|
||||
describe('ProductCarouselWidgetComponent sizing regression (layout.columns)', () => {
|
||||
it('defaults to 4 items per page when layout.columns is unset', () => {
|
||||
const fixture = TestBed.createComponent(ProductCarouselWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(undefined);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.itemsPerPage()).toBe(4);
|
||||
});
|
||||
|
||||
it('reflects layout.columns when set', () => {
|
||||
const fixture = TestBed.createComponent(ProductCarouselWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(3);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.itemsPerPage()).toBe(3);
|
||||
});
|
||||
|
||||
it('applies the resolved value as the --items-per-page CSS custom property', () => {
|
||||
const fixture = TestBed.createComponent(ProductCarouselWidgetComponent);
|
||||
fixture.componentInstance.section = makeSection(2);
|
||||
fixture.detectChanges();
|
||||
|
||||
const host = (fixture.nativeElement as HTMLElement).querySelector('.product-carousel-widget') as HTMLElement;
|
||||
expect(host.style.getPropertyValue('--items-per-page').trim()).toBe('2');
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
standalone: true,
|
||||
imports: [CommonModule, CatalogProductGridComponent, TranslatePipe],
|
||||
template: `
|
||||
<section class="product-carousel-widget">
|
||||
<section class="product-carousel-widget" [style.--items-per-page]="itemsPerPage()">
|
||||
@if (data?.title) {
|
||||
<h2 class="product-carousel-widget__title">{{ data?.title }}</h2>
|
||||
}
|
||||
@@ -83,7 +83,8 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
|
||||
::ng-deep .catalog-product-shell {
|
||||
flex: 0 0 auto;
|
||||
width: 220px;
|
||||
width: calc((100% - (var(--items-per-page, 4) - 1) * var(--space-md, 16px)) / var(--items-per-page, 4));
|
||||
min-width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +122,12 @@ export class ProductCarouselWidgetComponent {
|
||||
return this.section?.layout?.strategy === 'carousel';
|
||||
}
|
||||
|
||||
/** Items visible per page - reuses `layout.columns` (default 4, min 1) rather than a dedicated field. */
|
||||
itemsPerPage(): number {
|
||||
const columns = this.section?.layout?.columns;
|
||||
return columns && columns >= 1 ? Math.floor(columns) : 4;
|
||||
}
|
||||
|
||||
scrollBy(direction: -1 | 1): void {
|
||||
const el = this.scroller?.nativeElement;
|
||||
if (!el) {
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user