diff --git a/docs/superpowers/plans/2026-07-13-marketplace-project-editor-sprint16.md b/docs/superpowers/plans/2026-07-13-marketplace-project-editor-sprint16.md new file mode 100644 index 0000000..d737fe3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-marketplace-project-editor-sprint16.md @@ -0,0 +1,1817 @@ +# 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` (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`: + +```typescript + { + 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`: + +```typescript +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`: + +```typescript +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`: + +```html + +``` + +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): + +```scss +.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/
` 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** + +```bash +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`: + +```typescript +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( + record: Record | undefined, + locale: string, + mode: SyncMode, + createEmpty: () => T + ): Record { + const next: Record = { ...(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`): + +```typescript +import { LocaleSyncService } from '../services/locale-sync.service'; +``` + +```typescript + private readonly localeSync = inject(LocaleSyncService); +``` + +```typescript + 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: + +```typescript +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`: + +```typescript +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`: + +```html +
+
+

{{ 'builder.languagesTab' | translate }}

+
+ +
+ + +
+ +
+ @for (code of locales(); track code) { +
+
+

{{ code }}

+
+ @if (code === defaultLocale()) { + {{ 'builder.defaultLanguageLabel' | translate }} + } @else { + + + } +
+
+
+ } +
+
+``` + +- [ ] **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`: + +```typescript +import { ProjectEditorLanguagesSectionComponent } from '../sections/languages-section.component'; +``` + +Add `ProjectEditorLanguagesSectionComponent` to the `imports` array (after `ProjectEditorFeaturesSectionComponent`), and add `'languages'` to `KNOWN_SECTIONS`: + +```typescript +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): + +```html + @case ('languages') { } +``` + +In `src/app/features/project-editor/components/project-editor-nav.component.ts`, add to `sections` (after `features`, before `preview`): + +```typescript + { id: 'languages', label: 'builder.languagesTab' }, +``` + +- [ ] **Step 6: i18n keys** + +In `src/app/i18n/translations.ts`, inside the `builder: { ... }` interface block, add (after `livePreview: string;`): + +```typescript + languagesTab: string; + addLanguage: string; + removeLanguage: string; + setDefaultLanguage: string; + defaultLanguageLabel: string; +``` + +In `src/app/i18n/en.ts`, inside `builder: { ... }`, add: + +```typescript + languagesTab: 'Languages', + addLanguage: 'Add Language', + removeLanguage: 'Remove', + setDefaultLanguage: 'Set as Default', + defaultLanguageLabel: 'Default', +``` + +In `src/app/i18n/ru.ts`, inside `builder: { ... }`, add: + +```typescript + languagesTab: 'Языки', + addLanguage: 'Добавить язык', + removeLanguage: 'Удалить', + setDefaultLanguage: 'Сделать языком по умолчанию', + defaultLanguageLabel: 'По умолчанию', +``` + +In `src/app/i18n/hy.ts`, inside `builder: { ... }`, add: + +```typescript + 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** + +```bash +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` 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: + +```typescript + 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: + +```html + @for (locale of ['en','ru','hy']; track locale) { +``` + +to: + +```html + @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** + +```bash +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: + +```typescript +import { NavigationItemConfig } from '../../../shared/models/config'; +``` + +Add these methods after `setDefaultLocale` (from Task 2), before `private normalize`: + +```typescript + 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): 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'`): + +```typescript +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`: + +```typescript +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(() => { + 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`: + +```html +
+
+

{{ 'builder.navigationHeader' | translate }}

+ +
+ +
+ @for (item of headerLinks(); track item.id) { +
+
+

{{ labelOf(item) }}

+
+ + + +
+
+
+ + + +
+
+ } +
+ +
+

{{ 'builder.navigationFooter' | translate }}

+ @if (footerLinks(); as footer) { + + } +
+ + @if (footerLinks(); as footer) { +
+ @for (item of footer; track item.id) { +
+
+

{{ labelOf(item) }}

+
+ + + +
+
+
+ + + +
+
+ } +
+ } @else { +

{{ 'builder.navigationFooterGrouped' | translate }}

+ } +
+``` + +- [ ] **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`: + +```typescript +import { ProjectEditorNavigationSectionComponent } from '../sections/navigation-section.component'; +``` + +```typescript +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`): + +```html + @case ('navigation') { } +``` + +In `src/app/features/project-editor/components/project-editor-nav.component.ts`, add (after `languages`, before `preview`): + +```typescript + { 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): + +```typescript + navigationTab: string; + navigationHeader: string; + navigationFooter: string; + navigationFooterGrouped: string; + addLink: string; + removeLink: string; + linkLabel: string; + linkUrl: string; +``` + +In `src/app/i18n/en.ts`, add: + +```typescript + 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: + +```typescript + navigationTab: 'Навигация', + navigationHeader: 'Навигация в шапке', + navigationFooter: 'Навигация в подвале', + navigationFooterGrouped: 'Навигация подвала сгруппирована по колонкам и пока недоступна для редактирования здесь — используйте вкладку "Подвал".', + addLink: 'Добавить ссылку', + removeLink: 'Удалить', + linkLabel: 'Название', + linkUrl: 'URL', +``` + +In `src/app/i18n/hy.ts`, add: + +```typescript + 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** + +```bash +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` — consumed by Task 6. + +- [ ] **Step 1: Component** + +Create `src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.ts`: + +```typescript +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: '
  
' }, +]; + +@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(); + @ViewChild('surface', { static: true }) surface!: ElementRef; + + 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`: + +```html +
+
+ @for (item of toolbar; track item.id) { + + } + +
+ + @if (showCode()) { + + } @else { +
+ } +
+``` + +- [ ] **Step 3: Styles** + +Create `src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.scss`: + +```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** + +```bash +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 ` + +``` + +with: + +```html + +``` + +- [ ] **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. `...`) 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..html` contains the exact HTML you produced (bold tags etc.) — proving nothing sanitized it in the editor. + +- [ ] **Step 4: Commit** + +```bash +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`: + +```typescript +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; 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: + +```typescript + validationMissingLogo: string; + validationNoLanguages: string; + validationInvalidUrl: string; + validationDuplicateSlugs: string; + validationEmptyHomepage: string; + validationMissingWidget: string; + validationDuplicateNavLinks: string; + validationInvalidColors: string; +``` + +In `src/app/i18n/en.ts`, add: + +```typescript + 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: + +```typescript + validationMissingLogo: 'В брендинге отсутствует логотип.', + validationNoLanguages: 'Не настроены языки.', + validationInvalidUrl: 'Некорректный URL маркетплейса.', + validationDuplicateSlugs: 'Две или более статические страницы имеют одинаковый slug.', + validationEmptyHomepage: 'На главной странице нет секций.', + validationMissingWidget: 'В секции главной страницы есть виджет без типа.', + validationDuplicateNavLinks: 'Две или более ссылки в навигации шапки дублируются.', + validationInvalidColors: 'Один или несколько цветов темы указаны некорректно.', +``` + +In `src/app/i18n/hy.ts`, add: + +```typescript + 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** + +```bash +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`, `.validationIssues: Signal`, `.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`: + +```typescript +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: + +```typescript +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): + +```typescript + private readonly runtime = inject(PlatformRuntimeService); + private readonly validator = inject(ProjectValidator); +``` + +Update the initial state: + +```typescript + private readonly state = signal({ + bootstrap: null, + importError: null, + activeSection: 'general', + status: 'draft', + lastSavedBootstrap: null, + }); +``` + +Add these computed signals (after `homepageWidgets`): + +```typescript + 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`: + +```typescript + 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): + +```typescript + 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** + +```bash +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`: + +```typescript +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`: + +```html +
+
+ {{ (status() === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }} + @if (dirty()) { + {{ 'builder.unsavedChanges' | translate }} + } + @if (issues().length > 0) { +
    + @for (issue of issues(); track issue.code) { +
  • {{ issue.message | translate }}
  • + } +
+ } +
+
+ + +
+
+``` + +- [ ] **Step 3: Styles** + +Create `src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.scss`: + +```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`: + +```typescript +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 `
` of `project-editor-stack` (still inside the `@else` block, as a sibling after the stack section): + +```html + +``` + +- [ ] **Step 5: i18n keys** + +In `src/app/i18n/translations.ts`, add to the `builder` interface: + +```typescript + statusDraft: string; + statusPublished: string; + unsavedChanges: string; + save: string; + publish: string; +``` + +In `src/app/i18n/en.ts`, add: + +```typescript + statusDraft: 'Draft', + statusPublished: 'Published', + unsavedChanges: 'Unsaved changes', + save: 'Save', + publish: 'Publish', +``` + +In `src/app/i18n/ru.ts`, add: + +```typescript + statusDraft: 'Черновик', + statusPublished: 'Опубликовано', + unsavedChanges: 'Есть несохранённые изменения', + save: 'Сохранить', + publish: 'Опубликовать', +``` + +In `src/app/i18n/hy.ts`, add: + +```typescript + 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** + +```bash +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`, wired as `canDeactivate` on the `edit/:section` route. + +- [ ] **Step 1: Guard** + +Create `src/app/features/project-editor/guards/project-editor-dirty.guard.ts`: + +```typescript +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 = () => { + 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: + +```typescript + { + 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: + +```typescript +import { ChangeDetectionStrategy, Component, HostListener, effect, inject } from '@angular/core'; +``` + +Add inside the class body: + +```typescript + @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** + +```bash +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`): + +```markdown +- 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: + +```markdown +## 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** + +```bash +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.