Merge pull request 'dev' (#38) from dev into main
Reviewed-on: #38
This commit was merged in pull request #38.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.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>
|
||||||
@@ -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>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.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