# 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.