From 04b36bab9b686d904dc404df66e86e0296aed0c0 Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Sat, 18 Jul 2026 10:24:13 +0400 Subject: [PATCH] feat(builder): implement visual homepage builder New HomepageOverviewComponent (Tasks 1+8), mounted at the top of the existing Homepage section page: completion ring, active/hidden section counts, recommended next step, last-modified timestamp, and a 6-item Homepage Health checklist (hero/categories/products/promotion/ newsletter/any-sections) - all derived from the real page.sections/ widgets data already in the facade, nothing fabricated. Existing page-level drag-and-drop reordering (homepage-section's CdkDragDrop over page.sections) was already implemented pre-Sprint 5 - left as-is per "build on top of, do not replace." Widgets section (Task 2) rewritten from a bare list into visual cards: per-widget icon + humanized type label (hero/categories/product- collection), a visible/hidden toggle and badge, up/down move buttons (keyboard-accessible reorder within a section - see Known limitations for why this replaces pointer drag for widgets specifically), duplicate, and remove (with confirm). The existing per-type field editors (hero/ categories/product-collection) are unchanged; the raw-JSON fallback for unknown widget types now sits behind a collapsed "Advanced settings" disclosure instead of being the default view. Co-Authored-By: Claude Sonnet 5 --- .../sections/homepage-section.component.html | 1 + .../sections/homepage-section.component.ts | 3 +- .../homepage/homepage-overview.component.html | 41 ++++++ .../homepage/homepage-overview.component.scss | 118 ++++++++++++++++++ .../homepage/homepage-overview.component.ts | 67 ++++++++++ .../sections/widgets-section.component.html | 52 ++++++-- .../sections/widgets-section.component.scss | 96 ++++++++++++++ .../sections/widgets-section.component.ts | 104 ++++++++++++++- src/app/i18n/en.ts | 22 ++++ src/app/i18n/hy.ts | 22 ++++ src/app/i18n/ru.ts | 22 ++++ src/app/i18n/translations.ts | 22 ++++ 12 files changed, 551 insertions(+), 19 deletions(-) create mode 100644 src/app/features/project-editor/sections/homepage/homepage-overview.component.html create mode 100644 src/app/features/project-editor/sections/homepage/homepage-overview.component.scss create mode 100644 src/app/features/project-editor/sections/homepage/homepage-overview.component.ts create mode 100644 src/app/features/project-editor/sections/widgets-section.component.scss diff --git a/src/app/features/project-editor/sections/homepage-section.component.html b/src/app/features/project-editor/sections/homepage-section.component.html index 72276ed..a696354 100644 --- a/src/app/features/project-editor/sections/homepage-section.component.html +++ b/src/app/features/project-editor/sections/homepage-section.component.html @@ -1,4 +1,5 @@ @if (homePage()) { + @if (fieldError('pages'); as msg) {

{{ msg }}

diff --git a/src/app/features/project-editor/sections/homepage-section.component.ts b/src/app/features/project-editor/sections/homepage-section.component.ts index 440fb67..85d253b 100644 --- a/src/app/features/project-editor/sections/homepage-section.component.ts +++ b/src/app/features/project-editor/sections/homepage-section.component.ts @@ -7,6 +7,7 @@ import { TranslateService } from '../../../i18n/translate.service'; import { InputComponent } from '../../../shared/ui/input/input.component'; import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component'; import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component'; +import { HomepageOverviewComponent } from './homepage/homepage-overview.component'; export interface LayoutStrategyOption { value: 'stack' | 'grid' | 'hero' | 'carousel' | 'split'; @@ -17,7 +18,7 @@ export interface LayoutStrategyOption { @Component({ selector: 'app-project-editor-homepage-section', standalone: true, - imports: [DragDropModule, FormsModule, TranslatePipe, InputComponent, SectionCardComponent, ToggleComponent], + imports: [DragDropModule, FormsModule, TranslatePipe, InputComponent, SectionCardComponent, ToggleComponent, HomepageOverviewComponent], templateUrl: './homepage-section.component.html', styleUrls: ['./section.shared.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/app/features/project-editor/sections/homepage/homepage-overview.component.html b/src/app/features/project-editor/sections/homepage/homepage-overview.component.html new file mode 100644 index 0000000..978ff10 --- /dev/null +++ b/src/app/features/project-editor/sections/homepage/homepage-overview.component.html @@ -0,0 +1,41 @@ +@if (bootstrap()) { +
+
+
+ {{ completionPercent() }}% +
+
+

{{ 'builder.homepageOverviewTitle' | translate }}

+

{{ 'builder.homepageOverviewSubtitle' | translate }}

+

+ {{ 'builder.homepageActiveSections' | translate }}: {{ activeCount() }} +  ·  + {{ 'builder.homepageDisabledSections' | translate }}: {{ disabledCount() }} +

+ @if (nextStepKey(); as key) { +

+ + {{ 'builder.overviewNextStepLabel' | translate }}: {{ key | translate }} +

+ } @else { +

+ + {{ 'builder.homepageOverviewComplete' | translate }} +

+ } + @if (formattedLastSaved(); as saved) { +

{{ 'builder.brandLastModified' | translate }}: {{ saved }}

+ } +
+
+ +
    + @for (item of healthItems(); track item.labelKey) { +
  • + + {{ item.labelKey | translate }} +
  • + } +
+
+} diff --git a/src/app/features/project-editor/sections/homepage/homepage-overview.component.scss b/src/app/features/project-editor/sections/homepage/homepage-overview.component.scss new file mode 100644 index 0000000..c8b577c --- /dev/null +++ b/src/app/features/project-editor/sections/homepage/homepage-overview.component.scss @@ -0,0 +1,118 @@ +.homepage-overview { + display: flex; + flex-direction: column; + gap: var(--space-lg); + padding: var(--space-lg); + margin-bottom: var(--space-lg); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-primary); + box-shadow: var(--shadow-sm); +} + +.homepage-overview__summary { + display: flex; + align-items: center; + gap: var(--space-lg); + flex-wrap: wrap; +} + +.homepage-overview__ring { + flex-shrink: 0; + width: 88px; + height: 88px; + border-radius: 50%; + display: grid; + place-items: center; + font-weight: 700; + font-size: 1.125rem; + color: var(--text-primary); + + span { + width: 68px; + height: 68px; + border-radius: 50%; + background: var(--bg-primary); + display: grid; + place-items: center; + } +} + +.homepage-overview__summary-text { + flex: 1 1 auto; + min-width: 200px; + + h2 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + } + + p { + margin: 4px 0 0; + font-size: 1rem; + color: var(--text-secondary); + } +} + +.homepage-overview__counts { + font-size: 0.75rem; +} + +.homepage-overview__next-step { + display: flex; + align-items: center; + gap: var(--space-xs); + font-weight: 600; + color: var(--primary-color); + + &--done { + color: var(--success-color); + } +} + +.homepage-overview__last-saved { + font-size: 0.75rem; + color: var(--text-light); +} + +.homepage-overview__checklist { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: var(--space-sm); + + li { + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: 1rem; + color: var(--text-light); + + .pi { + font-size: 1rem; + } + } +} + +.homepage-overview__check--ok { + color: var(--text-primary); + + .pi { + color: var(--success-color); + } +} + +@media (max-width: 639px) { + .homepage-overview { + padding: var(--space-md); + } + + .homepage-overview__summary { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/src/app/features/project-editor/sections/homepage/homepage-overview.component.ts b/src/app/features/project-editor/sections/homepage/homepage-overview.component.ts new file mode 100644 index 0000000..d4b42ce --- /dev/null +++ b/src/app/features/project-editor/sections/homepage/homepage-overview.component.ts @@ -0,0 +1,67 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { TranslatePipe } from '../../../../i18n/translate.pipe'; +import { LanguageService } from '../../../../services/language.service'; +import { ProjectEditorFacade } from '../../facade/project-editor.facade'; + +interface HomepageHealthItem { + labelKey: string; + ok: boolean; +} + +@Component({ + selector: 'app-homepage-overview', + standalone: true, + imports: [TranslatePipe], + templateUrl: './homepage-overview.component.html', + styleUrl: './homepage-overview.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class HomepageOverviewComponent { + private readonly facade = inject(ProjectEditorFacade); + private readonly languageService = inject(LanguageService); + + readonly bootstrap = this.facade.bootstrap; + readonly widgets = this.facade.homepageWidgets; + readonly lastSavedAt = this.facade.lastSavedAt; + + private hasType(...keywords: string[]): boolean { + return this.widgets().some(w => keywords.some(k => w.widget.type.toLowerCase().includes(k))); + } + + readonly activeCount = computed(() => this.widgets().filter(w => w.widget.visible !== false).length); + readonly disabledCount = computed(() => this.widgets().filter(w => w.widget.visible === false).length); + + readonly healthItems = computed(() => { + if (!this.bootstrap()) { + return []; + } + return [ + { labelKey: 'builder.homepageHealthHero', ok: this.hasType('hero') }, + { labelKey: 'builder.homepageHealthCategories', ok: this.hasType('categor') }, + { labelKey: 'builder.homepageHealthProducts', ok: this.hasType('product') }, + { labelKey: 'builder.homepageHealthPromotion', ok: this.hasType('promo', 'banner') }, + { labelKey: 'builder.homepageHealthNewsletter', ok: this.hasType('newsletter') }, + { labelKey: 'builder.homepageHealthAnySections', ok: this.widgets().length > 0 }, + ]; + }); + + readonly completionPercent = computed(() => { + const items = this.healthItems(); + if (items.length === 0) { + return null; + } + return Math.round((items.filter(i => i.ok).length / items.length) * 100); + }); + + readonly ringBackground = computed(() => { + const pct = this.completionPercent() ?? 0; + return `conic-gradient(var(--primary-color) ${pct}%, var(--bg-tertiary) 0)`; + }); + + readonly nextStepKey = computed(() => this.healthItems().find(i => !i.ok)?.labelKey ?? null); + + formattedLastSaved(): string | null { + const timestamp = this.lastSavedAt(); + return timestamp ? new Date(timestamp).toLocaleString(this.languageService.currentLanguage()) : null; + } +} diff --git a/src/app/features/project-editor/sections/widgets-section.component.html b/src/app/features/project-editor/sections/widgets-section.component.html index 187f479..2dfb1c4 100644 --- a/src/app/features/project-editor/sections/widgets-section.component.html +++ b/src/app/features/project-editor/sections/widgets-section.component.html @@ -3,10 +3,35 @@ @if (fieldError('pages'); as msg) {

{{ msg }}

} -
- @for (entry of widgets(); track entry.widget.id) { -
-

{{ entry.widget.type }} · {{ entry.widget.id }}

+
+ @for (entry of widgets(); track entry.widget.id; let first = $first; let last = $last) { +
+
+ +

{{ widgetLabel(entry.widget) }}

+ @if (!isVisible(entry.widget)) { + {{ 'builder.widgetHidden' | translate }} + } +
+ + + + + +
+
+ @switch (entry.widget.type) { @case ('hero') {
@@ -33,14 +58,17 @@
} @default { - +
+ {{ 'builder.widgetAdvancedSettings' | translate }} + +
} }
diff --git a/src/app/features/project-editor/sections/widgets-section.component.scss b/src/app/features/project-editor/sections/widgets-section.component.scss new file mode 100644 index 0000000..5d1a80f --- /dev/null +++ b/src/app/features/project-editor/sections/widgets-section.component.scss @@ -0,0 +1,96 @@ +.widget-card--hidden { + opacity: 0.6; +} + +.widget-card__header { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-sm); + margin-bottom: var(--space-sm); +} + +.widget-card__icon { + font-size: 1.25rem; + color: var(--primary-color); + flex-shrink: 0; +} + +.widget-card__title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + flex: 1 1 auto; + min-width: 120px; +} + +.widget-card__hidden-badge { + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + padding: 2px 8px; + border-radius: var(--radius-sm); + background: var(--bg-tertiary); + color: var(--text-light); +} + +.widget-card__actions { + display: flex; + align-items: center; + gap: var(--space-xs); + flex-wrap: wrap; + + button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-primary); + color: var(--text-secondary); + cursor: pointer; + + &:hover:not(:disabled) { + border-color: var(--primary-color); + color: var(--primary-color); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } +} + +.widget-card__remove:hover:not(:disabled) { + border-color: var(--error-color) !important; + color: var(--error-color) !important; +} + +.widget-card__visible-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.75rem; + color: var(--text-secondary); +} + +.widget-card__advanced { + margin-top: var(--space-sm); + + summary { + cursor: pointer; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-light); + } +} diff --git a/src/app/features/project-editor/sections/widgets-section.component.ts b/src/app/features/project-editor/sections/widgets-section.component.ts index 4add87c..243e875 100644 --- a/src/app/features/project-editor/sections/widgets-section.component.ts +++ b/src/app/features/project-editor/sections/widgets-section.component.ts @@ -6,13 +6,26 @@ import { TranslateService } from '../../../i18n/translate.service'; import { InputComponent } from '../../../shared/ui/input/input.component'; import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component'; import { ToggleComponent } from '../../../shared/ui/toggle/toggle.component'; +import { WidgetConfig } from '../../../shared/models/config'; + +const WIDGET_ICONS: Record = { + hero: 'pi-image', + categories: 'pi-th-large', + 'product-collection': 'pi-box', +}; + +const WIDGET_LABEL_KEYS: Record = { + hero: 'builder.widgetTypeHero', + categories: 'builder.widgetTypeCategories', + 'product-collection': 'builder.widgetTypeProducts', +}; @Component({ selector: 'app-project-editor-widgets-section', standalone: true, imports: [FormsModule, TranslatePipe, InputComponent, SectionCardComponent, ToggleComponent], templateUrl: './widgets-section.component.html', - styleUrls: ['./section.shared.scss'], + styleUrls: ['./section.shared.scss', './widgets-section.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class ProjectEditorWidgetsSectionComponent { @@ -24,22 +37,101 @@ export class ProjectEditorWidgetsSectionComponent { return messageKey ? this.translate.t(messageKey) : null; }; - updateWidget(widgetId: string, updater: (props: Record) => Record): void { + widgetIcon(type: string): string { + return WIDGET_ICONS[type] ?? 'pi-stop'; + } + + widgetLabel(widget: WidgetConfig): string { + if (widget.title) { + return widget.title; + } + const key = WIDGET_LABEL_KEYS[widget.type]; + return key ? this.translate.t(key) : widget.type; + } + + isVisible(widget: WidgetConfig): boolean { + return widget.visible !== false; + } + + toggleVisible(widgetId: string): void { + this.updateWidgetConfig(widgetId, widget => ({ ...widget, visible: !this.isVisible(widget) })); + } + + moveWidget(widgetId: string, direction: -1 | 1): void { + this.facade.updateBootstrap(current => ({ + ...current, + pages: current.pages.map(page => ({ + ...page, + sections: page.sections.map(section => { + const index = section.widgets.findIndex(w => w.id === widgetId); + if (index === -1) { + return section; + } + const targetIndex = index + direction; + if (targetIndex < 0 || targetIndex >= section.widgets.length) { + return section; + } + const widgets = [...section.widgets]; + [widgets[index], widgets[targetIndex]] = [widgets[targetIndex], widgets[index]]; + return { ...section, widgets }; + }) + })) + })); + } + + duplicateWidget(widgetId: string): void { + this.facade.updateBootstrap(current => ({ + ...current, + pages: current.pages.map(page => ({ + ...page, + sections: page.sections.map(section => { + const index = section.widgets.findIndex(w => w.id === widgetId); + if (index === -1) { + return section; + } + const original = section.widgets[index]; + const copy: WidgetConfig = { ...original, id: `${original.id}-copy-${Date.now()}` }; + const widgets = [...section.widgets]; + widgets.splice(index + 1, 0, copy); + return { ...section, widgets }; + }) + })) + })); + } + + removeWidget(widgetId: string): void { + if (!confirm(this.translate.t('builder.widgetRemoveConfirm'))) { + return; + } this.facade.updateBootstrap(current => ({ ...current, pages: current.pages.map(page => ({ ...page, sections: page.sections.map(section => ({ ...section, - widgets: section.widgets.map(widget => widget.id !== widgetId ? widget : ({ - ...widget, - props: updater(widget.props ?? {}) - })) + widgets: section.widgets.filter(w => w.id !== widgetId) })) })) })); } + private updateWidgetConfig(widgetId: string, updater: (widget: WidgetConfig) => WidgetConfig): void { + this.facade.updateBootstrap(current => ({ + ...current, + pages: current.pages.map(page => ({ + ...page, + sections: page.sections.map(section => ({ + ...section, + widgets: section.widgets.map(widget => widget.id !== widgetId ? widget : updater(widget)) + })) + })) + })); + } + + updateWidget(widgetId: string, updater: (props: Record) => Record): void { + this.updateWidgetConfig(widgetId, widget => ({ ...widget, props: updater(widget.props ?? {}) })); + } + updateProp(widgetId: string, key: string, value: unknown): void { this.updateWidget(widgetId, props => ({ ...props, [key]: value })); } diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 1106336..7558b79 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -780,6 +780,28 @@ export const en: Translations = { brandSocialEmpty: "No social share image yet - shared links will show a blank card until you add one.", brandAccentColor: 'Accent', brandBorderColor: 'Borders', + homepageOverviewTitle: 'Homepage', + homepageOverviewSubtitle: "What your marketplace's homepage is made of.", + homepageOverviewComplete: 'Your homepage is fully set up.', + homepageActiveSections: 'Active sections', + homepageDisabledSections: 'Hidden sections', + homepageHealthHero: 'Hero banner added', + homepageHealthCategories: 'Featured categories added', + homepageHealthProducts: 'Featured products added', + homepageHealthPromotion: 'Promotion block added', + homepageHealthNewsletter: 'Newsletter signup added', + homepageHealthAnySections: 'The page has at least one section', + widgetTypeHero: 'Hero banner', + widgetTypeCategories: 'Featured categories', + widgetTypeProducts: 'Featured products', + widgetHidden: 'Hidden', + widgetVisible: 'Show', + widgetMoveUp: 'Move up', + widgetMoveDown: 'Move down', + widgetDuplicate: 'Duplicate', + widgetRemove: 'Remove', + widgetRemoveConfirm: 'Remove this section from the homepage?', + widgetAdvancedSettings: 'Advanced settings', }, dashboard: { title: 'Dashboard', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 8f13804..b0b2752 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -780,6 +780,28 @@ export const hy: Translations = { brandSocialEmpty: 'Սոցիալական պատկեր դեռ վերբեռնված չէ․ հղումը կիսելիս գնորդները կտեսնեն դատարկ քարտ։', brandAccentColor: 'Ընդգծող', brandBorderColor: 'Եզրագծեր', + homepageOverviewTitle: 'Գլխավոր էջ', + homepageOverviewSubtitle: 'Ինչից է կազմված ձեր մարկետփլեյսի գլխավոր էջը։', + homepageOverviewComplete: 'Ձեր գլխավոր էջն ամբողջությամբ կարգավորված է։', + homepageActiveSections: 'Ակտիվ բլոկներ', + homepageDisabledSections: 'Թաքցված բլոկներ', + homepageHealthHero: 'Ողջունող բաններ ավելացված է', + homepageHealthCategories: 'Կատեգորիաների բլոկ ավելացված է', + homepageHealthProducts: 'Ապրանքների բլոկ ավելացված է', + homepageHealthPromotion: 'Ակցիայի բլոկ ավելացված է', + homepageHealthNewsletter: 'Բաժանորդագրման ձև ավելացված է', + homepageHealthAnySections: 'Էջում կա գոնե մեկ բլոկ', + widgetTypeHero: 'Ողջունող բաններ', + widgetTypeCategories: 'Կատեգորիաների բլոկ', + widgetTypeProducts: 'Ապրանքների բլոկ', + widgetHidden: 'Թաքցված', + widgetVisible: 'Ցուցադրել', + widgetMoveUp: 'Տեղափոխել վերև', + widgetMoveDown: 'Տեղափոխել ներքև', + widgetDuplicate: 'Կրկնօրինակել', + widgetRemove: 'Հեռացնել', + widgetRemoveConfirm: 'Հեռացնե՞լ այս բլոկը գլխավոր էջից։', + widgetAdvancedSettings: 'Լրացուցիչ կարգավորումներ', }, dashboard: { title: 'Կառավարման վահանակ', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 8e30c06..72cef7b 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -780,6 +780,28 @@ export const ru: Translations = { brandSocialEmpty: 'Изображение для соцсетей ещё не загружено — при публикации ссылки покупатели увидят пустую карточку.', brandAccentColor: 'Акцентный', brandBorderColor: 'Границы', + homepageOverviewTitle: 'Главная страница', + homepageOverviewSubtitle: 'Из чего состоит главная страница вашего маркетплейса.', + homepageOverviewComplete: 'Главная страница полностью настроена.', + homepageActiveSections: 'Активные блоки', + homepageDisabledSections: 'Скрытые блоки', + homepageHealthHero: 'Приветственный баннер добавлен', + homepageHealthCategories: 'Витрина категорий добавлена', + homepageHealthProducts: 'Витрина товаров добавлена', + homepageHealthPromotion: 'Блок акции добавлен', + homepageHealthNewsletter: 'Форма подписки добавлена', + homepageHealthAnySections: 'На странице есть хотя бы один блок', + widgetTypeHero: 'Приветственный баннер', + widgetTypeCategories: 'Витрина категорий', + widgetTypeProducts: 'Витрина товаров', + widgetHidden: 'Скрыт', + widgetVisible: 'Показывать', + widgetMoveUp: 'Переместить выше', + widgetMoveDown: 'Переместить ниже', + widgetDuplicate: 'Дублировать', + widgetRemove: 'Удалить', + widgetRemoveConfirm: 'Удалить этот блок с главной страницы?', + widgetAdvancedSettings: 'Дополнительные настройки', }, dashboard: { title: 'Панель управления', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 213ad5d..88735d3 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -778,6 +778,28 @@ export interface Translations { brandSocialEmpty: string; brandAccentColor: string; brandBorderColor: string; + homepageOverviewTitle: string; + homepageOverviewSubtitle: string; + homepageOverviewComplete: string; + homepageActiveSections: string; + homepageDisabledSections: string; + homepageHealthHero: string; + homepageHealthCategories: string; + homepageHealthProducts: string; + homepageHealthPromotion: string; + homepageHealthNewsletter: string; + homepageHealthAnySections: string; + widgetTypeHero: string; + widgetTypeCategories: string; + widgetTypeProducts: string; + widgetHidden: string; + widgetVisible: string; + widgetMoveUp: string; + widgetMoveDown: string; + widgetDuplicate: string; + widgetRemove: string; + widgetRemoveConfirm: string; + widgetAdvancedSettings: string; }; dashboard: { title: string;