From 7d6c09a3467d5be30f4c33f6c26be57280047c28 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Fri, 10 Jul 2026 13:52:01 +0400 Subject: [PATCH] feat(cms): add static pages module --- docs/Content-Management.md | 154 ++++++++++++++ docs/platform/11-navigation-system.md | 5 + docs/platform/12-static-pages-system.md | 5 + .../components/footer/footer.component.html | 6 +- .../components/header/header.component.html | 15 +- src/app/components/header/header.component.ts | 34 +++ .../core/config/footer-resolver.service.ts | 80 +++---- .../config/static-page-resolver.service.ts | 106 +--------- .../static-pages-editor.component.html | 52 +++++ .../static-pages-editor.component.ts | 108 ++++++++++ .../facade/content-management.facade.ts | 24 +++ .../models/content-page.model.ts | 58 ++++++ .../content-management-page.component.ts | 11 + .../services/content-page.service.ts | 196 ++++++++++++++++++ .../project-editor-nav.component.ts | 1 + .../models/project-editor.model.ts | 1 + .../pages/project-editor-page.component.html | 1 + .../pages/project-editor-page.component.ts | 2 + src/app/i18n/en.ts | 21 +- src/app/i18n/hy.ts | 21 +- src/app/i18n/ru.ts | 21 +- src/app/i18n/translations.ts | 21 +- .../static-page/static-page.component.html | 6 +- .../static-page/static-page.component.ts | 35 +++- .../shared/models/config/static-page.model.ts | 65 +++++- 25 files changed, 890 insertions(+), 159 deletions(-) create mode 100644 docs/Content-Management.md create mode 100644 src/app/features/content-management/components/static-pages-editor.component.html create mode 100644 src/app/features/content-management/components/static-pages-editor.component.ts create mode 100644 src/app/features/content-management/facade/content-management.facade.ts create mode 100644 src/app/features/content-management/models/content-page.model.ts create mode 100644 src/app/features/content-management/pages/content-management-page.component.ts create mode 100644 src/app/features/content-management/services/content-page.service.ts diff --git a/docs/Content-Management.md b/docs/Content-Management.md new file mode 100644 index 0000000..61d2c58 --- /dev/null +++ b/docs/Content-Management.md @@ -0,0 +1,154 @@ +# Static Pages / CMS - Sprint 14 + +## Scope + +Sprint 14 adds configuration-driven CMS support for unlimited marketplace static pages. + +Out of scope: +- backend save endpoints +- marketplace-specific content logic +- hardcoded page names + +## Feature Architecture + +```text +src/app/features/content-management/ + facade/ + services/ + models/ + pages/ + components/ +``` + +Main pieces: +- `ContentPageService` normalizes bootstrap static page data +- `ContentManagementFacade` exposes normalized pages and validation helpers +- `StaticPagesEditorComponent` integrates CMS editing into Project Editor + +## Bootstrap Structure + +Static pages live under `bootstrap.staticPages`. + +Supported properties per page: +- `id` +- `slug` +- `title` +- `showInFooter` +- `showInHeader` +- `showInSitemap` +- `icon` +- `order` +- `visibility` +- `requiresAuthentication` +- `footerGroup` +- `translations` +- `html` +- `seo` + +## Translation Model + +Content can be stored in `translations[locale]`: +- `title` +- `html` +- `seo` + +Legacy structures remain supported through normalization. + +## Dynamic Routing + +Frontend uses dynamic static page resolution instead of manual page registration. + +Current route surface: +- `/:lang/:staticPath` +- legacy compatibility: `/:lang/page/:key` + +Resolver maps route slug to bootstrap-configured page. + +## Header Generation + +Pages with `showInHeader: true` render in header navigation. + +No hardcoded page list required. + +## Footer Generation + +Footer can render grouped static pages from bootstrap flags and metadata. + +Supported grouping: +- Company +n- Customer +- Legal +- Support +- Social + +Social links come from footer bootstrap config. + +## Rendering + +`StaticPageComponent` now: +- resolves content by slug or id +- reloads content when language changes +- updates document title/meta +- supports RTL-ready `dir` switching +- renders backend HTML safely through sanitization + +## Editor Integration + +Project Editor now includes `Static Pages` section. + +Supported actions: +- create page +- delete page +- enable footer/header/sitemap visibility +- edit slug +- edit icon +- edit order +- edit translations +- edit HTML + +## Validation + +Current validation prevents: +- duplicate slugs +- empty titles + +## Future Backend Endpoints + +Recommended future endpoints: +- `GET /builder/content-pages` +- `PUT /builder/content-pages` +- `POST /builder/content-pages/import` +- `GET /builder/content-pages/export` +- `POST /builder/content-pages/validate` + +## Example Bootstrap + +```json +{ + "staticPages": { + "about": { + "id": "about", + "slug": "about", + "title": { "en": "About Us", "ru": "О нас" }, + "showInFooter": true, + "showInHeader": true, + "showInSitemap": true, + "icon": "info", + "order": 1, + "requiresAuthentication": false, + "footerGroup": "Company", + "translations": { + "en": { + "title": "About Us", + "html": "

About

Company story

" + } + }, + "seo": { + "title": { "en": "About Us" }, + "description": { "en": "About our marketplace" }, + "canonical": "/about" + } + } + } +} +``` diff --git a/docs/platform/11-navigation-system.md b/docs/platform/11-navigation-system.md index 92e116a..652bafd 100644 --- a/docs/platform/11-navigation-system.md +++ b/docs/platform/11-navigation-system.md @@ -1,3 +1,8 @@ +## CMS Navigation Integration +- Static pages with `showInHeader: true` may appear in header navigation automatically. +- Static pages with `showInFooter: true` may be grouped into footer sections using `footerGroup`. +- Social links remain footer-config driven and can coexist with CMS page groups. +- Navigation stays configuration-driven; no marketplace-specific page names are hardcoded in frontend. # 11. Система навигации ## Назначение diff --git a/docs/platform/12-static-pages-system.md b/docs/platform/12-static-pages-system.md index b2ad65e..645f43b 100644 --- a/docs/platform/12-static-pages-system.md +++ b/docs/platform/12-static-pages-system.md @@ -1,3 +1,8 @@ +## CMS Upgrade +- Static pages now support unlimited bootstrap-driven entries under `staticPages`. +- Each page may define slug, visibility, header/footer/sitemap flags, icon, order, translations, HTML and SEO metadata. +- Frontend resolves pages dynamically from bootstrap instead of hardcoded page names. +- Header and footer may consume the same static page registry without duplicating page definitions. # 12. Система статических страниц ## Назначение diff --git a/src/app/components/footer/footer.component.html b/src/app/components/footer/footer.component.html index dd7e89b..7bb2300 100644 --- a/src/app/components/footer/footer.component.html +++ b/src/app/components/footer/footer.component.html @@ -14,7 +14,11 @@ diff --git a/src/app/components/header/header.component.html b/src/app/components/header/header.component.html index d0e9a70..a81ad1c 100644 --- a/src/app/components/header/header.component.html +++ b/src/app/components/header/header.component.html @@ -20,7 +20,11 @@ {{ 'header.catalog' | translate }} } - + @for (page of headerPages(); track page.id) { + + } @@ -140,7 +144,14 @@ } - + @for (page of headerPages(); track page.id) { + + {{ page.title }} + + + + + }
@if (headerConfig().showRegion) { diff --git a/src/app/components/header/header.component.ts b/src/app/components/header/header.component.ts index 17362ee..0db6f98 100644 --- a/src/app/components/header/header.component.ts +++ b/src/app/components/header/header.component.ts @@ -11,6 +11,7 @@ import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade'; import { UserExperienceFacade } from '../../facades/platform/user-experience.facade'; import { ConfigService } from '../../core/config/config.service'; import { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config'; +import { StaticPageResolverService } from '../../core/config/static-page-resolver.service'; @Component({ selector: 'app-header', @@ -30,11 +31,13 @@ export class HeaderComponent { private uiRuntime = inject(UiRuntimeFacade); private uxFacade = inject(UserExperienceFacade); private configService = inject(ConfigService); + private staticPageResolver = inject(StaticPageResolverService); readonly wishlistCount = this.uxFacade.wishlistCount; readonly compareCount = this.uxFacade.compareCount; readonly userExperienceConfig = computed(() => this.resolveUserExperienceConfig()); readonly headerConfig = computed(() => this.resolveHeaderConfig()); + readonly headerPages = computed(() => this.resolveHeaderPages()); constructor(private cartService: CartService, private router: Router) { this.cartItemCount = this.cartService.itemCount; @@ -111,6 +114,12 @@ export class HeaderComponent { this.router.navigate([`/${lang}/compare`]); } + navigateToStatic(route: string): void { + this.closeMenu(); + const lang = this.langService.currentLanguage(); + this.router.navigate([`/${lang}${route.startsWith('/') ? route : `/${route}`}`]); + } + formatCartTotal(total: number): string { const locale = this.langService.currentLanguage() === 'en' ? 'en-US' @@ -154,6 +163,31 @@ export class HeaderComponent { ...raw, }; } + + private resolveHeaderPages() { + this.configService.bootstrapRevision(); + const bootstrap = this.configService.getBootstrapSnapshot(); + if (!bootstrap?.staticPages) { + return [] as Array<{ id: string; title: string; route: string; icon?: string; order: number }>; + } + + const lang = this.langService.currentLanguage(); + const pages = Object.values(bootstrap.staticPages as Record) + .filter(page => page.showInHeader === true) + .map(page => { + const resolved = this.staticPageResolver.resolveByKeyFromBootstrap(bootstrap, page.id, lang); + return resolved ? { + id: resolved.id, + title: resolved.title, + route: resolved.route, + icon: resolved.icon, + order: page.order ?? 0, + } : null; + }) + .filter((page): page is { id: string; title: string; route: string; icon: string | undefined; order: number } => page !== null); + + return pages.sort((left, right) => left.order - right.order); + } } diff --git a/src/app/core/config/footer-resolver.service.ts b/src/app/core/config/footer-resolver.service.ts index 9c75f59..878aebb 100644 --- a/src/app/core/config/footer-resolver.service.ts +++ b/src/app/core/config/footer-resolver.service.ts @@ -16,6 +16,7 @@ export interface FooterResolvedItem { id: string; label: string; route: string; + external?: boolean; } export interface FooterResolvedGroup { @@ -62,33 +63,17 @@ export class FooterResolverService { private resolveFooterGroups(bootstrap: BootstrapConfig): FooterResolvedGroup[] { const footer = bootstrap.navigation?.footer ?? []; const lang = this.languageService.currentLanguage(); - const legalKeys = bootstrap.footer?.legalPageKeys ?? []; - const legalItems = legalKeys - .map(key => { - const page = this.staticPageResolver.resolveByKeyFromBootstrap(bootstrap, key, lang); - if (!page) { - return null; - } - - return { - id: `legal-${key}`, - label: page.title, - route: page.route - } as FooterResolvedItem; - }) - .filter((item): item is FooterResolvedItem => item != null); + const contentPages = this.resolveBootstrapStaticPages(bootstrap, lang); if (this.isGroupedFooter(footer)) { const groups = footer .map((group, index) => this.resolveGroupFromConfig(group, bootstrap, lang, index)) .filter((group): group is FooterResolvedGroup => group != null && group.items.length > 0); - if (legalItems.length > 0) { - groups.push({ - id: 'footer-group-legal', - title: 'Legal', - items: legalItems - }); + for (const group of contentPages) { + if (!groups.some(existing => existing.id === group.id)) { + groups.push(group); + } } return groups; @@ -99,9 +84,7 @@ export class FooterResolverService { .filter((item): item is FooterResolvedItem => item != null); if (!items.length) { - return legalItems.length - ? [{ id: 'footer-group-legal', title: 'Legal', items: legalItems }] - : []; + return contentPages; } const groups: FooterResolvedGroup[] = [{ @@ -110,15 +93,44 @@ export class FooterResolverService { items }]; - if (legalItems.length > 0) { - groups.push({ - id: 'footer-group-legal', - title: 'Legal', - items: legalItems + groups.push(...contentPages); + + return groups; + } + + private resolveBootstrapStaticPages(bootstrap: BootstrapConfig, lang: string): FooterResolvedGroup[] { + const rawPages = Object.values(bootstrap.staticPages ?? {}) as any[]; + const pages = rawPages + .filter(page => page.showInFooter !== false) + .map(page => ({ raw: page, resolved: this.staticPageResolver.resolveByKeyFromBootstrap(bootstrap, page.id ?? '', lang) })) + .filter(entry => entry.resolved !== null); + + const groups = new Map(); + + for (const page of pages) { + const raw = page.raw; + const resolved = page.resolved!; + const groupTitle = raw?.footerGroup || 'Legal'; + const groupId = `footer-group-${String(groupTitle).toLowerCase().replace(/\s+/g, '-')}`; + if (!groups.has(groupId)) { + groups.set(groupId, { id: groupId, title: groupTitle, items: [] }); + } + groups.get(groupId)!.items.push({ id: resolved.id, label: resolved.title, route: resolved.route }); + } + + const socialLinks = bootstrap.footer?.socialLinks ?? []; + if (socialLinks.length > 0) { + groups.set('footer-group-social', { + id: 'footer-group-social', + title: 'Social', + items: socialLinks.map(link => ({ id: link.id, label: link.label, route: link.url, external: true })) }); } - return groups; + return [...groups.values()].map(group => ({ + ...group, + items: group.items.sort((left, right) => left.label.localeCompare(right.label)) + })); } private resolveGroupFromConfig( @@ -213,14 +225,8 @@ export class FooterResolverService { } private resolvePaymentIcons(footerConfig: FooterConfig | undefined): FooterPaymentIcon[] { - const fallback: FooterPaymentIcon[] = [ - { src: '/assets/images/mir-logo.svg', alt: 'MIR', width: 40, height: 28 }, - { src: '/assets/images/visa-logo.svg', alt: 'Visa', width: 40, height: 28 }, - { src: '/assets/images/mastercard-logo.svg', alt: 'Mastercard', width: 40, height: 28 } - ]; - if (!footerConfig?.paymentIcons?.length) { - return fallback; + return []; } return footerConfig.paymentIcons diff --git a/src/app/core/config/static-page-resolver.service.ts b/src/app/core/config/static-page-resolver.service.ts index 533f62a..32a3046 100644 --- a/src/app/core/config/static-page-resolver.service.ts +++ b/src/app/core/config/static-page-resolver.service.ts @@ -2,14 +2,16 @@ import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { ConfigService } from './config.service'; -import { BootstrapConfig, LegacyStaticPageConfig, LocalizedHtmlContent, LocalizedTextContent, ResolvedStaticPage, StaticPageConfig } from '../../shared/models/config'; +import { BootstrapConfig, LocalizedTextContent, ResolvedStaticPage } from '../../shared/models/config'; import { LanguageService } from '../../services/language.service'; +import { ContentPageService } from '../../features/content-management/services/content-page.service'; @Injectable({ providedIn: 'root' }) export class StaticPageResolverService { constructor( private readonly configService: ConfigService, - private readonly languageService: LanguageService + private readonly languageService: LanguageService, + private readonly contentPageService: ContentPageService ) {} resolveByKey(key: string, lang?: string): Observable { @@ -25,40 +27,11 @@ export class StaticPageResolverService { } resolveByKeyFromBootstrap(bootstrap: BootstrapConfig, key: string, lang: string): ResolvedStaticPage | null { - const page = this.getPageByKey(bootstrap, key); - if (!page) { - return null; - } - - const route = this.normalizeRoute(page.route || `/page/${key}`); - const title = this.resolveLocalizedText(page.title, lang, key); - const html = this.resolveLocalizedHtml(page.content, lang); - - return { - key, - route, - title, - html - }; + return this.contentPageService.resolvePage(bootstrap, key, lang); } resolveByRouteFromBootstrap(bootstrap: BootstrapConfig, route: string, lang: string): ResolvedStaticPage | null { - const normalized = this.normalizeRoute(route); - const registry = this.getRegistry(bootstrap); - - for (const [key, page] of Object.entries(registry)) { - const routePath = this.normalizeRoute(page.route || `/page/${key}`); - if (routePath === normalized) { - return { - key, - route: routePath, - title: this.resolveLocalizedText(page.title, lang, key), - html: this.resolveLocalizedHtml(page.content, lang) - }; - } - } - - return null; + return this.contentPageService.resolvePage(bootstrap, this.normalizeRoute(route).replace(/^\//, ''), lang); } resolveFooterTitle(groupTitle: string | LocalizedTextContent | undefined, lang: string): string { @@ -73,65 +46,6 @@ export class StaticPageResolverService { return this.resolveLocalizedText(groupTitle, lang, ''); } - private getRegistry(bootstrap: BootstrapConfig): Record { - const raw = bootstrap.staticPages; - if (!raw) { - return {}; - } - - if (Array.isArray(raw)) { - return raw.reduce>((acc, item) => { - const legacy = item as LegacyStaticPageConfig; - const key = legacy.key; - if (!key) { - return acc; - } - - const route = typeof legacy.route === 'string' ? legacy.route : legacy.route?.path ?? `/page/${key}`; - - let titleMap: LocalizedTextContent; - if (typeof legacy.title === 'string') { - titleMap = { en: legacy.title }; - } else { - titleMap = legacy.title ?? { en: key }; - } - - let contentMap: LocalizedHtmlContent; - if (typeof legacy.content === 'string') { - contentMap = { en: legacy.content }; - } else if (legacy.content && typeof legacy.content === 'object' && 'value' in legacy.content) { - contentMap = { en: legacy.content.value ?? '' }; - } else { - contentMap = (legacy.content as LocalizedHtmlContent) ?? { en: '' }; - } - - acc[key] = { - route, - title: titleMap, - content: contentMap, - visible: legacy.visible - }; - return acc; - }, {}); - } - - return raw; - } - - private getPageByKey(bootstrap: BootstrapConfig, key: string): StaticPageConfig | null { - const registry = this.getRegistry(bootstrap); - const page = registry[key]; - if (!page) { - return null; - } - - if (page.visible === false) { - return null; - } - - return page; - } - private resolveLocalizedText(text: LocalizedTextContent | undefined, lang: string, fallback: string): string { if (!text) { return fallback; @@ -140,14 +54,6 @@ export class StaticPageResolverService { return text[lang] ?? text['en'] ?? Object.values(text)[0] ?? fallback; } - private resolveLocalizedHtml(content: LocalizedHtmlContent | undefined, lang: string): string { - if (!content) { - return ''; - } - - return content[lang] ?? content['en'] ?? Object.values(content)[0] ?? ''; - } - private normalizeRoute(route: string): string { if (!route) { return '/'; diff --git a/src/app/features/content-management/components/static-pages-editor.component.html b/src/app/features/content-management/components/static-pages-editor.component.html new file mode 100644 index 0000000..dcf417e --- /dev/null +++ b/src/app/features/content-management/components/static-pages-editor.component.html @@ -0,0 +1,52 @@ +
+
+

{{ 'builder.staticPages' | translate }}

+ +
+ + @if (validation().duplicateSlugs.length > 0) { +

{{ 'staticPages.duplicateSlug' | translate }}

+ } + @if (validation().emptyTitles.length > 0) { +

{{ 'staticPages.emptyTitle' | translate }}

+ } + +
+ @for (page of pages(); track page.id) { +
+
+

{{ page.id }}

+
+ + + +
+
+
+ + + + + + + + + +
+ + @for (locale of ['en','ru','hy']; track locale) { +
+ + +
+ } +
+ } +
+
diff --git a/src/app/features/content-management/components/static-pages-editor.component.ts b/src/app/features/content-management/components/static-pages-editor.component.ts new file mode 100644 index 0000000..71d65cf --- /dev/null +++ b/src/app/features/content-management/components/static-pages-editor.component.ts @@ -0,0 +1,108 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ProjectEditorFacade } from '../../project-editor/facade/project-editor.facade'; +import { ContentManagementFacade } from '../facade/content-management.facade'; +import { ContentPage } from '../models/content-page.model'; +import { TranslatePipe } from '../../../i18n/translate.pipe'; + +@Component({ + selector: 'app-static-pages-editor', + standalone: true, + imports: [FormsModule, TranslatePipe], + templateUrl: './static-pages-editor.component.html', + styleUrls: ['../../project-editor/sections/section.shared.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class StaticPagesEditorComponent { + private readonly projectEditor = inject(ProjectEditorFacade); + private readonly contentFacade = inject(ContentManagementFacade); + + readonly bootstrap = this.projectEditor.bootstrap; + readonly pages = computed(() => this.contentFacade.pages(this.bootstrap())); + readonly validation = computed(() => this.contentFacade.validatePages(this.bootstrap())); + + createPage(): void { + const page: ContentPage = { + id: `page-${Date.now()}`, + slug: `custom-page-${this.pages().length + 1}`, + title: '', + order: this.pages().length + 1, + showInFooter: false, + showInHeader: false, + showInSitemap: true, + visibility: { desktop: true, tablet: true, mobile: true }, + requiresAuthentication: false, + translations: { + en: { title: '', html: '' }, + ru: { title: '', html: '' }, + hy: { title: '', html: '' }, + }, + }; + + this.persist([...this.pages(), page]); + } + + deletePage(id: string): void { + this.persist(this.pages().filter(page => page.id !== id)); + } + + updatePage(id: string, patch: Partial): void { + this.persist(this.pages().map(page => page.id !== id ? page : ({ ...page, ...patch }))); + } + + updateTranslation(id: string, locale: string, field: 'title' | 'html', value: string): void { + this.persist(this.pages().map(page => page.id !== id ? page : ({ + ...page, + translations: { + ...page.translations, + [locale]: { + ...(page.translations[locale] ?? {}), + [field]: value, + } + } + }))); + } + + move(id: string, direction: -1 | 1): void { + const pages = [...this.pages()].sort((a, b) => a.order - b.order); + const index = pages.findIndex(page => page.id === id); + const nextIndex = index + direction; + if (index < 0 || nextIndex < 0 || nextIndex >= pages.length) { + return; + } + + const current = pages[index]; + pages[index] = pages[nextIndex]; + pages[nextIndex] = current; + this.persist(pages.map((page, order) => ({ ...page, order: order + 1 }))); + } + + private persist(pages: ContentPage[]): void { + this.projectEditor.updateBootstrap(current => ({ + ...current, + staticPages: pages.reduce>((acc, page) => { + const record = { + id: page.id, + slug: page.slug, + title: Object.fromEntries(Object.entries(page.translations).map(([locale, translation]) => [locale, translation.title || page.title]).filter(([, value]) => !!value)), + html: Object.fromEntries(Object.entries(page.translations).map(([locale, translation]) => [locale, translation.html || ''])), + showInFooter: page.showInFooter, + showInHeader: page.showInHeader, + showInSitemap: page.showInSitemap, + icon: page.icon, + order: page.order, + visibility: page.visibility, + requiresAuthentication: page.requiresAuthentication, + footerGroup: page.footerGroup, + translations: page.translations, + seo: page.seo, + visible: true, + route: `/${page.slug}`, + content: Object.fromEntries(Object.entries(page.translations).map(([locale, translation]) => [locale, translation.html || ''])), + }; + acc[page.id] = record; + return acc; + }, {}) + })); + } +} diff --git a/src/app/features/content-management/facade/content-management.facade.ts b/src/app/features/content-management/facade/content-management.facade.ts new file mode 100644 index 0000000..e884e1e --- /dev/null +++ b/src/app/features/content-management/facade/content-management.facade.ts @@ -0,0 +1,24 @@ +import { Injectable, computed, inject } from '@angular/core'; +import { BootstrapConfig } from '../../../shared/models/config'; +import { ContentPageService } from '../services/content-page.service'; + +@Injectable({ providedIn: 'root' }) +export class ContentManagementFacade { + private readonly service = inject(ContentPageService); + + pages(bootstrap: BootstrapConfig | null) { + return bootstrap ? this.service.normalizePages(bootstrap.staticPages) : []; + } + + resolvePage(bootstrap: BootstrapConfig, keyOrSlug: string, locale: string) { + return this.service.resolvePage(bootstrap, keyOrSlug, locale); + } + + validatePages(bootstrap: BootstrapConfig | null) { + return this.service.validatePages(this.pages(bootstrap)); + } + + toBootstrapRecord(bootstrap: BootstrapConfig | null) { + return bootstrap ? this.service.toBootstrapRecord(this.pages(bootstrap)) : {}; + } +} diff --git a/src/app/features/content-management/models/content-page.model.ts b/src/app/features/content-management/models/content-page.model.ts new file mode 100644 index 0000000..9530ae1 --- /dev/null +++ b/src/app/features/content-management/models/content-page.model.ts @@ -0,0 +1,58 @@ +import { LocalizedHtmlContent, LocalizedTextContent } from '../../../shared/models/config'; + +export interface ContentPageSeoConfig { + title?: string; + description?: string; + keywords?: string; + canonical?: string; + ogTitle?: string; + ogDescription?: string; + ogImage?: string; +} + +export interface ContentPageTranslation { + title?: string; + html?: string; + seo?: ContentPageSeoConfig; +} + +export interface ContentPage { + id: string; + slug: string; + title: string; + icon?: string; + order: number; + showInFooter: boolean; + showInHeader: boolean; + showInSitemap: boolean; + visibility: { + desktop?: boolean; + tablet?: boolean; + mobile?: boolean; + }; + requiresAuthentication: boolean; + footerGroup?: string; + translations: Record; + seo?: ContentPageSeoConfig; +} + +export interface ContentPageBootstrapInput { + id: string; + slug: string; + title?: string | LocalizedTextContent; + showInFooter?: boolean; + showInHeader?: boolean; + showInSitemap?: boolean; + icon?: string; + order?: number; + visibility?: { + desktop?: boolean; + tablet?: boolean; + mobile?: boolean; + }; + requiresAuthentication?: boolean; + footerGroup?: string; + translations?: Record; + html?: string | LocalizedHtmlContent; + seo?: ContentPageSeoConfig | { title?: string | LocalizedTextContent; description?: string | LocalizedTextContent; keywords?: string; canonical?: string; ogTitle?: string | LocalizedTextContent; ogDescription?: string | LocalizedTextContent; ogImage?: string; robots?: string; metaTags?: Array<{ name?: string; property?: string; content: string }>; }; +} diff --git a/src/app/features/content-management/pages/content-management-page.component.ts b/src/app/features/content-management/pages/content-management-page.component.ts new file mode 100644 index 0000000..a9ac125 --- /dev/null +++ b/src/app/features/content-management/pages/content-management-page.component.ts @@ -0,0 +1,11 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { StaticPagesEditorComponent } from '../components/static-pages-editor.component'; + +@Component({ + selector: 'app-content-management-page', + standalone: true, + imports: [StaticPagesEditorComponent], + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ContentManagementPageComponent {} diff --git a/src/app/features/content-management/services/content-page.service.ts b/src/app/features/content-management/services/content-page.service.ts new file mode 100644 index 0000000..d1c9cb6 --- /dev/null +++ b/src/app/features/content-management/services/content-page.service.ts @@ -0,0 +1,196 @@ +import { Injectable } from '@angular/core'; +import { BootstrapConfig, LocalizedHtmlContent, LocalizedTextContent, ResolvedStaticPage, StaticPageConfig, StaticPagesConfig } from '../../../shared/models/config'; +import { ContentPage, ContentPageBootstrapInput } from '../models/content-page.model'; + +@Injectable({ providedIn: 'root' }) +export class ContentPageService { + normalizePages(config: StaticPagesConfig | undefined): ContentPage[] { + if (!config) { + return []; + } + + if (Array.isArray(config)) { + return config.map((page, index) => ({ + id: page.key, + slug: this.normalizeSlug(typeof page.route === 'string' ? page.route : page.route?.path ?? page.key), + title: typeof page.title === 'string' ? page.title : page.title?.['en'] ?? page.key, + icon: undefined, + order: index + 1, + showInFooter: false, + showInHeader: false, + showInSitemap: true, + visibility: { desktop: true, tablet: true, mobile: true }, + requiresAuthentication: false, + footerGroup: undefined, + translations: this.normalizeLegacyTranslations(page.title, page.content), + seo: undefined, + })); + } + + return Object.values(config) + .map((page, index) => this.normalizePage(page as ContentPageBootstrapInput, index)) + .sort((left, right) => left.order - right.order); + } + + resolvePage(bootstrap: BootstrapConfig, keyOrSlug: string, locale: string): ResolvedStaticPage | null { + const normalizedPages = this.normalizePages(bootstrap.staticPages); + const target = normalizedPages.find(page => page.id === keyOrSlug || page.slug === this.normalizeSlug(keyOrSlug)); + if (!target) { + return null; + } + + const translation = target.translations[locale] ?? target.translations['en'] ?? Object.values(target.translations)[0] ?? {}; + + return { + key: target.id, + id: target.id, + slug: target.slug, + route: `/${target.slug}`, + title: translation.title || target.title, + html: translation.html || '', + icon: target.icon, + requiresAuthentication: target.requiresAuthentication, + seo: translation.seo ?? target.seo, + }; + } + + validatePages(pages: ContentPage[]): { duplicateSlugs: string[]; emptyTitles: string[] } { + const seen = new Set(); + const duplicateSlugs = new Set(); + const emptyTitles = new Set(); + + for (const page of pages) { + const slug = this.normalizeSlug(page.slug); + if (seen.has(slug)) { + duplicateSlugs.add(slug); + } + seen.add(slug); + + const hasTitle = page.title.trim().length > 0 || Object.values(page.translations).some(translation => (translation.title ?? '').trim().length > 0); + if (!hasTitle) { + emptyTitles.add(page.id); + } + } + + return { + duplicateSlugs: [...duplicateSlugs], + emptyTitles: [...emptyTitles], + }; + } + + toBootstrapRecord(pages: ContentPage[]): Record { + return pages.reduce>((acc, page) => { + const htmlMap = Object.entries(page.translations).reduce((result, [locale, translation]) => { + if ((translation.html ?? '').trim()) { + result[locale] = translation.html!; + } + return result; + }, {}); + + const titleMap = Object.entries(page.translations).reduce((result, [locale, translation]) => { + if ((translation.title ?? '').trim()) { + result[locale] = translation.title!; + } + return result; + }, {}); + + acc[page.id] = { + id: page.id, + slug: this.normalizeSlug(page.slug), + title: Object.keys(titleMap).length > 0 ? titleMap : page.title, + html: htmlMap, + showInFooter: page.showInFooter, + showInHeader: page.showInHeader, + showInSitemap: page.showInSitemap, + icon: page.icon, + order: page.order, + visibility: page.visibility, + requiresAuthentication: page.requiresAuthentication, + footerGroup: page.footerGroup, + translations: page.translations, + seo: page.seo, + visible: true, + route: `/${this.normalizeSlug(page.slug)}`, + content: htmlMap, + }; + return acc; + }, {}); + } + + private normalizePage(page: ContentPageBootstrapInput, index: number): ContentPage { + const titleMap = typeof page.title === 'object' && page.title ? page.title : {}; + const htmlMap = typeof page.html === 'object' && page.html ? page.html : {}; + const translations = { ...(page.translations ?? {}) }; + + for (const [locale, value] of Object.entries(titleMap)) { + translations[locale] = { ...(translations[locale] ?? {}), title: value }; + } + + for (const [locale, value] of Object.entries(htmlMap)) { + translations[locale] = { ...(translations[locale] ?? {}), html: value }; + } + + return { + id: page.id, + slug: this.normalizeSlug(page.slug || page.id), + title: typeof page.title === 'string' ? page.title : Object.values(titleMap)[0] ?? page.id, + icon: page.icon, + order: page.order ?? index + 1, + showInFooter: page.showInFooter !== false, + showInHeader: page.showInHeader === true, + showInSitemap: page.showInSitemap !== false, + visibility: page.visibility ?? { desktop: true, tablet: true, mobile: true }, + requiresAuthentication: page.requiresAuthentication === true, + footerGroup: page.footerGroup, + translations, + seo: this.normalizeSeo(page.seo), + }; + } + + private normalizeLegacyTranslations(title: LegacyTitle, content: LegacyContent): Record { + const translations: Record = {}; + if (typeof title === 'string') { + translations['en'] = { ...(translations['en'] ?? {}), title }; + } else { + for (const [locale, value] of Object.entries(title ?? {})) { + translations[locale] = { ...(translations[locale] ?? {}), title: value }; + } + } + + if (typeof content === 'string') { + translations['en'] = { ...(translations['en'] ?? {}), html: content }; + } else if (content && typeof content === 'object' && 'value' in content) { + translations['en'] = { ...(translations['en'] ?? {}), html: content.value ?? '' }; + } else { + for (const [locale, value] of Object.entries((content ?? {}) as LocalizedHtmlContent)) { + translations[locale] = { ...(translations[locale] ?? {}), html: value }; + } + } + + return translations; + } + + private normalizeSlug(value: string): string { + return value.replace(/^\/+/, '').trim(); + } + + private normalizeSeo(input: ContentPageBootstrapInput['seo']): ContentPage['seo'] { + if (!input) { + return undefined; + } + + const first = (value?: string | LocalizedTextContent) => typeof value === 'string' ? value : value?.['en'] ?? Object.values(value ?? {})[0]; + return { + title: first(input.title), + description: first(input.description), + keywords: input.keywords, + canonical: input.canonical, + ogTitle: first(input.ogTitle), + ogDescription: first(input.ogDescription), + ogImage: input.ogImage, + }; + } +} + +type LegacyTitle = string | LocalizedTextContent | undefined; +type LegacyContent = string | LocalizedHtmlContent | { value?: string; contentType?: string; source?: string } | undefined; diff --git a/src/app/features/project-editor/components/project-editor-nav.component.ts b/src/app/features/project-editor/components/project-editor-nav.component.ts index 2e59965..920e7d7 100644 --- a/src/app/features/project-editor/components/project-editor-nav.component.ts +++ b/src/app/features/project-editor/components/project-editor-nav.component.ts @@ -22,6 +22,7 @@ export class ProjectEditorNavComponent { { id: 'footer', label: 'builder.footer' }, { id: 'homepage', label: 'builder.homepage' }, { id: 'widgets', label: 'builder.widgets' }, + { id: 'static-pages', label: 'builder.staticPages' }, { id: 'features', label: 'builder.marketplaceFeatures' }, { id: 'preview', label: 'builder.preview' }, ]; diff --git a/src/app/features/project-editor/models/project-editor.model.ts b/src/app/features/project-editor/models/project-editor.model.ts index 5a5af8d..73f7e9e 100644 --- a/src/app/features/project-editor/models/project-editor.model.ts +++ b/src/app/features/project-editor/models/project-editor.model.ts @@ -8,6 +8,7 @@ export type ProjectEditorSectionId = | 'footer' | 'homepage' | 'widgets' + | 'static-pages' | 'features' | 'preview'; diff --git a/src/app/features/project-editor/pages/project-editor-page.component.html b/src/app/features/project-editor/pages/project-editor-page.component.html index 96e3508..00b602d 100644 --- a/src/app/features/project-editor/pages/project-editor-page.component.html +++ b/src/app/features/project-editor/pages/project-editor-page.component.html @@ -21,6 +21,7 @@ @case ('footer') { } @case ('homepage') { } @case ('widgets') { } + @case ('static-pages') { } @case ('features') { } @case ('preview') { } } diff --git a/src/app/features/project-editor/pages/project-editor-page.component.ts b/src/app/features/project-editor/pages/project-editor-page.component.ts index 8c56591..6feced2 100644 --- a/src/app/features/project-editor/pages/project-editor-page.component.ts +++ b/src/app/features/project-editor/pages/project-editor-page.component.ts @@ -11,6 +11,7 @@ import { ProjectEditorWidgetsSectionComponent } from '../sections/widgets-sectio import { ProjectEditorFeaturesSectionComponent } from '../sections/features-section.component'; import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component'; import { TranslatePipe } from '../../../i18n/translate.pipe'; +import { StaticPagesEditorComponent } from '../../content-management/components/static-pages-editor.component'; @Component({ selector: 'app-project-editor-page', @@ -25,6 +26,7 @@ import { TranslatePipe } from '../../../i18n/translate.pipe'; ProjectEditorFooterSectionComponent, ProjectEditorHomepageSectionComponent, ProjectEditorWidgetsSectionComponent, + StaticPagesEditorComponent, ProjectEditorFeaturesSectionComponent, ProjectEditorPreviewSectionComponent, ], diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index f8b0c6a..ba826b3 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -407,6 +407,7 @@ export const en: Translations = { footer: 'Footer', homepage: 'Homepage', widgets: 'Widgets', + staticPages: 'Static Pages', marketplaceFeatures: 'Marketplace Features', preview: 'Preview', brandName: 'Brand Name', @@ -443,7 +444,19 @@ export const en: Translations = { copyright: 'Copyright', paymentIcons: 'Payment Icons', socialLinks: 'Social Links', - staticPages: 'Static Pages', + createPage: 'Create Page', + deletePage: 'Delete Page', + pageId: 'Page ID', + slug: 'Slug', + iconLabel: 'Icon', + showInFooter: 'Show In Footer', + showInHeader: 'Show In Header', + showInSitemap: 'Show In Sitemap', + requiresAuthentication: 'Requires Authentication', + footerGroup: 'Footer Group', + orderLabel: 'Order', + translationTitle: 'Translation Title', + htmlPreview: 'HTML', visible: 'Visible', layoutLabel: 'Layout', columns: 'Columns', @@ -471,6 +484,12 @@ export const en: Translations = { importError: 'Import failed. Invalid bootstrap JSON.', featureFlags: 'Feature Flags', }, + staticPages: { + notFound: 'Page not found', + backHome: 'Back to home', + duplicateSlug: 'Duplicate slugs are not allowed.', + emptyTitle: 'Each page must have at least one title.', + }, widgets: { unavailable: 'Widget unavailable', }, diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 19f177f..4007c56 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -407,6 +407,7 @@ export const hy: Translations = { footer: 'Footer', homepage: 'Գլխավոր էջ', widgets: 'Վիջեթներ', + staticPages: 'Ստատիկ էջեր', marketplaceFeatures: 'Մարքեթփլեյսի հնարավորություններ', preview: 'Preview', brandName: 'Բրենդի անվանում', @@ -443,7 +444,19 @@ export const hy: Translations = { copyright: 'Copyright', paymentIcons: 'Վճարման icon-ներ', socialLinks: 'Սոցիալական հղումներ', - staticPages: 'Ստատիկ էջեր', + createPage: 'Ստեղծել էջ', + deletePage: 'Ջնջել էջը', + pageId: 'Էջի ID', + slug: 'Slug', + iconLabel: 'Իկոնա', + showInFooter: 'Ցույց տալ footer-ում', + showInHeader: 'Ցույց տալ header-ում', + showInSitemap: 'Ցույց տալ sitemap-ում', + requiresAuthentication: 'Պահանջում է մուտք', + footerGroup: 'Footer խումբ', + orderLabel: 'Հերթականություն', + translationTitle: 'Թարգմանության վերնագիր', + htmlPreview: 'HTML', visible: 'Տեսանելի', layoutLabel: 'Layout', columns: 'Սյուներ', @@ -471,6 +484,12 @@ export const hy: Translations = { importError: 'Ներմուծումը ձախողվեց։ Սխալ bootstrap JSON։', featureFlags: 'Feature flags', }, + staticPages: { + notFound: 'Էջը չի գտնվել', + backHome: 'Վերադառնալ գլխավոր', + duplicateSlug: 'Կրկնվող slug-երը թույլատրելի չեն։', + emptyTitle: 'Յուրաքանչյուր էջ պետք է ունենա առնվազն մեկ վերնագիր։', + }, widgets: { unavailable: 'Վիջեթը հասանելի չէ', }, diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index f2ccc5d..5e858a2 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -407,6 +407,7 @@ export const ru: Translations = { footer: 'Футер', homepage: 'Главная страница', widgets: 'Виджеты', + staticPages: 'Статические страницы', marketplaceFeatures: 'Функции маркетплейса', preview: 'Превью', brandName: 'Название бренда', @@ -443,7 +444,19 @@ export const ru: Translations = { copyright: 'Копирайт', paymentIcons: 'Иконки оплаты', socialLinks: 'Социальные ссылки', - staticPages: 'Статические страницы', + createPage: 'Создать страницу', + deletePage: 'Удалить страницу', + pageId: 'ID страницы', + slug: 'Slug', + iconLabel: 'Иконка', + showInFooter: 'Показывать в футере', + showInHeader: 'Показывать в хедере', + showInSitemap: 'Показывать в sitemap', + requiresAuthentication: 'Требует авторизацию', + footerGroup: 'Группа футера', + orderLabel: 'Порядок', + translationTitle: 'Заголовок перевода', + htmlPreview: 'HTML', visible: 'Видимость', layoutLabel: 'Layout', columns: 'Колонки', @@ -471,6 +484,12 @@ export const ru: Translations = { importError: 'Импорт не удался. Некорректный JSON bootstrap.', featureFlags: 'Фичи-флаги', }, + staticPages: { + notFound: 'Страница не найдена', + backHome: 'На главную', + duplicateSlug: 'Дублирующиеся slug запрещены.', + emptyTitle: 'У каждой страницы должен быть хотя бы один заголовок.', + }, widgets: { unavailable: 'Виджет недоступен', }, diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index becd7e8..5a892ab 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -405,6 +405,7 @@ export interface Translations { footer: string; homepage: string; widgets: string; + staticPages: string; marketplaceFeatures: string; preview: string; brandName: string; @@ -441,7 +442,19 @@ export interface Translations { copyright: string; paymentIcons: string; socialLinks: string; - staticPages: string; + createPage: string; + deletePage: string; + pageId: string; + slug: string; + iconLabel: string; + showInFooter: string; + showInHeader: string; + showInSitemap: string; + requiresAuthentication: string; + footerGroup: string; + orderLabel: string; + translationTitle: string; + htmlPreview: string; visible: string; layoutLabel: string; columns: string; @@ -469,6 +482,12 @@ export interface Translations { importError: string; featureFlags: string; }; + staticPages: { + notFound: string; + backHome: string; + duplicateSlug: string; + emptyTitle: string; + }; widgets: { unavailable: string; }; diff --git a/src/app/pages/static-page/static-page.component.html b/src/app/pages/static-page/static-page.component.html index 3e528f9..05da3fc 100644 --- a/src/app/pages/static-page/static-page.component.html +++ b/src/app/pages/static-page/static-page.component.html @@ -1,11 +1,11 @@ -
+
@if (loading()) {
{{ 'app.connecting' | translate }}
} @else if (notFound()) {

404

-

Страница не найдена

- На главную +

{{ 'staticPages.notFound' | translate }}

+ {{ 'staticPages.backHome' | translate }}
} @else {
diff --git a/src/app/pages/static-page/static-page.component.ts b/src/app/pages/static-page/static-page.component.ts index fe7c9bf..cb1f009 100644 --- a/src/app/pages/static-page/static-page.component.ts +++ b/src/app/pages/static-page/static-page.component.ts @@ -1,7 +1,8 @@ -import { ChangeDetectionStrategy, Component, SecurityContext, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, SecurityContext, effect, inject, signal } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, RouterLink } from '@angular/router'; -import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { DomSanitizer, Meta, SafeHtml, Title } from '@angular/platform-browser'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { StaticPageResolverService } from '../../core/config/static-page-resolver.service'; import { LanguageService } from '../../services/language.service'; import { TranslatePipe } from '../../i18n/translate.pipe'; @@ -16,29 +17,50 @@ import { TranslatePipe } from '../../i18n/translate.pipe'; }) export class StaticPageComponent { private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); private readonly sanitizer = inject(DomSanitizer); private readonly staticPageResolver = inject(StaticPageResolverService); private readonly languageService = inject(LanguageService); + private readonly titleService = inject(Title); + private readonly meta = inject(Meta); readonly loading = signal(true); readonly notFound = signal(false); readonly title = signal(''); readonly homeRoute = signal(''); + readonly dir = signal<'ltr' | 'rtl'>('ltr'); readonly safeHtml = signal(this.sanitizer.bypassSecurityTrustHtml('')); - constructor() { - this.homeRoute.set(`/${this.languageService.currentLanguage()}`); + private lastKey: string | null = null; + private lastPath: string | null = null; - this.route.paramMap.subscribe(params => { + constructor() { + effect(() => { + const lang = this.languageService.currentLanguage(); + this.homeRoute.set(`/${lang}`); + this.dir.set(['ar', 'fa', 'he', 'ur'].includes(lang) ? 'rtl' : 'ltr'); + + if (this.lastKey) { + this.loadByKey(this.lastKey); + } else if (this.lastPath) { + this.loadByPath(this.lastPath); + } + }); + + this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(params => { const keyFromParam = params.get('key'); const staticPath = params.get('staticPath'); if (keyFromParam) { + this.lastKey = keyFromParam; + this.lastPath = null; this.loadByKey(keyFromParam); return; } if (staticPath) { + this.lastKey = null; + this.lastPath = `/${staticPath}`; this.loadByPath(`/${staticPath}`); return; } @@ -72,6 +94,7 @@ export class StaticPageComponent { this.safeHtml.set(this.sanitizer.bypassSecurityTrustHtml('')); this.notFound.set(true); this.loading.set(false); + this.titleService.setTitle('404'); return; } @@ -80,5 +103,7 @@ export class StaticPageComponent { this.safeHtml.set(this.sanitizer.bypassSecurityTrustHtml(sanitized)); this.notFound.set(false); this.loading.set(false); + this.titleService.setTitle(title); + this.meta.updateTag({ name: 'description', content: title }); } } diff --git a/src/app/shared/models/config/static-page.model.ts b/src/app/shared/models/config/static-page.model.ts index bb0a799..65ebabe 100644 --- a/src/app/shared/models/config/static-page.model.ts +++ b/src/app/shared/models/config/static-page.model.ts @@ -6,16 +6,53 @@ export interface LocalizedTextContent { [locale: string]: string; } -export interface StaticPageConfig { - route: string; - title: LocalizedTextContent; - content: LocalizedHtmlContent; +export interface StaticPageSeoConfig { + title?: string | LocalizedTextContent; + description?: string | LocalizedTextContent; + keywords?: string; + canonical?: string; + ogTitle?: string | LocalizedTextContent; + ogDescription?: string | LocalizedTextContent; + ogImage?: string; + robots?: string; +} + +export interface StaticPageTranslationConfig { + title?: string; + html?: string; seo?: { - title?: LocalizedTextContent; - description?: LocalizedTextContent; - robots?: string; + title?: string; + description?: string; + keywords?: string; + canonical?: string; + ogTitle?: string; + ogDescription?: string; + ogImage?: string; }; +} + +export interface StaticPageConfig { + id: string; + slug: string; + title: string | LocalizedTextContent; + showInFooter?: boolean; + showInHeader?: boolean; + showInSitemap?: boolean; + icon?: string; + order?: number; + visibility?: { + desktop?: boolean; + tablet?: boolean; + mobile?: boolean; + }; + requiresAuthentication?: boolean; + footerGroup?: string; + translations?: Record; + html?: string | LocalizedHtmlContent; + seo?: StaticPageSeoConfig; visible?: boolean; + route?: string; + content?: LocalizedHtmlContent; } export interface LegacyStaticPageConfig { @@ -30,7 +67,21 @@ export type StaticPagesConfig = Record | LegacyStaticP export interface ResolvedStaticPage { key: string; + id: string; + slug: string; route: string; title: string; html: string; + icon?: string; + requiresAuthentication?: boolean; + seo?: { + title?: string; + description?: string; + keywords?: string; + canonical?: string; + ogTitle?: string; + ogDescription?: string; + ogImage?: string; + robots?: string; + }; }