From b31c75214d18132831c51f61f4ffd1aeaa993e77 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 18 Jul 2026 11:02:26 +0400 Subject: [PATCH] feat(builder): implement professional content management experience Content dashboard with real published/draft/SEO-health/completion metrics and a recommended-next-action; static pages editor rebuilt as visual page cards (icon/status/SEO badge/last edited) with legal pages surfaced first; full-page editor grouped into Content/SEO/Sharing/Advanced tabs, raw HTML moved behind an Advanced disclosure; hero image now supports alt text and caption; content-health-widget is a reusable checklist+completion component. --- .../content-dashboard.component.html | 31 ++ .../content-dashboard.component.scss | 52 ++++ .../content-dashboard.component.ts | 46 +++ .../content-health-widget.component.html | 17 ++ .../content-health-widget.component.scss | 66 +++++ .../content-health-widget.component.ts | 25 ++ .../page-card/page-card.component.html | 36 +++ .../page-card/page-card.component.scss | 83 ++++++ .../page-card/page-card.component.ts | 57 ++++ .../page-editor/page-editor.component.html | 215 ++++++++++++++ .../page-editor/page-editor.component.scss | 134 +++++++++ .../page-editor/page-editor.component.ts | 86 ++++++ .../static-pages-editor.component.html | 271 ++++++------------ .../static-pages-editor.component.scss | 46 +-- .../static-pages-editor.component.ts | 111 ++++--- .../facade/content-management.facade.ts | 91 ++++++ .../models/content-page.model.ts | 7 + .../models/legal-pages.model.ts | 33 +++ .../services/content-page.service.ts | 6 + src/app/i18n/en.ts | 49 ++++ src/app/i18n/hy.ts | 49 ++++ src/app/i18n/ru.ts | 49 ++++ src/app/i18n/translations.ts | 49 ++++ .../shared/models/config/static-page.model.ts | 4 + 24 files changed, 1350 insertions(+), 263 deletions(-) create mode 100644 src/app/features/content-management/components/content-dashboard/content-dashboard.component.html create mode 100644 src/app/features/content-management/components/content-dashboard/content-dashboard.component.scss create mode 100644 src/app/features/content-management/components/content-dashboard/content-dashboard.component.ts create mode 100644 src/app/features/content-management/components/content-health-widget/content-health-widget.component.html create mode 100644 src/app/features/content-management/components/content-health-widget/content-health-widget.component.scss create mode 100644 src/app/features/content-management/components/content-health-widget/content-health-widget.component.ts create mode 100644 src/app/features/content-management/components/page-card/page-card.component.html create mode 100644 src/app/features/content-management/components/page-card/page-card.component.scss create mode 100644 src/app/features/content-management/components/page-card/page-card.component.ts create mode 100644 src/app/features/content-management/components/page-editor/page-editor.component.html create mode 100644 src/app/features/content-management/components/page-editor/page-editor.component.scss create mode 100644 src/app/features/content-management/components/page-editor/page-editor.component.ts create mode 100644 src/app/features/content-management/models/legal-pages.model.ts diff --git a/src/app/features/content-management/components/content-dashboard/content-dashboard.component.html b/src/app/features/content-management/components/content-dashboard/content-dashboard.component.html new file mode 100644 index 0000000..897acc6 --- /dev/null +++ b/src/app/features/content-management/components/content-dashboard/content-dashboard.component.html @@ -0,0 +1,31 @@ +
+
+ + + + + +
+ +
+ + + + + +

{{ 'contentManagement.recommendedNext' | translate }}

+

{{ health().recommendation.labelKey | translate }}

+
+ @if (health().recommendation.pageId) { + {{ 'contentManagement.openAction' | translate }} + } @else if (health().recommendation.kind === 'createLegal') { + {{ 'builder.createPage' | translate }} + } +
+
+
+
diff --git a/src/app/features/content-management/components/content-dashboard/content-dashboard.component.scss b/src/app/features/content-management/components/content-dashboard/content-dashboard.component.scss new file mode 100644 index 0000000..53484db --- /dev/null +++ b/src/app/features/content-management/components/content-dashboard/content-dashboard.component.scss @@ -0,0 +1,52 @@ +.content-dashboard { + display: grid; + gap: 16px; + margin-bottom: 8px; +} + +.content-dashboard__metrics { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; +} + +@media (max-width: 1000px) { + .content-dashboard__metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.content-dashboard__row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; +} + +@media (max-width: 800px) { + .content-dashboard__row { + grid-template-columns: 1fr; + } +} + +.content-dashboard__recommend { + display: grid; + gap: 8px; + align-content: start; + + h4 { + margin: 0; + font-size: 0.95rem; + font-weight: 700; + } + + p { + margin: 0; + color: var(--text-secondary, #5f6e6a); + font-size: 0.9rem; + } +} + +.content-dashboard__recommend-actions { + display: flex; + gap: 8px; +} diff --git a/src/app/features/content-management/components/content-dashboard/content-dashboard.component.ts b/src/app/features/content-management/components/content-dashboard/content-dashboard.component.ts new file mode 100644 index 0000000..5c84c53 --- /dev/null +++ b/src/app/features/content-management/components/content-dashboard/content-dashboard.component.ts @@ -0,0 +1,46 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Output, computed, input } from '@angular/core'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { TranslateService } from '../../../../i18n/translate.service'; +import { ButtonComponent } from '../../../../shared/ui/button/button.component'; +import { CardComponent } from '../../../../shared/ui/card/card.component'; +import { DashboardMetricComponent } from '../../../admin/dashboard/components/dashboard-metric.component'; +import { ContentHealthWidgetComponent, ContentHealthItem } from '../content-health-widget/content-health-widget.component'; +import { ContentHealth } from '../../facade/content-management.facade'; +import { inject } from '@angular/core'; + +@Component({ + selector: 'app-content-dashboard', + standalone: true, + imports: [TranslatePipe, ButtonComponent, CardComponent, DashboardMetricComponent, ContentHealthWidgetComponent], + templateUrl: './content-dashboard.component.html', + styleUrl: './content-dashboard.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContentDashboardComponent { + private readonly translate = inject(TranslateService); + + readonly health = input.required(); + + @Output() createPage = new EventEmitter(); + @Output() goToPage = new EventEmitter(); + + readonly seoHealthValue = computed(() => `${this.health().seoHealthPercent}%`); + readonly lastEditedValue = computed(() => { + const iso = this.health().lastEditedAt; + if (!iso) { + return this.translate.t('contentManagement.lastEditedNever'); + } + return new Date(iso).toLocaleDateString(); + }); + + readonly legalItems = computed(() => + this.health().legalStatus.map(status => ({ labelKey: status.def.labelKey, done: !!status.page })), + ); + + goRecommended(): void { + const target = this.health().recommendation.pageId; + if (target) { + this.goToPage.emit(target); + } + } +} diff --git a/src/app/features/content-management/components/content-health-widget/content-health-widget.component.html b/src/app/features/content-management/components/content-health-widget/content-health-widget.component.html new file mode 100644 index 0000000..fbac164 --- /dev/null +++ b/src/app/features/content-management/components/content-health-widget/content-health-widget.component.html @@ -0,0 +1,17 @@ +
+
+

{{ titleKey() | translate }}

+ {{ completionPercent() }}% +
+
+
+
+
    + @for (item of items(); track item.labelKey) { +
  • + + {{ item.labelKey | translate }} +
  • + } +
+
diff --git a/src/app/features/content-management/components/content-health-widget/content-health-widget.component.scss b/src/app/features/content-management/components/content-health-widget/content-health-widget.component.scss new file mode 100644 index 0000000..8df628c --- /dev/null +++ b/src/app/features/content-management/components/content-health-widget/content-health-widget.component.scss @@ -0,0 +1,66 @@ +.health-widget { + display: grid; + gap: 10px; +} + +.health-widget__head { + display: flex; + align-items: baseline; + justify-content: space-between; + + h4 { + margin: 0; + font-size: 0.95rem; + font-weight: 700; + color: var(--text-primary, #1e3c38); + } +} + +.health-widget__percent { + font-size: 1.05rem; + font-weight: 800; + color: var(--brand-primary, #1e8a6e); +} + +.health-widget__bar { + height: 8px; + border-radius: 999px; + background: var(--surface-muted, #eef2f0); + overflow: hidden; +} + +.health-widget__bar-fill { + height: 100%; + background: var(--brand-primary, #1e8a6e); + transition: width 0.3s ease; +} + +.health-widget__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; + font-size: 0.85rem; + color: var(--text-secondary, #5f6e6a); + + li { + display: flex; + align-items: center; + gap: 8px; + } +} + +.health-widget__icon { + width: 18px; + text-align: center; + color: var(--text-tertiary, #9aa6a2); +} + +.health-widget__list-item--done { + color: var(--text-primary, #1e3c38); + + .health-widget__icon { + color: var(--brand-primary, #1e8a6e); + } +} diff --git a/src/app/features/content-management/components/content-health-widget/content-health-widget.component.ts b/src/app/features/content-management/components/content-health-widget/content-health-widget.component.ts new file mode 100644 index 0000000..1bc1362 --- /dev/null +++ b/src/app/features/content-management/components/content-health-widget/content-health-widget.component.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; + +export interface ContentHealthItem { + labelKey: string; + done: boolean; +} + +/** + * Reusable checklist + completion meter. Fed real per-item booleans and a + * precomputed percentage by the caller - never invents its own data. + */ +@Component({ + selector: 'app-content-health-widget', + standalone: true, + imports: [TranslatePipe], + templateUrl: './content-health-widget.component.html', + styleUrl: './content-health-widget.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContentHealthWidgetComponent { + readonly titleKey = input.required(); + readonly items = input.required(); + readonly completionPercent = input.required(); +} diff --git a/src/app/features/content-management/components/page-card/page-card.component.html b/src/app/features/content-management/components/page-card/page-card.component.html new file mode 100644 index 0000000..fae6fb4 --- /dev/null +++ b/src/app/features/content-management/components/page-card/page-card.component.html @@ -0,0 +1,36 @@ + + +
+ {{ 'contentManagement.openAction' | translate }} + {{ 'staticPages.previewToggle' | translate }} + {{ 'staticPages.duplicatePage' | translate }} + {{ 'builder.deletePage' | translate }} +
+
diff --git a/src/app/features/content-management/components/page-card/page-card.component.scss b/src/app/features/content-management/components/page-card/page-card.component.scss new file mode 100644 index 0000000..40a7537 --- /dev/null +++ b/src/app/features/content-management/components/page-card/page-card.component.scss @@ -0,0 +1,83 @@ +.page-card-tile { + display: block; + height: 100%; +} + +.page-card-tile__surface { + all: unset; + display: flex; + gap: 12px; + width: 100%; + cursor: pointer; + box-sizing: border-box; + padding-bottom: 12px; + + &:focus-visible { + outline: 2px solid var(--brand-primary, #1e8a6e); + outline-offset: 3px; + border-radius: 8px; + } +} + +.page-card-tile__icon { + font-size: 1.5rem; + line-height: 1; + flex-shrink: 0; +} + +.page-card-tile__body { + display: grid; + gap: 4px; + min-width: 0; +} + +.page-card-tile__title-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.page-card-tile__title { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: var(--text-primary, #1e3c38); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.page-card-tile__slug { + margin: 0; + font-size: 0.8rem; + color: var(--text-secondary, #5f6e6a); + font-family: monospace; +} + +.page-card-tile__meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin-top: 2px; +} + +.page-card-tile__visibility { + font-size: 0.75rem; + color: var(--text-tertiary, #9aa6a2); +} + +.page-card-tile__updated { + margin: 4px 0 0; + font-size: 0.75rem; + color: var(--text-tertiary, #9aa6a2); +} + +.page-card-tile__actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + border-top: 1px solid var(--border-subtle, #e7ece9); + padding-top: 10px; +} diff --git a/src/app/features/content-management/components/page-card/page-card.component.ts b/src/app/features/content-management/components/page-card/page-card.component.ts new file mode 100644 index 0000000..ed79b0b --- /dev/null +++ b/src/app/features/content-management/components/page-card/page-card.component.ts @@ -0,0 +1,57 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Output, computed, input } from '@angular/core'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { ButtonComponent } from '../../../../shared/ui/button/button.component'; +import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; +import { CardComponent } from '../../../../shared/ui/card/card.component'; +import { ContentPage } from '../../models/content-page.model'; + +export type PageSeoStatus = 'good' | 'partial' | 'missing' | 'warning'; + +@Component({ + selector: 'app-page-card', + standalone: true, + imports: [TranslatePipe, ButtonComponent, BadgeComponent, CardComponent], + templateUrl: './page-card.component.html', + styleUrl: './page-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PageCardComponent { + readonly page = input.required(); + readonly legalLabelKey = input(null); + readonly seoStatus = input.required(); + readonly hasIssue = input(false); + readonly updatedAtLabel = input(null); + + @Output() open = new EventEmitter(); + @Output() duplicate = new EventEmitter(); + @Output() preview = new EventEmitter(); + @Output() remove = new EventEmitter(); + + readonly visibilitySummary = computed(() => { + const visibility = this.page().visibility; + const devices = [ + visibility?.desktop !== false ? 'D' : null, + visibility?.tablet !== false ? 'T' : null, + visibility?.mobile !== false ? 'M' : null, + ].filter((value): value is string => !!value); + return devices.join(' · '); + }); + + readonly seoStatusVariant = computed<'success' | 'warning' | 'danger' | 'neutral'>(() => { + switch (this.seoStatus()) { + case 'good': return 'success'; + case 'partial': return 'neutral'; + case 'warning': return 'danger'; + default: return 'warning'; + } + }); + + readonly seoStatusLabelKey = computed(() => { + switch (this.seoStatus()) { + case 'good': return 'contentManagement.seoStatusGood'; + case 'partial': return 'contentManagement.seoStatusPartial'; + case 'warning': return 'contentManagement.seoStatusWarning'; + default: return 'contentManagement.seoStatusMissing'; + } + }); +} diff --git a/src/app/features/content-management/components/page-editor/page-editor.component.html b/src/app/features/content-management/components/page-editor/page-editor.component.html new file mode 100644 index 0000000..8e8ace9 --- /dev/null +++ b/src/app/features/content-management/components/page-editor/page-editor.component.html @@ -0,0 +1,215 @@ +
+
+ ← {{ 'contentManagement.backToPages' | translate }} +

{{ previewTitle() || page().slug }}

+
+ {{ 'staticPages.previewToggle' | translate }} + {{ 'staticPages.duplicatePage' | translate }} + @if (page().status === 'published') { + {{ 'staticPages.unpublishPageAction' | translate }} + } @else { + {{ 'staticPages.publishPageAction' | translate }} + } + {{ 'builder.deletePage' | translate }} +
+
+ + @if (previewOpen()) { + + } + +
+ @for (group of groups; track group.id) { + + } +
+ +
+ @if (activeGroup() === 'content') { +
+ @for (locale of locales(); track locale) { + + + + } + +
+
+ {{ 'staticPages.heroImageLabel' | translate }} + @if (page().heroImage) { + + } +
+ + {{ (page().heroImage ? 'contentManagement.mediaReplace' : 'contentManagement.mediaSelect') | translate }} + + @if (page().heroImage) { + {{ 'contentManagement.mediaRemove' | translate }} + } +
+ + + + + + +
+ +
+ {{ 'staticPages.thumbnailLabel' | translate }} + @if (page().thumbnail) { + + } +
+ + {{ (page().thumbnail ? 'contentManagement.mediaReplace' : 'contentManagement.mediaSelect') | translate }} + + @if (page().thumbnail) { + {{ 'contentManagement.mediaRemove' | translate }} + } +
+
+
+
+ } + + @if (activeGroup() === 'seo') { +
+

{{ 'contentManagement.seoExplainDesc' | translate }}

+ + + + + + + + + + + + +
+ } + + @if (activeGroup() === 'sharing') { +
+

{{ 'contentManagement.sharingExplain' | translate }}

+ + + + + + + + + +
+ } + + @if (activeGroup() === 'advanced') { +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ {{ 'contentManagement.groupAdvancedHtml' | translate }} + @for (locale of locales(); track locale) { + + } +
+
+ } +
+
diff --git a/src/app/features/content-management/components/page-editor/page-editor.component.scss b/src/app/features/content-management/components/page-editor/page-editor.component.scss new file mode 100644 index 0000000..01c08e6 --- /dev/null +++ b/src/app/features/content-management/components/page-editor/page-editor.component.scss @@ -0,0 +1,134 @@ +.page-editor { + display: grid; + gap: 16px; +} + +.page-editor__header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; +} + +.page-editor__title { + margin: 0; + font-size: 1.15rem; + font-weight: 700; + color: var(--text-primary, #1e3c38); + flex: 1; + min-width: 160px; +} + +.page-editor__header-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.page-editor__tabs { + display: flex; + flex-wrap: wrap; + gap: 4px; + border-bottom: 1px solid var(--border-subtle, #e7ece9); +} + +.page-editor__tab { + all: unset; + cursor: pointer; + padding: 10px 14px; + border-radius: 8px 8px 0 0; + font-size: 0.9rem; + font-weight: 600; + color: var(--text-secondary, #5f6e6a); + display: inline-flex; + align-items: center; + gap: 6px; + + &:focus-visible { + outline: 2px solid var(--brand-primary, #1e8a6e); + outline-offset: 2px; + } +} + +.page-editor__tab--active { + color: var(--brand-primary, #1e8a6e); + background: var(--surface-muted, #eef2f0); +} + +.page-editor__panel { + min-height: 200px; +} + +.page-editor__stack { + display: grid; + gap: 14px; +} + +.page-editor__explain { + margin: 0; + font-size: 0.85rem; + color: var(--text-secondary, #5f6e6a); +} + +.page-editor__media-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +@media (max-width: 720px) { + .page-editor__media-grid { + grid-template-columns: 1fr; + } +} + +.media-block { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid var(--border-subtle, #e7ece9); + border-radius: 10px; +} + +.media-block__label { + font-size: 0.85rem; + font-weight: 700; + color: var(--text-primary, #1e3c38); +} + +.media-block__preview { + width: 100%; + max-height: 140px; + object-fit: cover; + border-radius: 8px; +} + +.media-block__actions { + display: flex; + gap: 8px; +} + +.page-editor__html-disclosure { + border: 1px solid var(--border-subtle, #e7ece9); + border-radius: 10px; + padding: 10px 14px; + + summary { + cursor: pointer; + font-weight: 700; + color: var(--text-primary, #1e3c38); + } +} + +.page-editor__html-block { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.page-editor__error { + color: var(--danger, #c0392b); + font-size: 0.8rem; + margin: 0; +} diff --git a/src/app/features/content-management/components/page-editor/page-editor.component.ts b/src/app/features/content-management/components/page-editor/page-editor.component.ts new file mode 100644 index 0000000..4077810 --- /dev/null +++ b/src/app/features/content-management/components/page-editor/page-editor.component.ts @@ -0,0 +1,86 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Output, computed, input, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { ButtonComponent } from '../../../../shared/ui/button/button.component'; +import { InputComponent } from '../../../../shared/ui/input/input.component'; +import { FormFieldComponent } from '../../../../shared/ui/form-field/form-field.component'; +import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; +import { ToggleComponent } from '../../../../shared/ui/toggle/toggle.component'; +import { MarketplaceHtmlEditorComponent } from '../../../project-editor/components/html-editor/marketplace-html-editor.component'; +import { StaticPagePreviewComponent } from '../static-page-preview/static-page-preview.component'; +import { ContentPage, ContentPageSeoConfig } from '../../models/content-page.model'; + +export type PageEditorGroup = 'content' | 'seo' | 'sharing' | 'advanced'; +export type PageMediaField = 'heroImage' | 'thumbnail'; + +@Component({ + selector: 'app-page-editor', + standalone: true, + imports: [ + FormsModule, + TranslatePipe, + ButtonComponent, + InputComponent, + FormFieldComponent, + BadgeComponent, + ToggleComponent, + MarketplaceHtmlEditorComponent, + StaticPagePreviewComponent, + ], + templateUrl: './page-editor.component.html', + styleUrl: './page-editor.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PageEditorComponent { + readonly page = input.required(); + readonly locales = input.required(); + readonly defaultLocale = input('en'); + + readonly hasDuplicateSlug = input(false); + readonly hasDuplicateRoute = input(false); + readonly hasEmptyTitle = input(false); + readonly hasInvalidHtml = input(false); + readonly hasInvalidSeo = input(false); + + @Output() close = new EventEmitter(); + @Output() updatePage = new EventEmitter>(); + @Output() updateTranslation = new EventEmitter<{ locale: string; field: 'title' | 'html'; value: string }>(); + @Output() updateSeo = new EventEmitter>(); + @Output() updateGallery = new EventEmitter(); + @Output() openMediaPicker = new EventEmitter(); + @Output() clearMedia = new EventEmitter(); + @Output() duplicatePage = new EventEmitter(); + @Output() deletePage = new EventEmitter(); + + readonly activeGroup = signal('content'); + readonly previewOpen = signal(false); + + readonly groups: { id: PageEditorGroup; labelKey: string }[] = [ + { id: 'content', labelKey: 'contentManagement.groupContent' }, + { id: 'seo', labelKey: 'contentManagement.groupSeo' }, + { id: 'sharing', labelKey: 'contentManagement.groupSharing' }, + { id: 'advanced', labelKey: 'contentManagement.groupAdvanced' }, + ]; + + readonly previewHtml = computed(() => { + const translations = this.page().translations; + return translations[this.defaultLocale()]?.html ?? Object.values(translations)[0]?.html ?? ''; + }); + + readonly previewTitle = computed(() => { + const translations = this.page().translations; + return translations[this.defaultLocale()]?.title || this.page().title; + }); + + setGroup(group: PageEditorGroup): void { + this.activeGroup.set(group); + } + + togglePreview(): void { + this.previewOpen.set(!this.previewOpen()); + } + + onCheckbox(event: Event): boolean { + return (event.target as HTMLInputElement).checked; + } +} diff --git a/src/app/features/content-management/components/static-pages-editor.component.html b/src/app/features/content-management/components/static-pages-editor.component.html index 8656dba..a8f4ec2 100644 --- a/src/app/features/content-management/components/static-pages-editor.component.html +++ b/src/app/features/content-management/components/static-pages-editor.component.html @@ -1,198 +1,97 @@
-
-

{{ 'builder.staticPages' | translate }}

- {{ 'builder.createPage' | translate }} -
@if (fieldError('staticPages'); as msg) {

{{ msg }}

} -
- - - - - - - - - -
+ @if (editingPage(); as page) { + + } @else { +
+

{{ 'builder.staticPages' | translate }}

+ {{ 'builder.createPage' | translate }} +
- @if (pages().length > 0) { -
- - @if (selectedCount() > 0) { - {{ selectedCount() }} {{ 'staticPages.selectedCount' | translate }} - {{ 'staticPages.bulkEnable' | translate }} - {{ 'staticPages.bulkDisable' | translate }} - {{ 'staticPages.bulkPublishAction' | translate }} - {{ 'staticPages.bulkUnpublish' | translate }} - {{ 'staticPages.bulkDelete' | translate }} + + +
+ + + + + + + + + +
+ + @if (pages().length > 0) { +
+ + @if (selectedCount() > 0) { + {{ selectedCount() }} {{ 'staticPages.selectedCount' | translate }} + {{ 'staticPages.bulkEnable' | translate }} + {{ 'staticPages.bulkDisable' | translate }} + {{ 'staticPages.bulkPublishAction' | translate }} + {{ 'staticPages.bulkUnpublish' | translate }} + {{ 'staticPages.bulkDelete' | translate }} + } +
+ } + + @if (pages().length === 0) { + + + {{ 'builder.createPage' | translate }} + + + } + +
+ @for (page of pages(); track page.id) { +
+ + + @if (previewOpenPageId() === page.id) { + + } +
}
} - @if (pages().length === 0) { - - - {{ 'builder.createPage' | translate }} - - - } - -
- @for (page of pages(); track page.id) { - -
-
- -

- {{ page.id }} - {{ (page.status === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }} - @if (!page.enabled) { - {{ 'staticPages.disabledBadge' | translate }} - } - @if (isModified(page)) { - {{ 'builder.unsavedChanges' | translate }} - } - @if (hasDuplicateSlug(page)) { - {{ 'staticPages.duplicateSlug' | translate }} - } - @if (hasDuplicateRoute(page)) { - {{ 'staticPages.duplicateRoute' | translate }} - } - @if (hasEmptyTitle(page)) { - {{ 'staticPages.emptyTitle' | translate }} - } - @if (hasInvalidHtml(page)) { - {{ 'staticPages.invalidHtml' | translate }} - } - @if (hasInvalidSeo(page)) { - {{ 'staticPages.invalidSeo' | translate }} - } -

-
- - - {{ 'staticPages.duplicatePage' | translate }} - {{ 'staticPages.previewToggle' | translate }} - @if (page.status === 'published') { - {{ 'staticPages.unpublishPageAction' | translate }} - } @else { - {{ 'staticPages.publishPageAction' | translate }} - } - {{ 'builder.deletePage' | translate }} -
-
- - @if (previewOpenPageId() === page.id) { - - } - -
- - - - - - - - - - - - - - - - - - - - - - -
- -
- -
- - {{ 'adminCategories.chooseImage' | translate }} -
-
- -
- - {{ 'adminCategories.chooseImage' | translate }} -
-
- - - -
- -
- - - - -
- - @for (locale of locales(); track locale) { -
- - - - -
- } - -

{{ 'builder.seoSection' | translate }}

-
- - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- } -
diff --git a/src/app/features/content-management/components/static-pages-editor.component.scss b/src/app/features/content-management/components/static-pages-editor.component.scss index 35b4b19..cfadc6e 100644 --- a/src/app/features/content-management/components/static-pages-editor.component.scss +++ b/src/app/features/content-management/components/static-pages-editor.component.scss @@ -1,44 +1,18 @@ -.page-card { +.page-card-grid { display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; } -.page-card__header { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 10px; -} - -.page-card__title { - margin: 0; - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - font-size: 1rem; - font-weight: 700; - color: var(--text-primary, #1e3c38); -} - -.page-card__subheading { - margin: 4px 0 0; - font-size: 0.875rem; - font-weight: 700; - color: var(--text-secondary, #5f6e6a); - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.toggle-grid { +.page-card-grid__item { + position: relative; display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 12px; + gap: 8px; } -@media (max-width: 900px) { - .toggle-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } +.page-card-grid__select { + position: absolute; + top: 12px; + right: 12px; + z-index: 1; } diff --git a/src/app/features/content-management/components/static-pages-editor.component.ts b/src/app/features/content-management/components/static-pages-editor.component.ts index b4a723c..324e7f7 100644 --- a/src/app/features/content-management/components/static-pages-editor.component.ts +++ b/src/app/features/content-management/components/static-pages-editor.component.ts @@ -3,25 +3,25 @@ import { FormsModule } from '@angular/forms'; import { ProjectEditorFacade } from '../../project-editor/facade/project-editor.facade'; import { ContentManagementFacade } from '../facade/content-management.facade'; import { ContentPage, ContentPageSeoConfig, ContentPageStatus } from '../models/content-page.model'; +import { findLegalDefinition } from '../models/legal-pages.model'; import { BootstrapConfig } from '../../../shared/models/config'; import { TranslatePipe } from '../../../i18n/translate.pipe'; import { TranslateService } from '../../../i18n/translate.service'; -import { MarketplaceHtmlEditorComponent } from '../../project-editor/components/html-editor/marketplace-html-editor.component'; import { ButtonComponent } from '../../../shared/ui/button/button.component'; import { InputComponent } from '../../../shared/ui/input/input.component'; -import { CardComponent } from '../../../shared/ui/card/card.component'; import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component'; -import { BadgeComponent } from '../../../shared/ui/badge/badge.component'; import { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component'; import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component'; import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component'; import { MediaPickerComponent } from '../../../shared/media/media-picker/media-picker.component'; import { MediaAsset } from '../../../core/media/models/media-asset.model'; import { StaticPagePreviewComponent } from './static-page-preview/static-page-preview.component'; +import { ContentDashboardComponent } from './content-dashboard/content-dashboard.component'; +import { PageCardComponent, PageSeoStatus } from './page-card/page-card.component'; +import { PageEditorComponent, PageMediaField } from './page-editor/page-editor.component'; type StatusFilter = 'all' | ContentPageStatus; const ALL_LOCALES_FILTER = 'all'; -type MediaField = 'heroImage' | 'thumbnail'; @Component({ selector: 'app-static-pages-editor', @@ -29,17 +29,17 @@ type MediaField = 'heroImage' | 'thumbnail'; imports: [ FormsModule, TranslatePipe, - MarketplaceHtmlEditorComponent, ButtonComponent, InputComponent, - CardComponent, FormFieldComponent, - BadgeComponent, EmptyStateComponent, SelectComponent, ToggleComponent, MediaPickerComponent, StaticPagePreviewComponent, + ContentDashboardComponent, + PageCardComponent, + PageEditorComponent, ], templateUrl: './static-pages-editor.component.html', styleUrls: ['../../project-editor/sections/section.shared.scss', './static-pages-editor.component.scss'], @@ -57,12 +57,18 @@ export class StaticPagesEditorComponent { }; readonly allPages = computed(() => this.contentFacade.pages(this.bootstrap())); readonly validation = computed(() => this.contentFacade.validatePages(this.bootstrap())); + readonly contentHealth = computed(() => this.contentFacade.contentHealth(this.bootstrap())); readonly locales = computed(() => this.bootstrap()?.localization.supportedLocales ?? ['en']); + readonly defaultLocale = computed(() => this.bootstrap()?.localization.defaultLocale ?? 'en'); readonly searchQuery = signal(''); readonly statusFilter = signal('all'); readonly localeFilter = signal(ALL_LOCALES_FILTER); readonly selectedIds = signal>(new Set()); + readonly editingPageId = signal(null); + readonly previewOpenPageId = signal(null); + + readonly editingPage = computed(() => this.allPages().find(page => page.id === this.editingPageId()) ?? null); readonly statusFilterOptions = computed(() => [ { value: 'all', label: this.translate.t('staticPages.filterAll') }, @@ -80,7 +86,7 @@ export class StaticPagesEditorComponent { const status = this.statusFilter(); const locale = this.localeFilter(); - return this.allPages().filter(page => { + const filtered = this.allPages().filter(page => { if (status !== 'all' && page.status !== status) { return false; } @@ -95,6 +101,13 @@ export class StaticPagesEditorComponent { .toLowerCase(); return haystack.includes(query); }); + + // Legal pages surface first (Task 3) - stable sort keeps the rest in their existing order. + return [...filtered].sort((a, b) => { + const aLegal = findLegalDefinition(a) ? 0 : 1; + const bLegal = findLegalDefinition(b) ? 0 : 1; + return aLegal - bLegal; + }); }); readonly selectedCount = computed(() => this.selectedIds().size); @@ -103,11 +116,6 @@ export class StaticPagesEditorComponent { return visible.length > 0 && visible.every(page => this.selectedIds().has(page.id)); }); - /** - * The originally loaded/published pages, normalized through the same - * ContentManagementFacade.pages() path as the live editor list, keyed by - * id - reused (not reimplemented) for the per-page modified indicator. - */ private readonly originalPagesById = computed>(() => { const staticPages = this.projectEditor.originalStaticPages(); if (!staticPages) { @@ -120,31 +128,61 @@ export class StaticPagesEditorComponent { isModified(page: ContentPage): boolean { const original = this.originalPagesById().get(page.id); if (!original) { - return true; // a page that didn't exist in the original snapshot is new/modified. + return true; } return JSON.stringify(page) !== JSON.stringify(original); } - readonly previewOpenPageId = signal(null); + legalLabelKey(page: ContentPage): string | null { + return findLegalDefinition(page)?.labelKey ?? null; + } + + seoStatus(page: ContentPage): PageSeoStatus { + if (this.hasInvalidSeo(page)) { + return 'warning'; + } + const hasSeo = this.contentFacade.hasSeoContent(page); + if (!hasSeo) { + return 'missing'; + } + const complete = !!(page.seo?.title?.trim() && page.seo?.description?.trim()); + return complete ? 'good' : 'partial'; + } + + hasAnyIssue(page: ContentPage): boolean { + return this.hasDuplicateSlug(page) || this.hasDuplicateRoute(page) || this.hasEmptyTitle(page) || this.hasInvalidHtml(page); + } + + updatedAtLabel(page: ContentPage): string | null { + return page.updatedAt ? new Date(page.updatedAt).toLocaleDateString() : null; + } + + openEditor(id: string): void { + this.editingPageId.set(id); + } + + closeEditor(): void { + this.editingPageId.set(null); + } togglePreview(pageId: string): void { this.previewOpenPageId.set(this.previewOpenPageId() === pageId ? null : pageId); } previewHtml(page: ContentPage): string { - const defaultLocale = this.bootstrap()?.localization.defaultLocale ?? 'en'; + const defaultLocale = this.defaultLocale(); return page.translations[defaultLocale]?.html ?? Object.values(page.translations)[0]?.html ?? ''; } previewTitle(page: ContentPage): string { - const defaultLocale = this.bootstrap()?.localization.defaultLocale ?? 'en'; + const defaultLocale = this.defaultLocale(); return page.translations[defaultLocale]?.title || page.title; } protected mediaPickerOpen = false; - private mediaPickerTarget: { pageId: string; field: MediaField } | null = null; + private mediaPickerTarget: { pageId: string; field: PageMediaField } | null = null; - openMediaPicker(pageId: string, field: MediaField): void { + openMediaPicker(pageId: string, field: PageMediaField): void { this.mediaPickerTarget = { pageId, field }; this.mediaPickerOpen = true; } @@ -156,14 +194,10 @@ export class StaticPagesEditorComponent { this.mediaPickerOpen = false; } - /** - * Appends `-2`, `-3`, ... to `candidate` until it's not in `existing`. - * createPage()/duplicatePage() both used to derive slugs/routes from array - * length or a fixed "-copy" suffix, which reproducibly collides with an - * existing page (create, delete, create again; or duplicate the same page - * twice) - the duplicate then trips the duplicate-slug/route validator - * on a page the user never touched. - */ + clearMedia(pageId: string, field: PageMediaField): void { + this.updatePage(pageId, { [field]: undefined }); + } + private uniqueValue(candidate: string, existing: Set): string { if (!existing.has(candidate)) { return candidate; @@ -194,14 +228,13 @@ export class StaticPagesEditorComponent { ru: { title: '', html: '' }, hy: { title: '', html: '' }, }, - // New pages start disabled from the storefront's perspective until an - // author explicitly publishes them - existing bootstrap pages default - // to enabled/published on normalize (see ContentPageService.normalizePage). enabled: true, status: 'draft', + updatedAt: new Date().toISOString(), }; this.persist([...this.allPages(), page]); + this.openEditor(page.id); } duplicatePage(id: string): void { @@ -219,6 +252,7 @@ export class StaticPagesEditorComponent { order: this.allPages().length + 1, status: 'draft', translations: Object.fromEntries(Object.entries(source.translations).map(([locale, t]) => [locale, { ...t }])), + updatedAt: new Date().toISOString(), }; this.persist([...this.allPages(), clone]); } @@ -229,15 +263,19 @@ export class StaticPagesEditorComponent { } this.persist(this.allPages().filter(page => page.id !== id)); this.deselect(id); + if (this.editingPageId() === id) { + this.closeEditor(); + } } updatePage(id: string, patch: Partial): void { - this.persist(this.allPages().map(page => page.id !== id ? page : ({ ...page, ...patch }))); + this.persist(this.allPages().map(page => page.id !== id ? page : ({ ...page, ...patch, updatedAt: new Date().toISOString() }))); } updateTranslation(id: string, locale: string, field: 'title' | 'html', value: string): void { this.persist(this.allPages().map(page => page.id !== id ? page : ({ ...page, + updatedAt: new Date().toISOString(), translations: { ...page.translations, [locale]: { @@ -276,6 +314,7 @@ export class StaticPagesEditorComponent { updateSeo(id: string, patch: Partial): void { this.persist(this.allPages().map(page => page.id !== id ? page : ({ ...page, + updatedAt: new Date().toISOString(), seo: { ...(page.seo ?? {}), ...patch, @@ -284,9 +323,6 @@ export class StaticPagesEditorComponent { } move(id: string, direction: -1 | 1): void { - // Reorders within the full page set (not the filtered/visible subset) so - // a search or status/locale filter can never drop pages out of the - // bootstrap - see the persist() note below. const pages = [...this.allPages()].sort((a, b) => a.order - b.order); const index = pages.findIndex(page => page.id === id); const nextIndex = index + direction; @@ -300,8 +336,6 @@ export class StaticPagesEditorComponent { this.persist(pages.map((page, order) => ({ ...page, order: order + 1 }))); } - // --- Search / filter --- - updateSearchQuery(value: string): void { this.searchQuery.set(value); } @@ -314,8 +348,6 @@ export class StaticPagesEditorComponent { this.localeFilter.set(value); } - // --- Bulk selection / actions --- - isSelected(id: string): boolean { return this.selectedIds().has(id); } @@ -364,9 +396,6 @@ export class StaticPagesEditorComponent { } private persist(pages: ContentPage[]): void { - // Always writes the full page set. Callers must build `pages` from - // allPages(), never from the filtered pages() view, or an active search/ - // status/locale filter would silently delete the pages it hid. this.projectEditor.updateBootstrap(current => ({ ...current, staticPages: this.contentFacade.serializePages(pages) diff --git a/src/app/features/content-management/facade/content-management.facade.ts b/src/app/features/content-management/facade/content-management.facade.ts index e932be7..1a1ed69 100644 --- a/src/app/features/content-management/facade/content-management.facade.ts +++ b/src/app/features/content-management/facade/content-management.facade.ts @@ -2,6 +2,33 @@ import { Injectable, computed, inject } from '@angular/core'; import { BootstrapConfig } from '../../../shared/models/config'; import { ContentPageService } from '../services/content-page.service'; import { ContentPage } from '../models/content-page.model'; +import { LEGAL_PAGE_DEFINITIONS, LegalPageDefinition, matchesLegalDefinition } from '../models/legal-pages.model'; + +export interface ContentHealthLegalStatus { + def: LegalPageDefinition; + page: ContentPage | null; +} + +export type ContentRecommendationKind = 'createLegal' | 'fixSeo' | 'publish' | 'addTitles' | 'none'; + +export interface ContentRecommendation { + kind: ContentRecommendationKind; + labelKey: string; + pageId?: string; +} + +export interface ContentHealth { + total: number; + published: number; + draft: number; + legalStatus: ContentHealthLegalStatus[]; + missingRequiredLegal: LegalPageDefinition[]; + seoConfiguredCount: number; + seoHealthPercent: number; + completionPercent: number; + lastEditedAt: string | null; + recommendation: ContentRecommendation; +} @Injectable({ providedIn: 'root' }) export class ContentManagementFacade { @@ -11,6 +38,70 @@ export class ContentManagementFacade { return bootstrap ? this.service.normalizePages(bootstrap.staticPages) : []; } + hasSeoContent(page: ContentPage): boolean { + const seoBlocks = [page.seo, ...Object.values(page.translations).map(t => t.seo)].filter((seo): seo is NonNullable => !!seo); + return seoBlocks.some(seo => !!(seo.title?.trim() || seo.description?.trim())); + } + + contentHealth(bootstrap: BootstrapConfig | null): ContentHealth { + const pages = this.pages(bootstrap); + const validation = this.validatePages(bootstrap); + + const legalStatus: ContentHealthLegalStatus[] = LEGAL_PAGE_DEFINITIONS.map(def => ({ + def, + page: pages.find(page => matchesLegalDefinition(page, def)) ?? null, + })); + const missingRequiredLegal = legalStatus.filter(status => status.def.required && !status.page).map(status => status.def); + + const seoConfiguredCount = pages.filter(page => this.hasSeoContent(page)).length; + const seoHealthPercent = pages.length > 0 ? Math.round((seoConfiguredCount / pages.length) * 100) : 0; + + const legalWeight = LEGAL_PAGE_DEFINITIONS.length > 0 ? (legalStatus.filter(s => s.page).length / LEGAL_PAGE_DEFINITIONS.length) * 60 : 0; + const seoWeight = pages.length > 0 ? (seoConfiguredCount / pages.length) * 40 : 0; + const completionPercent = Math.round(legalWeight + seoWeight); + + const lastEditedAt = pages.reduce((latest, page) => { + if (!page.updatedAt) { + return latest; + } + return !latest || page.updatedAt > latest ? page.updatedAt : latest; + }, null); + + const published = pages.filter(page => page.status === 'published').length; + const draft = pages.filter(page => page.status === 'draft').length; + + let recommendation: ContentRecommendation; + const firstMissingLegal = missingRequiredLegal[0]; + const firstInvalidSeoId = validation.invalidSeo[0]; + const firstEmptyTitleId = validation.emptyTitles[0]; + const firstDraftLegal = legalStatus.find(status => status.def.required && status.page?.status === 'draft')?.page; + + if (firstMissingLegal) { + recommendation = { kind: 'createLegal', labelKey: firstMissingLegal.labelKey }; + } else if (firstEmptyTitleId) { + recommendation = { kind: 'addTitles', labelKey: 'contentManagement.recommendAddTitles', pageId: firstEmptyTitleId }; + } else if (firstInvalidSeoId) { + recommendation = { kind: 'fixSeo', labelKey: 'contentManagement.recommendFixSeo', pageId: firstInvalidSeoId }; + } else if (firstDraftLegal) { + recommendation = { kind: 'publish', labelKey: 'contentManagement.recommendPublish', pageId: firstDraftLegal.id }; + } else { + recommendation = { kind: 'none', labelKey: 'contentManagement.recommendNone' }; + } + + return { + total: pages.length, + published, + draft, + legalStatus, + missingRequiredLegal, + seoConfiguredCount, + seoHealthPercent, + completionPercent, + lastEditedAt, + recommendation, + }; + } + resolvePage(bootstrap: BootstrapConfig, keyOrSlug: string, locale: string) { return this.service.resolvePage(bootstrap, keyOrSlug, locale); } diff --git a/src/app/features/content-management/models/content-page.model.ts b/src/app/features/content-management/models/content-page.model.ts index 4f6d7e6..ff553b4 100644 --- a/src/app/features/content-management/models/content-page.model.ts +++ b/src/app/features/content-management/models/content-page.model.ts @@ -45,8 +45,12 @@ export interface ContentPage { status: ContentPageStatus; customTemplate?: string; heroImage?: string; + heroImageAlt?: string; + heroImageCaption?: string; thumbnail?: string; gallery?: string[]; + /** ISO timestamp set whenever this page is persisted; drives the CMS dashboard's "last edited" and health metrics. */ + updatedAt?: string; } export interface ContentPageBootstrapInput { @@ -73,6 +77,9 @@ export interface ContentPageBootstrapInput { status?: ContentPageStatus; customTemplate?: string; heroImage?: string; + heroImageAlt?: string; + heroImageCaption?: string; thumbnail?: string; gallery?: string[]; + updatedAt?: string; } diff --git a/src/app/features/content-management/models/legal-pages.model.ts b/src/app/features/content-management/models/legal-pages.model.ts new file mode 100644 index 0000000..e7370a9 --- /dev/null +++ b/src/app/features/content-management/models/legal-pages.model.ts @@ -0,0 +1,33 @@ +import { ContentPage } from './content-page.model'; + +export type LegalPageKey = 'about' | 'contacts' | 'privacy' | 'terms' | 'cookies' | 'delivery' | 'returns' | 'faq'; + +export interface LegalPageDefinition { + key: LegalPageKey; + labelKey: string; + whyKey: string; + required: boolean; + /** Slug/id/route fragments used to recognize an existing page as fulfilling this legal role. */ + matchers: string[]; +} + +// Order doubles as the recommended publishing order (Task 10). +export const LEGAL_PAGE_DEFINITIONS: readonly LegalPageDefinition[] = [ + { key: 'about', labelKey: 'contentManagement.legal.about.label', whyKey: 'contentManagement.legal.about.why', required: true, matchers: ['about'] }, + { key: 'contacts', labelKey: 'contentManagement.legal.contacts.label', whyKey: 'contentManagement.legal.contacts.why', required: true, matchers: ['contact'] }, + { key: 'privacy', labelKey: 'contentManagement.legal.privacy.label', whyKey: 'contentManagement.legal.privacy.why', required: true, matchers: ['privacy'] }, + { key: 'terms', labelKey: 'contentManagement.legal.terms.label', whyKey: 'contentManagement.legal.terms.why', required: true, matchers: ['terms', 'tos'] }, + { key: 'cookies', labelKey: 'contentManagement.legal.cookies.label', whyKey: 'contentManagement.legal.cookies.why', required: false, matchers: ['cookie'] }, + { key: 'delivery', labelKey: 'contentManagement.legal.delivery.label', whyKey: 'contentManagement.legal.delivery.why', required: false, matchers: ['delivery', 'shipping'] }, + { key: 'returns', labelKey: 'contentManagement.legal.returns.label', whyKey: 'contentManagement.legal.returns.why', required: false, matchers: ['return', 'refund'] }, + { key: 'faq', labelKey: 'contentManagement.legal.faq.label', whyKey: 'contentManagement.legal.faq.why', required: false, matchers: ['faq'] }, +]; + +export function matchesLegalDefinition(page: ContentPage, def: LegalPageDefinition): boolean { + const haystack = `${page.id} ${page.slug} ${page.route}`.toLowerCase(); + return def.matchers.some(matcher => haystack.includes(matcher)); +} + +export function findLegalDefinition(page: ContentPage): LegalPageDefinition | null { + return LEGAL_PAGE_DEFINITIONS.find(def => matchesLegalDefinition(page, def)) ?? null; +} diff --git a/src/app/features/content-management/services/content-page.service.ts b/src/app/features/content-management/services/content-page.service.ts index ca73f89..0e2f17e 100644 --- a/src/app/features/content-management/services/content-page.service.ts +++ b/src/app/features/content-management/services/content-page.service.ts @@ -180,8 +180,11 @@ export class ContentPageService { content: htmlMap, customTemplate: page.customTemplate, heroImage: page.heroImage, + heroImageAlt: page.heroImageAlt, + heroImageCaption: page.heroImageCaption, thumbnail: page.thumbnail, gallery: page.gallery, + updatedAt: page.updatedAt, }; return acc; }, {}); @@ -224,8 +227,11 @@ export class ContentPageService { status: page.status ?? 'published', customTemplate: page.customTemplate, heroImage: page.heroImage, + heroImageAlt: page.heroImageAlt, + heroImageCaption: page.heroImageCaption, thumbnail: page.thumbnail, gallery: page.gallery, + updatedAt: page.updatedAt, }; } diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 7558b79..c5ceb77 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -937,6 +937,55 @@ export const en: Translations = { previewMobile: 'Mobile', previewToggle: 'Preview', }, + contentManagement: { + publishedPages: 'Published pages', + draftPages: 'Draft pages', + missingLegalPages: 'Missing legal pages', + seoHealth: 'SEO health', + lastEdited: 'Last edited', + lastEditedNever: 'Never', + completionLabel: 'Completion', + recommendedNext: 'Recommended next step', + recommendAddTitles: 'Some pages are missing a title in at least one language.', + recommendFixSeo: 'A page has an SEO field that needs fixing (canonical/OG image URL or robots value).', + recommendPublish: 'A required legal page is still a draft — publish it so customers can see it.', + recommendNone: 'Nice work — your content is in good shape.', + openAction: 'Open', + backToPages: 'Back to pages', + groupContent: 'Content', + groupSeo: 'SEO', + groupSharing: 'Sharing', + groupAdvanced: 'Advanced', + groupAdvancedHtml: 'Raw HTML (advanced)', + seoExplainDesc: 'This is what shows up in search results — a clear title and description help customers find this page.', + seoTitleHelp: 'The headline shown in search results. Keep it short and descriptive.', + seoDescriptionHelp: 'A short summary shown under the title in search results.', + seoCanonicalHelp: 'The official web address for this page, used to avoid duplicate-content issues.', + seoRobotsHelp: 'Controls whether search engines are allowed to list this page.', + sharingExplain: 'This is how the page looks when shared on social media (Facebook, Twitter/X, and others).', + mediaSelect: 'Choose image', + mediaReplace: 'Replace', + mediaRemove: 'Remove', + mediaAlt: 'Alt text', + mediaCaption: 'Caption', + emptyStateGuideTitle: 'Start building your storefront pages', + emptyStateGuideBody: 'Legal pages like About, Privacy, and Terms build trust with customers. We recommend publishing About, Contacts, Privacy, and Terms first, then adding Cookies, Delivery, Returns, and FAQ.', + seoStatusGood: 'SEO good', + seoStatusPartial: 'SEO partial', + seoStatusWarning: 'SEO issue', + seoStatusMissing: 'SEO missing', + needsAttention: 'Needs attention', + legal: { + about: { label: 'About', why: 'Tells customers who you are and builds trust before they buy.' }, + contacts: { label: 'Contacts', why: 'Gives customers a way to reach you with questions or issues.' }, + privacy: { label: 'Privacy', why: 'Explains how customer data is handled — often legally required.' }, + terms: { label: 'Terms', why: 'Sets the rules for using your store — protects you and your customers.' }, + cookies: { label: 'Cookies', why: 'Discloses cookie usage, required in many regions.' }, + delivery: { label: 'Delivery', why: 'Sets clear shipping expectations and reduces support requests.' }, + returns: { label: 'Returns', why: 'Explains your refund policy and builds purchase confidence.' }, + faq: { label: 'FAQ', why: 'Answers common questions before customers have to ask.' }, + }, + }, widgets: { unavailable: 'Widget unavailable', }, diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index b0b2752..0859e46 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -937,6 +937,55 @@ export const hy: Translations = { previewMobile: 'Հեռախոս', previewToggle: 'Նախադիտում', }, + contentManagement: { + publishedPages: 'Հրապարակված էջեր', + draftPages: 'Սևագրեր', + missingLegalPages: 'Բացակայող իրավական էջեր', + seoHealth: 'SEO վիճակ', + lastEdited: 'Վերջին փոփոխություն', + lastEditedNever: 'Երբեք', + completionLabel: 'Ավարտվածություն', + recommendedNext: 'Հաջորդ առաջարկվող քայլը', + recommendAddTitles: 'Որոշ էջեր չունեն վերնագիր առնվազն մեկ լեզվով։', + recommendFixSeo: 'Մի էջ ունի ուղղման կարիք ունեցող SEO դաշտ (canonical/OG պատկեր կամ robots)։', + recommendPublish: 'Պարտադիր իրավական էջը դեռ սևագիր է․ հրապարակեք այն։', + recommendNone: 'Հիանալի է․ բովանդակությունը լավ վիճակում է։', + openAction: 'Բացել', + backToPages: 'Վերադառնալ էջերին', + groupContent: 'Բովանդակություն', + groupSeo: 'SEO', + groupSharing: 'Տարածում', + groupAdvanced: 'Լրացուցիչ', + groupAdvancedHtml: 'Հում HTML (լրացուցիչ)', + seoExplainDesc: 'Սա այն է, ինչ երևում է որոնման արդյունքներում․ հստակ վերնագիրն ու նկարագրությունը օգնում են հաճախորդներին գտնել այս էջը։', + seoTitleHelp: 'Վերնագիրը, որ երևում է որոնման արդյունքներում։ Պահեք կարճ և հստակ։', + seoDescriptionHelp: 'Կարճ նկարագրություն՝ վերնագրի տակ, որոնման արդյունքներում։', + seoCanonicalHelp: 'Էջի պաշտոնական հասցեն՝ կրկնվող բովանդակության խնդիրներից խուսափելու համար։', + seoRobotsHelp: 'Կարգավորում է՝ արդյոք որոնողական համակարգերին թույլատրվում է ցուցադրել այս էջը։', + sharingExplain: 'Այսպես է էջը երևում սոցիալական ցանցերում կիսվելիս (Facebook, Twitter/X և այլն)։', + mediaSelect: 'Ընտրել պատկեր', + mediaReplace: 'Փոխարինել', + mediaRemove: 'Հեռացնել', + mediaAlt: 'Alt տեքստ', + mediaCaption: 'Ենթագիր', + emptyStateGuideTitle: 'Սկսեք ստեղծել ձեր խանութի էջերը', + emptyStateGuideBody: 'Իրավական էջերը՝ ինչպիսիք են Մեր մասին, Գաղտնիություն և Պայմաններ, մեծացնում են հաճախորդների վստահությունը։ Խորհուրդ ենք տալիս նախ հրապարակել Մեր մասին, Կապ, Գաղտնիություն և Պայմաններ, ապա ավելացնել Cookies, Առաքում, Վերադարձներ և ՀՏՀ։', + seoStatusGood: 'SEO լավ է', + seoStatusPartial: 'SEO մասնակի', + seoStatusWarning: 'SEO խնդիր', + seoStatusMissing: 'SEO բացակայում է', + needsAttention: 'Ուշադրություն է պահանջում', + legal: { + about: { label: 'Մեր մասին', why: 'Ասում է հաճախորդներին, թե ով եք դուք, և վստահություն է ստեղծում գնումից առաջ։' }, + contacts: { label: 'Կապ', why: 'Հաճախորդներին հնարավորություն է տալիս կապվել հարցերով։' }, + privacy: { label: 'Գաղտնիություն', why: 'Բացատրում է, թե ինչպես են մշակվում տվյալները․ հաճախ պահանջվում է օրենքով։' }, + terms: { label: 'Պայմաններ', why: 'Սահմանում է խանութից օգտվելու կանոնները՝ պաշտպանելով և՛ ձեզ, և՛ հաճախորդներին։' }, + cookies: { label: 'Cookies', why: 'Բացահայտում է cookie-ների օգտագործումը՝ պահանջվում է շատ երկրներում։' }, + delivery: { label: 'Առաքում', why: 'Սահմանում է հստակ առաքման ակնկալիքներ և նվազեցնում աջակցության հարցումները։' }, + returns: { label: 'Վերադարձներ', why: 'Բացատրում է վերադարձի քաղաքականությունը և մեծացնում գնման վստահությունը։' }, + faq: { label: 'ՀՏՀ', why: 'Պատասխանում է հաճախակի հարցերին նախապես։' }, + }, + }, widgets: { unavailable: 'Վիջեթը հասանելի չէ', }, diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 72cef7b..77df43a 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -937,6 +937,55 @@ export const ru: Translations = { previewMobile: 'Телефон', previewToggle: 'Превью', }, + contentManagement: { + publishedPages: 'Опубликованные страницы', + draftPages: 'Черновики', + missingLegalPages: 'Отсутствующие юридические страницы', + seoHealth: 'SEO-здоровье', + lastEdited: 'Последнее изменение', + lastEditedNever: 'Никогда', + completionLabel: 'Готовность', + recommendedNext: 'Рекомендуемый следующий шаг', + recommendAddTitles: 'У некоторых страниц отсутствует заголовок хотя бы на одном языке.', + recommendFixSeo: 'На одной из страниц есть SEO-поле, которое нужно исправить (canonical/OG-изображение или robots).', + recommendPublish: 'Обязательная юридическая страница всё ещё черновик — опубликуйте её.', + recommendNone: 'Отлично — контент в хорошем состоянии.', + openAction: 'Открыть', + backToPages: 'Назад к страницам', + groupContent: 'Контент', + groupSeo: 'SEO', + groupSharing: 'Публикация в соцсетях', + groupAdvanced: 'Дополнительно', + groupAdvancedHtml: 'Исходный HTML (дополнительно)', + seoExplainDesc: 'Это то, что видно в результатах поиска — понятный заголовок и описание помогают клиентам найти страницу.', + seoTitleHelp: 'Заголовок, отображаемый в результатах поиска. Делайте его коротким и понятным.', + seoDescriptionHelp: 'Краткое описание под заголовком в результатах поиска.', + seoCanonicalHelp: 'Официальный адрес страницы — помогает избежать дублирования контента.', + seoRobotsHelp: 'Определяет, могут ли поисковые системы показывать эту страницу.', + sharingExplain: 'Так страница выглядит при публикации в соцсетях (Facebook, Twitter/X и другие).', + mediaSelect: 'Выбрать изображение', + mediaReplace: 'Заменить', + mediaRemove: 'Удалить', + mediaAlt: 'Альтернативный текст', + mediaCaption: 'Подпись', + emptyStateGuideTitle: 'Начните создавать страницы магазина', + emptyStateGuideBody: 'Юридические страницы, такие как О нас, Конфиденциальность и Условия, повышают доверие клиентов. Рекомендуем сначала опубликовать О нас, Контакты, Конфиденциальность и Условия, затем добавить Cookies, Доставку, Возвраты и FAQ.', + seoStatusGood: 'SEO в порядке', + seoStatusPartial: 'SEO частично', + seoStatusWarning: 'Проблема SEO', + seoStatusMissing: 'SEO отсутствует', + needsAttention: 'Требует внимания', + legal: { + about: { label: 'О нас', why: 'Рассказывает клиентам, кто вы, и повышает доверие перед покупкой.' }, + contacts: { label: 'Контакты', why: 'Даёт клиентам способ связаться с вами по вопросам.' }, + privacy: { label: 'Конфиденциальность', why: 'Объясняет, как обрабатываются данные клиентов — часто обязательно по закону.' }, + terms: { label: 'Условия', why: 'Устанавливает правила использования магазина — защищает вас и клиентов.' }, + cookies: { label: 'Cookies', why: 'Раскрывает использование cookie — требуется во многих регионах.' }, + delivery: { label: 'Доставка', why: 'Задаёт понятные ожидания по доставке и снижает число обращений в поддержку.' }, + returns: { label: 'Возвраты', why: 'Объясняет политику возврата и повышает уверенность в покупке.' }, + faq: { label: 'Вопросы и ответы', why: 'Отвечает на частые вопросы заранее.' }, + }, + }, widgets: { unavailable: 'Виджет недоступен', }, diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 88735d3..526f221 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -935,6 +935,55 @@ export interface Translations { previewMobile: string; previewToggle: string; }; + contentManagement: { + publishedPages: string; + draftPages: string; + missingLegalPages: string; + seoHealth: string; + lastEdited: string; + lastEditedNever: string; + completionLabel: string; + recommendedNext: string; + recommendAddTitles: string; + recommendFixSeo: string; + recommendPublish: string; + recommendNone: string; + openAction: string; + backToPages: string; + groupContent: string; + groupSeo: string; + groupSharing: string; + groupAdvanced: string; + groupAdvancedHtml: string; + seoExplainDesc: string; + seoTitleHelp: string; + seoDescriptionHelp: string; + seoCanonicalHelp: string; + seoRobotsHelp: string; + sharingExplain: string; + mediaSelect: string; + mediaReplace: string; + mediaRemove: string; + mediaAlt: string; + mediaCaption: string; + emptyStateGuideTitle: string; + emptyStateGuideBody: string; + seoStatusGood: string; + seoStatusPartial: string; + seoStatusWarning: string; + seoStatusMissing: string; + needsAttention: string; + legal: { + about: { label: string; why: string }; + contacts: { label: string; why: string }; + privacy: { label: string; why: string }; + terms: { label: string; why: string }; + cookies: { label: string; why: string }; + delivery: { label: string; why: string }; + returns: { label: string; why: string }; + faq: { label: string; why: string }; + }; + }; widgets: { unavailable: string; }; diff --git a/src/app/shared/models/config/static-page.model.ts b/src/app/shared/models/config/static-page.model.ts index 471e10f..3404f98 100644 --- a/src/app/shared/models/config/static-page.model.ts +++ b/src/app/shared/models/config/static-page.model.ts @@ -60,8 +60,12 @@ export interface StaticPageConfig { status?: 'draft' | 'published'; customTemplate?: string; heroImage?: string; + heroImageAlt?: string; + heroImageCaption?: string; thumbnail?: string; gallery?: string[]; + /** ISO timestamp set whenever the page is saved; drives the CMS content dashboard's last-edited/health metrics. */ + updatedAt?: string; } export interface LegacyStaticPageConfig {