feat(builder): redesign marketplace builder experience and navigation

Renamed the experience consistently to Marketplace Builder everywhere
visible (page title/subtitle, breadcrumbs, sidebar, dashboard quick
action/shortcut copy) - internal ProjectEditor class/selector names
kept as-is to avoid regressions. builder.title/subtitle no longer say
'bootstrap-config editing surface' to end users.

New business-oriented IA (builder-groups.model.ts): 12 existing
ProjectEditorSectionIds regrouped into 8 groups (Marketplace, Branding
and Design, Homepage, Content, Languages, Marketplace Features,
Navigation and Search, Preview) - every existing section mapped to
exactly one group, none dropped, no group points at a page that
doesn't exist.

New Builder landing page at /edit (was a redirect straight into the
General form): readiness percent, recommended next step, one overview
card per group (purpose + real complete/in-progress/not-started/unknown
status, icon+text never color-alone), a Marketplace Readiness
checklist, quick links. All derived from real facade/schema signals
(required-field fill state, modifiedSections, staticPages, catalog
counts via BackofficeDataService) or explicitly marked unknown
(the "preview reviewed" check has no tracking - shown as unknown,
never guessed).

Section pages (/edit/:section) now share one consistent header:
breadcrumb (Builder > Group > Section), draft/published + modified
badges, and a contextual help panel (what is this / where visible /
what happens if you skip it) sourced per group. Sidebar nav rewritten
as grouped, icon-led sections with per-section and per-group status
indicators; layout converted from a top pill-tab bar to a responsive
sidebar (desktop 280px, tablet 72px icon rail, mobile drawer with
Escape-to-close), matching the Sprint 1 admin shell pattern. Section
form components themselves untouched.

Routes: 'edit' is now its own landing route instead of redirecting to
'edit/general'; /builder and /project-editor redirect to 'edit'.
Sprint 1/2 destinations that pointed at edit/general now point at the
new edit landing page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sdarbinyan
2026-07-18 09:43:03 +04:00
parent 419cb9a32c
commit 821a1c6ba8
19 changed files with 1699 additions and 152 deletions

View File

@@ -1,10 +1,32 @@
<nav class="editor-nav" [attr.aria-label]="'builder.title' | translate">
@for (section of sections; track section.id) {
<a
[routerLink]="['/edit', section.id] | langRoute"
routerLinkActive="active"
[ariaCurrentWhenActive]="'page'"
class="editor-nav-link"
>{{ section.label | translate }}@if (issueCount(section.id); as count) {<span class="editor-nav-badge" [attr.aria-label]="count + ' ' + ('builder.title' | translate)">{{ count }}</span>} @else if (isModified(section.id)) {<span class="editor-nav-dot" [attr.aria-label]="'builder.unsavedChanges' | translate"></span>}</a>
<nav class="editor-nav" [attr.aria-label]="'builder.appName' | translate">
@for (item of groups(); track item.group.id) {
<div class="editor-nav-group">
<div class="editor-nav-group__header">
<span class="pi {{ item.group.icon }} editor-nav-group__icon" aria-hidden="true"></span>
<span class="editor-nav-group__label">{{ item.group.labelKey | translate }}</span>
<span class="pi {{ item.statusIcon }} editor-nav-group__status editor-nav-group__status--{{ item.status }}" [attr.aria-label]="item.statusLabelKey | translate"></span>
</div>
<ul class="editor-nav-group__list">
@for (section of item.sections; track section.id) {
<li>
<a
[routerLink]="['/edit', section.id] | langRoute"
routerLinkActive="active"
[ariaCurrentWhenActive]="'page'"
class="editor-nav-link"
(click)="onLinkClick()"
>
<span class="pi {{ section.statusIcon }} editor-nav-link__status editor-nav-link__status--{{ section.status }}" [attr.aria-label]="section.statusLabelKey | translate"></span>
<span class="editor-nav-link__label">{{ section.labelKey | translate }}</span>
@if (section.issueCount > 0) {
<span class="editor-nav-badge" [attr.aria-label]="section.issueCount + ' ' + ('builder.title' | translate)">{{ section.issueCount }}</span>
} @else if (section.modified) {
<span class="editor-nav-dot" [attr.aria-label]="'builder.unsavedChanges' | translate"></span>
}
</a>
</li>
}
</ul>
</div>
}
</nav>

View File

@@ -1,79 +1,144 @@
.editor-nav {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm, 10px);
flex-direction: column;
gap: var(--space-md);
}
.editor-nav-group__header {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: 0 var(--space-sm);
margin-bottom: var(--space-xs);
}
.editor-nav-group__icon {
color: var(--primary-color);
font-size: 1rem;
}
.editor-nav-group__label {
flex: 1 1 auto;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
color: var(--text-light);
}
.editor-nav-group__status {
font-size: 1rem;
}
.editor-nav-group__status--complete {
color: var(--success-color);
}
.editor-nav-group__status--in-progress {
color: var(--warning-color);
}
.editor-nav-group__status--not-started {
color: var(--text-light);
}
.editor-nav-group__status--unknown {
color: var(--text-light);
opacity: 0.6;
}
.editor-nav-group__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.editor-nav-link {
min-height: 40px;
border-radius: var(--radius-full, 999px);
border: 1px solid var(--border-color, #d3dad9);
background: var(--bg-primary, #fff);
color: var(--text-primary, #1e3c38);
padding: 0 var(--space-md, 14px);
font-weight: 600;
display: flex;
align-items: center;
gap: var(--space-sm);
min-height: 38px;
border-radius: var(--radius-sm);
border: none;
background: none;
color: var(--text-primary);
padding: 0 var(--space-sm);
font-weight: 500;
font-size: 1rem;
cursor: pointer;
text-decoration: none;
display: inline-block;
line-height: 38px;
transition: background-color var(--transition-fast, 120ms ease),
border-color var(--transition-fast, 120ms ease),
color var(--transition-fast, 120ms ease);
&:hover {
border-color: var(--primary-color, #2f6e5d);
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
color: var(--primary-color);
}
&:focus-visible {
outline: 2px solid var(--primary-color, #2f6e5d);
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
&.active {
border-color: var(--primary-color, #2f6e5d);
background: var(--primary-color, #2f6e5d);
color: var(--bg-primary, #fff);
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
color: var(--primary-color);
font-weight: 600;
}
}
.editor-nav-link__status {
font-size: 1rem;
flex-shrink: 0;
}
.editor-nav-link__status--complete {
color: var(--success-color);
}
.editor-nav-link__status--in-progress {
color: var(--warning-color);
}
.editor-nav-link__status--not-started {
color: var(--text-light);
}
.editor-nav-link__status--unknown {
color: var(--text-light);
opacity: 0.5;
}
.editor-nav-link__label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editor-nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
margin-left: 6px;
padding: 0 5px;
border-radius: 999px;
background: var(--error-color, #ef4444);
border-radius: var(--radius-lg);
background: var(--error-color);
color: #fff;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
vertical-align: middle;
}
.editor-nav-link.active .editor-nav-badge {
background: #fff;
color: var(--error-color, #ef4444);
flex-shrink: 0;
}
.editor-nav-dot {
display: inline-block;
width: 6px;
height: 6px;
margin-left: 6px;
border-radius: 999px;
background: var(--warning-color, #f59e0b);
vertical-align: middle;
}
.editor-nav-link.active .editor-nav-dot {
background: #fff;
}
@media (prefers-reduced-motion: reduce) {
.editor-nav-link {
transition: none;
}
border-radius: var(--radius-lg);
background: var(--warning-color);
flex-shrink: 0;
}

View File

@@ -1,9 +1,57 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { TranslatePipe } from '../../../i18n/translate.pipe';
import { LangRoutePipe } from '../../../pipes/lang-route.pipe';
import { ProjectEditorSectionId } from '../models/project-editor.model';
import { ProjectEditorSectionId, BuilderSectionStatus } from '../models/project-editor.model';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { BUILDER_GROUPS, BuilderGroup } from '../builder/builder-groups.model';
const STATUS_ICON: Record<BuilderSectionStatus, string> = {
complete: 'pi-check-circle',
'in-progress': 'pi-circle-fill',
'not-started': 'pi-circle',
unknown: 'pi-question-circle',
};
const STATUS_LABEL_KEY: Record<BuilderSectionStatus, string> = {
complete: 'builder.statusComplete',
'in-progress': 'builder.statusInProgress',
'not-started': 'builder.statusNotStarted',
unknown: 'builder.statusUnknown',
};
interface NavSectionItem {
id: ProjectEditorSectionId;
labelKey: string;
status: BuilderSectionStatus;
statusIcon: string;
statusLabelKey: string;
modified: boolean;
issueCount: number;
}
interface NavGroupItem {
group: BuilderGroup;
status: BuilderSectionStatus;
statusIcon: string;
statusLabelKey: string;
sections: NavSectionItem[];
}
const SECTION_LABEL_KEYS: Record<ProjectEditorSectionId, string> = {
general: 'builder.general',
branding: 'builder.branding',
theme: 'builder.theme',
header: 'builder.header',
footer: 'builder.footer',
homepage: 'builder.homepage',
widgets: 'builder.widgets',
'static-pages': 'builder.staticPages',
features: 'builder.marketplaceFeatures',
languages: 'builder.languagesTab',
navigation: 'builder.navigationTab',
preview: 'builder.preview',
};
@Component({
selector: 'app-project-editor-nav',
@@ -11,35 +59,40 @@ import { ProjectEditorFacade } from '../facade/project-editor.facade';
imports: [TranslatePipe, RouterLink, RouterLinkActive, LangRoutePipe],
templateUrl: './project-editor-nav.component.html',
styleUrls: ['./project-editor-nav.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ProjectEditorNavComponent {
private readonly facade = inject(ProjectEditorFacade);
readonly linkClicked = output<void>();
private readonly issuesBySection = this.facade.issuesBySection;
private readonly modifiedSections = this.facade.modifiedSections;
private readonly sectionStatus = this.facade.sectionStatus;
private readonly groupStatus = this.facade.groupStatus;
/** Count of blocking issues in a section, for the nav badge. */
issueCount(sectionId: ProjectEditorSectionId): number {
return this.issuesBySection().get(sectionId) ?? 0;
readonly groups = computed<NavGroupItem[]>(() =>
BUILDER_GROUPS.map(group => ({
group,
status: this.groupStatus().get(group.id) ?? 'unknown',
statusIcon: STATUS_ICON[this.groupStatus().get(group.id) ?? 'unknown'],
statusLabelKey: STATUS_LABEL_KEY[this.groupStatus().get(group.id) ?? 'unknown'],
sections: group.sections.map(id => {
const status = this.sectionStatus().get(id) ?? 'unknown';
return {
id,
labelKey: SECTION_LABEL_KEYS[id],
status,
statusIcon: STATUS_ICON[status],
statusLabelKey: STATUS_LABEL_KEY[status],
modified: this.modifiedSections().has(id),
issueCount: this.issuesBySection().get(id) ?? 0,
};
}),
})),
);
onLinkClick(): void {
this.linkClicked.emit();
}
/** Whether a section has unsaved modified fields, for the nav dot. */
isModified(sectionId: ProjectEditorSectionId): boolean {
return this.modifiedSections().has(sectionId);
}
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: 'languages', label: 'builder.languagesTab' },
{ id: 'navigation', label: 'builder.navigationTab' },
{ id: 'preview', label: 'builder.preview' },
];
}