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

@@ -43,6 +43,10 @@ const coreRoutes: Routes = [
path: 'search',
loadComponent: () => import('./features/website/catalog/containers/catalog-container.component').then(m => m.CatalogContainerComponent)
},
{
path: 'edit',
loadComponent: () => import('./features/project-editor/pages/builder-overview-page.component').then(m => m.BuilderOverviewPageComponent)
},
{
path: 'edit/:section',
loadComponent: () => import('./features/project-editor/pages/project-editor-page.component').then(m => m.ProjectEditorPageComponent),
@@ -202,19 +206,14 @@ const coreRoutes: Routes = [
{ path: '**', redirectTo: 'dashboard' }
]
},
{
path: 'edit',
redirectTo: 'edit/general',
pathMatch: 'full'
},
{
path: 'builder',
redirectTo: 'edit/general',
redirectTo: 'edit',
pathMatch: 'full'
},
{
path: 'project-editor',
redirectTo: 'edit/general',
redirectTo: 'edit',
pathMatch: 'full'
},
{

View File

@@ -18,7 +18,7 @@ import {
const QUICK_ACTIONS: AdminDashboardQuickAction[] = [
{ id: 'add-product', icon: 'pi-plus-circle', labelKey: 'dashboard.actionAddProduct', descriptionKey: 'dashboard.actionAddProductDescription', route: ['backoffice', 'products', 'create'] },
{ id: 'create-category', icon: 'pi-sitemap', labelKey: 'dashboard.actionCreateCategory', descriptionKey: 'dashboard.actionCreateCategoryDescription', route: ['backoffice', 'categories', 'create'] },
{ id: 'edit-project', icon: 'pi-th-large', labelKey: 'dashboard.actionEditProject', descriptionKey: 'dashboard.actionEditProjectDescription', route: ['edit', 'general'] },
{ id: 'edit-project', icon: 'pi-th-large', labelKey: 'dashboard.actionEditProject', descriptionKey: 'dashboard.actionEditProjectDescription', route: ['edit'] },
{ id: 'edit-homepage', icon: 'pi-home', labelKey: 'dashboard.actionEditHomepage', descriptionKey: 'dashboard.actionEditHomepageDescription', route: ['edit', 'homepage'] },
{ id: 'media-library', icon: 'pi-images', labelKey: 'dashboard.actionMediaLibrary', descriptionKey: 'dashboard.actionMediaLibraryDescription', route: ['backoffice', 'media'] },
{ id: 'orders', icon: 'pi-shopping-cart', labelKey: 'dashboard.actionOrders', descriptionKey: 'dashboard.actionOrdersDescription', route: ['backoffice', 'orders'] },
@@ -27,7 +27,7 @@ const QUICK_ACTIONS: AdminDashboardQuickAction[] = [
const SHORTCUTS: AdminDashboardShortcut[] = [
{ id: 'products', icon: 'pi-box', labelKey: 'dashboard.actionProducts', route: ['backoffice', 'products'] },
{ id: 'categories', icon: 'pi-tags', labelKey: 'dashboard.actionCategories', route: ['backoffice', 'categories'] },
{ id: 'marketplace-builder', icon: 'pi-sitemap', labelKey: 'dashboard.shortcutMarketplaceBuilder', route: ['edit', 'general'] },
{ id: 'marketplace-builder', icon: 'pi-sitemap', labelKey: 'dashboard.shortcutMarketplaceBuilder', route: ['edit'] },
{ id: 'static-pages', icon: 'pi-file-edit', labelKey: 'dashboard.actionStaticPages', route: ['backoffice', 'static-pages'] },
{ id: 'orders', icon: 'pi-shopping-cart', labelKey: 'dashboard.actionOrders', route: ['backoffice', 'orders'] },
{ id: 'users', icon: 'pi-users', labelKey: 'dashboard.actionUsers', route: ['backoffice', 'users'] },

View File

@@ -36,7 +36,7 @@ export const ADMIN_NAV_PRIMARY: AdminNavEntry[] = [
{ type: 'link', id: 'reports', icon: 'pi-chart-bar', labelKey: 'adminShell.nav.reports', comingSoon: true },
{ type: 'link', id: 'content', icon: 'pi-file-edit', labelKey: 'adminShell.nav.content', absolutePath: ['edit', 'static-pages'] },
{ type: 'link', id: 'media', icon: 'pi-images', labelKey: 'adminShell.nav.mediaLibrary', path: ['media'] },
{ type: 'link', id: 'marketplace-builder', icon: 'pi-sitemap', labelKey: 'adminShell.nav.marketplaceBuilder', absolutePath: ['edit', 'general'] },
{ type: 'link', id: 'marketplace-builder', icon: 'pi-sitemap', labelKey: 'adminShell.nav.marketplaceBuilder', absolutePath: ['edit'] },
{ type: 'link', id: 'users', icon: 'pi-users', labelKey: 'adminShell.nav.users', path: ['users'] },
{ type: 'link', id: 'settings', icon: 'pi-cog', labelKey: 'adminShell.nav.settings', comingSoon: true },
{ type: 'link', id: 'monitoring', icon: 'pi-desktop', labelKey: 'adminShell.nav.monitoring', path: ['monitoring'] },

View File

@@ -0,0 +1,122 @@
import { ProjectEditorSectionId } from '../models/project-editor.model';
export type BuilderGroupId =
| 'marketplace'
| 'branding'
| 'homepage'
| 'content'
| 'languages'
| 'features'
| 'navigation'
| 'preview';
export interface BuilderGroup {
id: BuilderGroupId;
icon: string;
labelKey: string;
descriptionKey: string;
purposeKey: string;
whereKey: string;
skipKey: string;
sections: ProjectEditorSectionId[];
}
/**
* The Builder's business-oriented information architecture (Sprint 3). Maps
* every existing ProjectEditorSectionId to exactly one group - no section is
* dropped, no group points at a section that doesn't exist yet. `static-pages`
* routes here as "Content" (it's the same StaticPagesEditorComponent already
* wired at that section id, just relabeled for a non-technical audience).
*/
export const BUILDER_GROUPS: BuilderGroup[] = [
{
id: 'marketplace',
icon: 'pi-shop',
labelKey: 'builder.groupMarketplaceLabel',
descriptionKey: 'builder.groupMarketplaceDescription',
purposeKey: 'builder.groupMarketplacePurpose',
whereKey: 'builder.groupMarketplaceWhere',
skipKey: 'builder.groupMarketplaceSkip',
sections: ['general'],
},
{
id: 'branding',
icon: 'pi-palette',
labelKey: 'builder.groupBrandingLabel',
descriptionKey: 'builder.groupBrandingDescription',
purposeKey: 'builder.groupBrandingPurpose',
whereKey: 'builder.groupBrandingWhere',
skipKey: 'builder.groupBrandingSkip',
sections: ['branding', 'theme', 'header', 'footer'],
},
{
id: 'homepage',
icon: 'pi-home',
labelKey: 'builder.groupHomepageLabel',
descriptionKey: 'builder.groupHomepageDescription',
purposeKey: 'builder.groupHomepagePurpose',
whereKey: 'builder.groupHomepageWhere',
skipKey: 'builder.groupHomepageSkip',
sections: ['homepage', 'widgets'],
},
{
id: 'content',
icon: 'pi-file-edit',
labelKey: 'builder.groupContentLabel',
descriptionKey: 'builder.groupContentDescription',
purposeKey: 'builder.groupContentPurpose',
whereKey: 'builder.groupContentWhere',
skipKey: 'builder.groupContentSkip',
sections: ['static-pages'],
},
{
id: 'languages',
icon: 'pi-globe',
labelKey: 'builder.groupLanguagesLabel',
descriptionKey: 'builder.groupLanguagesDescription',
purposeKey: 'builder.groupLanguagesPurpose',
whereKey: 'builder.groupLanguagesWhere',
skipKey: 'builder.groupLanguagesSkip',
sections: ['languages'],
},
{
id: 'features',
icon: 'pi-sliders-h',
labelKey: 'builder.groupFeaturesLabel',
descriptionKey: 'builder.groupFeaturesDescription',
purposeKey: 'builder.groupFeaturesPurpose',
whereKey: 'builder.groupFeaturesWhere',
skipKey: 'builder.groupFeaturesSkip',
sections: ['features'],
},
{
id: 'navigation',
icon: 'pi-compass',
labelKey: 'builder.groupNavigationLabel',
descriptionKey: 'builder.groupNavigationDescription',
purposeKey: 'builder.groupNavigationPurpose',
whereKey: 'builder.groupNavigationWhere',
skipKey: 'builder.groupNavigationSkip',
sections: ['navigation'],
},
{
id: 'preview',
icon: 'pi-desktop',
labelKey: 'builder.groupPreviewLabel',
descriptionKey: 'builder.groupPreviewDescription',
purposeKey: 'builder.groupPreviewPurpose',
whereKey: 'builder.groupPreviewWhere',
skipKey: 'builder.groupPreviewSkip',
sections: ['preview'],
},
];
export const SECTION_TO_GROUP: Record<ProjectEditorSectionId, BuilderGroupId> = BUILDER_GROUPS.reduce(
(map, group) => {
for (const section of group.sections) {
map[section] = group.id;
}
return map;
},
{} as Record<ProjectEditorSectionId, BuilderGroupId>,
);

View File

@@ -1,10 +1,32 @@
<nav class="editor-nav" [attr.aria-label]="'builder.title' | translate">
@for (section of sections; track section.id) {
<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"
>{{ 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>
(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' },
];
}

View File

@@ -11,9 +11,24 @@ import { PlatformRuntimeService } from '../../../core/runtime/platform-runtime.s
import { ProjectValidator, ProjectValidationIssue } from '../services/project-validator.service';
import { ProjectEditorSectionId } from '../models/project-editor.model';
import { EditorSchemaService } from '../schema/editor-schema.service';
import { FieldSchema } from '../schema/field-schema.model';
import { History, commit as commitHistory, emptyHistory, redo as redoHistory, undo as undoHistory } from '../schema/history.util';
import { ProjectEditorDraftStorageService } from '../services/project-editor-draft-storage.service';
import { EDITOR_SECTION_BOOTSTRAP_KEYS } from '../models/project-editor.model';
import { EDITOR_SECTION_BOOTSTRAP_KEYS, BuilderSectionStatus } from '../models/project-editor.model';
import { BUILDER_GROUPS, BuilderGroupId } from '../builder/builder-groups.model';
function isFilled(value: unknown): boolean {
if (value === null || value === undefined) {
return false;
}
if (typeof value === 'string') {
return value.trim().length > 0;
}
if (Array.isArray(value)) {
return value.length > 0;
}
return true;
}
const HISTORY_DEBOUNCE_MS = 300;
@@ -134,6 +149,81 @@ export class ProjectEditorFacade {
}
return sections;
});
/** Required-field completeness per section (Sprint 3 Builder IA) - real schema data only, never guessed. */
readonly sectionStatus = computed<Map<ProjectEditorSectionId, BuilderSectionStatus>>(() => {
const current = this.bootstrap();
const map = new Map<ProjectEditorSectionId, BuilderSectionStatus>();
if (!current) {
return map;
}
const modified = this.modifiedSections();
const bySection = new Map<ProjectEditorSectionId, FieldSchema[]>();
for (const field of this.schema.all()) {
const list = bySection.get(field.section) ?? [];
list.push(field);
bySection.set(field.section, list);
}
for (const [section, fields] of bySection) {
const required = fields.filter(f => f.required);
if (required.length === 0) {
map.set(section, modified.has(section) ? 'in-progress' : 'unknown');
continue;
}
const filled = required.filter(f => isFilled(this.schema.getByPath(current, f.key))).length;
map.set(section, filled === required.length ? 'complete' : filled === 0 ? 'not-started' : 'in-progress');
}
return map;
});
/** Rolls sectionStatus up to the Builder's business-oriented groups. */
readonly groupStatus = computed<Map<BuilderGroupId, BuilderSectionStatus>>(() => {
const sections = this.sectionStatus();
const result = new Map<BuilderGroupId, BuilderSectionStatus>();
for (const group of BUILDER_GROUPS) {
const statuses = group.sections.map(id => sections.get(id) ?? 'unknown');
if (statuses.some(s => s === 'in-progress') || (statuses.some(s => s === 'not-started') && statuses.some(s => s === 'complete'))) {
result.set(group.id, 'in-progress');
} else if (statuses.every(s => s === 'not-started')) {
result.set(group.id, 'not-started');
} else {
const known = statuses.filter(s => s !== 'unknown');
result.set(group.id, known.length > 0 && known.every(s => s === 'complete') ? 'complete' : 'unknown');
}
}
return result;
});
/** % of schema-required fields filled across the whole config - the Builder overview's readiness number. Real data only. */
readonly readinessPercent = computed<number | null>(() => {
const current = this.bootstrap();
if (!current) {
return null;
}
const required = this.schema.all().filter(f => f.required);
if (required.length === 0) {
return null;
}
const filled = required.filter(f => isFilled(this.schema.getByPath(current, f.key))).length;
return Math.round((filled / required.length) * 100);
});
/** First section with a required field still missing, in group display order - the overview's "recommended next step". */
readonly nextRecommendedSection = computed<ProjectEditorSectionId | null>(() => {
if (!this.bootstrap()) {
return null;
}
const statuses = this.sectionStatus();
for (const group of BUILDER_GROUPS) {
for (const section of group.sections) {
const status = statuses.get(section);
if (status === 'not-started' || status === 'in-progress') {
return section;
}
}
}
return null;
});
/** Per-field before/after diff vs the originally loaded/published config, for the pre-publish preview. */
readonly changeSummary = computed<ChangeSummaryRow[]>(() => {
const current = this.bootstrap();

View File

@@ -39,6 +39,15 @@ export const EDITOR_SECTION_BOOTSTRAP_KEYS: Partial<Record<ProjectEditorSectionI
navigation: ['navigation'],
};
/**
* complete/in-progress/not-started are derived strictly from the schema's
* `required` fields for that section (real data, never guessed). Sections
* with no required fields (e.g. theme, preview) report 'unknown' unless the
* user has actively touched them this session (modifiedSections), which
* counts as real evidence of progress too.
*/
export type BuilderSectionStatus = 'complete' | 'in-progress' | 'not-started' | 'unknown';
export interface ProjectEditorWidgetPreset {
id: string;
type: string;

View File

@@ -0,0 +1,89 @@
<main class="builder-overview">
<header class="builder-overview__hero">
<p class="builder-overview__eyebrow">{{ 'builder.appName' | translate }}</p>
<h1 class="builder-overview__title">{{ 'builder.overviewTitle' | translate }}</h1>
<p class="builder-overview__subtitle">{{ 'builder.overviewSubtitle' | translate }}</p>
@if (!bootstrap()) {
<div class="builder-overview__readiness" aria-hidden="true">
<div class="builder-overview__readiness-bar"><div class="builder-overview__readiness-fill" style="width: 0%"></div></div>
<p>{{ 'common.loading' | translate }}</p>
</div>
} @else {
<div class="builder-overview__readiness">
@if (readinessPercent(); as percent) {
<div class="builder-overview__readiness-bar">
<div class="builder-overview__readiness-fill" [style.width.%]="percent"></div>
</div>
<p class="builder-overview__readiness-label">{{ 'builder.overviewReadinessLabel' | translate: { percent } }}</p>
<p class="builder-overview__readiness-hint">{{ 'builder.overviewReadinessHint' | translate }}</p>
} @else {
<p class="builder-overview__readiness-label">{{ 'builder.statusUnknown' | translate }}</p>
}
</div>
<div class="builder-overview__next-step">
<span class="pi pi-arrow-right-circle" aria-hidden="true"></span>
<div>
<p class="builder-overview__next-step-label">{{ 'builder.overviewNextStepLabel' | translate }}</p>
@if (nextStepLabelKey(); as labelKey) {
<a [routerLink]="nextStepRoute()" class="builder-overview__next-step-link">{{ labelKey | translate }}</a>
} @else {
<p class="builder-overview__next-step-done">{{ 'builder.overviewNextStepFallback' | translate }}</p>
}
</div>
</div>
}
</header>
@if (bootstrap()) {
<section class="builder-overview__section" aria-labelledby="builder-overview-sections-title">
<h2 id="builder-overview-sections-title">{{ 'builder.overviewSectionsTitle' | translate }}</h2>
<div class="builder-overview__cards">
@for (group of groups; track group.id) {
<a class="builder-overview__card" [routerLink]="groupSectionRoute(group.sections[0])">
<div class="builder-overview__card-header">
<span class="pi {{ group.icon }} builder-overview__card-icon" aria-hidden="true"></span>
<span
class="pi {{ statusIcon[groupStatus().get(group.id) ?? 'unknown'] }} builder-overview__card-status builder-overview__card-status--{{ groupStatus().get(group.id) ?? 'unknown' }}"
[attr.aria-label]="statusLabelKey[groupStatus().get(group.id) ?? 'unknown'] | translate"
></span>
</div>
<h3 class="builder-overview__card-title">{{ group.labelKey | translate }}</h3>
<p class="builder-overview__card-description">{{ group.descriptionKey | translate }}</p>
<span class="builder-overview__card-cta">{{ 'builder.overviewOpenSection' | translate }} →</span>
</a>
}
</div>
</section>
<section class="builder-overview__section" aria-labelledby="builder-overview-readiness-title">
<h2 id="builder-overview-readiness-title">{{ 'builder.overviewReadinessTitle' | translate }}</h2>
<ul class="builder-overview__checklist">
@for (check of readinessChecklist(); track check.labelKey) {
<li class="builder-overview__check builder-overview__check--{{ check.state }}">
@switch (check.state) {
@case ('true') { <span class="pi pi-check-circle" aria-hidden="true"></span> }
@case ('false') { <span class="pi pi-circle" aria-hidden="true"></span> }
@case ('loading') { <span class="pi pi-spin pi-spinner" aria-hidden="true"></span> }
@default { <span class="pi pi-question-circle" aria-hidden="true"></span> }
}
<span>{{ check.labelKey | translate }}</span>
</li>
}
</ul>
</section>
<section class="builder-overview__section" aria-labelledby="builder-overview-links-title">
<h2 id="builder-overview-links-title">{{ 'builder.overviewQuickLinksTitle' | translate }}</h2>
<div class="builder-overview__quick-links">
<a class="builder-overview__quick-link" [routerLink]="groupSectionRoute('preview')">
<span class="pi pi-desktop" aria-hidden="true"></span>{{ 'builder.preview' | translate }}
</a>
<a class="builder-overview__quick-link" [routerLink]="groupSectionRoute('general')">
<span class="pi pi-shop" aria-hidden="true"></span>{{ 'builder.general' | translate }}
</a>
</div>
</section>
}
</main>

View File

@@ -0,0 +1,280 @@
.builder-overview {
max-width: 1100px;
margin: 0 auto;
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.builder-overview__hero {
padding: var(--space-lg);
border-radius: var(--radius-md);
background: var(--gradient-primary);
color: #fff;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.builder-overview__eyebrow {
margin: 0;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.75);
}
.builder-overview__title {
margin: 0;
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 700;
}
.builder-overview__subtitle {
margin: 0;
font-size: 1rem;
color: rgba(255, 255, 255, 0.9);
}
.builder-overview__readiness {
margin-top: var(--space-sm);
}
.builder-overview__readiness-bar {
width: 100%;
height: 8px;
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.25);
overflow: hidden;
}
.builder-overview__readiness-fill {
height: 100%;
background: #fff;
transition: width 0.3s ease;
}
.builder-overview__readiness-label {
margin: var(--space-xs) 0 0;
font-weight: 600;
}
.builder-overview__readiness-hint {
margin: 2px 0 0;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.8);
}
.builder-overview__next-step {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.12);
.pi {
font-size: 1.25rem;
}
}
.builder-overview__next-step-label {
margin: 0;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.75);
}
.builder-overview__next-step-link {
color: #fff;
font-weight: 600;
font-size: 1rem;
text-decoration: underline;
}
.builder-overview__next-step-done {
margin: 0;
font-weight: 600;
}
.builder-overview__section {
display: flex;
flex-direction: column;
gap: var(--space-md);
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
}
.builder-overview__cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--space-md);
}
.builder-overview__card {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-primary);
box-shadow: var(--shadow-sm);
color: inherit;
text-decoration: none;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
&:hover {
border-color: var(--primary-color);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
}
.builder-overview__card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.builder-overview__card-icon {
font-size: 1.375rem;
color: var(--primary-color);
}
.builder-overview__card-status {
font-size: 1rem;
}
.builder-overview__card-status--complete {
color: var(--success-color);
}
.builder-overview__card-status--in-progress {
color: var(--warning-color);
}
.builder-overview__card-status--not-started {
color: var(--text-light);
}
.builder-overview__card-status--unknown {
color: var(--text-light);
opacity: 0.6;
}
.builder-overview__card-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}
.builder-overview__card-description {
margin: 0;
font-size: 0.75rem;
color: var(--text-secondary);
flex: 1 1 auto;
}
.builder-overview__card-cta {
font-size: 0.75rem;
font-weight: 600;
color: var(--primary-color);
}
.builder-overview__checklist {
list-style: none;
margin: 0;
padding: var(--space-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-primary);
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-sm) var(--space-md);
}
.builder-overview__check {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: 1rem;
color: var(--text-primary);
}
.builder-overview__check--true .pi {
color: var(--success-color);
}
.builder-overview__check--false .pi {
color: var(--text-light);
}
.builder-overview__check--unknown .pi,
.builder-overview__check--loading .pi {
color: var(--text-light);
opacity: 0.6;
}
.builder-overview__quick-links {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.builder-overview__quick-link {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
color: var(--text-primary);
text-decoration: none;
font-weight: 500;
&:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
.builder-overview__card,
.builder-overview__readiness-fill {
transition: none;
}
}
@media (max-width: 639px) {
.builder-overview {
padding: var(--space-md);
}
.builder-overview__cards,
.builder-overview__checklist {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,132 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { take } from 'rxjs/operators';
import { forkJoin } from 'rxjs';
import { TranslatePipe } from '../../../i18n/translate.pipe';
import { LanguageService } from '../../../services/language.service';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
import { BackofficeDataService } from '../../../core/backoffice/backoffice-data.service';
import { BUILDER_GROUPS } from '../builder/builder-groups.model';
import { BuilderSectionStatus } from '../models/project-editor.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',
};
type ReadinessState = 'loading' | 'true' | 'false' | 'unknown';
interface ReadinessItem {
labelKey: string;
state: ReadinessState;
}
const SECTION_LABEL_KEYS: Record<string, 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-builder-overview-page',
standalone: true,
imports: [RouterLink, TranslatePipe],
templateUrl: './builder-overview-page.component.html',
styleUrl: './builder-overview-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BuilderOverviewPageComponent {
readonly facade = inject(ProjectEditorFacade);
private readonly languageService = inject(LanguageService);
private readonly backofficeData = inject(BackofficeDataService);
readonly groups = BUILDER_GROUPS;
readonly sectionLabelKeys = SECTION_LABEL_KEYS;
readonly bootstrap = this.facade.bootstrap;
readonly readinessPercent = this.facade.readinessPercent;
readonly nextRecommendedSection = this.facade.nextRecommendedSection;
readonly groupStatus = this.facade.groupStatus;
readonly modifiedSections = this.facade.modifiedSections;
readonly dirty = this.facade.dirty;
readonly status = this.facade.status;
private readonly catalogCounts = signal<{ categories: number; products: number } | 'loading' | 'error'>('loading');
readonly nextStepLabelKey = computed(() => {
const section = this.nextRecommendedSection();
return section ? SECTION_LABEL_KEYS[section] : null;
});
readonly nextStepRoute = computed(() => {
const section = this.nextRecommendedSection();
return section ? ['/', this.languageService.currentLanguage(), 'edit', section] : null;
});
readonly readinessChecklist = computed<ReadinessItem[]>(() => {
const current = this.bootstrap();
const counts = this.catalogCounts();
const item = (labelKey: string, state: ReadinessState): ReadinessItem => ({ labelKey, state });
if (!current) {
return [
item('dashboard.healthProductsCount', 'loading'),
];
}
const staticPages = current.staticPages;
const staticPagesCount = staticPages ? (Array.isArray(staticPages) ? staticPages.length : Object.keys(staticPages).length) : 0;
return [
item('builder.readinessLogoUploaded', current.branding?.logoUrl ? 'true' : 'false'),
item('builder.readinessThemeConfigured', current.theme?.themeId ? 'true' : 'false'),
item('builder.readinessHomepageConfigured', this.facade.homepageWidgets().length > 0 ? 'true' : 'false'),
item('builder.readinessLanguagesConfigured', (current.localization?.supportedLocales?.length ?? 0) > 0 ? 'true' : 'false'),
item('builder.readinessContentPagesAdded', staticPagesCount > 0 ? 'true' : 'false'),
item('builder.readinessCategoriesExist', counts === 'loading' ? 'loading' : counts === 'error' ? 'unknown' : counts.categories > 0 ? 'true' : 'false'),
item('builder.readinessProductsExist', counts === 'loading' ? 'loading' : counts === 'error' ? 'unknown' : counts.products > 0 ? 'true' : 'false'),
item('builder.readinessPreviewReviewed', 'unknown'),
item('builder.readinessPublished', this.facade.lastPublishedAt() !== null ? 'true' : 'false'),
];
});
readonly statusIcon = STATUS_ICON;
readonly statusLabelKey = STATUS_LABEL_KEY;
constructor() {
this.facade.loadBootstrap();
forkJoin({
categories: this.backofficeData.loadCategories(),
products: this.backofficeData.loadProducts(),
}).pipe(take(1)).subscribe({
next: ({ categories, products }) => this.catalogCounts.set({ categories: categories.length, products: products.length }),
error: () => this.catalogCounts.set('error'),
});
}
groupSectionRoute(sectionId: string): string[] {
return ['/', this.languageService.currentLanguage(), 'edit', sectionId];
}
formatTimestamp(timestamp: number | null): string | null {
return timestamp ? new Date(timestamp).toLocaleString(this.languageService.currentLanguage()) : null;
}
}

View File

@@ -1,12 +1,77 @@
<main class="project-editor-page">
<header class="project-editor-hero">
<div>
<h1>{{ 'builder.title' | translate }}</h1>
<p>{{ 'builder.subtitle' | translate }}</p>
<div class="project-editor-layout" [class.project-editor-layout--drawer-open]="mobileDrawerOpen()">
@if (mobileDrawerOpen()) {
<div class="project-editor-layout__backdrop" (click)="closeMobileDrawer()"></div>
}
<aside class="project-editor-layout__sidebar">
<a class="project-editor-layout__brand" [routerLink]="overviewRoute()">
<span class="pi pi-arrow-left" aria-hidden="true"></span>
{{ 'builder.appName' | translate }}
</a>
<app-project-editor-nav (linkClicked)="closeMobileDrawer()" />
</aside>
<main class="project-editor-layout__content">
<header class="project-editor-layout__header">
<button
type="button"
class="project-editor-layout__menu-toggle"
[attr.aria-expanded]="mobileDrawerOpen()"
[attr.aria-label]="(mobileDrawerOpen() ? 'builder.closeSidebar' : 'builder.openSidebar') | translate"
(click)="toggleMobileDrawer()"
>
<span class="pi pi-bars" aria-hidden="true"></span>
</button>
<div class="project-editor-layout__heading">
<nav class="project-editor-layout__breadcrumb" [attr.aria-label]="'builder.breadcrumbRoot' | translate">
<ol>
<li><a [routerLink]="overviewRoute()">{{ 'builder.breadcrumbRoot' | translate }}</a></li>
@if (activeGroup(); as group) {
<li><span class="project-editor-layout__breadcrumb-sep" aria-hidden="true">/</span>{{ group.labelKey | translate }}</li>
}
<li><span class="project-editor-layout__breadcrumb-sep" aria-hidden="true">/</span><span aria-current="page">{{ sectionTitleKey() | translate }}</span></li>
</ol>
</nav>
<div class="project-editor-layout__title-row">
<h1>{{ sectionTitleKey() | translate }}</h1>
@if (facade.status() === 'draft') {
<span class="project-editor-layout__badge project-editor-layout__badge--draft">{{ 'builder.draftBadge' | translate }}</span>
} @else {
<span class="project-editor-layout__badge project-editor-layout__badge--published">{{ 'builder.publishedBadge' | translate }}</span>
}
@if (isModifiedActiveSection()) {
<span class="project-editor-layout__badge project-editor-layout__badge--modified">{{ 'builder.modifiedBadge' | translate }}</span>
}
</div>
<app-project-editor-nav />
@if (activeGroup(); as group) {
<p class="project-editor-layout__description">{{ group.descriptionKey | translate }}</p>
}
</div>
<button type="button" class="project-editor-layout__help-toggle" [attr.aria-expanded]="helpOpen()" (click)="toggleHelp()">
<span class="pi pi-question-circle" aria-hidden="true"></span>
{{ (helpOpen() ? 'builder.helpToggleHide' : 'builder.helpToggleShow') | translate }}
</button>
</header>
@if (helpOpen() && activeGroup(); as group) {
<section class="project-editor-layout__help" aria-label="{{ 'builder.helpToggleShow' | translate }}">
<div>
<h2>{{ 'builder.helpWhatIsThis' | translate }}</h2>
<p>{{ group.purposeKey | translate }}</p>
</div>
<div>
<h2>{{ 'builder.helpWhereVisible' | translate }}</h2>
<p>{{ group.whereKey | translate }}</p>
</div>
<div>
<h2>{{ 'builder.helpWhatIfSkip' | translate }}</h2>
<p>{{ group.skipKey | translate }}</p>
</div>
</section>
}
@if (!bootstrap()) {
<section class="project-editor-empty">
<p>{{ 'common.loading' | translate }}</p>
@@ -35,4 +100,5 @@
</section>
<app-project-editor-save-bar />
}
</main>
</main>
</div>

View File

@@ -1,23 +1,227 @@
.project-editor-page {
max-width: 1240px;
margin: 0 auto;
padding: 24px;
display: grid;
gap: 18px;
:host {
--builder-sidebar-width: 280px;
display: block;
}
.project-editor-hero {
.project-editor-layout {
display: grid;
gap: 14px;
grid-template-columns: var(--builder-sidebar-width) 1fr;
align-items: start;
min-height: 100vh;
background: var(--bg-secondary);
}
.project-editor-hero h1,
.project-editor-hero p {
.project-editor-layout__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 30;
display: none;
}
.project-editor-layout__sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
padding: var(--space-md);
background: var(--bg-primary);
border-right: 1px solid var(--border-color);
z-index: 40;
}
.project-editor-layout__brand {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm);
margin-bottom: var(--space-md);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-weight: 700;
text-decoration: none;
&:hover {
background: var(--bg-tertiary);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
}
.project-editor-layout__content {
min-width: 0;
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.project-editor-layout__header {
display: flex;
align-items: flex-start;
gap: var(--space-md);
}
.project-editor-layout__menu-toggle {
display: none;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
flex-shrink: 0;
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
}
.project-editor-layout__heading {
flex: 1 1 auto;
min-width: 0;
}
.project-editor-layout__breadcrumb {
ol {
display: flex;
flex-wrap: wrap;
list-style: none;
margin: 0 0 4px;
padding: 0;
font-size: 0.7rem;
color: var(--text-light);
}
li {
display: flex;
align-items: center;
}
a {
color: var(--text-light);
text-decoration: none;
&:hover {
color: var(--primary-color);
text-decoration: underline;
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
}
[aria-current='page'] {
color: var(--text-secondary);
}
}
.project-editor-layout__breadcrumb-sep {
margin: 0 var(--space-xs);
}
.project-editor-layout__title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
h1 {
margin: 0;
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 700;
color: var(--text-primary);
}
}
.project-editor-hero p {
color: #697777;
.project-editor-layout__badge {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
padding: 2px 8px;
border-radius: var(--radius-sm);
}
.project-editor-layout__badge--draft {
background: color-mix(in srgb, var(--warning-color) 15%, transparent);
color: var(--warning-color);
}
.project-editor-layout__badge--published {
background: color-mix(in srgb, var(--success-color) 15%, transparent);
color: var(--success-color);
}
.project-editor-layout__badge--modified {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
}
.project-editor-layout__description {
margin: var(--space-xs) 0 0;
font-size: 1rem;
color: var(--text-secondary);
max-width: 65ch;
}
.project-editor-layout__help-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
color: var(--text-primary);
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
&:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
&:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
}
.project-editor-layout__help {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-md);
padding: var(--space-md);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-primary);
h2 {
margin: 0 0 4px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.4px;
text-transform: uppercase;
color: var(--text-light);
}
p {
margin: 0;
font-size: 1rem;
color: var(--text-secondary);
}
}
.project-editor-stack {
@@ -83,8 +287,65 @@
place-items: center;
}
@media (max-width: 720px) {
.project-editor-page {
padding: 16px;
/* Tablet: icon-only collapsed sidebar */
@media (max-width: 1023px) and (min-width: 768px) {
.project-editor-layout.project-editor-layout {
grid-template-columns: 72px 1fr;
}
.project-editor-layout__sidebar {
padding: var(--space-sm);
}
.project-editor-layout__brand {
justify-content: center;
span:last-child:not(.pi) {
display: none;
}
}
}
/* Mobile: drawer */
@media (max-width: 767px) {
.project-editor-layout.project-editor-layout {
grid-template-columns: 0 1fr;
}
.project-editor-layout__menu-toggle {
display: inline-flex;
}
.project-editor-layout__content {
padding: var(--space-md);
}
.project-editor-layout__sidebar {
position: fixed;
inset: 0 auto 0 0;
width: min(var(--builder-sidebar-width), 84vw);
transform: translateX(-100%);
transition: transform 0.2s ease;
box-shadow: var(--shadow-lg);
}
.project-editor-layout--drawer-open {
.project-editor-layout__sidebar {
transform: translateX(0);
}
.project-editor-layout__backdrop {
display: block;
}
}
.project-editor-layout__header {
flex-wrap: wrap;
}
}
@media (prefers-reduced-motion: reduce) {
.project-editor-layout__sidebar {
transition: none;
}
}

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, HostListener, effect, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ChangeDetectionStrategy, Component, HostListener, computed, effect, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
import { ProjectEditorFacade } from '../facade/project-editor.facade';
@@ -17,18 +17,36 @@ import { ProjectEditorNavigationSectionComponent } from '../sections/navigation-
import { ProjectEditorPreviewSectionComponent } from '../sections/preview-section.component';
import { TranslatePipe } from '../../../i18n/translate.pipe';
import { TranslateService } from '../../../i18n/translate.service';
import { LanguageService } from '../../../services/language.service';
import { StaticPagesEditorComponent } from '../../content-management/components/static-pages-editor.component';
import { ProjectEditorSaveBarComponent } from '../components/save-bar/project-editor-save-bar.component';
import { EDITOR_SECTION_BOOTSTRAP_KEYS, ProjectEditorSectionId } from '../models/project-editor.model';
import { BUILDER_GROUPS, SECTION_TO_GROUP } from '../builder/builder-groups.model';
const KNOWN_SECTIONS: ProjectEditorSectionId[] = [
'general', 'branding', 'theme', 'header', 'footer', 'homepage', 'widgets', 'static-pages', 'features', 'languages', 'navigation', 'preview'
];
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-page',
standalone: true,
imports: [
RouterLink,
TranslatePipe,
ProjectEditorNavComponent,
ProjectEditorGeneralSectionComponent,
@@ -53,21 +71,38 @@ export class ProjectEditorPageComponent {
readonly facade = inject(ProjectEditorFacade);
private readonly route = inject(ActivatedRoute);
private readonly translate = inject(TranslateService);
private readonly languageService = inject(LanguageService);
readonly bootstrap = this.facade.bootstrap;
readonly activeSection = this.facade.activeSection;
readonly canResetActiveSection = () => !!EDITOR_SECTION_BOOTSTRAP_KEYS[this.activeSection()];
readonly mobileDrawerOpen = signal(false);
readonly helpOpen = signal(false);
private readonly routeSection = toSignal(
this.route.paramMap.pipe(map(params => params.get('section') as ProjectEditorSectionId | null)),
{ initialValue: null }
);
readonly activeGroup = computed(() => {
const groupId = SECTION_TO_GROUP[this.activeSection()];
return BUILDER_GROUPS.find(g => g.id === groupId) ?? null;
});
readonly sectionTitleKey = computed(() => SECTION_LABEL_KEYS[this.activeSection()]);
readonly isModifiedActiveSection = computed(() => this.facade.modifiedSections().has(this.activeSection()));
readonly overviewRoute = computed(() => ['/', this.languageService.currentLanguage(), 'edit']);
constructor() {
this.facade.loadBootstrap();
effect(() => {
const section = this.routeSection();
if (section && KNOWN_SECTIONS.includes(section)) {
this.facade.setActiveSection(section);
this.helpOpen.set(false);
this.mobileDrawerOpen.set(false);
}
});
}
@@ -78,6 +113,18 @@ export class ProjectEditorPageComponent {
}
}
toggleHelp(): void {
this.helpOpen.update(open => !open);
}
toggleMobileDrawer(): void {
this.mobileDrawerOpen.update(open => !open);
}
closeMobileDrawer(): void {
this.mobileDrawerOpen.set(false);
}
@HostListener('window:beforeunload', ['$event'])
warnBeforeUnload(event: BeforeUnloadEvent): void {
if (this.facade.dirty()) {
@@ -93,6 +140,10 @@ export class ProjectEditorPageComponent {
*/
@HostListener('document:keydown', ['$event'])
handleUndoRedoShortcut(event: KeyboardEvent): void {
if (event.key === 'Escape' && this.mobileDrawerOpen()) {
this.mobileDrawerOpen.set(false);
return;
}
if (!event.ctrlKey && !event.metaKey) {
return;
}

View File

@@ -406,8 +406,8 @@ export const en: Translations = {
notAvailable: 'n/a',
},
builder: {
title: 'Marketplace Project Editor',
subtitle: 'Configuration-only bootstrap editing surface.',
title: 'Marketplace Builder',
subtitle: 'Build your marketplace step by step.',
general: 'General',
branding: 'Branding',
theme: 'Theme',
@@ -685,6 +685,83 @@ export const en: Translations = {
linkLabelDesc: 'Text shown for this navigation link.',
linkUrlDesc: 'Path or URL this navigation link points to.',
linkVisibleDesc: 'Whether this navigation link is shown to shoppers.',
appName: 'Marketplace Builder',
breadcrumbRoot: 'Marketplace Builder',
statusComplete: 'Complete',
statusInProgress: 'In progress',
statusNotStarted: 'Not started',
statusUnknown: 'Not tracked yet',
draftBadge: 'Draft',
publishedBadge: 'Published',
modifiedBadge: 'Unsaved changes',
helpToggleShow: 'What is this?',
helpToggleHide: 'Hide help',
helpWhatIsThis: 'What is this',
helpWhySkip: 'Why configure it',
helpWhereVisible: 'Where shoppers see it',
helpWhatIfSkip: "What happens if you skip it",
helpWhenReturn: 'When to come back to this',
openSidebar: 'Open sections menu',
closeSidebar: 'Close sections menu',
overviewTitle: 'Marketplace Builder',
overviewSubtitle: 'Build your marketplace step by step',
overviewReadinessLabel: 'Your marketplace is {{percent}}% configured',
overviewReadinessHint: 'Based on the required fields you have filled in so far.',
overviewNextStepLabel: 'Recommended next step',
overviewNextStepFallback: 'All required steps are done - you are ready to publish.',
overviewQuickLinksTitle: 'Quick links',
overviewSectionsTitle: 'Builder sections',
overviewOpenSection: 'Open section',
overviewReadinessTitle: 'Marketplace readiness',
readinessLogoUploaded: 'Logo uploaded',
readinessHomepageConfigured: 'Homepage configured',
readinessThemeConfigured: 'Theme configured',
readinessLanguagesConfigured: 'Languages configured',
readinessContentPagesAdded: 'Info pages added',
readinessPreviewReviewed: 'Preview reviewed',
readinessPublished: 'Marketplace published',
readinessCategoriesExist: 'Categories exist',
readinessProductsExist: 'Products exist',
groupMarketplaceLabel: 'Marketplace',
groupMarketplaceDescription: 'Name, address, and the core details of your marketplace.',
groupMarketplacePurpose: 'The basic information that defines your marketplace: name, domain, and default language.',
groupMarketplaceWhere: 'The name and address show up in the browser tab, search results, and every storefront page.',
groupMarketplaceSkip: "Without a name and address, your marketplace won't feel finished to shoppers.",
groupBrandingLabel: 'Branding & Design',
groupBrandingDescription: 'Logo, colors, header and footer - your store\'s visual identity.',
groupBrandingPurpose: "Set how your marketplace looks: logo, color palette, and the header/footer chrome.",
groupBrandingWhere: 'Changes show up on every page a shopper sees.',
groupBrandingSkip: "Your marketplace will use default styling that doesn't reflect your brand.",
groupHomepageLabel: 'Homepage',
groupHomepageDescription: 'The banners, showcases, and widgets shoppers see first.',
groupHomepagePurpose: 'Build your homepage from blocks: a hero banner, featured categories and products, promotions.',
groupHomepageWhere: "It's the first page a shopper sees when they open your marketplace.",
groupHomepageSkip: 'Your homepage will stay empty or use a default layout.',
groupContentLabel: 'Content',
groupContentDescription: 'About us, contacts, delivery, returns, and other info pages.',
groupContentPurpose: 'Add the pages shoppers trust: about us, contacts, delivery and return terms.',
groupContentWhere: "Links to these pages usually appear in the site footer.",
groupContentSkip: "Shoppers won't find answers about delivery, returns, or how to contact you - that hurts trust.",
groupLanguagesLabel: 'Languages',
groupLanguagesDescription: 'Which languages your marketplace works in.',
groupLanguagesPurpose: 'Pick a default language and add more for shoppers in other regions.',
groupLanguagesWhere: 'The language switcher is visible in the header on every page.',
groupLanguagesSkip: 'Your marketplace will only be available in the default language.',
groupFeaturesLabel: 'Marketplace Features',
groupFeaturesDescription: 'Wishlist, compare, reviews, guest checkout, and more.',
groupFeaturesPurpose: 'Turn shopper-facing features on or off: wishlist, product comparison, guest checkout.',
groupFeaturesWhere: 'Affects the catalog, product, and checkout pages.',
groupFeaturesSkip: 'Your marketplace will use the default feature set.',
groupNavigationLabel: 'Navigation & Search',
groupNavigationDescription: "The site menu and how shoppers find products.",
groupNavigationPurpose: 'Configure the menu items and how catalog navigation is structured.',
groupNavigationWhere: 'Visible in the site header on every page.',
groupNavigationSkip: 'Shoppers will use the default navigation.',
groupPreviewLabel: 'Preview',
groupPreviewDescription: 'See how your marketplace looks on desktop, tablet, and phone.',
groupPreviewPurpose: 'Check your marketplace through a shopper\'s eyes before publishing - across screen sizes.',
groupPreviewWhere: "This is a preview - shoppers never see it.",
groupPreviewSkip: 'You can publish your changes without reviewing them first.',
},
dashboard: {
title: 'Dashboard',
@@ -708,7 +785,7 @@ export const en: Translations = {
statePendingBackend: 'Awaiting backend integration',
never: 'Never',
quickActionsTitle: 'Quick Actions',
actionEditProject: 'Edit Project',
actionEditProject: 'Marketplace Builder',
actionCategories: 'Categories',
actionProducts: 'Products',
actionStaticPages: 'Static Pages',

View File

@@ -406,8 +406,8 @@ export const hy: Translations = {
notAvailable: 'չկա',
},
builder: {
title: 'Մարքեթփլեյսի նախագծի խմբագիր',
subtitle: 'Միայն bootstrap կարգավորման խմբագրման մակերես։',
title: 'Մարկետփլեյսի կոնստրուկտոր',
subtitle: 'Կառուցեք ձեր մարկետփլեյսը քայլ առ քայլ։',
general: 'Ընդհանուր',
branding: 'Բրենդինգ',
theme: 'Թեմա',
@@ -685,6 +685,83 @@ export const hy: Translations = {
linkLabelDesc: 'Այս նավիգացիոն հղման համար ցուցադրվող տեքստը։',
linkUrlDesc: 'Ուղին կամ URL-ը, որին ուղղորդում է այս նավիգացիոն հղումը։',
linkVisibleDesc: 'Արդյո՞ք այս նավիգացիոն հղումը ցուցադրվում է գնորդներին։',
appName: 'Մարկետփլեյսի կոնստրուկտոր',
breadcrumbRoot: 'Մարկետփլեյսի կոնստրուկտոր',
statusComplete: 'Ավարտված',
statusInProgress: 'Ընթացքի մեջ',
statusNotStarted: 'Չսկսված',
statusUnknown: 'Դեռ չի հետևվում',
draftBadge: 'Սևագիր',
publishedBadge: 'Հրապարակված',
modifiedBadge: 'Չպահպանված փոփոխություններ',
helpToggleShow: 'Ի՞նչ է սա',
helpToggleHide: 'Թաքցնել օգնությունը',
helpWhatIsThis: 'Ի՞նչ է սա',
helpWhySkip: 'Ինչու՞ կարգավորել',
helpWhereVisible: 'Որտե՞ղ են տեսնում գնորդները',
helpWhatIfSkip: 'Ի՞նչ կլինի, եթե բաց թողնեք',
helpWhenReturn: 'Ե՞րբ վերադառնալ այս բաժնին',
openSidebar: 'Բացել բաժինների ցանկը',
closeSidebar: 'Փակել բաժինների ցանկը',
overviewTitle: 'Մարկետփլեյսի կոնստրուկտոր',
overviewSubtitle: 'Կառուցեք ձեր մարկետփլեյսը քայլ առ քայլ',
overviewReadinessLabel: 'Ձեր մարկետփլեյսը կարգավորված է {{percent}}%',
overviewReadinessHint: 'Հիմնված է արդեն լրացված պարտադիր դաշտերի վրա։',
overviewNextStepLabel: 'Առաջարկվող հաջորդ քայլը',
overviewNextStepFallback: 'Բոլոր պարտադիր քայլերն ավարտված են՝ կարող եք հրապարակել։',
overviewQuickLinksTitle: 'Արագ հղումներ',
overviewSectionsTitle: 'Կոնստրուկտորի բաժինները',
overviewOpenSection: 'Բացել բաժինը',
overviewReadinessTitle: 'Մարկետփլեյսի պատրաստվածություն',
readinessLogoUploaded: 'Լոգոն վերբեռնված է',
readinessHomepageConfigured: 'Գլխավոր էջը կարգավորված է',
readinessThemeConfigured: 'Թեման կարգավորված է',
readinessLanguagesConfigured: 'Լեզուները կարգավորված են',
readinessContentPagesAdded: 'Ինֆորմացիոն էջեր ավելացված են',
readinessPreviewReviewed: 'Նախադիտումը ստուգված է',
readinessPublished: 'Մարկետփլեյսը հրապարակված է',
readinessCategoriesExist: 'Կատեգորիաներ կան',
readinessProductsExist: 'Ապրանքներ կան',
groupMarketplaceLabel: 'Մարկետփլեյս',
groupMarketplaceDescription: 'Անվանումը, հասցեն և ձեր մարկետփլեյսի հիմնական տվյալները։',
groupMarketplacePurpose: 'Հիմնական տվյալները, որոնք սահմանում են ձեր մարկետփլեյսը՝ անվանում, դոմեն և լռելյայն լեզու։',
groupMarketplaceWhere: 'Անվանումը և հասցեն երևում են բրաուզերի ներդիրում, որոնման արդյունքներում և խանութի բոլոր էջերում։',
groupMarketplaceSkip: 'Առանց անվանման և հասցեի ձեր մարկետփլեյսը գնորդներին կթվա անավարտ։',
groupBrandingLabel: 'Բրենդինգ և ձևավորում',
groupBrandingDescription: 'Լոգո, գույներ, վերնագիր և ստորագիր՝ ձեր խանութի տեսողական ոճը։',
groupBrandingPurpose: 'Կարգավորեք ձեր մարկետփլեյսի տեսքը՝ լոգո, գունային ներկապնակ, վերնագրի/ստորագրի տարրեր։',
groupBrandingWhere: 'Փոփոխությունները երևում են գնորդի տեսած յուրաքանչյուր էջում։',
groupBrandingSkip: 'Ձեր մարկետփլեյսը կօգտագործի լռելյայն ոճավորում, որը չի արտացոլում ձեր բրենդը։',
groupHomepageLabel: 'Գլխավոր էջ',
groupHomepageDescription: 'Բաններներ, ցուցափեղկեր և վիջեթներ, որոնք գնորդներն առաջինը տեսնում են։',
groupHomepagePurpose: 'Կառուցեք ձեր գլխավոր էջը բլոկներից՝ ողջունող բաններ, ընտրված կատեգորիաներ և ապրանքներ, ակցիաներ։',
groupHomepageWhere: 'Սա առաջին էջն է, որը գնորդը տեսնում է ձեր մարկետփլեյսը բացելիս։',
groupHomepageSkip: 'Ձեր գլխավոր էջը կմնա դատարկ կամ կօգտագործի լռելյայն դասավորություն։',
groupContentLabel: 'Բովանդակություն',
groupContentDescription: 'Մեր մասին, կոնտակտներ, առաքում, վերադարձ և այլ ինֆորմացիոն էջեր։',
groupContentPurpose: 'Ավելացրեք էջեր, որոնց գնորդները վստահում են՝ մեր մասին, կոնտակտներ, առաքման և վերադարձի պայմաններ։',
groupContentWhere: 'Այս էջերի հղումները սովորաբար ցուցադրվում են կայքի ստորագրում։',
groupContentSkip: 'Գնորդները չեն գտնի պատասխաններ առաքման, վերադարձի կամ կապի մասին հարցերին՝ սա նվազեցնում է վստահությունը։',
groupLanguagesLabel: 'Լեզուներ',
groupLanguagesDescription: 'Ինչ լեզուներով է աշխատում ձեր մարկետփլեյսը։',
groupLanguagesPurpose: 'Ընտրեք լռելյայն լեզուն և ավելացրեք լրացուցիչ լեզուներ այլ տարածաշրջանների գնորդների համար։',
groupLanguagesWhere: 'Լեզվի փոխարկիչը երևում է կայքի վերնագրում յուրաքանչյուր էջում։',
groupLanguagesSkip: 'Ձեր մարկետփլեյսը հասանելի կլինի միայն լռելյայն լեզվով։',
groupFeaturesLabel: 'Մարկետփլեյսի հնարավորություններ',
groupFeaturesDescription: 'Ցանկություններ, համեմատում, կարծիքներ, հյուր-պատվեր և այլն։',
groupFeaturesPurpose: 'Միացրեք կամ անջատեք գնորդի հնարավորությունները՝ ցանկությունների ցանկ, ապրանքների համեմատում, հյուր-պատվեր։',
groupFeaturesWhere: 'Ազդում է կատալոգի, ապրանքի և պատվերի ձևակերպման էջերի վրա։',
groupFeaturesSkip: 'Ձեր մարկետփլեյսը կօգտագործի հնարավորությունների լռելյայն հավաքածուն։',
groupNavigationLabel: 'Նավիգացիա և որոնում',
groupNavigationDescription: 'Կայքի ընտրացանկը և ինչպես են գնորդները գտնում ապրանքները։',
groupNavigationPurpose: 'Կարգավորեք ընտրացանկի կետերը և կատալոգի նավիգացիայի կառուցվածքը։',
groupNavigationWhere: 'Երևում է կայքի վերնագրում յուրաքանչյուր էջում։',
groupNavigationSkip: 'Գնորդները կօգտագործեն լռելյայն նավիգացիան։',
groupPreviewLabel: 'Նախադիտում',
groupPreviewDescription: 'Տեսեք, թե ինչպես է ձեր մարկետփլեյսը երևում դեսքթոփի, պլանշետի և հեռախոսի վրա։',
groupPreviewPurpose: 'Ստուգեք ձեր մարկետփլեյսը գնորդի աչքերով նախքան հրապարակելը՝ տարբեր էկրանների վրա։',
groupPreviewWhere: 'Սա նախադիտում է՝ գնորդները այն երբեք չեն տեսնում։',
groupPreviewSkip: 'Կարող եք հրապարակել փոփոխությունները առանց ստուգելու։',
},
dashboard: {
title: 'Կառավարման վահանակ',
@@ -708,7 +785,7 @@ export const hy: Translations = {
statePendingBackend: 'Սպասվում է backend ինտեգրման',
never: 'Երբեք',
quickActionsTitle: 'Արագ գործողություններ',
actionEditProject: 'Խմբագրել նախագիծը',
actionEditProject: 'Մարկետփլեյսի կոնստրուկտոր',
actionCategories: 'Կատեգորիաներ',
actionProducts: 'Ապրանքներ',
actionStaticPages: 'Ստատիկ էջեր',

View File

@@ -406,8 +406,8 @@ export const ru: Translations = {
notAvailable: 'н/д',
},
builder: {
title: 'Редактор проекта маркетплейса',
subtitle: 'Поверхность редактирования только bootstrap-конфигурации.',
title: 'Конструктор маркетплейса',
subtitle: 'Соберите свой маркетплейс шаг за шагом.',
general: 'Общие',
branding: 'Брендинг',
theme: 'Тема',
@@ -685,6 +685,83 @@ export const ru: Translations = {
linkLabelDesc: 'Текст, отображаемый для этой ссылки навигации.',
linkUrlDesc: 'Путь или URL, на который ведёт эта ссылка навигации.',
linkVisibleDesc: 'Показывать ли эту ссылку навигации покупателям.',
appName: 'Конструктор маркетплейса',
breadcrumbRoot: 'Конструктор маркетплейса',
statusComplete: 'Готово',
statusInProgress: 'В процессе',
statusNotStarted: 'Не начато',
statusUnknown: 'Пока не отслеживается',
draftBadge: 'Черновик',
publishedBadge: 'Опубликовано',
modifiedBadge: 'Есть несохранённые изменения',
helpToggleShow: 'Что это такое?',
helpToggleHide: 'Скрыть подсказку',
helpWhatIsThis: 'Что это',
helpWhySkip: 'Зачем это настраивать',
helpWhereVisible: 'Где это видят покупатели',
helpWhatIfSkip: 'Что будет, если пропустить',
helpWhenReturn: 'Когда возвращаться к этому разделу',
openSidebar: 'Открыть меню разделов',
closeSidebar: 'Закрыть меню разделов',
overviewTitle: 'Конструктор маркетплейса',
overviewSubtitle: 'Соберите свой маркетплейс шаг за шагом',
overviewReadinessLabel: 'Маркетплейс настроен на {{percent}}%',
overviewReadinessHint: 'Основано на обязательных полях, которые вы уже заполнили.',
overviewNextStepLabel: 'Рекомендуемый следующий шаг',
overviewNextStepFallback: 'Все обязательные шаги выполнены — можно опубликовать маркетплейс.',
overviewQuickLinksTitle: 'Быстрые ссылки',
overviewSectionsTitle: 'Разделы конструктора',
overviewOpenSection: 'Открыть раздел',
overviewReadinessTitle: 'Готовность маркетплейса',
readinessLogoUploaded: 'Логотип загружен',
readinessHomepageConfigured: 'Главная страница настроена',
readinessThemeConfigured: 'Тема настроена',
readinessLanguagesConfigured: 'Языки настроены',
readinessContentPagesAdded: 'Добавлены информационные страницы',
readinessPreviewReviewed: 'Просмотр проверен',
readinessPublished: 'Маркетплейс опубликован',
readinessCategoriesExist: 'Есть категории',
readinessProductsExist: 'Есть товары',
groupMarketplaceLabel: 'Маркетплейс',
groupMarketplaceDescription: 'Название, адрес и основная информация о вашем маркетплейсе.',
groupMarketplacePurpose: 'Базовая информация, которая определяет ваш маркетплейс: название, домен и язык по умолчанию.',
groupMarketplaceWhere: 'Название и адрес видны во вкладке браузера, поисковой выдаче и на всех страницах магазина.',
groupMarketplaceSkip: 'Без названия и адреса маркетплейс не будет выглядеть завершённым для покупателей.',
groupBrandingLabel: 'Брендинг и оформление',
groupBrandingDescription: 'Логотип, цвета, шапка и подвал — визуальный стиль вашего магазина.',
groupBrandingPurpose: 'Настройте, как выглядит ваш маркетплейс: логотип, цветовую палитру и элементы шапки/подвала сайта.',
groupBrandingWhere: 'Изменения видны на каждой странице, которую видят покупатели.',
groupBrandingSkip: 'Маркетплейс будет использовать оформление по умолчанию, не отражающее ваш бренд.',
groupHomepageLabel: 'Главная страница',
groupHomepageDescription: 'Баннеры, витрины и виджеты, которые покупатели видят первыми.',
groupHomepagePurpose: 'Соберите главную страницу из блоков: приветственный баннер, подборки категорий и товаров, акции.',
groupHomepageWhere: 'Это первая страница, которую видит покупатель, открыв ваш маркетплейс.',
groupHomepageSkip: 'Главная страница останется пустой или будет использовать макет по умолчанию.',
groupContentLabel: 'Контент',
groupContentDescription: 'О компании, контакты, доставка, возврат и другие информационные страницы.',
groupContentPurpose: 'Добавьте страницы, которым покупатели доверяют: о компании, контакты, условия доставки и возврата.',
groupContentWhere: 'Ссылки на эти страницы обычно показываются в подвале сайта.',
groupContentSkip: 'У покупателей не будет ответов на вопросы о доставке, возврате и контактах — это снижает доверие.',
groupLanguagesLabel: 'Языки',
groupLanguagesDescription: 'На каких языках работает ваш маркетплейс.',
groupLanguagesPurpose: 'Выберите язык по умолчанию и добавьте дополнительные языки для покупателей из других регионов.',
groupLanguagesWhere: 'Переключатель языка виден в шапке сайта на каждой странице.',
groupLanguagesSkip: 'Маркетплейс будет доступен только на одном языке по умолчанию.',
groupFeaturesLabel: 'Функции маркетплейса',
groupFeaturesDescription: 'Список желаний, сравнение, отзывы, оформление заказа без регистрации и другое.',
groupFeaturesPurpose: 'Включите или отключите функции для покупателей: список желаний, сравнение товаров, гостевой заказ.',
groupFeaturesWhere: 'Влияет на страницы каталога, товара и оформления заказа.',
groupFeaturesSkip: 'Маркетплейс будет использовать функции по умолчанию.',
groupNavigationLabel: 'Навигация и поиск',
groupNavigationDescription: 'Меню сайта и то, как покупатели находят товары.',
groupNavigationPurpose: 'Настройте пункты меню и то, как устроена навигация по каталогу.',
groupNavigationWhere: 'Видно в шапке сайта на каждой странице.',
groupNavigationSkip: 'Покупатели будут использовать навигацию по умолчанию.',
groupPreviewLabel: 'Просмотр',
groupPreviewDescription: 'Посмотрите, как маркетплейс выглядит на десктопе, планшете и телефоне.',
groupPreviewPurpose: 'Проверьте маркетплейс глазами покупателя перед публикацией — на разных экранах.',
groupPreviewWhere: 'Это предпросмотр — покупатели его не видят.',
groupPreviewSkip: 'Вы можете опубликовать изменения непроверенными.',
},
dashboard: {
title: 'Панель управления',
@@ -708,7 +785,7 @@ export const ru: Translations = {
statePendingBackend: 'Ожидает интеграции с бэкендом',
never: 'Никогда',
quickActionsTitle: 'Быстрые действия',
actionEditProject: 'Редактировать проект',
actionEditProject: 'Конструктор маркетплейса',
actionCategories: 'Категории',
actionProducts: 'Товары',
actionStaticPages: 'Статические страницы',

View File

@@ -683,6 +683,83 @@ export interface Translations {
linkLabelDesc: string;
linkUrlDesc: string;
linkVisibleDesc: string;
appName: string;
breadcrumbRoot: string;
statusComplete: string;
statusInProgress: string;
statusNotStarted: string;
statusUnknown: string;
draftBadge: string;
publishedBadge: string;
modifiedBadge: string;
helpToggleShow: string;
helpToggleHide: string;
helpWhatIsThis: string;
helpWhySkip: string;
helpWhereVisible: string;
helpWhatIfSkip: string;
helpWhenReturn: string;
openSidebar: string;
closeSidebar: string;
overviewTitle: string;
overviewSubtitle: string;
overviewReadinessLabel: string;
overviewReadinessHint: string;
overviewNextStepLabel: string;
overviewNextStepFallback: string;
overviewQuickLinksTitle: string;
overviewSectionsTitle: string;
overviewOpenSection: string;
overviewReadinessTitle: string;
readinessLogoUploaded: string;
readinessHomepageConfigured: string;
readinessThemeConfigured: string;
readinessLanguagesConfigured: string;
readinessContentPagesAdded: string;
readinessPreviewReviewed: string;
readinessPublished: string;
readinessCategoriesExist: string;
readinessProductsExist: string;
groupMarketplaceLabel: string;
groupMarketplaceDescription: string;
groupMarketplacePurpose: string;
groupMarketplaceWhere: string;
groupMarketplaceSkip: string;
groupBrandingLabel: string;
groupBrandingDescription: string;
groupBrandingPurpose: string;
groupBrandingWhere: string;
groupBrandingSkip: string;
groupHomepageLabel: string;
groupHomepageDescription: string;
groupHomepagePurpose: string;
groupHomepageWhere: string;
groupHomepageSkip: string;
groupContentLabel: string;
groupContentDescription: string;
groupContentPurpose: string;
groupContentWhere: string;
groupContentSkip: string;
groupLanguagesLabel: string;
groupLanguagesDescription: string;
groupLanguagesPurpose: string;
groupLanguagesWhere: string;
groupLanguagesSkip: string;
groupFeaturesLabel: string;
groupFeaturesDescription: string;
groupFeaturesPurpose: string;
groupFeaturesWhere: string;
groupFeaturesSkip: string;
groupNavigationLabel: string;
groupNavigationDescription: string;
groupNavigationPurpose: string;
groupNavigationWhere: string;
groupNavigationSkip: string;
groupPreviewLabel: string;
groupPreviewDescription: string;
groupPreviewPurpose: string;
groupPreviewWhere: string;
groupPreviewSkip: string;
};
dashboard: {
title: string;