feat(front-end): rich text
This commit is contained in:
@@ -635,17 +635,91 @@
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="ui-form-field form-field--wide">
|
||||
<div class="ui-form-field form-field--wide">
|
||||
<span class="ui-form-caption">
|
||||
Descrizione {{ languageLabels[activeContentLanguage] }}
|
||||
</span>
|
||||
<textarea
|
||||
class="ui-form-control textarea-control textarea-control--large"
|
||||
[(ngModel)]="productForm.descriptions[activeContentLanguage]"
|
||||
[name]="'product-description-' + activeContentLanguage"
|
||||
rows="6"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="rich-text-field">
|
||||
<div class="rich-text-toolbar" role="toolbar">
|
||||
<button
|
||||
type="button"
|
||||
class="rich-text-toolbar__button"
|
||||
(mousedown)="preventRichTextToolbarMouseDown($event)"
|
||||
(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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -252,6 +252,63 @@
|
||||
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 {
|
||||
display: flex;
|
||||
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 MIN_LIST_PANEL_WIDTH_PERCENT = 32;
|
||||
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({
|
||||
selector: 'app-admin-shop',
|
||||
@@ -128,6 +142,8 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
private readonly adminOperationsService = inject(AdminOperationsService);
|
||||
@ViewChild('workspaceRef')
|
||||
private readonly workspaceRef?: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('descriptionEditorRef')
|
||||
private readonly descriptionEditorRef?: ElementRef<HTMLDivElement>;
|
||||
|
||||
readonly shopLanguages = SHOP_LANGUAGES;
|
||||
readonly mediaLanguages = MEDIA_LANGUAGES;
|
||||
@@ -525,6 +541,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
setActiveContentLanguage(language: ShopLanguage): void {
|
||||
this.syncDescriptionFromEditor(
|
||||
this.descriptionEditorRef?.nativeElement ?? null,
|
||||
true,
|
||||
);
|
||||
this.activeContentLanguage = language;
|
||||
}
|
||||
|
||||
@@ -536,7 +556,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
return (
|
||||
!!this.productForm.names[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 {
|
||||
const nextMaterialCode = this.nextAvailableMaterialCode();
|
||||
if (!nextMaterialCode) {
|
||||
@@ -1253,10 +1301,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
fr: product.excerptFr ?? '',
|
||||
},
|
||||
descriptions: {
|
||||
it: product.descriptionIt ?? '',
|
||||
en: product.descriptionEn ?? '',
|
||||
de: product.descriptionDe ?? '',
|
||||
fr: product.descriptionFr ?? '',
|
||||
it: this.normalizeDescriptionForEditor(product.descriptionIt),
|
||||
en: this.normalizeDescriptionForEditor(product.descriptionEn),
|
||||
de: this.normalizeDescriptionForEditor(product.descriptionDe),
|
||||
fr: this.normalizeDescriptionForEditor(product.descriptionFr),
|
||||
},
|
||||
seoTitles: {
|
||||
it: product.seoTitleIt ?? '',
|
||||
@@ -1394,11 +1442,19 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
excerptEn: this.optionalValue(this.productForm.excerpts['en']),
|
||||
excerptDe: this.optionalValue(this.productForm.excerpts['de']),
|
||||
excerptFr: this.optionalValue(this.productForm.excerpts['fr']),
|
||||
description: this.optionalValue(this.productForm.descriptions['it']),
|
||||
descriptionIt: this.optionalValue(this.productForm.descriptions['it']),
|
||||
descriptionEn: this.optionalValue(this.productForm.descriptions['en']),
|
||||
descriptionDe: this.optionalValue(this.productForm.descriptions['de']),
|
||||
descriptionFr: this.optionalValue(this.productForm.descriptions['fr']),
|
||||
description: this.optionalRichTextValue(this.productForm.descriptions['it']),
|
||||
descriptionIt: this.optionalRichTextValue(
|
||||
this.productForm.descriptions['it'],
|
||||
),
|
||||
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']),
|
||||
seoTitleIt: this.optionalValue(this.productForm.seoTitles['it']),
|
||||
seoTitleEn: this.optionalValue(this.productForm.seoTitles['en']),
|
||||
@@ -1760,6 +1816,189 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
||||
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 {
|
||||
return this.productForm.seoDescriptions[language].trim().length;
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
<p class="excerpt">
|
||||
{{
|
||||
p.excerpt ||
|
||||
p.description ||
|
||||
descriptionPlainText(p.description) ||
|
||||
("SHOP.EXCERPT_FALLBACK" | translate)
|
||||
}}
|
||||
</p>
|
||||
@@ -304,10 +304,13 @@
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
@if (p.description) {
|
||||
@if (descriptionPlainText(p.description)) {
|
||||
<div class="description-block">
|
||||
<h2>{{ "SHOP.DESCRIPTION_TITLE" | translate }}</h2>
|
||||
<p>{{ p.description }}</p>
|
||||
<div
|
||||
class="description-block__content"
|
||||
[innerHTML]="descriptionRichHtml(p.description)"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -209,8 +209,7 @@ h1 {
|
||||
line-height: 1.06;
|
||||
}
|
||||
|
||||
.excerpt,
|
||||
.description-block p {
|
||||
.excerpt {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
@@ -380,11 +379,32 @@ h1 {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.description-block p {
|
||||
.description-block__content {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.06rem;
|
||||
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 {
|
||||
padding: 0.95rem 1rem;
|
||||
}
|
||||
|
||||
@@ -469,6 +469,7 @@ export class ProductDetailComponent {
|
||||
const description =
|
||||
product.seoDescription ||
|
||||
product.excerpt ||
|
||||
this.extractTextFromRichContent(product.description) ||
|
||||
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
||||
const robots =
|
||||
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
||||
@@ -500,6 +501,28 @@ export class ProductDetailComponent {
|
||||
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(
|
||||
variant: ShopProductVariantOption | 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 {
|
||||
const currentProductSlug = this.productSlug()?.trim().toLowerCase() ?? '';
|
||||
const targetProductSlug = this.shopRouteService.productPathSegment(product);
|
||||
|
||||
Reference in New Issue
Block a user