feat(project-editor): footer validation rules (contact email, social link URLs, payment icons)
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Real gaps, not fabricated: isValidEmail existed in primitives.ts but was never called anywhere; social-link URL check only lived as a per-row template hint (never blocked publish or set the nav badge); payment icons with only src or only alt set were silently accepted. - invalid-contact-email: company.contacts.email must be a valid email (error) - invalid-social-link-url: footer.socialLinks entries need a valid http(s) URL (warning) - incomplete-payment-icon: a payment icon needs both src and alt, or neither (warning) Wired into footer-section via the existing fieldError() pattern. Header has no equivalent gap today (every header field is a bool/enum, always valid by construction) so nothing was added there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
<app-form-field [label]="'builder.companyName' | translate" [hint]="'builder.companyNameDesc' | translate"><app-input [ngModel]="bootstrap.company.companyName" (ngModelChange)="updateCompanyName($event)" /></app-form-field>
|
<app-form-field [label]="'builder.companyName' | translate" [hint]="'builder.companyNameDesc' | translate"><app-input [ngModel]="bootstrap.company.companyName" (ngModelChange)="updateCompanyName($event)" /></app-form-field>
|
||||||
<app-form-field [label]="'builder.address' | translate" [hint]="'builder.addressDesc' | translate"><app-input [ngModel]="bootstrap.company.address.street || ''" (ngModelChange)="updateAddress($event)" /></app-form-field>
|
<app-form-field [label]="'builder.address' | translate" [hint]="'builder.addressDesc' | translate"><app-input [ngModel]="bootstrap.company.address.street || ''" (ngModelChange)="updateAddress($event)" /></app-form-field>
|
||||||
<app-form-field [label]="'builder.phone' | translate" [hint]="'builder.phoneDesc' | translate"><app-input [ngModel]="bootstrap.company.contacts.phone || ''" (ngModelChange)="updatePhone($event)" /></app-form-field>
|
<app-form-field [label]="'builder.phone' | translate" [hint]="'builder.phoneDesc' | translate"><app-input [ngModel]="bootstrap.company.contacts.phone || ''" (ngModelChange)="updatePhone($event)" /></app-form-field>
|
||||||
<app-form-field [label]="'builder.email' | translate" [hint]="'builder.emailDesc' | translate"><app-input [ngModel]="bootstrap.company.contacts.email" (ngModelChange)="updateEmail($event)" /></app-form-field>
|
<app-form-field [label]="'builder.email' | translate" [hint]="'builder.emailDesc' | translate" [error]="fieldError('company.contacts.email')"><app-input [ngModel]="bootstrap.company.contacts.email" (ngModelChange)="updateEmail($event)" /></app-form-field>
|
||||||
<app-form-field class="full" [label]="'builder.copyright' | translate" [hint]="'builder.copyrightDesc' | translate"><app-input [ngModel]="copyrightValue()" (ngModelChange)="updateCopyright($event)" /></app-form-field>
|
<app-form-field class="full" [label]="'builder.copyright' | translate" [hint]="'builder.copyrightDesc' | translate"><app-input [ngModel]="copyrightValue()" (ngModelChange)="updateCopyright($event)" /></app-form-field>
|
||||||
<app-form-field class="full" [label]="'builder.footerLogo' | translate" [hint]="'builder.footerLogoDesc' | translate">
|
<app-form-field class="full" [label]="'builder.footerLogo' | translate" [hint]="'builder.footerLogoDesc' | translate">
|
||||||
<app-image-field [value]="bootstrap.footer?.logoUrl || ''" (valueChange)="updateLogo($event)" />
|
<app-image-field [value]="bootstrap.footer?.logoUrl || ''" (valueChange)="updateLogo($event)" />
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
|
|
||||||
<div class="full">
|
<div class="full">
|
||||||
<label><span>{{ 'builder.paymentIcons' | translate }}</span><small class="field-desc">{{ 'builder.paymentIconsDesc' | translate }}</small></label>
|
<label><span>{{ 'builder.paymentIcons' | translate }}</span><small class="field-desc">{{ 'builder.paymentIconsDesc' | translate }}</small></label>
|
||||||
|
@if (fieldError('footer.paymentIcons'); as msg) {
|
||||||
|
<p class="editor-error">{{ msg }}</p>
|
||||||
|
}
|
||||||
<app-key-value-editor
|
<app-key-value-editor
|
||||||
[rows]="paymentIconRows()"
|
[rows]="paymentIconRows()"
|
||||||
[addLabel]="'builder.addPaymentIcon' | translate"
|
[addLabel]="'builder.addPaymentIcon' | translate"
|
||||||
@@ -30,6 +33,9 @@
|
|||||||
|
|
||||||
<div class="full">
|
<div class="full">
|
||||||
<label><span>{{ 'builder.socialLinks' | translate }}</span><small class="field-desc">{{ 'builder.socialLinksDesc' | translate }}</small></label>
|
<label><span>{{ 'builder.socialLinks' | translate }}</span><small class="field-desc">{{ 'builder.socialLinksDesc' | translate }}</small></label>
|
||||||
|
@if (fieldError('footer.socialLinks'); as msg) {
|
||||||
|
<p class="editor-error">{{ msg }}</p>
|
||||||
|
}
|
||||||
<app-key-value-editor
|
<app-key-value-editor
|
||||||
[rows]="socialLinkRows()"
|
[rows]="socialLinkRows()"
|
||||||
[addLabel]="'builder.addSocialLink' | translate"
|
[addLabel]="'builder.addSocialLink' | translate"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/c
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
import { ProjectEditorFacade } from '../facade/project-editor.facade';
|
||||||
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
import { TranslatePipe } from '../../../i18n/translate.pipe';
|
||||||
|
import { TranslateService } from '../../../i18n/translate.service';
|
||||||
import { InputComponent } from '../../../shared/ui/input/input.component';
|
import { InputComponent } from '../../../shared/ui/input/input.component';
|
||||||
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
|
import { FormFieldComponent } from '../../../shared/ui/form-field/form-field.component';
|
||||||
import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component';
|
import { SectionCardComponent } from '../../../shared/ui/section-card/section-card.component';
|
||||||
@@ -28,7 +29,12 @@ import { isValidHttpUrl } from '../schema/validators/primitives';
|
|||||||
})
|
})
|
||||||
export class ProjectEditorFooterSectionComponent {
|
export class ProjectEditorFooterSectionComponent {
|
||||||
private readonly facade = inject(ProjectEditorFacade);
|
private readonly facade = inject(ProjectEditorFacade);
|
||||||
|
private readonly translate = inject(TranslateService);
|
||||||
readonly bootstrap = this.facade.bootstrap;
|
readonly bootstrap = this.facade.bootstrap;
|
||||||
|
readonly fieldError = (key: string): string | null => {
|
||||||
|
const messageKey = this.facade.fieldError(key);
|
||||||
|
return messageKey ? this.translate.t(messageKey) : null;
|
||||||
|
};
|
||||||
|
|
||||||
readonly staticPagesValue = computed(() => (this.bootstrap()?.footer?.staticPageKeys ?? this.bootstrap()?.footer?.legalPageKeys ?? []).join(', '));
|
readonly staticPagesValue = computed(() => (this.bootstrap()?.footer?.staticPageKeys ?? this.bootstrap()?.footer?.legalPageKeys ?? []).join(', '));
|
||||||
readonly copyrightValue = computed(() => {
|
readonly copyrightValue = computed(() => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BootstrapConfig } from '../../../shared/models/config';
|
|||||||
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
import { ProjectEditorSectionId } from '../models/project-editor.model';
|
||||||
import {
|
import {
|
||||||
extractStyleBlocks,
|
extractStyleBlocks,
|
||||||
|
isValidEmail,
|
||||||
isValidHexColor,
|
isValidHexColor,
|
||||||
isValidHttpUrl,
|
isValidHttpUrl,
|
||||||
normalizeRoute,
|
normalizeRoute,
|
||||||
@@ -56,6 +57,9 @@ export class ProjectValidator {
|
|||||||
...this.cssIssues(bootstrap),
|
...this.cssIssues(bootstrap),
|
||||||
...this.translationIssues(bootstrap),
|
...this.translationIssues(bootstrap),
|
||||||
...this.layoutIssues(bootstrap),
|
...this.layoutIssues(bootstrap),
|
||||||
|
...this.footerContactIssues(bootstrap),
|
||||||
|
...this.footerSocialLinkIssues(bootstrap),
|
||||||
|
...this.footerPaymentIconIssues(bootstrap),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,4 +226,26 @@ export class ProjectValidator {
|
|||||||
? [error('invalid-layouts', 'builder.validationInvalidLayouts', 'theme', 'layout.type')]
|
? [error('invalid-layouts', 'builder.validationInvalidLayouts', 'theme', 'layout.type')]
|
||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private footerContactIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||||
|
const email = bootstrap.company?.contacts?.email;
|
||||||
|
return !email || isValidEmail(email)
|
||||||
|
? []
|
||||||
|
: [error('invalid-contact-email', 'builder.validationInvalidContactEmail', 'footer', 'company.contacts.email')];
|
||||||
|
}
|
||||||
|
|
||||||
|
private footerSocialLinkIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||||
|
const hasInvalidUrl = (bootstrap.footer?.socialLinks ?? []).some(link => !!link.url && !isValidHttpUrl(link.url));
|
||||||
|
return hasInvalidUrl
|
||||||
|
? [warning('invalid-social-link-url', 'builder.validationInvalidSocialLinkUrl', 'footer', 'footer.socialLinks')]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A payment icon row with only one of src/alt set is a broken image reference or missing accessibility text. */
|
||||||
|
private footerPaymentIconIssues(bootstrap: BootstrapConfig): ProjectValidationIssue[] {
|
||||||
|
const hasIncompleteRow = (bootstrap.footer?.paymentIcons ?? []).some(icon => !!icon.src !== !!icon.alt);
|
||||||
|
return hasIncompleteRow
|
||||||
|
? [warning('incomplete-payment-icon', 'builder.validationIncompletePaymentIcon', 'footer', 'footer.paymentIcons')]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -550,6 +550,9 @@ export const en: Translations = {
|
|||||||
validationInvalidCss: 'A static page contains invalid CSS.',
|
validationInvalidCss: 'A static page contains invalid CSS.',
|
||||||
validationDuplicateRoutes: 'Two or more pages share the same route.',
|
validationDuplicateRoutes: 'Two or more pages share the same route.',
|
||||||
validationInvalidWidgetConfig: 'A widget is missing a required field (id, type, version, or props).',
|
validationInvalidWidgetConfig: 'A widget is missing a required field (id, type, version, or props).',
|
||||||
|
validationInvalidContactEmail: 'The company contact email is not a valid email address.',
|
||||||
|
validationInvalidSocialLinkUrl: 'One or more footer social links have an invalid URL.',
|
||||||
|
validationIncompletePaymentIcon: 'A footer payment icon is missing its image or alt text.',
|
||||||
undo: 'Undo',
|
undo: 'Undo',
|
||||||
redo: 'Redo',
|
redo: 'Redo',
|
||||||
statusDraft: 'Draft',
|
statusDraft: 'Draft',
|
||||||
|
|||||||
@@ -550,6 +550,9 @@ export const hy: Translations = {
|
|||||||
validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։',
|
validationInvalidCss: 'Ստատիկ էջը պարունակում է անվավեր CSS։',
|
||||||
validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։',
|
validationDuplicateRoutes: 'Երկու կամ ավելի էջ ունեն նույն երթուղին։',
|
||||||
validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։',
|
validationInvalidWidgetConfig: 'Վիջեթին բացակայում է պարտադիր դաշտ (id, type, version կամ props)։',
|
||||||
|
validationInvalidContactEmail: 'Ընկերության կոնտակտային էլ. փոստը վավեր չէ։',
|
||||||
|
validationInvalidSocialLinkUrl: 'Ֆուտերի սոցիալական հղումներից մեկը կամ մի քանիսը վավեր URL չունեն։',
|
||||||
|
validationIncompletePaymentIcon: 'Ֆուտերի վճարային պատկերակին բացակայում է պատկերը կամ alt տեքստը։',
|
||||||
undo: 'Հետարկել',
|
undo: 'Հետարկել',
|
||||||
redo: 'Կրկնել',
|
redo: 'Կրկնել',
|
||||||
statusDraft: 'Սևագիր',
|
statusDraft: 'Սևագիր',
|
||||||
|
|||||||
@@ -550,6 +550,9 @@ export const ru: Translations = {
|
|||||||
validationInvalidCss: 'Статическая страница содержит недопустимый CSS.',
|
validationInvalidCss: 'Статическая страница содержит недопустимый CSS.',
|
||||||
validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.',
|
validationDuplicateRoutes: 'Две или более страницы используют один и тот же маршрут.',
|
||||||
validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).',
|
validationInvalidWidgetConfig: 'У виджета отсутствует обязательное поле (id, type, version или props).',
|
||||||
|
validationInvalidContactEmail: 'Контактный email компании указан некорректно.',
|
||||||
|
validationInvalidSocialLinkUrl: 'У одной или нескольких социальных ссылок в футере некорректный URL.',
|
||||||
|
validationIncompletePaymentIcon: 'У иконки оплаты в футере отсутствует изображение или альтернативный текст.',
|
||||||
undo: 'Отменить',
|
undo: 'Отменить',
|
||||||
redo: 'Повторить',
|
redo: 'Повторить',
|
||||||
statusDraft: 'Черновик',
|
statusDraft: 'Черновик',
|
||||||
|
|||||||
@@ -548,6 +548,9 @@ export interface Translations {
|
|||||||
validationInvalidCss: string;
|
validationInvalidCss: string;
|
||||||
validationDuplicateRoutes: string;
|
validationDuplicateRoutes: string;
|
||||||
validationInvalidWidgetConfig: string;
|
validationInvalidWidgetConfig: string;
|
||||||
|
validationInvalidContactEmail: string;
|
||||||
|
validationInvalidSocialLinkUrl: string;
|
||||||
|
validationIncompletePaymentIcon: string;
|
||||||
undo: string;
|
undo: string;
|
||||||
redo: string;
|
redo: string;
|
||||||
statusDraft: string;
|
statusDraft: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user