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`) —
|
- **`tenant`** (`TenantConfig`, `tenant.model.ts`) —
|
||||||
`{ id (UUID), slug, code, host, name, websiteBaseUrl, builderBaseUrl,
|
`{ id (UUID), slug, code, host, name, websiteBaseUrl, builderBaseUrl,
|
||||||
backofficeBaseUrl, defaultLocale, supportedLocales[], defaultCurrency,
|
backofficeBaseUrl, defaultLocale, supportedLocales[], defaultCurrency,
|
||||||
supportedCurrencies[], timezone }`. Identifies the tenant and its per-surface
|
supportedCurrencies[], timezone, documentationUrl? }`. Identifies the tenant
|
||||||
base URLs, locale/currency sets, and timezone.
|
and its per-surface base URLs, locale/currency sets, and timezone.
|
||||||
|
`documentationUrl` (optional string) is the external docs link rendered by
|
||||||
|
the backoffice "Documentation" nav item — when absent, that nav item shows
|
||||||
|
as a disabled `comingSoon` entry instead of a link (frontend-only fallback,
|
||||||
|
no backend action required beyond optionally sending the field).
|
||||||
- **`branding`** (`BrandingConfig`, `branding.model.ts`) —
|
- **`branding`** (`BrandingConfig`, `branding.model.ts`) —
|
||||||
`{ brandName, legalName, slogan?, logoUrl, logoCompactUrl?, faviconUrl,
|
`{ brandName, legalName, slogan?, logoUrl, logoCompactUrl?, faviconUrl,
|
||||||
appIconUrl?, socialImageUrl?, galleryUrls?, supportEmail?, supportPhone? }`.
|
appIconUrl?, socialImageUrl?, galleryUrls?, supportEmail?, supportPhone? }`.
|
||||||
@@ -304,7 +308,8 @@ trimmed for length; full versions in that file):
|
|||||||
"supportedLocales": ["ru", "en", "hy"],
|
"supportedLocales": ["ru", "en", "hy"],
|
||||||
"defaultCurrency": "RUB",
|
"defaultCurrency": "RUB",
|
||||||
"supportedCurrencies": ["RUB", "USD", "EUR", "AMD"],
|
"supportedCurrencies": ["RUB", "USD", "EUR", "AMD"],
|
||||||
"timezone": "Europe/Moscow"
|
"timezone": "Europe/Moscow",
|
||||||
|
"documentationUrl": "https://docs.marketplace.local"
|
||||||
},
|
},
|
||||||
"branding": {
|
"branding": {
|
||||||
"brandName": "Marketplace",
|
"brandName": "Marketplace",
|
||||||
|
|||||||
68
docs/DEAD-CONFIG-AUDIT.md
Normal file
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)
|
### Known gaps found but not fixed (real, out of scope for this pass)
|
||||||
|
|
||||||
- **Theme Mode has no runtime effect.** `theme-section`'s light/dark/system selector correctly saves and sets a `data-theme-mode` attribute (`theme-engine.service.ts`), but zero CSS anywhere in the app reads that attribute — picking Dark or System currently changes nothing visually. (Theme palette colors *are* live — real CSS custom properties consumed throughout the stylesheets — only the mode switch is dead.) Fixing this is a real dark-mode implementation project (dark palette + CSS strategy + `matchMedia` for "system"), not a wiring fix.
|
- **Theme Mode has no runtime effect.** `theme-section`'s light/dark/system selector correctly saves and sets a `data-theme-mode` attribute (`theme-engine.service.ts`), but zero CSS anywhere in the app reads that attribute — picking Dark or System currently changes nothing visually. (Theme palette colors *are* live — real CSS custom properties consumed throughout the stylesheets — only the mode switch is dead.) Fixing this is a real dark-mode implementation project (dark palette + CSS strategy + `matchMedia` for "system"), not a wiring fix. Tracked: `docs/PRODUCT_BACKLOG.md`.
|
||||||
- **`layout.type` (Site Layout) and the homepage section's `type` field both feed a rendering pipeline that was never wired up.** `src/app/dynamic-renderer/` has services/models for page/section/widget rendering but zero components or templates (every directory has only a `.gitkeep`) — the storefront homepage renders through a separate, older path that ignores both fields. `homepage-section.component.ts`'s `updateSection(id, 'type', ...)` has no UI calling it because of this; not built, since building UI for a field nothing reads would be inventing dead controls.
|
- ~~`HeaderConfig.showProfile` has no corresponding profile/account menu~~ — **fixed**: `header.component.html`/`.ts` now render a login/logout-only control (no dropdown, no account links) gated by this toggle, reusing the customer Telegram `AuthService`. See `docs/KNOWN-ISSUES.md` "Fixed (this cycle)" and `docs/GLOBAL-SPRINT-PLAN.md` Sprint A.
|
||||||
- **`HeaderConfig.showProfile`** is a real toggle in `header-section` with no corresponding profile/account menu anywhere in `header.component.html` — the toggle currently does nothing. Building the actual menu is a feature (needs an auth-system check first), not an editor-wiring fix.
|
- ~~`layout.type`/homepage `type` field feed an unwired `dynamic-renderer/`~~ — **stale, corrected**: `dynamic-renderer/` (`PageRendererService`/`SectionRendererService`/`WidgetHostService`) is the live homepage rendering pipeline, wired through `dynamic-page-layout.component.ts`. Verified fixed/non-issue in `docs/KNOWN-ISSUES.md` "Fixed (this cycle)".
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ Nice-to-have, non-blocking work — no client decision needed, just not worth do
|
|||||||
|
|
||||||
## Cart payment modal → `app-dialog` migration
|
## Cart payment modal → `app-dialog` migration
|
||||||
|
|
||||||
`.bank-payment-modal` on the cart page is a custom overlay component with its own focus-trap (added during the WCAG audit) rather than the shared `app-dialog` primitive. Functionally and accessibly complete as-is — migrating it to the shared primitive is a composition cleanup, deliberately deferred across every polish pass so far because it touches multi-step payment state.
|
**Done, 2026-08-06.** `.payment-modal`/`.bank-payment-modal` on the cart page now render through the shared `app-dialog` primitive instead of hand-rolled overlays. Two earlier same-session attempts were reverted before landing (one stopped cleanly after finding real conflicts, one botched the sequencing — deleted the old focus-trap before finishing the swap); this pass fixed the actual API gaps first, then migrated, then verified live in a browser before shipping:
|
||||||
|
|
||||||
|
- `DialogComponent` gained `closeOnEscape`/`closeOnBackdropClick` inputs (default `true`, backward-compatible with its other 13 call sites) and an `ariaLabel` input (for dialogs with no visible title header — cart's modals render their own close button in content instead). `FOCUSABLE_SELECTOR` now includes `iframe` (needed for the bank-payment panel's focus trap).
|
||||||
|
- Cart wires `[closeOnBackdropClick]="false"` on both dialogs (in-flight payment shouldn't cancel on a stray click) and `[closeOnEscape]="!showBankPaymentPopup()"` on the QR/status dialog (so Escape closes the bank iframe first, falls back to the QR view, matches the original nested-modal priority).
|
||||||
|
- Exact original geometry (500px QR modal, 40px padding; 960×760 bank iframe modal, 56/16/16 padding, both mobile breakpoints) preserved via `:host ::ng-deep` overrides on `.app-dialog-panel`/`.app-dialog-panel__body`/`.app-dialog-backdrop`, scoped per-instance via `.payment-dialog`/`.bank-payment-dialog` host classes — same `::ng-deep` pattern already used by `product-carousel-widget.component.ts`.
|
||||||
|
- `cart.component.ts` lost its hand-rolled `@ViewChild`/`@HostListener`/focus-trap methods (~90 lines) — `app-dialog` owns all of that now.
|
||||||
|
- Verified live: both dialogs render at correct size/padding/aria-label at mobile and desktop breakpoints, backdrop-click confirmed inert, Escape-priority confirmed (closes bank first, then QR), initial focus confirmed landing on the close button. 83/83 tests pass, tsc/build clean.
|
||||||
|
|
||||||
## Angular 22 upgrade
|
## Angular 22 upgrade
|
||||||
|
|
||||||
@@ -12,7 +18,9 @@ Researched, not executed. Estimated ~2–3.5 days, needs the `barry-cache` depen
|
|||||||
|
|
||||||
## Bundle splitting
|
## Bundle splitting
|
||||||
|
|
||||||
Two lazy chunks are large: `project-editor` (320 kB), `catalog-container` (126 kB). No mechanical split found yet — needs a dedicated profiling task.
|
**Initial (eagerly-loaded) bundle carries an ~11 MB chunk that is the entire `@lucide/angular` icon set**, confirmed 2026-08-05 by inspecting build output — `app-icon`/`IconComponent` only ever needs the ~85 icons named in `icon-registry.ts`, but esbuild is not eliminating the other ~1500+ unused icon classes from `@lucide/angular`'s single-file `fesm2022/lucide-angular.mjs` bundle, despite the package declaring `sideEffects: false` and every usage in this codebase being clean named imports (no wildcard imports found). Root cause not fully diagnosed — likely each icon's Angular component metadata assignment isn't PURE-annotated in that build, so esbuild can't drop unreferenced classes within the single shared module even though it can drop unreferenced *exports*. The package ships no per-icon deep-import path as a workaround (single fesm file only). Real fix options, neither attempted here (touches a dependency, needs sign-off): (a) check for a newer `@lucide/angular` release with better tree-shaking, (b) drop the dependency and hand-roll inline SVG path data for just the ~85 used icons (removes a dependency, matches this repo's minimal-deps convention, but is real work — extracting/verifying 85 icon paths). This alone is roughly **6x the size of the two lazy chunks below combined** and, unlike them, ships to every visitor on first load.
|
||||||
|
|
||||||
|
Two lazy chunks are also large: `project-editor` (~1.0 MB), `catalog-container` (~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
|
## 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
|
- Found: 2026-07-26, Backend Finalization Sprint documentation pass (traced while
|
||||||
writing `docs/BACKEND.md` §4 Authentication / §6 Error Model).
|
writing `docs/BACKEND.md` §4 Authentication / §6 Error Model).
|
||||||
|
|
||||||
|
2. **`NavigationConfig.header` dead editable field — top nav links list has no renderer.**
|
||||||
|
The Navigation editor section lets a client edit a list of header nav items
|
||||||
|
(`navigation.header`), but `HeaderComponent` never reads `NavigationConfig.header`
|
||||||
|
anywhere — its category menu comes from `CategoryFacade` instead. Editing this
|
||||||
|
list currently has zero visible effect on the storefront.
|
||||||
|
- **Fix requires real feature work**, not a wiring change: rendering a
|
||||||
|
configurable top-nav means deciding positioning relative to the existing
|
||||||
|
category menu, active-route styling, and whether `children` (dropdowns) are
|
||||||
|
supported — out of scope for a mechanical fix.
|
||||||
|
- Found: 2026-08-05, Sprint G dead-config sweep (`docs/DEAD-CONFIG-AUDIT.md`).
|
||||||
|
|
||||||
## Fixed (this cycle)
|
## Fixed (this cycle)
|
||||||
|
|
||||||
Condensed — full detail in commit history and `docs/RELEASE_REPORT.md`.
|
Condensed — full detail in commit history and `docs/RELEASE_REPORT.md`.
|
||||||
@@ -39,3 +50,8 @@ Condensed — full detail in commit history and `docs/RELEASE_REPORT.md`.
|
|||||||
- `primeng`/`primeicons` unused dependency — removed.
|
- `primeng`/`primeicons` unused dependency — removed.
|
||||||
- Builder static-page body editor hidden inside a mislabeled collapsed section — un-hidden, relabeled.
|
- Builder static-page body editor hidden inside a mislabeled collapsed section — un-hidden, relabeled.
|
||||||
- Several project-editor/admin-categories correctness bugs (footer icon id collisions, features toggle only driving one flag, languages silent duplicate no-op, static-pages slug collision, branding `socialImageUrl` never read, media-picker facade filter leakage between dialogs, categories draft-recovery/drag-reorder bugs, hardcoded locale-tab order) — see git history for the full per-bug list.
|
- Several project-editor/admin-categories correctness bugs (footer icon id collisions, features toggle only driving one flag, languages silent duplicate no-op, static-pages slug collision, branding `socialImageUrl` never read, media-picker facade filter leakage between dialogs, categories draft-recovery/drag-reorder bugs, hardcoded locale-tab order) — see git history for the full per-bug list.
|
||||||
|
- `HeaderConfig.showProfile` dead toggle — wired up (login/logout only, no dropdown), reuses the existing customer Telegram `AuthService`.
|
||||||
|
- Admin `reports` nav stub — real page (`backoffice/reports`), reuses `AdminAnalyticsFacade` for Sales/Top Products/Marketplace Health cards with CSV export.
|
||||||
|
- Admin `settings` nav stub — real page (`backoffice/settings`), UI density preference (comfortable/compact), persisted to `localStorage`, applied to admin list tables.
|
||||||
|
- Admin `documentation`/`help` nav stubs — both wired to real external links (`mailto:` support email, `tenant.documentationUrl`).
|
||||||
|
- Sprint G dead-config sweep: `footer.logoUrl`, `company.address.street`, `company.contacts.phone`, `catalog.suggestionsEnabled` were editable with no runtime consumer — all four wired up. Full findings table in `docs/DEAD-CONFIG-AUDIT.md`.
|
||||||
|
|||||||
@@ -20,4 +20,4 @@ Wire real error tracking/APM and a real event source for the admin Monitoring pa
|
|||||||
|
|
||||||
## Phase 5 — Version 2 ideas
|
## Phase 5 — Version 2 ideas
|
||||||
|
|
||||||
Everything in `docs/PRODUCT_BACKLOG.md` (dark mode, brand-color contrast decision, advanced analytics, additional payment providers, Contacts page content) and `docs/FUTURE_FEATURES.md` (Angular 22 upgrade, cart-modal composition cleanup) — none of it scheduled, all of it deliberately deferred past initial launch.
|
Everything in `docs/PRODUCT_BACKLOG.md` (dark mode, brand-color contrast decision, advanced analytics, additional payment providers, Contacts page content) and `docs/FUTURE_FEATURES.md` (Angular 22 upgrade, cart-modal composition cleanup) — none of it scheduled, all of it deliberately deferred past initial launch. The former stub-page/dead-toggle inventory (profile menu, admin Reports, admin Settings, Documentation/Help) is closed — see `docs/GLOBAL-SPRINT-PLAN.md` and `docs/KNOWN-ISSUES.md` "Fixed (this cycle)".
|
||||||
|
|||||||
@@ -20,6 +20,29 @@ Items that need a client/business decision before any code is written — not bl
|
|||||||
|
|
||||||
**Decision needed:** add a token for this exact shade, or intentionally reuse an existing token (visual shift either way) — needs a design-system owner's call, not an engineering guess.
|
**Decision needed:** add a token for this exact shade, or intentionally reuse an existing token (visual shift either way) — needs a design-system owner's call, not an engineering guess.
|
||||||
|
|
||||||
|
## `layout.type` ("Site Layout" selector) — dead editable field
|
||||||
|
|
||||||
|
The Theme section's "Site Layout" dropdown edits top-level `BootstrapConfig.layout.type`,
|
||||||
|
but page rendering (`SectionEngineService.resolveLayoutType`) only ever reads each
|
||||||
|
individual `PageConfig.layout`, never the top-level `bootstrap.layout` — so the
|
||||||
|
selector has no visible effect regardless of what's chosen.
|
||||||
|
|
||||||
|
**Decision needed:** which page(s) should this selector actually drive — only the
|
||||||
|
homepage, or every page that doesn't set its own `layout`? That decision determines
|
||||||
|
the wiring, not an engineering guess. Found: Sprint G dead-config sweep, `docs/DEAD-CONFIG-AUDIT.md`.
|
||||||
|
|
||||||
|
## `company.companyName` — dead editable field, needs a copyright-fallback decision
|
||||||
|
|
||||||
|
The Footer section's "Company Name" field has no runtime consumer. The footer
|
||||||
|
already has a copyright fallback (`© {year} {brandName}`) using `branding.brandName`
|
||||||
|
when `footer.copyrightText` is empty — reusing `company.companyName` there instead
|
||||||
|
(or in addition) is a content/legal-wording decision (brand name vs. legal entity
|
||||||
|
name are intentionally different fields), not a safe mechanical fix.
|
||||||
|
|
||||||
|
**Decision needed:** should the copyright fallback use the legal company name
|
||||||
|
instead of (or alongside) the brand name? Found: Sprint G dead-config sweep,
|
||||||
|
`docs/DEAD-CONFIG-AUDIT.md`.
|
||||||
|
|
||||||
## Footer "Contacts" page content
|
## Footer "Contacts" page content
|
||||||
|
|
||||||
The footer's "Contacts" link (`footer-contacts` / `nav.contacts`) has no static-page content in the bootstrap mock data at all — unlike "About" (which was a route-name mismatch, already fixed), there's simply nothing written for Contacts.
|
The footer's "Contacts" link (`footer-contacts` / `nav.contacts`) has no static-page content in the bootstrap mock data at all — unlike "About" (which was a route-name mismatch, already fixed), there's simply nothing written for Contacts.
|
||||||
|
|||||||
94
docs/SPRINT-PLAN-NEXT.md
Normal file
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-jasmine'),
|
||||||
require('karma-chrome-launcher'),
|
require('karma-chrome-launcher'),
|
||||||
require('karma-jasmine-html-reporter'),
|
require('karma-jasmine-html-reporter'),
|
||||||
|
require('karma-coverage'),
|
||||||
],
|
],
|
||||||
browsers: ['ChromeHeadlessNoSandbox'],
|
browsers: ['ChromeHeadlessNoSandbox'],
|
||||||
customLaunchers: {
|
customLaunchers: {
|
||||||
@@ -20,7 +21,12 @@ module.exports = function (config) {
|
|||||||
flags: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
flags: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
reporters: ['progress'],
|
reporters: ['progress', 'coverage'],
|
||||||
|
coverageReporter: {
|
||||||
|
dir: require('path').join(__dirname, 'coverage'),
|
||||||
|
subdir: '.',
|
||||||
|
reporters: [{ type: 'text-summary' }, { type: 'html' }, { type: 'lcovonly' }],
|
||||||
|
},
|
||||||
restartOnFileChange: true,
|
restartOnFileChange: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
160
package-lock.json
generated
160
package-lock.json
generated
@@ -17,7 +17,6 @@
|
|||||||
"@angular/platform-browser": "22.0.8",
|
"@angular/platform-browser": "22.0.8",
|
||||||
"@angular/router": "22.0.8",
|
"@angular/router": "22.0.8",
|
||||||
"@angular/service-worker": "22.0.8",
|
"@angular/service-worker": "22.0.8",
|
||||||
"@lucide/angular": "^1.25.0",
|
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.8.0",
|
"tslib": "^2.8.0",
|
||||||
"zone.js": "~0.16.0"
|
"zone.js": "~0.16.0"
|
||||||
@@ -32,6 +31,7 @@
|
|||||||
"jasmine-core": "~5.5.0",
|
"jasmine-core": "~5.5.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "^2.2.1",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
@@ -1968,19 +1968,6 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@lucide/angular": {
|
|
||||||
"version": "1.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@lucide/angular/-/angular-1.25.0.tgz",
|
|
||||||
"integrity": "sha512-Hu1eHlGIeyamhSCFEO5YmiUlWn/h4y9BOXMDndY0HRu05CNeCt5BENkfBoeE/bzggMGGbYG+SOBkQUG939tFXw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.3.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@angular/common": ">=17.0.0",
|
|
||||||
"@angular/core": ">=17.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
@@ -5459,6 +5446,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-symbols": {
|
"node_modules/has-symbols": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
@@ -5534,6 +5531,13 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-escaper": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/htmlparser2": {
|
"node_modules/htmlparser2": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||||
@@ -5934,6 +5938,60 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/istanbul-lib-report": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"istanbul-lib-coverage": "^3.0.0",
|
||||||
|
"make-dir": "^4.0.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-source-maps": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.1.1",
|
||||||
|
"istanbul-lib-coverage": "^3.0.0",
|
||||||
|
"source-map": "^0.6.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-lib-source-maps/node_modules/source-map": {
|
||||||
|
"version": "0.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/istanbul-reports": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"html-escaper": "^2.0.0",
|
||||||
|
"istanbul-lib-report": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jasmine-core": {
|
"node_modules/jasmine-core": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.5.0.tgz",
|
||||||
@@ -6097,6 +6155,51 @@
|
|||||||
"which": "bin/which"
|
"which": "bin/which"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/karma-coverage": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"istanbul-lib-coverage": "^3.2.0",
|
||||||
|
"istanbul-lib-instrument": "^5.1.0",
|
||||||
|
"istanbul-lib-report": "^3.0.0",
|
||||||
|
"istanbul-lib-source-maps": "^4.0.1",
|
||||||
|
"istanbul-reports": "^3.0.5",
|
||||||
|
"minimatch": "^3.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/karma-coverage/node_modules/istanbul-lib-instrument": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/core": "^7.12.3",
|
||||||
|
"@babel/parser": "^7.14.7",
|
||||||
|
"@istanbuljs/schema": "^0.1.2",
|
||||||
|
"istanbul-lib-coverage": "^3.2.0",
|
||||||
|
"semver": "^6.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/karma-coverage/node_modules/semver": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/karma-jasmine": {
|
"node_modules/karma-jasmine": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/karma-jasmine/-/karma-jasmine-5.1.0.tgz",
|
||||||
@@ -6610,6 +6713,22 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/make-dir": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"semver": "^7.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/make-fetch-happen": {
|
"node_modules/make-fetch-happen": {
|
||||||
"version": "15.0.6",
|
"version": "15.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz",
|
||||||
@@ -8563,6 +8682,19 @@
|
|||||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.22",
|
"version": "7.5.22",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"build:dexar": "ng build --configuration=production",
|
"build:dexar": "ng build --configuration=production",
|
||||||
"test": "ng test --watch=false --browsers=ChromeHeadlessNoSandbox",
|
"test": "ng test --watch=false --browsers=ChromeHeadlessNoSandbox",
|
||||||
|
"test:coverage": "ng test --watch=false --browsers=ChromeHeadlessNoSandbox --code-coverage",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"arch:check:boundaries": "node tools/architecture/check-boundaries.mjs",
|
"arch:check:boundaries": "node tools/architecture/check-boundaries.mjs",
|
||||||
"arch:check:cycles": "npx --yes madge --circular --extensions ts src/app --ts-config tsconfig.app.json",
|
"arch:check:cycles": "npx --yes madge --circular --extensions ts src/app --ts-config tsconfig.app.json",
|
||||||
@@ -30,7 +31,6 @@
|
|||||||
"@angular/platform-browser": "22.0.8",
|
"@angular/platform-browser": "22.0.8",
|
||||||
"@angular/router": "22.0.8",
|
"@angular/router": "22.0.8",
|
||||||
"@angular/service-worker": "22.0.8",
|
"@angular/service-worker": "22.0.8",
|
||||||
"@lucide/angular": "^1.25.0",
|
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.8.0",
|
"tslib": "^2.8.0",
|
||||||
"zone.js": "~0.16.0"
|
"zone.js": "~0.16.0"
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
"jasmine-core": "~5.5.0",
|
"jasmine-core": "~5.5.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "^2.2.1",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
"typescript": "~6.0.3"
|
"typescript": "~6.0.3"
|
||||||
|
|||||||
@@ -254,6 +254,24 @@ const coreRoutes: Routes = [
|
|||||||
breadcrumb: [{ labelKey: 'adminShell.nav.analytics' }]
|
breadcrumb: [{ labelKey: 'adminShell.nav.analytics' }]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'reports',
|
||||||
|
loadComponent: () => import('./features/admin/reports/pages/admin-reports-page.component').then(m => m.AdminReportsPageComponent),
|
||||||
|
data: {
|
||||||
|
titleKey: 'adminShell.pages.reports.title',
|
||||||
|
descriptionKey: 'adminShell.pages.reports.description',
|
||||||
|
breadcrumb: [{ labelKey: 'adminShell.nav.reports' }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
loadComponent: () => import('./features/admin/settings/pages/admin-settings-page.component').then(m => m.AdminSettingsPageComponent),
|
||||||
|
data: {
|
||||||
|
titleKey: 'adminShell.pages.settings.title',
|
||||||
|
descriptionKey: 'adminShell.pages.settings.description',
|
||||||
|
breadcrumb: [{ labelKey: 'adminShell.nav.settings' }]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'partners/seller-management',
|
path: 'partners/seller-management',
|
||||||
loadComponent: () => import('./features/admin/seller-management/pages/admin-seller-management-page.component').then(m => m.AdminSellerManagementPageComponent),
|
loadComponent: () => import('./features/admin/seller-management/pages/admin-seller-management-page.component').then(m => m.AdminSellerManagementPageComponent),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="app-footer__container page-container">
|
<div class="app-footer__container page-container">
|
||||||
<div class="app-footer__top">
|
<div class="app-footer__top">
|
||||||
<div class="app-footer__brand">
|
<div class="app-footer__brand">
|
||||||
<app-logo />
|
<app-logo [srcOverride]="footerLogoUrl()" />
|
||||||
<p>{{ 'footer.description' | translate }}</p>
|
<p>{{ 'footer.description' | translate }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -46,6 +46,12 @@
|
|||||||
@if (contactEmail) {
|
@if (contactEmail) {
|
||||||
<a [href]="'mailto:' + contactEmail">{{ contactEmail }}</a>
|
<a [href]="'mailto:' + contactEmail">{{ contactEmail }}</a>
|
||||||
}
|
}
|
||||||
|
@if (contactPhone) {
|
||||||
|
<a [href]="'tel:' + contactPhone">{{ contactPhone }}</a>
|
||||||
|
}
|
||||||
|
@if (companyAddress) {
|
||||||
|
<span class="app-footer__address">{{ companyAddress }}</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class FooterComponent {
|
|||||||
readonly footerGroups = signal<FooterResolvedGroup[]>([]);
|
readonly footerGroups = signal<FooterResolvedGroup[]>([]);
|
||||||
readonly paymentIcons = signal<FooterPaymentIcon[]>([]);
|
readonly paymentIcons = signal<FooterPaymentIcon[]>([]);
|
||||||
readonly copyrightText = signal('');
|
readonly copyrightText = signal('');
|
||||||
|
readonly footerLogoUrl = signal<string | undefined>(undefined);
|
||||||
|
|
||||||
private readonly configService = inject(ConfigService);
|
private readonly configService = inject(ConfigService);
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ export class FooterComponent {
|
|||||||
this.footerGroups.set([]);
|
this.footerGroups.set([]);
|
||||||
this.paymentIcons.set([]);
|
this.paymentIcons.set([]);
|
||||||
this.copyrightText.set('');
|
this.copyrightText.set('');
|
||||||
|
this.footerLogoUrl.set(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ export class FooterComponent {
|
|||||||
this.footerGroups.set(model.groups);
|
this.footerGroups.set(model.groups);
|
||||||
this.paymentIcons.set(model.paymentIcons);
|
this.paymentIcons.set(model.paymentIcons);
|
||||||
this.copyrightText.set(model.copyrightText);
|
this.copyrightText.set(model.copyrightText);
|
||||||
|
this.footerLogoUrl.set(model.logoUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.configService.loadBootstrap().subscribe();
|
this.configService.loadBootstrap().subscribe();
|
||||||
@@ -56,4 +59,12 @@ export class FooterComponent {
|
|||||||
get contactEmail(): string {
|
get contactEmail(): string {
|
||||||
return this.uiRuntime.contactEmail();
|
return this.uiRuntime.contactEmail();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get contactPhone(): string {
|
||||||
|
return this.uiRuntime.contactPhone();
|
||||||
|
}
|
||||||
|
|
||||||
|
get companyAddress(): string {
|
||||||
|
return this.uiRuntime.companyAddress();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,19 @@
|
|||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Profile (login/logout only) -->
|
||||||
|
@if (headerConfig().showProfile) {
|
||||||
|
@if (isAuthenticated()) {
|
||||||
|
<button type="button" class="platform-ux-btn" (click)="logout()" [attr.aria-label]="'header.logout' | translate">
|
||||||
|
<app-icon name="logOut" [size]="16" class="platform-ux-icon" />
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button type="button" class="platform-ux-btn" (click)="login()" [attr.aria-label]="'header.login' | translate">
|
||||||
|
<app-icon name="user" [size]="16" class="platform-ux-icon" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Region Selector (desktop only) -->
|
<!-- Region Selector (desktop only) -->
|
||||||
@if (headerConfig().showRegion) {
|
@if (headerConfig().showRegion) {
|
||||||
<div class="platform-region-selector platform-lang-desktop">
|
<div class="platform-region-selector platform-lang-desktop">
|
||||||
@@ -147,4 +160,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<app-telegram-login />
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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 { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
|
||||||
import { StaticPageResolverService } from '../../core/config/static-page-resolver.service';
|
import { StaticPageResolverService } from '../../core/config/static-page-resolver.service';
|
||||||
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
import { IconComponent } from '../../shared/ui/icon/icon.component';
|
||||||
|
import { AuthService } from '../../services/auth.service';
|
||||||
|
import { TelegramLoginComponent } from '../telegram-login/telegram-login.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-header',
|
selector: 'app-header',
|
||||||
imports: [RouterLink, RouterLinkActive, LogoComponent, LanguageSelectorComponent, RegionSelectorComponent, LangRoutePipe, TranslatePipe, IconComponent],
|
imports: [RouterLink, RouterLinkActive, LogoComponent, LanguageSelectorComponent, RegionSelectorComponent, LangRoutePipe, TranslatePipe, IconComponent, TelegramLoginComponent],
|
||||||
templateUrl: './header.component.html',
|
templateUrl: './header.component.html',
|
||||||
styleUrls: ['./header.component.scss'],
|
styleUrls: ['./header.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -35,7 +37,9 @@ export class HeaderComponent {
|
|||||||
private configService = inject(ConfigService);
|
private configService = inject(ConfigService);
|
||||||
private featureConfig = inject(FeatureConfigService);
|
private featureConfig = inject(FeatureConfigService);
|
||||||
private staticPageResolver = inject(StaticPageResolverService);
|
private staticPageResolver = inject(StaticPageResolverService);
|
||||||
|
private authService = inject(AuthService);
|
||||||
|
|
||||||
|
readonly isAuthenticated = this.authService.isAuthenticated;
|
||||||
readonly wishlistCount = this.uxFacade.wishlistCount;
|
readonly wishlistCount = this.uxFacade.wishlistCount;
|
||||||
readonly compareCount = this.uxFacade.compareCount;
|
readonly compareCount = this.uxFacade.compareCount;
|
||||||
readonly userExperienceConfig = computed(() => this.resolveUserExperienceConfig());
|
readonly userExperienceConfig = computed(() => this.resolveUserExperienceConfig());
|
||||||
@@ -118,6 +122,14 @@ export class HeaderComponent {
|
|||||||
this.router.navigate([`/${lang}/compare`]);
|
this.router.navigate([`/${lang}/compare`]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
login(): void {
|
||||||
|
this.authService.requestLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
logout(): void {
|
||||||
|
this.authService.logout();
|
||||||
|
}
|
||||||
|
|
||||||
navigateToStatic(route: string): void {
|
navigateToStatic(route: string): void {
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
const lang = this.langService.currentLanguage();
|
const lang = this.langService.currentLanguage();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
|
||||||
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
|
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -17,6 +17,9 @@ import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class LogoComponent {
|
export class LogoComponent {
|
||||||
|
/** Overrides the default (branding.logoUrl) logo, e.g. footer.logoUrl. */
|
||||||
|
@Input() srcOverride?: string;
|
||||||
|
|
||||||
constructor(private readonly uiRuntime: UiRuntimeFacade) {}
|
constructor(private readonly uiRuntime: UiRuntimeFacade) {}
|
||||||
|
|
||||||
get brandName(): string {
|
get brandName(): string {
|
||||||
@@ -24,6 +27,6 @@ export class LogoComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get logoPath(): string {
|
get logoPath(): string {
|
||||||
return this.uiRuntime.logoUrl();
|
return this.srcOverride || this.uiRuntime.logoUrl();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface FooterResolvedModel {
|
|||||||
groups: FooterResolvedGroup[];
|
groups: FooterResolvedGroup[];
|
||||||
paymentIcons: FooterPaymentIcon[];
|
paymentIcons: FooterPaymentIcon[];
|
||||||
copyrightText: string;
|
copyrightText: string;
|
||||||
|
logoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -57,7 +58,8 @@ export class FooterResolverService {
|
|||||||
return {
|
return {
|
||||||
groups: this.resolveFooterGroups(bootstrap),
|
groups: this.resolveFooterGroups(bootstrap),
|
||||||
paymentIcons: this.resolvePaymentIcons(bootstrap.footer),
|
paymentIcons: this.resolvePaymentIcons(bootstrap.footer),
|
||||||
copyrightText: this.resolveCopyrightText(bootstrap)
|
copyrightText: this.resolveCopyrightText(bootstrap),
|
||||||
|
logoUrl: bootstrap.footer?.logoUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ interface UiRuntimeState {
|
|||||||
marketplaceDisplayName: string;
|
marketplaceDisplayName: string;
|
||||||
logoUrl: string;
|
logoUrl: string;
|
||||||
contactEmail: string;
|
contactEmail: string;
|
||||||
|
contactPhone: string;
|
||||||
|
companyAddress: string;
|
||||||
themeId: string;
|
themeId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ export class UiRuntimeFacade {
|
|||||||
marketplaceDisplayName: '',
|
marketplaceDisplayName: '',
|
||||||
logoUrl: '',
|
logoUrl: '',
|
||||||
contactEmail: '',
|
contactEmail: '',
|
||||||
|
contactPhone: '',
|
||||||
|
companyAddress: '',
|
||||||
themeId: ''
|
themeId: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,6 +42,8 @@ export class UiRuntimeFacade {
|
|||||||
marketplaceDisplayName: bootstrap.branding.brandName,
|
marketplaceDisplayName: bootstrap.branding.brandName,
|
||||||
logoUrl: bootstrap.branding.logoUrl,
|
logoUrl: bootstrap.branding.logoUrl,
|
||||||
contactEmail: bootstrap.branding.supportEmail ?? bootstrap.company?.contacts?.email ?? '',
|
contactEmail: bootstrap.branding.supportEmail ?? bootstrap.company?.contacts?.email ?? '',
|
||||||
|
contactPhone: bootstrap.branding.supportPhone ?? bootstrap.company?.contacts?.phone ?? '',
|
||||||
|
companyAddress: bootstrap.company?.address?.street ?? '',
|
||||||
themeId: bootstrap.theme.themeId
|
themeId: bootstrap.theme.themeId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -58,6 +64,14 @@ export class UiRuntimeFacade {
|
|||||||
return this.state().contactEmail;
|
return this.state().contactEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
contactPhone(): string {
|
||||||
|
return this.state().contactPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
companyAddress(): string {
|
||||||
|
return this.state().companyAddress;
|
||||||
|
}
|
||||||
|
|
||||||
themeId(): string {
|
themeId(): string {
|
||||||
return this.state().themeId;
|
return this.state().themeId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 'static-pages', icon: 'edit', labelKey: 'dashboard.actionStaticPages', route: ['backoffice', 'static-pages'] },
|
||||||
{ id: 'orders', icon: 'cart', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] },
|
{ id: 'orders', icon: 'cart', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] },
|
||||||
{ id: 'users', icon: 'users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] },
|
{ id: 'users', icon: 'users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] },
|
||||||
{ id: 'settings', icon: 'settings', labelKey: 'dashboard.shortcutSettings', route: [], comingSoon: true },
|
{ id: 'settings', icon: 'settings', labelKey: 'dashboard.shortcutSettings', route: ['backoffice', 'settings'] },
|
||||||
{ id: 'media-library', icon: 'images', labelKey: 'dashboard.actionMediaLibrary', route: ['backoffice', 'media'] },
|
{ id: 'media-library', icon: 'images', labelKey: 'dashboard.actionMediaLibrary', route: ['backoffice', 'media'] },
|
||||||
{ id: 'content', icon: 'alignLeft', labelKey: 'dashboard.shortcutContent', route: ['edit', 'static-pages'] },
|
{ id: 'content', icon: 'alignLeft', labelKey: 'dashboard.shortcutContent', route: ['edit', 'static-pages'] },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<ul class="admin-layout__nav-list admin-layout__nav-list--bottom">
|
<ul class="admin-layout__nav-list admin-layout__nav-list--bottom">
|
||||||
@for (entry of navBottom; track $index) {
|
@for (entry of navBottom(); track $index) {
|
||||||
@if (entry.type === 'link' && entry.comingSoon) {
|
@if (entry.type === 'link' && entry.externalHref) {
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="admin-layout__nav-link"
|
||||||
|
[href]="entry.externalHref"
|
||||||
|
[attr.target]="entry.externalHref.startsWith('mailto:') ? null : '_blank'"
|
||||||
|
[attr.rel]="entry.externalHref.startsWith('mailto:') ? null : 'noopener'"
|
||||||
|
>
|
||||||
|
<app-icon [name]="entry.icon" [size]="18" />
|
||||||
|
<span class="admin-layout__nav-label">{{ entry.labelKey | translate }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
} @else if (entry.type === 'link' && entry.comingSoon) {
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="admin-layout__nav-link admin-layout__nav-link--disabled" disabled>
|
<button type="button" class="admin-layout__nav-link admin-layout__nav-link--disabled" disabled>
|
||||||
<app-icon [name]="entry.icon" [size]="18" />
|
<app-icon [name]="entry.icon" [size]="18" />
|
||||||
@@ -151,7 +163,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="admin-content" class="admin-layout__content" tabindex="-1">
|
<main id="admin-content" class="admin-layout__content" [class.admin-density-compact]="density() === 'compact'" tabindex="-1">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { LanguageService } from '../../../services/language.service';
|
|||||||
import { AdminAuthService } from '../../../core/admin-auth/admin-auth.service';
|
import { AdminAuthService } from '../../../core/admin-auth/admin-auth.service';
|
||||||
import { ADMIN_NAV_BOTTOM, ADMIN_NAV_PRIMARY, AdminBreadcrumbEntry, AdminNavEntry } from './admin-nav.model';
|
import { ADMIN_NAV_BOTTOM, ADMIN_NAV_PRIMARY, AdminBreadcrumbEntry, AdminNavEntry } from './admin-nav.model';
|
||||||
import { IconComponent } from '../../../shared/ui/icon/icon.component';
|
import { IconComponent } from '../../../shared/ui/icon/icon.component';
|
||||||
|
import { AdminPreferencesService } from '../settings/services/admin-preferences.service';
|
||||||
|
import { UiRuntimeFacade } from '../../../facades/runtime/ui-runtime.facade';
|
||||||
|
import { ConfigService } from '../../../core/config/config.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-layout',
|
selector: 'app-admin-layout',
|
||||||
@@ -25,12 +28,35 @@ export class AdminLayoutComponent {
|
|||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
private readonly adminAuth = inject(AdminAuthService);
|
private readonly adminAuth = inject(AdminAuthService);
|
||||||
|
private readonly preferences = inject(AdminPreferencesService);
|
||||||
|
private readonly uiRuntime = inject(UiRuntimeFacade);
|
||||||
|
private readonly configService = inject(ConfigService);
|
||||||
|
|
||||||
|
readonly density = this.preferences.density;
|
||||||
|
|
||||||
private readonly drawerEl = viewChild<ElementRef<HTMLElement>>('drawer');
|
private readonly drawerEl = viewChild<ElementRef<HTMLElement>>('drawer');
|
||||||
private readonly menuToggleEl = viewChild<ElementRef<HTMLElement>>('menuToggle');
|
private readonly menuToggleEl = viewChild<ElementRef<HTMLElement>>('menuToggle');
|
||||||
|
|
||||||
readonly navPrimary: AdminNavEntry[] = ADMIN_NAV_PRIMARY;
|
readonly navPrimary: AdminNavEntry[] = ADMIN_NAV_PRIMARY;
|
||||||
readonly navBottom: AdminNavEntry[] = ADMIN_NAV_BOTTOM;
|
|
||||||
|
readonly navBottom = computed<AdminNavEntry[]>(() => {
|
||||||
|
this.configService.bootstrapRevision();
|
||||||
|
const supportEmail = this.uiRuntime.contactEmail();
|
||||||
|
const documentationUrl = this.configService.getBootstrapSnapshot()?.tenant?.documentationUrl;
|
||||||
|
|
||||||
|
return ADMIN_NAV_BOTTOM.map(entry => {
|
||||||
|
if (entry.type !== 'link') {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
if (entry.id === 'help' && supportEmail) {
|
||||||
|
return { ...entry, comingSoon: false, externalHref: `mailto:${supportEmail}` };
|
||||||
|
}
|
||||||
|
if (entry.id === 'documentation' && documentationUrl) {
|
||||||
|
return { ...entry, comingSoon: false, externalHref: documentationUrl };
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
readonly mobileDrawerOpen = signal(false);
|
readonly mobileDrawerOpen = signal(false);
|
||||||
readonly notificationsOpen = signal(false);
|
readonly notificationsOpen = signal(false);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export interface AdminNavLink {
|
|||||||
absolutePath?: string[];
|
absolutePath?: string[];
|
||||||
/** No route exists yet - rendered as a disabled item with a "coming soon" badge. */
|
/** No route exists yet - rendered as a disabled item with a "coming soon" badge. */
|
||||||
comingSoon?: boolean;
|
comingSoon?: boolean;
|
||||||
|
/** External URL (mailto: or https:). Mutually exclusive with path/absolutePath/comingSoon. */
|
||||||
|
externalHref?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminNavGroup {
|
export interface AdminNavGroup {
|
||||||
@@ -36,18 +38,24 @@ export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
|
|||||||
{ type: 'link', id: 'customers', icon: 'user', labelKey: 'adminShell.nav.customers', path: ['customers'] },
|
{ type: 'link', id: 'customers', icon: 'user', labelKey: 'adminShell.nav.customers', path: ['customers'] },
|
||||||
{ type: 'link', id: 'transactions', icon: 'creditCard', labelKey: 'adminShell.nav.transactions', path: ['transactions'] },
|
{ type: 'link', id: 'transactions', icon: 'creditCard', labelKey: 'adminShell.nav.transactions', path: ['transactions'] },
|
||||||
{ type: 'link', id: 'moderation', icon: 'star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] },
|
{ type: 'link', id: 'moderation', icon: 'star', labelKey: 'adminShell.nav.moderation', path: ['moderation'] },
|
||||||
{ type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', comingSoon: true },
|
{ type: 'link', id: 'reports', icon: 'chartBar', labelKey: 'adminShell.nav.reports', path: ['reports'] },
|
||||||
{ type: 'group', labelKey: 'adminShell.nav.partnersGroup' },
|
{ type: 'group', labelKey: 'adminShell.nav.partnersGroup' },
|
||||||
{ type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] },
|
{ type: 'link', id: 'seller-management', icon: 'store', labelKey: 'adminShell.nav.sellerManagement', path: ['partners', 'seller-management'] },
|
||||||
{ type: 'link', id: 'content', icon: 'edit', labelKey: 'adminShell.nav.content', absolutePath: ['edit', 'static-pages'] },
|
{ type: 'link', id: 'content', icon: 'edit', labelKey: 'adminShell.nav.content', absolutePath: ['edit', 'static-pages'] },
|
||||||
{ type: 'link', id: 'media', icon: 'images', labelKey: 'adminShell.nav.mediaLibrary', path: ['media'] },
|
{ type: 'link', id: 'media', icon: 'images', labelKey: 'adminShell.nav.mediaLibrary', path: ['media'] },
|
||||||
{ type: 'link', id: 'marketplace-builder', icon: 'network', labelKey: 'adminShell.nav.marketplaceBuilder', absolutePath: ['edit'] },
|
{ type: 'link', id: 'marketplace-builder', icon: 'network', labelKey: 'adminShell.nav.marketplaceBuilder', absolutePath: ['edit'] },
|
||||||
{ type: 'link', id: 'users', icon: 'users', labelKey: 'adminShell.nav.users', path: ['users'] },
|
{ type: 'link', id: 'users', icon: 'users', labelKey: 'adminShell.nav.users', path: ['users'] },
|
||||||
{ type: 'link', id: 'settings', icon: 'settings', labelKey: 'adminShell.nav.settings', comingSoon: true },
|
{ type: 'link', id: 'settings', icon: 'settings', labelKey: 'adminShell.nav.settings', path: ['settings'] },
|
||||||
{ type: 'link', id: 'monitoring', icon: 'monitor', labelKey: 'adminShell.nav.monitoring', path: ['monitoring'] },
|
{ type: 'link', id: 'monitoring', icon: 'monitor', labelKey: 'adminShell.nav.monitoring', path: ['monitoring'] },
|
||||||
{ type: 'link', id: 'analytics', icon: 'chartLine', labelKey: 'adminShell.nav.analytics', path: ['analytics'] },
|
{ type: 'link', id: 'analytics', icon: 'chartLine', labelKey: 'adminShell.nav.analytics', path: ['analytics'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `documentation`/`help` externalHref is resolved at runtime from bootstrap
|
||||||
|
* config (tenant.documentationUrl / branding.supportEmail) — see
|
||||||
|
* `AdminLayoutComponent.navBottom`. Static comingSoon here is the fallback
|
||||||
|
* when that data is absent.
|
||||||
|
*/
|
||||||
export const ADMIN_NAV_BOTTOM: AdminNavEntry[] = [
|
export const ADMIN_NAV_BOTTOM: AdminNavEntry[] = [
|
||||||
{ type: 'link', id: 'documentation', icon: 'book', labelKey: 'adminShell.nav.documentation', comingSoon: true },
|
{ type: 'link', id: 'documentation', icon: 'book', labelKey: 'adminShell.nav.documentation', comingSoon: true },
|
||||||
{ type: 'link', id: 'help', icon: 'help', labelKey: 'adminShell.nav.help', comingSoon: true },
|
{ type: 'link', id: 'help', icon: 'help', labelKey: 'adminShell.nav.help', comingSoon: true },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { TranslateService } from '../../../i18n/translate.service';
|
import { TranslateService } from '../../../i18n/translate.service';
|
||||||
import { BootstrapConfig } from '../../../shared/models/config';
|
import { BootstrapConfig } from '../../../shared/models/config';
|
||||||
import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract';
|
import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract';
|
||||||
|
import { validateAgainstSchemaLite } from '../../project-editor/schema/validators/primitives';
|
||||||
import { DiagnosticEntry } from '../models/diagnostics.model';
|
import { DiagnosticEntry } from '../models/diagnostics.model';
|
||||||
|
|
||||||
const KNOWN_LAYOUTS = new Set(['default', 'sidebar-left', 'carousel-home', 'minimal']);
|
const KNOWN_LAYOUTS = new Set(['default', 'sidebar-left', 'carousel-home', 'minimal']);
|
||||||
@@ -28,6 +29,7 @@ export class BootstrapDiagnosticsValidator {
|
|||||||
|
|
||||||
this.validateRequiredProperties(bootstrap, entries);
|
this.validateRequiredProperties(bootstrap, entries);
|
||||||
this.validateUnknownWidgetTypes(bootstrap, manifest, entries);
|
this.validateUnknownWidgetTypes(bootstrap, manifest, entries);
|
||||||
|
this.validateWidgetSettingsSchema(bootstrap, manifest, entries);
|
||||||
this.validateDuplicateIds(bootstrap, entries);
|
this.validateDuplicateIds(bootstrap, entries);
|
||||||
this.validateLayouts(bootstrap, entries);
|
this.validateLayouts(bootstrap, entries);
|
||||||
this.validateFeatureFlags(bootstrap, entries);
|
this.validateFeatureFlags(bootstrap, entries);
|
||||||
@@ -85,6 +87,36 @@ export class BootstrapDiagnosticsValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reuses the same shallow schema check as ProjectValidator.widgetSettingsSchemaIssues
|
||||||
|
* (`validateAgainstSchemaLite`) so the editor and the diagnostics page never
|
||||||
|
* disagree about what "valid widget settings" means - one check, two surfaces.
|
||||||
|
*/
|
||||||
|
private validateWidgetSettingsSchema(bootstrap: BootstrapConfig, manifest: WidgetManifestFile | null, entries: DiagnosticEntry[]): void {
|
||||||
|
const manifestByType = new Map((manifest?.widgets ?? []).map(widget => [widget.type, widget]));
|
||||||
|
for (const page of bootstrap.pages ?? []) {
|
||||||
|
for (const section of page.sections ?? []) {
|
||||||
|
for (const widget of section.widgets ?? []) {
|
||||||
|
const entry = manifestByType.get(widget.type);
|
||||||
|
if (!entry?.settingsSchema || !widget.props || typeof widget.props !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const errors = validateAgainstSchemaLite(widget.props, entry.settingsSchema);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
entries.push({
|
||||||
|
code: 'BOOTSTRAP_WIDGET_SETTINGS_SCHEMA_MISMATCH',
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'diagnostics.widgetSettingsSchemaMismatchTitle',
|
||||||
|
description: `diagnostics.widgetSettingsSchemaMismatchDescription:${widget.type}(${errors.join(', ')})`,
|
||||||
|
affectedComponent: `page:${page.key}/section:${section.id}/widget:${widget.id}`,
|
||||||
|
suggestedResolution: 'diagnostics.widgetSettingsSchemaMismatchResolution'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private validateDuplicateIds(bootstrap: BootstrapConfig, entries: DiagnosticEntry[]): void {
|
private validateDuplicateIds(bootstrap: BootstrapConfig, entries: DiagnosticEntry[]): void {
|
||||||
const pageIds = new Set<string>();
|
const pageIds = new Set<string>();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
isValidHexColor,
|
||||||
isValidHttpUrl,
|
isValidHttpUrl,
|
||||||
normalizeRoute,
|
normalizeRoute,
|
||||||
|
validateAgainstSchemaLite,
|
||||||
validateCss,
|
validateCss,
|
||||||
validateHtml,
|
validateHtml,
|
||||||
validateJson,
|
validateJson,
|
||||||
@@ -79,6 +80,36 @@ describe('validation primitives', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('validateAgainstSchemaLite', () => {
|
||||||
|
const schema = {
|
||||||
|
type: 'object' as const,
|
||||||
|
properties: { title: { type: 'string' }, count: { type: 'number' } },
|
||||||
|
required: ['title'],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts a value matching required fields and property types', () => {
|
||||||
|
expect(validateAgainstSchemaLite({ title: 'Hero', count: 3 }, schema)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a missing required property', () => {
|
||||||
|
const errors = validateAgainstSchemaLite({ count: 3 }, schema);
|
||||||
|
expect(errors.some(e => e.includes('title'))).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a property with the wrong type', () => {
|
||||||
|
const errors = validateAgainstSchemaLite({ title: 'Hero', count: 'three' }, schema);
|
||||||
|
expect(errors.some(e => e.includes('count'))).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores properties not declared in the schema', () => {
|
||||||
|
expect(validateAgainstSchemaLite({ title: 'Hero', extra: 'ignored' }, schema)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an undeclared-optional property that is simply absent', () => {
|
||||||
|
expect(validateAgainstSchemaLite({ title: 'Hero' }, schema)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('validateHtml', () => {
|
describe('validateHtml', () => {
|
||||||
it('accepts well-formed markup with nested tags', () => {
|
it('accepts well-formed markup with nested tags', () => {
|
||||||
expect(validateHtml('<p>Hello <strong>world</strong></p><ul><li>one</li></ul>').ok).toBeTrue();
|
expect(validateHtml('<p>Hello <strong>world</strong></p><ul><li>one</li></ul>').ok).toBeTrue();
|
||||||
|
|||||||
@@ -80,6 +80,54 @@ export function normalizeRoute(route: string): string {
|
|||||||
return (route ?? '').trim().replace(/^\/+|\/+$/g, '').toLowerCase();
|
return (route ?? '').trim().replace(/^\/+|\/+$/g, '').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JsonSchemaLite {
|
||||||
|
type?: 'object';
|
||||||
|
/** Loosely typed to accept the wider `WidgetManifestEntry.settingsSchema` shape as-is - each entry's `type` is read defensively below. */
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic (type + required) JSON Schema check against an object - not a full
|
||||||
|
* JSON Schema engine (no nested schemas, enums, formats, or $ref). Enough to
|
||||||
|
* catch a widget's `props` missing a required field or holding the wrong
|
||||||
|
* primitive type for a declared property, without pulling in a schema
|
||||||
|
* validation dependency for a check this shallow.
|
||||||
|
*/
|
||||||
|
export function validateAgainstSchemaLite(value: Record<string, unknown>, schema: JsonSchemaLite): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (const key of schema.required ?? []) {
|
||||||
|
if (value[key] === undefined || value[key] === null || value[key] === '') {
|
||||||
|
errors.push(`missing required property "${key}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, propSchema] of Object.entries(schema.properties ?? {})) {
|
||||||
|
const propValue = value[key];
|
||||||
|
const propType = propSchema && typeof propSchema === 'object' ? (propSchema as { type?: string }).type : undefined;
|
||||||
|
if (propValue === undefined || !propType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!matchesJsonSchemaType(propValue, propType)) {
|
||||||
|
errors.push(`property "${key}" should be ${propType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesJsonSchemaType(value: unknown, type: string): boolean {
|
||||||
|
switch (type) {
|
||||||
|
case 'string': return typeof value === 'string';
|
||||||
|
case 'number': case 'integer': return typeof value === 'number';
|
||||||
|
case 'boolean': return typeof value === 'boolean';
|
||||||
|
case 'array': return Array.isArray(value);
|
||||||
|
case 'object': return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
default: return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** HTML void elements per the WHATWG spec: never require (or accept) a closing tag. */
|
/** HTML void elements per the WHATWG spec: never require (or accept) a closing tag. */
|
||||||
const VOID_ELEMENTS = new Set([
|
const VOID_ELEMENTS = new Set([
|
||||||
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
||||||
|
|||||||
@@ -38,12 +38,13 @@
|
|||||||
[usedByLabel]="'builder.usedByLabel' | translate"
|
[usedByLabel]="'builder.usedByLabel' | translate"
|
||||||
>
|
>
|
||||||
<app-visual-layout-picker
|
<app-visual-layout-picker
|
||||||
[options]="layoutStrategyPickerOptions"
|
[options]="layoutOptionsFor(section)"
|
||||||
[ariaLabel]="'builder.layoutStrategyLabel' | translate"
|
[ariaLabel]="'builder.layoutStrategyLabel' | translate"
|
||||||
[ngModel]="section.layout?.strategy || 'stack'"
|
[ngModel]="section.layout?.strategy || 'stack'"
|
||||||
(ngModelChange)="updateLayout(section.id, 'strategy', $event)"
|
(ngModelChange)="updateLayout(section.id, 'strategy', $event)"
|
||||||
/>
|
/>
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
|
@if (showColumnsFor(section)) {
|
||||||
<app-form-field
|
<app-form-field
|
||||||
appHighlightSource="spacing"
|
appHighlightSource="spacing"
|
||||||
[label]="'builder.columns' | translate"
|
[label]="'builder.columns' | translate"
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
>
|
>
|
||||||
<app-input type="number" [ngModel]="section.layout?.columns || 1" (ngModelChange)="updateLayout(section.id, 'columns', $event)" />
|
<app-input type="number" [ngModel]="section.layout?.columns || 1" (ngModelChange)="updateLayout(section.id, 'columns', $event)" />
|
||||||
</app-form-field>
|
</app-form-field>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
|
import { CdkDragDrop, DragDropModule, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||||
@@ -16,6 +17,8 @@ import { ConfirmDialogComponent } from '../../../shared/ui/confirm-dialog/confir
|
|||||||
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
|
||||||
import { VisualLayoutPickerComponent, VisualLayoutOption } from '../../../shared/ui/visual-layout-picker/visual-layout-picker.component';
|
import { VisualLayoutPickerComponent, VisualLayoutOption } from '../../../shared/ui/visual-layout-picker/visual-layout-picker.component';
|
||||||
import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive';
|
import { HighlightSourceDirective } from '../../../shared/ui/highlight-source/highlight-source.directive';
|
||||||
|
import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
|
||||||
|
import { WidgetLayoutSupport, WidgetManifestEntry } from '../../../widgets/contracts/widget-manifest.contract';
|
||||||
|
|
||||||
export interface LayoutStrategyOption {
|
export interface LayoutStrategyOption {
|
||||||
value: 'stack' | 'grid' | 'hero' | 'carousel' | 'split';
|
value: 'stack' | 'grid' | 'hero' | 'carousel' | 'split';
|
||||||
@@ -45,6 +48,9 @@ const BLOCK_CATALOG: BlockCatalogEntry[] = [
|
|||||||
|
|
||||||
const BLOCK_BY_TYPE = new Map(BLOCK_CATALOG.map(entry => [entry.type, entry]));
|
const BLOCK_BY_TYPE = new Map(BLOCK_CATALOG.map(entry => [entry.type, entry]));
|
||||||
|
|
||||||
|
/** Widget componentKeys (widget-manifest.json) that read `section.layout.columns`. Keep in sync with the widget components that actually consume it - see hero-widget.component.ts and product-carousel-widget.component.ts. */
|
||||||
|
const COMPONENT_KEYS_READING_COLUMNS = new Set(['hero', 'product-collection']);
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-project-editor-homepage-section',
|
selector: 'app-project-editor-homepage-section',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -56,7 +62,11 @@ const BLOCK_BY_TYPE = new Map(BLOCK_CATALOG.map(entry => [entry.type, entry]));
|
|||||||
export class ProjectEditorHomepageSectionComponent {
|
export class ProjectEditorHomepageSectionComponent {
|
||||||
private readonly facade = inject(ProjectEditorFacade);
|
private readonly facade = inject(ProjectEditorFacade);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
|
private readonly widgetManifest = inject(WidgetManifestService);
|
||||||
readonly homePage = this.facade.homepagePage;
|
readonly homePage = this.facade.homepagePage;
|
||||||
|
|
||||||
|
private readonly manifestEntries = toSignal(this.widgetManifest.getWidgets(), { initialValue: [] as WidgetManifestEntry[] });
|
||||||
|
private readonly manifestByType = computed(() => new Map(this.manifestEntries().map(entry => [entry.type, entry])));
|
||||||
readonly fieldError = (key: string): string | null => {
|
readonly fieldError = (key: string): string | null => {
|
||||||
const messageKey = this.facade.fieldError(key);
|
const messageKey = this.facade.fieldError(key);
|
||||||
return messageKey ? this.translate.t(messageKey) : null;
|
return messageKey ? this.translate.t(messageKey) : null;
|
||||||
@@ -89,6 +99,35 @@ export class ProjectEditorHomepageSectionComponent {
|
|||||||
icon: this.layoutStrategyIcons[option.value],
|
icon: this.layoutStrategyIcons[option.value],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** Layout strategy options filtered to what the section's bound widget actually supports (widget-manifest.json `supportedLayouts`). Keeps a stale/unsupported saved value selectable instead of rendering a picker with no active card. */
|
||||||
|
layoutOptionsFor(section: SectionConfig): VisualLayoutOption[] {
|
||||||
|
const supported = this.manifestByType().get(section.type)?.supportedLayouts;
|
||||||
|
if (!supported || supported.length === 0) {
|
||||||
|
return this.layoutStrategyPickerOptions;
|
||||||
|
}
|
||||||
|
const options = this.layoutStrategyPickerOptions.filter(option => supported.includes(option.value as WidgetLayoutSupport));
|
||||||
|
const current = section.layout?.strategy;
|
||||||
|
if (current && !options.some(option => option.value === current)) {
|
||||||
|
const currentOption = this.layoutStrategyPickerOptions.find(option => option.value === current);
|
||||||
|
if (currentOption) {
|
||||||
|
return [...options, currentOption];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the section's bound widget consumes `layout.columns` (see COMPONENT_KEYS_READING_COLUMNS). Product carousel only uses it in carousel strategy - grid mode ignores it. */
|
||||||
|
showColumnsFor(section: SectionConfig): boolean {
|
||||||
|
const componentKey = this.manifestByType().get(section.type)?.componentKey;
|
||||||
|
if (!componentKey || !COMPONENT_KEYS_READING_COLUMNS.has(componentKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (componentKey === 'product-collection') {
|
||||||
|
return (section.layout?.strategy ?? 'stack') === 'carousel';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
blockLabel(type: string): string {
|
blockLabel(type: string): string {
|
||||||
const entry = BLOCK_BY_TYPE.get(type);
|
const entry = BLOCK_BY_TYPE.get(type);
|
||||||
return entry ? this.translate.t(entry.labelKey) : type;
|
return entry ? this.translate.t(entry.labelKey) : type;
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { BootstrapConfig } from '../../../shared/models/config';
|
import { BootstrapConfig } from '../../../shared/models/config';
|
||||||
|
import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
|
||||||
|
import { WidgetManifestFile } from '../../../widgets/contracts/widget-manifest.contract';
|
||||||
import { ProjectValidator } from './project-validator.service';
|
import { ProjectValidator } from './project-validator.service';
|
||||||
|
|
||||||
function makeBootstrap(): BootstrapConfig {
|
function makeBootstrap(): BootstrapConfig {
|
||||||
@@ -45,7 +50,8 @@ describe('ProjectValidator', () => {
|
|||||||
let validator: ProjectValidator;
|
let validator: ProjectValidator;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
validator = new ProjectValidator();
|
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||||
|
validator = TestBed.inject(ProjectValidator);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports no issues for a valid baseline config', () => {
|
it('reports no issues for a valid baseline config', () => {
|
||||||
@@ -95,4 +101,45 @@ describe('ProjectValidator', () => {
|
|||||||
const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-url');
|
const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-url');
|
||||||
expect(issue?.fieldKey).toBe('tenant.websiteBaseUrl');
|
expect(issue?.fieldKey).toBe('tenant.websiteBaseUrl');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('widgetSettingsSchema (against widget-manifest settingsSchema)', () => {
|
||||||
|
const manifest: WidgetManifestFile = {
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
type: 'hero',
|
||||||
|
version: '1',
|
||||||
|
componentKey: 'hero',
|
||||||
|
supportedLayouts: ['hero'],
|
||||||
|
supportedDataSources: [],
|
||||||
|
settingsSchema: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] },
|
||||||
|
defaultSettings: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('does nothing before the manifest has ever loaded (getManifestSnapshot() is null)', () => {
|
||||||
|
const bootstrap = makeBootstrap();
|
||||||
|
bootstrap.pages[0].sections[0].widgets[0].props = {};
|
||||||
|
expect(validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a widget missing a required settingsSchema property once the manifest is loaded', () => {
|
||||||
|
spyOn(TestBed.inject(WidgetManifestService), 'getManifestSnapshot').and.returnValue(manifest);
|
||||||
|
const bootstrap = makeBootstrap();
|
||||||
|
bootstrap.pages[0].sections[0].widgets[0].props = {};
|
||||||
|
|
||||||
|
const issue = validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings');
|
||||||
|
expect(issue).toBeDefined();
|
||||||
|
expect(issue?.section).toBe('widgets');
|
||||||
|
expect(issue?.severity).toBe('warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes when the widget props satisfy the manifest settingsSchema', () => {
|
||||||
|
spyOn(TestBed.inject(WidgetManifestService), 'getManifestSnapshot').and.returnValue(manifest);
|
||||||
|
const bootstrap = makeBootstrap();
|
||||||
|
bootstrap.pages[0].sections[0].widgets[0].props = { title: 'Welcome' };
|
||||||
|
|
||||||
|
expect(validator.validate(bootstrap).find(i => i.code === 'invalid-widget-settings')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { BootstrapConfig } from '../../../shared/models/config';
|
import { BootstrapConfig } from '../../../shared/models/config';
|
||||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||||||
|
import { WidgetManifestService } from '../../../widgets/registry/widget-manifest.service';
|
||||||
import {
|
import {
|
||||||
extractStyleBlocks,
|
extractStyleBlocks,
|
||||||
isValidEmail,
|
isValidEmail,
|
||||||
isValidHexColor,
|
isValidHexColor,
|
||||||
isValidHttpUrl,
|
isValidHttpUrl,
|
||||||
normalizeRoute,
|
normalizeRoute,
|
||||||
|
validateAgainstSchemaLite,
|
||||||
validateCss,
|
validateCss,
|
||||||
} from '../schema/validators/primitives';
|
} from '../schema/validators/primitives';
|
||||||
|
|
||||||
@@ -43,6 +45,8 @@ function warning(code: string, message: string, section: ProjectEditorSectionId,
|
|||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ProjectValidator {
|
export class ProjectValidator {
|
||||||
|
private readonly widgetManifest = inject(WidgetManifestService);
|
||||||
|
|
||||||
validate(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
validate(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||||
return [
|
return [
|
||||||
...this.brandingIssues(bootstrap),
|
...this.brandingIssues(bootstrap),
|
||||||
@@ -52,6 +56,7 @@ export class ProjectValidator {
|
|||||||
...this.duplicateRouteIssues(bootstrap),
|
...this.duplicateRouteIssues(bootstrap),
|
||||||
...this.homepageIssues(bootstrap),
|
...this.homepageIssues(bootstrap),
|
||||||
...this.widgetConfigIssues(bootstrap),
|
...this.widgetConfigIssues(bootstrap),
|
||||||
|
...this.widgetSettingsSchemaIssues(bootstrap),
|
||||||
...this.navigationIssues(bootstrap),
|
...this.navigationIssues(bootstrap),
|
||||||
...this.colorIssues(bootstrap),
|
...this.colorIssues(bootstrap),
|
||||||
...this.cssIssues(bootstrap),
|
...this.cssIssues(bootstrap),
|
||||||
@@ -162,6 +167,37 @@ export class ProjectValidator {
|
|||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates each widget's `props` against its manifest entry's `settingsSchema`
|
||||||
|
* (basic type/required checks - see validateAgainstSchemaLite). No-ops until
|
||||||
|
* the manifest has loaded once (same early-return convention as
|
||||||
|
* RuntimeDiagnosticsValidator.validateDatasourceCoverage) rather than blocking
|
||||||
|
* publish on a manifest fetch race.
|
||||||
|
*/
|
||||||
|
private widgetSettingsSchemaIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||||
|
const manifest = this.widgetManifest.getManifestSnapshot();
|
||||||
|
if (!manifest) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const manifestByType = new Map(manifest.widgets.map(entry => [entry.type, entry]));
|
||||||
|
|
||||||
|
const hasInvalidSettings = bootstrap.pages.some(page =>
|
||||||
|
page.sections.some(section =>
|
||||||
|
section.widgets.some(widget => {
|
||||||
|
const entry = manifestByType.get(widget.type);
|
||||||
|
if (!entry?.settingsSchema || !widget.props || typeof widget.props !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return validateAgainstSchemaLite(widget.props, entry.settingsSchema).length > 0;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return hasInvalidSettings
|
||||||
|
? [warning('invalid-widget-settings', 'builder.validationInvalidWidgetSettings', 'widgets', 'pages')]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
private navigationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
private navigationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||||
const keyOf = (item: { label?: string | Record<string, string>; route?: string }): string =>
|
const keyOf = (item: { label?: string | Record<string, string>; route?: string }): string =>
|
||||||
`${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`;
|
`${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ParamMap, Params } from '@angular/router';
|
|||||||
import { Observable, Subject, of } from 'rxjs';
|
import { Observable, Subject, of } from 'rxjs';
|
||||||
import { debounceTime, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
|
import { debounceTime, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
|
||||||
import { Category } from '../../../core/categories/models/category-domain.model';
|
import { Category } from '../../../core/categories/models/category-domain.model';
|
||||||
|
import { ConfigService } from '../../../core/config/config.service';
|
||||||
import { Product, ProductListResult } from '../../../core/products/models/product-domain.model';
|
import { Product, ProductListResult } from '../../../core/products/models/product-domain.model';
|
||||||
import { CategoryFacade } from '../../../facades/platform/category.facade';
|
import { CategoryFacade } from '../../../facades/platform/category.facade';
|
||||||
import { ProductFacade } from '../../../facades/platform/product.facade';
|
import { ProductFacade } from '../../../facades/platform/product.facade';
|
||||||
@@ -42,6 +43,7 @@ export class SearchFacade {
|
|||||||
private readonly trendingService = inject(SearchTrendingService);
|
private readonly trendingService = inject(SearchTrendingService);
|
||||||
private readonly cacheService = inject(SearchCacheService);
|
private readonly cacheService = inject(SearchCacheService);
|
||||||
private readonly store = inject(SearchStore);
|
private readonly store = inject(SearchStore);
|
||||||
|
private readonly configService = inject(ConfigService);
|
||||||
|
|
||||||
private readonly metadataMemo = new Map<string, FilterGroup[]>();
|
private readonly metadataMemo = new Map<string, FilterGroup[]>();
|
||||||
private readonly autocompleteInput$ = new Subject<{
|
private readonly autocompleteInput$ = new Subject<{
|
||||||
@@ -123,6 +125,10 @@ export class SearchFacade {
|
|||||||
|
|
||||||
autocomplete(query: string, products: Product[], categories: Category[] = [], limit = 10): void {
|
autocomplete(query: string, products: Product[], categories: Category[] = [], limit = 10): void {
|
||||||
this.store.setQuery(query);
|
this.store.setQuery(query);
|
||||||
|
if (this.configService.getBootstrapSnapshot()?.catalog?.suggestionsEnabled === false) {
|
||||||
|
this.store.setSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.autocompleteInput$.next({ query, products, categories, limit });
|
this.autocompleteInput$.next({ query, products, categories, limit });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const en: Translations = {
|
|||||||
compare: 'Compare',
|
compare: 'Compare',
|
||||||
openMenu: 'Open menu',
|
openMenu: 'Open menu',
|
||||||
closeMenu: 'Close menu',
|
closeMenu: 'Close menu',
|
||||||
|
login: 'Log in',
|
||||||
|
logout: 'Log out',
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
description: 'A modern marketplace for comfortable shopping',
|
description: 'A modern marketplace for comfortable shopping',
|
||||||
@@ -563,6 +565,7 @@ export const en: Translations = {
|
|||||||
validationInvalidCss: 'A static page contains invalid CSS.',
|
validationInvalidCss: 'A static page contains invalid CSS.',
|
||||||
validationDuplicateRoutes: 'Two or more pages share the same route.',
|
validationDuplicateRoutes: 'Two or more pages share the same route.',
|
||||||
validationInvalidWidgetConfig: 'A widget is missing a required field (id, type, version, or props).',
|
validationInvalidWidgetConfig: 'A widget is missing a required field (id, type, version, or props).',
|
||||||
|
validationInvalidWidgetSettings: "A widget's settings don't match what its type expects (missing or wrong-type field).",
|
||||||
validationInvalidContactEmail: 'The company contact email is not a valid email address.',
|
validationInvalidContactEmail: 'The company contact email is not a valid email address.',
|
||||||
validationInvalidSocialLinkUrl: 'One or more footer social links have an invalid URL.',
|
validationInvalidSocialLinkUrl: 'One or more footer social links have an invalid URL.',
|
||||||
validationIncompletePaymentIcon: 'A footer payment icon is missing its image or alt text.',
|
validationIncompletePaymentIcon: 'A footer payment icon is missing its image or alt text.',
|
||||||
@@ -1114,6 +1117,8 @@ export const en: Translations = {
|
|||||||
slidesLabel: 'Slides',
|
slidesLabel: 'Slides',
|
||||||
previousProducts: 'Previous products',
|
previousProducts: 'Previous products',
|
||||||
nextProducts: 'Next products',
|
nextProducts: 'Next products',
|
||||||
|
previousSlide: 'Previous slide',
|
||||||
|
nextSlide: 'Next slide',
|
||||||
closeDialog: 'Close dialog',
|
closeDialog: 'Close dialog',
|
||||||
dismiss: 'Dismiss',
|
dismiss: 'Dismiss',
|
||||||
qrCode: 'QR Code',
|
qrCode: 'QR Code',
|
||||||
@@ -1220,6 +1225,9 @@ export const en: Translations = {
|
|||||||
unknownWidgetTypeTitle: 'Unknown widget type',
|
unknownWidgetTypeTitle: 'Unknown widget type',
|
||||||
unknownWidgetTypeDescription: 'Widget type is not present in widget manifest.',
|
unknownWidgetTypeDescription: 'Widget type is not present in widget manifest.',
|
||||||
unknownWidgetTypeResolution: 'Register widget in manifest or remove invalid widget config.',
|
unknownWidgetTypeResolution: 'Register widget in manifest or remove invalid widget config.',
|
||||||
|
widgetSettingsSchemaMismatchTitle: 'Widget settings do not match manifest schema',
|
||||||
|
widgetSettingsSchemaMismatchDescription: 'Widget props do not satisfy the settingsSchema declared for this widget type.',
|
||||||
|
widgetSettingsSchemaMismatchResolution: 'Fix the widget props in the editor to match the required fields/types, or update the manifest schema.',
|
||||||
duplicateIdTitle: 'Duplicate identifier',
|
duplicateIdTitle: 'Duplicate identifier',
|
||||||
duplicatePageIdDescription: 'Duplicate page id detected.',
|
duplicatePageIdDescription: 'Duplicate page id detected.',
|
||||||
duplicateSectionIdDescription: 'Duplicate section id detected.',
|
duplicateSectionIdDescription: 'Duplicate section id detected.',
|
||||||
@@ -1909,6 +1917,16 @@ export const en: Translations = {
|
|||||||
lastPublished: 'Last published',
|
lastPublished: 'Last published',
|
||||||
syncStatus: 'Sync',
|
syncStatus: 'Sync',
|
||||||
},
|
},
|
||||||
|
adminReports: {
|
||||||
|
sales: 'Sales',
|
||||||
|
topProducts: 'Top Products',
|
||||||
|
marketplaceHealth: 'Marketplace Health',
|
||||||
|
},
|
||||||
|
adminSettings: {
|
||||||
|
density: 'Table density',
|
||||||
|
densityExplain: 'Reduce row padding across backoffice list pages for a more compact view.',
|
||||||
|
densityCompact: 'Compact rows',
|
||||||
|
},
|
||||||
adminAnalytics: {
|
adminAnalytics: {
|
||||||
topProductsEmptyTitle: 'No product sales in this period',
|
topProductsEmptyTitle: 'No product sales in this period',
|
||||||
topProductsEmptyDescription: 'Try a wider date range.',
|
topProductsEmptyDescription: 'Try a wider date range.',
|
||||||
@@ -2060,6 +2078,8 @@ export const en: Translations = {
|
|||||||
monitoring: { title: 'Monitoring', description: 'Queue, webhook, and system event status' },
|
monitoring: { title: 'Monitoring', description: 'Queue, webhook, and system event status' },
|
||||||
analytics: { title: 'Analytics', description: 'Sales, products, and shopper behavior' },
|
analytics: { title: 'Analytics', description: 'Sales, products, and shopper behavior' },
|
||||||
sellerManagement: { title: 'Seller Management', description: 'Onboard and manage independent sellers on your marketplace' },
|
sellerManagement: { title: 'Seller Management', description: 'Onboard and manage independent sellers on your marketplace' },
|
||||||
|
reports: { title: 'Reports', description: 'Export sales, product, and marketplace health reports' },
|
||||||
|
settings: { title: 'Settings', description: 'Backoffice display preferences' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminSellerManagement: {
|
adminSellerManagement: {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const hy: Translations = {
|
|||||||
compare: 'Համեմատում',
|
compare: 'Համեմատում',
|
||||||
openMenu: 'Բացել մենյուն',
|
openMenu: 'Բացել մենյուն',
|
||||||
closeMenu: 'Փակել մենյուն',
|
closeMenu: 'Փակել մենյուն',
|
||||||
|
login: 'Մուտք',
|
||||||
|
logout: 'Ելք',
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
description: 'Ժամանակակից մարքեթփլեյս հարմար գնումների համար',
|
description: 'Ժամանակակից մարքեթփլեյս հարմար գնումների համար',
|
||||||
@@ -563,6 +565,7 @@ export const hy: Translations = {
|
|||||||
validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։',
|
validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։',
|
||||||
validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։',
|
validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։',
|
||||||
validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։',
|
validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։',
|
||||||
|
validationInvalidWidgetSettings: 'Վիջեթի կարգավորումները չեն համապատասխանում իր տիպի սպասվող ձևաչափին (դաշտ բացակայում է կամ սխալ տիպի է)։',
|
||||||
validationInvalidContactEmail: 'Ընկերության կոնտակտային էլ. փոստը վավեր չէ։',
|
validationInvalidContactEmail: 'Ընկերության կոնտակտային էլ. փոստը վավեր չէ։',
|
||||||
validationInvalidSocialLinkUrl: 'Ֆուտերի սոցիալական հղումներից մեկը կամ մի քանիսը վավեր URL չունեն։',
|
validationInvalidSocialLinkUrl: 'Ֆուտերի սոցիալական հղումներից մեկը կամ մի քանիսը վավեր URL չունեն։',
|
||||||
validationIncompletePaymentIcon: 'Ֆուտերի վճարային պատկերակին բացակայում է պատկերը կամ alt տեքստը։',
|
validationIncompletePaymentIcon: 'Ֆուտերի վճարային պատկերակին բացակայում է պատկերը կամ alt տեքստը։',
|
||||||
@@ -1114,6 +1117,8 @@ export const hy: Translations = {
|
|||||||
slidesLabel: 'Սլայդներ',
|
slidesLabel: 'Սլայդներ',
|
||||||
previousProducts: 'Նախորդ ապրանքները',
|
previousProducts: 'Նախորդ ապրանքները',
|
||||||
nextProducts: 'Հաջորդ ապրանքները',
|
nextProducts: 'Հաջորդ ապրանքները',
|
||||||
|
previousSlide: 'Նախորդ սլայդը',
|
||||||
|
nextSlide: 'Հաջորդ սլայդը',
|
||||||
closeDialog: 'Փակել պատուհանը',
|
closeDialog: 'Փակել պատուհանը',
|
||||||
dismiss: 'Փակել',
|
dismiss: 'Փակել',
|
||||||
qrCode: 'QR կոդ',
|
qrCode: 'QR կոդ',
|
||||||
@@ -1215,6 +1220,9 @@ export const hy: Translations = {
|
|||||||
unknownWidgetTypeTitle: 'Անհայտ widget type',
|
unknownWidgetTypeTitle: 'Անհայտ widget type',
|
||||||
unknownWidgetTypeDescription: 'Widget type-ը չկա widget manifest-ում։',
|
unknownWidgetTypeDescription: 'Widget type-ը չկա widget manifest-ում։',
|
||||||
unknownWidgetTypeResolution: 'Ավելացրեք widget-ը manifest-ում կամ հեռացրեք սխալ config-ը։',
|
unknownWidgetTypeResolution: 'Ավելացրեք widget-ը manifest-ում կամ հեռացրեք սխալ config-ը։',
|
||||||
|
widgetSettingsSchemaMismatchTitle: 'Վիջեթի կարգավորումները չեն համապատասխանում manifest-ի սխեմային',
|
||||||
|
widgetSettingsSchemaMismatchDescription: 'Վիջեթի props-ը չի բավարարում այս տիպի համար հայտարարված settingsSchema-ին։',
|
||||||
|
widgetSettingsSchemaMismatchResolution: 'Ուղղեք վիջեթի props-ը խմբագրիչում՝ պարտադիր դաշտերին/տիպերին համապատասխան, կամ թարմացրեք manifest-ի սխեման։',
|
||||||
duplicateIdTitle: 'Կրկնվող ID',
|
duplicateIdTitle: 'Կրկնվող ID',
|
||||||
duplicatePageIdDescription: 'Գտնվել է կրկնվող page id։',
|
duplicatePageIdDescription: 'Գտնվել է կրկնվող page id։',
|
||||||
duplicateSectionIdDescription: 'Գտնվել է կրկնվող section id։',
|
duplicateSectionIdDescription: 'Գտնվել է կրկնվող section id։',
|
||||||
@@ -1904,6 +1912,16 @@ export const hy: Translations = {
|
|||||||
lastPublished: 'Վերջին հրապարակում',
|
lastPublished: 'Վերջին հրապարակում',
|
||||||
syncStatus: 'Համաժամացում',
|
syncStatus: 'Համաժամացում',
|
||||||
},
|
},
|
||||||
|
adminReports: {
|
||||||
|
sales: 'Վաճառքներ',
|
||||||
|
topProducts: 'Լավագույն ապրանքներ',
|
||||||
|
marketplaceHealth: 'Մարքեթփլեյսի վիճակ',
|
||||||
|
},
|
||||||
|
adminSettings: {
|
||||||
|
density: 'Աղյուսակի խտություն',
|
||||||
|
densityExplain: 'Փոքրացնել տողերի հեռավորությունը ադմինիստրատիվ վահանակի ցուցակներում՝ ավելի կոմպակտ տեսքի համար։',
|
||||||
|
densityCompact: 'Կոմպակտ տողեր',
|
||||||
|
},
|
||||||
adminAnalytics: {
|
adminAnalytics: {
|
||||||
topProductsEmptyTitle: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա',
|
topProductsEmptyTitle: 'Այս ժամանակահատվածում ապրանքների վաճառք չկա',
|
||||||
topProductsEmptyDescription: 'Փորձեք ընտրել ավելի լայն ամսաթվերի միջակայք։',
|
topProductsEmptyDescription: 'Փորձեք ընտրել ավելի լայն ամսաթվերի միջակայք։',
|
||||||
@@ -2055,6 +2073,8 @@ export const hy: Translations = {
|
|||||||
monitoring: { title: 'Մոնիտորինգ', description: 'Հերթերի, webhook-ների և համակարգային իրադարձությունների վիճակը' },
|
monitoring: { title: 'Մոնիտորինգ', description: 'Հերթերի, webhook-ների և համակարգային իրադարձությունների վիճակը' },
|
||||||
analytics: { title: 'Վերլուծություն', description: 'Վաճառքներ, ապրանքներ և գնորդների վարքագիծ' },
|
analytics: { title: 'Վերլուծություն', description: 'Վաճառքներ, ապրանքներ և գնորդների վարքագիծ' },
|
||||||
sellerManagement: { title: 'Վաճառողների կառավարում', description: 'Թույլ տվեք անկախ վաճառողներին միանալ ձեր մարքեթփլեյսին՝ պահպանելով կենտրոնացված կառավարումը' },
|
sellerManagement: { title: 'Վաճառողների կառավարում', description: 'Թույլ տվեք անկախ վաճառողներին միանալ ձեր մարքեթփլեյսին՝ պահպանելով կենտրոնացված կառավարումը' },
|
||||||
|
reports: { title: 'Հաշվետվություններ', description: 'Արտահանել վաճառքների, ապրանքների և մարքեթփլեյսի վիճակի հաշվետվություններ' },
|
||||||
|
settings: { title: 'Կարգավորումներ', description: 'Ադմինիստրատիվ վահանակի ցուցադրման նախապատվություններ' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminSellerManagement: {
|
adminSellerManagement: {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export const ru: Translations = {
|
|||||||
compare: 'Сравнение',
|
compare: 'Сравнение',
|
||||||
openMenu: 'Открыть меню',
|
openMenu: 'Открыть меню',
|
||||||
closeMenu: 'Закрыть меню',
|
closeMenu: 'Закрыть меню',
|
||||||
|
login: 'Войти',
|
||||||
|
logout: 'Выйти',
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
description: 'Современный маркетплейс для комфортных покупок',
|
description: 'Современный маркетплейс для комфортных покупок',
|
||||||
@@ -563,6 +565,7 @@ export const ru: Translations = {
|
|||||||
validationInvalidCss: 'Статическая страница содержит недопустимый CSS.',
|
validationInvalidCss: 'Статическая страница содержит недопустимый CSS.',
|
||||||
validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.',
|
validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.',
|
||||||
validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).',
|
validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).',
|
||||||
|
validationInvalidWidgetSettings: 'Настройки виджета не соответствуют ожидаемым для его типа (поле отсутствует или неверного типа).',
|
||||||
validationInvalidContactEmail: 'Контактный email компании указан некорректно.',
|
validationInvalidContactEmail: 'Контактный email компании указан некорректно.',
|
||||||
validationInvalidSocialLinkUrl: 'У одной или нескольких социальных ссылок в футере некорректный URL.',
|
validationInvalidSocialLinkUrl: 'У одной или нескольких социальных ссылок в футере некорректный URL.',
|
||||||
validationIncompletePaymentIcon: 'У иконки оплаты в футере отсутствует изображение или альтернативный текст.',
|
validationIncompletePaymentIcon: 'У иконки оплаты в футере отсутствует изображение или альтернативный текст.',
|
||||||
@@ -1114,6 +1117,8 @@ export const ru: Translations = {
|
|||||||
slidesLabel: 'Слайды',
|
slidesLabel: 'Слайды',
|
||||||
previousProducts: 'Предыдущие товары',
|
previousProducts: 'Предыдущие товары',
|
||||||
nextProducts: 'Следующие товары',
|
nextProducts: 'Следующие товары',
|
||||||
|
previousSlide: 'Предыдущий слайд',
|
||||||
|
nextSlide: 'Следующий слайд',
|
||||||
closeDialog: 'Закрыть диалог',
|
closeDialog: 'Закрыть диалог',
|
||||||
dismiss: 'Скрыть',
|
dismiss: 'Скрыть',
|
||||||
qrCode: 'QR-код',
|
qrCode: 'QR-код',
|
||||||
@@ -1215,6 +1220,9 @@ export const ru: Translations = {
|
|||||||
unknownWidgetTypeTitle: 'Неизвестный тип виджета',
|
unknownWidgetTypeTitle: 'Неизвестный тип виджета',
|
||||||
unknownWidgetTypeDescription: 'Тип виджета отсутствует в widget manifest.',
|
unknownWidgetTypeDescription: 'Тип виджета отсутствует в widget manifest.',
|
||||||
unknownWidgetTypeResolution: 'Добавьте виджет в manifest или удалите неверную конфигурацию.',
|
unknownWidgetTypeResolution: 'Добавьте виджет в manifest или удалите неверную конфигурацию.',
|
||||||
|
widgetSettingsSchemaMismatchTitle: 'Настройки виджета не соответствуют схеме manifest',
|
||||||
|
widgetSettingsSchemaMismatchDescription: 'Props виджета не соответствуют settingsSchema, заданной для этого типа виджета.',
|
||||||
|
widgetSettingsSchemaMismatchResolution: 'Исправьте props виджета в редакторе в соответствии с обязательными полями/типами, либо обновите схему в manifest.',
|
||||||
duplicateIdTitle: 'Дублирующийся идентификатор',
|
duplicateIdTitle: 'Дублирующийся идентификатор',
|
||||||
duplicatePageIdDescription: 'Обнаружен дублирующийся id страницы.',
|
duplicatePageIdDescription: 'Обнаружен дублирующийся id страницы.',
|
||||||
duplicateSectionIdDescription: 'Обнаружен дублирующийся id секции.',
|
duplicateSectionIdDescription: 'Обнаружен дублирующийся id секции.',
|
||||||
@@ -1904,6 +1912,16 @@ export const ru: Translations = {
|
|||||||
lastPublished: 'Последняя публикация',
|
lastPublished: 'Последняя публикация',
|
||||||
syncStatus: 'Синхронизация',
|
syncStatus: 'Синхронизация',
|
||||||
},
|
},
|
||||||
|
adminReports: {
|
||||||
|
sales: 'Продажи',
|
||||||
|
topProducts: 'Топ товаров',
|
||||||
|
marketplaceHealth: 'Состояние маркетплейса',
|
||||||
|
},
|
||||||
|
adminSettings: {
|
||||||
|
density: 'Плотность таблиц',
|
||||||
|
densityExplain: 'Уменьшить отступы строк в списках панели управления для более компактного вида.',
|
||||||
|
densityCompact: 'Компактные строки',
|
||||||
|
},
|
||||||
adminAnalytics: {
|
adminAnalytics: {
|
||||||
topProductsEmptyTitle: 'Нет продаж товаров за этот период',
|
topProductsEmptyTitle: 'Нет продаж товаров за этот период',
|
||||||
topProductsEmptyDescription: 'Попробуйте выбрать более широкий диапазон дат.',
|
topProductsEmptyDescription: 'Попробуйте выбрать более широкий диапазон дат.',
|
||||||
@@ -2055,6 +2073,8 @@ export const ru: Translations = {
|
|||||||
monitoring: { title: 'Мониторинг', description: 'Состояние очередей, вебхуков и системных событий' },
|
monitoring: { title: 'Мониторинг', description: 'Состояние очередей, вебхуков и системных событий' },
|
||||||
analytics: { title: 'Аналитика', description: 'Продажи, товары и поведение покупателей' },
|
analytics: { title: 'Аналитика', description: 'Продажи, товары и поведение покупателей' },
|
||||||
sellerManagement: { title: 'Управление продавцами', description: 'Подключайте независимых продавцов к вашему маркетплейсу с централизованным администрированием' },
|
sellerManagement: { title: 'Управление продавцами', description: 'Подключайте независимых продавцов к вашему маркетплейсу с централизованным администрированием' },
|
||||||
|
reports: { title: 'Отчёты', description: 'Экспорт отчётов по продажам, товарам и состоянию маркетплейса' },
|
||||||
|
settings: { title: 'Настройки', description: 'Настройки отображения панели управления' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
adminSellerManagement: {
|
adminSellerManagement: {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export interface Translations {
|
|||||||
compare: string;
|
compare: string;
|
||||||
openMenu: string;
|
openMenu: string;
|
||||||
closeMenu: string;
|
closeMenu: string;
|
||||||
|
login: string;
|
||||||
|
logout: string;
|
||||||
};
|
};
|
||||||
footer: {
|
footer: {
|
||||||
description: string;
|
description: string;
|
||||||
@@ -561,6 +563,7 @@ export interface Translations {
|
|||||||
validationInvalidCss: string;
|
validationInvalidCss: string;
|
||||||
validationDuplicateRoutes: string;
|
validationDuplicateRoutes: string;
|
||||||
validationInvalidWidgetConfig: string;
|
validationInvalidWidgetConfig: string;
|
||||||
|
validationInvalidWidgetSettings: string;
|
||||||
validationInvalidContactEmail: string;
|
validationInvalidContactEmail: string;
|
||||||
validationInvalidSocialLinkUrl: string;
|
validationInvalidSocialLinkUrl: string;
|
||||||
validationIncompletePaymentIcon: string;
|
validationIncompletePaymentIcon: string;
|
||||||
@@ -1113,6 +1116,8 @@ export interface Translations {
|
|||||||
slidesLabel: string;
|
slidesLabel: string;
|
||||||
previousProducts: string;
|
previousProducts: string;
|
||||||
nextProducts: string;
|
nextProducts: string;
|
||||||
|
previousSlide: string;
|
||||||
|
nextSlide: string;
|
||||||
closeDialog: string;
|
closeDialog: string;
|
||||||
dismiss: string;
|
dismiss: string;
|
||||||
qrCode: string;
|
qrCode: string;
|
||||||
@@ -1219,6 +1224,9 @@ export interface Translations {
|
|||||||
unknownWidgetTypeTitle: string;
|
unknownWidgetTypeTitle: string;
|
||||||
unknownWidgetTypeDescription: string;
|
unknownWidgetTypeDescription: string;
|
||||||
unknownWidgetTypeResolution: string;
|
unknownWidgetTypeResolution: string;
|
||||||
|
widgetSettingsSchemaMismatchTitle: string;
|
||||||
|
widgetSettingsSchemaMismatchDescription: string;
|
||||||
|
widgetSettingsSchemaMismatchResolution: string;
|
||||||
duplicateIdTitle: string;
|
duplicateIdTitle: string;
|
||||||
duplicatePageIdDescription: string;
|
duplicatePageIdDescription: string;
|
||||||
duplicateSectionIdDescription: string;
|
duplicateSectionIdDescription: string;
|
||||||
@@ -1917,6 +1925,16 @@ export interface Translations {
|
|||||||
lastPublished: string;
|
lastPublished: string;
|
||||||
syncStatus: string;
|
syncStatus: string;
|
||||||
};
|
};
|
||||||
|
adminReports: {
|
||||||
|
sales: string;
|
||||||
|
topProducts: string;
|
||||||
|
marketplaceHealth: string;
|
||||||
|
};
|
||||||
|
adminSettings: {
|
||||||
|
density: string;
|
||||||
|
densityExplain: string;
|
||||||
|
densityCompact: string;
|
||||||
|
};
|
||||||
adminAnalytics: {
|
adminAnalytics: {
|
||||||
topProductsEmptyTitle: string;
|
topProductsEmptyTitle: string;
|
||||||
topProductsEmptyDescription: string;
|
topProductsEmptyDescription: string;
|
||||||
@@ -2068,6 +2086,8 @@ export interface Translations {
|
|||||||
monitoring: { title: string; description: string };
|
monitoring: { title: string; description: string };
|
||||||
analytics: { title: string; description: string };
|
analytics: { title: string; description: string };
|
||||||
sellerManagement: { title: string; description: string };
|
sellerManagement: { title: string; description: string };
|
||||||
|
reports: { title: string; description: string };
|
||||||
|
settings: { title: string; description: string };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
adminSellerManagement: {
|
adminSellerManagement: {
|
||||||
|
|||||||
@@ -195,9 +195,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payment Popup Modal -->
|
<!-- Payment Popup Modal -->
|
||||||
@if (showPaymentPopup()) {
|
<app-dialog
|
||||||
<div class="payment-modal-overlay">
|
class="payment-dialog"
|
||||||
<div class="payment-modal" #paymentModalPanel role="dialog" aria-modal="true" [attr.aria-label]="'cart.checkout' | translate" tabindex="-1">
|
[open]="showPaymentPopup()"
|
||||||
|
size="md"
|
||||||
|
[ariaLabel]="'cart.checkout' | translate"
|
||||||
|
[closeOnBackdropClick]="false"
|
||||||
|
[closeOnEscape]="!showBankPaymentPopup()"
|
||||||
|
(closed)="closePaymentPopup()"
|
||||||
|
>
|
||||||
<button class="close-modal-btn" (click)="closePaymentPopup()" [attr.aria-label]="'cart.close' | translate">
|
<button class="close-modal-btn" (click)="closePaymentPopup()" [attr.aria-label]="'cart.close' | translate">
|
||||||
<app-icon name="x" [size]="20" />
|
<app-icon name="x" [size]="20" />
|
||||||
</button>
|
</button>
|
||||||
@@ -276,19 +282,22 @@
|
|||||||
<p class="auto-close">{{ 'cart.autoClose' | translate }}</p>
|
<p class="auto-close">{{ 'cart.autoClose' | translate }}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</app-dialog>
|
||||||
|
|
||||||
@if (showBankPaymentPopup() && bankPaymentFrameUrl()) {
|
@if (showBankPaymentPopup() && bankPaymentFrameUrl()) {
|
||||||
<div class="bank-payment-modal-overlay">
|
<app-dialog
|
||||||
<div class="bank-payment-modal" #bankPaymentModalPanel role="dialog" aria-modal="true" [attr.aria-label]="'cart.payViaCard' | translate" tabindex="-1">
|
class="bank-payment-dialog"
|
||||||
|
[open]="showBankPaymentPopup()"
|
||||||
|
size="lg"
|
||||||
|
[ariaLabel]="'cart.payViaCard' | translate"
|
||||||
|
[closeOnBackdropClick]="false"
|
||||||
|
(closed)="closeBankPaymentPopup()"
|
||||||
|
>
|
||||||
<button class="close-modal-btn" (click)="closeBankPaymentPopup()" [attr.aria-label]="'cart.close' | translate">
|
<button class="close-modal-btn" (click)="closeBankPaymentPopup()" [attr.aria-label]="'cart.close' | translate">
|
||||||
<app-icon name="x" [size]="20" />
|
<app-icon name="x" [size]="20" />
|
||||||
</button>
|
</button>
|
||||||
<iframe [src]="bankPaymentFrameUrl()" class="bank-payment-frame" [attr.title]="'common.bankCardPayment' | translate"></iframe>
|
<iframe [src]="bankPaymentFrameUrl()" class="bank-payment-frame" [attr.title]="'common.bankCardPayment' | translate"></iframe>
|
||||||
</div>
|
</app-dialog>
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<app-telegram-login />
|
<app-telegram-login />
|
||||||
|
|||||||
@@ -640,32 +640,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payment modal styles
|
// Payment / bank-payment modals render through the shared app-dialog
|
||||||
.payment-modal-overlay {
|
// primitive (focus-trap, Escape, backdrop-click, ARIA all owned there —
|
||||||
position: fixed;
|
// see closeOnEscape/closeOnBackdropClick usage in the template). These
|
||||||
top: 0;
|
// ::ng-deep overrides restore the exact panel geometry the old hand-rolled
|
||||||
left: 0;
|
// modals had, which app-dialog's generic size presets/padding don't match.
|
||||||
right: 0;
|
:host ::ng-deep {
|
||||||
bottom: 0;
|
app-dialog.payment-dialog .app-dialog-panel {
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payment-modal {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-radius: var(--radius-lg, 13px);
|
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
width: 100%;
|
|
||||||
padding: 40px;
|
|
||||||
position: relative;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
box-shadow: var(--shadow-lg);
|
}
|
||||||
|
|
||||||
|
app-dialog.payment-dialog .app-dialog-panel__body {
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicated: this rule previously appeared twice (identical) - kept a single definition.
|
// Deduplicated: this rule previously appeared twice (identical) - kept a single definition.
|
||||||
@@ -970,25 +958,23 @@
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-payment-modal-overlay {
|
:host ::ng-deep {
|
||||||
position: fixed;
|
app-dialog.bank-payment-dialog .app-dialog-backdrop {
|
||||||
inset: 0;
|
|
||||||
z-index: 1001;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 24px;
|
|
||||||
background: rgba(17, 24, 39, 0.55);
|
background: rgba(17, 24, 39, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-payment-modal {
|
app-dialog.bank-payment-dialog .app-dialog-panel {
|
||||||
position: relative;
|
|
||||||
width: min(960px, 92vw);
|
width: min(960px, 92vw);
|
||||||
|
max-width: min(960px, 92vw);
|
||||||
height: min(760px, 86vh);
|
height: min(760px, 86vh);
|
||||||
padding: 56px 16px 16px;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-radius: var(--radius-lg, 13px);
|
|
||||||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
app-dialog.bank-payment-dialog .app-dialog-panel__body {
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 56px 16px 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-payment-frame {
|
.bank-payment-frame {
|
||||||
@@ -1041,9 +1027,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.payment-modal {
|
:host ::ng-deep app-dialog.payment-dialog .app-dialog-panel__body {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
padding-top: 56px; // <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
padding-top: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-modal-btn {
|
.close-modal-btn {
|
||||||
@@ -1078,15 +1064,21 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-payment-modal-overlay {
|
:host ::ng-deep {
|
||||||
|
app-dialog.bank-payment-dialog .app-dialog-backdrop {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bank-payment-modal {
|
app-dialog.bank-payment-dialog .app-dialog-panel {
|
||||||
width: 94vw;
|
width: 94vw;
|
||||||
|
max-width: 94vw;
|
||||||
height: 82vh;
|
height: 82vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
app-dialog.bank-payment-dialog .app-dialog-panel__body {
|
||||||
padding: 52px 10px 10px;
|
padding: 52px 10px 10px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.payment-active h2 {
|
.payment-active h2 {
|
||||||
font-size: var(--font-size-2xl, 1.25rem);
|
font-size: var(--font-size-2xl, 1.25rem);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, ChangeDetectionStrategy, signal, OnDestroy, inject, ElementRef, ViewChild, HostListener, effect } from '@angular/core';
|
import { Component, ChangeDetectionStrategy, signal, OnDestroy, inject } from '@angular/core';
|
||||||
import { DecimalPipe } from '@angular/common';
|
import { DecimalPipe } from '@angular/common';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
@@ -21,15 +21,13 @@ import { ConfigService } from '../../core/config/config.service';
|
|||||||
import { TenantResolverService } from '../../core/config/tenant-resolver.service';
|
import { TenantResolverService } from '../../core/config/tenant-resolver.service';
|
||||||
import { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service';
|
import { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service';
|
||||||
import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component';
|
||||||
|
import { DialogComponent } from '../../shared/ui/dialog/dialog.component';
|
||||||
|
|
||||||
type PaymentMethod = 'qr' | 'card';
|
type PaymentMethod = 'qr' | 'card';
|
||||||
|
|
||||||
const MODAL_FOCUSABLE_SELECTOR =
|
|
||||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-cart',
|
selector: 'app-cart',
|
||||||
imports: [DecimalPipe, RouterLink, FormsModule, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe, IconComponent, EmptyStateComponent, ButtonComponent, ConfirmDialogComponent],
|
imports: [DecimalPipe, RouterLink, FormsModule, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe, IconComponent, EmptyStateComponent, ButtonComponent, ConfirmDialogComponent, DialogComponent],
|
||||||
templateUrl: './cart.component.html',
|
templateUrl: './cart.component.html',
|
||||||
styleUrls: ['./cart.component.scss'],
|
styleUrls: ['./cart.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
@@ -82,13 +80,6 @@ export class CartComponent implements OnDestroy {
|
|||||||
private configService = inject(ConfigService);
|
private configService = inject(ConfigService);
|
||||||
private tenantResolver = inject(TenantResolverService);
|
private tenantResolver = inject(TenantResolverService);
|
||||||
|
|
||||||
// Focus management for the custom payment/bank-payment modals (not
|
|
||||||
// app-dialog — they own a real multi-step state machine). Mirrors
|
|
||||||
// app-dialog's confirmed-correct focus-trap/Escape/return-focus pattern.
|
|
||||||
@ViewChild('paymentModalPanel') private paymentModalPanel?: ElementRef<HTMLElement>;
|
|
||||||
@ViewChild('bankPaymentModalPanel') private bankPaymentModalPanel?: ElementRef<HTMLElement>;
|
|
||||||
private previouslyFocusedBeforeModal: HTMLElement | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private cartService: CartService,
|
private cartService: CartService,
|
||||||
private apiService: ApiService,
|
private apiService: ApiService,
|
||||||
@@ -103,78 +94,6 @@ export class CartComponent implements OnDestroy {
|
|||||||
this.totalWithDelivery = this.cartService.totalWithDelivery;
|
this.totalWithDelivery = this.cartService.totalWithDelivery;
|
||||||
this.hasDeliveryPrice = this.cartService.hasDeliveryPrice;
|
this.hasDeliveryPrice = this.cartService.hasDeliveryPrice;
|
||||||
this.allRequiredDeliveriesSelected = this.cartService.allRequiredDeliveriesSelected;
|
this.allRequiredDeliveriesSelected = this.cartService.allRequiredDeliveriesSelected;
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
const isOpen = this.showPaymentPopup();
|
|
||||||
if (isOpen) {
|
|
||||||
this.previouslyFocusedBeforeModal ??= document.activeElement as HTMLElement | null;
|
|
||||||
queueMicrotask(() => this.focusActiveModalPanel());
|
|
||||||
} else if (this.previouslyFocusedBeforeModal) {
|
|
||||||
this.previouslyFocusedBeforeModal.focus();
|
|
||||||
this.previouslyFocusedBeforeModal = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
effect(() => {
|
|
||||||
if (this.showBankPaymentPopup()) {
|
|
||||||
queueMicrotask(() => this.focusActiveModalPanel());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostListener('document:keydown', ['$event'])
|
|
||||||
protected handleModalKeydown(event: KeyboardEvent): void {
|
|
||||||
if (!this.showPaymentPopup()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
if (this.showBankPaymentPopup()) {
|
|
||||||
this.closeBankPaymentPopup();
|
|
||||||
} else {
|
|
||||||
this.closePaymentPopup();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key === 'Tab') {
|
|
||||||
this.trapModalFocus(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private activeModalPanel(): HTMLElement | undefined {
|
|
||||||
return this.showBankPaymentPopup()
|
|
||||||
? this.bankPaymentModalPanel?.nativeElement
|
|
||||||
: this.paymentModalPanel?.nativeElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
private focusActiveModalPanel(): void {
|
|
||||||
const panel = this.activeModalPanel();
|
|
||||||
if (!panel) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const focusable = panel.querySelectorAll<HTMLElement>(MODAL_FOCUSABLE_SELECTOR);
|
|
||||||
(focusable[0] ?? panel).focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
private trapModalFocus(event: KeyboardEvent): void {
|
|
||||||
const panel = this.activeModalPanel();
|
|
||||||
if (!panel) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const focusable = Array.from(panel.querySelectorAll<HTMLElement>(MODAL_FOCUSABLE_SELECTOR));
|
|
||||||
if (focusable.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const first = focusable[0];
|
|
||||||
const last = focusable[focusable.length - 1];
|
|
||||||
const active = document.activeElement;
|
|
||||||
|
|
||||||
if (event.shiftKey && active === first) {
|
|
||||||
event.preventDefault();
|
|
||||||
last.focus();
|
|
||||||
} else if (!event.shiftKey && active === last) {
|
|
||||||
event.preventDefault();
|
|
||||||
first.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
requestLogin(): void {
|
requestLogin(): void {
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ export interface TenantConfig {
|
|||||||
defaultCurrency: string;
|
defaultCurrency: string;
|
||||||
supportedCurrencies: string[];
|
supportedCurrencies: string[];
|
||||||
timezone: string;
|
timezone: string;
|
||||||
|
documentationUrl?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
[class.app-dialog-panel--lg]="size() === 'lg'"
|
[class.app-dialog-panel--lg]="size() === 'lg'"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
[attr.aria-label]="titleText()"
|
[attr.aria-label]="titleText() ?? ariaLabel()"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.app-dialog-panel {
|
.app-dialog-panel {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { TranslatePipe } from '../../../i18n/translate.pipe';
|
|||||||
export type DialogSize = 'sm' | 'md' | 'lg';
|
export type DialogSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
const FOCUSABLE_SELECTOR =
|
const FOCUSABLE_SELECTOR =
|
||||||
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-dialog',
|
selector: 'app-dialog',
|
||||||
@@ -28,7 +28,14 @@ const FOCUSABLE_SELECTOR =
|
|||||||
export class DialogComponent implements OnChanges, AfterViewInit {
|
export class DialogComponent implements OnChanges, AfterViewInit {
|
||||||
readonly open = input(false);
|
readonly open = input(false);
|
||||||
readonly titleText = input<string | null>(null);
|
readonly titleText = input<string | null>(null);
|
||||||
|
// Accessible name for dialogs that skip the visible titled header (a
|
||||||
|
// consumer-rendered close button inside the content instead). Ignored
|
||||||
|
// when titleText is set - the header already supplies the name.
|
||||||
|
readonly ariaLabel = input<string | null>(null);
|
||||||
readonly size = input<DialogSize>('md');
|
readonly size = input<DialogSize>('md');
|
||||||
|
// Both default true (existing behavior for every current consumer).
|
||||||
|
readonly closeOnEscape = input(true);
|
||||||
|
readonly closeOnBackdropClick = input(true);
|
||||||
|
|
||||||
readonly closed = output<void>();
|
readonly closed = output<void>();
|
||||||
|
|
||||||
@@ -60,7 +67,9 @@ export class DialogComponent implements OnChanges, AfterViewInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
|
if (this.closeOnEscape()) {
|
||||||
this.requestClose();
|
this.requestClose();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === 'Tab') {
|
if (event.key === 'Tab') {
|
||||||
@@ -73,8 +82,10 @@ export class DialogComponent implements OnChanges, AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected handleBackdropClick(): void {
|
protected handleBackdropClick(): void {
|
||||||
|
if (this.closeOnBackdropClick()) {
|
||||||
this.requestClose();
|
this.requestClose();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private focusPanel(): void {
|
private focusPanel(): void {
|
||||||
const panel = this.panelRef?.nativeElement;
|
const panel = this.panelRef?.nativeElement;
|
||||||
|
|||||||
@@ -1,179 +1,414 @@
|
|||||||
import {
|
|
||||||
LucideAlignLeft as AlignLeft,
|
|
||||||
LucideArrowDown as ArrowDown,
|
|
||||||
LucideArrowLeft as ArrowLeft,
|
|
||||||
LucideArrowRight as ArrowRight,
|
|
||||||
LucideArrowUp as ArrowUp,
|
|
||||||
LucideBadgeCheck as BadgeCheck,
|
|
||||||
LucideBarChart3 as BarChart3,
|
|
||||||
LucideBell as Bell,
|
|
||||||
LucideBook as Book,
|
|
||||||
LucideBoxes as Boxes,
|
|
||||||
LucideBuilding2 as Building2,
|
|
||||||
LucideCalendar as Calendar,
|
|
||||||
LucideCheck as Check,
|
|
||||||
LucideCheckCircle2 as CheckCircle2,
|
|
||||||
LucideChevronDown as ChevronDown,
|
|
||||||
LucideChevronLeft as ChevronLeft,
|
|
||||||
LucideChevronRight as ChevronRight,
|
|
||||||
LucideChevronUp as ChevronUp,
|
|
||||||
LucideCircle as Circle,
|
|
||||||
LucideClock as Clock,
|
|
||||||
LucideCode2 as Code2,
|
|
||||||
LucideCompass as Compass,
|
|
||||||
LucideCopy as Copy,
|
|
||||||
LucideCreditCard as CreditCard,
|
|
||||||
LucideDatabase as Database,
|
|
||||||
LucideDownload as Download,
|
|
||||||
LucideFilter as Filter,
|
|
||||||
LucideFlag as Flag,
|
|
||||||
LucideFolder as Folder,
|
|
||||||
LucideGlobe as Globe,
|
|
||||||
LucideGripVertical as GripVertical,
|
|
||||||
LucideHeart as Heart,
|
|
||||||
LucideHelpCircle as HelpCircle,
|
|
||||||
LucideHistory as History,
|
|
||||||
LucideHome as Home,
|
|
||||||
LucideImage as Image,
|
|
||||||
LucideImages as Images,
|
|
||||||
LucideInfo as Info,
|
|
||||||
LucideLayoutGrid as LayoutGrid,
|
|
||||||
LucideLineChart as LineChart,
|
|
||||||
LucideLink as Link,
|
|
||||||
LucideList as List,
|
|
||||||
LucideLoader2 as Loader2,
|
|
||||||
LucideLocate as Locate,
|
|
||||||
LucideLock as Lock,
|
|
||||||
LucideLogOut as LogOut,
|
|
||||||
LucideMapPin as MapPin,
|
|
||||||
LucideMegaphone as Megaphone,
|
|
||||||
LucideMenu as Menu,
|
|
||||||
LucideMinus as Minus,
|
|
||||||
LucideMonitor as Monitor,
|
|
||||||
LucideNetwork as Network,
|
|
||||||
LucidePackage as Package,
|
|
||||||
LucidePalette as Palette,
|
|
||||||
LucidePencil as Pencil,
|
|
||||||
LucidePlus as Plus,
|
|
||||||
LucidePlusCircle as PlusCircle,
|
|
||||||
LucideRefreshCw as RefreshCw,
|
|
||||||
LucideSave as Save,
|
|
||||||
LucideScale as Scale,
|
|
||||||
LucideSearch as Search,
|
|
||||||
LucideSettings as Settings,
|
|
||||||
LucideShield as Shield,
|
|
||||||
LucideShoppingCart as ShoppingCart,
|
|
||||||
LucideSlidersHorizontal as SlidersHorizontal,
|
|
||||||
LucideSquare as Square,
|
|
||||||
LucideStar as Star,
|
|
||||||
LucideStore as Store,
|
|
||||||
LucideTable as Table,
|
|
||||||
LucideTag as Tag,
|
|
||||||
LucideTags as Tags,
|
|
||||||
LucideThumbsDown as ThumbsDown,
|
|
||||||
LucideThumbsUp as ThumbsUp,
|
|
||||||
LucideTrash2 as Trash2,
|
|
||||||
LucideTrendingUp as TrendingUp,
|
|
||||||
LucideTriangleAlert as TriangleAlert,
|
|
||||||
LucideUpload as Upload,
|
|
||||||
LucideUser as User,
|
|
||||||
LucideUsers as Users,
|
|
||||||
LucideVideo as Video,
|
|
||||||
LucideX as X,
|
|
||||||
LucideXCircle as XCircle,
|
|
||||||
LucideZap as Zap
|
|
||||||
} from '@lucide/angular';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Canonical name -> Lucide icon map for the whole app (storefront + builder + backoffice).
|
* Canonical name -> raw Lucide icon node data map for the whole app
|
||||||
* One entry per concept so every screen renders the same icon for the same meaning
|
* (storefront + builder + backoffice). One entry per concept so every screen
|
||||||
* (RC UI Sprint - Icon & Visual Language Audit). Extend this list rather than
|
* renders the same icon for the same meaning (RC UI Sprint - Icon & Visual
|
||||||
* importing Lucide icons ad hoc in individual components.
|
* Language Audit). Extend this list rather than importing icon data ad hoc in
|
||||||
|
* individual components.
|
||||||
|
*
|
||||||
|
* Node data is transcribed directly from @lucide/angular's icon set
|
||||||
|
* (fesm2022/lucide-angular.mjs) rather than importing the package, because
|
||||||
|
* the package ships as a single non-tree-shakeable bundle that would pull
|
||||||
|
* ~1500 unused icon classes into the initial JS bundle. See IconComponent
|
||||||
|
* for the renderer that consumes this data.
|
||||||
*/
|
*/
|
||||||
|
export type LucideIconNode = readonly [string, Readonly<Record<string, string>>];
|
||||||
|
|
||||||
export const APP_ICONS = {
|
export const APP_ICONS = {
|
||||||
home: Home,
|
home: [
|
||||||
store: Store,
|
["path", { "d": "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" }],
|
||||||
cart: ShoppingCart,
|
["path", { "d": "M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" }]
|
||||||
package: Package,
|
],
|
||||||
boxes: Boxes,
|
store: [
|
||||||
folder: Folder,
|
["path", { "d": "M15 21v-5a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v5" }],
|
||||||
tag: Tag,
|
["path", { "d": "M17.774 10.31a1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.451 0 1.12 1.12 0 0 0-1.548 0 2.5 2.5 0 0 1-3.452 0 1.12 1.12 0 0 0-1.549 0 2.5 2.5 0 0 1-3.77-3.248l2.889-4.184A2 2 0 0 1 7 2h10a2 2 0 0 1 1.653.873l2.895 4.192a2.5 2.5 0 0 1-3.774 3.244" }],
|
||||||
tags: Tags,
|
["path", { "d": "M4 10.95V19a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8.05" }]
|
||||||
users: Users,
|
],
|
||||||
user: User,
|
cart: [
|
||||||
settings: Settings,
|
["circle", { "cx": "8", "cy": "21", "r": "1" }],
|
||||||
chartBar: BarChart3,
|
["circle", { "cx": "19", "cy": "21", "r": "1" }],
|
||||||
chartLine: LineChart,
|
["path", { "d": "M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12" }]
|
||||||
search: Search,
|
],
|
||||||
filter: Filter,
|
package: [
|
||||||
heart: Heart,
|
["path", { "d": "M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z" }],
|
||||||
scale: Scale,
|
["path", { "d": "M12 22V12" }],
|
||||||
bell: Bell,
|
["polyline", { "points": "3.29 7 12 12 20.71 7" }],
|
||||||
image: Image,
|
["path", { "d": "m7.5 4.27 9 5.15" }]
|
||||||
images: Images,
|
],
|
||||||
palette: Palette,
|
boxes: [
|
||||||
monitor: Monitor,
|
["path", { "d": "M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" }],
|
||||||
shield: Shield,
|
["path", { "d": "m7 16.5-4.74-2.85" }],
|
||||||
lock: Lock,
|
["path", { "d": "m7 16.5 5-3" }],
|
||||||
database: Database,
|
["path", { "d": "M7 16.5v5.17" }],
|
||||||
upload: Upload,
|
["path", { "d": "M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" }],
|
||||||
download: Download,
|
["path", { "d": "m17 16.5-5-3" }],
|
||||||
edit: Pencil,
|
["path", { "d": "m17 16.5 4.74-2.85" }],
|
||||||
trash: Trash2,
|
["path", { "d": "M17 16.5v5.17" }],
|
||||||
copy: Copy,
|
["path", { "d": "M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z" }],
|
||||||
save: Save,
|
["path", { "d": "M12 8 7.26 5.15" }],
|
||||||
plus: Plus,
|
["path", { "d": "m12 8 4.74-2.85" }],
|
||||||
plusCircle: PlusCircle,
|
["path", { "d": "M12 13.5V8" }]
|
||||||
minus: Minus,
|
],
|
||||||
refresh: RefreshCw,
|
folder: [
|
||||||
check: Check,
|
["path", { "d": "M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" }]
|
||||||
checkCircle: CheckCircle2,
|
],
|
||||||
x: X,
|
tag: [
|
||||||
xCircle: XCircle,
|
["path", { "d": "M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" }],
|
||||||
arrowUp: ArrowUp,
|
["circle", { "cx": "7.5", "cy": "7.5", "r": ".5", "fill": "currentColor" }]
|
||||||
arrowDown: ArrowDown,
|
],
|
||||||
arrowLeft: ArrowLeft,
|
tags: [
|
||||||
arrowRight: ArrowRight,
|
["path", { "d": "M13.172 2a2 2 0 0 1 1.414.586l6.71 6.71a2.4 2.4 0 0 1 0 3.408l-4.592 4.592a2.4 2.4 0 0 1-3.408 0l-6.71-6.71A2 2 0 0 1 6 9.172V3a1 1 0 0 1 1-1z" }],
|
||||||
chevronUp: ChevronUp,
|
["path", { "d": "M2 7v6.172a2 2 0 0 0 .586 1.414l6.71 6.71a2.4 2.4 0 0 0 3.191.193" }],
|
||||||
chevronDown: ChevronDown,
|
["circle", { "cx": "10.5", "cy": "6.5", "r": ".5", "fill": "currentColor" }]
|
||||||
chevronLeft: ChevronLeft,
|
],
|
||||||
chevronRight: ChevronRight,
|
users: [
|
||||||
info: Info,
|
["path", { "d": "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" }],
|
||||||
help: HelpCircle,
|
["path", { "d": "M16 3.128a4 4 0 0 1 0 7.744" }],
|
||||||
warning: TriangleAlert,
|
["path", { "d": "M22 21v-2a4 4 0 0 0-3-3.87" }],
|
||||||
clock: Clock,
|
["circle", { "cx": "9", "cy": "7", "r": "4" }]
|
||||||
calendar: Calendar,
|
],
|
||||||
menu: Menu,
|
user: [
|
||||||
alignLeft: AlignLeft,
|
["path", { "d": "M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" }],
|
||||||
book: Book,
|
["circle", { "cx": "12", "cy": "7", "r": "4" }]
|
||||||
building: Building2,
|
],
|
||||||
code: Code2,
|
settings: [
|
||||||
compass: Compass,
|
["path", { "d": "M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" }],
|
||||||
creditCard: CreditCard,
|
["circle", { "cx": "12", "cy": "12", "r": "3" }]
|
||||||
flag: Flag,
|
],
|
||||||
globe: Globe,
|
chartBar: [
|
||||||
grip: GripVertical,
|
["path", { "d": "M3 3v16a2 2 0 0 0 2 2h16" }],
|
||||||
history: History,
|
["path", { "d": "M18 17V9" }],
|
||||||
link: Link,
|
["path", { "d": "M13 17V5" }],
|
||||||
list: List,
|
["path", { "d": "M8 17v-3" }]
|
||||||
locate: Locate,
|
],
|
||||||
mapPin: MapPin,
|
chartLine: [
|
||||||
megaphone: Megaphone,
|
["path", { "d": "M3 3v16a2 2 0 0 0 2 2h16" }],
|
||||||
logOut: LogOut,
|
["path", { "d": "m19 9-5 5-4-4-3 3" }]
|
||||||
network: Network,
|
],
|
||||||
slidersHorizontal: SlidersHorizontal,
|
search: [
|
||||||
spinner: Loader2,
|
["path", { "d": "m21 21-4.34-4.34" }],
|
||||||
star: Star,
|
["circle", { "cx": "11", "cy": "11", "r": "8" }]
|
||||||
stop: Square,
|
],
|
||||||
table: Table,
|
filter: [
|
||||||
layoutGrid: LayoutGrid,
|
["path", { "d": "M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z" }]
|
||||||
thumbsUp: ThumbsUp,
|
],
|
||||||
thumbsDown: ThumbsDown,
|
heart: [
|
||||||
verified: BadgeCheck,
|
["path", { "d": "M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5" }]
|
||||||
video: Video,
|
],
|
||||||
circle: Circle,
|
scale: [
|
||||||
zap: Zap,
|
["path", { "d": "M12 3v18" }],
|
||||||
trendingUp: TrendingUp
|
["path", { "d": "m19 8 3 8a5 5 0 0 1-6 0zV7" }],
|
||||||
} as const;
|
["path", { "d": "M3 7h1a17 17 0 0 0 8-2 17 17 0 0 0 8 2h1" }],
|
||||||
|
["path", { "d": "m5 8 3 8a5 5 0 0 1-6 0zV7" }],
|
||||||
|
["path", { "d": "M7 21h10" }]
|
||||||
|
],
|
||||||
|
bell: [
|
||||||
|
["path", { "d": "M10.268 21a2 2 0 0 0 3.464 0" }],
|
||||||
|
["path", { "d": "M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" }]
|
||||||
|
],
|
||||||
|
image: [
|
||||||
|
["rect", { "width": "18", "height": "18", "x": "3", "y": "3", "rx": "2", "ry": "2" }],
|
||||||
|
["circle", { "cx": "9", "cy": "9", "r": "2" }],
|
||||||
|
["path", { "d": "m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" }]
|
||||||
|
],
|
||||||
|
images: [
|
||||||
|
["path", { "d": "m22 11-1.296-1.296a2.4 2.4 0 0 0-3.408 0L11 16" }],
|
||||||
|
["path", { "d": "M4 8a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2" }],
|
||||||
|
["circle", { "cx": "13", "cy": "7", "r": "1", "fill": "currentColor" }],
|
||||||
|
["rect", { "x": "8", "y": "2", "width": "14", "height": "14", "rx": "2" }]
|
||||||
|
],
|
||||||
|
palette: [
|
||||||
|
["path", { "d": "M12 22a1 1 0 0 1 0-20 10 9 0 0 1 10 9 5 5 0 0 1-5 5h-2.25a1.75 1.75 0 0 0-1.4 2.8l.3.4a1.75 1.75 0 0 1-1.4 2.8z" }],
|
||||||
|
["circle", { "cx": "13.5", "cy": "6.5", "r": ".5", "fill": "currentColor" }],
|
||||||
|
["circle", { "cx": "17.5", "cy": "10.5", "r": ".5", "fill": "currentColor" }],
|
||||||
|
["circle", { "cx": "6.5", "cy": "12.5", "r": ".5", "fill": "currentColor" }],
|
||||||
|
["circle", { "cx": "8.5", "cy": "7.5", "r": ".5", "fill": "currentColor" }]
|
||||||
|
],
|
||||||
|
monitor: [
|
||||||
|
["rect", { "width": "20", "height": "14", "x": "2", "y": "3", "rx": "2" }],
|
||||||
|
["line", { "x1": "8", "x2": "16", "y1": "21", "y2": "21" }],
|
||||||
|
["line", { "x1": "12", "x2": "12", "y1": "17", "y2": "21" }]
|
||||||
|
],
|
||||||
|
shield: [
|
||||||
|
["path", { "d": "M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" }]
|
||||||
|
],
|
||||||
|
lock: [
|
||||||
|
["rect", { "width": "18", "height": "11", "x": "3", "y": "11", "rx": "2", "ry": "2" }],
|
||||||
|
["path", { "d": "M7 11V7a5 5 0 0 1 10 0v4" }]
|
||||||
|
],
|
||||||
|
database: [
|
||||||
|
["ellipse", { "cx": "12", "cy": "5", "rx": "9", "ry": "3" }],
|
||||||
|
["path", { "d": "M3 5V19A9 3 0 0 0 21 19V5" }],
|
||||||
|
["path", { "d": "M3 12A9 3 0 0 0 21 12" }]
|
||||||
|
],
|
||||||
|
upload: [
|
||||||
|
["path", { "d": "M12 3v12" }],
|
||||||
|
["path", { "d": "m17 8-5-5-5 5" }],
|
||||||
|
["path", { "d": "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }]
|
||||||
|
],
|
||||||
|
download: [
|
||||||
|
["path", { "d": "M12 15V3" }],
|
||||||
|
["path", { "d": "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }],
|
||||||
|
["path", { "d": "m7 10 5 5 5-5" }]
|
||||||
|
],
|
||||||
|
edit: [
|
||||||
|
["path", { "d": "M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" }],
|
||||||
|
["path", { "d": "m15 5 4 4" }]
|
||||||
|
],
|
||||||
|
trash: [
|
||||||
|
["path", { "d": "M10 11v6" }],
|
||||||
|
["path", { "d": "M14 11v6" }],
|
||||||
|
["path", { "d": "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" }],
|
||||||
|
["path", { "d": "M3 6h18" }],
|
||||||
|
["path", { "d": "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }]
|
||||||
|
],
|
||||||
|
copy: [
|
||||||
|
["rect", { "width": "14", "height": "14", "x": "8", "y": "8", "rx": "2", "ry": "2" }],
|
||||||
|
["path", { "d": "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" }]
|
||||||
|
],
|
||||||
|
save: [
|
||||||
|
["path", { "d": "M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z" }],
|
||||||
|
["path", { "d": "M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7" }],
|
||||||
|
["path", { "d": "M7 3v4a1 1 0 0 0 1 1h7" }]
|
||||||
|
],
|
||||||
|
plus: [
|
||||||
|
["path", { "d": "M5 12h14" }],
|
||||||
|
["path", { "d": "M12 5v14" }]
|
||||||
|
],
|
||||||
|
plusCircle: [
|
||||||
|
["circle", { "cx": "12", "cy": "12", "r": "10" }],
|
||||||
|
["path", { "d": "M8 12h8" }],
|
||||||
|
["path", { "d": "M12 8v8" }]
|
||||||
|
],
|
||||||
|
minus: [
|
||||||
|
["path", { "d": "M5 12h14" }]
|
||||||
|
],
|
||||||
|
refresh: [
|
||||||
|
["path", { "d": "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" }],
|
||||||
|
["path", { "d": "M21 3v5h-5" }],
|
||||||
|
["path", { "d": "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" }],
|
||||||
|
["path", { "d": "M8 16H3v5" }]
|
||||||
|
],
|
||||||
|
check: [
|
||||||
|
["path", { "d": "M20 6 9 17l-5-5" }]
|
||||||
|
],
|
||||||
|
checkCircle: [
|
||||||
|
["circle", { "cx": "12", "cy": "12", "r": "10" }],
|
||||||
|
["path", { "d": "m9 12 2 2 4-4" }]
|
||||||
|
],
|
||||||
|
x: [
|
||||||
|
["path", { "d": "M18 6 6 18" }],
|
||||||
|
["path", { "d": "m6 6 12 12" }]
|
||||||
|
],
|
||||||
|
xCircle: [
|
||||||
|
["circle", { "cx": "12", "cy": "12", "r": "10" }],
|
||||||
|
["path", { "d": "m15 9-6 6" }],
|
||||||
|
["path", { "d": "m9 9 6 6" }]
|
||||||
|
],
|
||||||
|
arrowUp: [
|
||||||
|
["path", { "d": "m5 12 7-7 7 7" }],
|
||||||
|
["path", { "d": "M12 19V5" }]
|
||||||
|
],
|
||||||
|
arrowDown: [
|
||||||
|
["path", { "d": "M12 5v14" }],
|
||||||
|
["path", { "d": "m19 12-7 7-7-7" }]
|
||||||
|
],
|
||||||
|
arrowLeft: [
|
||||||
|
["path", { "d": "m12 19-7-7 7-7" }],
|
||||||
|
["path", { "d": "M19 12H5" }]
|
||||||
|
],
|
||||||
|
arrowRight: [
|
||||||
|
["path", { "d": "M5 12h14" }],
|
||||||
|
["path", { "d": "m12 5 7 7-7 7" }]
|
||||||
|
],
|
||||||
|
chevronUp: [
|
||||||
|
["path", { "d": "m18 15-6-6-6 6" }]
|
||||||
|
],
|
||||||
|
chevronDown: [
|
||||||
|
["path", { "d": "m6 9 6 6 6-6" }]
|
||||||
|
],
|
||||||
|
chevronLeft: [
|
||||||
|
["path", { "d": "m15 18-6-6 6-6" }]
|
||||||
|
],
|
||||||
|
chevronRight: [
|
||||||
|
["path", { "d": "m9 18 6-6-6-6" }]
|
||||||
|
],
|
||||||
|
info: [
|
||||||
|
["circle", { "cx": "12", "cy": "12", "r": "10" }],
|
||||||
|
["path", { "d": "M12 16v-4" }],
|
||||||
|
["path", { "d": "M12 8h.01" }]
|
||||||
|
],
|
||||||
|
help: [
|
||||||
|
["circle", { "cx": "12", "cy": "12", "r": "10" }],
|
||||||
|
["path", { "d": "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" }],
|
||||||
|
["path", { "d": "M12 17h.01" }]
|
||||||
|
],
|
||||||
|
warning: [
|
||||||
|
["path", { "d": "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" }],
|
||||||
|
["path", { "d": "M12 9v4" }],
|
||||||
|
["path", { "d": "M12 17h.01" }]
|
||||||
|
],
|
||||||
|
clock: [
|
||||||
|
["circle", { "cx": "12", "cy": "12", "r": "10" }],
|
||||||
|
["path", { "d": "M12 6v6l4 2" }]
|
||||||
|
],
|
||||||
|
calendar: [
|
||||||
|
["path", { "d": "M8 2v4" }],
|
||||||
|
["path", { "d": "M16 2v4" }],
|
||||||
|
["rect", { "width": "18", "height": "18", "x": "3", "y": "4", "rx": "2" }],
|
||||||
|
["path", { "d": "M3 10h18" }]
|
||||||
|
],
|
||||||
|
menu: [
|
||||||
|
["path", { "d": "M4 5h16" }],
|
||||||
|
["path", { "d": "M4 12h16" }],
|
||||||
|
["path", { "d": "M4 19h16" }]
|
||||||
|
],
|
||||||
|
alignLeft: [
|
||||||
|
["path", { "d": "M21 5H3" }],
|
||||||
|
["path", { "d": "M15 12H3" }],
|
||||||
|
["path", { "d": "M17 19H3" }]
|
||||||
|
],
|
||||||
|
book: [
|
||||||
|
["path", { "d": "M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" }]
|
||||||
|
],
|
||||||
|
building: [
|
||||||
|
["path", { "d": "M10 12h4" }],
|
||||||
|
["path", { "d": "M10 8h4" }],
|
||||||
|
["path", { "d": "M14 21v-3a2 2 0 0 0-4 0v3" }],
|
||||||
|
["path", { "d": "M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2" }],
|
||||||
|
["path", { "d": "M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16" }]
|
||||||
|
],
|
||||||
|
code: [
|
||||||
|
["path", { "d": "m18 16 4-4-4-4" }],
|
||||||
|
["path", { "d": "m6 8-4 4 4 4" }],
|
||||||
|
["path", { "d": "m14.5 4-5 16" }]
|
||||||
|
],
|
||||||
|
compass: [
|
||||||
|
["circle", { "cx": "12", "cy": "12", "r": "10" }],
|
||||||
|
["path", { "d": "m16.24 7.76-1.804 5.411a2 2 0 0 1-1.265 1.265L7.76 16.24l1.804-5.411a2 2 0 0 1 1.265-1.265z" }]
|
||||||
|
],
|
||||||
|
creditCard: [
|
||||||
|
["rect", { "width": "20", "height": "14", "x": "2", "y": "5", "rx": "2" }],
|
||||||
|
["line", { "x1": "2", "x2": "22", "y1": "10", "y2": "10" }]
|
||||||
|
],
|
||||||
|
flag: [
|
||||||
|
["path", { "d": "M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528" }]
|
||||||
|
],
|
||||||
|
globe: [
|
||||||
|
["circle", { "cx": "12", "cy": "12", "r": "10" }],
|
||||||
|
["path", { "d": "M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" }],
|
||||||
|
["path", { "d": "M2 12h20" }]
|
||||||
|
],
|
||||||
|
grip: [
|
||||||
|
["circle", { "cx": "9", "cy": "12", "r": "1" }],
|
||||||
|
["circle", { "cx": "9", "cy": "5", "r": "1" }],
|
||||||
|
["circle", { "cx": "9", "cy": "19", "r": "1" }],
|
||||||
|
["circle", { "cx": "15", "cy": "12", "r": "1" }],
|
||||||
|
["circle", { "cx": "15", "cy": "5", "r": "1" }],
|
||||||
|
["circle", { "cx": "15", "cy": "19", "r": "1" }]
|
||||||
|
],
|
||||||
|
history: [
|
||||||
|
["path", { "d": "M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" }],
|
||||||
|
["path", { "d": "M3 3v5h5" }],
|
||||||
|
["path", { "d": "M12 7v5l4 2" }]
|
||||||
|
],
|
||||||
|
link: [
|
||||||
|
["path", { "d": "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" }],
|
||||||
|
["path", { "d": "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" }]
|
||||||
|
],
|
||||||
|
list: [
|
||||||
|
["path", { "d": "M3 5h.01" }],
|
||||||
|
["path", { "d": "M3 12h.01" }],
|
||||||
|
["path", { "d": "M3 19h.01" }],
|
||||||
|
["path", { "d": "M8 5h13" }],
|
||||||
|
["path", { "d": "M8 12h13" }],
|
||||||
|
["path", { "d": "M8 19h13" }]
|
||||||
|
],
|
||||||
|
locate: [
|
||||||
|
["line", { "x1": "2", "x2": "5", "y1": "12", "y2": "12" }],
|
||||||
|
["line", { "x1": "19", "x2": "22", "y1": "12", "y2": "12" }],
|
||||||
|
["line", { "x1": "12", "x2": "12", "y1": "2", "y2": "5" }],
|
||||||
|
["line", { "x1": "12", "x2": "12", "y1": "19", "y2": "22" }],
|
||||||
|
["circle", { "cx": "12", "cy": "12", "r": "7" }]
|
||||||
|
],
|
||||||
|
mapPin: [
|
||||||
|
["path", { "d": "M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" }],
|
||||||
|
["circle", { "cx": "12", "cy": "10", "r": "3" }]
|
||||||
|
],
|
||||||
|
megaphone: [
|
||||||
|
["path", { "d": "M11 6a13 13 0 0 0 8.4-2.8A1 1 0 0 1 21 4v12a1 1 0 0 1-1.6.8A13 13 0 0 0 11 14H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z" }],
|
||||||
|
["path", { "d": "M6 14a12 12 0 0 0 2.4 7.2 2 2 0 0 0 3.2-2.4A8 8 0 0 1 10 14" }],
|
||||||
|
["path", { "d": "M8 6v8" }]
|
||||||
|
],
|
||||||
|
logOut: [
|
||||||
|
["path", { "d": "m16 17 5-5-5-5" }],
|
||||||
|
["path", { "d": "M21 12H9" }],
|
||||||
|
["path", { "d": "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }]
|
||||||
|
],
|
||||||
|
network: [
|
||||||
|
["rect", { "x": "16", "y": "16", "width": "6", "height": "6", "rx": "1" }],
|
||||||
|
["rect", { "x": "2", "y": "16", "width": "6", "height": "6", "rx": "1" }],
|
||||||
|
["rect", { "x": "9", "y": "2", "width": "6", "height": "6", "rx": "1" }],
|
||||||
|
["path", { "d": "M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" }],
|
||||||
|
["path", { "d": "M12 12V8" }]
|
||||||
|
],
|
||||||
|
slidersHorizontal: [
|
||||||
|
["path", { "d": "M10 5H3" }],
|
||||||
|
["path", { "d": "M12 19H3" }],
|
||||||
|
["path", { "d": "M14 3v4" }],
|
||||||
|
["path", { "d": "M16 17v4" }],
|
||||||
|
["path", { "d": "M21 12h-9" }],
|
||||||
|
["path", { "d": "M21 19h-5" }],
|
||||||
|
["path", { "d": "M21 5h-7" }],
|
||||||
|
["path", { "d": "M8 10v4" }],
|
||||||
|
["path", { "d": "M8 12H3" }]
|
||||||
|
],
|
||||||
|
spinner: [
|
||||||
|
["path", { "d": "M21 12a9 9 0 1 1-6.219-8.56" }]
|
||||||
|
],
|
||||||
|
star: [
|
||||||
|
["path", { "d": "M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" }]
|
||||||
|
],
|
||||||
|
stop: [
|
||||||
|
["rect", { "width": "18", "height": "18", "x": "3", "y": "3", "rx": "2" }]
|
||||||
|
],
|
||||||
|
table: [
|
||||||
|
["path", { "d": "M12 3v18" }],
|
||||||
|
["rect", { "width": "18", "height": "18", "x": "3", "y": "3", "rx": "2" }],
|
||||||
|
["path", { "d": "M3 9h18" }],
|
||||||
|
["path", { "d": "M3 15h18" }]
|
||||||
|
],
|
||||||
|
layoutGrid: [
|
||||||
|
["rect", { "width": "7", "height": "7", "x": "3", "y": "3", "rx": "1" }],
|
||||||
|
["rect", { "width": "7", "height": "7", "x": "14", "y": "3", "rx": "1" }],
|
||||||
|
["rect", { "width": "7", "height": "7", "x": "14", "y": "14", "rx": "1" }],
|
||||||
|
["rect", { "width": "7", "height": "7", "x": "3", "y": "14", "rx": "1" }]
|
||||||
|
],
|
||||||
|
thumbsUp: [
|
||||||
|
["path", { "d": "M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" }],
|
||||||
|
["path", { "d": "M7 10v12" }]
|
||||||
|
],
|
||||||
|
thumbsDown: [
|
||||||
|
["path", { "d": "M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z" }],
|
||||||
|
["path", { "d": "M17 14V2" }]
|
||||||
|
],
|
||||||
|
verified: [
|
||||||
|
["path", { "d": "M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z" }],
|
||||||
|
["path", { "d": "m9 12 2 2 4-4" }]
|
||||||
|
],
|
||||||
|
video: [
|
||||||
|
["path", { "d": "m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" }],
|
||||||
|
["rect", { "x": "2", "y": "6", "width": "14", "height": "12", "rx": "2" }]
|
||||||
|
],
|
||||||
|
circle: [
|
||||||
|
["circle", { "cx": "12", "cy": "12", "r": "10" }]
|
||||||
|
],
|
||||||
|
zap: [
|
||||||
|
["path", { "d": "M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" }]
|
||||||
|
],
|
||||||
|
trendingUp: [
|
||||||
|
["path", { "d": "M16 7h6v6" }],
|
||||||
|
["path", { "d": "m22 7-8.5 8.5-5-5L2 17" }]
|
||||||
|
],
|
||||||
|
} as const satisfies Record<string, readonly LucideIconNode[]>;
|
||||||
|
|
||||||
export type AppIconName = keyof typeof APP_ICONS;
|
export type AppIconName = keyof typeof APP_ICONS;
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
||||||
import { LucideDynamicIcon } from '@lucide/angular';
|
import { APP_ICONS, type AppIconName, type LucideIconNode } from './icon-registry';
|
||||||
import { APP_ICONS, type AppIconName } from './icon-registry';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single entry point for every icon in the app. Renders a Lucide icon by
|
* Single entry point for every icon in the app. Renders a Lucide icon by
|
||||||
* canonical name (see icon-registry.ts) so every screen uses one icon family
|
* canonical name (see icon-registry.ts) so every screen uses one icon family
|
||||||
* at one consistent size/stroke-width by default.
|
* at one consistent size/stroke-width by default.
|
||||||
*
|
*
|
||||||
|
* Renders directly from transcribed Lucide node data (see icon-registry.ts)
|
||||||
|
* instead of using @lucide/angular's runtime, matching the shape switch and
|
||||||
|
* host attributes of Lucide's own LucideIconBase/LucideDynamicIcon so output
|
||||||
|
* is pixel-identical.
|
||||||
|
*
|
||||||
* Decorative by default (aria-hidden). Pass `ariaLabel` only when the icon
|
* Decorative by default (aria-hidden). Pass `ariaLabel` only when the icon
|
||||||
* itself carries meaning with no adjacent text (e.g. inside an icon-only
|
* itself carries meaning with no adjacent text (e.g. inside an icon-only
|
||||||
* button) - the label belongs on the interactive element when there is one.
|
* button) - the label belongs on the interactive element when there is one.
|
||||||
@@ -14,18 +18,59 @@ import { APP_ICONS, type AppIconName } from './icon-registry';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-icon',
|
selector: 'app-icon',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [LucideDynamicIcon],
|
|
||||||
template: `
|
template: `
|
||||||
<svg
|
<svg
|
||||||
[lucideIcon]="iconData()"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
[size]="size()"
|
viewBox="0 0 24 24"
|
||||||
[color]="color()"
|
fill="none"
|
||||||
[strokeWidth]="strokeWidth()"
|
stroke-linecap="round"
|
||||||
[title]="ariaLabel()"
|
stroke-linejoin="round"
|
||||||
|
class="lucide"
|
||||||
|
[attr.width]="size()"
|
||||||
|
[attr.height]="size()"
|
||||||
|
[attr.stroke]="color()"
|
||||||
|
[attr.stroke-width]="strokeWidth()"
|
||||||
[attr.role]="ariaLabel() ? 'img' : null"
|
[attr.role]="ariaLabel() ? 'img' : null"
|
||||||
[attr.aria-label]="ariaLabel()"
|
[attr.aria-label]="ariaLabel()"
|
||||||
[attr.aria-hidden]="ariaLabel() ? null : 'true'"
|
[attr.aria-hidden]="ariaLabel() ? null : 'true'"
|
||||||
></svg>
|
>
|
||||||
|
@if (ariaLabel(); as label) {
|
||||||
|
<title>{{ label }}</title>
|
||||||
|
}
|
||||||
|
@for (node of nodes(); track $index) {
|
||||||
|
@let attrs = node[1];
|
||||||
|
@switch (node[0]) {
|
||||||
|
@case ('path') {
|
||||||
|
<path [attr.d]="attrs['d']" [attr.fill]="attrs['fill']" />
|
||||||
|
}
|
||||||
|
@case ('line') {
|
||||||
|
<line [attr.x1]="attrs['x1']" [attr.x2]="attrs['x2']" [attr.y1]="attrs['y1']" [attr.y2]="attrs['y2']" />
|
||||||
|
}
|
||||||
|
@case ('polygon') {
|
||||||
|
<polygon [attr.points]="attrs['points']" />
|
||||||
|
}
|
||||||
|
@case ('polyline') {
|
||||||
|
<polyline [attr.points]="attrs['points']" />
|
||||||
|
}
|
||||||
|
@case ('circle') {
|
||||||
|
<circle [attr.cx]="attrs['cx']" [attr.cy]="attrs['cy']" [attr.r]="attrs['r']" [attr.fill]="attrs['fill']" />
|
||||||
|
}
|
||||||
|
@case ('ellipse') {
|
||||||
|
<ellipse [attr.cx]="attrs['cx']" [attr.cy]="attrs['cy']" [attr.rx]="attrs['rx']" [attr.ry]="attrs['ry']" />
|
||||||
|
}
|
||||||
|
@case ('rect') {
|
||||||
|
<rect
|
||||||
|
[attr.x]="attrs['x']"
|
||||||
|
[attr.y]="attrs['y']"
|
||||||
|
[attr.width]="attrs['width']"
|
||||||
|
[attr.height]="attrs['height']"
|
||||||
|
[attr.rx]="attrs['rx']"
|
||||||
|
[attr.ry]="attrs['ry']"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
`,
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
host: {
|
host: {
|
||||||
@@ -40,5 +85,10 @@ export class IconComponent {
|
|||||||
readonly strokeWidth = input<number>(2);
|
readonly strokeWidth = input<number>(2);
|
||||||
readonly ariaLabel = input<string | null>(null);
|
readonly ariaLabel = input<string | null>(null);
|
||||||
|
|
||||||
protected readonly iconData = computed(() => APP_ICONS[this.name()]);
|
// `APP_ICONS` is declared `as const satisfies ...` so per-icon tuple types stay
|
||||||
|
// literal (keeps `AppIconName` a precise key union for callers); widen here at
|
||||||
|
// the single consumption point instead of loosening the registry's own typing.
|
||||||
|
protected readonly nodes = computed(
|
||||||
|
() => APP_ICONS[this.name()] as readonly LucideIconNode[]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,3 +50,9 @@ app-table {
|
|||||||
background: var(--bg-secondary, #f4f4f5);
|
background: var(--bg-secondary, #f4f4f5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-density-compact .app-table {
|
||||||
|
th, td {
|
||||||
|
padding: var(--space-xs, 0.25rem) var(--space-md, 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Observable, catchError, map, of, shareReplay, switchMap, take } from 'rxjs';
|
import { Observable, catchError, map, of, shareReplay, switchMap, take, tap } from 'rxjs';
|
||||||
import { WidgetManifestEntry, WidgetManifestFile } from '../contracts/widget-manifest.contract';
|
import { WidgetManifestEntry, WidgetManifestFile } from '../contracts/widget-manifest.contract';
|
||||||
import { ConfigService } from '../../core/config/config.service';
|
import { ConfigService } from '../../core/config/config.service';
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ import { ConfigService } from '../../core/config/config.service';
|
|||||||
export class WidgetManifestService {
|
export class WidgetManifestService {
|
||||||
private readonly fallbackManifestUrl = '/assets/mock/bootstrap/widget-manifest.json';
|
private readonly fallbackManifestUrl = '/assets/mock/bootstrap/widget-manifest.json';
|
||||||
private readonly manifestByUrl = new Map<string, Observable<WidgetManifestFile>>();
|
private readonly manifestByUrl = new Map<string, Observable<WidgetManifestFile>>();
|
||||||
|
/** Last manifest resolved by getManifest(), for synchronous readers (e.g. ProjectValidator) that can't await an Observable. Mirrors ConfigService.getBootstrapSnapshot(). */
|
||||||
|
private manifestSnapshot: WidgetManifestFile | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly http: HttpClient,
|
private readonly http: HttpClient,
|
||||||
@@ -23,6 +25,7 @@ export class WidgetManifestService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const manifest$ = this.http.get<WidgetManifestFile>(manifestUrl).pipe(
|
const manifest$ = this.http.get<WidgetManifestFile>(manifestUrl).pipe(
|
||||||
|
tap(manifest => { this.manifestSnapshot = manifest; }),
|
||||||
shareReplay({ bufferSize: 1, refCount: true }),
|
shareReplay({ bufferSize: 1, refCount: true }),
|
||||||
catchError(() => of({ widgets: [] }))
|
catchError(() => of({ widgets: [] }))
|
||||||
);
|
);
|
||||||
@@ -41,6 +44,11 @@ export class WidgetManifestService {
|
|||||||
return this.getWidgets().pipe(map((widgets) => widgets.find((widget) => widget.type === type)));
|
return this.getWidgets().pipe(map((widgets) => widgets.find((widget) => widget.type === type)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Synchronous accessor for the last-resolved manifest, or null before it's loaded once. */
|
||||||
|
getManifestSnapshot(): WidgetManifestFile | null {
|
||||||
|
return this.manifestSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
private resolveManifestUrl(): Observable<string> {
|
private resolveManifestUrl(): Observable<string> {
|
||||||
const snapshotUrl = this.configService.getBootstrapSnapshot()?.widgetRegistry?.manifestUrl;
|
const snapshotUrl = this.configService.getBootstrapSnapshot()?.widgetRegistry?.manifestUrl;
|
||||||
if (snapshotUrl) {
|
if (snapshotUrl) {
|
||||||
|
|||||||
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';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
|
|
||||||
const AUTOPLAY_INTERVAL_MS = 5000;
|
const AUTOPLAY_INTERVAL_MS = 5000;
|
||||||
|
const SWIPE_THRESHOLD_PX = 50;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-hero-widget',
|
selector: 'app-hero-widget',
|
||||||
@@ -12,14 +13,40 @@ const AUTOPLAY_INTERVAL_MS = 5000;
|
|||||||
imports: [CommonModule, TranslatePipe],
|
imports: [CommonModule, TranslatePipe],
|
||||||
template: `
|
template: `
|
||||||
<section class="hero-widget">
|
<section class="hero-widget">
|
||||||
@if (activeSlide(); as slide) {
|
@if (visibleSlides().length) {
|
||||||
<h1 class="hero-widget__title">{{ slide.title }}</h1>
|
<div class="hero-widget__nav">
|
||||||
|
@if (allSlides().length > 1) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hero-widget__arrow hero-widget__arrow--prev"
|
||||||
|
[attr.aria-label]="'common.previousSlide' | translate"
|
||||||
|
(click)="prevSlide()"
|
||||||
|
>←</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="hero-widget__slides" (touchstart)="onSwipeStart($event)" (touchend)="onSwipeEnd($event)">
|
||||||
|
@for (slide of visibleSlides(); track $index) {
|
||||||
|
<article class="hero-widget__slide">
|
||||||
|
<h2 class="hero-widget__title">{{ slide.title }}</h2>
|
||||||
@if (slide.subtitle) {
|
@if (slide.subtitle) {
|
||||||
<p class="hero-widget__subtitle">{{ slide.subtitle }}</p>
|
<p class="hero-widget__subtitle">{{ slide.subtitle }}</p>
|
||||||
}
|
}
|
||||||
@if (slide.ctaLabel) {
|
@if (slide.ctaLabel) {
|
||||||
<button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ slide.ctaLabel }}</button>
|
<button type="button" class="hero-widget__cta" (click)="onCtaClick()">{{ slide.ctaLabel }}</button>
|
||||||
}
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (allSlides().length > 1) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hero-widget__arrow hero-widget__arrow--next"
|
||||||
|
[attr.aria-label]="'common.nextSlide' | translate"
|
||||||
|
(click)="nextSlide()"
|
||||||
|
>→</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (allSlides().length > 1) {
|
@if (allSlides().length > 1) {
|
||||||
<div class="hero-widget__dots" role="tablist" [attr.aria-label]="'common.slidesLabel' | translate">
|
<div class="hero-widget__dots" role="tablist" [attr.aria-label]="'common.slidesLabel' | translate">
|
||||||
@@ -49,6 +76,40 @@ const AUTOPLAY_INTERVAL_MS = 5000;
|
|||||||
animation: hero-widget-in 420ms ease-out both;
|
animation: hero-widget-in 420ms ease-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-widget__nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: var(--space-sm, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-widget__slides {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-lg, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-widget__slide {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-widget__arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border-color, #d3dad9);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-primary, #1e3c38);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
&:hover { background: var(--primary-color, #497671); color: #fff; }
|
||||||
|
&:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; }
|
||||||
|
}
|
||||||
|
|
||||||
.hero-widget__title {
|
.hero-widget__title {
|
||||||
margin: 0 0 var(--space-sm, 8px);
|
margin: 0 0 var(--space-sm, 8px);
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
@@ -140,6 +201,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
|||||||
readonly activeIndex = signal(0);
|
readonly activeIndex = signal(0);
|
||||||
private readonly dataSignal = signal<HeroWidgetData | null>(null);
|
private readonly dataSignal = signal<HeroWidgetData | null>(null);
|
||||||
private autoplayHandle: ReturnType<typeof setInterval> | null = null;
|
private autoplayHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private swipeStartX: number | null = null;
|
||||||
|
|
||||||
readonly allSlides = computed<HeroSlideData[]>(() => {
|
readonly allSlides = computed<HeroSlideData[]>(() => {
|
||||||
const current = this.dataSignal();
|
const current = this.dataSignal();
|
||||||
@@ -152,6 +214,25 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
|||||||
|
|
||||||
readonly activeSlide = computed<HeroSlideData | null>(() => this.allSlides()[this.activeIndex()] ?? null);
|
readonly activeSlide = computed<HeroSlideData | null>(() => this.allSlides()[this.activeIndex()] ?? null);
|
||||||
|
|
||||||
|
/** 1 or 2, reusing `layout.columns` (no dedicated "slides per page" field). 2 has nothing to show a second panel with when there's only one slide. */
|
||||||
|
get panelCount(): number {
|
||||||
|
const requested = this.section?.layout?.columns === 2 ? 2 : 1;
|
||||||
|
return requested === 2 && this.allSlides().length > 1 ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
visibleSlides(): HeroSlideData[] {
|
||||||
|
const slides = this.allSlides();
|
||||||
|
if (slides.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (this.panelCount === 2) {
|
||||||
|
const nextIndex = (this.activeIndex() + 1) % slides.length;
|
||||||
|
return [slides[this.activeIndex()], slides[nextIndex]];
|
||||||
|
}
|
||||||
|
const current = slides[this.activeIndex()];
|
||||||
|
return current ? [current] : [];
|
||||||
|
}
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
if (changes['data']) {
|
if (changes['data']) {
|
||||||
this.dataSignal.set(this.data);
|
this.dataSignal.set(this.data);
|
||||||
@@ -169,6 +250,41 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
|
|||||||
this.setupAutoplay();
|
this.setupAutoplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prevSlide(): void {
|
||||||
|
const total = this.allSlides().length;
|
||||||
|
if (total <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.goTo((this.activeIndex() - 1 + total) % total);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSlide(): void {
|
||||||
|
const total = this.allSlides().length;
|
||||||
|
if (total <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.goTo((this.activeIndex() + 1) % total);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSwipeStart(event: TouchEvent): void {
|
||||||
|
this.swipeStartX = event.touches[0]?.clientX ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSwipeEnd(event: TouchEvent): void {
|
||||||
|
const startX = this.swipeStartX;
|
||||||
|
this.swipeStartX = null;
|
||||||
|
if (startX === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const endX = event.changedTouches[0]?.clientX ?? startX;
|
||||||
|
const diff = startX - endX;
|
||||||
|
if (diff > SWIPE_THRESHOLD_PX) {
|
||||||
|
this.nextSlide();
|
||||||
|
} else if (diff < -SWIPE_THRESHOLD_PX) {
|
||||||
|
this.prevSlide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onCtaClick(): void {
|
onCtaClick(): void {
|
||||||
this.ctaClicked.emit();
|
this.ctaClicked.emit();
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
standalone: true,
|
||||||
imports: [CommonModule, CatalogProductGridComponent, TranslatePipe],
|
imports: [CommonModule, CatalogProductGridComponent, TranslatePipe],
|
||||||
template: `
|
template: `
|
||||||
<section class="product-carousel-widget">
|
<section class="product-carousel-widget" [style.--items-per-page]="itemsPerPage()">
|
||||||
@if (data?.title) {
|
@if (data?.title) {
|
||||||
<h2 class="product-carousel-widget__title">{{ data?.title }}</h2>
|
<h2 class="product-carousel-widget__title">{{ data?.title }}</h2>
|
||||||
}
|
}
|
||||||
@@ -83,7 +83,8 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
|
|||||||
|
|
||||||
::ng-deep .catalog-product-shell {
|
::ng-deep .catalog-product-shell {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 220px;
|
width: calc((100% - (var(--items-per-page, 4) - 1) * var(--space-md, 16px)) / var(--items-per-page, 4));
|
||||||
|
min-width: 160px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +122,12 @@ export class ProductCarouselWidgetComponent {
|
|||||||
return this.section?.layout?.strategy === 'carousel';
|
return this.section?.layout?.strategy === 'carousel';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Items visible per page - reuses `layout.columns` (default 4, min 1) rather than a dedicated field. */
|
||||||
|
itemsPerPage(): number {
|
||||||
|
const columns = this.section?.layout?.columns;
|
||||||
|
return columns && columns >= 1 ? Math.floor(columns) : 4;
|
||||||
|
}
|
||||||
|
|
||||||
scrollBy(direction: -1 | 1): void {
|
scrollBy(direction: -1 | 1): void {
|
||||||
const el = this.scroller?.nativeElement;
|
const el = this.scroller?.nativeElement;
|
||||||
if (!el) {
|
if (!el) {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"supportedLocales": ["ru", "en", "hy"],
|
"supportedLocales": ["ru", "en", "hy"],
|
||||||
"defaultCurrency": "RUB",
|
"defaultCurrency": "RUB",
|
||||||
"supportedCurrencies": ["RUB", "USD", "EUR", "AMD"],
|
"supportedCurrencies": ["RUB", "USD", "EUR", "AMD"],
|
||||||
"timezone": "Europe/Moscow"
|
"timezone": "Europe/Moscow",
|
||||||
|
"documentationUrl": "https://docs.marketplace.local"
|
||||||
},
|
},
|
||||||
"branding": {
|
"branding": {
|
||||||
"brandName": "Marketplace",
|
"brandName": "Marketplace",
|
||||||
|
|||||||
Reference in New Issue
Block a user