Files
marketplaces/docs/superpowers/plans/2026-07-13-marketplace-project-editor-sprint16.md
sdarbinyan e4a3ee25db docs: add Sprint 16 project editor implementation plan
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.
2026-07-13 07:45:48 +04:00

73 KiB
Raw Blame History

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.ts files 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 (exact ng serve steps, 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 to src/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.ts for 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 parallel ProjectConfig/EditorConfig type is created anywhere in this plan.
  • There is no projectId concept 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/:section that 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, BootstrapConfig from src/app/shared/models/config; StaticPageTranslationConfig, LocalizedTextContent likewise.

  • 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 locales computed

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: NavigationItemConfig from src/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 same target: '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, native contentEditable).

  • Produces: MarketplaceHtmlEditorComponent with @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>&nbsp;</td><td>&nbsp;</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 facade validationIssues computed 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 as canDeactivate on the edit/:section route.

  • 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.json has 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/publish endpoints.
  • A genuinely separate Angular app/deployment for admin.marketplace.com, replacing this sprint's same-build flat routes.
  • Grouped (column-based) footer navigation editing.