feat(cms): add static pages module
This commit is contained in:
154
docs/Content-Management.md
Normal file
154
docs/Content-Management.md
Normal file
@@ -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": "<h2>About</h2><p>Company story</p>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"seo": {
|
||||||
|
"title": { "en": "About Us" },
|
||||||
|
"description": { "en": "About our marketplace" },
|
||||||
|
"canonical": "/about"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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. Система навигации
|
# 11. Система навигации
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
|
|||||||
@@ -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. Система статических страниц
|
# 12. Система статических страниц
|
||||||
|
|
||||||
## Назначение
|
## Назначение
|
||||||
|
|||||||
@@ -14,7 +14,11 @@
|
|||||||
<ul>
|
<ul>
|
||||||
@for (item of group.items; track item.id) {
|
@for (item of group.items; track item.id) {
|
||||||
<li>
|
<li>
|
||||||
|
@if (item.external) {
|
||||||
|
<a [href]="item.route" target="_blank" rel="noopener">{{ item.label | translate }}</a>
|
||||||
|
} @else {
|
||||||
<a [routerLink]="item.route | langRoute">{{ item.label | translate }}</a>
|
<a [routerLink]="item.route | langRoute">{{ item.label | translate }}</a>
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -20,7 +20,11 @@
|
|||||||
{{ 'header.catalog' | translate }}
|
{{ 'header.catalog' | translate }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<!-- TODO(CMS): Render backend-configured header content links here. -->
|
@for (page of headerPages(); track page.id) {
|
||||||
|
<button type="button" (click)="navigateToStatic(page.route)" class="platform-nav-btn platform-nav-btn-left">
|
||||||
|
{{ page.title }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -140,7 +144,14 @@
|
|||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- TODO(CMS): Render backend-configured mobile content links here. -->
|
@for (page of headerPages(); track page.id) {
|
||||||
|
<a (click)="navigateToStatic(page.route)" class="platform-mobile-item" style="cursor: pointer;">
|
||||||
|
<span>{{ page.title }}</span>
|
||||||
|
<svg class="platform-mobile-chevron" width="8" height="14" viewBox="0 0 8 14" fill="none">
|
||||||
|
<path d="M1 1L7 7L1 13" stroke="#697777" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="platform-mobile-controls">
|
<div class="platform-mobile-controls">
|
||||||
@if (headerConfig().showRegion) {
|
@if (headerConfig().showRegion) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
|
|||||||
import { UserExperienceFacade } from '../../facades/platform/user-experience.facade';
|
import { UserExperienceFacade } from '../../facades/platform/user-experience.facade';
|
||||||
import { ConfigService } from '../../core/config/config.service';
|
import { ConfigService } from '../../core/config/config.service';
|
||||||
import { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
|
import { DEFAULT_HEADER_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
|
||||||
|
import { StaticPageResolverService } from '../../core/config/static-page-resolver.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-header',
|
selector: 'app-header',
|
||||||
@@ -30,11 +31,13 @@ export class HeaderComponent {
|
|||||||
private uiRuntime = inject(UiRuntimeFacade);
|
private uiRuntime = inject(UiRuntimeFacade);
|
||||||
private uxFacade = inject(UserExperienceFacade);
|
private uxFacade = inject(UserExperienceFacade);
|
||||||
private configService = inject(ConfigService);
|
private configService = inject(ConfigService);
|
||||||
|
private staticPageResolver = inject(StaticPageResolverService);
|
||||||
|
|
||||||
readonly wishlistCount = this.uxFacade.wishlistCount;
|
readonly wishlistCount = this.uxFacade.wishlistCount;
|
||||||
readonly compareCount = this.uxFacade.compareCount;
|
readonly compareCount = this.uxFacade.compareCount;
|
||||||
readonly userExperienceConfig = computed(() => this.resolveUserExperienceConfig());
|
readonly userExperienceConfig = computed(() => this.resolveUserExperienceConfig());
|
||||||
readonly headerConfig = computed(() => this.resolveHeaderConfig());
|
readonly headerConfig = computed(() => this.resolveHeaderConfig());
|
||||||
|
readonly headerPages = computed(() => this.resolveHeaderPages());
|
||||||
|
|
||||||
constructor(private cartService: CartService, private router: Router) {
|
constructor(private cartService: CartService, private router: Router) {
|
||||||
this.cartItemCount = this.cartService.itemCount;
|
this.cartItemCount = this.cartService.itemCount;
|
||||||
@@ -111,6 +114,12 @@ export class HeaderComponent {
|
|||||||
this.router.navigate([`/${lang}/compare`]);
|
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 {
|
formatCartTotal(total: number): string {
|
||||||
const locale = this.langService.currentLanguage() === 'en'
|
const locale = this.langService.currentLanguage() === 'en'
|
||||||
? 'en-US'
|
? 'en-US'
|
||||||
@@ -154,6 +163,31 @@ export class HeaderComponent {
|
|||||||
...raw,
|
...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<string, any>)
|
||||||
|
.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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface FooterResolvedItem {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
route: string;
|
route: string;
|
||||||
|
external?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FooterResolvedGroup {
|
export interface FooterResolvedGroup {
|
||||||
@@ -62,33 +63,17 @@ export class FooterResolverService {
|
|||||||
private resolveFooterGroups(bootstrap: BootstrapConfig): FooterResolvedGroup[] {
|
private resolveFooterGroups(bootstrap: BootstrapConfig): FooterResolvedGroup[] {
|
||||||
const footer = bootstrap.navigation?.footer ?? [];
|
const footer = bootstrap.navigation?.footer ?? [];
|
||||||
const lang = this.languageService.currentLanguage();
|
const lang = this.languageService.currentLanguage();
|
||||||
const legalKeys = bootstrap.footer?.legalPageKeys ?? [];
|
const contentPages = this.resolveBootstrapStaticPages(bootstrap, lang);
|
||||||
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);
|
|
||||||
|
|
||||||
if (this.isGroupedFooter(footer)) {
|
if (this.isGroupedFooter(footer)) {
|
||||||
const groups = footer
|
const groups = footer
|
||||||
.map((group, index) => this.resolveGroupFromConfig(group, bootstrap, lang, index))
|
.map((group, index) => this.resolveGroupFromConfig(group, bootstrap, lang, index))
|
||||||
.filter((group): group is FooterResolvedGroup => group != null && group.items.length > 0);
|
.filter((group): group is FooterResolvedGroup => group != null && group.items.length > 0);
|
||||||
|
|
||||||
if (legalItems.length > 0) {
|
for (const group of contentPages) {
|
||||||
groups.push({
|
if (!groups.some(existing => existing.id === group.id)) {
|
||||||
id: 'footer-group-legal',
|
groups.push(group);
|
||||||
title: 'Legal',
|
}
|
||||||
items: legalItems
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
@@ -99,9 +84,7 @@ export class FooterResolverService {
|
|||||||
.filter((item): item is FooterResolvedItem => item != null);
|
.filter((item): item is FooterResolvedItem => item != null);
|
||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
return legalItems.length
|
return contentPages;
|
||||||
? [{ id: 'footer-group-legal', title: 'Legal', items: legalItems }]
|
|
||||||
: [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups: FooterResolvedGroup[] = [{
|
const groups: FooterResolvedGroup[] = [{
|
||||||
@@ -110,15 +93,44 @@ export class FooterResolverService {
|
|||||||
items
|
items
|
||||||
}];
|
}];
|
||||||
|
|
||||||
if (legalItems.length > 0) {
|
groups.push(...contentPages);
|
||||||
groups.push({
|
|
||||||
id: 'footer-group-legal',
|
return groups;
|
||||||
title: 'Legal',
|
}
|
||||||
items: legalItems
|
|
||||||
|
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<string, FooterResolvedGroup>();
|
||||||
|
|
||||||
|
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(
|
private resolveGroupFromConfig(
|
||||||
@@ -213,14 +225,8 @@ export class FooterResolverService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolvePaymentIcons(footerConfig: FooterConfig | undefined): FooterPaymentIcon[] {
|
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) {
|
if (!footerConfig?.paymentIcons?.length) {
|
||||||
return fallback;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return footerConfig.paymentIcons
|
return footerConfig.paymentIcons
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ import { Injectable } from '@angular/core';
|
|||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { map } from 'rxjs/operators';
|
import { map } from 'rxjs/operators';
|
||||||
import { ConfigService } from './config.service';
|
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 { LanguageService } from '../../services/language.service';
|
||||||
|
import { ContentPageService } from '../../features/content-management/services/content-page.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class StaticPageResolverService {
|
export class StaticPageResolverService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly languageService: LanguageService
|
private readonly languageService: LanguageService,
|
||||||
|
private readonly contentPageService: ContentPageService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
resolveByKey(key: string, lang?: string): Observable<ResolvedStaticPage | null> {
|
resolveByKey(key: string, lang?: string): Observable<ResolvedStaticPage | null> {
|
||||||
@@ -25,40 +27,11 @@ export class StaticPageResolverService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolveByKeyFromBootstrap(bootstrap: BootstrapConfig, key: string, lang: string): ResolvedStaticPage | null {
|
resolveByKeyFromBootstrap(bootstrap: BootstrapConfig, key: string, lang: string): ResolvedStaticPage | null {
|
||||||
const page = this.getPageByKey(bootstrap, key);
|
return this.contentPageService.resolvePage(bootstrap, key, lang);
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveByRouteFromBootstrap(bootstrap: BootstrapConfig, route: string, lang: string): ResolvedStaticPage | null {
|
resolveByRouteFromBootstrap(bootstrap: BootstrapConfig, route: string, lang: string): ResolvedStaticPage | null {
|
||||||
const normalized = this.normalizeRoute(route);
|
return this.contentPageService.resolvePage(bootstrap, this.normalizeRoute(route).replace(/^\//, ''), lang);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveFooterTitle(groupTitle: string | LocalizedTextContent | undefined, lang: string): string {
|
resolveFooterTitle(groupTitle: string | LocalizedTextContent | undefined, lang: string): string {
|
||||||
@@ -73,65 +46,6 @@ export class StaticPageResolverService {
|
|||||||
return this.resolveLocalizedText(groupTitle, lang, '');
|
return this.resolveLocalizedText(groupTitle, lang, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRegistry(bootstrap: BootstrapConfig): Record<string, StaticPageConfig> {
|
|
||||||
const raw = bootstrap.staticPages;
|
|
||||||
if (!raw) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(raw)) {
|
|
||||||
return raw.reduce<Record<string, StaticPageConfig>>((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 {
|
private resolveLocalizedText(text: LocalizedTextContent | undefined, lang: string, fallback: string): string {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return fallback;
|
return fallback;
|
||||||
@@ -140,14 +54,6 @@ export class StaticPageResolverService {
|
|||||||
return text[lang] ?? text['en'] ?? Object.values(text)[0] ?? fallback;
|
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 {
|
private normalizeRoute(route: string): string {
|
||||||
if (!route) {
|
if (!route) {
|
||||||
return '/';
|
return '/';
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<section class="editor-section-card">
|
||||||
|
<div class="editor-actions">
|
||||||
|
<h2>{{ 'builder.staticPages' | translate }}</h2>
|
||||||
|
<button type="button" (click)="createPage()">{{ 'builder.createPage' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (validation().duplicateSlugs.length > 0) {
|
||||||
|
<p class="editor-error">{{ 'staticPages.duplicateSlug' | translate }}</p>
|
||||||
|
}
|
||||||
|
@if (validation().emptyTitles.length > 0) {
|
||||||
|
<p class="editor-error">{{ 'staticPages.emptyTitle' | translate }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="stack-list">
|
||||||
|
@for (page of pages(); track page.id) {
|
||||||
|
<article class="sub-card">
|
||||||
|
<div class="editor-actions">
|
||||||
|
<h3>{{ page.id }}</h3>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button type="button" class="secondary" (click)="move(page.id, -1)">↑</button>
|
||||||
|
<button type="button" class="secondary" (click)="move(page.id, 1)">↓</button>
|
||||||
|
<button type="button" class="secondary" (click)="deletePage(page.id)">{{ 'builder.deletePage' | translate }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-grid three">
|
||||||
|
<label><span>{{ 'builder.pageId' | translate }}</span><input type="text" [ngModel]="page.id" (ngModelChange)="updatePage(page.id, { id: $event })" /></label>
|
||||||
|
<label><span>{{ 'builder.slug' | translate }}</span><input type="text" [ngModel]="page.slug" (ngModelChange)="updatePage(page.id, { slug: $event })" /></label>
|
||||||
|
<label><span>{{ 'builder.iconLabel' | translate }}</span><input type="text" [ngModel]="page.icon || ''" (ngModelChange)="updatePage(page.id, { icon: $event })" /></label>
|
||||||
|
<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>
|
||||||
|
<label><span>{{ 'builder.footerGroup' | translate }}</span><input type="text" [ngModel]="page.footerGroup || ''" (ngModelChange)="updatePage(page.id, { footerGroup: $event })" /></label>
|
||||||
|
<label><span>{{ 'builder.orderLabel' | translate }}</span><input type="number" [ngModel]="page.order" (ngModelChange)="updatePage(page.id, { order: +$event })" /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@for (locale of ['en','ru','hy']; track locale) {
|
||||||
|
<div class="editor-grid two">
|
||||||
|
<label>
|
||||||
|
<span>{{ 'builder.translationTitle' | translate }} {{ locale }}</span>
|
||||||
|
<input type="text" [ngModel]="page.translations[locale]?.title || ''" (ngModelChange)="updateTranslation(page.id, locale, 'title', $event)" />
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
<span>{{ 'builder.htmlPreview' | translate }} {{ locale }}</span>
|
||||||
|
<textarea rows="6" [ngModel]="page.translations[locale]?.html || ''" (ngModelChange)="updateTranslation(page.id, locale, 'html', $event)"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -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<ContentPage>): 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<Record<string, any>>((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;
|
||||||
|
}, {})
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)) : {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, ContentPageTranslation>;
|
||||||
|
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<string, ContentPageTranslation>;
|
||||||
|
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 }>; };
|
||||||
|
}
|
||||||
@@ -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: `<app-static-pages-editor />`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class ContentManagementPageComponent {}
|
||||||
@@ -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<string>();
|
||||||
|
const duplicateSlugs = new Set<string>();
|
||||||
|
const emptyTitles = new Set<string>();
|
||||||
|
|
||||||
|
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<string, StaticPageConfig> {
|
||||||
|
return pages.reduce<Record<string, StaticPageConfig>>((acc, page) => {
|
||||||
|
const htmlMap = Object.entries(page.translations).reduce<LocalizedHtmlContent>((result, [locale, translation]) => {
|
||||||
|
if ((translation.html ?? '').trim()) {
|
||||||
|
result[locale] = translation.html!;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const titleMap = Object.entries(page.translations).reduce<LocalizedTextContent>((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<string, { title?: string; html?: string }> {
|
||||||
|
const translations: Record<string, { title?: string; html?: string }> = {};
|
||||||
|
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;
|
||||||
@@ -22,6 +22,7 @@ export class ProjectEditorNavComponent {
|
|||||||
{ id: 'footer', label: 'builder.footer' },
|
{ id: 'footer', label: 'builder.footer' },
|
||||||
{ id: 'homepage', label: 'builder.homepage' },
|
{ id: 'homepage', label: 'builder.homepage' },
|
||||||
{ id: 'widgets', label: 'builder.widgets' },
|
{ id: 'widgets', label: 'builder.widgets' },
|
||||||
|
{ id: 'static-pages', label: 'builder.staticPages' },
|
||||||
{ id: 'features', label: 'builder.marketplaceFeatures' },
|
{ id: 'features', label: 'builder.marketplaceFeatures' },
|
||||||
{ id: 'preview', label: 'builder.preview' },
|
{ id: 'preview', label: 'builder.preview' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type ProjectEditorSectionId =
|
|||||||
| 'footer'
|
| 'footer'
|
||||||
| 'homepage'
|
| 'homepage'
|
||||||
| 'widgets'
|
| 'widgets'
|
||||||
|
| 'static-pages'
|
||||||
| 'features'
|
| 'features'
|
||||||
| 'preview';
|
| 'preview';
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
@case ('footer') { <app-project-editor-footer-section /> }
|
@case ('footer') { <app-project-editor-footer-section /> }
|
||||||
@case ('homepage') { <app-project-editor-homepage-section /> }
|
@case ('homepage') { <app-project-editor-homepage-section /> }
|
||||||
@case ('widgets') { <app-project-editor-widgets-section /> }
|
@case ('widgets') { <app-project-editor-widgets-section /> }
|
||||||
|
@case ('static-pages') { <app-static-pages-editor /> }
|
||||||
@case ('features') { <app-project-editor-features-section /> }
|
@case ('features') { <app-project-editor-features-section /> }
|
||||||
@case ('preview') { <app-project-editor-preview-section /> }
|
@case ('preview') { <app-project-editor-preview-section /> }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ProjectEditorWidgetsSectionComponent } from '../sections/widgets-sectio
|
|||||||
import { ProjectEditorFeaturesSectionComponent } from '../sections/features-section.component';
|
import { ProjectEditorFeaturesSectionComponent } from '../sections/features-section.component';
|
||||||
import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component';
|
import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component';
|
||||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||||
|
import { StaticPagesEditorComponent } from '../../content-management/components/static-pages-editor.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-project-editor-page',
|
selector: 'app-project-editor-page',
|
||||||
@@ -25,6 +26,7 @@ import { TranslatePipe } from '../../../i18n/translate.pipe';
|
|||||||
ProjectEditorFooterSectionComponent,
|
ProjectEditorFooterSectionComponent,
|
||||||
ProjectEditorHomepageSectionComponent,
|
ProjectEditorHomepageSectionComponent,
|
||||||
ProjectEditorWidgetsSectionComponent,
|
ProjectEditorWidgetsSectionComponent,
|
||||||
|
StaticPagesEditorComponent,
|
||||||
ProjectEditorFeaturesSectionComponent,
|
ProjectEditorFeaturesSectionComponent,
|
||||||
ProjectEditorPreviewSectionComponent,
|
ProjectEditorPreviewSectionComponent,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ export const en: Translations = {
|
|||||||
footer: 'Footer',
|
footer: 'Footer',
|
||||||
homepage: 'Homepage',
|
homepage: 'Homepage',
|
||||||
widgets: 'Widgets',
|
widgets: 'Widgets',
|
||||||
|
staticPages: 'Static Pages',
|
||||||
marketplaceFeatures: 'Marketplace Features',
|
marketplaceFeatures: 'Marketplace Features',
|
||||||
preview: 'Preview',
|
preview: 'Preview',
|
||||||
brandName: 'Brand Name',
|
brandName: 'Brand Name',
|
||||||
@@ -443,7 +444,19 @@ export const en: Translations = {
|
|||||||
copyright: 'Copyright',
|
copyright: 'Copyright',
|
||||||
paymentIcons: 'Payment Icons',
|
paymentIcons: 'Payment Icons',
|
||||||
socialLinks: 'Social Links',
|
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',
|
visible: 'Visible',
|
||||||
layoutLabel: 'Layout',
|
layoutLabel: 'Layout',
|
||||||
columns: 'Columns',
|
columns: 'Columns',
|
||||||
@@ -471,6 +484,12 @@ export const en: Translations = {
|
|||||||
importError: 'Import failed. Invalid bootstrap JSON.',
|
importError: 'Import failed. Invalid bootstrap JSON.',
|
||||||
featureFlags: 'Feature Flags',
|
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: {
|
widgets: {
|
||||||
unavailable: 'Widget unavailable',
|
unavailable: 'Widget unavailable',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ export const hy: Translations = {
|
|||||||
footer: 'Footer',
|
footer: 'Footer',
|
||||||
homepage: 'Գլխավոր էջ',
|
homepage: 'Գլխավոր էջ',
|
||||||
widgets: 'Վիջեթներ',
|
widgets: 'Վիջեթներ',
|
||||||
|
staticPages: 'Ստատիկ էջեր',
|
||||||
marketplaceFeatures: 'Մարքեթփլեյսի հնարավորություններ',
|
marketplaceFeatures: 'Մարքեթփլեյսի հնարավորություններ',
|
||||||
preview: 'Preview',
|
preview: 'Preview',
|
||||||
brandName: 'Բրենդի անվանում',
|
brandName: 'Բրենդի անվանում',
|
||||||
@@ -443,7 +444,19 @@ export const hy: Translations = {
|
|||||||
copyright: 'Copyright',
|
copyright: 'Copyright',
|
||||||
paymentIcons: 'Վճարման icon-ներ',
|
paymentIcons: 'Վճարման icon-ներ',
|
||||||
socialLinks: 'Սոցիալական հղումներ',
|
socialLinks: 'Սոցիալական հղումներ',
|
||||||
staticPages: 'Ստատիկ էջեր',
|
createPage: 'Ստեղծել էջ',
|
||||||
|
deletePage: 'Ջնջել էջը',
|
||||||
|
pageId: 'Էջի ID',
|
||||||
|
slug: 'Slug',
|
||||||
|
iconLabel: 'Իկոնա',
|
||||||
|
showInFooter: 'Ցույց տալ footer-ում',
|
||||||
|
showInHeader: 'Ցույց տալ header-ում',
|
||||||
|
showInSitemap: 'Ցույց տալ sitemap-ում',
|
||||||
|
requiresAuthentication: 'Պահանջում է մուտք',
|
||||||
|
footerGroup: 'Footer խումբ',
|
||||||
|
orderLabel: 'Հերթականություն',
|
||||||
|
translationTitle: 'Թարգմանության վերնագիր',
|
||||||
|
htmlPreview: 'HTML',
|
||||||
visible: 'Տեսանելի',
|
visible: 'Տեսանելի',
|
||||||
layoutLabel: 'Layout',
|
layoutLabel: 'Layout',
|
||||||
columns: 'Սյուներ',
|
columns: 'Սյուներ',
|
||||||
@@ -471,6 +484,12 @@ export const hy: Translations = {
|
|||||||
importError: 'Ներմուծումը ձախողվեց։ Սխալ bootstrap JSON։',
|
importError: 'Ներմուծումը ձախողվեց։ Սխալ bootstrap JSON։',
|
||||||
featureFlags: 'Feature flags',
|
featureFlags: 'Feature flags',
|
||||||
},
|
},
|
||||||
|
staticPages: {
|
||||||
|
notFound: 'Էջը չի գտնվել',
|
||||||
|
backHome: 'Վերադառնալ գլխավոր',
|
||||||
|
duplicateSlug: 'Կրկնվող slug-երը թույլատրելի չեն։',
|
||||||
|
emptyTitle: 'Յուրաքանչյուր էջ պետք է ունենա առնվազն մեկ վերնագիր։',
|
||||||
|
},
|
||||||
widgets: {
|
widgets: {
|
||||||
unavailable: 'Վիջեթը հասանելի չէ',
|
unavailable: 'Վիջեթը հասանելի չէ',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ export const ru: Translations = {
|
|||||||
footer: 'Футер',
|
footer: 'Футер',
|
||||||
homepage: 'Главная страница',
|
homepage: 'Главная страница',
|
||||||
widgets: 'Виджеты',
|
widgets: 'Виджеты',
|
||||||
|
staticPages: 'Статические страницы',
|
||||||
marketplaceFeatures: 'Функции маркетплейса',
|
marketplaceFeatures: 'Функции маркетплейса',
|
||||||
preview: 'Превью',
|
preview: 'Превью',
|
||||||
brandName: 'Название бренда',
|
brandName: 'Название бренда',
|
||||||
@@ -443,7 +444,19 @@ export const ru: Translations = {
|
|||||||
copyright: 'Копирайт',
|
copyright: 'Копирайт',
|
||||||
paymentIcons: 'Иконки оплаты',
|
paymentIcons: 'Иконки оплаты',
|
||||||
socialLinks: 'Социальные ссылки',
|
socialLinks: 'Социальные ссылки',
|
||||||
staticPages: 'Статические страницы',
|
createPage: 'Создать страницу',
|
||||||
|
deletePage: 'Удалить страницу',
|
||||||
|
pageId: 'ID страницы',
|
||||||
|
slug: 'Slug',
|
||||||
|
iconLabel: 'Иконка',
|
||||||
|
showInFooter: 'Показывать в футере',
|
||||||
|
showInHeader: 'Показывать в хедере',
|
||||||
|
showInSitemap: 'Показывать в sitemap',
|
||||||
|
requiresAuthentication: 'Требует авторизацию',
|
||||||
|
footerGroup: 'Группа футера',
|
||||||
|
orderLabel: 'Порядок',
|
||||||
|
translationTitle: 'Заголовок перевода',
|
||||||
|
htmlPreview: 'HTML',
|
||||||
visible: 'Видимость',
|
visible: 'Видимость',
|
||||||
layoutLabel: 'Layout',
|
layoutLabel: 'Layout',
|
||||||
columns: 'Колонки',
|
columns: 'Колонки',
|
||||||
@@ -471,6 +484,12 @@ export const ru: Translations = {
|
|||||||
importError: 'Импорт не удался. Некорректный JSON bootstrap.',
|
importError: 'Импорт не удался. Некорректный JSON bootstrap.',
|
||||||
featureFlags: 'Фичи-флаги',
|
featureFlags: 'Фичи-флаги',
|
||||||
},
|
},
|
||||||
|
staticPages: {
|
||||||
|
notFound: 'Страница не найдена',
|
||||||
|
backHome: 'На главную',
|
||||||
|
duplicateSlug: 'Дублирующиеся slug запрещены.',
|
||||||
|
emptyTitle: 'У каждой страницы должен быть хотя бы один заголовок.',
|
||||||
|
},
|
||||||
widgets: {
|
widgets: {
|
||||||
unavailable: 'Виджет недоступен',
|
unavailable: 'Виджет недоступен',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -405,6 +405,7 @@ export interface Translations {
|
|||||||
footer: string;
|
footer: string;
|
||||||
homepage: string;
|
homepage: string;
|
||||||
widgets: string;
|
widgets: string;
|
||||||
|
staticPages: string;
|
||||||
marketplaceFeatures: string;
|
marketplaceFeatures: string;
|
||||||
preview: string;
|
preview: string;
|
||||||
brandName: string;
|
brandName: string;
|
||||||
@@ -441,7 +442,19 @@ export interface Translations {
|
|||||||
copyright: string;
|
copyright: string;
|
||||||
paymentIcons: string;
|
paymentIcons: string;
|
||||||
socialLinks: 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;
|
visible: string;
|
||||||
layoutLabel: string;
|
layoutLabel: string;
|
||||||
columns: string;
|
columns: string;
|
||||||
@@ -469,6 +482,12 @@ export interface Translations {
|
|||||||
importError: string;
|
importError: string;
|
||||||
featureFlags: string;
|
featureFlags: string;
|
||||||
};
|
};
|
||||||
|
staticPages: {
|
||||||
|
notFound: string;
|
||||||
|
backHome: string;
|
||||||
|
duplicateSlug: string;
|
||||||
|
emptyTitle: string;
|
||||||
|
};
|
||||||
widgets: {
|
widgets: {
|
||||||
unavailable: string;
|
unavailable: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<main class="static-page page-container">
|
<main class="static-page page-container" [attr.dir]="dir()">
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<section class="static-page__state">{{ 'app.connecting' | translate }}</section>
|
<section class="static-page__state">{{ 'app.connecting' | translate }}</section>
|
||||||
} @else if (notFound()) {
|
} @else if (notFound()) {
|
||||||
<section class="static-page__state">
|
<section class="static-page__state">
|
||||||
<h1>404</h1>
|
<h1>404</h1>
|
||||||
<p>Страница не найдена</p>
|
<p>{{ 'staticPages.notFound' | translate }}</p>
|
||||||
<a [routerLink]="homeRoute()">На главную</a>
|
<a [routerLink]="homeRoute()">{{ 'staticPages.backHome' | translate }}</a>
|
||||||
</section>
|
</section>
|
||||||
} @else {
|
} @else {
|
||||||
<article class="static-page__content">
|
<article class="static-page__content">
|
||||||
|
|||||||
@@ -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 { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
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 { StaticPageResolverService } from '../../core/config/static-page-resolver.service';
|
||||||
import { LanguageService } from '../../services/language.service';
|
import { LanguageService } from '../../services/language.service';
|
||||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||||
@@ -16,29 +17,50 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
|
|||||||
})
|
})
|
||||||
export class StaticPageComponent {
|
export class StaticPageComponent {
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly sanitizer = inject(DomSanitizer);
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
private readonly staticPageResolver = inject(StaticPageResolverService);
|
private readonly staticPageResolver = inject(StaticPageResolverService);
|
||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
|
private readonly titleService = inject(Title);
|
||||||
|
private readonly meta = inject(Meta);
|
||||||
|
|
||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
readonly notFound = signal(false);
|
readonly notFound = signal(false);
|
||||||
readonly title = signal('');
|
readonly title = signal('');
|
||||||
readonly homeRoute = signal('');
|
readonly homeRoute = signal('');
|
||||||
|
readonly dir = signal<'ltr' | 'rtl'>('ltr');
|
||||||
readonly safeHtml = signal<SafeHtml>(this.sanitizer.bypassSecurityTrustHtml(''));
|
readonly safeHtml = signal<SafeHtml>(this.sanitizer.bypassSecurityTrustHtml(''));
|
||||||
|
|
||||||
constructor() {
|
private lastKey: string | null = null;
|
||||||
this.homeRoute.set(`/${this.languageService.currentLanguage()}`);
|
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 keyFromParam = params.get('key');
|
||||||
const staticPath = params.get('staticPath');
|
const staticPath = params.get('staticPath');
|
||||||
|
|
||||||
if (keyFromParam) {
|
if (keyFromParam) {
|
||||||
|
this.lastKey = keyFromParam;
|
||||||
|
this.lastPath = null;
|
||||||
this.loadByKey(keyFromParam);
|
this.loadByKey(keyFromParam);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (staticPath) {
|
if (staticPath) {
|
||||||
|
this.lastKey = null;
|
||||||
|
this.lastPath = `/${staticPath}`;
|
||||||
this.loadByPath(`/${staticPath}`);
|
this.loadByPath(`/${staticPath}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -72,6 +94,7 @@ export class StaticPageComponent {
|
|||||||
this.safeHtml.set(this.sanitizer.bypassSecurityTrustHtml(''));
|
this.safeHtml.set(this.sanitizer.bypassSecurityTrustHtml(''));
|
||||||
this.notFound.set(true);
|
this.notFound.set(true);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
this.titleService.setTitle('404');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,5 +103,7 @@ export class StaticPageComponent {
|
|||||||
this.safeHtml.set(this.sanitizer.bypassSecurityTrustHtml(sanitized));
|
this.safeHtml.set(this.sanitizer.bypassSecurityTrustHtml(sanitized));
|
||||||
this.notFound.set(false);
|
this.notFound.set(false);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
|
this.titleService.setTitle(title);
|
||||||
|
this.meta.updateTag({ name: 'description', content: title });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,53 @@ export interface LocalizedTextContent {
|
|||||||
[locale: string]: string;
|
[locale: string]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StaticPageConfig {
|
export interface StaticPageSeoConfig {
|
||||||
route: string;
|
title?: string | LocalizedTextContent;
|
||||||
title: LocalizedTextContent;
|
description?: string | LocalizedTextContent;
|
||||||
content: LocalizedHtmlContent;
|
keywords?: string;
|
||||||
seo?: {
|
canonical?: string;
|
||||||
title?: LocalizedTextContent;
|
ogTitle?: string | LocalizedTextContent;
|
||||||
description?: LocalizedTextContent;
|
ogDescription?: string | LocalizedTextContent;
|
||||||
|
ogImage?: string;
|
||||||
robots?: string;
|
robots?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaticPageTranslationConfig {
|
||||||
|
title?: string;
|
||||||
|
html?: string;
|
||||||
|
seo?: {
|
||||||
|
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<string, StaticPageTranslationConfig>;
|
||||||
|
html?: string | LocalizedHtmlContent;
|
||||||
|
seo?: StaticPageSeoConfig;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
|
route?: string;
|
||||||
|
content?: LocalizedHtmlContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LegacyStaticPageConfig {
|
export interface LegacyStaticPageConfig {
|
||||||
@@ -30,7 +67,21 @@ export type StaticPagesConfig = Record<string, StaticPageConfig> | LegacyStaticP
|
|||||||
|
|
||||||
export interface ResolvedStaticPage {
|
export interface ResolvedStaticPage {
|
||||||
key: string;
|
key: string;
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
route: string;
|
route: string;
|
||||||
title: string;
|
title: string;
|
||||||
html: string;
|
html: string;
|
||||||
|
icon?: string;
|
||||||
|
requiresAuthentication?: boolean;
|
||||||
|
seo?: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
keywords?: string;
|
||||||
|
canonical?: string;
|
||||||
|
ogTitle?: string;
|
||||||
|
ogDescription?: string;
|
||||||
|
ogImage?: string;
|
||||||
|
robots?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user