11-task plan extending the existing /builder editor: route-driven tabs, generic Languages/Navigation tabs, a dependency-free rich HTML editor, client-side draft/publish + validation, and a dirty-state guard. Notes the repo has no test runner configured, so tasks use manual verification instead of automated specs.
73 KiB
Sprint 16 — Marketplace Project Editor MVP Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Extend the existing /builder Project Editor into a routed, deep-linkable editor (/edit/...) with Languages, Navigation, a real HTML editor, client-side draft/publish, validation, and dirty-state protection — all still editing the same BootstrapConfig the storefront consumes.
Architecture: Keep ProjectEditorFacade (Angular signals, no NgRx) as the single source of truth. Add new signal-backed facade actions and two new pure services (LocaleSyncService, ProjectValidator). Add new tab components following the existing sections/*-section.component.ts pattern. No new state-management library, no new npm dependency.
Tech Stack: Angular 21.1.5 standalone components, Signals, @angular/forms (FormsModule + ngModel), @angular/router, existing i18n/translate.pipe + translations.ts/en.ts/ru.ts/hy.ts dictionaries.
Global Constraints
- Angular is actually 21.1.5 in
package.json, not 20 as the platform vision doc says — this plan targets the real installed version; syntax used (@if/@for/@switch, signals) is valid in both. - No test runner is configured in this repo (no karma/jasmine/jest in
package.json, zero*.spec.tsfiles exist). Writing Jasmine/Karma spec files here would not run under any configured tool and would be a placeholder. Every task below substitutes a manual verification procedure (exactng servesteps, exact clicks/values, exact expected result) for an automated test step. Standing up a real test harness is out of scope for this plan — flagged as a follow-up, not silently skipped. - State management: signal-based facade +
computed, no NgRx, matching every existing feature (ProjectEditorFacade,AdminProductsFacade). - Every new user-facing string goes through
TranslatePipe({{ 'builder.x' | translate }}) and is added tosrc/app/i18n/translations.ts(interface) +en.ts+ru.ts+hy.ts(implementations) in the same task that introduces it. - New standalone components go in
src/app/features/project-editor/{sections,components,services,models,guards}/following existing naming (*-section.component.tsfor tabs). - No new npm dependencies are introduced by this plan.
BootstrapConfig(src/app/shared/models/config/bootstrap-config.model.ts) remains the only configuration model. No parallelProjectConfig/EditorConfigtype is created anywhere in this plan.- There is no
projectIdconcept anywhere — a "project" is the tenant resolved from the current domain. No route or model in this plan introduces an id segment.
Task 1: Route-driven tabs (/edit/:section)
Files:
- Modify:
src/app/app.routes.ts:42-50 - Modify:
src/app/features/project-editor/pages/project-editor-page.component.ts - Modify:
src/app/features/project-editor/components/project-editor-nav.component.ts - Modify:
src/app/features/project-editor/components/project-editor-nav.component.html
Interfaces:
-
Consumes:
ProjectEditorFacade.setActiveSection(id: ProjectEditorSectionId),ProjectEditorFacade.activeSection: Signal<ProjectEditorSectionId>(both already exist, unchanged). -
Produces: route path
/edit/:sectionthat every later task's new tab reuses (Languages →/edit/languages, Navigation →/edit/navigation). -
Step 1: Update routes
Replace lines 42-50 of src/app/app.routes.ts:
{
path: 'edit/:section',
loadComponent: () => import('./features/project-editor/pages/project-editor-page.component').then(m => m.ProjectEditorPageComponent)
},
{
path: 'edit',
redirectTo: 'edit/general',
pathMatch: 'full'
},
{
path: 'builder',
redirectTo: 'edit/general',
pathMatch: 'full'
},
{
path: 'project-editor',
redirectTo: 'edit/general',
pathMatch: 'full'
},
- Step 2: Read the route param in the page component
Replace the full contents of src/app/features/project-editor/pages/project-editor-page.component.ts:
import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { ProjectEditorNavComponent } from '../components/project-editor-nav.component';
import { ProjectEditorGeneralSectionComponent } from '../sections/general-section.component';
import { ProjectEditorBrandingSectionComponent } from '../sections/branding-section.component';
import { ProjectEditorThemeSectionComponent } from '../sections/theme-section.component';
import { ProjectEditorHeaderSectionComponent } from '../sections/header-section.component';
import { ProjectEditorFooterSectionComponent } from '../sections/footer-section.component';
import { ProjectEditorHomepageSectionComponent } from '../sections/homepage-section.component';
import { ProjectEditorWidgetsSectionComponent } from '../sections/widgets-section.component';
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';
import { ProjectEditorSectionId } from '../models/project-editor.model';
const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'preview'
];
@Component({
selector: 'app-project-editor-page',
standalone: true,
imports: [
TranslatePipe,
ProjectEditorNavComponent,
ProjectEditorGeneralSectionComponent,
ProjectEditorBrandingSectionComponent,
ProjectEditorThemeSectionComponent,
ProjectEditorHeaderSectionComponent,
ProjectEditorFooterSectionComponent,
ProjectEditorHomepageSectionComponent,
ProjectEditorWidgetsSectionComponent,
StaticPagesEditorComponent,
ProjectEditorFeaturesSectionComponent,
ProjectEditorPreviewSectionComponent,
],
templateUrl: './project-editor-page.component.html',
styleUrls: ['./project-editor-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorPageComponent {
readonly facade = inject(ProjectEditorFacade);
private readonly route = inject(ActivatedRoute);
readonly bootstrap = this.facade.bootstrap;
readonly activeSection = this.facade.activeSection;
private readonly routeSection = toSignal(
this.route.paramMap.pipe(map(params => params.get('section') as ProjectEditorSectionId | null)),
{ initialValue: null }
);
constructor() {
this.facade.loadBootstrap();
effect(() => {
const section = this.routeSection();
if (section && KNOWN_SECTIONS.includes(section)) {
this.facade.setActiveSection(section);
}
});
}
}
- Step 3: Make the nav a real router link
Replace src/app/features/project-editor/components/project-editor-nav.component.ts:
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { TranslatePipe } from '../../../i18n/translate.pipe';
import { ProjectEditorSectionId } from '../models/project-editor.model';
@Component({
selector: 'app-project-editor-nav',
standalone: true,
imports: [TranslatePipe, RouterLink, RouterLinkActive],
templateUrl: './project-editor-nav.component.html',
styleUrls: ['./project-editor-nav.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorNavComponent {
readonly sections: Array<{ id: ProjectEditorSectionId; label: string }> = [
{ id: 'general', label: 'builder.general' },
{ id: 'branding', label: 'builder.branding' },
{ id: 'theme', label: 'builder.theme' },
{ id: 'header', label: 'builder.header' },
{ 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' },
];
}
Replace src/app/features/project-editor/components/project-editor-nav.component.html:
<nav class="editor-nav" [attr.aria-label]="'builder.title' | translate">
@for (section of sections; track section.id) {
<a
[routerLink]="['/edit', section.id]"
routerLinkActive="active"
class="editor-nav-link"
>{{ section.label | translate }}</a>
}
</nav>
Add to src/app/features/project-editor/components/project-editor-nav.component.scss (append; file already styles .editor-nav and buttons — add a matching anchor rule so links look identical to the old buttons):
.editor-nav-link {
text-decoration: none;
display: inline-block;
}
- Step 4: Manual verification
Run npx ng serve (or the project's npm run start), open http://localhost:4200/builder — confirm it redirects to /edit/general and the General tab renders. Click each nav link; confirm the URL changes to /edit/<section> for each and the correct tab content shows. Reload the browser on /edit/footer directly; confirm the Footer tab renders (not General) — this proves the route param, not just the click, drives the active tab.
- Step 5: Commit
git add src/app/app.routes.ts src/app/features/project-editor/pages/project-editor-page.component.ts src/app/features/project-editor/components/project-editor-nav.component.ts src/app/features/project-editor/components/project-editor-nav.component.html src/app/features/project-editor/components/project-editor-nav.component.scss
git commit -m "feat(project-editor): route-driven tabs under /edit/:section"
Task 2: Languages tab + generic locale sync
Files:
- Create:
src/app/features/project-editor/services/locale-sync.service.ts - Create:
src/app/features/project-editor/sections/languages-section.component.ts - Create:
src/app/features/project-editor/sections/languages-section.component.html - Modify:
src/app/features/project-editor/models/project-editor.model.ts - Modify:
src/app/features/project-editor/facade/project-editor.facade.ts - Modify:
src/app/features/project-editor/pages/project-editor-page.component.ts - Modify:
src/app/features/project-editor/pages/project-editor-page.component.html - Modify:
src/app/features/project-editor/components/project-editor-nav.component.ts - Modify:
src/app/i18n/translations.ts,en.ts,ru.ts,hy.ts
Interfaces:
-
Consumes:
NavigationItemConfig,NavigationLocalizedText,FooterNavigationGroupConfig,BootstrapConfigfromsrc/app/shared/models/config;StaticPageTranslationConfig,LocalizedTextContentlikewise. -
Produces:
LocaleSyncService.addLocale(bootstrap, locale): BootstrapConfig,LocaleSyncService.removeLocale(bootstrap, locale): BootstrapConfig;ProjectEditorFacade.addLocale(locale: string),.removeLocale(locale: string),.setDefaultLocale(locale: string)— used by Task 3 (static pages) and Task 4 (navigation) indirectly, and directly by the new Languages tab. -
Step 1:
LocaleSyncService
Create src/app/features/project-editor/services/locale-sync.service.ts:
import { Injectable } from '@angular/core';
import { BootstrapConfig, FooterNavigationGroupConfig, NavigationItemConfig, NavigationLocalizedText } from '../../../shared/models/config';
type SyncMode = 'add' | 'remove';
@Injectable({ providedIn: 'root' })
export class LocaleSyncService {
addLocale(bootstrap: BootstrapConfig, locale: string): BootstrapConfig {
return this.sync(bootstrap, locale, 'add');
}
removeLocale(bootstrap: BootstrapConfig, locale: string): BootstrapConfig {
if (locale === bootstrap.localization.defaultLocale) {
return bootstrap;
}
return this.sync(bootstrap, locale, 'remove');
}
private sync(bootstrap: BootstrapConfig, rawLocale: string, mode: SyncMode): BootstrapConfig {
const locale = rawLocale.trim().toLowerCase();
if (!locale) {
return bootstrap;
}
const alreadyPresent = bootstrap.localization.supportedLocales.includes(locale);
if (mode === 'add' && alreadyPresent) {
return bootstrap;
}
if (mode === 'remove' && !alreadyPresent) {
return bootstrap;
}
const supportedLocales = mode === 'add'
? [...bootstrap.localization.supportedLocales, locale]
: bootstrap.localization.supportedLocales.filter(existing => existing !== locale);
return {
...bootstrap,
tenant: { ...bootstrap.tenant, supportedLocales },
localization: { ...bootstrap.localization, supportedLocales },
staticPages: this.syncStaticPages(bootstrap.staticPages, locale, mode),
navigation: {
...bootstrap.navigation,
header: this.syncNavItems(bootstrap.navigation.header, locale, mode),
footer: this.syncFooterNav(bootstrap.navigation.footer, locale, mode),
sidebar: bootstrap.navigation.sidebar ? this.syncNavItems(bootstrap.navigation.sidebar, locale, mode) : bootstrap.navigation.sidebar,
},
};
}
private syncStaticPages(staticPages: BootstrapConfig['staticPages'], locale: string, mode: SyncMode): BootstrapConfig['staticPages'] {
if (!staticPages || Array.isArray(staticPages)) {
return staticPages;
}
return Object.fromEntries(
Object.entries(staticPages).map(([id, page]) => [id, {
...page,
translations: this.syncRecord(page.translations, locale, mode, () => ({ title: '', html: '' })),
}])
);
}
private syncNavItems(items: NavigationItemConfig[], locale: string, mode: SyncMode): NavigationItemConfig[] {
return items.map(item => ({
...item,
label: this.syncLabel(item.label, locale, mode),
children: item.children ? this.syncNavItems(item.children, locale, mode) : item.children,
}));
}
private syncFooterNav(
footer: NavigationItemConfig[] | FooterNavigationGroupConfig[],
locale: string,
mode: SyncMode
): NavigationItemConfig[] | FooterNavigationGroupConfig[] {
if (footer.length === 0) {
return footer;
}
if ('items' in footer[0]) {
return (footer as FooterNavigationGroupConfig[]).map(group => ({
...group,
groupTitle: this.syncLabel(group.groupTitle, locale, mode),
items: group.items.map(item => ({ ...item, label: this.syncLabel(item.label, locale, mode) })),
}));
}
return this.syncNavItems(footer as NavigationItemConfig[], locale, mode);
}
private syncLabel(
label: string | NavigationLocalizedText | undefined,
locale: string,
mode: SyncMode
): string | NavigationLocalizedText | undefined {
if (label === undefined || typeof label === 'string') {
return label;
}
return this.syncRecord(label, locale, mode, () => Object.values(label)[0] ?? '');
}
private syncRecord<T>(
record: Record<string, T> | undefined,
locale: string,
mode: SyncMode,
createEmpty: () => T
): Record<string, T> {
const next: Record<string, T> = { ...(record ?? {}) };
if (mode === 'add') {
if (!(locale in next)) {
next[locale] = createEmpty();
}
} else {
delete next[locale];
}
return next;
}
}
- Step 2: Wire into the facade
In src/app/features/project-editor/facade/project-editor.facade.ts, add the import and the three methods (insert after setActiveSection, before private normalize):
import { LocaleSyncService } from '../services/locale-sync.service';
private readonly localeSync = inject(LocaleSyncService);
addLocale(locale: string): void {
this.updateBootstrap(current => this.localeSync.addLocale(current, locale));
}
removeLocale(locale: string): void {
this.updateBootstrap(current => this.localeSync.removeLocale(current, locale));
}
setDefaultLocale(locale: string): void {
this.updateBootstrap(current => ({
...current,
tenant: { ...current.tenant, defaultLocale: locale },
localization: { ...current.localization, defaultLocale: locale },
}));
}
- Step 3: Add the section id
In src/app/features/project-editor/models/project-editor.model.ts, change the union:
export type ProjectEditorSectionId =
| 'general'
| 'branding'
| 'theme'
| 'header'
| 'footer'
| 'homepage'
| 'widgets'
| 'static-pages'
| 'features'
| 'languages'
| 'preview';
- Step 4: Languages tab component
Create src/app/features/project-editor/sections/languages-section.component.ts:
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe';
@Component({
selector: 'app-project-editor-languages-section',
standalone: true,
imports: [FormsModule, TranslatePipe],
templateUrl: './languages-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorLanguagesSectionComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly bootstrap = this.facade.bootstrap;
readonly locales = computed(() => this.bootstrap()?.localization.supportedLocales ?? []);
readonly defaultLocale = computed(() => this.bootstrap()?.localization.defaultLocale ?? '');
readonly newLocale = signal('');
updateNewLocale(value: string): void {
this.newLocale.set(value);
}
addLocale(): void {
const code = this.newLocale().trim();
if (!code) {
return;
}
this.facade.addLocale(code);
this.newLocale.set('');
}
removeLocale(code: string): void {
this.facade.removeLocale(code);
}
setDefault(code: string): void {
this.facade.setDefaultLocale(code);
}
}
Create src/app/features/project-editor/sections/languages-section.component.html:
<section class="editor-section-card">
<div class="editor-actions">
<h2>{{ 'builder.languagesTab' | translate }}</h2>
</div>
<div class="editor-actions">
<input
type="text"
[ngModel]="newLocale()"
(ngModelChange)="updateNewLocale($event)"
placeholder="de"
/>
<button type="button" (click)="addLocale()">{{ 'builder.addLanguage' | translate }}</button>
</div>
<div class="stack-list">
@for (code of locales(); track code) {
<article class="sub-card">
<div class="editor-actions">
<h3>{{ code }}</h3>
<div class="editor-actions">
@if (code === defaultLocale()) {
<span>{{ 'builder.defaultLanguageLabel' | translate }}</span>
} @else {
<button type="button" class="secondary" (click)="setDefault(code)">{{ 'builder.setDefaultLanguage' | translate }}</button>
<button type="button" class="secondary" (click)="removeLocale(code)">{{ 'builder.removeLanguage' | translate }}</button>
}
</div>
</div>
</article>
}
</div>
</section>
- Step 5: Register the tab
In src/app/features/project-editor/pages/project-editor-page.component.ts, add the import and register it in imports:
import { ProjectEditorLanguagesSectionComponent } from '../sections/languages-section.component';
Add ProjectEditorLanguagesSectionComponent to the imports array (after ProjectEditorFeaturesSectionComponent), and add 'languages' to KNOWN_SECTIONS:
const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'languages', 'preview'
];
In src/app/features/project-editor/pages/project-editor-page.component.html, add a case inside the @switch (after the features case):
@case ('languages') { <app-project-editor-languages-section /> }
In src/app/features/project-editor/components/project-editor-nav.component.ts, add to sections (after features, before preview):
{ id: 'languages', label: 'builder.languagesTab' },
- Step 6: i18n keys
In src/app/i18n/translations.ts, inside the builder: { ... } interface block, add (after livePreview: string;):
languagesTab: string;
addLanguage: string;
removeLanguage: string;
setDefaultLanguage: string;
defaultLanguageLabel: string;
In src/app/i18n/en.ts, inside builder: { ... }, add:
languagesTab: 'Languages',
addLanguage: 'Add Language',
removeLanguage: 'Remove',
setDefaultLanguage: 'Set as Default',
defaultLanguageLabel: 'Default',
In src/app/i18n/ru.ts, inside builder: { ... }, add:
languagesTab: 'Языки',
addLanguage: 'Добавить язык',
removeLanguage: 'Удалить',
setDefaultLanguage: 'Сделать языком по умолчанию',
defaultLanguageLabel: 'По умолчанию',
In src/app/i18n/hy.ts, inside builder: { ... }, add:
languagesTab: 'Լեզուներ',
addLanguage: 'Ավելացնել լեզու',
removeLanguage: 'Հեռացնել',
setDefaultLanguage: 'Դարձնել լռելյայն',
defaultLanguageLabel: 'Լռելյայն',
- Step 7: Manual verification
Run ng serve, open /edit/languages. Confirm current locales (en, ru, hy) list with en marked default. Type de and click Add Language — confirm de appears in the list. Click "Set as Default" on de — confirm it now shows as default and en shows the remove/set-default buttons instead. Go to /edit/preview, click the export/refresh action, and inspect the exported JSON textarea: confirm localization.supportedLocales includes "de" and localization.defaultLocale is "de", and (if any static pages exist) each static page's translations object now has a "de" key with { "title": "", "html": "" }. Go back to /edit/languages and remove de — confirm it disappears from the list and from the exported JSON's translations objects. Attempt to remove the current default locale — confirm nothing happens (no button is shown for the default row).
- Step 8: Commit
git add src/app/features/project-editor/services/locale-sync.service.ts src/app/features/project-editor/sections/languages-section.component.ts src/app/features/project-editor/sections/languages-section.component.html src/app/features/project-editor/models/project-editor.model.ts src/app/features/project-editor/facade/project-editor.facade.ts src/app/features/project-editor/pages/project-editor-page.component.ts src/app/features/project-editor/pages/project-editor-page.component.html src/app/features/project-editor/components/project-editor-nav.component.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
git commit -m "feat(project-editor): add Languages tab with generic locale sync"
Task 3: Static pages editor uses supported locales dynamically
Files:
- Modify:
src/app/features/content-management/components/static-pages-editor.component.ts - Modify:
src/app/features/content-management/components/static-pages-editor.component.html
Interfaces:
-
Consumes:
ProjectEditorFacade.bootstrap(existing),bootstrap.localization.supportedLocales: string[](existing field). -
Produces:
locales: Signal<string[]>computed on the component, consumed by its own template only. -
Step 1: Add a
localescomputed
In src/app/features/content-management/components/static-pages-editor.component.ts, add after the existing readonly validation = ... line:
readonly locales = computed(() => this.bootstrap()?.localization.supportedLocales ?? ['en']);
- Step 2: Use it in the template instead of the hardcoded array
In src/app/features/content-management/components/static-pages-editor.component.html, change:
@for (locale of ['en','ru','hy']; track locale) {
to:
@for (locale of locales(); track locale) {
- Step 3: Manual verification
With the dev server running, go to /edit/languages and add de. Go to /edit/static-pages — confirm every page card now shows a fourth translation row for de (title input + HTML field) in addition to en/ru/hy. Remove de on the Languages tab, return to Static Pages, confirm the de row is gone.
- Step 4: Commit
git add src/app/features/content-management/components/static-pages-editor.component.ts src/app/features/content-management/components/static-pages-editor.component.html
git commit -m "fix(content-management): static pages editor reads supported locales instead of hardcoding en/ru/hy"
Task 4: Navigation tab
Files:
- Modify:
src/app/features/project-editor/facade/project-editor.facade.ts - Modify:
src/app/features/project-editor/models/project-editor.model.ts - Create:
src/app/features/project-editor/sections/navigation-section.component.ts - Create:
src/app/features/project-editor/sections/navigation-section.component.html - Modify:
src/app/features/project-editor/pages/project-editor-page.component.ts - Modify:
src/app/features/project-editor/pages/project-editor-page.component.html - Modify:
src/app/features/project-editor/components/project-editor-nav.component.ts - Modify:
src/app/i18n/translations.ts,en.ts,ru.ts,hy.ts
Interfaces:
-
Consumes:
NavigationItemConfigfromsrc/app/shared/models/config(id,label,route,order,visible,children). -
Produces:
ProjectEditorFacade.addNavLink(target),.removeNavLink(target, id),.updateNavLink(target, id, patch),.reorderNavLink(target, id, direction)— no other task depends on these, but they follow the sametarget: 'header' | 'footer'shape a future Sidebar tab could reuse. -
Step 1: Facade actions
In src/app/features/project-editor/facade/project-editor.facade.ts, add the import:
import { NavigationItemConfig } from '../../../shared/models/config';
Add these methods after setDefaultLocale (from Task 2), before private normalize:
addNavLink(target: 'header' | 'footer'): void {
this.updateBootstrap(current => {
const list = current.navigation[target];
if (!this.isFlatNavList(list)) {
return current;
}
const items = list as NavigationItemConfig[];
const newItem: NavigationItemConfig = {
id: `nav-${Date.now()}`,
label: 'New link',
route: '/',
order: items.length + 1,
visible: true,
};
return { ...current, navigation: { ...current.navigation, [target]: [...items, newItem] } };
});
}
removeNavLink(target: 'header' | 'footer', id: string): void {
this.updateBootstrap(current => {
const list = current.navigation[target];
if (!this.isFlatNavList(list)) {
return current;
}
return {
...current,
navigation: { ...current.navigation, [target]: (list as NavigationItemConfig[]).filter(item => item.id !== id) },
};
});
}
updateNavLink(target: 'header' | 'footer', id: string, patch: Partial<NavigationItemConfig>): void {
this.updateBootstrap(current => {
const list = current.navigation[target];
if (!this.isFlatNavList(list)) {
return current;
}
return {
...current,
navigation: {
...current.navigation,
[target]: (list as NavigationItemConfig[]).map(item => (item.id !== id ? item : { ...item, ...patch })),
},
};
});
}
reorderNavLink(target: 'header' | 'footer', id: string, direction: -1 | 1): void {
this.updateBootstrap(current => {
const list = current.navigation[target];
if (!this.isFlatNavList(list)) {
return current;
}
const items = [...(list as NavigationItemConfig[])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const index = items.findIndex(item => item.id === id);
const nextIndex = index + direction;
if (index < 0 || nextIndex < 0 || nextIndex >= items.length) {
return current;
}
const tmp = items[index];
items[index] = items[nextIndex];
items[nextIndex] = tmp;
return {
...current,
navigation: { ...current.navigation, [target]: items.map((item, order) => ({ ...item, order: order + 1 })) },
};
});
}
private isFlatNavList(list: NavigationItemConfig[] | { items: unknown }[]): list is NavigationItemConfig[] {
return list.length === 0 || !('items' in list[0]);
}
- Step 2: Add the section id
In src/app/features/project-editor/models/project-editor.model.ts, add 'navigation' to the ProjectEditorSectionId union (next to 'languages'):
export type ProjectEditorSectionId =
| 'general'
| 'branding'
| 'theme'
| 'header'
| 'footer'
| 'homepage'
| 'widgets'
| 'static-pages'
| 'features'
| 'languages'
| 'navigation'
| 'preview';
- Step 3: Navigation tab component
Create src/app/features/project-editor/sections/navigation-section.component.ts:
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { TranslatePipe } from '../../../i18n/translate.pipe';
import { NavigationItemConfig } from '../../../shared/models/config';
@Component({
selector: 'app-project-editor-navigation-section',
standalone: true,
imports: [FormsModule, TranslatePipe],
templateUrl: './navigation-section.component.html',
styleUrls: ['./section.shared.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorNavigationSectionComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly bootstrap = this.facade.bootstrap;
readonly headerLinks = computed(() => this.sorted(this.bootstrap()?.navigation.header ?? []));
readonly footerLinks = computed<NavigationItemConfig[] | null>(() => {
const footer = this.bootstrap()?.navigation.footer ?? [];
if (footer.length === 0) {
return [];
}
return 'items' in footer[0] ? null : this.sorted(footer as NavigationItemConfig[]);
});
labelOf(item: NavigationItemConfig): string {
if (typeof item.label === 'string' || !item.label) {
return item.label ?? '';
}
const defaultLocale = this.bootstrap()?.localization.defaultLocale ?? 'en';
return item.label[defaultLocale] ?? Object.values(item.label)[0] ?? '';
}
addLink(target: 'header' | 'footer'): void {
this.facade.addNavLink(target);
}
removeLink(target: 'header' | 'footer', id: string): void {
this.facade.removeNavLink(target, id);
}
move(target: 'header' | 'footer', id: string, direction: -1 | 1): void {
this.facade.reorderNavLink(target, id, direction);
}
updateLabel(target: 'header' | 'footer', id: string, value: string): void {
this.facade.updateNavLink(target, id, { label: value });
}
updateRoute(target: 'header' | 'footer', id: string, value: string): void {
this.facade.updateNavLink(target, id, { route: value });
}
updateVisible(target: 'header' | 'footer', id: string, value: boolean): void {
this.facade.updateNavLink(target, id, { visible: value });
}
private sorted(items: NavigationItemConfig[]): NavigationItemConfig[] {
return [...items].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
}
Create src/app/features/project-editor/sections/navigation-section.component.html:
<section class="editor-section-card">
<div class="editor-actions">
<h2>{{ 'builder.navigationHeader' | translate }}</h2>
<button type="button" (click)="addLink('header')">{{ 'builder.addLink' | translate }}</button>
</div>
<div class="stack-list">
@for (item of headerLinks(); track item.id) {
<article class="sub-card">
<div class="editor-actions">
<h3>{{ labelOf(item) }}</h3>
<div class="editor-actions">
<button type="button" class="secondary" (click)="move('header', item.id, -1)">↑</button>
<button type="button" class="secondary" (click)="move('header', item.id, 1)">↓</button>
<button type="button" class="secondary" (click)="removeLink('header', item.id)">{{ 'builder.removeLink' | translate }}</button>
</div>
</div>
<div class="editor-grid three">
<label><span>{{ 'builder.linkLabel' | translate }}</span><input type="text" [ngModel]="labelOf(item)" (ngModelChange)="updateLabel('header', item.id, $event)" /></label>
<label><span>{{ 'builder.linkUrl' | translate }}</span><input type="text" [ngModel]="item.route" (ngModelChange)="updateRoute('header', item.id, $event)" /></label>
<label class="toggle-row"><input type="checkbox" [checked]="item.visible !== false" (change)="updateVisible('header', item.id, $any($event.target).checked)" /><span>{{ 'builder.visible' | translate }}</span></label>
</div>
</article>
}
</div>
<div class="editor-actions">
<h2>{{ 'builder.navigationFooter' | translate }}</h2>
@if (footerLinks(); as footer) {
<button type="button" (click)="addLink('footer')">{{ 'builder.addLink' | translate }}</button>
}
</div>
@if (footerLinks(); as footer) {
<div class="stack-list">
@for (item of footer; track item.id) {
<article class="sub-card">
<div class="editor-actions">
<h3>{{ labelOf(item) }}</h3>
<div class="editor-actions">
<button type="button" class="secondary" (click)="move('footer', item.id, -1)">↑</button>
<button type="button" class="secondary" (click)="move('footer', item.id, 1)">↓</button>
<button type="button" class="secondary" (click)="removeLink('footer', item.id)">{{ 'builder.removeLink' | translate }}</button>
</div>
</div>
<div class="editor-grid three">
<label><span>{{ 'builder.linkLabel' | translate }}</span><input type="text" [ngModel]="labelOf(item)" (ngModelChange)="updateLabel('footer', item.id, $event)" /></label>
<label><span>{{ 'builder.linkUrl' | translate }}</span><input type="text" [ngModel]="item.route" (ngModelChange)="updateRoute('footer', item.id, $event)" /></label>
<label class="toggle-row"><input type="checkbox" [checked]="item.visible !== false" (change)="updateVisible('footer', item.id, $any($event.target).checked)" /><span>{{ 'builder.visible' | translate }}</span></label>
</div>
</article>
}
</div>
} @else {
<p class="editor-error">{{ 'builder.navigationFooterGrouped' | translate }}</p>
}
</section>
- Step 4: Register the tab
In src/app/features/project-editor/pages/project-editor-page.component.ts, add the import, add to imports, and add 'navigation' to KNOWN_SECTIONS:
import { ProjectEditorNavigationSectionComponent } from '../sections/navigation-section.component';
const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'languages', 'navigation', 'preview'
];
In src/app/features/project-editor/pages/project-editor-page.component.html, add the case (after languages):
@case ('navigation') { <app-project-editor-navigation-section /> }
In src/app/features/project-editor/components/project-editor-nav.component.ts, add (after languages, before preview):
{ id: 'navigation', label: 'builder.navigationTab' },
- Step 5: i18n keys
In src/app/i18n/translations.ts, add to the builder interface (after the Task 2 keys):
navigationTab: string;
navigationHeader: string;
navigationFooter: string;
navigationFooterGrouped: string;
addLink: string;
removeLink: string;
linkLabel: string;
linkUrl: string;
In src/app/i18n/en.ts, add:
navigationTab: 'Navigation',
navigationHeader: 'Header Navigation',
navigationFooter: 'Footer Navigation',
navigationFooterGrouped: 'Footer navigation uses grouped columns and is not editable here yet — edit via the Footer tab.',
addLink: 'Add Link',
removeLink: 'Remove',
linkLabel: 'Label',
linkUrl: 'URL',
In src/app/i18n/ru.ts, add:
navigationTab: 'Навигация',
navigationHeader: 'Навигация в шапке',
navigationFooter: 'Навигация в подвале',
navigationFooterGrouped: 'Навигация подвала сгруппирована по колонкам и пока недоступна для редактирования здесь — используйте вкладку "Подвал".',
addLink: 'Добавить ссылку',
removeLink: 'Удалить',
linkLabel: 'Название',
linkUrl: 'URL',
In src/app/i18n/hy.ts, add:
navigationTab: 'Նավիգացիա',
navigationHeader: 'Վերին նավիգացիա',
navigationFooter: 'Ստորին նավիգացիա',
navigationFooterGrouped: 'Ստորին նավիգացիան խմբավորված է սյուներով և դեռ խմբագրելի չէ այստեղ. օգտագործեք «Footer» ներդիրը։',
addLink: 'Ավելացնել հղում',
removeLink: 'Հեռացնել',
linkLabel: 'Պիտակ',
linkUrl: 'URL',
- Step 6: Manual verification
Run ng serve, open /edit/navigation. Confirm the existing header nav items list, sorted by order. Click "Add Link" under Header Navigation — confirm a "New link" row appears at the bottom. Edit its label and URL — confirm the values persist (check via /edit/preview export JSON: navigation.header contains the new item with your edited label/route). Click ↑ on the new item — confirm it moves up one position and the exported JSON's order values update accordingly. Click "Remove" — confirm it disappears. If navigation.footer in this bootstrap is grouped (FooterNavigationGroupConfig[]), confirm the footer section shows the "not editable here yet" message instead of a broken list.
- Step 7: Commit
git add src/app/features/project-editor/facade/project-editor.facade.ts src/app/features/project-editor/models/project-editor.model.ts src/app/features/project-editor/sections/navigation-section.component.ts src/app/features/project-editor/sections/navigation-section.component.html src/app/features/project-editor/pages/project-editor-page.component.ts src/app/features/project-editor/pages/project-editor-page.component.html src/app/features/project-editor/components/project-editor-nav.component.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
git commit -m "feat(project-editor): add Navigation tab for header/flat footer nav"
Task 5: Reusable rich HTML editor component
Files:
- Create:
src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.ts - Create:
src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.html - Create:
src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.scss
Interfaces:
-
Consumes: nothing outside Angular core/browser APIs (
document.execCommand, nativecontentEditable). -
Produces:
MarketplaceHtmlEditorComponentwith@Input() html: string,@Output() htmlChange: EventEmitter<string>— consumed by Task 6. -
Step 1: Component
Create src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.ts:
import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnChanges, Output, SimpleChanges, ViewChild, signal } from '@angular/core';
export interface HtmlEditorToolbarCommand {
id: string;
label: string;
command: string;
value?: string;
}
export const HTML_EDITOR_TOOLBAR: HtmlEditorToolbarCommand[] = [
{ id: 'bold', label: 'B', command: 'bold' },
{ id: 'italic', label: 'I', command: 'italic' },
{ id: 'underline', label: 'U', command: 'underline' },
{ id: 'h2', label: 'H2', command: 'formatBlock', value: 'H2' },
{ id: 'h3', label: 'H3', command: 'formatBlock', value: 'H3' },
{ id: 'ul', label: 'List', command: 'insertUnorderedList' },
{ id: 'ol', label: '1,2,3', command: 'insertOrderedList' },
{ id: 'link', label: 'Link', command: 'createLink' },
{ id: 'image', label: 'Image', command: 'insertImage' },
{ id: 'table', label: 'Table', command: 'insertHTML', value: '<table><tr><td> </td><td> </td></tr></table>' },
];
@Component({
selector: 'app-marketplace-html-editor',
standalone: true,
imports: [],
templateUrl: './marketplace-html-editor.component.html',
styleUrls: ['./marketplace-html-editor.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MarketplaceHtmlEditorComponent implements OnChanges {
@Input() html = '';
@Output() htmlChange = new EventEmitter<string>();
@ViewChild('surface', { static: true }) surface!: ElementRef<HTMLDivElement>;
readonly toolbar = HTML_EDITOR_TOOLBAR;
readonly showCode = signal(false);
readonly codeValue = signal('');
ngOnChanges(changes: SimpleChanges): void {
if (changes['html'] && this.surface && this.surface.nativeElement.innerHTML !== (this.html || '')) {
this.surface.nativeElement.innerHTML = this.html || '';
}
}
runCommand(item: HtmlEditorToolbarCommand): void {
this.surface.nativeElement.focus();
if (item.command === 'createLink') {
const url = window.prompt('URL');
if (!url) {
return;
}
document.execCommand('createLink', false, url);
} else if (item.command === 'insertImage') {
const url = window.prompt('Image URL');
if (!url) {
return;
}
document.execCommand('insertImage', false, url);
} else {
document.execCommand(item.command, false, item.value);
}
this.emitChange();
}
onInput(): void {
this.emitChange();
}
toggleCode(): void {
if (!this.showCode()) {
this.codeValue.set(this.surface.nativeElement.innerHTML);
this.showCode.set(true);
return;
}
this.surface.nativeElement.innerHTML = this.codeValue();
this.showCode.set(false);
this.emitChange();
}
updateCode(value: string): void {
this.codeValue.set(value);
}
private emitChange(): void {
this.htmlChange.emit(this.surface.nativeElement.innerHTML);
}
}
- Step 2: Template
Create src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.html:
<div class="html-editor">
<div class="html-editor-toolbar">
@for (item of toolbar; track item.id) {
<button type="button" (click)="runCommand(item)">{{ item.label }}</button>
}
<button type="button" (click)="toggleCode()">{{ showCode() ? 'Preview' : 'Code' }}</button>
</div>
@if (showCode()) {
<textarea
class="html-editor-code"
rows="10"
[value]="codeValue()"
(input)="updateCode($any($event.target).value)"
></textarea>
} @else {
<div
#surface
class="html-editor-surface"
contenteditable="true"
(input)="onInput()"
></div>
}
</div>
- Step 3: Styles
Create src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.scss:
.html-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.html-editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.html-editor-surface {
min-height: 160px;
border: 1px solid var(--border, #ccc);
border-radius: 4px;
padding: 0.5rem;
overflow-y: auto;
}
.html-editor-code {
min-height: 160px;
font-family: monospace;
border: 1px solid var(--border, #ccc);
border-radius: 4px;
padding: 0.5rem;
}
- Step 4: Manual verification
This component isn't wired into a page yet — verify it compiles: run npx tsc -p tsconfig.app.json --noEmit. Expected: no new errors referencing marketplace-html-editor.component.ts. (Full interactive verification happens in Task 6 once it's mounted somewhere.)
- Step 5: Commit
git add src/app/features/project-editor/components/html-editor/
git commit -m "feat(project-editor): add reusable contentEditable HTML editor component"
Task 6: Replace the static-pages <textarea> with the HTML editor
Files:
- Modify:
src/app/features/content-management/components/static-pages-editor.component.ts - Modify:
src/app/features/content-management/components/static-pages-editor.component.html
Interfaces:
-
Consumes:
MarketplaceHtmlEditorComponent([html]input,(htmlChange)output) from Task 5. -
Produces: nothing new — same
updateTranslation(id, locale, 'html', value)method is now driven by(htmlChange)instead of(ngModelChange). -
Step 1: Import the component
In src/app/features/content-management/components/static-pages-editor.component.ts, add the import and register it:
import { MarketplaceHtmlEditorComponent } from '../../project-editor/components/html-editor/marketplace-html-editor.component';
Change the imports array from [FormsModule, TranslatePipe] to:
imports: [FormsModule, TranslatePipe, MarketplaceHtmlEditorComponent],
- Step 2: Swap the textarea for the editor
In src/app/features/content-management/components/static-pages-editor.component.html, replace:
<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>
with:
<label class="full">
<span>{{ 'builder.htmlPreview' | translate }} {{ locale }}</span>
<app-marketplace-html-editor
[html]="page.translations[locale]?.html || ''"
(htmlChange)="updateTranslation(page.id, locale, 'html', $event)"
/>
</label>
- Step 3: Manual verification
Run ng serve, open /edit/static-pages, create a page. Confirm each locale's HTML field is now the toolbar editor, not a plain textarea. Click Bold, type text — confirm it renders bold in the editing surface. Click "Code", confirm the raw HTML (e.g. <b>...</b>) shows in a textarea, edit it, click "Code" again (now labeled "Preview") — confirm the surface re-renders your manual HTML edit. Go to /edit/preview, export JSON, confirm the page's translations.<locale>.html contains the exact HTML you produced (bold tags etc.) — proving nothing sanitized it in the editor.
- Step 4: Commit
git add src/app/features/content-management/components/static-pages-editor.component.ts src/app/features/content-management/components/static-pages-editor.component.html
git commit -m "feat(content-management): use rich HTML editor for static page content"
Task 7: ProjectValidator
Files:
- Create:
src/app/features/project-editor/services/project-validator.service.ts - Modify:
src/app/i18n/translations.ts,en.ts,ru.ts,hy.ts
Interfaces:
-
Consumes:
BootstrapConfig(existing). -
Produces:
ProjectValidationIssue { code: string; message: string },ProjectValidator.validate(bootstrap: BootstrapConfig): ProjectValidationIssue[]— consumed by Task 8's facadevalidationIssuescomputed and Task 9's save bar. -
Step 1: Service
Create src/app/features/project-editor/services/project-validator.service.ts:
import { Injectable } from '@angular/core';
import { BootstrapConfig } from '../../../shared/models/config';
export interface ProjectValidationIssue {
code: string;
message: string;
}
const HEX_COLOR = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
const HTTP_URL = /^https?:\/\/\S+$/;
@Injectable({ providedIn: 'root' })
export class ProjectValidator {
validate(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
return [
...this.brandingIssues(bootstrap),
...this.languageIssues(bootstrap),
...this.urlIssues(bootstrap),
...this.duplicateSlugIssues(bootstrap),
...this.homepageIssues(bootstrap),
...this.navigationIssues(bootstrap),
...this.colorIssues(bootstrap),
];
}
private brandingIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
return bootstrap.branding.logoUrl ? [] : [{ code: 'missing-logo', message: 'builder.validationMissingLogo' }];
}
private languageIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
return bootstrap.localization.supportedLocales.length > 0 ? [] : [{ code: 'no-languages', message: 'builder.validationNoLanguages' }];
}
private urlIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const url = bootstrap.tenant.websiteBaseUrl;
return !url || HTTP_URL.test(url) ? [] : [{ code: 'invalid-url', message: 'builder.validationInvalidUrl' }];
}
private duplicateSlugIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const staticPages = bootstrap.staticPages;
if (!staticPages || Array.isArray(staticPages)) {
return [];
}
const slugs = Object.values(staticPages).map(page => page.slug);
const hasDuplicates = slugs.some((slug, index) => slugs.indexOf(slug) !== index);
return hasDuplicates ? [{ code: 'duplicate-slugs', message: 'builder.validationDuplicateSlugs' }] : [];
}
private homepageIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const homePage = bootstrap.pages.find(page => page.key === 'home' || page.route.path === '/');
if (!homePage || homePage.sections.length === 0) {
return [{ code: 'empty-homepage', message: 'builder.validationEmptyHomepage' }];
}
const hasMissingWidgetType = homePage.sections.some(section => section.widgets.some(widget => !widget.type?.trim()));
return hasMissingWidgetType ? [{ code: 'missing-widget', message: 'builder.validationMissingWidget' }] : [];
}
private navigationIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const keyOf = (item: { label?: string | Record<string, string>; route?: string }): string =>
`${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`;
const keys = bootstrap.navigation.header.map(keyOf);
const hasDuplicates = keys.some((key, index) => keys.indexOf(key) !== index);
return hasDuplicates ? [{ code: 'duplicate-nav-links', message: 'builder.validationDuplicateNavLinks' }] : [];
}
private colorIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const invalid = Object.values(bootstrap.theme.palette).some(value => !HEX_COLOR.test(value));
return invalid ? [{ code: 'invalid-colors', message: 'builder.validationInvalidColors' }] : [];
}
}
- Step 2: i18n keys
In src/app/i18n/translations.ts, add to the builder interface:
validationMissingLogo: string;
validationNoLanguages: string;
validationInvalidUrl: string;
validationDuplicateSlugs: string;
validationEmptyHomepage: string;
validationMissingWidget: string;
validationDuplicateNavLinks: string;
validationInvalidColors: string;
In src/app/i18n/en.ts, add:
validationMissingLogo: 'Branding is missing a logo.',
validationNoLanguages: 'No languages are configured.',
validationInvalidUrl: 'The marketplace URL is invalid.',
validationDuplicateSlugs: 'Two or more static pages share the same slug.',
validationEmptyHomepage: 'The homepage has no sections.',
validationMissingWidget: 'A homepage section has a widget with no type.',
validationDuplicateNavLinks: 'Two or more header navigation links are duplicates.',
validationInvalidColors: 'One or more theme colors are not valid hex colors.',
In src/app/i18n/ru.ts, add:
validationMissingLogo: 'В брендинге отсутствует логотип.',
validationNoLanguages: 'Не настроены языки.',
validationInvalidUrl: 'Некорректный URL маркетплейса.',
validationDuplicateSlugs: 'Две или более статические страницы имеют одинаковый slug.',
validationEmptyHomepage: 'На главной странице нет секций.',
validationMissingWidget: 'В секции главной страницы есть виджет без типа.',
validationDuplicateNavLinks: 'Две или более ссылки в навигации шапки дублируются.',
validationInvalidColors: 'Один или несколько цветов темы указаны некорректно.',
In src/app/i18n/hy.ts, add:
validationMissingLogo: 'Բրենդինգում բացակայում է լոգոն։',
validationNoLanguages: 'Կարգավորված լեզուներ չկան։',
validationInvalidUrl: 'Մարքեթփլեյսի URL-ը սխալ է։',
validationDuplicateSlugs: 'Երկու կամ ավելի ստատիկ էջեր ունեն նույն slug-ը։',
validationEmptyHomepage: 'Գլխավոր էջում սեկցիաներ չկան։',
validationMissingWidget: 'Գլխավոր էջի սեկցիաներից մեկն ունի վիջեթ առանց տիպի։',
validationDuplicateNavLinks: 'Վերնագրի նավիգացիայում կան կրկնվող հղումներ։',
validationInvalidColors: 'Թեմայի գույներից մեկը կամ մի քանիսը վավեր hex գույն չեն։',
- Step 3: Manual verification
This service isn't wired into the UI yet. Verify it compiles: npx tsc -p tsconfig.app.json --noEmit. Expected: no new errors. (Full interactive verification happens in Task 9.)
- Step 4: Commit
git add src/app/features/project-editor/services/project-validator.service.ts src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
git commit -m "feat(project-editor): add ProjectValidator with MVP validation rules"
Task 8: Draft/Publish state and dirty tracking on the facade
Files:
- Modify:
src/app/features/project-editor/models/project-editor.model.ts - Modify:
src/app/features/project-editor/facade/project-editor.facade.ts
Interfaces:
-
Consumes:
ProjectValidator.validate(Task 7),PlatformRuntimeService.reloadFromBootstrap(existing,src/app/core/runtime/platform-runtime.service.ts:60). -
Produces:
ProjectEditorFacade.status: Signal<'draft' | 'published'>,.dirty: Signal<boolean>,.validationIssues: Signal<ProjectValidationIssue[]>,.save(): void,.publish(): boolean— consumed by Task 9 (save bar) and Task 10 (dirty guard). -
Step 1: Extend the state shape
In src/app/features/project-editor/models/project-editor.model.ts, change ProjectEditorState:
export interface ProjectEditorState {
bootstrap: BootstrapConfig | null;
importError: string | null;
activeSection: ProjectEditorSectionId;
status: 'draft' | 'published';
lastSavedBootstrap: BootstrapConfig | null;
}
- Step 2: Update the facade
In src/app/features/project-editor/facade/project-editor.facade.ts, add imports:
import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.service';
import { ProjectValidator } from '../services/project-validator.service';
Add injected services (with the others at the top of the class):
private readonly runtime = inject(PlatformRuntimeService);
private readonly validator = inject(ProjectValidator);
Update the initial state:
private readonly state = signal<ProjectEditorState>({
bootstrap: null,
importError: null,
activeSection: 'general',
status: 'draft',
lastSavedBootstrap: null,
});
Add these computed signals (after homepageWidgets):
readonly status = computed(() => this.state().status);
readonly validationIssues = computed(() => {
const current = this.bootstrap();
return current ? this.validator.validate(current) : [];
});
readonly dirty = computed(() => {
const current = this.bootstrap();
if (!current) {
return false;
}
return JSON.stringify(current) !== JSON.stringify(this.state().lastSavedBootstrap);
});
Update loadBootstrap() to seed lastSavedBootstrap:
loadBootstrap(): void {
this.configService.loadBootstrap(true).pipe(take(1)).subscribe({
next: config => {
const normalized = this.normalize(JSON.parse(JSON.stringify(config)) as BootstrapConfig);
this.state.update(current => ({ ...current, bootstrap: normalized, importError: null, lastSavedBootstrap: normalized, status: 'draft' }));
},
error: () => this.state.update(current => ({ ...current, bootstrap: null, importError: 'builder.importError' })),
});
}
Add save() and publish() after setDefaultLocale / nav actions (from Tasks 2 and 4):
save(): void {
const current = this.state().bootstrap;
if (!current) {
return;
}
this.state.update(state => ({ ...state, lastSavedBootstrap: JSON.parse(JSON.stringify(current)) }));
}
publish(): boolean {
const current = this.state().bootstrap;
if (!current || this.validationIssues().length > 0) {
return false;
}
this.runtime.reloadFromBootstrap(current);
this.state.update(state => ({
...state,
status: 'published',
lastSavedBootstrap: JSON.parse(JSON.stringify(current)),
}));
return true;
}
- Step 3: Manual verification
This isn't wired to any button yet. Verify it compiles: npx tsc -p tsconfig.app.json --noEmit. Expected: no new errors — in particular no error about PlatformRuntimeService not having reloadFromBootstrap (it does, confirmed at src/app/core/runtime/platform-runtime.service.ts:60). Full interactive verification happens in Task 9.
- Step 4: Commit
git add src/app/features/project-editor/models/project-editor.model.ts src/app/features/project-editor/facade/project-editor.facade.ts
git commit -m "feat(project-editor): add draft/publish status, dirty tracking, save/publish to facade"
Task 9: Sticky Save/Publish bar
Files:
- Create:
src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts - Create:
src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html - Create:
src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.scss - Modify:
src/app/features/project-editor/pages/project-editor-page.component.ts - Modify:
src/app/features/project-editor/pages/project-editor-page.component.html - Modify:
src/app/i18n/translations.ts,en.ts,ru.ts,hy.ts
Interfaces:
-
Consumes:
ProjectEditorFacade.dirty,.status,.validationIssues,.save(),.publish()(Task 8). -
Produces: nothing consumed elsewhere.
-
Step 1: Component
Create src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.ts:
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { ProjectEditorFacade } from '../../facade/project-editor.facade';
@Component({
selector: 'app-project-editor-save-bar',
standalone: true,
imports: [TranslatePipe],
templateUrl: './project-editor-save-bar.component.html',
styleUrls: ['./project-editor-save-bar.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ProjectEditorSaveBarComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly dirty = this.facade.dirty;
readonly status = this.facade.status;
readonly issues = this.facade.validationIssues;
save(): void {
this.facade.save();
}
publish(): void {
this.facade.publish();
}
}
- Step 2: Template
Create src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.html:
<div class="project-editor-save-bar">
<div class="project-editor-save-bar-status">
<span>{{ (status() === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }}</span>
@if (dirty()) {
<span class="project-editor-save-bar-dirty">{{ 'builder.unsavedChanges' | translate }}</span>
}
@if (issues().length > 0) {
<ul class="project-editor-save-bar-issues">
@for (issue of issues(); track issue.code) {
<li>{{ issue.message | translate }}</li>
}
</ul>
}
</div>
<div class="project-editor-save-bar-actions">
<button type="button" (click)="save()">{{ 'builder.save' | translate }}</button>
<button type="button" [disabled]="issues().length > 0" (click)="publish()">{{ 'builder.publish' | translate }}</button>
</div>
</div>
- Step 3: Styles
Create src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.scss:
.project-editor-save-bar {
position: sticky;
bottom: 0;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--surface, #fff);
border-top: 1px solid var(--border, #ddd);
z-index: 5;
}
.project-editor-save-bar-dirty {
color: var(--warning, #b45309);
margin-left: 0.5rem;
}
.project-editor-save-bar-issues {
margin: 0.25rem 0 0;
padding-left: 1.25rem;
color: var(--danger, #b91c1c);
}
.project-editor-save-bar-actions button + button {
margin-left: 0.5rem;
}
- Step 4: Mount it in the page
In src/app/features/project-editor/pages/project-editor-page.component.ts, add the import and register in imports:
import { ProjectEditorSaveBarComponent } from '../components/save-bar/project-editor-save-bar.component';
In src/app/features/project-editor/pages/project-editor-page.component.html, add the save bar after the closing </section> of project-editor-stack (still inside the @else block, as a sibling after the stack section):
<app-project-editor-save-bar />
- Step 5: i18n keys
In src/app/i18n/translations.ts, add to the builder interface:
statusDraft: string;
statusPublished: string;
unsavedChanges: string;
save: string;
publish: string;
In src/app/i18n/en.ts, add:
statusDraft: 'Draft',
statusPublished: 'Published',
unsavedChanges: 'Unsaved changes',
save: 'Save',
publish: 'Publish',
In src/app/i18n/ru.ts, add:
statusDraft: 'Черновик',
statusPublished: 'Опубликовано',
unsavedChanges: 'Есть несохранённые изменения',
save: 'Сохранить',
publish: 'Опубликовать',
In src/app/i18n/hy.ts, add:
statusDraft: 'Սևագիր',
statusPublished: 'Հրապարակված',
unsavedChanges: 'Չպահված փոփոխություններ',
save: 'Պահպանել',
publish: 'Հրապարակել',
- Step 6: Manual verification
Run ng serve, open /edit/general. Confirm the sticky bar at the bottom shows "Draft" and no "Unsaved changes" label initially. Change the marketplace name — confirm "Unsaved changes" appears immediately. If your test bootstrap has no branding.logoUrl, confirm a validation message ("Branding is missing a logo.") appears in the bar and the Publish button is disabled; set a logo URL on the Branding tab and confirm the message disappears and Publish becomes enabled. Click Save — confirm "Unsaved changes" disappears (status stays Draft). Click Publish — confirm status flips to "Published".
- Step 7: Commit
git add src/app/features/project-editor/components/save-bar/ src/app/features/project-editor/pages/project-editor-page.component.ts src/app/features/project-editor/pages/project-editor-page.component.html src/app/i18n/translations.ts src/app/i18n/en.ts src/app/i18n/ru.ts src/app/i18n/hy.ts
git commit -m "feat(project-editor): add sticky save/publish bar with validation summary"
Task 10: Warn before losing unsaved changes
Files:
- Create:
src/app/features/project-editor/guards/project-editor-dirty.guard.ts - Modify:
src/app/app.routes.ts - Modify:
src/app/features/project-editor/pages/project-editor-page.component.ts
Interfaces:
-
Consumes:
ProjectEditorFacade.dirty(Task 8). -
Produces:
projectEditorDirtyGuard: CanDeactivateFn<ProjectEditorPageComponent>, wired ascanDeactivateon theedit/:sectionroute. -
Step 1: Guard
Create src/app/features/project-editor/guards/project-editor-dirty.guard.ts:
import { inject } from '@angular/core';
import { CanDeactivateFn } from '@angular/router';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { ProjectEditorPageComponent } from '../pages/project-editor-page.component';
export const projectEditorDirtyGuard: CanDeactivateFn<ProjectEditorPageComponent> = () => {
const facade = inject(ProjectEditorFacade);
if (!facade.dirty()) {
return true;
}
return window.confirm('You have unsaved changes. Leave anyway?');
};
- Step 2: Wire it into the route
In src/app/app.routes.ts, update the edit/:section route added in Task 1:
{
path: 'edit/:section',
loadComponent: () => import('./features/project-editor/pages/project-editor-page.component').then(m => m.ProjectEditorPageComponent),
canDeactivate: [() => import('./features/project-editor/guards/project-editor-dirty.guard').then(m => m.projectEditorDirtyGuard())]
},
- Step 3: Warn on tab/window close
In src/app/features/project-editor/pages/project-editor-page.component.ts, add HostListener import and a listener method:
import { ChangeDetectionStrategy, Component, HostListener, effect, inject } from '@angular/core';
Add inside the class body:
@HostListener('window:beforeunload', ['$event'])
warnBeforeUnload(event: BeforeUnloadEvent): void {
if (this.facade.dirty()) {
event.preventDefault();
event.returnValue = '';
}
}
- Step 4: Manual verification
Run ng serve, open /edit/general, change the marketplace name (dirty state is now true). Try to navigate to /catalog via the URL bar or a link — confirm a native "Leave site?" confirm dialog appears; cancel it and confirm you're still on the editor. Confirm it, and confirm you land on /catalog. Reload the editor, make a change, then try closing/reloading the tab — confirm the browser's native "leave site" prompt appears (exact wording is browser-controlled, not customizable — this is expected beforeunload behavior).
- Step 5: Commit
git add src/app/features/project-editor/guards/project-editor-dirty.guard.ts src/app/app.routes.ts src/app/features/project-editor/pages/project-editor-page.component.ts
git commit -m "feat(project-editor): warn before leaving with unsaved changes"
Task 11: Documentation
Files:
- Modify:
docs/Project-Editor.md - Create:
docs/context/features/project-editor/FACTS.jsonl
Interfaces:
-
Consumes: nothing (documentation only).
-
Produces: nothing consumed by code — this is the "every new feature must include documentation" deliverable from the platform's coding rules and this plan's Global Constraints.
-
Step 1: Update
docs/Project-Editor.md
Replace the ## Supported Sections list item order and add new subsections after Preview, and replace ## Future Backend Endpoints with a section documenting the current client-side draft/publish behavior and the still-needed backend contract. Insert after the existing - Preview bullet block (before ## Preview Strategy):
- Languages (Sprint 16)
- add/remove supported locale
- set default locale
- generically syncs translation keys across static pages and navigation labels (`LocaleSyncService`)
- Navigation (Sprint 16)
- header navigation: add/remove/reorder/edit label/URL/visibility
- flat footer navigation: same actions
- grouped footer navigation (column-based) is read-only in this tab for now
Replace the ## Future Backend Endpoints section with:
## Draft / Publish (Sprint 16)
There is still no backend draft/publish API. This sprint models it client-side
in `ProjectEditorFacade`:
- `status: 'draft' | 'published'` and `dirty` (diffed against the
last-saved snapshot) live in facade state.
- `save()` snapshots the current in-memory bootstrap as "last saved" (no
network call yet).
- `publish()` runs `ProjectValidator`, and if there are no issues, applies
the bootstrap via `PlatformRuntimeService.reloadFromBootstrap` and marks
status `published`.
**Backend gap, not yet implemented:** real persistence needs
`PUT /builder/bootstrap/draft` and `POST /builder/bootstrap/publish`
endpoints so drafts/publishes survive a reload and are shared across editors.
## Validation
`ProjectValidator` (`services/project-validator.service.ts`) runs on every
render of the save bar: missing logo, no languages, invalid marketplace URL,
duplicate static-page slugs, empty homepage, a homepage widget with no
`type`, duplicate header navigation links, invalid theme colors. Publish is
blocked while any issue is present; Save is not.
## Rich HTML editing
Static page HTML is edited via `MarketplaceHtmlEditorComponent`
(`components/html-editor/`), a `contentEditable` + toolbar component with no
external dependency. It emits raw HTML on every change and never sanitizes —
sanitization remains a storefront-render concern.
- Step 2: Fact pack
Create the directory and file docs/context/features/project-editor/FACTS.jsonl:
{"id":"PE-20260713T010000Z-0001","subject":"project-editor-routing","predicate":"is","object":"flat routes under /edit/:section (no projectId — a project is the domain-resolved tenant); /builder and /project-editor redirect to /edit/general","src":["docs/superpowers/specs/2026-07-13-marketplace-project-editor-sprint16-design.md","src/app/app.routes.ts"],"status":"active","kind":"decision","updated_at":"2026-07-13T01:00:00Z","confidence":"high","tags":["project-editor","routing"]}
{"id":"PE-20260713T010000Z-0002","subject":"locale-sync","predicate":"is-implemented-by","object":"LocaleSyncService, which generically adds/removes a locale key across static page translations and navigation labels without per-field hardcoding","src":["src/app/features/project-editor/services/locale-sync.service.ts"],"status":"active","kind":"implemented","confidence":"high","updated_at":"2026-07-13T01:00:00Z","tags":["project-editor","i18n"]}
{"id":"PE-20260713T010000Z-0003","subject":"draft-publish-flow","predicate":"is","object":"client-side only (ProjectEditorFacade.status/dirty/save/publish) because no backend draft/publish endpoint exists yet; PUT /builder/bootstrap/draft and POST /builder/bootstrap/publish are the documented backend gap","src":["docs/Project-Editor.md","src/app/features/project-editor/facade/project-editor.facade.ts"],"status":"active","kind":"constraint","confidence":"high","updated_at":"2026-07-13T01:00:00Z","tags":["project-editor","backend-gap"]}
{"id":"PE-20260713T010000Z-0004","subject":"html-editing","predicate":"uses","object":"MarketplaceHtmlEditorComponent, a contentEditable + toolbar component with no external rich-text dependency; emits raw HTML, never sanitizes during editing","src":["src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.ts"],"status":"active","kind":"decision","confidence":"high","updated_at":"2026-07-13T01:00:00Z","tags":["project-editor","html-editor"]}
{"id":"PE-20260713T010000Z-0005","subject":"navigation-tab","predicate":"supports","object":"header navigation and flat-list footer navigation (add/remove/reorder/edit); grouped-column footer navigation is read-only until a future sprint","src":["src/app/features/project-editor/sections/navigation-section.component.ts"],"status":"active","kind":"constraint","confidence":"high","updated_at":"2026-07-13T01:00:00Z","tags":["project-editor","navigation"]}
- Step 3: Manual verification
Read both files back to confirm they render/parse: cat docs/Project-Editor.md (visually check the new sections landed in the right place) and validate the JSONL parses one-object-per-line: run node -e "require('fs').readFileSync('docs/context/features/project-editor/FACTS.jsonl','utf8').trim().split('\n').forEach(l => JSON.parse(l))" — expected: no output (no thrown error) means every line is valid JSON.
- Step 4: Commit
git add docs/Project-Editor.md docs/context/features/project-editor/FACTS.jsonl
git commit -m "docs(project-editor): document Sprint 16 tabs, draft/publish gap, and validation rules"
Follow-ups (explicitly out of scope, do not fold into the above)
- No test runner exists in this repo (
package.jsonhas none). Recommend a follow-up to add Karma+Jasmine or Jest so this feature (and everything else) gets real automated tests. - Real backend
PUT /builder/bootstrap/draft/POST /builder/bootstrap/publishendpoints. - A genuinely separate Angular app/deployment for
admin.marketplace.com, replacing this sprint's same-build flat routes. - Grouped (column-based) footer navigation editing.