From 48bcffa22c0dafcfdc6f2680e506211007d60cbc Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Wed, 5 Aug 2026 17:48:50 +0400 Subject: [PATCH 1/8] feat: close stub-page gaps - profile login/logout, admin Reports/Settings, Help/Docs links Sprint A: storefront header profile control (login/logout only, no menu), wired to existing customer Telegram auth (AuthService). Sprint B: backoffice/reports page, reuses AdminAnalyticsFacade (Sales, Top Products, Marketplace Health cards + CSV export). Sprint C: backoffice/settings page, admin UI density preference (comfortable/compact), localStorage-persisted, applied to app-table across all admin list pages. Sprint D: admin bottom-nav Help -> mailto using existing supportEmail, Documentation -> external link via new TenantConfig.documentationUrl. AdminNavLink gains externalHref for non-routerLink nav entries. Docs: docs/GLOBAL-SPRINT-PLAN.md tracks the full sprint breakdown. docs/COMING-SOON-AUDIT.md removed, folded into docs/KNOWN-ISSUES.md. docs/BACKEND.md updated with the new documentationUrl bootstrap field. Co-Authored-By: Claude Sonnet 5 --- docs/BACKEND.md | 11 ++-- docs/EDITOR.md | 6 +-- docs/GLOBAL-SPRINT-PLAN.md | 51 +++++++++++++++++++ docs/KNOWN-ISSUES.md | 4 ++ docs/NEXT_PHASE.md | 2 +- src/app/app.routes.ts | 18 +++++++ .../components/header/header.component.html | 15 ++++++ src/app/components/header/header.component.ts | 14 ++++- .../facade/admin-dashboard.facade.ts | 2 +- .../pages/admin-reports-page.component.html | 32 ++++++++++++ .../pages/admin-reports-page.component.scss | 19 +++++++ .../pages/admin-reports-page.component.ts | 43 ++++++++++++++++ .../pages/admin-settings-page.component.html | 14 +++++ .../pages/admin-settings-page.component.scss | 14 +++++ .../pages/admin-settings-page.component.ts | 21 ++++++++ .../services/admin-preferences.service.ts | 24 +++++++++ .../admin/shell/admin-layout.component.html | 18 +++++-- .../admin/shell/admin-layout.component.ts | 28 +++++++++- .../features/admin/shell/admin-nav.model.ts | 12 ++++- src/app/i18n/en.ts | 14 +++++ src/app/i18n/hy.ts | 14 +++++ src/app/i18n/ru.ts | 14 +++++ src/app/i18n/translations.ts | 14 +++++ src/app/shared/models/config/tenant.model.ts | 1 + src/app/shared/ui/table/table.component.scss | 6 +++ src/assets/mock/bootstrap/bootstrap.json | 3 +- 26 files changed, 398 insertions(+), 16 deletions(-) create mode 100644 docs/GLOBAL-SPRINT-PLAN.md create mode 100644 src/app/features/admin/reports/pages/admin-reports-page.component.html create mode 100644 src/app/features/admin/reports/pages/admin-reports-page.component.scss create mode 100644 src/app/features/admin/reports/pages/admin-reports-page.component.ts create mode 100644 src/app/features/admin/settings/pages/admin-settings-page.component.html create mode 100644 src/app/features/admin/settings/pages/admin-settings-page.component.scss create mode 100644 src/app/features/admin/settings/pages/admin-settings-page.component.ts create mode 100644 src/app/features/admin/settings/services/admin-preferences.service.ts diff --git a/docs/BACKEND.md b/docs/BACKEND.md index 4eefb80..f347041 100644 --- a/docs/BACKEND.md +++ b/docs/BACKEND.md @@ -168,8 +168,12 @@ Each nested field, with its source model file under - **`tenant`** (`TenantConfig`, `tenant.model.ts`) — `{ id (UUID), slug, code, host, name, websiteBaseUrl, builderBaseUrl, backofficeBaseUrl, defaultLocale, supportedLocales[], defaultCurrency, - supportedCurrencies[], timezone }`. Identifies the tenant and its per-surface - base URLs, locale/currency sets, and timezone. + supportedCurrencies[], timezone, documentationUrl? }`. Identifies the tenant + and its per-surface base URLs, locale/currency sets, and timezone. + `documentationUrl` (optional string) is the external docs link rendered by + the backoffice "Documentation" nav item — when absent, that nav item shows + as a disabled `comingSoon` entry instead of a link (frontend-only fallback, + no backend action required beyond optionally sending the field). - **`branding`** (`BrandingConfig`, `branding.model.ts`) — `{ brandName, legalName, slogan?, logoUrl, logoCompactUrl?, faviconUrl, appIconUrl?, socialImageUrl?, galleryUrls?, supportEmail?, supportPhone? }`. @@ -304,7 +308,8 @@ trimmed for length; full versions in that file): "supportedLocales": ["ru", "en", "hy"], "defaultCurrency": "RUB", "supportedCurrencies": ["RUB", "USD", "EUR", "AMD"], - "timezone": "Europe/Moscow" + "timezone": "Europe/Moscow", + "documentationUrl": "https://docs.marketplace.local" }, "branding": { "brandName": "Marketplace", diff --git a/docs/EDITOR.md b/docs/EDITOR.md index 0fdd25b..d9169ed 100644 --- a/docs/EDITOR.md +++ b/docs/EDITOR.md @@ -158,6 +158,6 @@ A section-by-section correctness audit (not a feature pass) — for each section ### Known gaps found but not fixed (real, out of scope for this pass) -- **Theme Mode has no runtime effect.** `theme-section`'s light/dark/system selector correctly saves and sets a `data-theme-mode` attribute (`theme-engine.service.ts`), but zero CSS anywhere in the app reads that attribute — picking Dark or System currently changes nothing visually. (Theme palette colors *are* live — real CSS custom properties consumed throughout the stylesheets — only the mode switch is dead.) Fixing this is a real dark-mode implementation project (dark palette + CSS strategy + `matchMedia` for "system"), not a wiring fix. -- **`layout.type` (Site Layout) and the homepage section's `type` field both feed a rendering pipeline that was never wired up.** `src/app/dynamic-renderer/` has services/models for page/section/widget rendering but zero components or templates (every directory has only a `.gitkeep`) — the storefront homepage renders through a separate, older path that ignores both fields. `homepage-section.component.ts`'s `updateSection(id, 'type', ...)` has no UI calling it because of this; not built, since building UI for a field nothing reads would be inventing dead controls. -- **`HeaderConfig.showProfile`** is a real toggle in `header-section` with no corresponding profile/account menu anywhere in `header.component.html` — the toggle currently does nothing. Building the actual menu is a feature (needs an auth-system check first), not an editor-wiring fix. +- **Theme Mode has no runtime effect.** `theme-section`'s light/dark/system selector correctly saves and sets a `data-theme-mode` attribute (`theme-engine.service.ts`), but zero CSS anywhere in the app reads that attribute — picking Dark or System currently changes nothing visually. (Theme palette colors *are* live — real CSS custom properties consumed throughout the stylesheets — only the mode switch is dead.) Fixing this is a real dark-mode implementation project (dark palette + CSS strategy + `matchMedia` for "system"), not a wiring fix. Tracked: `docs/PRODUCT_BACKLOG.md`. +- ~~`HeaderConfig.showProfile` has no corresponding profile/account menu~~ — **fixed**: `header.component.html`/`.ts` now render a login/logout-only control (no dropdown, no account links) gated by this toggle, reusing the customer Telegram `AuthService`. See `docs/KNOWN-ISSUES.md` "Fixed (this cycle)" and `docs/GLOBAL-SPRINT-PLAN.md` Sprint A. +- ~~`layout.type`/homepage `type` field feed an unwired `dynamic-renderer/`~~ — **stale, corrected**: `dynamic-renderer/` (`PageRendererService`/`SectionRendererService`/`WidgetHostService`) is the live homepage rendering pipeline, wired through `dynamic-page-layout.component.ts`. Verified fixed/non-issue in `docs/KNOWN-ISSUES.md` "Fixed (this cycle)". diff --git a/docs/GLOBAL-SPRINT-PLAN.md b/docs/GLOBAL-SPRINT-PLAN.md new file mode 100644 index 0000000..8c14c48 --- /dev/null +++ b/docs/GLOBAL-SPRINT-PLAN.md @@ -0,0 +1,51 @@ +# Global Sprint Plan — "Coming Soon" Stub Closure + +Supersedes `docs/COMING-SOON-AUDIT.md` §5 sprint breakdown. One consolidated tracker for the four stub-closure sprints. Approved decisions (from AskUserQuestion): Reports/Settings ship as minimal real pages (not fake data, not empty shells); Documentation/Help nav uses an external-link approach; `docs/COMING-SOON-AUDIT.md` is deleted once all sprints land, folded into `docs/KNOWN-ISSUES.md`. Profile control constraint: **login/logout only — no dropdown, no account links.** + +## Sprint A — Profile menu (storefront header) + +- [x] i18n: `header.login` / `header.logout` keys in en/ru/hy (`translations.ts` type already updated) +- [x] `header.component.ts`: inject `AuthService`, expose `isAuthenticated`, add `login()`/`logout()` +- [x] `header.component.ts`: import `TelegramLoginComponent` +- [x] `header.component.html`: profile control gated by `headerConfig().showProfile`, login/logout only, `` rendered once +- [x] SCSS matches existing header button conventions (reused `.platform-ux-btn`, no new SCSS needed) + +**What shipped:** Header profile control wired to the customer `AuthService` (Telegram QR login). Gated by `headerConfig().showProfile` (already a real toggle in Project Editor, previously dead). Logged-out shows a login button (`user` icon), logged-in shows a logout button (`logOut` icon) — no dropdown, no account links, per the explicit constraint. + +## Sprint B — Admin Reports page + +- [x] `admin-reports-page.component.ts/.html/.scss` (mirrors `admin-analytics-page` structure), reuses `AdminAnalyticsFacade` +- [x] Report cards: Sales, Top Products, Marketplace Health +- [x] CSV export wired to existing facade export methods / existing download helper (same Blob pattern as `admin-analytics-page.component.ts`) +- [x] Route `backoffice/reports` in `app.routes.ts`, i18n keys `adminShell.pages.reports.*` + new `adminReports.*` block +- [x] Remove `comingSoon: true` from `reports` nav entry + +**What shipped:** Minimal real Reports page with 3 cards (Sales, Top Products, Marketplace Health), each showing a live summary from `AdminAnalyticsFacade` and a CSV export button. Orders card was scoped out — see final report for why (reuse would require mutating a shared singleton facade's pagination state). + +## Sprint C — Admin Settings page + +- [x] `AdminPreferencesService` (density signal, localStorage-backed, key `adminPreferences.density.v1`) +- [x] `admin-layout.component` applies `admin-density-compact` class to `#admin-content` shell wrapper +- [x] `admin-settings-page.component.ts/.html/.scss` — density toggle (`app-toggle`), auto-persists on change, no separate Save button +- [x] Route `backoffice/settings`, i18n keys `adminShell.pages.settings.*` + `adminSettings.*` block +- [x] Remove `comingSoon: true` from nav entry AND dashboard shortcut; shortcut route → `['backoffice','settings']` +- [x] Compact-density CSS rule added to the shared `app-table` component stylesheet (`.admin-density-compact .app-table th/td`) — applies to every admin list page built on `app-table` (orders, products, categories, etc.), not just one + +**What shipped:** Genuinely real, backend-independent UI density preference. No maintenance-mode toggle built (explicitly deferred per `docs/NEXT_PHASE.md` Phase 4). + +## Sprint D — Documentation / Help nav + +- [x] Help: `mailto:` using existing `supportEmail` read path (`UiRuntimeFacade.contactEmail()`, same one `header.component.ts` already uses for `bootstrap.branding.supportEmail`) +- [x] `AdminNavLink` gains optional `externalHref?: string`; nav renderer renders `` branch (bottom nav) +- [x] Documentation: added `tenant.documentationUrl?: string` to `TenantConfig`, populated mock with `https://docs.marketplace.local` +- [x] `help`/`documentation` resolved dynamically in `admin-layout.component.ts` (`navBottom` computed) — real `` when bootstrap data present, static `comingSoon: true` entries kept as defensive fallback for the (currently unreachable, since mock always has both fields) case where the backend omits them + +**What shipped:** Both Help and Documentation wired to real external links, not just Help. `comingSoon: true` remains in `admin-nav.model.ts` source as a fallback flag only — it is overridden to `false` at render time whenever bootstrap actually has the data, which it does today. + +## Housekeeping + +- [ ] Delete `docs/COMING-SOON-AUDIT.md` +- [ ] Fold summary into `docs/KNOWN-ISSUES.md` "Fixed (this cycle)"; remove the `HeaderConfig.showProfile` dead-toggle entry from "Open" +- [ ] Update `docs/BACKEND.md` (and `docs/backend/BACKEND-INTEGRATION.md` if applicable) for `tenant.documentationUrl` only, if added +- [ ] `npm run barry -- validate` +- [ ] Typecheck touched files diff --git a/docs/KNOWN-ISSUES.md b/docs/KNOWN-ISSUES.md index a3883e7..286b9c6 100644 --- a/docs/KNOWN-ISSUES.md +++ b/docs/KNOWN-ISSUES.md @@ -39,3 +39,7 @@ Condensed — full detail in commit history and `docs/RELEASE_REPORT.md`. - `primeng`/`primeicons` unused dependency — removed. - Builder static-page body editor hidden inside a mislabeled collapsed section — un-hidden, relabeled. - Several project-editor/admin-categories correctness bugs (footer icon id collisions, features toggle only driving one flag, languages silent duplicate no-op, static-pages slug collision, branding `socialImageUrl` never read, media-picker facade filter leakage between dialogs, categories draft-recovery/drag-reorder bugs, hardcoded locale-tab order) — see git history for the full per-bug list. +- `HeaderConfig.showProfile` dead toggle — wired up (login/logout only, no dropdown), reuses the existing customer Telegram `AuthService`. +- Admin `reports` nav stub — real page (`backoffice/reports`), reuses `AdminAnalyticsFacade` for Sales/Top Products/Marketplace Health cards with CSV export. +- Admin `settings` nav stub — real page (`backoffice/settings`), UI density preference (comfortable/compact), persisted to `localStorage`, applied to admin list tables. +- Admin `documentation`/`help` nav stubs — both wired to real external links (`mailto:` support email, `tenant.documentationUrl`). diff --git a/docs/NEXT_PHASE.md b/docs/NEXT_PHASE.md index 28b4036..89e5e26 100644 --- a/docs/NEXT_PHASE.md +++ b/docs/NEXT_PHASE.md @@ -20,4 +20,4 @@ Wire real error tracking/APM and a real event source for the admin Monitoring pa ## Phase 5 — Version 2 ideas -Everything in `docs/PRODUCT_BACKLOG.md` (dark mode, brand-color contrast decision, advanced analytics, additional payment providers, Contacts page content) and `docs/FUTURE_FEATURES.md` (Angular 22 upgrade, cart-modal composition cleanup) — none of it scheduled, all of it deliberately deferred past initial launch. +Everything in `docs/PRODUCT_BACKLOG.md` (dark mode, brand-color contrast decision, advanced analytics, additional payment providers, Contacts page content) and `docs/FUTURE_FEATURES.md` (Angular 22 upgrade, cart-modal composition cleanup) — none of it scheduled, all of it deliberately deferred past initial launch. The former stub-page/dead-toggle inventory (profile menu, admin Reports, admin Settings, Documentation/Help) is closed — see `docs/GLOBAL-SPRINT-PLAN.md` and `docs/KNOWN-ISSUES.md` "Fixed (this cycle)". diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index f4bf1a5..1d5ba92 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -254,6 +254,24 @@ const coreRoutes: Routes = [ breadcrumb: [{ labelKey: 'adminShell.nav.analytics' }] } }, + { + path: 'reports', + loadComponent: () => import('./features/admin/reports/pages/admin-reports-page.component').then(m => m.AdminReportsPageComponent), + data: { + titleKey: 'adminShell.pages.reports.title', + descriptionKey: 'adminShell.pages.reports.description', + breadcrumb: [{ labelKey: 'adminShell.nav.reports' }] + } + }, + { + path: 'settings', + loadComponent: () => import('./features/admin/settings/pages/admin-settings-page.component').then(m => m.AdminSettingsPageComponent), + data: { + titleKey: 'adminShell.pages.settings.title', + descriptionKey: 'adminShell.pages.settings.description', + breadcrumb: [{ labelKey: 'adminShell.nav.settings' }] + } + }, { path: 'partners/seller-management', loadComponent: () => import('./features/admin/seller-management/pages/admin-seller-management-page.component').then(m => m.AdminSellerManagementPageComponent), diff --git a/src/app/components/header/header.component.html b/src/app/components/header/header.component.html index 556a79f..6884ed9 100644 --- a/src/app/components/header/header.component.html +++ b/src/app/components/header/header.component.html @@ -80,6 +80,19 @@ } + + @if (headerConfig().showProfile) { + @if (isAuthenticated()) { + + } @else { + + } + } + @if (headerConfig().showRegion) {
@@ -147,4 +160,6 @@
+ + diff --git a/src/app/components/header/header.component.ts b/src/app/components/header/header.component.ts index 142f5ed..0e4ed93 100644 --- a/src/app/components/header/header.component.ts +++ b/src/app/components/header/header.component.ts @@ -14,10 +14,12 @@ import { FeatureConfigService } from '../../core/config/feature-config.service'; import { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config'; import { StaticPageResolverService } from '../../core/config/static-page-resolver.service'; import { IconComponent } from '../../shared/ui/icon/icon.component'; +import { AuthService } from '../../services/auth.service'; +import { TelegramLoginComponent } from '../telegram-login/telegram-login.component'; @Component({ selector: 'app-header', - imports: [RouterLink, RouterLinkActive, LogoComponent, LanguageSelectorComponent, RegionSelectorComponent, LangRoutePipe, TranslatePipe, IconComponent], + imports: [RouterLink, RouterLinkActive, LogoComponent, LanguageSelectorComponent, RegionSelectorComponent, LangRoutePipe, TranslatePipe, IconComponent, TelegramLoginComponent], templateUrl: './header.component.html', styleUrls: ['./header.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -35,7 +37,9 @@ export class HeaderComponent { private configService = inject(ConfigService); private featureConfig = inject(FeatureConfigService); private staticPageResolver = inject(StaticPageResolverService); + private authService = inject(AuthService); + readonly isAuthenticated = this.authService.isAuthenticated; readonly wishlistCount = this.uxFacade.wishlistCount; readonly compareCount = this.uxFacade.compareCount; readonly userExperienceConfig = computed(() => this.resolveUserExperienceConfig()); @@ -118,6 +122,14 @@ export class HeaderComponent { this.router.navigate([`/${lang}/compare`]); } + login(): void { + this.authService.requestLogin(); + } + + logout(): void { + this.authService.logout(); + } + navigateToStatic(route: string): void { this.closeMenu(); const lang = this.langService.currentLanguage(); diff --git a/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts b/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts index 526eeca..37c60c9 100644 --- a/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts +++ b/src/app/features/admin/dashboard/facade/admin-dashboard.facade.ts @@ -31,7 +31,7 @@ const SHORTCUTS: AdminDashboardShortcut[] = [ { id: 'static-pages', icon: 'edit', labelKey: 'dashboard.actionStaticPages', route: ['backoffice', 'static-pages'] }, { id: 'orders', icon: 'cart', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] }, { id: 'users', icon: 'users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] }, - { id: 'settings', icon: 'settings', labelKey: 'dashboard.shortcutSettings', route: [], comingSoon: true }, + { id: 'settings', icon: 'settings', labelKey: 'dashboard.shortcutSettings', route: ['backoffice', 'settings'] }, { id: 'media-library', icon: 'images', labelKey: 'dashboard.actionMediaLibrary', route: ['backoffice', 'media'] }, { id: 'content', icon: 'alignLeft', labelKey: 'dashboard.shortcutContent', route: ['edit', 'static-pages'] }, ]; diff --git a/src/app/features/admin/reports/pages/admin-reports-page.component.html b/src/app/features/admin/reports/pages/admin-reports-page.component.html new file mode 100644 index 0000000..84e69c6 --- /dev/null +++ b/src/app/features/admin/reports/pages/admin-reports-page.component.html @@ -0,0 +1,32 @@ +
+ @if (facade.loading()) { +
+ @for (i of [1,2,3]; track i) { + + } + {{ 'common.loading' | translate }} +
+ } @else { +
+
+

{{ 'adminReports.sales' | translate }}

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

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

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

{{ 'adminReports.topProducts' | translate }}

+

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

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

{{ 'adminReports.marketplaceHealth' | translate }}

+

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

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

{{ 'adminSettings.density' | translate }}

+

{{ 'adminSettings.densityExplain' | translate }}

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

    {{ slide.title }}

    + @if (slide.subtitle) { +

    {{ slide.subtitle }}

    + } + @if (slide.ctaLabel) { + + } +
    + } +
    + + @if (allSlides().length > 1) { + + } +
    @if (allSlides().length > 1) {
    @@ -49,6 +76,40 @@ const AUTOPLAY_INTERVAL_MS = 5000; animation: hero-widget-in 420ms ease-out both; } + .hero-widget__nav { + display: flex; + align-items: stretch; + gap: var(--space-sm, 8px); + } + + .hero-widget__slides { + flex: 1; + min-width: 0; + display: flex; + gap: var(--space-lg, 24px); + } + + .hero-widget__slide { + flex: 1 1 0; + min-width: 0; + } + + .hero-widget__arrow { + flex-shrink: 0; + align-self: center; + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid var(--border-color, #d3dad9); + background: #fff; + color: var(--text-primary, #1e3c38); + cursor: pointer; + font-size: 1rem; + + &:hover { background: var(--primary-color, #497671); color: #fff; } + &:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; } + } + .hero-widget__title { margin: 0 0 var(--space-sm, 8px); font-size: 2rem; @@ -140,6 +201,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy { readonly activeIndex = signal(0); private readonly dataSignal = signal(null); private autoplayHandle: ReturnType | null = null; + private swipeStartX: number | null = null; readonly allSlides = computed(() => { const current = this.dataSignal(); @@ -152,6 +214,25 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy { readonly activeSlide = computed(() => this.allSlides()[this.activeIndex()] ?? null); + /** 1 or 2, reusing `layout.columns` (no dedicated "slides per page" field). 2 has nothing to show a second panel with when there's only one slide. */ + get panelCount(): number { + const requested = this.section?.layout?.columns === 2 ? 2 : 1; + return requested === 2 && this.allSlides().length > 1 ? 2 : 1; + } + + visibleSlides(): HeroSlideData[] { + const slides = this.allSlides(); + if (slides.length === 0) { + return []; + } + if (this.panelCount === 2) { + const nextIndex = (this.activeIndex() + 1) % slides.length; + return [slides[this.activeIndex()], slides[nextIndex]]; + } + const current = slides[this.activeIndex()]; + return current ? [current] : []; + } + ngOnChanges(changes: SimpleChanges): void { if (changes['data']) { this.dataSignal.set(this.data); @@ -169,6 +250,41 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy { this.setupAutoplay(); } + prevSlide(): void { + const total = this.allSlides().length; + if (total <= 1) { + return; + } + this.goTo((this.activeIndex() - 1 + total) % total); + } + + nextSlide(): void { + const total = this.allSlides().length; + if (total <= 1) { + return; + } + this.goTo((this.activeIndex() + 1) % total); + } + + onSwipeStart(event: TouchEvent): void { + this.swipeStartX = event.touches[0]?.clientX ?? null; + } + + onSwipeEnd(event: TouchEvent): void { + const startX = this.swipeStartX; + this.swipeStartX = null; + if (startX === null) { + return; + } + const endX = event.changedTouches[0]?.clientX ?? startX; + const diff = startX - endX; + if (diff > SWIPE_THRESHOLD_PX) { + this.nextSlide(); + } else if (diff < -SWIPE_THRESHOLD_PX) { + this.prevSlide(); + } + } + onCtaClick(): void { this.ctaClicked.emit(); } diff --git a/src/app/widgets/ui/product-carousel-widget.component.ts b/src/app/widgets/ui/product-carousel-widget.component.ts index 353e035..4cdd37e 100644 --- a/src/app/widgets/ui/product-carousel-widget.component.ts +++ b/src/app/widgets/ui/product-carousel-widget.component.ts @@ -10,7 +10,7 @@ import { TranslatePipe } from '../../i18n/translate.pipe'; standalone: true, imports: [CommonModule, CatalogProductGridComponent, TranslatePipe], template: ` -
    -@if (showPaymentPopup()) { -
    - } -
    + - @if (showBankPaymentPopup() && bankPaymentFrameUrl()) { -
    - -
    - } - +@if (showBankPaymentPopup() && bankPaymentFrameUrl()) { + + + + } diff --git a/src/app/pages/cart/cart.component.scss b/src/app/pages/cart/cart.component.scss index 882c39b..3f1c608 100644 --- a/src/app/pages/cart/cart.component.scss +++ b/src/app/pages/cart/cart.component.scss @@ -640,32 +640,20 @@ } } -// Payment modal styles -.payment-modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - padding: 20px; -} +// Payment / bank-payment modals render through the shared app-dialog +// primitive (focus-trap, Escape, backdrop-click, ARIA all owned there — +// see closeOnEscape/closeOnBackdropClick usage in the template). These +// ::ng-deep overrides restore the exact panel geometry the old hand-rolled +// modals had, which app-dialog's generic size presets/padding don't match. +:host ::ng-deep { + app-dialog.payment-dialog .app-dialog-panel { + max-width: 500px; + border: 1px solid var(--border-color); + } -.payment-modal { - background: var(--bg-primary); - border-radius: var(--radius-lg, 13px); - max-width: 500px; - width: 100%; - padding: 40px; - position: relative; - max-height: 90vh; - overflow-y: auto; - border: 1px solid var(--border-color); - box-shadow: var(--shadow-lg); + app-dialog.payment-dialog .app-dialog-panel__body { + padding: 40px; + } } // Deduplicated: this rule previously appeared twice (identical) - kept a single definition. @@ -970,25 +958,23 @@ } -.bank-payment-modal-overlay { - position: fixed; - inset: 0; - z-index: 1001; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; - background: rgba(17, 24, 39, 0.55); -} +:host ::ng-deep { + app-dialog.bank-payment-dialog .app-dialog-backdrop { + background: rgba(17, 24, 39, 0.55); + } -.bank-payment-modal { - position: relative; - width: min(960px, 92vw); - height: min(760px, 86vh); - padding: 56px 16px 16px; - background: var(--bg-primary); - border-radius: var(--radius-lg, 13px); - box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28); + app-dialog.bank-payment-dialog .app-dialog-panel { + width: min(960px, 92vw); + max-width: min(960px, 92vw); + height: min(760px, 86vh); + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.28); + } + + app-dialog.bank-payment-dialog .app-dialog-panel__body { + height: 100%; + box-sizing: border-box; + padding: 56px 16px 16px; + } } .bank-payment-frame { @@ -1041,9 +1027,9 @@ display: flex; } - .payment-modal { + :host ::ng-deep app-dialog.payment-dialog .app-dialog-panel__body { padding: 24px; - padding-top: 56px; // �������������� ������ ������ ��� ������ �������� + padding-top: 56px; } .close-modal-btn { @@ -1078,14 +1064,20 @@ grid-template-columns: 1fr; } - .bank-payment-modal-overlay { - padding: 12px; - } + :host ::ng-deep { + app-dialog.bank-payment-dialog .app-dialog-backdrop { + padding: 12px; + } - .bank-payment-modal { - width: 94vw; - height: 82vh; - padding: 52px 10px 10px; + app-dialog.bank-payment-dialog .app-dialog-panel { + width: 94vw; + max-width: 94vw; + height: 82vh; + } + + app-dialog.bank-payment-dialog .app-dialog-panel__body { + padding: 52px 10px 10px; + } } .payment-active h2 { diff --git a/src/app/pages/cart/cart.component.ts b/src/app/pages/cart/cart.component.ts index 9e71d84..cf00c2e 100644 --- a/src/app/pages/cart/cart.component.ts +++ b/src/app/pages/cart/cart.component.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, signal, OnDestroy, inject, ElementRef, ViewChild, HostListener, effect } from '@angular/core'; +import { Component, ChangeDetectionStrategy, signal, OnDestroy, inject } from '@angular/core'; import { DecimalPipe } from '@angular/common'; import { Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; @@ -21,15 +21,13 @@ import { ConfigService } from '../../core/config/config.service'; import { TenantResolverService } from '../../core/config/tenant-resolver.service'; import { UserNotificationService } from '../../features/website/user-experience/services/user-notification.service'; import { ConfirmDialogComponent } from '../../shared/ui/confirm-dialog/confirm-dialog.component'; +import { DialogComponent } from '../../shared/ui/dialog/dialog.component'; type PaymentMethod = 'qr' | 'card'; -const MODAL_FOCUSABLE_SELECTOR = - 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])'; - @Component({ selector: 'app-cart', - imports: [DecimalPipe, RouterLink, FormsModule, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe, IconComponent, EmptyStateComponent, ButtonComponent, ConfirmDialogComponent], + imports: [DecimalPipe, RouterLink, FormsModule, DeliverySelectorComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe, IconComponent, EmptyStateComponent, ButtonComponent, ConfirmDialogComponent, DialogComponent], templateUrl: './cart.component.html', styleUrls: ['./cart.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -82,13 +80,6 @@ export class CartComponent implements OnDestroy { private configService = inject(ConfigService); private tenantResolver = inject(TenantResolverService); - // Focus management for the custom payment/bank-payment modals (not - // app-dialog — they own a real multi-step state machine). Mirrors - // app-dialog's confirmed-correct focus-trap/Escape/return-focus pattern. - @ViewChild('paymentModalPanel') private paymentModalPanel?: ElementRef; - @ViewChild('bankPaymentModalPanel') private bankPaymentModalPanel?: ElementRef; - private previouslyFocusedBeforeModal: HTMLElement | null = null; - constructor( private cartService: CartService, private apiService: ApiService, @@ -103,78 +94,6 @@ export class CartComponent implements OnDestroy { this.totalWithDelivery = this.cartService.totalWithDelivery; this.hasDeliveryPrice = this.cartService.hasDeliveryPrice; this.allRequiredDeliveriesSelected = this.cartService.allRequiredDeliveriesSelected; - - effect(() => { - const isOpen = this.showPaymentPopup(); - if (isOpen) { - this.previouslyFocusedBeforeModal ??= document.activeElement as HTMLElement | null; - queueMicrotask(() => this.focusActiveModalPanel()); - } else if (this.previouslyFocusedBeforeModal) { - this.previouslyFocusedBeforeModal.focus(); - this.previouslyFocusedBeforeModal = null; - } - }); - - effect(() => { - if (this.showBankPaymentPopup()) { - queueMicrotask(() => this.focusActiveModalPanel()); - } - }); - } - - @HostListener('document:keydown', ['$event']) - protected handleModalKeydown(event: KeyboardEvent): void { - if (!this.showPaymentPopup()) { - return; - } - if (event.key === 'Escape') { - if (this.showBankPaymentPopup()) { - this.closeBankPaymentPopup(); - } else { - this.closePaymentPopup(); - } - return; - } - if (event.key === 'Tab') { - this.trapModalFocus(event); - } - } - - private activeModalPanel(): HTMLElement | undefined { - return this.showBankPaymentPopup() - ? this.bankPaymentModalPanel?.nativeElement - : this.paymentModalPanel?.nativeElement; - } - - private focusActiveModalPanel(): void { - const panel = this.activeModalPanel(); - if (!panel) { - return; - } - const focusable = panel.querySelectorAll(MODAL_FOCUSABLE_SELECTOR); - (focusable[0] ?? panel).focus(); - } - - private trapModalFocus(event: KeyboardEvent): void { - const panel = this.activeModalPanel(); - if (!panel) { - return; - } - const focusable = Array.from(panel.querySelectorAll(MODAL_FOCUSABLE_SELECTOR)); - if (focusable.length === 0) { - return; - } - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - const active = document.activeElement; - - if (event.shiftKey && active === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && active === last) { - event.preventDefault(); - first.focus(); - } } requestLogin(): void { diff --git a/src/app/shared/ui/dialog/dialog.component.html b/src/app/shared/ui/dialog/dialog.component.html index 24408c7..7f0a7c7 100644 --- a/src/app/shared/ui/dialog/dialog.component.html +++ b/src/app/shared/ui/dialog/dialog.component.html @@ -8,7 +8,7 @@ [class.app-dialog-panel--lg]="size() === 'lg'" role="dialog" aria-modal="true" - [attr.aria-label]="titleText()" + [attr.aria-label]="titleText() ?? ariaLabel()" tabindex="-1" (click)="$event.stopPropagation()" > diff --git a/src/app/shared/ui/dialog/dialog.component.scss b/src/app/shared/ui/dialog/dialog.component.scss index 2b8500b..069b85f 100644 --- a/src/app/shared/ui/dialog/dialog.component.scss +++ b/src/app/shared/ui/dialog/dialog.component.scss @@ -11,6 +11,7 @@ } .app-dialog-panel { + position: relative; width: 100%; max-height: 90vh; overflow-y: auto; diff --git a/src/app/shared/ui/dialog/dialog.component.ts b/src/app/shared/ui/dialog/dialog.component.ts index 88032eb..876630d 100644 --- a/src/app/shared/ui/dialog/dialog.component.ts +++ b/src/app/shared/ui/dialog/dialog.component.ts @@ -15,7 +15,7 @@ import { TranslatePipe } from '../../../i18n/translate.pipe'; export type DialogSize = 'sm' | 'md' | 'lg'; const FOCUSABLE_SELECTOR = - 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), iframe, [tabindex]:not([tabindex="-1"])'; @Component({ selector: 'app-dialog', @@ -28,7 +28,14 @@ const FOCUSABLE_SELECTOR = export class DialogComponent implements OnChanges, AfterViewInit { readonly open = input(false); readonly titleText = input(null); + // Accessible name for dialogs that skip the visible titled header (a + // consumer-rendered close button inside the content instead). Ignored + // when titleText is set - the header already supplies the name. + readonly ariaLabel = input(null); readonly size = input('md'); + // Both default true (existing behavior for every current consumer). + readonly closeOnEscape = input(true); + readonly closeOnBackdropClick = input(true); readonly closed = output(); @@ -60,7 +67,9 @@ export class DialogComponent implements OnChanges, AfterViewInit { return; } if (event.key === 'Escape') { - this.requestClose(); + if (this.closeOnEscape()) { + this.requestClose(); + } return; } if (event.key === 'Tab') { @@ -73,7 +82,9 @@ export class DialogComponent implements OnChanges, AfterViewInit { } protected handleBackdropClick(): void { - this.requestClose(); + if (this.closeOnBackdropClick()) { + this.requestClose(); + } } private focusPanel(): void {