dev #38

Merged
JoeKung merged 9 commits from dev into main 2026-03-11 11:05:13 +01:00
10 changed files with 751 additions and 50 deletions

View File

@@ -125,6 +125,18 @@ jobs:
docker build -t "$FRONTEND_IMAGE" ./frontend docker build -t "$FRONTEND_IMAGE" ./frontend
docker push "$FRONTEND_IMAGE" docker push "$FRONTEND_IMAGE"
- name: Cleanup Docker on runner (prevent vdisk growth)
if: always()
shell: bash
run: |
set +e
# Keep recent artifacts, drop old local residue from CI builds.
docker container prune -f --filter "until=168h" || true
docker image prune -a -f --filter "until=168h" || true
docker builder prune -a -f --filter "until=168h" || true
docker network prune -f --filter "until=168h" || true
deploy: deploy:
needs: build-and-push needs: build-and-push
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -42,6 +42,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0' implementation 'net.codecrete.qrbill:qrbill-generator:3.4.0'
implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.jsoup:jsoup:1.18.3'
implementation platform('org.lwjgl:lwjgl-bom:3.3.4') implementation platform('org.lwjgl:lwjgl-bom:3.3.4')
implementation 'org.lwjgl:lwjgl' implementation 'org.lwjgl:lwjgl'
implementation 'org.lwjgl:lwjgl-assimp' implementation 'org.lwjgl:lwjgl-assimp'

View File

@@ -22,6 +22,9 @@ import com.printcalculator.service.SlicerService;
import com.printcalculator.service.media.PublicMediaQueryService; import com.printcalculator.service.media.PublicMediaQueryService;
import com.printcalculator.service.shop.ShopStorageService; import com.printcalculator.service.shop.ShopStorageService;
import com.printcalculator.service.storage.ClamAVService; import com.printcalculator.service.storage.ClamAVService;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.safety.Safelist;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -64,6 +67,10 @@ public class AdminShopProductControllerService {
private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}+"); private static final Pattern DIACRITICS_PATTERN = Pattern.compile("\\p{M}+");
private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^a-z0-9]+"); private static final Pattern NON_ALPHANUMERIC_PATTERN = Pattern.compile("[^a-z0-9]+");
private static final Pattern EDGE_DASH_PATTERN = Pattern.compile("(^-+|-+$)"); private static final Pattern EDGE_DASH_PATTERN = Pattern.compile("(^-+|-+$)");
private static final Safelist PRODUCT_DESCRIPTION_SAFELIST = Safelist.none()
.addTags("p", "div", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "a")
.addAttributes("a", "href")
.addProtocols("a", "href", "http", "https", "mailto", "tel");
private final ShopProductRepository shopProductRepository; private final ShopProductRepository shopProductRepository;
private final ShopCategoryRepository shopCategoryRepository; private final ShopCategoryRepository shopCategoryRepository;
@@ -613,17 +620,17 @@ public class AdminShopProductControllerService {
excerpts.put("fr", firstNonBlank(normalizeOptional(payload.getExcerptFr()), fallbackExcerpt)); excerpts.put("fr", firstNonBlank(normalizeOptional(payload.getExcerptFr()), fallbackExcerpt));
String fallbackDescription = firstNonBlank( String fallbackDescription = firstNonBlank(
normalizeOptional(payload.getDescription()), normalizeRichTextOptional(payload.getDescription()),
normalizeOptional(payload.getDescriptionIt()), normalizeRichTextOptional(payload.getDescriptionIt()),
normalizeOptional(payload.getDescriptionEn()), normalizeRichTextOptional(payload.getDescriptionEn()),
normalizeOptional(payload.getDescriptionDe()), normalizeRichTextOptional(payload.getDescriptionDe()),
normalizeOptional(payload.getDescriptionFr()) normalizeRichTextOptional(payload.getDescriptionFr())
); );
Map<String, String> descriptions = new LinkedHashMap<>(); Map<String, String> descriptions = new LinkedHashMap<>();
descriptions.put("it", firstNonBlank(normalizeOptional(payload.getDescriptionIt()), fallbackDescription)); descriptions.put("it", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionIt()), fallbackDescription));
descriptions.put("en", firstNonBlank(normalizeOptional(payload.getDescriptionEn()), fallbackDescription)); descriptions.put("en", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionEn()), fallbackDescription));
descriptions.put("de", firstNonBlank(normalizeOptional(payload.getDescriptionDe()), fallbackDescription)); descriptions.put("de", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionDe()), fallbackDescription));
descriptions.put("fr", firstNonBlank(normalizeOptional(payload.getDescriptionFr()), fallbackDescription)); descriptions.put("fr", firstNonBlank(normalizeRichTextOptional(payload.getDescriptionFr()), fallbackDescription));
String fallbackSeoTitle = firstNonBlank( String fallbackSeoTitle = firstNonBlank(
normalizeOptional(payload.getSeoTitle()), normalizeOptional(payload.getSeoTitle()),
@@ -689,6 +696,27 @@ public class AdminShopProductControllerService {
return normalized.isBlank() ? null : normalized; return normalized.isBlank() ? null : normalized;
} }
private String normalizeRichTextOptional(String value) {
String normalized = normalizeOptional(value);
if (normalized == null) {
return null;
}
String sanitized = Jsoup.clean(
normalized,
"",
PRODUCT_DESCRIPTION_SAFELIST,
new Document.OutputSettings().prettyPrint(false)
).trim();
if (sanitized.isBlank()) {
return null;
}
String plainText = Jsoup.parse(sanitized).text();
return plainText != null && !plainText.trim().isEmpty() ? sanitized : null;
}
private String firstNonBlank(String... values) { private String firstNonBlank(String... values) {
if (values == null) { if (values == null) {
return null; return null;

View File

@@ -9,10 +9,12 @@ import com.printcalculator.dto.ShopProductDetailDto;
import com.printcalculator.dto.ShopProductModelDto; import com.printcalculator.dto.ShopProductModelDto;
import com.printcalculator.dto.ShopProductSummaryDto; import com.printcalculator.dto.ShopProductSummaryDto;
import com.printcalculator.dto.ShopProductVariantOptionDto; import com.printcalculator.dto.ShopProductVariantOptionDto;
import com.printcalculator.entity.FilamentVariant;
import com.printcalculator.entity.ShopCategory; import com.printcalculator.entity.ShopCategory;
import com.printcalculator.entity.ShopProduct; import com.printcalculator.entity.ShopProduct;
import com.printcalculator.entity.ShopProductModelAsset; import com.printcalculator.entity.ShopProductModelAsset;
import com.printcalculator.entity.ShopProductVariant; import com.printcalculator.entity.ShopProductVariant;
import com.printcalculator.repository.FilamentVariantRepository;
import com.printcalculator.repository.ShopCategoryRepository; import com.printcalculator.repository.ShopCategoryRepository;
import com.printcalculator.repository.ShopProductModelAssetRepository; import com.printcalculator.repository.ShopProductModelAssetRepository;
import com.printcalculator.repository.ShopProductRepository; import com.printcalculator.repository.ShopProductRepository;
@@ -31,6 +33,7 @@ import java.util.Collection;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
@@ -46,6 +49,7 @@ public class PublicShopCatalogService {
private final ShopProductRepository shopProductRepository; private final ShopProductRepository shopProductRepository;
private final ShopProductVariantRepository shopProductVariantRepository; private final ShopProductVariantRepository shopProductVariantRepository;
private final ShopProductModelAssetRepository shopProductModelAssetRepository; private final ShopProductModelAssetRepository shopProductModelAssetRepository;
private final FilamentVariantRepository filamentVariantRepository;
private final PublicMediaQueryService publicMediaQueryService; private final PublicMediaQueryService publicMediaQueryService;
private final ShopStorageService shopStorageService; private final ShopStorageService shopStorageService;
@@ -53,12 +57,14 @@ public class PublicShopCatalogService {
ShopProductRepository shopProductRepository, ShopProductRepository shopProductRepository,
ShopProductVariantRepository shopProductVariantRepository, ShopProductVariantRepository shopProductVariantRepository,
ShopProductModelAssetRepository shopProductModelAssetRepository, ShopProductModelAssetRepository shopProductModelAssetRepository,
FilamentVariantRepository filamentVariantRepository,
PublicMediaQueryService publicMediaQueryService, PublicMediaQueryService publicMediaQueryService,
ShopStorageService shopStorageService) { ShopStorageService shopStorageService) {
this.shopCategoryRepository = shopCategoryRepository; this.shopCategoryRepository = shopCategoryRepository;
this.shopProductRepository = shopProductRepository; this.shopProductRepository = shopProductRepository;
this.shopProductVariantRepository = shopProductVariantRepository; this.shopProductVariantRepository = shopProductVariantRepository;
this.shopProductModelAssetRepository = shopProductModelAssetRepository; this.shopProductModelAssetRepository = shopProductModelAssetRepository;
this.filamentVariantRepository = filamentVariantRepository;
this.publicMediaQueryService = publicMediaQueryService; this.publicMediaQueryService = publicMediaQueryService;
this.shopStorageService = shopStorageService; this.shopStorageService = shopStorageService;
} }
@@ -99,7 +105,12 @@ public class PublicShopCatalogService {
List<ShopProductSummaryDto> products = productContext.entries().stream() List<ShopProductSummaryDto> products = productContext.entries().stream()
.filter(entry -> allowedCategoryIds.contains(entry.product().getCategory().getId())) .filter(entry -> allowedCategoryIds.contains(entry.product().getCategory().getId()))
.filter(entry -> !Boolean.TRUE.equals(featuredOnly) || Boolean.TRUE.equals(entry.product().getIsFeatured())) .filter(entry -> !Boolean.TRUE.equals(featuredOnly) || Boolean.TRUE.equals(entry.product().getIsFeatured()))
.map(entry -> toProductSummaryDto(entry, productContext.productMediaBySlug(), language)) .map(entry -> toProductSummaryDto(
entry,
productContext.productMediaBySlug(),
productContext.variantColorHexByMaterialAndColor(),
language
))
.toList(); .toList();
ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null
@@ -128,7 +139,12 @@ public class PublicShopCatalogService {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
} }
return toProductDetailDto(entry, productContext.productMediaBySlug(), language); return toProductDetailDto(
entry,
productContext.productMediaBySlug(),
productContext.variantColorHexByMaterialAndColor(),
language
);
} }
public ProductModelDownload getProductModelDownload(String slug) { public ProductModelDownload getProductModelDownload(String slug) {
@@ -187,11 +203,32 @@ public class PublicShopCatalogService {
entries.stream().map(entry -> productMediaUsageKey(entry.product())).toList(), entries.stream().map(entry -> productMediaUsageKey(entry.product())).toList(),
language language
); );
Map<String, String> variantColorHexByMaterialAndColor = buildFilamentVariantColorHexMap();
Map<String, ProductEntry> entriesBySlug = entries.stream() Map<String, ProductEntry> entriesBySlug = entries.stream()
.collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new)); .collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new));
return new PublicProductContext(entries, entriesBySlug, productMediaBySlug); return new PublicProductContext(entries, entriesBySlug, productMediaBySlug, variantColorHexByMaterialAndColor);
}
private Map<String, String> buildFilamentVariantColorHexMap() {
Map<String, String> colorsByMaterialAndColor = new LinkedHashMap<>();
for (FilamentVariant variant : filamentVariantRepository.findByIsActiveTrue()) {
String materialCode = variant.getFilamentMaterialType() != null
? variant.getFilamentMaterialType().getMaterialCode()
: null;
String key = toMaterialAndColorKey(materialCode, variant.getColorName());
if (key == null) {
continue;
}
String colorHex = trimToNull(variant.getColorHex());
if (colorHex == null) {
continue;
}
colorsByMaterialAndColor.putIfAbsent(key, colorHex);
}
return colorsByMaterialAndColor;
} }
private List<ProductEntry> loadPublicProducts(Collection<UUID> activeCategoryIds) { private List<ProductEntry> loadPublicProducts(Collection<UUID> activeCategoryIds) {
@@ -349,6 +386,7 @@ public class PublicShopCatalogService {
private ShopProductSummaryDto toProductSummaryDto(ProductEntry entry, private ShopProductSummaryDto toProductSummaryDto(ProductEntry entry,
Map<String, List<PublicMediaUsageDto>> productMediaBySlug, Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
Map<String, String> variantColorHexByMaterialAndColor,
String language) { String language) {
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
return new ShopProductSummaryDto( return new ShopProductSummaryDto(
@@ -365,7 +403,7 @@ public class PublicShopCatalogService {
), ),
resolvePriceFrom(entry.variants()), resolvePriceFrom(entry.variants()),
resolvePriceTo(entry.variants()), resolvePriceTo(entry.variants()),
toVariantDto(entry.defaultVariant(), entry.defaultVariant()), toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor),
selectPrimaryMedia(images), selectPrimaryMedia(images),
toProductModelDto(entry) toProductModelDto(entry)
); );
@@ -373,6 +411,7 @@ public class PublicShopCatalogService {
private ShopProductDetailDto toProductDetailDto(ProductEntry entry, private ShopProductDetailDto toProductDetailDto(ProductEntry entry,
Map<String, List<PublicMediaUsageDto>> productMediaBySlug, Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
Map<String, String> variantColorHexByMaterialAndColor,
String language) { String language) {
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language); String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
@@ -398,9 +437,9 @@ public class PublicShopCatalogService {
buildCategoryBreadcrumbs(entry.product().getCategory()), buildCategoryBreadcrumbs(entry.product().getCategory()),
resolvePriceFrom(entry.variants()), resolvePriceFrom(entry.variants()),
resolvePriceTo(entry.variants()), resolvePriceTo(entry.variants()),
toVariantDto(entry.defaultVariant(), entry.defaultVariant()), toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor),
entry.variants().stream() entry.variants().stream()
.map(variant -> toVariantDto(variant, entry.defaultVariant())) .map(variant -> toVariantDto(variant, entry.defaultVariant(), variantColorHexByMaterialAndColor))
.toList(), .toList(),
selectPrimaryMedia(images), selectPrimaryMedia(images),
images, images,
@@ -408,21 +447,61 @@ public class PublicShopCatalogService {
); );
} }
private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant, ShopProductVariant defaultVariant) { private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant,
ShopProductVariant defaultVariant,
Map<String, String> variantColorHexByMaterialAndColor) {
if (variant == null) { if (variant == null) {
return null; return null;
} }
String colorHex = trimToNull(variant.getColorHex());
if (colorHex == null) {
String key = toMaterialAndColorKey(variant.getInternalMaterialCode(), variant.getColorName());
colorHex = key != null ? variantColorHexByMaterialAndColor.get(key) : null;
}
return new ShopProductVariantOptionDto( return new ShopProductVariantOptionDto(
variant.getId(), variant.getId(),
variant.getSku(), variant.getSku(),
variant.getVariantLabel(), variant.getVariantLabel(),
variant.getColorName(), variant.getColorName(),
variant.getColorHex(), colorHex,
variant.getPriceChf(), variant.getPriceChf(),
defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId()) defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId())
); );
} }
private String toMaterialAndColorKey(String materialCode, String colorName) {
String normalizedMaterialCode = normalizeMaterialCode(materialCode);
String normalizedColorName = normalizeColorName(colorName);
if (normalizedMaterialCode == null || normalizedColorName == null) {
return null;
}
return normalizedMaterialCode + "|" + normalizedColorName;
}
private String normalizeMaterialCode(String materialCode) {
String raw = trimToNull(materialCode);
if (raw == null) {
return null;
}
return raw.toUpperCase(Locale.ROOT);
}
private String normalizeColorName(String colorName) {
String raw = trimToNull(colorName);
if (raw == null) {
return null;
}
return raw.toLowerCase(Locale.ROOT);
}
private String trimToNull(String value) {
String raw = String.valueOf(value == null ? "" : value).trim();
if (raw.isEmpty()) {
return null;
}
return raw;
}
private ShopProductModelDto toProductModelDto(ProductEntry entry) { private ShopProductModelDto toProductModelDto(ProductEntry entry) {
if (entry.modelAsset() == null) { if (entry.modelAsset() == null) {
return null; return null;
@@ -494,7 +573,8 @@ public class PublicShopCatalogService {
private record PublicProductContext( private record PublicProductContext(
List<ProductEntry> entries, List<ProductEntry> entries,
Map<String, ProductEntry> entriesBySlug, Map<String, ProductEntry> entriesBySlug,
Map<String, List<PublicMediaUsageDto>> productMediaBySlug Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
Map<String, String> variantColorHexByMaterialAndColor
) { ) {
} }

View File

@@ -635,17 +635,90 @@
/> />
</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]
"
(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>

View File

@@ -252,6 +252,62 @@
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;
}
.admin-shop .rich-text-toolbar__button {
border: 1px solid var(--color-border) !important;
border-radius: var(--radius-sm) !important;
background: #fff !important;
color: var(--color-text) !important;
min-height: 2rem;
padding: 0.28rem 0.56rem !important;
font-size: 0.82rem;
}
.admin-shop .rich-text-toolbar__button:hover:not(:disabled) {
border-color: #cbb88a !important;
background: #fffdf4 !important;
}
.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;

View File

@@ -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',
@@ -126,8 +140,14 @@ const MAX_LIST_PANEL_WIDTH_PERCENT = 68;
export class AdminShopComponent implements OnInit, OnDestroy { export class AdminShopComponent implements OnInit, OnDestroy {
private readonly adminShopService = inject(AdminShopService); private readonly adminShopService = inject(AdminShopService);
private readonly adminOperationsService = inject(AdminOperationsService); private readonly adminOperationsService = inject(AdminOperationsService);
private descriptionEditorElement: HTMLDivElement | null = null;
@ViewChild('workspaceRef') @ViewChild('workspaceRef')
private readonly workspaceRef?: ElementRef<HTMLDivElement>; private readonly workspaceRef?: ElementRef<HTMLDivElement>;
@ViewChild('descriptionEditorRef')
set descriptionEditorRef(value: ElementRef<HTMLDivElement> | undefined) {
this.descriptionEditorElement = value?.nativeElement ?? null;
this.renderActiveDescriptionInEditor();
}
readonly shopLanguages = SHOP_LANGUAGES; readonly shopLanguages = SHOP_LANGUAGES;
readonly mediaLanguages = MEDIA_LANGUAGES; readonly mediaLanguages = MEDIA_LANGUAGES;
@@ -302,6 +322,8 @@ export class AdminShopComponent implements OnInit, OnDestroy {
return; return;
} }
this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
const validationError = this.validateProductForm(); const validationError = this.validateProductForm();
if (validationError) { if (validationError) {
this.errorMessage = validationError; this.errorMessage = validationError;
@@ -525,7 +547,9 @@ export class AdminShopComponent implements OnInit, OnDestroy {
} }
setActiveContentLanguage(language: ShopLanguage): void { setActiveContentLanguage(language: ShopLanguage): void {
this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
this.activeContentLanguage = language; this.activeContentLanguage = language;
this.renderActiveDescriptionInEditor();
} }
isContentLanguageComplete(language: ShopLanguage): boolean { isContentLanguageComplete(language: ShopLanguage): boolean {
@@ -536,7 +560,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 +592,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) {
@@ -1220,6 +1272,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
private resetProductForm(): void { private resetProductForm(): void {
Object.assign(this.productForm, this.createEmptyProductForm()); Object.assign(this.productForm, this.createEmptyProductForm());
this.renderActiveDescriptionInEditor();
} }
private createEmptyMaterialForm( private createEmptyMaterialForm(
@@ -1253,10 +1306,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 ?? '',
@@ -1276,6 +1329,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
sortOrder: product.sortOrder ?? 0, sortOrder: product.sortOrder ?? 0,
materials: this.toMaterialForms(product.variants), materials: this.toMaterialForms(product.variants),
}); });
this.renderActiveDescriptionInEditor();
} }
private toMaterialForms( private toMaterialForms(
@@ -1394,11 +1448,21 @@ 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(
descriptionIt: this.optionalValue(this.productForm.descriptions['it']), this.productForm.descriptions['it'],
descriptionEn: this.optionalValue(this.productForm.descriptions['en']), ),
descriptionDe: this.optionalValue(this.productForm.descriptions['de']), descriptionIt: this.optionalRichTextValue(
descriptionFr: this.optionalValue(this.productForm.descriptions['fr']), 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']), 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 +1824,239 @@ 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 currentHtml = this.serializeNodeChildren(editor);
const currentLanguage = this.activeContentLanguage;
if (sanitize) {
const normalized = this.normalizeRichTextStorageValue(currentHtml);
const safeHtml = normalized ?? '';
this.productForm.descriptions[currentLanguage] = safeHtml;
if (currentHtml !== safeHtml) {
this.replaceElementContentFromHtml(editor, safeHtml);
}
return;
}
this.productForm.descriptions[currentLanguage] = currentHtml;
}
private renderActiveDescriptionInEditor(): void {
const editor = this.descriptionEditorElement;
if (!editor) {
return;
}
const html =
this.productForm.descriptions[this.activeContentLanguage] ?? '';
if (this.serializeNodeChildren(editor) !== html) {
this.replaceElementContentFromHtml(editor, html);
}
}
private applyDescriptionExecCommand(command: string): void {
const editor = this.descriptionEditorElement;
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 this.serializeNodeChildren(outputBody);
}
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 parser = new DOMParser();
const parsed = parser.parseFromString(`<body>${value}</body>`, 'text/html');
return parsed.body.textContent ?? '';
}
private serializeNodeChildren(node: Node): string {
const serializer = new XMLSerializer();
let html = '';
for (const child of Array.from(node.childNodes)) {
html += serializer.serializeToString(child);
}
return html;
}
private replaceElementContentFromHtml(
element: HTMLElement,
html: string,
): void {
if (!html) {
element.replaceChildren();
return;
}
const parser = new DOMParser();
const parsed = parser.parseFromString(`<body>${html}</body>`, 'text/html');
const nodes = Array.from(parsed.body.childNodes).map((child) =>
document.importNode(child, true),
);
element.replaceChildren(...nodes);
}
private escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
seoDescriptionLength(language: ShopLanguage): number { seoDescriptionLength(language: ShopLanguage): number {
return this.productForm.seoDescriptions[language].trim().length; return this.productForm.seoDescriptions[language].trim().length;
} }

View File

@@ -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>
@@ -350,7 +353,7 @@
<app-stl-viewer <app-stl-viewer
[file]="modelPreviewFile" [file]="modelPreviewFile"
[height]="420" [height]="420"
[color]="selectedVariant()?.colorHex || '#facf0a'" [color]="colorHex(selectedVariant())"
></app-stl-viewer> ></app-stl-viewer>
} }
} }

View File

@@ -33,6 +33,7 @@
display: grid; display: grid;
gap: var(--space-8); gap: var(--space-8);
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr); grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
align-items: start;
} }
.visual-column, .visual-column,
@@ -41,6 +42,10 @@
gap: var(--space-5); gap: var(--space-5);
} }
.visual-column {
align-content: start;
}
.info-column { .info-column {
gap: var(--space-3); gap: var(--space-3);
align-content: start; align-content: start;
@@ -133,21 +138,24 @@
.model-launch-row { .model-launch-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: var(--space-4); gap: 0.8rem;
align-items: center; align-items: center;
padding: 0.9rem 1rem; width: min(100%, 440px);
justify-self: start;
padding: 0.72rem 0.82rem;
border: 1px solid rgba(16, 24, 32, 0.12); border: 1px solid rgba(16, 24, 32, 0.12);
border-radius: 1rem; border-radius: 1rem;
background: #fff; background: #fff;
} }
.model-open-btn { .model-open-btn {
height: 2.35rem; height: 2.1rem;
padding: 0 0.95rem; padding: 0 0.82rem;
border-radius: 0.65rem; border-radius: 0.65rem;
border: 1px solid rgba(16, 24, 32, 0.18); border: 1px solid rgba(16, 24, 32, 0.18);
background: #fff; background: #fff;
color: var(--color-text); color: var(--color-text);
font-size: 0.86rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
} }
@@ -176,8 +184,9 @@
.dimensions-inline { .dimensions-inline {
display: flex; display: flex;
gap: 0.8rem; gap: 0.58rem;
text-align: left; text-align: left;
font-size: 0.78rem;
} }
.title-block { .title-block {
@@ -209,8 +218,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 +388,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;
} }
@@ -451,6 +480,32 @@ h1 {
min-height: 300px; min-height: 300px;
} }
.thumb-strip {
gap: 0.5rem;
}
.thumb {
flex-basis: 78px;
height: 78px;
}
.model-launch-row {
width: min(100%, 350px);
gap: 0.6rem;
padding: 0.58rem 0.65rem;
}
.model-open-btn {
height: 1.95rem;
padding: 0 0.7rem;
font-size: 0.8rem;
}
.dimensions-inline {
gap: 0.42rem;
font-size: 0.74rem;
}
.property-grid { .property-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -14,6 +14,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs'; import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs';
import { SeoService } from '../../core/services/seo.service'; import { SeoService } from '../../core/services/seo.service';
import { LanguageService } from '../../core/services/language.service'; import { LanguageService } from '../../core/services/language.service';
import { getColorHex } from '../../core/constants/colors.const';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component'; import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
@@ -52,6 +53,8 @@ interface ShopMaterialProperty {
styleUrl: './product-detail.component.scss', styleUrl: './product-detail.component.scss',
}) })
export class ProductDetailComponent { export class ProductDetailComponent {
private static readonly HEX_COLOR_PATTERN =
/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
private readonly router = inject(Router); private readonly router = inject(Router);
@@ -376,8 +379,18 @@ export class ProductDetailComponent {
return variant.colorName || variant.variantLabel || '-'; return variant.colorName || variant.variantLabel || '-';
} }
colorHex(variant: ShopProductVariantOption): string { colorHex(variant: ShopProductVariantOption | null | undefined): string {
return variant.colorHex || '#d5d8de'; const normalizedHex = this.normalizeHexColor(variant?.colorHex);
if (normalizedHex) {
return normalizedHex;
}
const fallbackByName = this.colorHexFromName(variant?.colorName);
if (fallbackByName) {
return fallbackByName;
}
return '#d5d8de';
} }
materialPriceLabel(material: ShopMaterialOption): number { materialPriceLabel(material: ShopMaterialOption): number {
@@ -464,11 +477,40 @@ export class ProductDetailComponent {
}); });
} }
private normalizeHexColor(value: string | null | undefined): string | null {
const raw = String(value ?? '').trim();
if (!raw) {
return null;
}
const withHash = raw.startsWith('#') ? raw : `#${raw}`;
if (!ProductDetailComponent.HEX_COLOR_PATTERN.test(withHash)) {
return null;
}
return withHash.toUpperCase();
}
private colorHexFromName(value: string | null | undefined): string | null {
const colorName = String(value ?? '').trim();
if (!colorName) {
return null;
}
const fallback = getColorHex(colorName);
if (!fallback || fallback === '#facf0a') {
return null;
}
return fallback;
}
private applySeo(product: ShopProductDetail): void { private applySeo(product: ShopProductDetail): void {
const title = product.seoTitle || `${product.name} | 3D fab`; const title = product.seoTitle || `${product.name} | 3D fab`;
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 +542,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 +661,38 @@ 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 parser = new DOMParser();
const parsed = parser.parseFromString(
`<body>${normalized}</body>`,
'text/html',
);
const text = (parsed.body.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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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);