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:
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<div class="page-editor">
|
||||||
|
<div class="page-editor__header">
|
||||||
|
<app-button variant="ghost" size="sm" (click)="close.emit()">← {{ '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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,35 @@
|
|||||||
<section class="editor-section-card">
|
<section class="editor-section-card">
|
||||||
|
@if (fieldError('staticPages'); as msg) {
|
||||||
|
<p class="editor-error">{{ msg }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (editingPage(); as page) {
|
||||||
|
<app-page-editor
|
||||||
|
[page]="page"
|
||||||
|
[locales]="locales()"
|
||||||
|
[defaultLocale]="defaultLocale()"
|
||||||
|
[hasDuplicateSlug]="hasDuplicateSlug(page)"
|
||||||
|
[hasDuplicateRoute]="hasDuplicateRoute(page)"
|
||||||
|
[hasEmptyTitle]="hasEmptyTitle(page)"
|
||||||
|
[hasInvalidHtml]="hasInvalidHtml(page)"
|
||||||
|
[hasInvalidSeo]="hasInvalidSeo(page)"
|
||||||
|
(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">
|
<div class="editor-actions">
|
||||||
<h2>{{ 'builder.staticPages' | translate }}</h2>
|
<h2>{{ 'builder.staticPages' | translate }}</h2>
|
||||||
<app-button variant="primary" (click)="createPage()">{{ 'builder.createPage' | translate }}</app-button>
|
<app-button variant="primary" (click)="createPage()">{{ 'builder.createPage' | translate }}</app-button>
|
||||||
</div>
|
</div>
|
||||||
@if (fieldError('staticPages'); as msg) {
|
|
||||||
<p class="editor-error">{{ msg }}</p>
|
<app-content-dashboard [health]="contentHealth()" (createPage)="createPage()" (goToPage)="openEditor($event)" />
|
||||||
}
|
|
||||||
|
|
||||||
<div class="editor-grid three static-pages-toolbar">
|
<div class="editor-grid three static-pages-toolbar">
|
||||||
<app-form-field [label]="'staticPages.searchPlaceholder' | translate">
|
<app-form-field [label]="'staticPages.searchPlaceholder' | translate">
|
||||||
@@ -37,162 +61,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@if (pages().length === 0) {
|
@if (pages().length === 0) {
|
||||||
<app-empty-state [title]="'builder.staticPages' | translate" [description]="'builder.createPage' | translate">
|
<app-empty-state [title]="'contentManagement.emptyStateGuideTitle' | translate" [description]="'contentManagement.emptyStateGuideBody' | translate">
|
||||||
<span slot="actions">
|
<span slot="actions">
|
||||||
<app-button variant="primary" (click)="createPage()">{{ 'builder.createPage' | translate }}</app-button>
|
<app-button variant="primary" (click)="createPage()">{{ 'builder.createPage' | translate }}</app-button>
|
||||||
</span>
|
</span>
|
||||||
</app-empty-state>
|
</app-empty-state>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="stack-list">
|
<div class="page-card-grid">
|
||||||
@for (page of pages(); track page.id) {
|
@for (page of pages(); track page.id) {
|
||||||
<app-card padding="md">
|
<div class="page-card-grid__item">
|
||||||
<div class="page-card">
|
<label class="page-card-grid__select">
|
||||||
<div class="page-card__header">
|
<app-toggle [ngModel]="isSelected(page.id)" (ngModelChange)="toggleSelect(page.id, $event)" [ariaLabel]="page.id" size="sm" />
|
||||||
<label class="toggle-row page-card__select">
|
|
||||||
<app-toggle [ngModel]="isSelected(page.id)" (ngModelChange)="toggleSelect(page.id, $event)" [ariaLabel]="page.id" />
|
|
||||||
</label>
|
</label>
|
||||||
<h3 class="page-card__title">
|
<app-page-card
|
||||||
{{ page.id }}
|
[page]="page"
|
||||||
<app-badge [variant]="page.status === 'published' ? 'success' : 'neutral'">{{ (page.status === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }}</app-badge>
|
[legalLabelKey]="legalLabelKey(page)"
|
||||||
@if (!page.enabled) {
|
[seoStatus]="seoStatus(page)"
|
||||||
<app-badge variant="neutral">{{ 'staticPages.disabledBadge' | translate }}</app-badge>
|
[hasIssue]="hasAnyIssue(page)"
|
||||||
}
|
[updatedAtLabel]="updatedAtLabel(page)"
|
||||||
@if (isModified(page)) {
|
(open)="openEditor(page.id)"
|
||||||
<app-badge variant="info">{{ 'builder.unsavedChanges' | translate }}</app-badge>
|
(duplicate)="duplicatePage(page.id)"
|
||||||
}
|
(preview)="togglePreview(page.id)"
|
||||||
@if (hasDuplicateSlug(page)) {
|
(remove)="deletePage(page.id)"
|
||||||
<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)">↑</app-button>
|
|
||||||
<app-button variant="ghost" size="sm" (click)="move(page.id, 1)">↓</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) {
|
@if (previewOpenPageId() === page.id) {
|
||||||
<app-static-page-preview [html]="previewHtml(page)" [title]="previewTitle(page)" />
|
<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>
|
||||||
|
}
|
||||||
<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>
|
</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: 'Վիջեթը հասանելի չէ',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: 'Виджет недоступен',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user