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.
This commit is contained in:
sdarbinyan
2026-07-18 11:02:26 +04:00
parent 04b36bab9b
commit b31c75214d
24 changed files with 1350 additions and 263 deletions

View File

@@ -0,0 +1,31 @@
<section class="content-dashboard">
<div class="content-dashboard__metrics">
<app-dashboard-metric labelKey="contentManagement.publishedPages" [value]="health().published.toString()" />
<app-dashboard-metric labelKey="contentManagement.draftPages" [value]="health().draft.toString()" />
<app-dashboard-metric labelKey="contentManagement.missingLegalPages" [value]="health().missingRequiredLegal.length.toString()" />
<app-dashboard-metric labelKey="contentManagement.seoHealth" [value]="seoHealthValue()" />
<app-dashboard-metric labelKey="contentManagement.lastEdited" [value]="lastEditedValue()" />
</div>
<div class="content-dashboard__row">
<app-card padding="md" class="content-dashboard__health">
<app-content-health-widget
titleKey="contentManagement.completionLabel"
[items]="legalItems()"
[completionPercent]="health().completionPercent"
/>
</app-card>
<app-card padding="md" class="content-dashboard__recommend">
<h4>{{ 'contentManagement.recommendedNext' | translate }}</h4>
<p>{{ health().recommendation.labelKey | translate }}</p>
<div class="content-dashboard__recommend-actions">
@if (health().recommendation.pageId) {
<app-button variant="primary" size="sm" (click)="goRecommended()">{{ 'contentManagement.openAction' | translate }}</app-button>
} @else if (health().recommendation.kind === 'createLegal') {
<app-button variant="primary" size="sm" (click)="createPage.emit()">{{ 'builder.createPage' | translate }}</app-button>
}
</div>
</app-card>
</div>
</section>

View File

@@ -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;
}

View File

@@ -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<ContentHealth>();
@Output() createPage = new EventEmitter<void>();
@Output() goToPage = new EventEmitter<string>();
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<ContentHealthItem[]>(() =>
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);
}
}
}

View File

@@ -0,0 +1,17 @@
<div class="health-widget">
<div class="health-widget__head">
<h4>{{ titleKey() | translate }}</h4>
<span class="health-widget__percent">{{ completionPercent() }}%</span>
</div>
<div class="health-widget__bar" role="progressbar" [attr.aria-valuenow]="completionPercent()" aria-valuemin="0" aria-valuemax="100">
<div class="health-widget__bar-fill" [style.width.%]="completionPercent()"></div>
</div>
<ul class="health-widget__list">
@for (item of items(); track item.labelKey) {
<li [class.health-widget__list-item--done]="item.done">
<span class="health-widget__icon" aria-hidden="true">{{ item.done ? '✓' : '○' }}</span>
<span>{{ item.labelKey | translate }}</span>
</li>
}
</ul>
</div>

View File

@@ -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);
}
}

View File

@@ -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<string>();
readonly items = input.required<ContentHealthItem[]>();
readonly completionPercent = input.required<number>();
}

View File

@@ -0,0 +1,36 @@
<app-card padding="md" [interactive]="true" class="page-card-tile">
<button type="button" class="page-card-tile__surface" (click)="open.emit()" [attr.aria-label]="page().title || page().slug">
<div class="page-card-tile__icon" aria-hidden="true">{{ page().icon || '📄' }}</div>
<div class="page-card-tile__body">
<div class="page-card-tile__title-row">
<h3 class="page-card-tile__title">{{ page().title || page().slug }}</h3>
@if (legalLabelKey(); as legalKey) {
<app-badge variant="primary">{{ legalKey | translate }}</app-badge>
}
</div>
<p class="page-card-tile__slug">/{{ page().route || page().slug }}</p>
<div class="page-card-tile__meta">
<app-badge [variant]="page().status === 'published' ? 'success' : 'neutral'">
{{ (page().status === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }}
</app-badge>
@if (!page().enabled) {
<app-badge variant="neutral">{{ 'staticPages.disabledBadge' | translate }}</app-badge>
}
<app-badge [variant]="seoStatusVariant()">{{ seoStatusLabelKey() | translate }}</app-badge>
@if (hasIssue()) {
<app-badge variant="danger">{{ 'contentManagement.needsAttention' | translate }}</app-badge>
}
@if (visibilitySummary()) {
<span class="page-card-tile__visibility">{{ visibilitySummary() }}</span>
}
</div>
<p class="page-card-tile__updated">{{ 'contentManagement.lastEdited' | translate }}: {{ updatedAtLabel() || ('contentManagement.lastEditedNever' | translate) }}</p>
</div>
</button>
<div class="page-card-tile__actions">
<app-button variant="secondary" size="sm" (click)="open.emit()">{{ 'contentManagement.openAction' | translate }}</app-button>
<app-button variant="ghost" size="sm" (click)="preview.emit()">{{ 'staticPages.previewToggle' | translate }}</app-button>
<app-button variant="ghost" size="sm" (click)="duplicate.emit()">{{ 'staticPages.duplicatePage' | translate }}</app-button>
<app-button variant="ghost" size="sm" (click)="remove.emit()">{{ 'builder.deletePage' | translate }}</app-button>
</div>
</app-card>

View File

@@ -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;
}

View File

@@ -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<ContentPage>();
readonly legalLabelKey = input<string | null>(null);
readonly seoStatus = input.required<PageSeoStatus>();
readonly hasIssue = input(false);
readonly updatedAtLabel = input<string | null>(null);
@Output() open = new EventEmitter<void>();
@Output() duplicate = new EventEmitter<void>();
@Output() preview = new EventEmitter<void>();
@Output() remove = new EventEmitter<void>();
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';
}
});
}

View File

@@ -0,0 +1,215 @@
<div class="page-editor">
<div class="page-editor__header">
<app-button variant="ghost" size="sm" (click)="close.emit()">&larr; {{ 'contentManagement.backToPages' | translate }}</app-button>
<h2 class="page-editor__title">{{ previewTitle() || page().slug }}</h2>
<div class="page-editor__header-actions">
<app-button variant="secondary" size="sm" (click)="togglePreview()">{{ 'staticPages.previewToggle' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="duplicatePage.emit()">{{ 'staticPages.duplicatePage' | translate }}</app-button>
@if (page().status === 'published') {
<app-button variant="secondary" size="sm" (click)="updatePage.emit({ status: 'draft' })">{{ 'staticPages.unpublishPageAction' | translate }}</app-button>
} @else {
<app-button variant="primary" size="sm" (click)="updatePage.emit({ status: 'published' })">{{ 'staticPages.publishPageAction' | translate }}</app-button>
}
<app-button variant="danger" size="sm" (click)="deletePage.emit()">{{ 'builder.deletePage' | translate }}</app-button>
</div>
</div>
@if (previewOpen()) {
<app-static-page-preview [html]="previewHtml()" [title]="previewTitle()" />
}
<div class="page-editor__tabs" role="tablist">
@for (group of groups; track group.id) {
<button
type="button"
role="tab"
[id]="'page-editor-tab-' + group.id"
[attr.aria-selected]="activeGroup() === group.id"
[class.page-editor__tab--active]="activeGroup() === group.id"
class="page-editor__tab"
(click)="setGroup(group.id)"
>
{{ group.labelKey | translate }}
@if (group.id === 'seo' && hasInvalidSeo()) {
<app-badge variant="danger">!</app-badge>
}
@if (group.id === 'content' && hasEmptyTitle()) {
<app-badge variant="danger">!</app-badge>
}
</button>
}
</div>
<div class="page-editor__panel" role="tabpanel" [attr.aria-labelledby]="'page-editor-tab-' + activeGroup()">
@if (activeGroup() === 'content') {
<div class="page-editor__stack">
@for (locale of locales(); track locale) {
<app-form-field [label]="(('builder.translationTitle' | translate) + ' ' + locale)" [error]="hasEmptyTitle() ? ('staticPages.emptyTitle' | translate) : null">
<app-input [ngModel]="page().translations[locale]?.title || ''" (ngModelChange)="updateTranslation.emit({ locale, field: 'title', value: $event })" />
</app-form-field>
}
<div class="page-editor__media-grid">
<div class="media-block">
<span class="media-block__label">{{ 'staticPages.heroImageLabel' | translate }}</span>
@if (page().heroImage) {
<img class="media-block__preview" [src]="page().heroImage" [alt]="page().heroImageAlt || ''" />
}
<div class="media-block__actions">
<app-button variant="secondary" size="sm" (click)="openMediaPicker.emit('heroImage')">
{{ (page().heroImage ? 'contentManagement.mediaReplace' : 'contentManagement.mediaSelect') | translate }}
</app-button>
@if (page().heroImage) {
<app-button variant="ghost" size="sm" (click)="clearMedia.emit('heroImage')">{{ 'contentManagement.mediaRemove' | translate }}</app-button>
}
</div>
<app-form-field [label]="'contentManagement.mediaAlt' | translate">
<app-input [ngModel]="page().heroImageAlt || ''" (ngModelChange)="updatePage.emit({ heroImageAlt: $event })" />
</app-form-field>
<app-form-field [label]="'contentManagement.mediaCaption' | translate">
<app-input [ngModel]="page().heroImageCaption || ''" (ngModelChange)="updatePage.emit({ heroImageCaption: $event })" />
</app-form-field>
</div>
<div class="media-block">
<span class="media-block__label">{{ 'staticPages.thumbnailLabel' | translate }}</span>
@if (page().thumbnail) {
<img class="media-block__preview" [src]="page().thumbnail" alt="" />
}
<div class="media-block__actions">
<app-button variant="secondary" size="sm" (click)="openMediaPicker.emit('thumbnail')">
{{ (page().thumbnail ? 'contentManagement.mediaReplace' : 'contentManagement.mediaSelect') | translate }}
</app-button>
@if (page().thumbnail) {
<app-button variant="ghost" size="sm" (click)="clearMedia.emit('thumbnail')">{{ 'contentManagement.mediaRemove' | translate }}</app-button>
}
</div>
</div>
</div>
</div>
}
@if (activeGroup() === 'seo') {
<div class="page-editor__stack">
<p class="page-editor__explain">{{ 'contentManagement.seoExplainDesc' | translate }}</p>
<app-form-field [label]="'builder.seoTitle' | translate" [hint]="'contentManagement.seoTitleHelp' | translate">
<app-input [ngModel]="page().seo?.title || ''" (ngModelChange)="updateSeo.emit({ title: $event })" />
</app-form-field>
<app-form-field [label]="'builder.seoDescription' | translate" [hint]="'contentManagement.seoDescriptionHelp' | translate">
<app-input [ngModel]="page().seo?.description || ''" (ngModelChange)="updateSeo.emit({ description: $event })" />
</app-form-field>
<app-form-field [label]="'builder.seoCanonical' | translate" [hint]="'contentManagement.seoCanonicalHelp' | translate">
<app-input [ngModel]="page().seo?.canonical || ''" (ngModelChange)="updateSeo.emit({ canonical: $event })" />
</app-form-field>
<app-form-field
[label]="'staticPages.seoRobotsLabel' | translate"
[hint]="'contentManagement.seoRobotsHelp' | translate"
[error]="hasInvalidSeo() ? ('staticPages.invalidSeo' | translate) : null"
>
<app-input [ngModel]="page().seo?.robots || ''" (ngModelChange)="updateSeo.emit({ robots: $event })" placeholder="index,follow" />
</app-form-field>
</div>
}
@if (activeGroup() === 'sharing') {
<div class="page-editor__stack">
<p class="page-editor__explain">{{ 'contentManagement.sharingExplain' | translate }}</p>
<app-form-field [label]="'builder.seoOgTitle' | translate">
<app-input [ngModel]="page().seo?.ogTitle || ''" (ngModelChange)="updateSeo.emit({ ogTitle: $event })" />
</app-form-field>
<app-form-field [label]="'builder.seoOgDescription' | translate">
<app-input [ngModel]="page().seo?.ogDescription || ''" (ngModelChange)="updateSeo.emit({ ogDescription: $event })" />
</app-form-field>
<app-form-field [label]="'builder.seoOgImage' | translate">
<app-input [ngModel]="page().seo?.ogImage || ''" (ngModelChange)="updateSeo.emit({ ogImage: $event })" />
</app-form-field>
</div>
}
@if (activeGroup() === 'advanced') {
<div class="page-editor__stack">
<div class="editor-grid three">
<app-form-field [label]="'builder.pageId' | translate">
<app-input [ngModel]="page().id" (ngModelChange)="updatePage.emit({ id: $event })" />
</app-form-field>
<app-form-field [label]="'builder.slug' | translate" [error]="hasDuplicateSlug() ? ('staticPages.duplicateSlug' | translate) : null">
<app-input [ngModel]="page().slug" (ngModelChange)="updatePage.emit({ slug: $event })" />
</app-form-field>
<app-form-field [label]="'staticPages.routeLabel' | translate" [error]="hasDuplicateRoute() ? ('staticPages.duplicateRoute' | translate) : null">
<app-input [ngModel]="page().route" (ngModelChange)="updatePage.emit({ route: $event })" />
</app-form-field>
<app-form-field [label]="'builder.iconLabel' | translate">
<app-input [ngModel]="page().icon || ''" (ngModelChange)="updatePage.emit({ icon: $event })" />
</app-form-field>
<app-form-field [label]="'builder.footerGroup' | translate">
<app-input [ngModel]="page().footerGroup || ''" (ngModelChange)="updatePage.emit({ footerGroup: $event })" />
</app-form-field>
<app-form-field [label]="'builder.orderLabel' | translate">
<app-input type="number" [ngModel]="page().order" (ngModelChange)="updatePage.emit({ order: +$event })" />
</app-form-field>
<app-form-field [label]="'staticPages.customTemplateLabel' | translate">
<app-input [ngModel]="page().customTemplate || ''" (ngModelChange)="updatePage.emit({ customTemplate: $event })" />
</app-form-field>
<app-form-field [label]="'staticPages.galleryLabel' | translate">
<app-input [ngModel]="(page().gallery || []).join(', ')" (ngModelChange)="updateGallery.emit($event)" />
</app-form-field>
</div>
<div class="toggle-grid">
<label class="toggle-row">
<app-toggle [ngModel]="page().enabled" (ngModelChange)="updatePage.emit({ enabled: $event })" [ariaLabel]="'staticPages.enabledLabel' | translate" />
<span>{{ 'staticPages.enabledLabel' | translate }}</span>
</label>
<label class="toggle-row">
<app-toggle [ngModel]="page().showInFooter" (ngModelChange)="updatePage.emit({ showInFooter: $event })" [ariaLabel]="'builder.showInFooter' | translate" />
<span>{{ 'builder.showInFooter' | translate }}</span>
</label>
<label class="toggle-row">
<app-toggle [ngModel]="page().showInHeader" (ngModelChange)="updatePage.emit({ showInHeader: $event })" [ariaLabel]="'builder.showInHeader' | translate" />
<span>{{ 'builder.showInHeader' | translate }}</span>
</label>
<label class="toggle-row">
<app-toggle [ngModel]="page().showInSitemap" (ngModelChange)="updatePage.emit({ showInSitemap: $event })" [ariaLabel]="'builder.showInSitemap' | translate" />
<span>{{ 'builder.showInSitemap' | translate }}</span>
</label>
<label class="toggle-row">
<app-toggle [ngModel]="page().requiresAuthentication" (ngModelChange)="updatePage.emit({ requiresAuthentication: $event })" [ariaLabel]="'builder.requiresAuthentication' | translate" />
<span>{{ 'builder.requiresAuthentication' | translate }}</span>
</label>
<label class="toggle-row">
<app-toggle [ngModel]="page().visibility.desktop !== false" (ngModelChange)="updatePage.emit({ visibility: { ...page().visibility, desktop: $event } })" ariaLabel="Desktop" />
<span>{{ 'staticPages.previewDesktop' | translate }}</span>
</label>
<label class="toggle-row">
<app-toggle [ngModel]="page().visibility.tablet !== false" (ngModelChange)="updatePage.emit({ visibility: { ...page().visibility, tablet: $event } })" ariaLabel="Tablet" />
<span>{{ 'staticPages.previewTablet' | translate }}</span>
</label>
<label class="toggle-row">
<app-toggle [ngModel]="page().visibility.mobile !== false" (ngModelChange)="updatePage.emit({ visibility: { ...page().visibility, mobile: $event } })" ariaLabel="Mobile" />
<span>{{ 'staticPages.previewMobile' | translate }}</span>
</label>
</div>
<app-form-field [label]="'builder.seoKeywords' | translate">
<app-input [ngModel]="page().seo?.keywords || ''" (ngModelChange)="updateSeo.emit({ keywords: $event })" />
</app-form-field>
<details class="page-editor__html-disclosure">
<summary>{{ 'contentManagement.groupAdvancedHtml' | translate }}</summary>
@for (locale of locales(); track locale) {
<label class="full page-editor__html-block">
<span>{{ 'builder.htmlPreview' | translate }} {{ locale }}</span>
<app-marketplace-html-editor
[html]="page().translations[locale]?.html || ''"
(htmlChange)="updateTranslation.emit({ locale, field: 'html', value: $event })"
/>
@if (hasInvalidHtml()) {
<p class="page-editor__error">{{ 'staticPages.invalidHtml' | translate }}</p>
}
</label>
}
</details>
</div>
}
</div>
</div>

View File

@@ -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;
}

View File

@@ -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<ContentPage>();
readonly locales = input.required<string[]>();
readonly defaultLocale = input<string>('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<void>();
@Output() updatePage = new EventEmitter<Partial<ContentPage>>();
@Output() updateTranslation = new EventEmitter<{ locale: string; field: 'title' | 'html'; value: string }>();
@Output() updateSeo = new EventEmitter<Partial<ContentPageSeoConfig>>();
@Output() updateGallery = new EventEmitter<string>();
@Output() openMediaPicker = new EventEmitter<PageMediaField>();
@Output() clearMedia = new EventEmitter<PageMediaField>();
@Output() duplicatePage = new EventEmitter<void>();
@Output() deletePage = new EventEmitter<void>();
readonly activeGroup = signal<PageEditorGroup>('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;
}
}

View File

@@ -1,198 +1,97 @@
<section class="editor-section-card"> <section class="editor-section-card">
<div class="editor-actions">
<h2>{{ 'builder.staticPages' | translate }}</h2>
<app-button variant="primary" (click)="createPage()">{{ 'builder.createPage' | translate }}</app-button>
</div>
@if (fieldError('staticPages'); as msg) { @if (fieldError('staticPages'); as msg) {
<p class="editor-error">{{ msg }}</p> <p class="editor-error">{{ msg }}</p>
} }
<div class="editor-grid three static-pages-toolbar"> @if (editingPage(); as page) {
<app-form-field [label]="'staticPages.searchPlaceholder' | translate"> <app-page-editor
<app-input [ngModel]="searchQuery()" (ngModelChange)="updateSearchQuery($event)" [placeholder]="'staticPages.searchPlaceholder' | translate" /> [page]="page"
</app-form-field> [locales]="locales()"
<app-form-field [label]="'staticPages.statusLabel' | translate"> [defaultLocale]="defaultLocale()"
<app-select [options]="statusFilterOptions()" [ngModel]="statusFilter()" (ngModelChange)="updateStatusFilter($event)" /> [hasDuplicateSlug]="hasDuplicateSlug(page)"
</app-form-field> [hasDuplicateRoute]="hasDuplicateRoute(page)"
<app-form-field [label]="'builder.languagesTab' | translate"> [hasEmptyTitle]="hasEmptyTitle(page)"
<app-select [options]="localeFilterOptions()" [ngModel]="localeFilter()" (ngModelChange)="updateLocaleFilter($event)" /> [hasInvalidHtml]="hasInvalidHtml(page)"
</app-form-field> [hasInvalidSeo]="hasInvalidSeo(page)"
</div> (close)="closeEditor()"
(updatePage)="updatePage(page.id, $event)"
(updateTranslation)="updateTranslation(page.id, $event.locale, $event.field, $event.value)"
(updateSeo)="updateSeo(page.id, $event)"
(updateGallery)="updateGallery(page.id, $event)"
(openMediaPicker)="openMediaPicker(page.id, $event)"
(clearMedia)="clearMedia(page.id, $event)"
(duplicatePage)="duplicatePage(page.id)"
(deletePage)="deletePage(page.id)"
/>
} @else {
<div class="editor-actions">
<h2>{{ 'builder.staticPages' | translate }}</h2>
<app-button variant="primary" (click)="createPage()">{{ 'builder.createPage' | translate }}</app-button>
</div>
@if (pages().length > 0) { <app-content-dashboard [health]="contentHealth()" (createPage)="createPage()" (goToPage)="openEditor($event)" />
<div class="editor-actions static-pages-bulk-bar">
<label class="toggle-row"> <div class="editor-grid three static-pages-toolbar">
<app-toggle [ngModel]="allVisibleSelected()" (ngModelChange)="toggleSelectAllVisible($event)" [ariaLabel]="'staticPages.selectAll' | translate" /> <app-form-field [label]="'staticPages.searchPlaceholder' | translate">
<span>{{ 'staticPages.selectAll' | translate }}</span> <app-input [ngModel]="searchQuery()" (ngModelChange)="updateSearchQuery($event)" [placeholder]="'staticPages.searchPlaceholder' | translate" />
</label> </app-form-field>
@if (selectedCount() > 0) { <app-form-field [label]="'staticPages.statusLabel' | translate">
<span>{{ selectedCount() }} {{ 'staticPages.selectedCount' | translate }}</span> <app-select [options]="statusFilterOptions()" [ngModel]="statusFilter()" (ngModelChange)="updateStatusFilter($event)" />
<app-button variant="secondary" size="sm" (click)="bulkSetEnabled(true)">{{ 'staticPages.bulkEnable' | translate }}</app-button> </app-form-field>
<app-button variant="secondary" size="sm" (click)="bulkSetEnabled(false)">{{ 'staticPages.bulkDisable' | translate }}</app-button> <app-form-field [label]="'builder.languagesTab' | translate">
<app-button variant="secondary" size="sm" (click)="bulkSetStatus('published')">{{ 'staticPages.bulkPublishAction' | translate }}</app-button> <app-select [options]="localeFilterOptions()" [ngModel]="localeFilter()" (ngModelChange)="updateLocaleFilter($event)" />
<app-button variant="secondary" size="sm" (click)="bulkSetStatus('draft')">{{ 'staticPages.bulkUnpublish' | translate }}</app-button> </app-form-field>
<app-button variant="danger" size="sm" (click)="bulkDelete()">{{ 'staticPages.bulkDelete' | translate }}</app-button> </div>
@if (pages().length > 0) {
<div class="editor-actions static-pages-bulk-bar">
<label class="toggle-row">
<app-toggle [ngModel]="allVisibleSelected()" (ngModelChange)="toggleSelectAllVisible($event)" [ariaLabel]="'staticPages.selectAll' | translate" />
<span>{{ 'staticPages.selectAll' | translate }}</span>
</label>
@if (selectedCount() > 0) {
<span>{{ selectedCount() }} {{ 'staticPages.selectedCount' | translate }}</span>
<app-button variant="secondary" size="sm" (click)="bulkSetEnabled(true)">{{ 'staticPages.bulkEnable' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="bulkSetEnabled(false)">{{ 'staticPages.bulkDisable' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="bulkSetStatus('published')">{{ 'staticPages.bulkPublishAction' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="bulkSetStatus('draft')">{{ 'staticPages.bulkUnpublish' | translate }}</app-button>
<app-button variant="danger" size="sm" (click)="bulkDelete()">{{ 'staticPages.bulkDelete' | translate }}</app-button>
}
</div>
}
@if (pages().length === 0) {
<app-empty-state [title]="'contentManagement.emptyStateGuideTitle' | translate" [description]="'contentManagement.emptyStateGuideBody' | translate">
<span slot="actions">
<app-button variant="primary" (click)="createPage()">{{ 'builder.createPage' | translate }}</app-button>
</span>
</app-empty-state>
}
<div class="page-card-grid">
@for (page of pages(); track page.id) {
<div class="page-card-grid__item">
<label class="page-card-grid__select">
<app-toggle [ngModel]="isSelected(page.id)" (ngModelChange)="toggleSelect(page.id, $event)" [ariaLabel]="page.id" size="sm" />
</label>
<app-page-card
[page]="page"
[legalLabelKey]="legalLabelKey(page)"
[seoStatus]="seoStatus(page)"
[hasIssue]="hasAnyIssue(page)"
[updatedAtLabel]="updatedAtLabel(page)"
(open)="openEditor(page.id)"
(duplicate)="duplicatePage(page.id)"
(preview)="togglePreview(page.id)"
(remove)="deletePage(page.id)"
/>
@if (previewOpenPageId() === page.id) {
<app-static-page-preview [html]="previewHtml(page)" [title]="previewTitle(page)" />
}
</div>
} }
</div> </div>
} }
@if (pages().length === 0) {
<app-empty-state [title]="'builder.staticPages' | translate" [description]="'builder.createPage' | translate">
<span slot="actions">
<app-button variant="primary" (click)="createPage()">{{ 'builder.createPage' | translate }}</app-button>
</span>
</app-empty-state>
}
<div class="stack-list">
@for (page of pages(); track page.id) {
<app-card padding="md">
<div class="page-card">
<div class="page-card__header">
<label class="toggle-row page-card__select">
<app-toggle [ngModel]="isSelected(page.id)" (ngModelChange)="toggleSelect(page.id, $event)" [ariaLabel]="page.id" />
</label>
<h3 class="page-card__title">
{{ page.id }}
<app-badge [variant]="page.status === 'published' ? 'success' : 'neutral'">{{ (page.status === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }}</app-badge>
@if (!page.enabled) {
<app-badge variant="neutral">{{ 'staticPages.disabledBadge' | translate }}</app-badge>
}
@if (isModified(page)) {
<app-badge variant="info">{{ 'builder.unsavedChanges' | translate }}</app-badge>
}
@if (hasDuplicateSlug(page)) {
<app-badge variant="danger">{{ 'staticPages.duplicateSlug' | translate }}</app-badge>
}
@if (hasDuplicateRoute(page)) {
<app-badge variant="danger">{{ 'staticPages.duplicateRoute' | translate }}</app-badge>
}
@if (hasEmptyTitle(page)) {
<app-badge variant="danger">{{ 'staticPages.emptyTitle' | translate }}</app-badge>
}
@if (hasInvalidHtml(page)) {
<app-badge variant="danger">{{ 'staticPages.invalidHtml' | translate }}</app-badge>
}
@if (hasInvalidSeo(page)) {
<app-badge variant="warning">{{ 'staticPages.invalidSeo' | translate }}</app-badge>
}
</h3>
<div class="editor-actions">
<app-button variant="ghost" size="sm" (click)="move(page.id, -1)">&uarr;</app-button>
<app-button variant="ghost" size="sm" (click)="move(page.id, 1)">&darr;</app-button>
<app-button variant="secondary" size="sm" (click)="duplicatePage(page.id)">{{ 'staticPages.duplicatePage' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="togglePreview(page.id)">{{ 'staticPages.previewToggle' | translate }}</app-button>
@if (page.status === 'published') {
<app-button variant="secondary" size="sm" (click)="updatePage(page.id, { status: 'draft' })">{{ 'staticPages.unpublishPageAction' | translate }}</app-button>
} @else {
<app-button variant="primary" size="sm" (click)="updatePage(page.id, { status: 'published' })">{{ 'staticPages.publishPageAction' | translate }}</app-button>
}
<app-button variant="danger" size="sm" (click)="deletePage(page.id)">{{ 'builder.deletePage' | translate }}</app-button>
</div>
</div>
@if (previewOpenPageId() === page.id) {
<app-static-page-preview [html]="previewHtml(page)" [title]="previewTitle(page)" />
}
<div class="editor-grid three">
<app-form-field [label]="'builder.pageId' | translate" [error]="hasEmptyTitle(page) ? ('staticPages.emptyTitle' | translate) : null">
<app-input [ngModel]="page.id" (ngModelChange)="updatePage(page.id, { id: $event })" />
</app-form-field>
<app-form-field [label]="'builder.slug' | translate" [error]="hasDuplicateSlug(page) ? ('staticPages.duplicateSlug' | translate) : null">
<app-input [ngModel]="page.slug" (ngModelChange)="updatePage(page.id, { slug: $event })" />
</app-form-field>
<app-form-field [label]="'staticPages.routeLabel' | translate" [error]="hasDuplicateRoute(page) ? ('staticPages.duplicateRoute' | translate) : null">
<app-input [ngModel]="page.route" (ngModelChange)="updatePage(page.id, { route: $event })" />
</app-form-field>
<app-form-field [label]="'builder.iconLabel' | translate">
<app-input [ngModel]="page.icon || ''" (ngModelChange)="updatePage(page.id, { icon: $event })" />
</app-form-field>
<app-form-field [label]="'builder.footerGroup' | translate">
<app-input [ngModel]="page.footerGroup || ''" (ngModelChange)="updatePage(page.id, { footerGroup: $event })" />
</app-form-field>
<app-form-field [label]="'builder.orderLabel' | translate">
<app-input type="number" [ngModel]="page.order" (ngModelChange)="updatePage(page.id, { order: +$event })" />
</app-form-field>
<app-form-field [label]="'staticPages.customTemplateLabel' | translate">
<app-input [ngModel]="page.customTemplate || ''" (ngModelChange)="updatePage(page.id, { customTemplate: $event })" />
</app-form-field>
<label class="toggle-row">
<app-toggle [ngModel]="page.enabled" (ngModelChange)="updatePage(page.id, { enabled: $event })" [ariaLabel]="'staticPages.enabledLabel' | translate" />
<span>{{ 'staticPages.enabledLabel' | translate }}</span>
</label>
</div>
<div class="editor-grid three">
<app-form-field [label]="'staticPages.heroImageLabel' | translate">
<div class="media-field-row">
<app-input [ngModel]="page.heroImage || ''" (ngModelChange)="updatePage(page.id, { heroImage: $event })" />
<app-button variant="secondary" size="sm" (click)="openMediaPicker(page.id, 'heroImage')">{{ 'adminCategories.chooseImage' | translate }}</app-button>
</div>
</app-form-field>
<app-form-field [label]="'staticPages.thumbnailLabel' | translate">
<div class="media-field-row">
<app-input [ngModel]="page.thumbnail || ''" (ngModelChange)="updatePage(page.id, { thumbnail: $event })" />
<app-button variant="secondary" size="sm" (click)="openMediaPicker(page.id, 'thumbnail')">{{ 'adminCategories.chooseImage' | translate }}</app-button>
</div>
</app-form-field>
<app-form-field [label]="'staticPages.galleryLabel' | translate">
<app-input [ngModel]="(page.gallery || []).join(', ')" (ngModelChange)="updateGallery(page.id, $event)" />
</app-form-field>
</div>
<div class="toggle-grid">
<label class="toggle-row"><input type="checkbox" [checked]="page.showInFooter" (change)="updatePage(page.id, { showInFooter: $any($event.target).checked })" /><span>{{ 'builder.showInFooter' | translate }}</span></label>
<label class="toggle-row"><input type="checkbox" [checked]="page.showInHeader" (change)="updatePage(page.id, { showInHeader: $any($event.target).checked })" /><span>{{ 'builder.showInHeader' | translate }}</span></label>
<label class="toggle-row"><input type="checkbox" [checked]="page.showInSitemap" (change)="updatePage(page.id, { showInSitemap: $any($event.target).checked })" /><span>{{ 'builder.showInSitemap' | translate }}</span></label>
<label class="toggle-row"><input type="checkbox" [checked]="page.requiresAuthentication" (change)="updatePage(page.id, { requiresAuthentication: $any($event.target).checked })" /><span>{{ 'builder.requiresAuthentication' | translate }}</span></label>
</div>
@for (locale of locales(); track locale) {
<div class="editor-grid two">
<app-form-field [label]="(('builder.translationTitle' | translate) + ' ' + locale)">
<app-input [ngModel]="page.translations[locale]?.title || ''" (ngModelChange)="updateTranslation(page.id, locale, 'title', $event)" />
</app-form-field>
<label class="full">
<span>{{ 'builder.htmlPreview' | translate }} {{ locale }}</span>
<app-marketplace-html-editor
[html]="page.translations[locale]?.html || ''"
(htmlChange)="updateTranslation(page.id, locale, 'html', $event)"
/>
</label>
</div>
}
<h4 class="page-card__subheading">{{ 'builder.seoSection' | translate }}</h4>
<div class="editor-grid two">
<app-form-field [label]="'builder.seoTitle' | translate">
<app-input [ngModel]="page.seo?.title || ''" (ngModelChange)="updateSeo(page.id, { title: $event })" />
</app-form-field>
<app-form-field [label]="'builder.seoDescription' | translate">
<app-input [ngModel]="page.seo?.description || ''" (ngModelChange)="updateSeo(page.id, { description: $event })" />
</app-form-field>
<app-form-field [label]="'builder.seoKeywords' | translate">
<app-input [ngModel]="page.seo?.keywords || ''" (ngModelChange)="updateSeo(page.id, { keywords: $event })" />
</app-form-field>
<app-form-field [label]="'builder.seoCanonical' | translate">
<app-input [ngModel]="page.seo?.canonical || ''" (ngModelChange)="updateSeo(page.id, { canonical: $event })" />
</app-form-field>
<app-form-field [label]="'builder.seoOgTitle' | translate">
<app-input [ngModel]="page.seo?.ogTitle || ''" (ngModelChange)="updateSeo(page.id, { ogTitle: $event })" />
</app-form-field>
<app-form-field [label]="'builder.seoOgDescription' | translate">
<app-input [ngModel]="page.seo?.ogDescription || ''" (ngModelChange)="updateSeo(page.id, { ogDescription: $event })" />
</app-form-field>
<app-form-field [label]="'builder.seoOgImage' | translate">
<app-input [ngModel]="page.seo?.ogImage || ''" (ngModelChange)="updateSeo(page.id, { ogImage: $event })" />
</app-form-field>
<app-form-field [label]="'staticPages.seoRobotsLabel' | translate" [error]="hasInvalidSeo(page) ? ('staticPages.invalidSeo' | translate) : null">
<app-input [ngModel]="page.seo?.robots || ''" (ngModelChange)="updateSeo(page.id, { robots: $event })" placeholder="index,follow" />
</app-form-field>
</div>
</div>
</app-card>
}
</div>
<app-media-picker [open]="mediaPickerOpen" (selected)="onImagePicked($event)" (closed)="mediaPickerOpen = false" /> <app-media-picker [open]="mediaPickerOpen" (selected)="onImagePicked($event)" (closed)="mediaPickerOpen = false" />
</section> </section>

View File

@@ -1,44 +1,18 @@
.page-card { .page-card-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px; gap: 14px;
} }
.page-card__header { .page-card-grid__item {
display: flex; position: relative;
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 {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px;
gap: 12px;
} }
@media (max-width: 900px) { .page-card-grid__select {
.toggle-grid { position: absolute;
grid-template-columns: repeat(2, minmax(0, 1fr)); top: 12px;
} right: 12px;
z-index: 1;
} }

View File

@@ -3,25 +3,25 @@ import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../../project-editor/facade/project-editor.facade'; import { ProjectEditorFacade } from '../../project-editor/facade/project-editor.facade';
import { ContentManagementFacade } from '../facade/content-management.facade'; import { ContentManagementFacade } from '../facade/content-management.facade';
import { ContentPage, ContentPageSeoConfig, ContentPageStatus } from '../models/content-page.model'; import { ContentPage, ContentPageSeoConfig, ContentPageStatus } from '../models/content-page.model';
import { findLegalDefinition } from '../models/legal-pages.model';
import { BootstrapConfig } from '../../../shared/models/config'; import { BootstrapConfig } from '../../../shared/models/config';
import { TranslatePipe } from '../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../i18n/translate.pipe';
import { TranslateService } from '../../../i18n/translate.service'; 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 { ButtonComponent } from '../../../shared/ui/button/button.component';
import { InputComponent } from '../../../shared/ui/input/input.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 { 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 { EmptyStateComponent } from '../../../shared/ui/empty-state/empty-state.component';
import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component'; import { SelectComponent, SelectOption } from '../../../shared/ui/select/select.component';
import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component'; import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component';
import { MediaPickerComponent } from '../../../shared/media/media-picker/media-picker.component'; import { MediaPickerComponent } from '../../../shared/media/media-picker/media-picker.component';
import { MediaAsset } from '../../../core/media/models/media-asset.model'; import { MediaAsset } from '../../../core/media/models/media-asset.model';
import { StaticPagePreviewComponent } from './static-page-preview/static-page-preview.component'; 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; type StatusFilter = 'all' | ContentPageStatus;
const ALL_LOCALES_FILTER = 'all'; const ALL_LOCALES_FILTER = 'all';
type MediaField = 'heroImage' | 'thumbnail';
@Component({ @Component({
selector: 'app-static-pages-editor', selector: 'app-static-pages-editor',
@@ -29,17 +29,17 @@ type MediaField = 'heroImage' | 'thumbnail';
imports: [ imports: [
FormsModule, FormsModule,
TranslatePipe, TranslatePipe,
MarketplaceHtmlEditorComponent,
ButtonComponent, ButtonComponent,
InputComponent, InputComponent,
CardComponent,
FormFieldComponent, FormFieldComponent,
BadgeComponent,
EmptyStateComponent, EmptyStateComponent,
SelectComponent, SelectComponent,
ToggleComponent, ToggleComponent,
MediaPickerComponent, MediaPickerComponent,
StaticPagePreviewComponent, StaticPagePreviewComponent,
ContentDashboardComponent,
PageCardComponent,
PageEditorComponent,
], ],
templateUrl: './static-pages-editor.component.html', templateUrl: './static-pages-editor.component.html',
styleUrls: ['../../project-editor/sections/section.shared.scss', './static-pages-editor.component.scss'], 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 allPages = computed(() => this.contentFacade.pages(this.bootstrap()));
readonly validation = computed(() => this.contentFacade.validatePages(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 locales = computed(() => this.bootstrap()?.localization.supportedLocales ?? ['en']);
readonly defaultLocale = computed(() => this.bootstrap()?.localization.defaultLocale ?? 'en');
readonly searchQuery = signal(''); readonly searchQuery = signal('');
readonly statusFilter = signal<StatusFilter>('all'); readonly statusFilter = signal<StatusFilter>('all');
readonly localeFilter = signal<string>(ALL_LOCALES_FILTER); readonly localeFilter = signal<string>(ALL_LOCALES_FILTER);
readonly selectedIds = signal<Set<string>>(new Set()); readonly selectedIds = signal<Set<string>>(new Set());
readonly editingPageId = signal<string | null>(null);
readonly previewOpenPageId = signal<string | null>(null);
readonly editingPage = computed(() => this.allPages().find(page => page.id === this.editingPageId()) ?? null);
readonly statusFilterOptions = computed<SelectOption[]>(() => [ readonly statusFilterOptions = computed<SelectOption[]>(() => [
{ value: 'all', label: this.translate.t('staticPages.filterAll') }, { value: 'all', label: this.translate.t('staticPages.filterAll') },
@@ -80,7 +86,7 @@ export class StaticPagesEditorComponent {
const status = this.statusFilter(); const status = this.statusFilter();
const locale = this.localeFilter(); const locale = this.localeFilter();
return this.allPages().filter(page => { const filtered = this.allPages().filter(page => {
if (status !== 'all' && page.status !== status) { if (status !== 'all' && page.status !== status) {
return false; return false;
} }
@@ -95,6 +101,13 @@ export class StaticPagesEditorComponent {
.toLowerCase(); .toLowerCase();
return haystack.includes(query); 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); 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)); 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<Map<string, ContentPage>>(() => { private readonly originalPagesById = computed<Map<string, ContentPage>>(() => {
const staticPages = this.projectEditor.originalStaticPages(); const staticPages = this.projectEditor.originalStaticPages();
if (!staticPages) { if (!staticPages) {
@@ -120,31 +128,61 @@ export class StaticPagesEditorComponent {
isModified(page: ContentPage): boolean { isModified(page: ContentPage): boolean {
const original = this.originalPagesById().get(page.id); const original = this.originalPagesById().get(page.id);
if (!original) { 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); return JSON.stringify(page) !== JSON.stringify(original);
} }
readonly previewOpenPageId = signal<string | null>(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 { togglePreview(pageId: string): void {
this.previewOpenPageId.set(this.previewOpenPageId() === pageId ? null : pageId); this.previewOpenPageId.set(this.previewOpenPageId() === pageId ? null : pageId);
} }
previewHtml(page: ContentPage): string { 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 ?? ''; return page.translations[defaultLocale]?.html ?? Object.values(page.translations)[0]?.html ?? '';
} }
previewTitle(page: ContentPage): string { previewTitle(page: ContentPage): string {
const defaultLocale = this.bootstrap()?.localization.defaultLocale ?? 'en'; const defaultLocale = this.defaultLocale();
return page.translations[defaultLocale]?.title || page.title; return page.translations[defaultLocale]?.title || page.title;
} }
protected mediaPickerOpen = false; 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.mediaPickerTarget = { pageId, field };
this.mediaPickerOpen = true; this.mediaPickerOpen = true;
} }
@@ -156,14 +194,10 @@ export class StaticPagesEditorComponent {
this.mediaPickerOpen = false; this.mediaPickerOpen = false;
} }
/** clearMedia(pageId: string, field: PageMediaField): void {
* Appends `-2`, `-3`, ... to `candidate` until it's not in `existing`. this.updatePage(pageId, { [field]: undefined });
* 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.
*/
private uniqueValue(candidate: string, existing: Set<string>): string { private uniqueValue(candidate: string, existing: Set<string>): string {
if (!existing.has(candidate)) { if (!existing.has(candidate)) {
return candidate; return candidate;
@@ -194,14 +228,13 @@ export class StaticPagesEditorComponent {
ru: { title: '', html: '' }, ru: { title: '', html: '' },
hy: { 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, enabled: true,
status: 'draft', status: 'draft',
updatedAt: new Date().toISOString(),
}; };
this.persist([...this.allPages(), page]); this.persist([...this.allPages(), page]);
this.openEditor(page.id);
} }
duplicatePage(id: string): void { duplicatePage(id: string): void {
@@ -219,6 +252,7 @@ export class StaticPagesEditorComponent {
order: this.allPages().length + 1, order: this.allPages().length + 1,
status: 'draft', status: 'draft',
translations: Object.fromEntries(Object.entries(source.translations).map(([locale, t]) => [locale, { ...t }])), translations: Object.fromEntries(Object.entries(source.translations).map(([locale, t]) => [locale, { ...t }])),
updatedAt: new Date().toISOString(),
}; };
this.persist([...this.allPages(), clone]); this.persist([...this.allPages(), clone]);
} }
@@ -229,15 +263,19 @@ export class StaticPagesEditorComponent {
} }
this.persist(this.allPages().filter(page => page.id !== id)); this.persist(this.allPages().filter(page => page.id !== id));
this.deselect(id); this.deselect(id);
if (this.editingPageId() === id) {
this.closeEditor();
}
} }
updatePage(id: string, patch: Partial<ContentPage>): void { updatePage(id: string, patch: Partial<ContentPage>): 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 { updateTranslation(id: string, locale: string, field: 'title' | 'html', value: string): void {
this.persist(this.allPages().map(page => page.id !== id ? page : ({ this.persist(this.allPages().map(page => page.id !== id ? page : ({
...page, ...page,
updatedAt: new Date().toISOString(),
translations: { translations: {
...page.translations, ...page.translations,
[locale]: { [locale]: {
@@ -276,6 +314,7 @@ export class StaticPagesEditorComponent {
updateSeo(id: string, patch: Partial<ContentPageSeoConfig>): void { updateSeo(id: string, patch: Partial<ContentPageSeoConfig>): void {
this.persist(this.allPages().map(page => page.id !== id ? page : ({ this.persist(this.allPages().map(page => page.id !== id ? page : ({
...page, ...page,
updatedAt: new Date().toISOString(),
seo: { seo: {
...(page.seo ?? {}), ...(page.seo ?? {}),
...patch, ...patch,
@@ -284,9 +323,6 @@ export class StaticPagesEditorComponent {
} }
move(id: string, direction: -1 | 1): void { 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 pages = [...this.allPages()].sort((a, b) => a.order - b.order);
const index = pages.findIndex(page => page.id === id); const index = pages.findIndex(page => page.id === id);
const nextIndex = index + direction; const nextIndex = index + direction;
@@ -300,8 +336,6 @@ export class StaticPagesEditorComponent {
this.persist(pages.map((page, order) => ({ ...page, order: order + 1 }))); this.persist(pages.map((page, order) => ({ ...page, order: order + 1 })));
} }
// --- Search / filter ---
updateSearchQuery(value: string): void { updateSearchQuery(value: string): void {
this.searchQuery.set(value); this.searchQuery.set(value);
} }
@@ -314,8 +348,6 @@ export class StaticPagesEditorComponent {
this.localeFilter.set(value); this.localeFilter.set(value);
} }
// --- Bulk selection / actions ---
isSelected(id: string): boolean { isSelected(id: string): boolean {
return this.selectedIds().has(id); return this.selectedIds().has(id);
} }
@@ -364,9 +396,6 @@ export class StaticPagesEditorComponent {
} }
private persist(pages: ContentPage[]): void { 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 => ({ this.projectEditor.updateBootstrap(current => ({
...current, ...current,
staticPages: this.contentFacade.serializePages(pages) staticPages: this.contentFacade.serializePages(pages)

View File

@@ -2,6 +2,33 @@ import { Injectable, computed, inject } from '@angular/core';
import { BootstrapConfig } from '../../../shared/models/config'; import { BootstrapConfig } from '../../../shared/models/config';
import { ContentPageService } from '../services/content-page.service'; import { ContentPageService } from '../services/content-page.service';
import { ContentPage } from '../models/content-page.model'; 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' }) @Injectable({ providedIn: 'root' })
export class ContentManagementFacade { export class ContentManagementFacade {
@@ -11,6 +38,70 @@ export class ContentManagementFacade {
return bootstrap ? this.service.normalizePages(bootstrap.staticPages) : []; 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<typeof seo> => !!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<string | null>((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) { resolvePage(bootstrap: BootstrapConfig, keyOrSlug: string, locale: string) {
return this.service.resolvePage(bootstrap, keyOrSlug, locale); return this.service.resolvePage(bootstrap, keyOrSlug, locale);
} }

View File

@@ -45,8 +45,12 @@ export interface ContentPage {
status: ContentPageStatus; status: ContentPageStatus;
customTemplate?: string; customTemplate?: string;
heroImage?: string; heroImage?: string;
heroImageAlt?: string;
heroImageCaption?: string;
thumbnail?: string; thumbnail?: string;
gallery?: 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 { export interface ContentPageBootstrapInput {
@@ -73,6 +77,9 @@ export interface ContentPageBootstrapInput {
status?: ContentPageStatus; status?: ContentPageStatus;
customTemplate?: string; customTemplate?: string;
heroImage?: string; heroImage?: string;
heroImageAlt?: string;
heroImageCaption?: string;
thumbnail?: string; thumbnail?: string;
gallery?: string[]; gallery?: string[];
updatedAt?: string;
} }

View File

@@ -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;
}

View File

@@ -180,8 +180,11 @@ export class ContentPageService {
content: htmlMap, content: htmlMap,
customTemplate: page.customTemplate, customTemplate: page.customTemplate,
heroImage: page.heroImage, heroImage: page.heroImage,
heroImageAlt: page.heroImageAlt,
heroImageCaption: page.heroImageCaption,
thumbnail: page.thumbnail, thumbnail: page.thumbnail,
gallery: page.gallery, gallery: page.gallery,
updatedAt: page.updatedAt,
}; };
return acc; return acc;
}, {}); }, {});
@@ -224,8 +227,11 @@ export class ContentPageService {
status: page.status ?? 'published', status: page.status ?? 'published',
customTemplate: page.customTemplate, customTemplate: page.customTemplate,
heroImage: page.heroImage, heroImage: page.heroImage,
heroImageAlt: page.heroImageAlt,
heroImageCaption: page.heroImageCaption,
thumbnail: page.thumbnail, thumbnail: page.thumbnail,
gallery: page.gallery, gallery: page.gallery,
updatedAt: page.updatedAt,
}; };
} }

View File

@@ -937,6 +937,55 @@ export const en: Translations = {
previewMobile: 'Mobile', previewMobile: 'Mobile',
previewToggle: 'Preview', 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: { widgets: {
unavailable: 'Widget unavailable', unavailable: 'Widget unavailable',
}, },

View File

@@ -937,6 +937,55 @@ export const hy: Translations = {
previewMobile: 'Հեռախոս', previewMobile: 'Հեռախոս',
previewToggle: 'Նախադիտում', 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: { widgets: {
unavailable: 'Վիջեթը հասանելի չէ', unavailable: 'Վիջեթը հասանելի չէ',
}, },

View File

@@ -937,6 +937,55 @@ export const ru: Translations = {
previewMobile: 'Телефон', previewMobile: 'Телефон',
previewToggle: 'Превью', 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: { widgets: {
unavailable: 'Виджет недоступен', unavailable: 'Виджет недоступен',
}, },

View File

@@ -935,6 +935,55 @@ export interface Translations {
previewMobile: string; previewMobile: string;
previewToggle: 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: { widgets: {
unavailable: string; unavailable: string;
}; };

View File

@@ -60,8 +60,12 @@ export interface StaticPageConfig {
status?: 'draft' | 'published'; status?: 'draft' | 'published';
customTemplate?: string; customTemplate?: string;
heroImage?: string; heroImage?: string;
heroImageAlt?: string;
heroImageCaption?: string;
thumbnail?: string; thumbnail?: string;
gallery?: 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 { export interface LegacyStaticPageConfig {