feat(front-end): rich text
This commit is contained in:
@@ -635,17 +635,91 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="ui-form-field form-field--wide">
|
<div class="ui-form-field form-field--wide">
|
||||||
<span class="ui-form-caption">
|
<span class="ui-form-caption">
|
||||||
Descrizione {{ languageLabels[activeContentLanguage] }}
|
Descrizione {{ languageLabels[activeContentLanguage] }}
|
||||||
</span>
|
</span>
|
||||||
<textarea
|
<div class="rich-text-field">
|
||||||
class="ui-form-control textarea-control textarea-control--large"
|
<div class="rich-text-toolbar" role="toolbar">
|
||||||
[(ngModel)]="productForm.descriptions[activeContentLanguage]"
|
<button
|
||||||
[name]="'product-description-' + activeContentLanguage"
|
type="button"
|
||||||
rows="6"
|
class="rich-text-toolbar__button"
|
||||||
></textarea>
|
(mousedown)="preventRichTextToolbarMouseDown($event)"
|
||||||
</label>
|
(click)="formatDescription('bold')"
|
||||||
|
title="Grassetto"
|
||||||
|
aria-label="Grassetto"
|
||||||
|
>
|
||||||
|
<strong>B</strong>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rich-text-toolbar__button"
|
||||||
|
(mousedown)="preventRichTextToolbarMouseDown($event)"
|
||||||
|
(click)="formatDescription('italic')"
|
||||||
|
title="Corsivo"
|
||||||
|
aria-label="Corsivo"
|
||||||
|
>
|
||||||
|
<em>I</em>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rich-text-toolbar__button"
|
||||||
|
(mousedown)="preventRichTextToolbarMouseDown($event)"
|
||||||
|
(click)="formatDescription('underline')"
|
||||||
|
title="Sottolineato"
|
||||||
|
aria-label="Sottolineato"
|
||||||
|
>
|
||||||
|
<u>U</u>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rich-text-toolbar__button"
|
||||||
|
(mousedown)="preventRichTextToolbarMouseDown($event)"
|
||||||
|
(click)="formatDescriptionList('unordered')"
|
||||||
|
title="Lista puntata"
|
||||||
|
aria-label="Lista puntata"
|
||||||
|
>
|
||||||
|
• Lista
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rich-text-toolbar__button"
|
||||||
|
(mousedown)="preventRichTextToolbarMouseDown($event)"
|
||||||
|
(click)="formatDescriptionList('ordered')"
|
||||||
|
title="Lista numerata"
|
||||||
|
aria-label="Lista numerata"
|
||||||
|
>
|
||||||
|
1. Lista
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rich-text-toolbar__button"
|
||||||
|
(mousedown)="preventRichTextToolbarMouseDown($event)"
|
||||||
|
(click)="clearDescriptionFormatting()"
|
||||||
|
title="Rimuovi formattazione"
|
||||||
|
aria-label="Rimuovi formattazione"
|
||||||
|
>
|
||||||
|
Tx
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
#descriptionEditorRef
|
||||||
|
class="ui-form-control textarea-control textarea-control--large rich-text-editor"
|
||||||
|
contenteditable="true"
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
[attr.aria-label]="
|
||||||
|
'Descrizione ' + languageLabels[activeContentLanguage]
|
||||||
|
"
|
||||||
|
[innerHTML]="productForm.descriptions[activeContentLanguage]"
|
||||||
|
(input)="onDescriptionEditorInput($event)"
|
||||||
|
(blur)="onDescriptionEditorBlur($event)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="ui-form-caption">
|
||||||
|
Supporta grassetto, corsivo, liste puntate e numerate.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,63 @@
|
|||||||
min-height: 136px;
|
min-height: 136px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rich-text-field {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-toolbar__button {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--color-text);
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0.28rem 0.56rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-toolbar__button:hover {
|
||||||
|
border-color: #cbb88a;
|
||||||
|
background: #fffdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-editor {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.62rem 0.72rem;
|
||||||
|
line-height: 1.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-editor:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-editor:empty::before {
|
||||||
|
content: "Scrivi la descrizione del prodotto...";
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-editor p,
|
||||||
|
.rich-text-editor div {
|
||||||
|
margin: 0 0 0.62rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-editor ul,
|
||||||
|
.rich-text-editor ol {
|
||||||
|
margin: 0 0 0.62rem 1.25rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-editor li + li {
|
||||||
|
margin-top: 0.22rem;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-row {
|
.toggle-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -115,6 +115,20 @@ const MAX_MODEL_FILE_SIZE_BYTES = 100 * 1024 * 1024;
|
|||||||
const SHOP_LIST_PANEL_WIDTH_STORAGE_KEY = 'admin-shop-list-panel-width';
|
const SHOP_LIST_PANEL_WIDTH_STORAGE_KEY = 'admin-shop-list-panel-width';
|
||||||
const MIN_LIST_PANEL_WIDTH_PERCENT = 32;
|
const MIN_LIST_PANEL_WIDTH_PERCENT = 32;
|
||||||
const MAX_LIST_PANEL_WIDTH_PERCENT = 68;
|
const MAX_LIST_PANEL_WIDTH_PERCENT = 68;
|
||||||
|
const RICH_TEXT_ALLOWED_TAGS = new Set([
|
||||||
|
'P',
|
||||||
|
'DIV',
|
||||||
|
'BR',
|
||||||
|
'STRONG',
|
||||||
|
'B',
|
||||||
|
'EM',
|
||||||
|
'I',
|
||||||
|
'U',
|
||||||
|
'UL',
|
||||||
|
'OL',
|
||||||
|
'LI',
|
||||||
|
'A',
|
||||||
|
]);
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-admin-shop',
|
selector: 'app-admin-shop',
|
||||||
@@ -128,6 +142,8 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
private readonly adminOperationsService = inject(AdminOperationsService);
|
private readonly adminOperationsService = inject(AdminOperationsService);
|
||||||
@ViewChild('workspaceRef')
|
@ViewChild('workspaceRef')
|
||||||
private readonly workspaceRef?: ElementRef<HTMLDivElement>;
|
private readonly workspaceRef?: ElementRef<HTMLDivElement>;
|
||||||
|
@ViewChild('descriptionEditorRef')
|
||||||
|
private readonly descriptionEditorRef?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
readonly shopLanguages = SHOP_LANGUAGES;
|
readonly shopLanguages = SHOP_LANGUAGES;
|
||||||
readonly mediaLanguages = MEDIA_LANGUAGES;
|
readonly mediaLanguages = MEDIA_LANGUAGES;
|
||||||
@@ -525,6 +541,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setActiveContentLanguage(language: ShopLanguage): void {
|
setActiveContentLanguage(language: ShopLanguage): void {
|
||||||
|
this.syncDescriptionFromEditor(
|
||||||
|
this.descriptionEditorRef?.nativeElement ?? null,
|
||||||
|
true,
|
||||||
|
);
|
||||||
this.activeContentLanguage = language;
|
this.activeContentLanguage = language;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,7 +556,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
return (
|
return (
|
||||||
!!this.productForm.names[language].trim() ||
|
!!this.productForm.names[language].trim() ||
|
||||||
!!this.productForm.excerpts[language].trim() ||
|
!!this.productForm.excerpts[language].trim() ||
|
||||||
!!this.productForm.descriptions[language].trim()
|
this.hasMeaningfulRichText(this.productForm.descriptions[language])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +588,34 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
preventRichTextToolbarMouseDown(event: MouseEvent): void {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDescriptionEditorInput(event: Event): void {
|
||||||
|
const editor = event.target as HTMLDivElement | null;
|
||||||
|
this.syncDescriptionFromEditor(editor, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
onDescriptionEditorBlur(event: Event): void {
|
||||||
|
const editor = event.target as HTMLDivElement | null;
|
||||||
|
this.syncDescriptionFromEditor(editor, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDescription(command: 'bold' | 'italic' | 'underline'): void {
|
||||||
|
this.applyDescriptionExecCommand(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDescriptionList(type: 'unordered' | 'ordered'): void {
|
||||||
|
this.applyDescriptionExecCommand(
|
||||||
|
type === 'unordered' ? 'insertUnorderedList' : 'insertOrderedList',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearDescriptionFormatting(): void {
|
||||||
|
this.applyDescriptionExecCommand('removeFormat');
|
||||||
|
}
|
||||||
|
|
||||||
addMaterial(): void {
|
addMaterial(): void {
|
||||||
const nextMaterialCode = this.nextAvailableMaterialCode();
|
const nextMaterialCode = this.nextAvailableMaterialCode();
|
||||||
if (!nextMaterialCode) {
|
if (!nextMaterialCode) {
|
||||||
@@ -1253,10 +1301,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
fr: product.excerptFr ?? '',
|
fr: product.excerptFr ?? '',
|
||||||
},
|
},
|
||||||
descriptions: {
|
descriptions: {
|
||||||
it: product.descriptionIt ?? '',
|
it: this.normalizeDescriptionForEditor(product.descriptionIt),
|
||||||
en: product.descriptionEn ?? '',
|
en: this.normalizeDescriptionForEditor(product.descriptionEn),
|
||||||
de: product.descriptionDe ?? '',
|
de: this.normalizeDescriptionForEditor(product.descriptionDe),
|
||||||
fr: product.descriptionFr ?? '',
|
fr: this.normalizeDescriptionForEditor(product.descriptionFr),
|
||||||
},
|
},
|
||||||
seoTitles: {
|
seoTitles: {
|
||||||
it: product.seoTitleIt ?? '',
|
it: product.seoTitleIt ?? '',
|
||||||
@@ -1394,11 +1442,19 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
excerptEn: this.optionalValue(this.productForm.excerpts['en']),
|
excerptEn: this.optionalValue(this.productForm.excerpts['en']),
|
||||||
excerptDe: this.optionalValue(this.productForm.excerpts['de']),
|
excerptDe: this.optionalValue(this.productForm.excerpts['de']),
|
||||||
excerptFr: this.optionalValue(this.productForm.excerpts['fr']),
|
excerptFr: this.optionalValue(this.productForm.excerpts['fr']),
|
||||||
description: this.optionalValue(this.productForm.descriptions['it']),
|
description: this.optionalRichTextValue(this.productForm.descriptions['it']),
|
||||||
descriptionIt: this.optionalValue(this.productForm.descriptions['it']),
|
descriptionIt: this.optionalRichTextValue(
|
||||||
descriptionEn: this.optionalValue(this.productForm.descriptions['en']),
|
this.productForm.descriptions['it'],
|
||||||
descriptionDe: this.optionalValue(this.productForm.descriptions['de']),
|
),
|
||||||
descriptionFr: this.optionalValue(this.productForm.descriptions['fr']),
|
descriptionEn: this.optionalRichTextValue(
|
||||||
|
this.productForm.descriptions['en'],
|
||||||
|
),
|
||||||
|
descriptionDe: this.optionalRichTextValue(
|
||||||
|
this.productForm.descriptions['de'],
|
||||||
|
),
|
||||||
|
descriptionFr: this.optionalRichTextValue(
|
||||||
|
this.productForm.descriptions['fr'],
|
||||||
|
),
|
||||||
seoTitle: this.optionalValue(this.productForm.seoTitles['it']),
|
seoTitle: this.optionalValue(this.productForm.seoTitles['it']),
|
||||||
seoTitleIt: this.optionalValue(this.productForm.seoTitles['it']),
|
seoTitleIt: this.optionalValue(this.productForm.seoTitles['it']),
|
||||||
seoTitleEn: this.optionalValue(this.productForm.seoTitles['en']),
|
seoTitleEn: this.optionalValue(this.productForm.seoTitles['en']),
|
||||||
@@ -1760,6 +1816,189 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
return normalized ? normalized : undefined;
|
return normalized ? normalized : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private optionalRichTextValue(value: string): string | undefined {
|
||||||
|
const normalized = this.normalizeRichTextStorageValue(value);
|
||||||
|
return normalized ? normalized : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncDescriptionFromEditor(
|
||||||
|
editor: HTMLDivElement | null,
|
||||||
|
sanitize: boolean,
|
||||||
|
): void {
|
||||||
|
if (!editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentLanguage = this.activeContentLanguage;
|
||||||
|
if (sanitize) {
|
||||||
|
const normalized = this.normalizeRichTextStorageValue(editor.innerHTML);
|
||||||
|
const safeHtml = normalized ?? '';
|
||||||
|
this.productForm.descriptions[currentLanguage] = safeHtml;
|
||||||
|
if (editor.innerHTML !== safeHtml) {
|
||||||
|
editor.innerHTML = safeHtml;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.productForm.descriptions[currentLanguage] = editor.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyDescriptionExecCommand(command: string): void {
|
||||||
|
const editor = this.descriptionEditorRef?.nativeElement ?? null;
|
||||||
|
if (!editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.focus();
|
||||||
|
document.execCommand(command, false);
|
||||||
|
this.syncDescriptionFromEditor(editor, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeDescriptionForEditor(value: string | null | undefined): string {
|
||||||
|
return this.normalizeRichTextStorageValue(value ?? '') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRichTextStorageValue(value: string): string | null {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sanitized = this.containsHtmlMarkup(normalized)
|
||||||
|
? this.sanitizeRichTextHtml(normalized)
|
||||||
|
: this.plainTextToRichTextHtml(normalized);
|
||||||
|
const compact = sanitized.trim();
|
||||||
|
if (!compact || !this.hasMeaningfulRichText(compact)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
|
||||||
|
private containsHtmlMarkup(value: string): boolean {
|
||||||
|
return /<\/?[a-z][\s\S]*>/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private plainTextToRichTextHtml(value: string): string {
|
||||||
|
const normalized = value.replace(/\r\n?/g, '\n').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map(
|
||||||
|
(paragraph) =>
|
||||||
|
`<p>${this.escapeHtml(paragraph).replace(/\n/g, '<br>')}</p>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeRichTextHtml(value: string): string {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const sourceDocument = parser.parseFromString(
|
||||||
|
`<body>${value}</body>`,
|
||||||
|
'text/html',
|
||||||
|
);
|
||||||
|
const outputDocument = parser.parseFromString('<body></body>', 'text/html');
|
||||||
|
const outputBody = outputDocument.body;
|
||||||
|
|
||||||
|
for (const child of Array.from(sourceDocument.body.childNodes)) {
|
||||||
|
const sanitizedNode = this.sanitizeRichTextNode(child, outputDocument);
|
||||||
|
if (sanitizedNode) {
|
||||||
|
outputBody.appendChild(sanitizedNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputBody.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeRichTextNode(
|
||||||
|
node: Node,
|
||||||
|
outputDocument: Document,
|
||||||
|
): Node | DocumentFragment | null {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
return outputDocument.createTextNode(node.textContent ?? '');
|
||||||
|
}
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceElement = node as HTMLElement;
|
||||||
|
const tagName = sourceElement.tagName.toUpperCase();
|
||||||
|
const childNodes = Array.from(sourceElement.childNodes)
|
||||||
|
.map((child) => this.sanitizeRichTextNode(child, outputDocument))
|
||||||
|
.filter((child): child is Node | DocumentFragment => child !== null);
|
||||||
|
|
||||||
|
if (!RICH_TEXT_ALLOWED_TAGS.has(tagName)) {
|
||||||
|
const fragment = outputDocument.createDocumentFragment();
|
||||||
|
for (const child of childNodes) {
|
||||||
|
fragment.appendChild(child);
|
||||||
|
}
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = outputDocument.createElement(tagName.toLowerCase());
|
||||||
|
if (tagName === 'A') {
|
||||||
|
const href = this.sanitizeRichTextHref(sourceElement.getAttribute('href'));
|
||||||
|
if (href) {
|
||||||
|
element.setAttribute('href', href);
|
||||||
|
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||||
|
element.setAttribute('target', '_blank');
|
||||||
|
element.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const child of childNodes) {
|
||||||
|
element.appendChild(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagName === 'A' && !element.textContent?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ((tagName === 'UL' || tagName === 'OL') && !element.querySelector('li')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (tagName === 'LI' && !element.textContent?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeRichTextHref(rawHref: string | null): string | null {
|
||||||
|
const href = rawHref?.trim();
|
||||||
|
if (!href) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lowerHref = href.toLowerCase();
|
||||||
|
if (lowerHref.startsWith('/') || lowerHref.startsWith('#')) {
|
||||||
|
return href;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
lowerHref.startsWith('http://') ||
|
||||||
|
lowerHref.startsWith('https://') ||
|
||||||
|
lowerHref.startsWith('mailto:') ||
|
||||||
|
lowerHref.startsWith('tel:')
|
||||||
|
) {
|
||||||
|
return href;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasMeaningfulRichText(value: string): boolean {
|
||||||
|
return this.extractTextFromHtml(value).replace(/\u00a0/g, ' ').trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTextFromHtml(value: string): string {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = value;
|
||||||
|
return container.textContent ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
seoDescriptionLength(language: ShopLanguage): number {
|
seoDescriptionLength(language: ShopLanguage): number {
|
||||||
return this.productForm.seoDescriptions[language].trim().length;
|
return this.productForm.seoDescriptions[language].trim().length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
<p class="excerpt">
|
<p class="excerpt">
|
||||||
{{
|
{{
|
||||||
p.excerpt ||
|
p.excerpt ||
|
||||||
p.description ||
|
descriptionPlainText(p.description) ||
|
||||||
("SHOP.EXCERPT_FALLBACK" | translate)
|
("SHOP.EXCERPT_FALLBACK" | translate)
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
@@ -304,10 +304,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</app-card>
|
</app-card>
|
||||||
|
|
||||||
@if (p.description) {
|
@if (descriptionPlainText(p.description)) {
|
||||||
<div class="description-block">
|
<div class="description-block">
|
||||||
<h2>{{ "SHOP.DESCRIPTION_TITLE" | translate }}</h2>
|
<h2>{{ "SHOP.DESCRIPTION_TITLE" | translate }}</h2>
|
||||||
<p>{{ p.description }}</p>
|
<div
|
||||||
|
class="description-block__content"
|
||||||
|
[innerHTML]="descriptionRichHtml(p.description)"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -209,8 +209,7 @@ h1 {
|
|||||||
line-height: 1.06;
|
line-height: 1.06;
|
||||||
}
|
}
|
||||||
|
|
||||||
.excerpt,
|
.excerpt {
|
||||||
.description-block p {
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -380,11 +379,32 @@ h1 {
|
|||||||
font-size: 1.45rem;
|
font-size: 1.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description-block p {
|
.description-block__content {
|
||||||
|
color: var(--color-text-muted);
|
||||||
font-size: 1.06rem;
|
font-size: 1.06rem;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.description-block__content p,
|
||||||
|
.description-block__content div {
|
||||||
|
margin: 0 0 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-block__content p:last-child,
|
||||||
|
.description-block__content div:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-block__content ul,
|
||||||
|
.description-block__content ol {
|
||||||
|
margin: 0 0 0.7rem 1.3rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-block__content li + li {
|
||||||
|
margin-top: 0.22rem;
|
||||||
|
}
|
||||||
|
|
||||||
:host ::ng-deep app-card.purchase-shell .card-body {
|
:host ::ng-deep app-card.purchase-shell .card-body {
|
||||||
padding: 0.95rem 1rem;
|
padding: 0.95rem 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -469,6 +469,7 @@ export class ProductDetailComponent {
|
|||||||
const description =
|
const description =
|
||||||
product.seoDescription ||
|
product.seoDescription ||
|
||||||
product.excerpt ||
|
product.excerpt ||
|
||||||
|
this.extractTextFromRichContent(product.description) ||
|
||||||
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
||||||
const robots =
|
const robots =
|
||||||
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
||||||
@@ -500,6 +501,28 @@ export class ProductDetailComponent {
|
|||||||
return String(variant?.variantLabel || '').trim() || 'Standard';
|
return String(variant?.variantLabel || '').trim() || 'Standard';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
descriptionPlainText(description: string | null | undefined): string {
|
||||||
|
return this.extractTextFromRichContent(description) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptionRichHtml(description: string | null | undefined): string {
|
||||||
|
const normalized = String(description ?? '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (this.containsHtmlMarkup(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
.replace(/\r\n?/g, '\n')
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map(
|
||||||
|
(paragraph) =>
|
||||||
|
`<p>${this.escapeHtml(paragraph).replace(/\n/g, '<br>')}</p>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
private materialKeyForVariant(
|
private materialKeyForVariant(
|
||||||
variant: ShopProductVariantOption | null,
|
variant: ShopProductVariantOption | null,
|
||||||
): string | null {
|
): string | null {
|
||||||
@@ -597,6 +620,35 @@ export class ProductDetailComponent {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractTextFromRichContent(
|
||||||
|
value: string | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!this.containsHtmlMarkup(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = normalized;
|
||||||
|
const text = (container.textContent ?? '').replace(/\u00a0/g, ' ').trim();
|
||||||
|
return text || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private containsHtmlMarkup(value: string): boolean {
|
||||||
|
return /<\/?[a-z][\s\S]*>/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
private syncPublicUrl(product: ShopProductDetail): void {
|
private syncPublicUrl(product: ShopProductDetail): void {
|
||||||
const currentProductSlug = this.productSlug()?.trim().toLowerCase() ?? '';
|
const currentProductSlug = this.productSlug()?.trim().toLowerCase() ?? '';
|
||||||
const targetProductSlug = this.shopRouteService.productPathSegment(product);
|
const targetProductSlug = this.shopRouteService.productPathSegment(product);
|
||||||
|
|||||||
Reference in New Issue
Block a user