diff --git a/src/app/features/project-editor/sections/brand/brand-overview.component.html b/src/app/features/project-editor/sections/brand/brand-overview.component.html new file mode 100644 index 0000000..5dba7fa --- /dev/null +++ b/src/app/features/project-editor/sections/brand/brand-overview.component.html @@ -0,0 +1,85 @@ +@if (bootstrap()) { +
+
+
+ {{ completionPercent() }}% +
+
+

{{ 'builder.brandOverviewTitle' | translate }}

+

{{ 'builder.brandOverviewSubtitle' | translate }}

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

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

+ } @else { +

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

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

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

+ } +
+
+ + + +
+
+

{{ 'builder.brandColorsPreviewTitle' | translate }}

+
+ @for (swatch of paletteSwatches(); track swatch.key) { +
+ + {{ swatch.labelKey | translate }} +
+ } +
+ @if (!textContrastPasses()) { +

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

+ } +
+ +
+

{{ 'builder.brandTypographyPreviewTitle' | translate }}

+ @if (typography(); as type) { +
+

{{ 'builder.brandTypeSampleHeading' | translate }}

+

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

+
+ } +
+ +
+

{{ 'builder.brandSocialPreviewTitle' | translate }}

+ @if (branding()?.socialImageUrl; as image) { + + } @else { + + } +
+
+
+} diff --git a/src/app/features/project-editor/sections/brand/brand-overview.component.scss b/src/app/features/project-editor/sections/brand/brand-overview.component.scss new file mode 100644 index 0000000..cb3eb53 --- /dev/null +++ b/src/app/features/project-editor/sections/brand/brand-overview.component.scss @@ -0,0 +1,234 @@ +.brand-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); +} + +.brand-overview__summary { + display: flex; + align-items: center; + gap: var(--space-lg); + flex-wrap: wrap; +} + +.brand-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; + } +} + +.brand-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); + } +} + +.brand-overview__next-step { + display: flex; + align-items: center; + gap: var(--space-xs); + font-weight: 600; + color: var(--primary-color); + + &--done { + color: var(--success-color); + } +} + +.brand-overview__last-saved { + font-size: 0.75rem; + color: var(--text-light); +} + +.brand-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; + } + } +} + +.brand-overview__check--ok { + color: var(--text-primary); + + .pi { + color: var(--success-color); + } +} + +.brand-overview__row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: var(--space-md); +} + +.brand-overview__card { + padding: var(--space-md); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--bg-secondary); + + h3 { + margin: 0 0 var(--space-sm); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--text-light); + } +} + +.brand-overview__swatches { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); + gap: var(--space-sm); +} + +.brand-overview__swatch { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.brand-overview__swatch-color { + display: block; + width: 100%; + aspect-ratio: 1; + border-radius: var(--radius-sm); + border: 1px solid var(--border-color); +} + +.brand-overview__swatch-label { + font-size: 0.7rem; + color: var(--text-light); + text-align: center; +} + +.brand-overview__contrast-warning { + display: flex; + align-items: center; + gap: var(--space-xs); + margin: var(--space-sm) 0 0; + padding: var(--space-sm); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--warning-color) 12%, transparent); + color: var(--warning-color); + font-size: 0.75rem; + font-weight: 600; +} + +.brand-overview__type-sample { + padding: var(--space-sm); + border-radius: var(--radius-sm); + background: var(--bg-primary); + border: 1px solid var(--border-color); +} + +.brand-overview__type-heading { + margin: 0 0 4px; + font-size: 1.125rem; + font-weight: 700; + color: var(--text-primary); +} + +.brand-overview__type-body { + margin: 0; + color: var(--text-secondary); +} + +.brand-overview__social-card { + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + overflow: hidden; + background: var(--bg-primary); +} + +.brand-overview__social-image { + width: 100%; + aspect-ratio: 1.91 / 1; + object-fit: cover; + background: var(--bg-tertiary); +} + +.brand-overview__social-text { + padding: var(--space-sm); +} + +.brand-overview__social-title { + margin: 0; + font-weight: 600; + font-size: 1rem; + color: var(--text-primary); +} + +.brand-overview__social-desc { + margin: 2px 0 0; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.brand-overview__social-empty { + margin: 0; + font-size: 0.75rem; + color: var(--text-light); +} + +@media (max-width: 639px) { + .brand-overview { + padding: var(--space-md); + } + + .brand-overview__summary { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/src/app/features/project-editor/sections/brand/brand-overview.component.ts b/src/app/features/project-editor/sections/brand/brand-overview.component.ts new file mode 100644 index 0000000..5db424a --- /dev/null +++ b/src/app/features/project-editor/sections/brand/brand-overview.component.ts @@ -0,0 +1,107 @@ +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'; +import { contrastRatio } from './contrast.util'; + +interface BrandHealthItem { + labelKey: string; + ok: boolean; +} + +@Component({ + selector: 'app-brand-overview', + standalone: true, + imports: [TranslatePipe], + templateUrl: './brand-overview.component.html', + styleUrl: './brand-overview.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BrandOverviewComponent { + private readonly facade = inject(ProjectEditorFacade); + private readonly languageService = inject(LanguageService); + + readonly bootstrap = this.facade.bootstrap; + readonly lastSavedAt = this.facade.lastSavedAt; + + readonly branding = computed(() => this.bootstrap()?.branding ?? null); + readonly palette = computed(() => this.bootstrap()?.theme.palette ?? null); + readonly typography = computed(() => this.bootstrap()?.theme.typography ?? null); + + readonly paletteSwatches = computed(() => { + const palette = this.palette(); + if (!palette) { + return []; + } + return [ + { key: 'primary', labelKey: 'builder.primaryColor', value: palette.primary }, + { key: 'secondary', labelKey: 'builder.secondaryColor', value: palette.secondary }, + { key: 'accent', labelKey: 'builder.brandAccentColor', value: palette.accent }, + { key: 'success', labelKey: 'builder.successColor', value: palette.success }, + { key: 'warning', labelKey: 'builder.warningColor', value: palette.warning }, + { key: 'danger', labelKey: 'builder.dangerColor', value: palette.danger }, + { key: 'backgroundPrimary', labelKey: 'builder.backgroundColor', value: palette.backgroundPrimary }, + { key: 'backgroundSecondary', labelKey: 'builder.surfaceColor', value: palette.backgroundSecondary }, + { key: 'textPrimary', labelKey: 'builder.textColor', value: palette.textPrimary }, + { key: 'border', labelKey: 'builder.brandBorderColor', value: palette.border }, + ]; + }); + + /** Real WCAG contrast math against the real configured colors - null when a value isn't a parseable hex, never fabricated. */ + readonly textContrast = computed(() => { + const palette = this.palette(); + return palette ? contrastRatio(palette.textPrimary, palette.backgroundPrimary) : null; + }); + + readonly primaryContrast = computed(() => { + const palette = this.palette(); + return palette ? contrastRatio(palette.primary, palette.backgroundPrimary) : null; + }); + + readonly textContrastPasses = computed(() => { + const ratio = this.textContrast(); + return ratio !== null && ratio >= 4.5; + }); + + readonly healthItems = computed(() => { + const branding = this.branding(); + if (!branding) { + return []; + } + return [ + { labelKey: 'builder.readinessLogoUploaded', ok: !!branding.logoUrl }, + { labelKey: 'builder.brandFaviconUploaded', ok: !!branding.faviconUrl }, + { labelKey: 'builder.brandColorsSelected', ok: !!this.palette() }, + { labelKey: 'builder.brandTypographyConfigured', ok: !!this.typography() }, + { labelKey: 'builder.brandSocialImageUploaded', ok: !!branding.socialImageUrl }, + { labelKey: 'builder.brandAccessibilityPassed', ok: this.textContrastPasses() }, + ]; + }); + + 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(() => { + const items = this.healthItems(); + const missing = items.find(i => !i.ok); + return missing?.labelKey ?? null; + }); + + readonly socialTitle = computed(() => this.bootstrap()?.seo.default.title || this.branding()?.brandName || null); + readonly socialDescription = computed(() => this.bootstrap()?.seo.default.description || 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/brand/contrast.util.ts b/src/app/features/project-editor/sections/brand/contrast.util.ts new file mode 100644 index 0000000..7c7f530 --- /dev/null +++ b/src/app/features/project-editor/sections/brand/contrast.util.ts @@ -0,0 +1,31 @@ +/** WCAG 2.x relative-luminance contrast ratio between two hex colors. Pure, no fabrication - real math on real values. */ +export function contrastRatio(hexA: string, hexB: string): number | null { + const lumA = relativeLuminance(hexA); + const lumB = relativeLuminance(hexB); + if (lumA === null || lumB === null) { + return null; + } + const lighter = Math.max(lumA, lumB); + const darker = Math.min(lumA, lumB); + return (lighter + 0.05) / (darker + 0.05); +} + +function relativeLuminance(hex: string): number | null { + const rgb = hexToRgb(hex); + if (!rgb) { + return null; + } + const [r, g, b] = rgb.map(channel => { + const c = channel / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function hexToRgb(hex: string): [number, number, number] | null { + const match = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex.trim()); + if (!match) { + return null; + } + return [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)]; +} diff --git a/src/app/features/project-editor/sections/branding-section.component.html b/src/app/features/project-editor/sections/branding-section.component.html index a94628a..90932aa 100644 --- a/src/app/features/project-editor/sections/branding-section.component.html +++ b/src/app/features/project-editor/sections/branding-section.component.html @@ -1,4 +1,5 @@ @if (bootstrap(); as bootstrap) { +
diff --git a/src/app/features/project-editor/sections/branding-section.component.ts b/src/app/features/project-editor/sections/branding-section.component.ts index 5498d25..24495f3 100644 --- a/src/app/features/project-editor/sections/branding-section.component.ts +++ b/src/app/features/project-editor/sections/branding-section.component.ts @@ -8,13 +8,14 @@ import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.com import { ButtonComponent } from '../../../shared/ui/button/button.component'; import { ImageFieldComponent } from '../../../shared/ui/image-field/image-field.component'; import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component'; +import { BrandOverviewComponent } from './brand/brand-overview.component'; type BrandingImageField = 'logoUrl' | 'logoCompactUrl' | 'faviconUrl' | 'socialImageUrl'; @Component({ selector: 'app-project-editor-branding-section', standalone: true, - imports: [FormsModule, TranslatePipe, InputComponent, FormFieldComponent, ButtonComponent, ImageFieldComponent, SectionCardComponent], + imports: [FormsModule, TranslatePipe, InputComponent, FormFieldComponent, ButtonComponent, ImageFieldComponent, SectionCardComponent, BrandOverviewComponent], templateUrl: './branding-section.component.html', styleUrls: ['./section.shared.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 7166c71..1106336 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -762,6 +762,24 @@ export const en: Translations = { 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.', + brandOverviewTitle: 'Brand identity', + brandOverviewSubtitle: 'The logo, colors, and fonts shoppers see on every page.', + brandOverviewComplete: 'Your brand is fully set up.', + brandLastModified: 'Last modified', + brandFaviconUploaded: 'Favicon uploaded', + brandColorsSelected: 'Brand colors selected', + brandTypographyConfigured: 'Typography configured', + brandSocialImageUploaded: 'Social share image uploaded', + brandAccessibilityPassed: 'Text contrast looks good', + brandColorsPreviewTitle: 'Marketplace colors', + brandTypographyPreviewTitle: 'Typography', + brandSocialPreviewTitle: 'How this looks when shared', + brandTypeSampleHeading: 'Sample heading', + brandTypeSampleBody: 'Sample body text the way shoppers will see it.', + brandContrastWarning: 'This text is hard to read against the background - consider higher-contrast colors. This will not block publishing.', + brandSocialEmpty: "No social share image yet - shared links will show a blank card until you add one.", + brandAccentColor: 'Accent', + brandBorderColor: 'Borders', }, dashboard: { title: 'Dashboard', diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 314665d..8f13804 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -762,6 +762,24 @@ export const hy: Translations = { groupPreviewPurpose: 'Ստուգեք ձեր մարկետփլեյսը գնորդի աչքերով նախքան հրապարակելը՝ տարբեր էկրանների վրա։', groupPreviewWhere: 'Սա նախադիտում է՝ գնորդները այն երբեք չեն տեսնում։', groupPreviewSkip: 'Կարող եք հրապարակել փոփոխությունները առանց ստուգելու։', + brandOverviewTitle: 'Բրենդի ինքնություն', + brandOverviewSubtitle: 'Լոգոն, գույները և տառատեսակները, որոնք գնորդները տեսնում են յուրաքանչյուր էջում։', + brandOverviewComplete: 'Ձեր բրենդը ամբողջությամբ կարգավորված է։', + brandLastModified: 'Վերջին փոփոխությունը', + brandFaviconUploaded: 'Կայքի պատկերակը վերբեռնված է', + brandColorsSelected: 'Բրենդի գույները ընտրված են', + brandTypographyConfigured: 'Տառատեսակները կարգավորված են', + brandSocialImageUploaded: 'Սոցիալական ցանցերի պատկերը վերբեռնված է', + brandAccessibilityPassed: 'Տեքստի հակադրությունը լավ է', + brandColorsPreviewTitle: 'Մարկետփլեյսի գույները', + brandTypographyPreviewTitle: 'Տառատեսակներ', + brandSocialPreviewTitle: 'Ինչպես է սա երևում հղումը կիսելիս', + brandTypeSampleHeading: 'Վերնագրի օրինակ', + brandTypeSampleBody: 'Հիմնական տեքստի օրինակ, ինչպես գնորդները կտեսնեն։', + brandContrastWarning: 'Տեքստը ֆոնի վրա դժվար է կարդալ․ դիտարկեք ավելի հակադիր գույներ։ Սա չի արգելափակում հրապարակումը։', + brandSocialEmpty: 'Սոցիալական պատկեր դեռ վերբեռնված չէ․ հղումը կիսելիս գնորդները կտեսնեն դատարկ քարտ։', + brandAccentColor: 'Ընդգծող', + brandBorderColor: 'Եզրագծեր', }, dashboard: { title: 'Կառավարման վահանակ', diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 15934be..8e30c06 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -762,6 +762,24 @@ export const ru: Translations = { groupPreviewPurpose: 'Проверьте маркетплейс глазами покупателя перед публикацией — на разных экранах.', groupPreviewWhere: 'Это предпросмотр — покупатели его не видят.', groupPreviewSkip: 'Вы можете опубликовать изменения непроверенными.', + brandOverviewTitle: 'Идентичность бренда', + brandOverviewSubtitle: 'Логотип, цвета и шрифты, которые видят покупатели на каждой странице.', + brandOverviewComplete: 'Бренд полностью настроен.', + brandLastModified: 'Последнее изменение', + brandFaviconUploaded: 'Иконка сайта загружена', + brandColorsSelected: 'Цвета бренда выбраны', + brandTypographyConfigured: 'Шрифты настроены', + brandSocialImageUploaded: 'Изображение для соцсетей загружено', + brandAccessibilityPassed: 'Контрастность текста в норме', + brandColorsPreviewTitle: 'Цвета маркетплейса', + brandTypographyPreviewTitle: 'Шрифты', + brandSocialPreviewTitle: 'Как это выглядит при публикации ссылки', + brandTypeSampleHeading: 'Пример заголовка', + brandTypeSampleBody: 'Пример основного текста, который видят покупатели.', + brandContrastWarning: 'Текст на фоне читается плохо — рассмотрите более контрастные цвета. Публикацию это не блокирует.', + brandSocialEmpty: 'Изображение для соцсетей ещё не загружено — при публикации ссылки покупатели увидят пустую карточку.', + brandAccentColor: 'Акцентный', + brandBorderColor: 'Границы', }, dashboard: { title: 'Панель управления', diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 71b57d1..213ad5d 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -760,6 +760,24 @@ export interface Translations { groupPreviewPurpose: string; groupPreviewWhere: string; groupPreviewSkip: string; + brandOverviewTitle: string; + brandOverviewSubtitle: string; + brandOverviewComplete: string; + brandLastModified: string; + brandFaviconUploaded: string; + brandColorsSelected: string; + brandTypographyConfigured: string; + brandSocialImageUploaded: string; + brandAccessibilityPassed: string; + brandColorsPreviewTitle: string; + brandTypographyPreviewTitle: string; + brandSocialPreviewTitle: string; + brandTypeSampleHeading: string; + brandTypeSampleBody: string; + brandContrastWarning: string; + brandSocialEmpty: string; + brandAccentColor: string; + brandBorderColor: string; }; dashboard: { title: string;