Files
marketplaces/docs/superpowers/plans/2026-07-13-marketplace-project-editor-sprint16.md
sdarbinyan e4a3ee25db docs: add Sprint 16 project editor implementation plan
11-task plan extending the existing /builder editor: route-driven
tabs, generic Languages/Navigation tabs, a dependency-free rich HTML
editor, client-side draft/publish + validation, and a dirty-state
guard. Notes the repo has no test runner configured, so tasks use
manual verification instead of automated specs.
2026-07-13 07:45:48 +04:00

1818 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sprint 16 — Marketplace Project Editor MVP Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Extend the existing `/builder` Project Editor into a routed, deep-linkable editor (`/edit/...`) with Languages, Navigation, a real HTML editor, client-side draft/publish, validation, and dirty-state protection — all still editing the same `BootstrapConfig` the storefront consumes.
**Architecture:** Keep `ProjectEditorFacade` (Angular signals, no NgRx) as the single source of truth. Add new signal-backed facade actions and two new pure services (`LocaleSyncService`, `ProjectValidator`). Add new tab components following the existing `sections/*-section.component.ts` pattern. No new state-management library, no new npm dependency.
**Tech Stack:** Angular 21.1.5 standalone components, Signals, `@angular/forms` (`FormsModule` + `ngModel`), `@angular/router`, existing `i18n/translate.pipe` + `translations.ts`/`en.ts`/`ru.ts`/`hy.ts` dictionaries.
## Global Constraints
- Angular is actually **21.1.5** in `package.json`, not 20 as the platform vision doc says — this plan targets the real installed version; syntax used (`@if`/`@for`/`@switch`, signals) is valid in both.
- **No test runner is configured in this repo** (no karma/jasmine/jest in `package.json`, zero `*.spec.ts` files exist). Writing Jasmine/Karma spec files here would not run under any configured tool and would be a placeholder. Every task below substitutes a **manual verification procedure** (exact `ng serve` steps, exact clicks/values, exact expected result) for an automated test step. Standing up a real test harness is out of scope for this plan — flagged as a follow-up, not silently skipped.
- State management: signal-based facade + `computed`, no NgRx, matching every existing feature (`ProjectEditorFacade`, `AdminProductsFacade`).
- Every new user-facing string goes through `TranslatePipe` (`{{ 'builder.x' | translate }}`) and is added to `src/app/i18n/translations.ts` (interface) + `en.ts` + `ru.ts` + `hy.ts` (implementations) in the same task that introduces it.
- New standalone components go in `src/app/features/project-editor/{sections,components,services,models,guards}/` following existing naming (`*-section.component.ts` for tabs).
- No new npm dependencies are introduced by this plan.
- `BootstrapConfig` (`src/app/shared/models/config/bootstrap-config.model.ts`) remains the only configuration model. No parallel `ProjectConfig`/`EditorConfig` type is created anywhere in this plan.
- There is no `projectId` concept anywhere — a "project" is the tenant resolved from the current domain. No route or model in this plan introduces an id segment.
---
### Task 1: Route-driven tabs (`/edit/:section`)
**Files:**
- Modify: `src/app/app.routes.ts:42-50`
- Modify: `src/app/features/project-editor/pages/project-editor-page.component.ts`
- Modify: `src/app/features/project-editor/components/project-editor-nav.component.ts`
- Modify: `src/app/features/project-editor/components/project-editor-nav.component.html`
**Interfaces:**
- Consumes: `ProjectEditorFacade.setActiveSection(id: ProjectEditorSectionId)`, `ProjectEditorFacade.activeSection: Signal<ProjectEditorSectionId>` (both already exist, unchanged).
- Produces: route path `/edit/:section` that every later task's new tab reuses (Languages → `/edit/languages`, Navigation → `/edit/navigation`).
- [ ] **Step 1: Update routes**
Replace lines 42-50 of `src/app/app.routes.ts`:
```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
<nav class="editor-nav" [attr.aria-label]="'builder.title' | translate">
@for (section of sections; track section.id) {
<a
[routerLink]="['/edit', section.id]"
routerLinkActive="active"
class="editor-nav-link"
>{{ section.label | translate }}</a>
}
</nav>
```
Add to `src/app/features/project-editor/components/project-editor-nav.component.scss` (append; file already styles `.editor-nav` and buttons — add a matching anchor rule so links look identical to the old buttons):
```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/<section>` for each and the correct tab content shows. Reload the browser on `/edit/footer` directly; confirm the Footer tab renders (not General) — this proves the route param, not just the click, drives the active tab.
- [ ] **Step 5: Commit**
```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<T>(
record: Record<string, T> | undefined,
locale: string,
mode: SyncMode,
createEmpty: () => T
): Record<string, T> {
const next: Record<string, T> = { ...(record ?? {}) };
if (mode === 'add') {
if (!(locale in next)) {
next[locale] = createEmpty();
}
} else {
delete next[locale];
}
return next;
}
}
```
- [ ] **Step 2: Wire into the facade**
In `src/app/features/project-editor/facade/project-editor.facade.ts`, add the import and the three methods (insert after `setActiveSection`, before `private normalize`):
```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
<section class="editor-section-card">
<div class="editor-actions">
<h2>{{ 'builder.languagesTab' | translate }}</h2>
</div>
<div class="editor-actions">
<input
type="text"
[ngModel]="newLocale()"
(ngModelChange)="updateNewLocale($event)"
placeholder="de"
/>
<button type="button" (click)="addLocale()">{{ 'builder.addLanguage' | translate }}</button>
</div>
<div class="stack-list">
@for (code of locales(); track code) {
<article class="sub-card">
<div class="editor-actions">
<h3>{{ code }}</h3>
<div class="editor-actions">
@if (code === defaultLocale()) {
<span>{{ 'builder.defaultLanguageLabel' | translate }}</span>
} @else {
<button type="button" class="secondary" (click)="setDefault(code)">{{ 'builder.setDefaultLanguage' | translate }}</button>
<button type="button" class="secondary" (click)="removeLocale(code)">{{ 'builder.removeLanguage' | translate }}</button>
}
</div>
</div>
</article>
}
</div>
</section>
```
- [ ] **Step 5: Register the tab**
In `src/app/features/project-editor/pages/project-editor-page.component.ts`, add the import and register it in `imports`:
```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') { <app-project-editor-languages-section /> }
```
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<string[]>` computed on the component, consumed by its own template only.
- [ ] **Step 1: Add a `locales` computed**
In `src/app/features/content-management/components/static-pages-editor.component.ts`, add after the existing `readonly validation = ...` line:
```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<NavigationItemConfig>): void {
this.updateBootstrap(current => {
const list = current.navigation[target];
if (!this.isFlatNavList(list)) {
return current;
}
return {
...current,
navigation: {
...current.navigation,
[target]: (list as NavigationItemConfig[]).map(item => (item.id !== id ? item : { ...item, ...patch })),
},
};
});
}
reorderNavLink(target: 'header' | 'footer', id: string, direction: -1 | 1): void {
this.updateBootstrap(current => {
const list = current.navigation[target];
if (!this.isFlatNavList(list)) {
return current;
}
const items = [...(list as NavigationItemConfig[])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const index = items.findIndex(item => item.id === id);
const nextIndex = index + direction;
if (index < 0 || nextIndex < 0 || nextIndex >= items.length) {
return current;
}
const tmp = items[index];
items[index] = items[nextIndex];
items[nextIndex] = tmp;
return {
...current,
navigation: { ...current.navigation, [target]: items.map((item, order) => ({ ...item, order: order + 1 })) },
};
});
}
private isFlatNavList(list: NavigationItemConfig[] | { items: unknown }[]): list is NavigationItemConfig[] {
return list.length === 0 || !('items' in list[0]);
}
```
- [ ] **Step 2: Add the section id**
In `src/app/features/project-editor/models/project-editor.model.ts`, add `'navigation'` to the `ProjectEditorSectionId` union (next to `'languages'`):
```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<NavigationItemConfig[] | null>(() => {
const footer = this.bootstrap()?.navigation.footer ?? [];
if (footer.length === 0) {
return [];
}
return 'items' in footer[0] ? null : this.sorted(footer as NavigationItemConfig[]);
});
labelOf(item: NavigationItemConfig): string {
if (typeof item.label === 'string' || !item.label) {
return item.label ?? '';
}
const defaultLocale = this.bootstrap()?.localization.defaultLocale ?? 'en';
return item.label[defaultLocale] ?? Object.values(item.label)[0] ?? '';
}
addLink(target: 'header' | 'footer'): void {
this.facade.addNavLink(target);
}
removeLink(target: 'header' | 'footer', id: string): void {
this.facade.removeNavLink(target, id);
}
move(target: 'header' | 'footer', id: string, direction: -1 | 1): void {
this.facade.reorderNavLink(target, id, direction);
}
updateLabel(target: 'header' | 'footer', id: string, value: string): void {
this.facade.updateNavLink(target, id, { label: value });
}
updateRoute(target: 'header' | 'footer', id: string, value: string): void {
this.facade.updateNavLink(target, id, { route: value });
}
updateVisible(target: 'header' | 'footer', id: string, value: boolean): void {
this.facade.updateNavLink(target, id, { visible: value });
}
private sorted(items: NavigationItemConfig[]): NavigationItemConfig[] {
return [...items].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
}
```
Create `src/app/features/project-editor/sections/navigation-section.component.html`:
```html
<section class="editor-section-card">
<div class="editor-actions">
<h2>{{ 'builder.navigationHeader' | translate }}</h2>
<button type="button" (click)="addLink('header')">{{ 'builder.addLink' | translate }}</button>
</div>
<div class="stack-list">
@for (item of headerLinks(); track item.id) {
<article class="sub-card">
<div class="editor-actions">
<h3>{{ labelOf(item) }}</h3>
<div class="editor-actions">
<button type="button" class="secondary" (click)="move('header', item.id, -1)"></button>
<button type="button" class="secondary" (click)="move('header', item.id, 1)"></button>
<button type="button" class="secondary" (click)="removeLink('header', item.id)">{{ 'builder.removeLink' | translate }}</button>
</div>
</div>
<div class="editor-grid three">
<label><span>{{ 'builder.linkLabel' | translate }}</span><input type="text" [ngModel]="labelOf(item)" (ngModelChange)="updateLabel('header', item.id, $event)" /></label>
<label><span>{{ 'builder.linkUrl' | translate }}</span><input type="text" [ngModel]="item.route" (ngModelChange)="updateRoute('header', item.id, $event)" /></label>
<label class="toggle-row"><input type="checkbox" [checked]="item.visible !== false" (change)="updateVisible('header', item.id, $any($event.target).checked)" /><span>{{ 'builder.visible' | translate }}</span></label>
</div>
</article>
}
</div>
<div class="editor-actions">
<h2>{{ 'builder.navigationFooter' | translate }}</h2>
@if (footerLinks(); as footer) {
<button type="button" (click)="addLink('footer')">{{ 'builder.addLink' | translate }}</button>
}
</div>
@if (footerLinks(); as footer) {
<div class="stack-list">
@for (item of footer; track item.id) {
<article class="sub-card">
<div class="editor-actions">
<h3>{{ labelOf(item) }}</h3>
<div class="editor-actions">
<button type="button" class="secondary" (click)="move('footer', item.id, -1)"></button>
<button type="button" class="secondary" (click)="move('footer', item.id, 1)"></button>
<button type="button" class="secondary" (click)="removeLink('footer', item.id)">{{ 'builder.removeLink' | translate }}</button>
</div>
</div>
<div class="editor-grid three">
<label><span>{{ 'builder.linkLabel' | translate }}</span><input type="text" [ngModel]="labelOf(item)" (ngModelChange)="updateLabel('footer', item.id, $event)" /></label>
<label><span>{{ 'builder.linkUrl' | translate }}</span><input type="text" [ngModel]="item.route" (ngModelChange)="updateRoute('footer', item.id, $event)" /></label>
<label class="toggle-row"><input type="checkbox" [checked]="item.visible !== false" (change)="updateVisible('footer', item.id, $any($event.target).checked)" /><span>{{ 'builder.visible' | translate }}</span></label>
</div>
</article>
}
</div>
} @else {
<p class="editor-error">{{ 'builder.navigationFooterGrouped' | translate }}</p>
}
</section>
```
- [ ] **Step 4: Register the tab**
In `src/app/features/project-editor/pages/project-editor-page.component.ts`, add the import, add to `imports`, and add `'navigation'` to `KNOWN_SECTIONS`:
```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') { <app-project-editor-navigation-section /> }
```
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<string>` — 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: '<table><tr><td>&nbsp;</td><td>&nbsp;</td></tr></table>' },
];
@Component({
selector: 'app-marketplace-html-editor',
standalone: true,
imports: [],
templateUrl: './marketplace-html-editor.component.html',
styleUrls: ['./marketplace-html-editor.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MarketplaceHtmlEditorComponent implements OnChanges {
@Input() html = '';
@Output() htmlChange = new EventEmitter<string>();
@ViewChild('surface', { static: true }) surface!: ElementRef<HTMLDivElement>;
readonly toolbar = HTML_EDITOR_TOOLBAR;
readonly showCode = signal(false);
readonly codeValue = signal('');
ngOnChanges(changes: SimpleChanges): void {
if (changes['html'] && this.surface && this.surface.nativeElement.innerHTML !== (this.html || '')) {
this.surface.nativeElement.innerHTML = this.html || '';
}
}
runCommand(item: HtmlEditorToolbarCommand): void {
this.surface.nativeElement.focus();
if (item.command === 'createLink') {
const url = window.prompt('URL');
if (!url) {
return;
}
document.execCommand('createLink', false, url);
} else if (item.command === 'insertImage') {
const url = window.prompt('Image URL');
if (!url) {
return;
}
document.execCommand('insertImage', false, url);
} else {
document.execCommand(item.command, false, item.value);
}
this.emitChange();
}
onInput(): void {
this.emitChange();
}
toggleCode(): void {
if (!this.showCode()) {
this.codeValue.set(this.surface.nativeElement.innerHTML);
this.showCode.set(true);
return;
}
this.surface.nativeElement.innerHTML = this.codeValue();
this.showCode.set(false);
this.emitChange();
}
updateCode(value: string): void {
this.codeValue.set(value);
}
private emitChange(): void {
this.htmlChange.emit(this.surface.nativeElement.innerHTML);
}
}
```
- [ ] **Step 2: Template**
Create `src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.html`:
```html
<div class="html-editor">
<div class="html-editor-toolbar">
@for (item of toolbar; track item.id) {
<button type="button" (click)="runCommand(item)">{{ item.label }}</button>
}
<button type="button" (click)="toggleCode()">{{ showCode() ? 'Preview' : 'Code' }}</button>
</div>
@if (showCode()) {
<textarea
class="html-editor-code"
rows="10"
[value]="codeValue()"
(input)="updateCode($any($event.target).value)"
></textarea>
} @else {
<div
#surface
class="html-editor-surface"
contenteditable="true"
(input)="onInput()"
></div>
}
</div>
```
- [ ] **Step 3: Styles**
Create `src/app/features/project-editor/components/html-editor/marketplace-html-editor.component.scss`:
```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 `<textarea>` with the HTML editor
**Files:**
- Modify: `src/app/features/content-management/components/static-pages-editor.component.ts`
- Modify: `src/app/features/content-management/components/static-pages-editor.component.html`
**Interfaces:**
- Consumes: `MarketplaceHtmlEditorComponent` (`[html]` input, `(htmlChange)` output) from Task 5.
- Produces: nothing new — same `updateTranslation(id, locale, 'html', value)` method is now driven by `(htmlChange)` instead of `(ngModelChange)`.
- [ ] **Step 1: Import the component**
In `src/app/features/content-management/components/static-pages-editor.component.ts`, add the import and register it:
```typescript
import { MarketplaceHtmlEditorComponent } from '../../project-editor/components/html-editor/marketplace-html-editor.component';
```
Change the `imports` array from `[FormsModule, TranslatePipe]` to:
```typescript
imports: [FormsModule, TranslatePipe, MarketplaceHtmlEditorComponent],
```
- [ ] **Step 2: Swap the textarea for the editor**
In `src/app/features/content-management/components/static-pages-editor.component.html`, replace:
```html
<label class="full">
<span>{{ 'builder.htmlPreview' | translate }} {{ locale }}</span>
<textarea rows="6" [ngModel]="page.translations[locale]?.html || ''" (ngModelChange)="updateTranslation(page.id, locale, 'html', $event)"></textarea>
</label>
```
with:
```html
<label class="full">
<span>{{ 'builder.htmlPreview' | translate }} {{ locale }}</span>
<app-marketplace-html-editor
[html]="page.translations[locale]?.html || ''"
(htmlChange)="updateTranslation(page.id, locale, 'html', $event)"
/>
</label>
```
- [ ] **Step 3: Manual verification**
Run `ng serve`, open `/edit/static-pages`, create a page. Confirm each locale's HTML field is now the toolbar editor, not a plain textarea. Click Bold, type text — confirm it renders bold in the editing surface. Click "Code", confirm the raw HTML (e.g. `<b>...</b>`) shows in a textarea, edit it, click "Code" again (now labeled "Preview") — confirm the surface re-renders your manual HTML edit. Go to `/edit/preview`, export JSON, confirm the page's `translations.<locale>.html` contains the exact HTML you produced (bold tags etc.) — proving nothing sanitized it in the editor.
- [ ] **Step 4: Commit**
```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<string, string>; route?: string }): string =>
`${typeof item.label === 'string' ? item.label : JSON.stringify(item.label ?? {})}|${item.route ?? ''}`;
const keys = bootstrap.navigation.header.map(keyOf);
const hasDuplicates = keys.some((key, index) => keys.indexOf(key) !== index);
return hasDuplicates ? [{ code: 'duplicate-nav-links', message: 'builder.validationDuplicateNavLinks' }] : [];
}
private colorIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
const invalid = Object.values(bootstrap.theme.palette).some(value => !HEX_COLOR.test(value));
return invalid ? [{ code: 'invalid-colors', message: 'builder.validationInvalidColors' }] : [];
}
}
```
- [ ] **Step 2: i18n keys**
In `src/app/i18n/translations.ts`, add to the `builder` interface:
```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<boolean>`, `.validationIssues: Signal<ProjectValidationIssue[]>`, `.save(): void`, `.publish(): boolean` — consumed by Task 9 (save bar) and Task 10 (dirty guard).
- [ ] **Step 1: Extend the state shape**
In `src/app/features/project-editor/models/project-editor.model.ts`, change `ProjectEditorState`:
```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<ProjectEditorState>({
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
<div class="project-editor-save-bar">
<div class="project-editor-save-bar-status">
<span>{{ (status() === 'published' ? 'builder.statusPublished' : 'builder.statusDraft') | translate }}</span>
@if (dirty()) {
<span class="project-editor-save-bar-dirty">{{ 'builder.unsavedChanges' | translate }}</span>
}
@if (issues().length > 0) {
<ul class="project-editor-save-bar-issues">
@for (issue of issues(); track issue.code) {
<li>{{ issue.message | translate }}</li>
}
</ul>
}
</div>
<div class="project-editor-save-bar-actions">
<button type="button" (click)="save()">{{ 'builder.save' | translate }}</button>
<button type="button" [disabled]="issues().length > 0" (click)="publish()">{{ 'builder.publish' | translate }}</button>
</div>
</div>
```
- [ ] **Step 3: Styles**
Create `src/app/features/project-editor/components/save-bar/project-editor-save-bar.component.scss`:
```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 `</section>` of `project-editor-stack` (still inside the `@else` block, as a sibling after the stack section):
```html
<app-project-editor-save-bar />
```
- [ ] **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<ProjectEditorPageComponent>`, 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<ProjectEditorPageComponent> = () => {
const facade = inject(ProjectEditorFacade);
if (!facade.dirty()) {
return true;
}
return window.confirm('You have unsaved changes. Leave anyway?');
};
```
- [ ] **Step 2: Wire it into the route**
In `src/app/app.routes.ts`, update the `edit/:section` route added in Task 1:
```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.