diff --git a/.gitignore b/.gitignore index fff0556..bfef9e1 100644 --- a/.gitignore +++ b/.gitignore @@ -46,10 +46,12 @@ build/ ./storage_quotes ./storage_requests ./storage_media +./storage_shop storage_orders storage_quotes storage_requests storage_media +storage_shop # Qodana local reports/artifacts backend/.qodana/ diff --git a/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java b/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java index a40301d..c7d70c6 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java @@ -12,8 +12,20 @@ public class AdminShopProductDto { private String categorySlug; private String slug; private String name; + private String nameIt; + private String nameEn; + private String nameDe; + private String nameFr; private String excerpt; + private String excerptIt; + private String excerptEn; + private String excerptDe; + private String excerptFr; private String description; + private String descriptionIt; + private String descriptionEn; + private String descriptionDe; + private String descriptionFr; private String seoTitle; private String seoDescription; private String ogTitle; @@ -83,6 +95,38 @@ public class AdminShopProductDto { this.name = name; } + public String getNameIt() { + return nameIt; + } + + public void setNameIt(String nameIt) { + this.nameIt = nameIt; + } + + public String getNameEn() { + return nameEn; + } + + public void setNameEn(String nameEn) { + this.nameEn = nameEn; + } + + public String getNameDe() { + return nameDe; + } + + public void setNameDe(String nameDe) { + this.nameDe = nameDe; + } + + public String getNameFr() { + return nameFr; + } + + public void setNameFr(String nameFr) { + this.nameFr = nameFr; + } + public String getExcerpt() { return excerpt; } @@ -91,6 +135,38 @@ public class AdminShopProductDto { this.excerpt = excerpt; } + public String getExcerptIt() { + return excerptIt; + } + + public void setExcerptIt(String excerptIt) { + this.excerptIt = excerptIt; + } + + public String getExcerptEn() { + return excerptEn; + } + + public void setExcerptEn(String excerptEn) { + this.excerptEn = excerptEn; + } + + public String getExcerptDe() { + return excerptDe; + } + + public void setExcerptDe(String excerptDe) { + this.excerptDe = excerptDe; + } + + public String getExcerptFr() { + return excerptFr; + } + + public void setExcerptFr(String excerptFr) { + this.excerptFr = excerptFr; + } + public String getDescription() { return description; } @@ -99,6 +175,38 @@ public class AdminShopProductDto { this.description = description; } + public String getDescriptionIt() { + return descriptionIt; + } + + public void setDescriptionIt(String descriptionIt) { + this.descriptionIt = descriptionIt; + } + + public String getDescriptionEn() { + return descriptionEn; + } + + public void setDescriptionEn(String descriptionEn) { + this.descriptionEn = descriptionEn; + } + + public String getDescriptionDe() { + return descriptionDe; + } + + public void setDescriptionDe(String descriptionDe) { + this.descriptionDe = descriptionDe; + } + + public String getDescriptionFr() { + return descriptionFr; + } + + public void setDescriptionFr(String descriptionFr) { + this.descriptionFr = descriptionFr; + } + public String getSeoTitle() { return seoTitle; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java index f4c4ac4..0c00744 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java @@ -7,8 +7,20 @@ public class AdminUpsertShopProductRequest { private UUID categoryId; private String slug; private String name; + private String nameIt; + private String nameEn; + private String nameDe; + private String nameFr; private String excerpt; + private String excerptIt; + private String excerptEn; + private String excerptDe; + private String excerptFr; private String description; + private String descriptionIt; + private String descriptionEn; + private String descriptionDe; + private String descriptionFr; private String seoTitle; private String seoDescription; private String ogTitle; @@ -43,6 +55,38 @@ public class AdminUpsertShopProductRequest { this.name = name; } + public String getNameIt() { + return nameIt; + } + + public void setNameIt(String nameIt) { + this.nameIt = nameIt; + } + + public String getNameEn() { + return nameEn; + } + + public void setNameEn(String nameEn) { + this.nameEn = nameEn; + } + + public String getNameDe() { + return nameDe; + } + + public void setNameDe(String nameDe) { + this.nameDe = nameDe; + } + + public String getNameFr() { + return nameFr; + } + + public void setNameFr(String nameFr) { + this.nameFr = nameFr; + } + public String getExcerpt() { return excerpt; } @@ -51,6 +95,38 @@ public class AdminUpsertShopProductRequest { this.excerpt = excerpt; } + public String getExcerptIt() { + return excerptIt; + } + + public void setExcerptIt(String excerptIt) { + this.excerptIt = excerptIt; + } + + public String getExcerptEn() { + return excerptEn; + } + + public void setExcerptEn(String excerptEn) { + this.excerptEn = excerptEn; + } + + public String getExcerptDe() { + return excerptDe; + } + + public void setExcerptDe(String excerptDe) { + this.excerptDe = excerptDe; + } + + public String getExcerptFr() { + return excerptFr; + } + + public void setExcerptFr(String excerptFr) { + this.excerptFr = excerptFr; + } + public String getDescription() { return description; } @@ -59,6 +135,38 @@ public class AdminUpsertShopProductRequest { this.description = description; } + public String getDescriptionIt() { + return descriptionIt; + } + + public void setDescriptionIt(String descriptionIt) { + this.descriptionIt = descriptionIt; + } + + public String getDescriptionEn() { + return descriptionEn; + } + + public void setDescriptionEn(String descriptionEn) { + this.descriptionEn = descriptionEn; + } + + public String getDescriptionDe() { + return descriptionDe; + } + + public void setDescriptionDe(String descriptionDe) { + this.descriptionDe = descriptionDe; + } + + public String getDescriptionFr() { + return descriptionFr; + } + + public void setDescriptionFr(String descriptionFr) { + this.descriptionFr = descriptionFr; + } + public String getSeoTitle() { return seoTitle; } diff --git a/backend/src/main/java/com/printcalculator/entity/ShopProduct.java b/backend/src/main/java/com/printcalculator/entity/ShopProduct.java index ea8211e..d5fd86d 100644 --- a/backend/src/main/java/com/printcalculator/entity/ShopProduct.java +++ b/backend/src/main/java/com/printcalculator/entity/ShopProduct.java @@ -15,6 +15,7 @@ import jakarta.persistence.Table; import org.hibernate.annotations.ColumnDefault; import java.time.OffsetDateTime; +import java.util.List; import java.util.UUID; @Entity @@ -23,6 +24,8 @@ import java.util.UUID; @Index(name = "ix_shop_product_featured_sort", columnList = "is_featured, is_active, sort_order") }) public class ShopProduct { + public static final List SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr"); + @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = "shop_product_id", nullable = false) @@ -38,12 +41,48 @@ public class ShopProduct { @Column(name = "name", nullable = false, length = Integer.MAX_VALUE) private String name; + @Column(name = "name_it", length = Integer.MAX_VALUE) + private String nameIt; + + @Column(name = "name_en", length = Integer.MAX_VALUE) + private String nameEn; + + @Column(name = "name_de", length = Integer.MAX_VALUE) + private String nameDe; + + @Column(name = "name_fr", length = Integer.MAX_VALUE) + private String nameFr; + @Column(name = "excerpt", length = Integer.MAX_VALUE) private String excerpt; + @Column(name = "excerpt_it", length = Integer.MAX_VALUE) + private String excerptIt; + + @Column(name = "excerpt_en", length = Integer.MAX_VALUE) + private String excerptEn; + + @Column(name = "excerpt_de", length = Integer.MAX_VALUE) + private String excerptDe; + + @Column(name = "excerpt_fr", length = Integer.MAX_VALUE) + private String excerptFr; + @Column(name = "description", length = Integer.MAX_VALUE) private String description; + @Column(name = "description_it", length = Integer.MAX_VALUE) + private String descriptionIt; + + @Column(name = "description_en", length = Integer.MAX_VALUE) + private String descriptionEn; + + @Column(name = "description_de", length = Integer.MAX_VALUE) + private String descriptionDe; + + @Column(name = "description_fr", length = Integer.MAX_VALUE) + private String descriptionFr; + @Column(name = "seo_title", length = Integer.MAX_VALUE) private String seoTitle; @@ -152,6 +191,38 @@ public class ShopProduct { this.name = name; } + public String getNameIt() { + return nameIt; + } + + public void setNameIt(String nameIt) { + this.nameIt = nameIt; + } + + public String getNameEn() { + return nameEn; + } + + public void setNameEn(String nameEn) { + this.nameEn = nameEn; + } + + public String getNameDe() { + return nameDe; + } + + public void setNameDe(String nameDe) { + this.nameDe = nameDe; + } + + public String getNameFr() { + return nameFr; + } + + public void setNameFr(String nameFr) { + this.nameFr = nameFr; + } + public String getExcerpt() { return excerpt; } @@ -160,6 +231,38 @@ public class ShopProduct { this.excerpt = excerpt; } + public String getExcerptIt() { + return excerptIt; + } + + public void setExcerptIt(String excerptIt) { + this.excerptIt = excerptIt; + } + + public String getExcerptEn() { + return excerptEn; + } + + public void setExcerptEn(String excerptEn) { + this.excerptEn = excerptEn; + } + + public String getExcerptDe() { + return excerptDe; + } + + public void setExcerptDe(String excerptDe) { + this.excerptDe = excerptDe; + } + + public String getExcerptFr() { + return excerptFr; + } + + public void setExcerptFr(String excerptFr) { + this.excerptFr = excerptFr; + } + public String getDescription() { return description; } @@ -168,6 +271,38 @@ public class ShopProduct { this.description = description; } + public String getDescriptionIt() { + return descriptionIt; + } + + public void setDescriptionIt(String descriptionIt) { + this.descriptionIt = descriptionIt; + } + + public String getDescriptionEn() { + return descriptionEn; + } + + public void setDescriptionEn(String descriptionEn) { + this.descriptionEn = descriptionEn; + } + + public String getDescriptionDe() { + return descriptionDe; + } + + public void setDescriptionDe(String descriptionDe) { + this.descriptionDe = descriptionDe; + } + + public String getDescriptionFr() { + return descriptionFr; + } + + public void setDescriptionFr(String descriptionFr) { + this.descriptionFr = descriptionFr; + } + public String getSeoTitle() { return seoTitle; } @@ -247,4 +382,94 @@ public class ShopProduct { public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } + + public String getNameForLanguage(String language) { + return resolveLocalizedValue(language, name, nameIt, nameEn, nameDe, nameFr); + } + + public void setNameForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> nameIt = value; + case "en" -> nameEn = value; + case "de" -> nameDe = value; + case "fr" -> nameFr = value; + default -> { + } + } + } + + public String getExcerptForLanguage(String language) { + return resolveLocalizedValue(language, excerpt, excerptIt, excerptEn, excerptDe, excerptFr); + } + + public void setExcerptForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> excerptIt = value; + case "en" -> excerptEn = value; + case "de" -> excerptDe = value; + case "fr" -> excerptFr = value; + default -> { + } + } + } + + public String getDescriptionForLanguage(String language) { + return resolveLocalizedValue(language, description, descriptionIt, descriptionEn, descriptionDe, descriptionFr); + } + + public void setDescriptionForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> descriptionIt = value; + case "en" -> descriptionEn = value; + case "de" -> descriptionDe = value; + case "fr" -> descriptionFr = value; + default -> { + } + } + } + + private String resolveLocalizedValue(String language, + String fallback, + String valueIt, + String valueEn, + String valueDe, + String valueFr) { + String normalizedLanguage = normalizeLanguage(language); + String preferred = switch (normalizedLanguage) { + case "it" -> valueIt; + case "en" -> valueEn; + case "de" -> valueDe; + case "fr" -> valueFr; + default -> null; + }; + String resolved = firstNonBlank(preferred, fallback); + if (resolved != null) { + return resolved; + } + return firstNonBlank(valueIt, valueEn, valueDe, valueFr); + } + + private String normalizeLanguage(String language) { + if (language == null) { + return ""; + } + String normalized = language.trim().toLowerCase(); + int separatorIndex = normalized.indexOf('-'); + if (separatorIndex > 0) { + normalized = normalized.substring(0, separatorIndex); + } + return normalized; + } + + private String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } } diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java index 1a2e2bf..c90ef08 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java @@ -22,6 +22,7 @@ import com.printcalculator.service.SlicerService; import com.printcalculator.service.media.PublicMediaQueryService; import com.printcalculator.service.shop.ShopStorageService; import com.printcalculator.service.storage.ClamAVService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -75,6 +76,7 @@ public class AdminShopProductControllerService { private final ShopStorageService shopStorageService; private final SlicerService slicerService; private final ClamAVService clamAVService; + private final long maxModelFileSizeBytes; public AdminShopProductControllerService(ShopProductRepository shopProductRepository, ShopCategoryRepository shopCategoryRepository, @@ -86,7 +88,8 @@ public class AdminShopProductControllerService { AdminMediaControllerService adminMediaControllerService, ShopStorageService shopStorageService, SlicerService slicerService, - ClamAVService clamAVService) { + ClamAVService clamAVService, + @Value("${shop.model.max-file-size-bytes:104857600}") long maxModelFileSizeBytes) { this.shopProductRepository = shopProductRepository; this.shopCategoryRepository = shopCategoryRepository; this.shopProductVariantRepository = shopProductVariantRepository; @@ -98,6 +101,7 @@ public class AdminShopProductControllerService { this.shopStorageService = shopStorageService; this.slicerService = slicerService; this.clamAVService = clamAVService; + this.maxModelFileSizeBytes = maxModelFileSizeBytes; } public List getProducts() { @@ -113,13 +117,13 @@ public class AdminShopProductControllerService { @Transactional public AdminShopProductDto createProduct(AdminUpsertShopProductRequest payload) { ensurePayload(payload); - String normalizedName = normalizeRequired(payload.getName(), "Product name is required"); - String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName); + LocalizedProductContent localizedContent = normalizeLocalizedProductContent(payload); + String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName()); ensureSlugAvailable(normalizedSlug, null); ShopProduct product = new ShopProduct(); product.setCreatedAt(OffsetDateTime.now()); - applyProductPayload(product, payload, normalizedName, normalizedSlug, resolveCategory(payload.getCategoryId())); + applyProductPayload(product, payload, localizedContent, normalizedSlug, resolveCategory(payload.getCategoryId())); ShopProduct saved = shopProductRepository.save(product); syncVariants(saved, payload.getVariants()); return getProduct(saved.getId()); @@ -131,11 +135,11 @@ public class AdminShopProductControllerService { ShopProduct product = shopProductRepository.findById(productId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Shop product not found")); - String normalizedName = normalizeRequired(payload.getName(), "Product name is required"); - String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName); + LocalizedProductContent localizedContent = normalizeLocalizedProductContent(payload); + String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName()); ensureSlugAvailable(normalizedSlug, productId); - applyProductPayload(product, payload, normalizedName, normalizedSlug, resolveCategory(payload.getCategoryId())); + applyProductPayload(product, payload, localizedContent, normalizedSlug, resolveCategory(payload.getCategoryId())); ShopProduct saved = shopProductRepository.save(product); syncVariants(saved, payload.getVariants()); return getProduct(saved.getId()); @@ -291,14 +295,26 @@ public class AdminShopProductControllerService { private void applyProductPayload(ShopProduct product, AdminUpsertShopProductRequest payload, - String normalizedName, + LocalizedProductContent localizedContent, String normalizedSlug, ShopCategory category) { product.setCategory(category); product.setSlug(normalizedSlug); - product.setName(normalizedName); - product.setExcerpt(normalizeOptional(payload.getExcerpt())); - product.setDescription(normalizeOptional(payload.getDescription())); + product.setName(localizedContent.defaultName()); + product.setNameIt(localizedContent.names().get("it")); + product.setNameEn(localizedContent.names().get("en")); + product.setNameDe(localizedContent.names().get("de")); + product.setNameFr(localizedContent.names().get("fr")); + product.setExcerpt(localizedContent.defaultExcerpt()); + product.setExcerptIt(localizedContent.excerpts().get("it")); + product.setExcerptEn(localizedContent.excerpts().get("en")); + product.setExcerptDe(localizedContent.excerpts().get("de")); + product.setExcerptFr(localizedContent.excerpts().get("fr")); + product.setDescription(localizedContent.defaultDescription()); + product.setDescriptionIt(localizedContent.descriptions().get("it")); + product.setDescriptionEn(localizedContent.descriptions().get("en")); + product.setDescriptionDe(localizedContent.descriptions().get("de")); + product.setDescriptionFr(localizedContent.descriptions().get("fr")); product.setSeoTitle(normalizeOptional(payload.getSeoTitle())); product.setSeoDescription(normalizeOptional(payload.getSeoDescription())); product.setOgTitle(normalizeOptional(payload.getOgTitle())); @@ -436,8 +452,20 @@ public class AdminShopProductControllerService { dto.setCategorySlug(product.getCategory() != null ? product.getCategory().getSlug() : null); dto.setSlug(product.getSlug()); dto.setName(product.getName()); + dto.setNameIt(product.getNameIt()); + dto.setNameEn(product.getNameEn()); + dto.setNameDe(product.getNameDe()); + dto.setNameFr(product.getNameFr()); dto.setExcerpt(product.getExcerpt()); + dto.setExcerptIt(product.getExcerptIt()); + dto.setExcerptEn(product.getExcerptEn()); + dto.setExcerptDe(product.getExcerptDe()); + dto.setExcerptFr(product.getExcerptFr()); dto.setDescription(product.getDescription()); + dto.setDescriptionIt(product.getDescriptionIt()); + dto.setDescriptionEn(product.getDescriptionEn()); + dto.setDescriptionDe(product.getDescriptionDe()); + dto.setDescriptionFr(product.getDescriptionFr()); dto.setSeoTitle(product.getSeoTitle()); dto.setSeoDescription(product.getSeoDescription()); dto.setOgTitle(product.getOgTitle()); @@ -523,6 +551,61 @@ public class AdminShopProductControllerService { } } + private LocalizedProductContent normalizeLocalizedProductContent(AdminUpsertShopProductRequest payload) { + String legacyName = normalizeOptional(payload.getName()); + String fallbackName = firstNonBlank( + legacyName, + normalizeOptional(payload.getNameIt()), + normalizeOptional(payload.getNameEn()), + normalizeOptional(payload.getNameDe()), + normalizeOptional(payload.getNameFr()) + ); + if (fallbackName == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Product name is required"); + } + + Map names = new LinkedHashMap<>(); + names.put("it", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameIt()), fallbackName), "Italian product name is required")); + names.put("en", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameEn()), fallbackName), "English product name is required")); + names.put("de", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameDe()), fallbackName), "German product name is required")); + names.put("fr", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameFr()), fallbackName), "French product name is required")); + + String fallbackExcerpt = firstNonBlank( + normalizeOptional(payload.getExcerpt()), + normalizeOptional(payload.getExcerptIt()), + normalizeOptional(payload.getExcerptEn()), + normalizeOptional(payload.getExcerptDe()), + normalizeOptional(payload.getExcerptFr()) + ); + Map excerpts = new LinkedHashMap<>(); + excerpts.put("it", firstNonBlank(normalizeOptional(payload.getExcerptIt()), fallbackExcerpt)); + excerpts.put("en", firstNonBlank(normalizeOptional(payload.getExcerptEn()), fallbackExcerpt)); + excerpts.put("de", firstNonBlank(normalizeOptional(payload.getExcerptDe()), fallbackExcerpt)); + excerpts.put("fr", firstNonBlank(normalizeOptional(payload.getExcerptFr()), fallbackExcerpt)); + + String fallbackDescription = firstNonBlank( + normalizeOptional(payload.getDescription()), + normalizeOptional(payload.getDescriptionIt()), + normalizeOptional(payload.getDescriptionEn()), + normalizeOptional(payload.getDescriptionDe()), + normalizeOptional(payload.getDescriptionFr()) + ); + Map descriptions = new LinkedHashMap<>(); + descriptions.put("it", firstNonBlank(normalizeOptional(payload.getDescriptionIt()), fallbackDescription)); + descriptions.put("en", firstNonBlank(normalizeOptional(payload.getDescriptionEn()), fallbackDescription)); + descriptions.put("de", firstNonBlank(normalizeOptional(payload.getDescriptionDe()), fallbackDescription)); + descriptions.put("fr", firstNonBlank(normalizeOptional(payload.getDescriptionFr()), fallbackDescription)); + + return new LocalizedProductContent( + names.get("it"), + firstNonBlank(excerpts.get("it"), fallbackExcerpt), + firstNonBlank(descriptions.get("it"), fallbackDescription), + names, + excerpts, + descriptions + ); + } + private void ensureSlugAvailable(String slug, UUID currentProductId) { shopProductRepository.findBySlugIgnoreCase(slug).ifPresent(existing -> { if (currentProductId == null || !existing.getId().equals(currentProductId)) { @@ -547,6 +630,18 @@ public class AdminShopProductControllerService { return normalized.isBlank() ? null : normalized; } + private String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + private String normalizeAndValidateSlug(String slug, String fallbackName) { String source = normalizeOptional(slug); if (source == null) { @@ -579,6 +674,9 @@ public class AdminShopProductControllerService { if (file == null || file.isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "3D model file is required"); } + if (maxModelFileSizeBytes > 0 && file.getSize() > maxModelFileSizeBytes) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "3D model file exceeds size limit"); + } String extension = resolveExtension(sanitizeOriginalFilename(file.getOriginalFilename())); if (!SUPPORTED_MODEL_EXTENSIONS.contains(extension)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported 3D model type. Allowed: stl, 3mf"); @@ -683,4 +781,14 @@ public class AdminShopProductControllerService { public record ProductModelDownload(Path path, String filename, String mimeType) { } + + private record LocalizedProductContent( + String defaultName, + String defaultExcerpt, + String defaultDescription, + Map names, + Map excerpts, + Map descriptions + ) { + } } diff --git a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java index 99a7b53..10f8fc0 100644 --- a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java +++ b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java @@ -99,7 +99,7 @@ public class PublicShopCatalogService { List products = productContext.entries().stream() .filter(entry -> allowedCategoryIds.contains(entry.product().getCategory().getId())) .filter(entry -> !Boolean.TRUE.equals(featuredOnly) || Boolean.TRUE.equals(entry.product().getIsFeatured())) - .map(entry -> toProductSummaryDto(entry, productContext.productMediaBySlug())) + .map(entry -> toProductSummaryDto(entry, productContext.productMediaBySlug(), language)) .toList(); ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null @@ -128,7 +128,7 @@ public class PublicShopCatalogService { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); } - return toProductDetailDto(entry, productContext.productMediaBySlug()); + return toProductDetailDto(entry, productContext.productMediaBySlug(), language); } public ProductModelDownload getProductModelDownload(String slug) { @@ -348,13 +348,14 @@ public class PublicShopCatalogService { } private ShopProductSummaryDto toProductSummaryDto(ProductEntry entry, - Map> productMediaBySlug) { + Map> productMediaBySlug, + String language) { List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); return new ShopProductSummaryDto( entry.product().getId(), entry.product().getSlug(), - entry.product().getName(), - entry.product().getExcerpt(), + entry.product().getNameForLanguage(language), + entry.product().getExcerptForLanguage(language), entry.product().getIsFeatured(), entry.product().getSortOrder(), new ShopCategoryRefDto( @@ -371,14 +372,15 @@ public class PublicShopCatalogService { } private ShopProductDetailDto toProductDetailDto(ProductEntry entry, - Map> productMediaBySlug) { + Map> productMediaBySlug, + String language) { List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); return new ShopProductDetailDto( entry.product().getId(), entry.product().getSlug(), - entry.product().getName(), - entry.product().getExcerpt(), - entry.product().getDescription(), + entry.product().getNameForLanguage(language), + entry.product().getExcerptForLanguage(language), + entry.product().getDescriptionForLanguage(language), entry.product().getSeoTitle(), entry.product().getSeoDescription(), entry.product().getOgTitle(), diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index b42b99e..a9def27 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -31,6 +31,7 @@ media.storage.root=${MEDIA_STORAGE_ROOT:storage_media} media.public.base-url=${MEDIA_PUBLIC_BASE_URL:http://localhost:8080/media} media.ffmpeg.path=${MEDIA_FFMPEG_PATH:ffmpeg} media.upload.max-file-size-bytes=${MEDIA_UPLOAD_MAX_FILE_SIZE_BYTES:26214400} +shop.model.max-file-size-bytes=${SHOP_MODEL_MAX_FILE_SIZE_BYTES:104857600} shop.storage.root=${SHOP_STORAGE_ROOT:storage_shop} shop.cart.cookie.ttl-days=${SHOP_CART_COOKIE_TTL_DAYS:30} shop.cart.cookie.secure=${SHOP_CART_COOKIE_SECURE:false} diff --git a/backend/src/test/java/com/printcalculator/entity/ShopProductTest.java b/backend/src/test/java/com/printcalculator/entity/ShopProductTest.java new file mode 100644 index 0000000..2a07ec2 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/entity/ShopProductTest.java @@ -0,0 +1,48 @@ +package com.printcalculator.entity; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ShopProductTest { + + @Test + void localizedAccessorsShouldReturnLanguageSpecificValues() { + ShopProduct product = new ShopProduct(); + product.setName("Desk Cable Clip"); + product.setNameIt("Fermacavo da scrivania"); + product.setNameEn("Desk Cable Clip"); + product.setNameDe("Schreibtisch-Kabelclip"); + product.setNameFr("Clip de cable de bureau"); + product.setExcerpt("Legacy excerpt"); + product.setExcerptIt("Clip compatta per i cavi sulla scrivania."); + product.setExcerptEn("Compact clip to keep desk cables in place."); + product.setExcerptDe("Kompakter Clip fur ordentliche Kabel auf dem Schreibtisch."); + product.setExcerptFr("Clip compact pour garder les cables du bureau en ordre."); + product.setDescription("Legacy description"); + product.setDescriptionIt("Supporto con base stabile e passaggio cavi frontale."); + product.setDescriptionEn("Stable desk clip with front cable routing."); + product.setDescriptionDe("Stabiler Tischclip mit frontaler Kabelfuhrung."); + product.setDescriptionFr("Clip de bureau stable avec passage frontal des cables."); + + assertEquals("Fermacavo da scrivania", product.getNameForLanguage("it")); + assertEquals("Desk Cable Clip", product.getNameForLanguage("en")); + assertEquals("Schreibtisch-Kabelclip", product.getNameForLanguage("de")); + assertEquals("Clip de cable de bureau", product.getNameForLanguage("fr")); + assertEquals("Compact clip to keep desk cables in place.", product.getExcerptForLanguage("en")); + assertEquals("Clip compact pour garder les cables du bureau en ordre.", product.getExcerptForLanguage("fr")); + assertEquals("Stabiler Tischclip mit frontaler Kabelfuhrung.", product.getDescriptionForLanguage("de")); + } + + @Test + void localizedAccessorsShouldFallbackToLegacyValues() { + ShopProduct product = new ShopProduct(); + product.setName("Desk Cable Clip"); + product.setExcerpt("Compact desk cable clip."); + product.setDescription("Stable clip with front cable channel."); + + assertEquals("Desk Cable Clip", product.getNameForLanguage("it")); + assertEquals("Compact desk cable clip.", product.getExcerptForLanguage("de")); + assertEquals("Stable clip with front cable channel.", product.getDescriptionForLanguage("fr-CH")); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java b/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java index 0a671b8..aa90829 100644 --- a/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java @@ -114,15 +114,15 @@ class OrderServiceTest { Path sourceDir = Path.of("storage_quotes").toAbsolutePath().normalize().resolve(sessionId.toString()); Files.createDirectories(sourceDir); - Path sourceFile = sourceDir.resolve("shop-demo.stl"); - Files.writeString(sourceFile, "solid demo\nendsolid demo\n", StandardCharsets.UTF_8); + Path sourceFile = sourceDir.resolve("shop-product.stl"); + Files.writeString(sourceFile, "solid product\nendsolid product\n", StandardCharsets.UTF_8); QuoteLineItem qItem = new QuoteLineItem(); qItem.setId(UUID.randomUUID()); qItem.setQuoteSession(session); qItem.setStatus("READY"); qItem.setLineItemType("SHOP_PRODUCT"); - qItem.setOriginalFilename("shop-demo.stl"); + qItem.setOriginalFilename("shop-product.stl"); qItem.setDisplayName("Desk Cable Clip"); qItem.setQuantity(2); qItem.setColorCode("Coral Red"); diff --git a/backend/src/test/java/com/printcalculator/service/payment/InvoicePdfRenderingServiceTest.java b/backend/src/test/java/com/printcalculator/service/payment/InvoicePdfRenderingServiceTest.java index 9bfd928..086c319 100644 --- a/backend/src/test/java/com/printcalculator/service/payment/InvoicePdfRenderingServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/payment/InvoicePdfRenderingServiceTest.java @@ -45,7 +45,7 @@ class InvoicePdfRenderingServiceTest { OrderItem shopItem = new OrderItem(); shopItem.setItemType("SHOP_PRODUCT"); shopItem.setDisplayName("Desk Cable Clip"); - shopItem.setOriginalFilename("desk-cable-clip-demo.stl"); + shopItem.setOriginalFilename("desk-cable-clip.stl"); shopItem.setShopProductName("Desk Cable Clip"); shopItem.setShopVariantLabel("Coral Red"); shopItem.setQuantity(2); diff --git a/db.sql b/db.sql index bc500ec..4d424a0 100644 --- a/db.sql +++ b/db.sql @@ -1040,8 +1040,20 @@ CREATE TABLE IF NOT EXISTS shop_product shop_category_id uuid NOT NULL REFERENCES shop_category (shop_category_id), slug text NOT NULL UNIQUE, name text NOT NULL, + name_it text, + name_en text, + name_de text, + name_fr text, excerpt text, + excerpt_it text, + excerpt_en text, + excerpt_de text, + excerpt_fr text, description text, + description_it text, + description_en text, + description_de text, + description_fr text, seo_title text, seo_description text, og_title text, @@ -1060,6 +1072,74 @@ CREATE INDEX IF NOT EXISTS ix_shop_product_category_active_sort CREATE INDEX IF NOT EXISTS ix_shop_product_featured_sort ON shop_product (is_featured, is_active, sort_order, created_at DESC); +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS name_it text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS name_en text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS name_de text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS name_fr text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS excerpt_it text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS excerpt_en text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS excerpt_de text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS excerpt_fr text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS description_it text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS description_en text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS description_de text; + +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS description_fr text; + +UPDATE shop_product +SET + name_it = COALESCE(NULLIF(btrim(name_it), ''), name), + name_en = COALESCE(NULLIF(btrim(name_en), ''), name), + name_de = COALESCE(NULLIF(btrim(name_de), ''), name), + name_fr = COALESCE(NULLIF(btrim(name_fr), ''), name), + excerpt_it = COALESCE(NULLIF(btrim(excerpt_it), ''), excerpt), + excerpt_en = COALESCE(NULLIF(btrim(excerpt_en), ''), excerpt), + excerpt_de = COALESCE(NULLIF(btrim(excerpt_de), ''), excerpt), + excerpt_fr = COALESCE(NULLIF(btrim(excerpt_fr), ''), excerpt), + description_it = COALESCE(NULLIF(btrim(description_it), ''), description), + description_en = COALESCE(NULLIF(btrim(description_en), ''), description), + description_de = COALESCE(NULLIF(btrim(description_de), ''), description), + description_fr = COALESCE(NULLIF(btrim(description_fr), ''), description) +WHERE + NULLIF(btrim(name_it), '') IS NULL + OR NULLIF(btrim(name_en), '') IS NULL + OR NULLIF(btrim(name_de), '') IS NULL + OR NULLIF(btrim(name_fr), '') IS NULL + OR (excerpt IS NOT NULL AND ( + NULLIF(btrim(excerpt_it), '') IS NULL + OR NULLIF(btrim(excerpt_en), '') IS NULL + OR NULLIF(btrim(excerpt_de), '') IS NULL + OR NULLIF(btrim(excerpt_fr), '') IS NULL + )) + OR (description IS NOT NULL AND ( + NULLIF(btrim(description_it), '') IS NULL + OR NULLIF(btrim(description_en), '') IS NULL + OR NULLIF(btrim(description_de), '') IS NULL + OR NULLIF(btrim(description_fr), '') IS NULL + )); + CREATE TABLE IF NOT EXISTS shop_product_variant ( shop_product_variant_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/frontend/src/app/core/layout/navbar.component.html b/frontend/src/app/core/layout/navbar.component.html index 6703ac7..36f5b5b 100644 --- a/frontend/src/app/core/layout/navbar.component.html +++ b/frontend/src/app/core/layout/navbar.component.html @@ -42,6 +42,33 @@
+ + + + + + + +
+ + +
+
+
+

Categorie shop

+

Gestione tassonomia e filtro del catalogo.

+
+ +
+ +
+
+
+ +
+ + {{ category.descendantProductCount }} prodotti + + + {{ category.isActive ? "Attiva" : "Inattiva" }} + +
+
+
+ +
+
+
+

+ {{ categoryForm.id ? "Modifica categoria" : "Nuova categoria" }} +

+

Aggiorna struttura, SEO e visibilità.

+

Crea una nuova categoria del catalogo.

+
+
+ +
+ + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ +
+ + + +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
ProdottoCategoriaVariantiPrezzo CHFStato
+
+ {{ product.name }} + {{ product.slug }} +
+
{{ product.categoryName }}{{ product.activeVariantCount }} / {{ product.variantCount }} + {{ product.priceFromChf | currency: "CHF" : "symbol" : "1.2-2" }} + + - + {{ + product.priceToChf | currency: "CHF" : "symbol" : "1.2-2" + }} + + + + {{ + product.isActive + ? product.isFeatured + ? "Featured" + : "Attivo" + : "Inattivo" + }} + +
Nessun prodotto trovato con i filtri correnti.
+
+ + + + +
+
+
+

+ {{ productMode === "create" ? "Nuovo prodotto" : selectedProduct?.name }} +

+

+ Compila i campi e salva per creare un nuovo prodotto shop. +

+

+ {{ selectedProduct.slug }} · {{ selectedProduct.categoryName }} +

+
+ + {{ + selectedProduct.isActive + ? selectedProduct.isFeatured + ? "Featured" + : "Attivo" + : "Inattivo" + }} + +
+ +

Caricamento dettaglio...

+ +
+
+ Categoria + {{ selectedProduct.categoryName }} +
+
+ Creato il + {{ selectedProduct.createdAt | date: "dd.MM.yyyy HH:mm" }} +
+
+ Aggiornato il + {{ selectedProduct.updatedAt | date: "dd.MM.yyyy HH:mm" }} +
+
+ Media + {{ productImages.length }} immagini attive +
+
+ +
+
+
+
+

Dati base

+

Slug, categoria, ordinamento e visibilità del prodotto.

+
+
+ +
+ + + + + +
+ +
+ + + + + +
+
+ +
+
+
+

Contenuti localizzati

+

Nome obbligatorio in tutte le lingue. Descrizioni opzionali.

+
+
+ +
+
+ Lingua editor +

IT / EN / DE / FR

+
+
+ +
+
+ +
+ + + + + +
+
+ +
+
+
+

SEO e social

+

Metadati globali per risultato organico e preview.

+
+
+ +
+ + + + + + + +
+
+ +
+
+
+

Varianti

+

Colori, materiale interno, SKU e prezzi.

+
+ +
+ +
+
+
+
+

+ {{ variant.variantLabel || variant.colorName || "Nuova variante" }} +

+

Ordine {{ variant.sortOrder }}

+
+
+ + +
+
+ +
+ + + + + + + + + + + + + + +
+ + + +
+
+
+
+
+ +
+
+
+

Immagini e modello 3D

+

+ Upload protetto con whitelist tipi file; il modello 3D è disponibile + solo dopo il primo salvataggio del prodotto. +

+
+
+ +
+ Salva prima il prodotto per collegare immagini e modello 3D. +
+ +
+
+
+
+

Nuova immagine prodotto

+

JPG, PNG o WEBP con titolo e alt text in tutte le lingue.

+
+
+ +
+
+ File immagine + + +
+ +
+ +
+ +
+
+ Testi localizzati immagine +

Titolo e alt text obbligatori

+
+
+ +
+
+ + + + + + + +
+ +
+
+ +
+ +
+
+ +
+
+
+

Immagini attive

+

{{ productImages.length }} immagini collegate al prodotto.

+
+
+ +
+
+
+ +
+ +
+
+ + {{ image.translations[imageUploadState.activeLanguage].title || "Senza titolo" }} + + + Primaria + +
+ +

+ {{ image.translations[imageUploadState.activeLanguage].altText || "Alt text mancante" }} +

+ +
+ + +
+ + + +
+
+
+
+
+
+ +
+
+
+

Modello 3D

+

+ Solo STL o 3MF. Limite client {{ maxModelFileSizeMb }} MB, + whitelist server-side e virus scan già attivi. +

+
+
+ +
+
+
+ File + {{ model.originalFilename }} +
+
+ Dimensione + {{ formatFileSize(model.fileSizeBytes) }} +
+
+ Bounding box + + {{ model.boundingBoxXMm || 0 }} × {{ model.boundingBoxYMm || 0 }} × + {{ model.boundingBoxZMm || 0 }} mm + +
+
+ +
+ + Apri modello + + +
+
+ +
+ Carica o sostituisci modello + + +
+ +
+ +
+
+
+
+ +
+ + + +
+
+
+ + + + +
Caricamento catalogo shop...
+
+ + +
Nessuna immagine collegata a questo prodotto.
+
+ + +
Nessuna preview
+
+ + +
Nessun modello 3D caricato per questo prodotto.
+
diff --git a/frontend/src/app/features/admin/pages/admin-shop.component.scss b/frontend/src/app/features/admin/pages/admin-shop.component.scss new file mode 100644 index 0000000..4ab1882 --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-shop.component.scss @@ -0,0 +1,437 @@ +.admin-shop { + display: flex; + flex-direction: column; + gap: var(--space-5); +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-4); +} + +.shop-header { + align-items: flex-start; +} + +.shop-header h1 { + margin: 0; +} + +.shop-header p { + margin: var(--space-2) 0 0; + color: var(--color-text-muted); +} + +.header-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); +} + +.workspace { + align-items: start; +} + +.resizable-workspace { + display: flex; + align-items: flex-start; + gap: 0; +} + +.resizable-workspace .list-panel { + flex: 0 0 var(--shop-list-panel-width, 53%); + width: var(--shop-list-panel-width, 53%); +} + +.resizable-workspace .detail-panel { + flex: 1 1 auto; +} + +.list-panel, +.detail-panel { + min-width: 0; +} + +.panel-block, +.list-panel, +.detail-panel, +.category-manager, +.category-editor, +.detail-stack, +.form-section, +.variant-stack, +.image-stack, +.media-grid, +.model-summary { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.panel-heading { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); +} + +.panel-heading h2, +.panel-heading h3, +.panel-heading h4, +.detail-header h2 { + margin: 0; +} + +.panel-heading p, +.detail-header p { + margin: var(--space-1) 0 0; + color: var(--color-text-muted); +} + +.list-toolbar { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, 0.8fr); + gap: var(--space-2); +} + +.list-toolbar > * { + min-width: 0; +} + +.category-manager { + gap: var(--space-2); +} + +.category-manager__header { + display: flex; + justify-content: space-between; + gap: var(--space-3); + align-items: flex-start; +} + +.category-manager__header h3 { + margin: 0; +} + +.category-manager__header p { + margin: var(--space-1) 0 0; + color: var(--color-text-muted); +} + +.category-manager__body { + display: grid; + grid-template-columns: minmax(220px, 320px) minmax(0, 1fr); + gap: var(--space-3); +} + +.category-list { + display: grid; + gap: var(--space-2); + max-height: 560px; + overflow: auto; +} + +.category-item { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + padding: var(--space-2); + display: grid; + gap: var(--space-2); +} + +.category-item.active { + border-color: var(--color-brand); + background: #fff9de; +} + +.category-item__main { + width: 100%; + border: 0; + background: transparent; + color: inherit; + text-align: left; + padding: 0; + cursor: pointer; + display: grid; + gap: 4px; +} + +.category-item__main strong, +.product-cell strong, +.image-item__header strong { + overflow-wrap: anywhere; +} + +.category-item__main span, +.product-cell span { + font-size: 0.84rem; + color: var(--color-text-muted); +} + +.category-item__meta { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.category-editor { + gap: var(--space-3); +} + +.textarea-control { + resize: vertical; + min-height: 82px; +} + +.textarea-control--large { + min-height: 136px; +} + +.toggle-row { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.toggle-row--compact { + align-items: flex-start; +} + +.form-field--wide { + grid-column: 1 / -1; +} + +.input-with-action { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-2); +} + +.product-cell { + display: grid; + gap: 4px; +} + +tbody tr { + cursor: pointer; +} + +tbody tr.selected { + background: #fff4c0; +} + +.detail-panel { + gap: var(--space-4); +} + +.detail-panel .ui-meta-item { + padding: var(--space-2); +} + +.panel-resizer { + position: relative; + flex: 0 0 16px; + align-self: stretch; + cursor: col-resize; + user-select: none; + touch-action: none; + background: transparent; +} + +.panel-resizer::before { + content: ""; + position: absolute; + top: 22px; + bottom: 22px; + left: 50%; + width: 1px; + transform: translateX(-50%); + background: var(--color-border); +} + +.panel-resizer__grip { + position: absolute; + top: 70px; + left: 50%; + transform: translateX(-50%); + width: 8px; + height: 64px; + border-radius: 999px; + background: + radial-gradient(circle, #b9b2a1 1.2px, transparent 1.2px) center / 8px 10px + repeat-y, + #fffdfa; + border: 1px solid var(--color-border); + box-shadow: var(--shadow-sm); +} + +.detail-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); +} + +.detail-stack { + gap: var(--space-4); +} + +.variant-card, +.image-item { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: linear-gradient(180deg, #fcfcfb 0%, #ffffff 100%); + padding: var(--space-3); +} + +.variant-card { + display: grid; + gap: var(--space-3); +} + +.variant-card__header, +.image-item__header, +.image-item__controls { + display: flex; + justify-content: space-between; + gap: var(--space-2); + align-items: flex-start; +} + +.variant-card__header p, +.image-meta { + margin: var(--space-1) 0 0; + color: var(--color-text-muted); +} + +.variant-card__actions, +.image-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + justify-content: flex-end; +} + +.locked-panel, +.loading-state, +.image-fallback { + padding: var(--space-4); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); + background: #fbfaf6; + color: var(--color-text-muted); + text-align: center; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.preview-card, +.image-item__preview { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--color-bg-card); +} + +.preview-card { + aspect-ratio: 16 / 10; +} + +.preview-card img, +.image-item__preview img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.image-item { + display: grid; + grid-template-columns: 148px minmax(0, 1fr); + gap: var(--space-3); +} + +.image-item__preview { + aspect-ratio: 1; +} + +.image-item__content { + display: grid; + gap: var(--space-3); + min-width: 0; +} + +.link-button { + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.detail-loading { + margin: 0; + color: var(--color-text-muted); +} + +@media (max-width: 1480px) { + .category-manager__body { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1180px) { + .section-header { + flex-direction: column; + align-items: stretch; + } + + .list-toolbar, + .ui-form-grid--two { + grid-template-columns: 1fr; + } + + .image-item { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1060px) { + .resizable-workspace { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); + } + + .panel-resizer { + display: none; + } +} + +@media (max-width: 900px) { + .detail-header, + .panel-heading, + .category-manager__header, + .variant-card__header, + .image-item__header, + .image-item__controls { + flex-direction: column; + } + + .input-with-action { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/app/features/admin/pages/admin-shop.component.ts b/frontend/src/app/features/admin/pages/admin-shop.component.ts new file mode 100644 index 0000000..7b1273b --- /dev/null +++ b/frontend/src/app/features/admin/pages/admin-shop.component.ts @@ -0,0 +1,1466 @@ +import { CommonModule } from '@angular/common'; +import { + Component, + ElementRef, + HostListener, + OnDestroy, + OnInit, + ViewChild, + inject, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { forkJoin } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { + AdminShopCategory, + AdminShopProduct, + AdminShopProductModel, + AdminShopProductVariant, + AdminShopService, + AdminUpsertShopCategoryPayload, + AdminUpsertShopProductPayload, + AdminUpsertShopProductVariantPayload, + AdminPublicMediaUsage, +} from '../services/admin-shop.service'; +import { + AdminMediaLanguage, + AdminMediaTranslation, +} from '../services/admin-media.service'; +import { environment } from '../../../../environments/environment'; + +type ShopLanguage = 'it' | 'en' | 'de' | 'fr'; +type ProductMode = 'create' | 'edit'; +type ProductStatusFilter = 'ALL' | 'ACTIVE' | 'INACTIVE' | 'FEATURED'; + +interface CategoryFormState { + id: string | null; + parentCategoryId: string | null; + slug: string; + name: string; + description: string; + seoTitle: string; + seoDescription: string; + ogTitle: string; + ogDescription: string; + indexable: boolean; + isActive: boolean; + sortOrder: number; +} + +interface ProductVariantFormState { + id: string | null; + sku: string; + variantLabel: string; + colorName: string; + colorHex: string; + internalMaterialCode: string; + priceChf: string; + isDefault: boolean; + isActive: boolean; + sortOrder: number; +} + +interface ProductFormState { + categoryId: string; + slug: string; + names: Record; + excerpts: Record; + descriptions: Record; + seoTitle: string; + seoDescription: string; + ogTitle: string; + ogDescription: string; + indexable: boolean; + isFeatured: boolean; + isActive: boolean; + sortOrder: number; + variants: ProductVariantFormState[]; +} + +interface ProductImageItem { + usageId: string; + mediaAssetId: string; + previewUrl: string | null; + sortOrder: number; + draftSortOrder: number; + isPrimary: boolean; + createdAt: string; + translations: Record; + title: string; + altText: string; +} + +interface ProductImageUploadState { + file: File | null; + previewUrl: string | null; + activeLanguage: AdminMediaLanguage; + translations: Record; + sortOrder: number; + isPrimary: boolean; + saving: boolean; +} + +const SHOP_LANGUAGES: readonly ShopLanguage[] = ['it', 'en', 'de', 'fr']; +const MEDIA_LANGUAGES: readonly AdminMediaLanguage[] = ['it', 'en', 'de', 'fr']; +const LANGUAGE_LABELS: Readonly> = { + it: 'IT', + en: 'EN', + de: 'DE', + fr: 'FR', +}; +const PRODUCT_STATUS_FILTERS: readonly ProductStatusFilter[] = [ + 'ALL', + 'ACTIVE', + 'INACTIVE', + 'FEATURED', +]; +const MAX_MODEL_FILE_SIZE_BYTES = 100 * 1024 * 1024; +const SHOP_LIST_PANEL_WIDTH_STORAGE_KEY = 'admin-shop-list-panel-width'; +const MIN_LIST_PANEL_WIDTH_PERCENT = 32; +const MAX_LIST_PANEL_WIDTH_PERCENT = 68; + +@Component({ + selector: 'app-admin-shop', + standalone: true, + imports: [CommonModule, FormsModule], + templateUrl: './admin-shop.component.html', + styleUrl: './admin-shop.component.scss', +}) +export class AdminShopComponent implements OnInit, OnDestroy { + private readonly adminShopService = inject(AdminShopService); + @ViewChild('workspaceRef') + private readonly workspaceRef?: ElementRef; + + readonly shopLanguages = SHOP_LANGUAGES; + readonly mediaLanguages = MEDIA_LANGUAGES; + readonly languageLabels = LANGUAGE_LABELS; + readonly productStatusFilters = PRODUCT_STATUS_FILTERS; + readonly maxModelFileSizeMb = Math.round( + MAX_MODEL_FILE_SIZE_BYTES / (1024 * 1024), + ); + + listPanelWidthPercent = 53; + categories: AdminShopCategory[] = []; + products: AdminShopProduct[] = []; + filteredProducts: AdminShopProduct[] = []; + selectedProduct: AdminShopProduct | null = null; + selectedProductId: string | null = null; + productImages: ProductImageItem[] = []; + + loading = false; + detailLoading = false; + savingProduct = false; + deletingProduct = false; + savingCategory = false; + deletingCategory = false; + uploadingModel = false; + deletingModel = false; + imageActionIds = new Set(); + isResizingPanels = false; + + productMode: ProductMode = 'create'; + productSearchTerm = ''; + categoryFilter = 'ALL'; + productStatusFilter: ProductStatusFilter = 'ALL'; + showCategoryManager = false; + activeContentLanguage: ShopLanguage = 'it'; + + errorMessage: string | null = null; + successMessage: string | null = null; + + readonly categoryForm: CategoryFormState = this.createEmptyCategoryForm(); + readonly productForm: ProductFormState = this.createEmptyProductForm(); + imageUploadState: ProductImageUploadState = this.createEmptyImageUploadState(); + modelUploadFile: File | null = null; + + ngOnInit(): void { + this.restoreListPanelWidth(); + this.loadWorkspace(); + } + + ngOnDestroy(): void { + this.revokeImagePreviewUrl(this.imageUploadState.previewUrl); + document.body.style.removeProperty('cursor'); + } + + @HostListener('window:pointermove', ['$event']) + onWindowPointerMove(event: PointerEvent): void { + if (!this.isResizingPanels) { + return; + } + this.updateListPanelWidthFromPointer(event.clientX); + } + + @HostListener('window:pointerup') + @HostListener('window:pointercancel') + onWindowPointerUp(): void { + if (!this.isResizingPanels) { + return; + } + this.isResizingPanels = false; + document.body.style.cursor = ''; + this.persistListPanelWidth(); + } + + loadWorkspace(preferredProductId?: string): void { + this.loading = true; + this.errorMessage = null; + + forkJoin({ + categories: this.adminShopService.getCategories(), + products: this.adminShopService.getProducts(), + }).subscribe({ + next: ({ categories, products }) => { + this.categories = categories; + this.products = products; + this.applyProductFilters(); + this.ensureCategoryFilterStillValid(); + this.loading = false; + + const targetProductId = + preferredProductId ?? + (this.productMode === 'edit' ? this.selectedProductId : null); + if (targetProductId && products.some((product) => product.id === targetProductId)) { + this.openProduct(targetProductId); + return; + } + + if (this.productMode === 'create') { + this.selectedProduct = null; + this.selectedProductId = null; + this.productImages = []; + return; + } + + if (this.filteredProducts.length > 0) { + this.openProduct(this.filteredProducts[0].id); + } else if (this.products.length === 0) { + this.startCreateProduct(); + } else { + this.selectedProduct = null; + this.selectedProductId = null; + this.productImages = []; + } + }, + error: (error) => { + this.loading = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Impossibile caricare il back-office shop.', + ); + }, + }); + } + + openProduct(productId: string): void { + this.productMode = 'edit'; + this.selectedProductId = productId; + this.detailLoading = true; + this.errorMessage = null; + + this.adminShopService.getProduct(productId).subscribe({ + next: (product) => { + this.selectedProduct = product; + this.productImages = this.buildProductImages(product); + this.loadProductIntoForm(product); + this.resetImageUploadState(product); + this.modelUploadFile = null; + this.detailLoading = false; + }, + error: (error) => { + this.detailLoading = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Impossibile caricare il dettaglio prodotto.', + ); + }, + }); + } + + startCreateProduct(): void { + this.productMode = 'create'; + this.selectedProduct = null; + this.selectedProductId = null; + this.productImages = []; + this.modelUploadFile = null; + this.activeContentLanguage = 'it'; + this.resetProductForm(); + this.resetImageUploadState(null); + } + + saveProduct(): void { + if (this.savingProduct) { + return; + } + + const validationError = this.validateProductForm(); + if (validationError) { + this.errorMessage = validationError; + this.successMessage = null; + return; + } + + const payload = this.buildProductPayload(); + this.savingProduct = true; + this.errorMessage = null; + this.successMessage = null; + + const request = + this.productMode === 'create' || !this.selectedProductId + ? this.adminShopService.createProduct(payload) + : this.adminShopService.updateProduct(this.selectedProductId, payload); + + request.subscribe({ + next: (product) => { + this.savingProduct = false; + this.productMode = 'edit'; + this.selectedProductId = product.id; + this.successMessage = + this.selectedProduct != null + ? 'Prodotto aggiornato.' + : 'Prodotto creato.'; + this.loadWorkspace(product.id); + }, + error: (error) => { + this.savingProduct = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Salvataggio prodotto non riuscito.', + ); + }, + }); + } + + deleteSelectedProduct(): void { + if (!this.selectedProductId || this.deletingProduct) { + return; + } + + if ( + !window.confirm( + "Eliminare questo prodotto? L'azione non puo essere annullata.", + ) + ) { + return; + } + + this.deletingProduct = true; + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService.deleteProduct(this.selectedProductId).subscribe({ + next: () => { + this.deletingProduct = false; + this.successMessage = 'Prodotto eliminato.'; + this.startCreateProduct(); + this.loadWorkspace(); + }, + error: (error) => { + this.deletingProduct = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Eliminazione prodotto non riuscita.', + ); + }, + }); + } + + onProductSearchChange(value: string): void { + this.productSearchTerm = value; + this.applyProductFilters(); + } + + onCategoryFilterChange(value: string): void { + this.categoryFilter = value || 'ALL'; + this.applyProductFilters(); + } + + onProductStatusFilterChange(value: string): void { + this.productStatusFilter = (value || 'ALL') as ProductStatusFilter; + this.applyProductFilters(); + } + + startPanelResize(event: PointerEvent): void { + if (window.innerWidth <= 1060) { + return; + } + event.preventDefault(); + this.isResizingPanels = true; + document.body.style.cursor = 'col-resize'; + this.updateListPanelWidthFromPointer(event.clientX); + } + + isSelectedProduct(productId: string): boolean { + return this.selectedProductId === productId; + } + + visibleProductCountForCategory(categoryId: string): number { + return this.products.filter((product) => product.categoryId === categoryId) + .length; + } + + categoryOptionLabel(category: AdminShopCategory): string { + return `${' '.repeat(Math.max(0, category.depth || 0))}${category.name}`; + } + + toggleCategoryManager(): void { + this.showCategoryManager = !this.showCategoryManager; + if (this.showCategoryManager && !this.categoryForm.id) { + this.resetCategoryForm(); + } + } + + editCategory(categoryId: string): void { + this.showCategoryManager = true; + this.errorMessage = null; + this.adminShopService.getCategory(categoryId).subscribe({ + next: (category) => { + this.loadCategoryIntoForm(category); + }, + error: (error) => { + this.errorMessage = this.extractErrorMessage( + error, + 'Impossibile caricare la categoria.', + ); + }, + }); + } + + prepareCreateCategory(): void { + this.resetCategoryForm(); + } + + saveCategory(): void { + if (this.savingCategory) { + return; + } + + const validationError = this.validateCategoryForm(); + if (validationError) { + this.errorMessage = validationError; + this.successMessage = null; + return; + } + + const payload = this.buildCategoryPayload(); + this.savingCategory = true; + this.errorMessage = null; + this.successMessage = null; + + const request = this.categoryForm.id + ? this.adminShopService.updateCategory(this.categoryForm.id, payload) + : this.adminShopService.createCategory(payload); + + request.subscribe({ + next: (category) => { + this.savingCategory = false; + this.successMessage = this.categoryForm.id + ? 'Categoria aggiornata.' + : 'Categoria creata.'; + this.loadCategoryIntoForm(category); + this.loadWorkspace(this.selectedProductId ?? undefined); + }, + error: (error) => { + this.savingCategory = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Salvataggio categoria non riuscito.', + ); + }, + }); + } + + deleteCategory(): void { + if (!this.categoryForm.id || this.deletingCategory) { + return; + } + + if ( + !window.confirm( + 'Eliminare questa categoria? Fallira se contiene sottocategorie o prodotti.', + ) + ) { + return; + } + + this.deletingCategory = true; + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService.deleteCategory(this.categoryForm.id).subscribe({ + next: () => { + this.deletingCategory = false; + this.successMessage = 'Categoria eliminata.'; + this.resetCategoryForm(); + this.loadWorkspace(this.selectedProductId ?? undefined); + }, + error: (error) => { + this.deletingCategory = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Eliminazione categoria non riuscita.', + ); + }, + }); + } + + slugifyProductFromCurrentLanguage(): void { + const source = + this.productForm.names[this.activeContentLanguage] || + this.productForm.names['it']; + this.productForm.slug = this.slugify(source); + } + + slugifyCategoryFromName(): void { + this.categoryForm.slug = this.slugify(this.categoryForm.name); + } + + setActiveContentLanguage(language: ShopLanguage): void { + this.activeContentLanguage = language; + } + + isContentLanguageComplete(language: ShopLanguage): boolean { + return !!this.productForm.names[language].trim(); + } + + addVariant(): void { + const sortOrder = + (this.productForm.variants.at(-1)?.sortOrder ?? -1) + 1; + const firstVariant = this.productForm.variants.length === 0; + this.productForm.variants = [ + ...this.productForm.variants, + this.createEmptyVariantForm(sortOrder, firstVariant), + ]; + } + + removeVariant(index: number): void { + if (this.productForm.variants.length <= 1) { + return; + } + + const nextVariants = this.productForm.variants.filter( + (_, currentIndex) => currentIndex !== index, + ); + if (!nextVariants.some((variant) => variant.isDefault)) { + nextVariants[0].isDefault = true; + } + this.productForm.variants = nextVariants; + } + + setDefaultVariant(index: number): void { + this.productForm.variants = this.productForm.variants.map( + (variant, currentIndex) => ({ + ...variant, + isDefault: currentIndex === index, + }), + ); + } + + onColorHexBlur(variant: ProductVariantFormState): void { + if (!variant.colorHex.trim()) { + return; + } + variant.colorHex = variant.colorHex.trim().toUpperCase(); + } + + onModelFileSelected(event: Event): void { + const input = event.target as HTMLInputElement | null; + const file = input?.files?.[0] ?? null; + if (!file) { + this.modelUploadFile = null; + return; + } + + const extension = this.resolveFileExtension(file.name); + if (!['stl', '3mf'].includes(extension)) { + this.modelUploadFile = null; + this.errorMessage = 'Sono ammessi solo file STL o 3MF.'; + return; + } + if (file.size > MAX_MODEL_FILE_SIZE_BYTES) { + this.modelUploadFile = null; + this.errorMessage = `Il modello supera il limite di ${this.maxModelFileSizeMb} MB.`; + return; + } + + this.modelUploadFile = file; + } + + uploadModel(): void { + if ( + !this.selectedProductId || + !this.modelUploadFile || + this.uploadingModel || + this.productMode !== 'edit' + ) { + return; + } + + this.uploadingModel = true; + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService + .uploadProductModel(this.selectedProductId, this.modelUploadFile) + .subscribe({ + next: (product) => { + this.uploadingModel = false; + this.modelUploadFile = null; + this.successMessage = 'Modello 3D aggiornato.'; + this.updateSelectedProduct(product); + this.loadWorkspace(product.id); + }, + error: (error) => { + this.uploadingModel = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Upload modello 3D non riuscito.', + ); + }, + }); + } + + deleteModel(): void { + if (!this.selectedProductId || this.deletingModel || !this.selectedProduct?.model3d) { + return; + } + + if (!window.confirm('Rimuovere il modello 3D associato a questo prodotto?')) { + return; + } + + this.deletingModel = true; + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService.deleteProductModel(this.selectedProductId).subscribe({ + next: () => { + this.deletingModel = false; + this.modelUploadFile = null; + this.successMessage = 'Modello 3D rimosso.'; + this.loadWorkspace(this.selectedProductId ?? undefined); + }, + error: (error) => { + this.deletingModel = false; + this.errorMessage = this.extractErrorMessage( + error, + 'Rimozione modello 3D non riuscita.', + ); + }, + }); + } + + getProductModelUrl(model: AdminShopProductModel | null): string | null { + if (!model?.url) { + return null; + } + return `${environment.apiUrl}${model.url}`; + } + + onImageFileSelected(event: Event): void { + const input = event.target as HTMLInputElement | null; + const file = input?.files?.[0] ?? null; + const previousPreviewUrl = this.imageUploadState.previewUrl; + this.revokeImagePreviewUrl(previousPreviewUrl); + + if (!file) { + this.imageUploadState = { + ...this.imageUploadState, + file: null, + previewUrl: null, + }; + return; + } + + if (!this.isAllowedImageType(file.type, file.name)) { + this.imageUploadState = { + ...this.imageUploadState, + file: null, + previewUrl: null, + }; + this.errorMessage = + 'Sono ammesse immagini JPG, PNG o WEBP per il catalogo.'; + return; + } + + const nextTranslations = this.cloneTranslations( + this.imageUploadState.translations, + ); + if (this.areAllTitlesBlank(nextTranslations)) { + const defaultTitle = this.deriveDefaultTitle(file.name); + for (const language of this.mediaLanguages) { + nextTranslations[language].title = defaultTitle; + } + } + + this.imageUploadState = { + ...this.imageUploadState, + file, + previewUrl: URL.createObjectURL(file), + translations: nextTranslations, + }; + } + + setActiveImageLanguage(language: AdminMediaLanguage): void { + this.imageUploadState = { + ...this.imageUploadState, + activeLanguage: language, + }; + } + + getActiveImageTranslation(): AdminMediaTranslation { + return this.imageUploadState.translations[this.imageUploadState.activeLanguage]; + } + + isImageLanguageComplete(language: AdminMediaLanguage): boolean { + return this.isTranslationComplete(this.imageUploadState.translations[language]); + } + + uploadProductImage(): void { + if ( + !this.selectedProduct || + !this.selectedProductId || + !this.imageUploadState.file || + this.imageUploadState.saving + ) { + return; + } + + const validationError = this.validateImageTranslations( + this.imageUploadState.translations, + ); + if (validationError) { + this.errorMessage = validationError; + this.successMessage = null; + return; + } + + const normalizedTranslations = this.normalizeTranslations( + this.imageUploadState.translations, + ); + const currentProductId = this.selectedProductId; + const uploadFile = this.imageUploadState.file; + const selectedProduct = this.selectedProduct; + + if (!uploadFile || !selectedProduct) { + return; + } + + this.imageUploadState = { + ...this.imageUploadState, + saving: true, + }; + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService + .uploadMediaAsset(uploadFile, { + title: normalizedTranslations['it'].title, + altText: normalizedTranslations['it'].altText, + visibility: 'PUBLIC', + }) + .pipe( + switchMap((asset) => + this.adminShopService.createMediaUsage({ + usageType: selectedProduct.mediaUsageType, + usageKey: selectedProduct.mediaUsageKey, + mediaAssetId: asset.id, + sortOrder: this.imageUploadState.sortOrder, + isPrimary: this.imageUploadState.isPrimary, + isActive: true, + translations: normalizedTranslations, + }), + ), + ) + .subscribe({ + next: () => { + this.imageUploadState = { + ...this.imageUploadState, + saving: false, + }; + this.successMessage = 'Immagine prodotto caricata.'; + this.loadWorkspace(currentProductId); + }, + error: (error) => { + this.imageUploadState = { + ...this.imageUploadState, + saving: false, + }; + this.errorMessage = this.extractErrorMessage( + error, + 'Upload immagine prodotto non riuscito.', + ); + }, + }); + } + + saveImageSortOrder(item: ProductImageItem): void { + if ( + this.imageActionIds.has(item.usageId) || + item.draftSortOrder === item.sortOrder + ) { + return; + } + + this.imageActionIds.add(item.usageId); + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService + .updateMediaUsage(item.usageId, { sortOrder: item.draftSortOrder }) + .subscribe({ + next: () => { + this.imageActionIds.delete(item.usageId); + this.successMessage = 'Ordine immagini aggiornato.'; + this.loadWorkspace(this.selectedProductId ?? undefined); + }, + error: (error) => { + this.imageActionIds.delete(item.usageId); + this.errorMessage = this.extractErrorMessage( + error, + 'Aggiornamento ordine immagini non riuscito.', + ); + }, + }); + } + + setPrimaryImage(item: ProductImageItem): void { + if (item.isPrimary || this.imageActionIds.has(item.usageId)) { + return; + } + + this.imageActionIds.add(item.usageId); + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService + .updateMediaUsage(item.usageId, { isPrimary: true, isActive: true }) + .subscribe({ + next: () => { + this.imageActionIds.delete(item.usageId); + this.successMessage = 'Immagine principale aggiornata.'; + this.loadWorkspace(this.selectedProductId ?? undefined); + }, + error: (error) => { + this.imageActionIds.delete(item.usageId); + this.errorMessage = this.extractErrorMessage( + error, + 'Aggiornamento immagine principale non riuscito.', + ); + }, + }); + } + + removeImage(item: ProductImageItem): void { + if (this.imageActionIds.has(item.usageId)) { + return; + } + + if (!window.confirm('Rimuovere questa immagine dal prodotto?')) { + return; + } + + this.imageActionIds.add(item.usageId); + this.errorMessage = null; + this.successMessage = null; + + this.adminShopService + .updateMediaUsage(item.usageId, { isActive: false, isPrimary: false }) + .subscribe({ + next: () => { + this.imageActionIds.delete(item.usageId); + this.successMessage = 'Immagine rimossa dal prodotto.'; + this.loadWorkspace(this.selectedProductId ?? undefined); + }, + error: (error) => { + this.imageActionIds.delete(item.usageId); + this.errorMessage = this.extractErrorMessage( + error, + 'Rimozione immagine non riuscita.', + ); + }, + }); + } + + isImageBusy(usageId: string): boolean { + return this.imageActionIds.has(usageId); + } + + trackCategory(_: number, category: AdminShopCategory): string { + return category.id; + } + + trackProduct(_: number, product: AdminShopProduct): string { + return product.id; + } + + trackVariant(_: number, variant: ProductVariantFormState): string { + return variant.id ?? `${variant.colorName}-${variant.sortOrder}`; + } + + trackImage(_: number, image: ProductImageItem): string { + return image.usageId; + } + + formatFileSize(bytes: number | null | undefined): string { + if (!bytes || bytes <= 0) { + return '-'; + } + const units = ['B', 'KB', 'MB', 'GB']; + let value = bytes; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; + } + + productStatusChipClass(product: AdminShopProduct): string { + if (!product.isActive) { + return 'ui-status-chip--danger'; + } + if (product.isFeatured) { + return 'ui-status-chip--success'; + } + return 'ui-status-chip--neutral'; + } + + private applyProductFilters(): void { + const searchNeedle = this.productSearchTerm.trim().toLowerCase(); + this.filteredProducts = this.products.filter((product) => { + const matchesCategory = + this.categoryFilter === 'ALL' || product.categoryId === this.categoryFilter; + const matchesStatus = + this.productStatusFilter === 'ALL' || + (this.productStatusFilter === 'ACTIVE' && product.isActive) || + (this.productStatusFilter === 'INACTIVE' && !product.isActive) || + (this.productStatusFilter === 'FEATURED' && product.isFeatured); + const matchesSearch = + searchNeedle.length === 0 || + [ + product.name, + product.slug, + product.categoryName, + ...product.variants.map((variant) => variant.colorName), + ...product.variants.map((variant) => variant.internalMaterialCode), + ] + .filter(Boolean) + .some((value) => value.toLowerCase().includes(searchNeedle)); + return matchesCategory && matchesStatus && matchesSearch; + }); + } + + private updateListPanelWidthFromPointer(clientX: number): void { + const workspace = this.workspaceRef?.nativeElement; + if (!workspace) { + return; + } + const bounds = workspace.getBoundingClientRect(); + if (bounds.width <= 0) { + return; + } + + const relativeX = clientX - bounds.left; + const nextPercent = (relativeX / bounds.width) * 100; + this.listPanelWidthPercent = this.clampListPanelWidth(nextPercent); + } + + private restoreListPanelWidth(): void { + const storedValue = window.localStorage.getItem( + SHOP_LIST_PANEL_WIDTH_STORAGE_KEY, + ); + if (!storedValue) { + return; + } + const parsed = Number(storedValue); + if (!Number.isFinite(parsed)) { + return; + } + this.listPanelWidthPercent = this.clampListPanelWidth(parsed); + } + + private persistListPanelWidth(): void { + window.localStorage.setItem( + SHOP_LIST_PANEL_WIDTH_STORAGE_KEY, + String(this.listPanelWidthPercent), + ); + } + + private clampListPanelWidth(value: number): number { + return Math.min( + MAX_LIST_PANEL_WIDTH_PERCENT, + Math.max(MIN_LIST_PANEL_WIDTH_PERCENT, value), + ); + } + + private ensureCategoryFilterStillValid(): void { + if ( + this.categoryFilter !== 'ALL' && + !this.categories.some((category) => category.id === this.categoryFilter) + ) { + this.categoryFilter = 'ALL'; + this.applyProductFilters(); + } + } + + private createEmptyCategoryForm(): CategoryFormState { + return { + id: null, + parentCategoryId: null, + slug: '', + name: '', + description: '', + seoTitle: '', + seoDescription: '', + ogTitle: '', + ogDescription: '', + indexable: true, + isActive: true, + sortOrder: 0, + }; + } + + private resetCategoryForm(): void { + Object.assign(this.categoryForm, this.createEmptyCategoryForm()); + } + + private loadCategoryIntoForm(category: AdminShopCategory): void { + Object.assign(this.categoryForm, { + id: category.id, + parentCategoryId: category.parentCategoryId, + slug: category.slug ?? '', + name: category.name ?? '', + description: category.description ?? '', + seoTitle: category.seoTitle ?? '', + seoDescription: category.seoDescription ?? '', + ogTitle: category.ogTitle ?? '', + ogDescription: category.ogDescription ?? '', + indexable: category.indexable, + isActive: category.isActive, + sortOrder: category.sortOrder ?? 0, + }); + } + + private buildCategoryPayload(): AdminUpsertShopCategoryPayload { + return { + parentCategoryId: this.categoryForm.parentCategoryId || null, + slug: this.categoryForm.slug.trim(), + name: this.categoryForm.name.trim(), + description: this.categoryForm.description.trim(), + seoTitle: this.categoryForm.seoTitle.trim(), + seoDescription: this.categoryForm.seoDescription.trim(), + ogTitle: this.categoryForm.ogTitle.trim(), + ogDescription: this.categoryForm.ogDescription.trim(), + indexable: this.categoryForm.indexable, + isActive: this.categoryForm.isActive, + sortOrder: Number(this.categoryForm.sortOrder) || 0, + }; + } + + private validateCategoryForm(): string | null { + if (!this.categoryForm.name.trim()) { + return 'Il nome categoria è obbligatorio.'; + } + if (!this.categoryForm.slug.trim()) { + return 'Lo slug categoria è obbligatorio.'; + } + return null; + } + + private createEmptyProductForm(): ProductFormState { + const defaultCategoryId = + this.categoryFilter !== 'ALL' + ? this.categoryFilter + : (this.categories[0]?.id ?? ''); + return { + categoryId: defaultCategoryId, + slug: '', + names: { + it: '', + en: '', + de: '', + fr: '', + }, + excerpts: { + it: '', + en: '', + de: '', + fr: '', + }, + descriptions: { + it: '', + en: '', + de: '', + fr: '', + }, + seoTitle: '', + seoDescription: '', + ogTitle: '', + ogDescription: '', + indexable: true, + isFeatured: false, + isActive: true, + sortOrder: 0, + variants: [this.createEmptyVariantForm(0, true)], + }; + } + + private resetProductForm(): void { + Object.assign(this.productForm, this.createEmptyProductForm()); + } + + private createEmptyVariantForm( + sortOrder: number, + isDefault: boolean, + ): ProductVariantFormState { + return { + id: null, + sku: '', + variantLabel: '', + colorName: '', + colorHex: '', + internalMaterialCode: '', + priceChf: '0.00', + isDefault, + isActive: true, + sortOrder, + }; + } + + private loadProductIntoForm(product: AdminShopProduct): void { + Object.assign(this.productForm, { + categoryId: product.categoryId ?? '', + slug: product.slug ?? '', + names: { + it: product.nameIt ?? '', + en: product.nameEn ?? '', + de: product.nameDe ?? '', + fr: product.nameFr ?? '', + }, + excerpts: { + it: product.excerptIt ?? '', + en: product.excerptEn ?? '', + de: product.excerptDe ?? '', + fr: product.excerptFr ?? '', + }, + descriptions: { + it: product.descriptionIt ?? '', + en: product.descriptionEn ?? '', + de: product.descriptionDe ?? '', + fr: product.descriptionFr ?? '', + }, + seoTitle: product.seoTitle ?? '', + seoDescription: product.seoDescription ?? '', + ogTitle: product.ogTitle ?? '', + ogDescription: product.ogDescription ?? '', + indexable: product.indexable, + isFeatured: product.isFeatured, + isActive: product.isActive, + sortOrder: product.sortOrder ?? 0, + variants: product.variants.length + ? product.variants.map((variant) => this.toVariantForm(variant)) + : [this.createEmptyVariantForm(0, true)], + }); + } + + private toVariantForm( + variant: AdminShopProductVariant, + ): ProductVariantFormState { + return { + id: variant.id, + sku: variant.sku ?? '', + variantLabel: variant.variantLabel ?? '', + colorName: variant.colorName ?? '', + colorHex: variant.colorHex ?? '', + internalMaterialCode: variant.internalMaterialCode ?? '', + priceChf: Number(variant.priceChf ?? 0).toFixed(2), + isDefault: variant.isDefault, + isActive: variant.isActive, + sortOrder: variant.sortOrder ?? 0, + }; + } + + private validateProductForm(): string | null { + if (!this.productForm.categoryId) { + return 'Seleziona una categoria per il prodotto.'; + } + if (!this.productForm.slug.trim()) { + return 'Lo slug prodotto è obbligatorio.'; + } + for (const language of this.shopLanguages) { + if (!this.productForm.names[language].trim()) { + return `Il nome prodotto ${this.languageLabels[language]} è obbligatorio.`; + } + } + if (this.productForm.variants.length === 0) { + return 'È richiesta almeno una variante.'; + } + + const colorNames = new Set(); + let defaultCount = 0; + for (const variant of this.productForm.variants) { + if (!variant.colorName.trim()) { + return 'Ogni variante richiede un nome colore.'; + } + const colorKey = variant.colorName.trim().toLowerCase(); + if (colorNames.has(colorKey)) { + return `Il colore "${variant.colorName.trim()}" è duplicato.`; + } + colorNames.add(colorKey); + if (!variant.internalMaterialCode.trim()) { + return `La variante "${variant.colorName.trim()}" richiede un codice materiale interno.`; + } + const price = Number(variant.priceChf); + if (!Number.isFinite(price) || price < 0) { + return `La variante "${variant.colorName.trim()}" ha un prezzo non valido.`; + } + if (variant.colorHex.trim() && !/^#[0-9A-Fa-f]{6}$/.test(variant.colorHex.trim())) { + return `La variante "${variant.colorName.trim()}" ha un colore HEX non valido.`; + } + if (variant.isDefault) { + defaultCount += 1; + } + } + if (defaultCount !== 1) { + return 'Devi impostare una sola variante predefinita.'; + } + + return null; + } + + private buildProductPayload(): AdminUpsertShopProductPayload { + const variants: AdminUpsertShopProductVariantPayload[] = + this.productForm.variants.map((variant) => ({ + id: variant.id ?? undefined, + sku: this.optionalValue(variant.sku), + variantLabel: this.optionalValue(variant.variantLabel), + colorName: variant.colorName.trim(), + colorHex: this.optionalValue(variant.colorHex)?.toUpperCase(), + internalMaterialCode: variant.internalMaterialCode.trim().toUpperCase(), + priceChf: Number(variant.priceChf), + isDefault: variant.isDefault, + isActive: variant.isActive, + sortOrder: Number(variant.sortOrder) || 0, + })); + + return { + categoryId: this.productForm.categoryId, + slug: this.productForm.slug.trim(), + name: this.productForm.names['it'].trim(), + nameIt: this.productForm.names['it'].trim(), + nameEn: this.productForm.names['en'].trim(), + nameDe: this.productForm.names['de'].trim(), + nameFr: this.productForm.names['fr'].trim(), + excerpt: this.optionalValue(this.productForm.excerpts['it']), + excerptIt: this.optionalValue(this.productForm.excerpts['it']), + excerptEn: this.optionalValue(this.productForm.excerpts['en']), + excerptDe: this.optionalValue(this.productForm.excerpts['de']), + excerptFr: this.optionalValue(this.productForm.excerpts['fr']), + description: this.optionalValue(this.productForm.descriptions['it']), + descriptionIt: this.optionalValue(this.productForm.descriptions['it']), + descriptionEn: this.optionalValue(this.productForm.descriptions['en']), + descriptionDe: this.optionalValue(this.productForm.descriptions['de']), + descriptionFr: this.optionalValue(this.productForm.descriptions['fr']), + seoTitle: this.optionalValue(this.productForm.seoTitle), + seoDescription: this.optionalValue(this.productForm.seoDescription), + ogTitle: this.optionalValue(this.productForm.ogTitle), + ogDescription: this.optionalValue(this.productForm.ogDescription), + indexable: this.productForm.indexable, + isFeatured: this.productForm.isFeatured, + isActive: this.productForm.isActive, + sortOrder: Number(this.productForm.sortOrder) || 0, + variants, + }; + } + + private updateSelectedProduct(product: AdminShopProduct): void { + this.selectedProduct = product; + this.selectedProductId = product.id; + this.productImages = this.buildProductImages(product); + this.loadProductIntoForm(product); + this.resetImageUploadState(product); + } + + private buildProductImages(product: AdminShopProduct): ProductImageItem[] { + const publicByAssetId = new Map(); + for (const image of product.images) { + publicByAssetId.set(image.mediaAssetId, image); + } + + return product.mediaUsages + .filter((usage) => usage.isActive) + .map((usage) => { + const publicUsage = publicByAssetId.get(usage.mediaAssetId); + const translations = this.normalizeTranslations(usage.translations); + return { + usageId: usage.id, + mediaAssetId: usage.mediaAssetId, + previewUrl: this.resolveProductImageUrl(publicUsage), + sortOrder: usage.sortOrder ?? 0, + draftSortOrder: usage.sortOrder ?? 0, + isPrimary: usage.isPrimary, + createdAt: usage.createdAt, + translations, + title: + publicUsage?.title ?? + translations[this.imageUploadState.activeLanguage].title, + altText: + publicUsage?.altText ?? + translations[this.imageUploadState.activeLanguage].altText, + }; + }) + .sort((left, right) => { + if (left.sortOrder !== right.sortOrder) { + return left.sortOrder - right.sortOrder; + } + return left.createdAt.localeCompare(right.createdAt); + }); + } + + private resolveProductImageUrl( + image: AdminPublicMediaUsage | undefined, + ): string | null { + if (!image) { + return null; + } + return image.card?.url ?? image.hero?.url ?? image.thumb?.url ?? null; + } + + private createEmptyImageUploadState(): ProductImageUploadState { + return { + file: null, + previewUrl: null, + activeLanguage: 'it', + translations: this.createEmptyTranslations(), + sortOrder: 0, + isPrimary: false, + saving: false, + }; + } + + private resetImageUploadState(product: AdminShopProduct | null): void { + this.revokeImagePreviewUrl(this.imageUploadState.previewUrl); + const nextSortOrder = (this.productImages.at(-1)?.sortOrder ?? -1) + 1; + this.imageUploadState = { + file: null, + previewUrl: null, + activeLanguage: 'it', + translations: this.createEmptyTranslations(), + sortOrder: Math.max(0, nextSortOrder), + isPrimary: (product?.images.length ?? 0) === 0, + saving: false, + }; + } + + private revokeImagePreviewUrl(previewUrl: string | null): void { + if (previewUrl?.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + } + + private createEmptyTranslations(): Record< + AdminMediaLanguage, + AdminMediaTranslation + > { + return { + it: { title: '', altText: '' }, + en: { title: '', altText: '' }, + de: { title: '', altText: '' }, + fr: { title: '', altText: '' }, + }; + } + + private cloneTranslations( + translations: Record, + ): Record { + return this.normalizeTranslations(translations); + } + + private normalizeTranslations( + translations: Partial< + Record> + >, + ): Record { + return { + it: { + title: translations['it']?.title?.trim() ?? '', + altText: translations['it']?.altText?.trim() ?? '', + }, + en: { + title: translations['en']?.title?.trim() ?? '', + altText: translations['en']?.altText?.trim() ?? '', + }, + de: { + title: translations['de']?.title?.trim() ?? '', + altText: translations['de']?.altText?.trim() ?? '', + }, + fr: { + title: translations['fr']?.title?.trim() ?? '', + altText: translations['fr']?.altText?.trim() ?? '', + }, + }; + } + + private isTranslationComplete(translation: AdminMediaTranslation): boolean { + return !!translation.title.trim() && !!translation.altText.trim(); + } + + private validateImageTranslations( + translations: Record, + ): string | null { + for (const language of this.mediaLanguages) { + if (!this.isTranslationComplete(translations[language])) { + return `Titolo e alt text immagine ${this.languageLabels[language]} sono obbligatori.`; + } + } + return null; + } + + private areAllTitlesBlank( + translations: Record, + ): boolean { + return this.mediaLanguages.every( + (language) => !translations[language].title.trim(), + ); + } + + private deriveDefaultTitle(filename: string): string { + return filename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim(); + } + + private optionalValue(value: string): string | undefined { + const normalized = value.trim(); + return normalized ? normalized : undefined; + } + + private slugify(source: string): string { + return source + .normalize('NFD') + .replace(/\p{Diacritic}+/gu, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + } + + private resolveFileExtension(filename: string): string { + const lastDotIndex = filename.lastIndexOf('.'); + return lastDotIndex >= 0 ? filename.slice(lastDotIndex + 1).toLowerCase() : ''; + } + + private isAllowedImageType(mimeType: string, filename: string): boolean { + if (['image/jpeg', 'image/png', 'image/webp'].includes(mimeType)) { + return true; + } + const extension = this.resolveFileExtension(filename); + return ['jpg', 'jpeg', 'png', 'webp'].includes(extension); + } + + private extractErrorMessage(error: unknown, fallback: string): string { + const candidate = error as { + error?: { message?: string }; + message?: string; + }; + return candidate?.error?.message || candidate?.message || fallback; + } +} diff --git a/frontend/src/app/features/admin/services/admin-shop.service.ts b/frontend/src/app/features/admin/services/admin-shop.service.ts new file mode 100644 index 0000000..f91b580 --- /dev/null +++ b/frontend/src/app/features/admin/services/admin-shop.service.ts @@ -0,0 +1,334 @@ +import { inject, Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import { + AdminCreateMediaUsagePayload, + AdminMediaLanguage, + AdminMediaService, + AdminMediaTranslation, + AdminMediaUsage, + AdminMediaUploadPayload, + AdminMediaAsset, + AdminUpdateMediaUsagePayload, +} from './admin-media.service'; + +export interface AdminMediaTextTranslation { + title: string; + altText: string; +} + +export interface AdminShopCategoryRef { + id: string; + slug: string; + name: string; +} + +export interface AdminShopCategory { + id: string; + parentCategoryId: string | null; + parentCategoryName: string | null; + slug: string; + name: string; + description: string | null; + seoTitle: string | null; + seoDescription: string | null; + ogTitle: string | null; + ogDescription: string | null; + indexable: boolean; + isActive: boolean; + sortOrder: number; + depth: number; + childCount: number; + directProductCount: number; + descendantProductCount: number; + mediaUsageType: string; + mediaUsageKey: string; + breadcrumbs: AdminShopCategoryRef[]; + children: AdminShopCategory[]; + createdAt: string; + updatedAt: string; +} + +export interface AdminUpsertShopCategoryPayload { + parentCategoryId?: string | null; + slug: string; + name: string; + description?: string; + seoTitle?: string; + seoDescription?: string; + ogTitle?: string; + ogDescription?: string; + indexable: boolean; + isActive: boolean; + sortOrder: number; +} + +export interface AdminShopProductVariant { + id: string; + sku: string | null; + variantLabel: string; + colorName: string; + colorHex: string | null; + internalMaterialCode: string; + priceChf: number; + isDefault: boolean; + isActive: boolean; + sortOrder: number; + createdAt: string; + updatedAt: string; +} + +export interface AdminShopProductModel { + url: string; + originalFilename: string; + mimeType: string; + fileSizeBytes: number; + boundingBoxXMm: number | null; + boundingBoxYMm: number | null; + boundingBoxZMm: number | null; +} + +export interface AdminPublicMediaVariant { + url: string; + widthPx: number | null; + heightPx: number | null; + mimeType: string | null; +} + +export interface AdminPublicMediaUsage { + mediaAssetId: string; + title: string | null; + altText: string | null; + usageType: string; + usageKey: string; + sortOrder: number; + isPrimary: boolean; + thumb: AdminPublicMediaVariant | null; + card: AdminPublicMediaVariant | null; + hero: AdminPublicMediaVariant | null; +} + +export interface AdminShopProduct { + id: string; + categoryId: string; + categoryName: string; + categorySlug: string; + slug: string; + name: string; + nameIt: string; + nameEn: string; + nameDe: string; + nameFr: string; + excerpt: string | null; + excerptIt: string | null; + excerptEn: string | null; + excerptDe: string | null; + excerptFr: string | null; + description: string | null; + descriptionIt: string | null; + descriptionEn: string | null; + descriptionDe: string | null; + descriptionFr: string | null; + seoTitle: string | null; + seoDescription: string | null; + ogTitle: string | null; + ogDescription: string | null; + indexable: boolean; + isFeatured: boolean; + isActive: boolean; + sortOrder: number; + variantCount: number; + activeVariantCount: number; + priceFromChf: number; + priceToChf: number; + mediaUsageType: string; + mediaUsageKey: string; + mediaUsages: AdminShopMediaUsage[]; + images: AdminPublicMediaUsage[]; + model3d: AdminShopProductModel | null; + variants: AdminShopProductVariant[]; + createdAt: string; + updatedAt: string; +} + +export interface AdminShopMediaUsage extends Omit { + translations: Record; +} + +export interface AdminUpsertShopProductVariantPayload { + id?: string; + sku?: string; + variantLabel?: string; + colorName: string; + colorHex?: string; + internalMaterialCode: string; + priceChf: number; + isDefault: boolean; + isActive: boolean; + sortOrder: number; +} + +export interface AdminUpsertShopProductPayload { + categoryId: string; + slug: string; + name: string; + nameIt: string; + nameEn: string; + nameDe: string; + nameFr: string; + excerpt?: string; + excerptIt?: string; + excerptEn?: string; + excerptDe?: string; + excerptFr?: string; + description?: string; + descriptionIt?: string; + descriptionEn?: string; + descriptionDe?: string; + descriptionFr?: string; + seoTitle?: string; + seoDescription?: string; + ogTitle?: string; + ogDescription?: string; + indexable: boolean; + isFeatured: boolean; + isActive: boolean; + sortOrder: number; + variants: AdminUpsertShopProductVariantPayload[]; +} + +@Injectable({ + providedIn: 'root', +}) +export class AdminShopService { + private readonly http = inject(HttpClient); + private readonly adminMediaService = inject(AdminMediaService); + private readonly productsBaseUrl = `${environment.apiUrl}/api/admin/shop/products`; + private readonly categoriesBaseUrl = `${environment.apiUrl}/api/admin/shop/categories`; + + getCategories(): Observable { + return this.http.get(this.categoriesBaseUrl, { + withCredentials: true, + }); + } + + getCategoryTree(): Observable { + return this.http.get(`${this.categoriesBaseUrl}/tree`, { + withCredentials: true, + }); + } + + getCategory(categoryId: string): Observable { + return this.http.get( + `${this.categoriesBaseUrl}/${categoryId}`, + { withCredentials: true }, + ); + } + + createCategory( + payload: AdminUpsertShopCategoryPayload, + ): Observable { + return this.http.post(this.categoriesBaseUrl, payload, { + withCredentials: true, + }); + } + + updateCategory( + categoryId: string, + payload: AdminUpsertShopCategoryPayload, + ): Observable { + return this.http.put( + `${this.categoriesBaseUrl}/${categoryId}`, + payload, + { withCredentials: true }, + ); + } + + deleteCategory(categoryId: string): Observable { + return this.http.delete(`${this.categoriesBaseUrl}/${categoryId}`, { + withCredentials: true, + }); + } + + getProducts(): Observable { + return this.http.get(this.productsBaseUrl, { + withCredentials: true, + }); + } + + getProduct(productId: string): Observable { + return this.http.get(`${this.productsBaseUrl}/${productId}`, { + withCredentials: true, + }); + } + + createProduct( + payload: AdminUpsertShopProductPayload, + ): Observable { + return this.http.post(this.productsBaseUrl, payload, { + withCredentials: true, + }); + } + + updateProduct( + productId: string, + payload: AdminUpsertShopProductPayload, + ): Observable { + return this.http.put( + `${this.productsBaseUrl}/${productId}`, + payload, + { withCredentials: true }, + ); + } + + deleteProduct(productId: string): Observable { + return this.http.delete(`${this.productsBaseUrl}/${productId}`, { + withCredentials: true, + }); + } + + uploadProductModel(productId: string, file: File): Observable { + const formData = new FormData(); + formData.append('file', file); + return this.http.post( + `${this.productsBaseUrl}/${productId}/model`, + formData, + { withCredentials: true }, + ); + } + + deleteProductModel(productId: string): Observable { + return this.http.delete(`${this.productsBaseUrl}/${productId}/model`, { + withCredentials: true, + }); + } + + listMediaAssets(): Observable { + return this.adminMediaService.listAssets(); + } + + uploadMediaAsset( + file: File, + payload: AdminMediaUploadPayload, + ): Observable { + return this.adminMediaService.uploadAsset(file, payload); + } + + createMediaUsage( + payload: AdminCreateMediaUsagePayload, + ): Observable { + return this.adminMediaService.createUsage(payload); + } + + updateMediaUsage( + usageId: string, + payload: AdminUpdateMediaUsagePayload, + ): Observable { + return this.adminMediaService.updateUsage(usageId, payload); + } + + deleteMediaUsage(usageId: string): Observable { + return this.adminMediaService.deleteUsage(usageId); + } +} diff --git a/frontend/src/app/features/calculator/calculator-page.component.html b/frontend/src/app/features/calculator/calculator-page.component.html index 14c4970..9f3ca0f 100644 --- a/frontend/src/app/features/calculator/calculator-page.component.html +++ b/frontend/src/app/features/calculator/calculator-page.component.html @@ -1,6 +1,6 @@ -
-

{{ "CALC.TITLE" | translate }}

-

{{ "CALC.SUBTITLE" | translate }}

+
+

{{ "CALC.TITLE" | translate }}

+

{{ "CALC.SUBTITLE" | translate }}

@if (error()) { {{ errorKey() | translate }} @@ -8,7 +8,7 @@
@if (step() === "success") { -
+
-
-

{{ "CONTACT.TITLE" | translate }}

-

{{ "CONTACT.HERO_SUBTITLE" | translate }}

-
- +
+

{{ "CONTACT.TITLE" | translate }}

+

+ {{ "CONTACT.HERO_SUBTITLE" | translate }} +

+
diff --git a/frontend/src/app/features/contact/contact-page.component.scss b/frontend/src/app/features/contact/contact-page.component.scss index f495fe5..4d2b687 100644 --- a/frontend/src/app/features/contact/contact-page.component.scss +++ b/frontend/src/app/features/contact/contact-page.component.scss @@ -1,13 +1,7 @@ .contact-hero { - padding: 3rem 0 2rem; background: var(--color-bg); - text-align: center; -} -.subtitle { - color: var(--color-text-muted); - max-width: 640px; - margin: var(--space-3) auto 0; } + .content { padding: 2rem 0 5rem; max-width: 800px; diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.html b/frontend/src/app/features/shop/components/product-card/product-card.component.html index 96d3994..5f2d260 100644 --- a/frontend/src/app/features/shop/components/product-card/product-card.component.html +++ b/frontend/src/app/features/shop/components/product-card/product-card.component.html @@ -13,11 +13,6 @@ }
- @if (product().isFeatured) { - {{ - "SHOP.FEATURED_BADGE" | translate - }} - } @if (cartQuantity() > 0) { {{ "SHOP.IN_CART_SHORT" | translate: { count: cartQuantity() } }} diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.scss b/frontend/src/app/features/shop/components/product-card/product-card.component.scss index 8825c7d..6d7c427 100644 --- a/frontend/src/app/features/shop/components/product-card/product-card.component.scss +++ b/frontend/src/app/features/shop/components/product-card/product-card.component.scss @@ -4,18 +4,17 @@ border: 1px solid rgba(16, 24, 32, 0.08); border-radius: 1.1rem; overflow: hidden; - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 246, 241, 1)); - box-shadow: 0 18px 40px rgba(16, 24, 32, 0.08); + background: #fff; + box-shadow: 0 10px 24px rgba(16, 24, 32, 0.04); transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; &:hover { - transform: translateY(-4px); - box-shadow: 0 22px 48px rgba(16, 24, 32, 0.14); - border-color: rgba(250, 207, 10, 0.42); + transform: translateY(-2px); + box-shadow: 0 16px 30px rgba(16, 24, 32, 0.08); + border-color: rgba(16, 24, 32, 0.14); } } @@ -23,9 +22,7 @@ position: relative; display: block; min-height: 244px; - background: - radial-gradient(circle at top right, rgba(250, 207, 10, 0.28), transparent 42%), - linear-gradient(160deg, #f7f4ed 0%, #ece7db 100%); + background: #f2eee5; } .media img { @@ -42,6 +39,9 @@ display: flex; align-items: flex-end; padding: var(--space-5); + background: + radial-gradient(circle at top right, rgba(250, 207, 10, 0.24), transparent 36%), + linear-gradient(160deg, #f7f4ed 0%, #ece7db 100%); } .image-fallback span { @@ -78,11 +78,6 @@ text-transform: uppercase; } -.badge-featured { - background: rgba(250, 207, 10, 0.92); - color: var(--color-neutral-900); -} - .badge-cart { background: rgba(16, 24, 32, 0.82); color: #fff; diff --git a/frontend/src/app/features/shop/product-detail.component.html b/frontend/src/app/features/shop/product-detail.component.html index eec9d42..f975ab4 100644 --- a/frontend/src/app/features/shop/product-detail.component.html +++ b/frontend/src/app/features/shop/product-detail.component.html @@ -104,11 +104,6 @@
{{ p.category.name }} - @if (p.isFeatured) { - {{ - "SHOP.FEATURED_BADGE" | translate - }} - }

{{ p.name }}

diff --git a/frontend/src/app/features/shop/product-detail.component.scss b/frontend/src/app/features/shop/product-detail.component.scss index a9e229d..312893f 100644 --- a/frontend/src/app/features/shop/product-detail.component.scss +++ b/frontend/src/app/features/shop/product-detail.component.scss @@ -1,8 +1,6 @@ .product-page { padding: var(--space-8) 0 var(--space-12); - background: - radial-gradient(circle at top left, rgba(250, 207, 10, 0.18), transparent 20%), - linear-gradient(180deg, #faf7ee 0%, var(--color-bg) 25%); + background: linear-gradient(180deg, #faf7ef 0%, var(--color-bg) 15rem); } .wrapper { @@ -39,10 +37,8 @@ overflow: hidden; border-radius: 1.25rem; border: 1px solid rgba(16, 24, 32, 0.08); - background: - radial-gradient(circle at top right, rgba(250, 207, 10, 0.3), transparent 30%), - linear-gradient(160deg, #f8f4ea 0%, #eee8db 100%); - box-shadow: 0 18px 42px rgba(16, 24, 32, 0.08); + background: #f2eee5; + box-shadow: 0 12px 28px rgba(16, 24, 32, 0.05); } .hero-image { @@ -59,6 +55,9 @@ display: flex; align-items: flex-end; padding: var(--space-6); + background: + radial-gradient(circle at top right, rgba(250, 207, 10, 0.24), transparent 34%), + linear-gradient(160deg, #f8f4ea 0%, #eee8db 100%); } .image-fallback span { @@ -156,8 +155,7 @@ gap: 0.7rem; } -.category, -.featured-pill { +.category { text-transform: uppercase; font-size: 0.76rem; letter-spacing: 0.08em; @@ -168,15 +166,6 @@ font-weight: 700; } -.featured-pill { - display: inline-flex; - padding: 0.25rem 0.65rem; - border-radius: 999px; - background: rgba(250, 207, 10, 0.92); - color: var(--color-neutral-900); - font-weight: 700; -} - h1 { font-size: clamp(2rem, 2vw + 1.2rem, 3.2rem); } diff --git a/frontend/src/app/features/shop/services/shop.service.spec.ts b/frontend/src/app/features/shop/services/shop.service.spec.ts index 69f5c8f..a4ea938 100644 --- a/frontend/src/app/features/shop/services/shop.service.spec.ts +++ b/frontend/src/app/features/shop/services/shop.service.spec.ts @@ -20,7 +20,7 @@ describe('ShopService', () => { { id: 'line-1', lineItemType: 'SHOP_PRODUCT', - originalFilename: 'desk-cable-clip-demo.stl', + originalFilename: 'desk-cable-clip.stl', displayName: 'Desk Cable Clip', quantity: 2, printTimeSeconds: null, @@ -42,13 +42,13 @@ describe('ShopService', () => { infillPattern: null, supportsEnabled: false, status: 'READY', - convertedStoredPath: '/storage/items/desk-cable-clip-demo.stl', + convertedStoredPath: '/storage/items/desk-cable-clip.stl', unitPriceChf: 11.4, }, { id: 'line-2', lineItemType: 'SHOP_PRODUCT', - originalFilename: 'desk-cable-clip-demo.stl', + originalFilename: 'desk-cable-clip.stl', displayName: 'Desk Cable Clip', quantity: 1, printTimeSeconds: null, @@ -70,7 +70,7 @@ describe('ShopService', () => { infillPattern: null, supportsEnabled: false, status: 'READY', - convertedStoredPath: '/storage/items/desk-cable-clip-demo.stl', + convertedStoredPath: '/storage/items/desk-cable-clip.stl', unitPriceChf: 12.0, }, ], diff --git a/frontend/src/app/features/shop/shop-page.component.html b/frontend/src/app/features/shop/shop-page.component.html index d7617c3..7d9a814 100644 --- a/frontend/src/app/features/shop/shop-page.component.html +++ b/frontend/src/app/features/shop/shop-page.component.html @@ -1,74 +1,19 @@

-
-
-
-

{{ "SHOP.HERO_EYEBROW" | translate }}

-

- {{ - selectedCategory()?.name || ("SHOP.TITLE" | translate) - }} -

-

- {{ - selectedCategory()?.description || - ("SHOP.SUBTITLE" | translate) - }} -

-

- {{ - selectedCategory() - ? ("SHOP.CATEGORY_META" | translate: { count: selectedCategory()?.productCount || 0 }) - : ("SHOP.CATALOG_META_DESCRIPTION" | translate) - }} -

- -
- @if (cartHasItems()) { - - {{ "SHOP.GO_TO_CHECKOUT" | translate }} - - } @else { - - {{ "SHOP.WIP_CTA_CALC" | translate }} - - } - - @if (selectedCategory()) { - - {{ "SHOP.VIEW_ALL" | translate }} - - } -
-
- -
-
- {{ - "SHOP.HIGHLIGHT_PRODUCTS" | translate - }} - {{ - selectedCategory()?.productCount ?? products().length - }} -
-
- {{ - "SHOP.HIGHLIGHT_CART" | translate - }} - {{ cartItemCount() }} -
-
- {{ - "SHOP.HIGHLIGHT_READY" | translate - }} - {{ "SHOP.MODEL_3D" | translate }} -
-
+
+

{{ "NAV.SHOP" | translate }}

+

+ {{ + selectedCategory() + ? (selectedCategory()?.description || ("SHOP.CATEGORY_META" | translate: { count: selectedCategory()?.productCount || 0 })) + : ("SHOP.CUSTOM_PART_CTA" | translate) + }} +

+
+ + {{ "NAV.CONTACT" | translate }} +
-
+
} @else { - @if (featuredProducts().length > 0 && !selectedCategory()) { - - } -
diff --git a/frontend/src/app/features/shop/shop-page.component.scss b/frontend/src/app/features/shop/shop-page.component.scss index 2cc97e3..861d52f 100644 --- a/frontend/src/app/features/shop/shop-page.component.scss +++ b/frontend/src/app/features/shop/shop-page.component.scss @@ -1,56 +1,14 @@ .shop-page { - --shop-hero-bg: #f8f3e5; - background: - radial-gradient(circle at top right, rgba(250, 207, 10, 0.24), transparent 22%), - linear-gradient(180deg, #faf7ef 0%, #f6f2e8 24%, var(--color-bg) 24%); + background: linear-gradient(180deg, #faf7ef 0%, var(--color-bg) 13rem); } -.shop-hero { - position: relative; - overflow: hidden; - padding: 4.75rem 0 3.5rem; - background: transparent; +.shop-hero .ui-simple-hero__subtitle { + max-width: 52rem; + line-height: 1.5; } -.shop-hero-grid { - display: grid; - gap: var(--space-8); - align-items: end; - grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.8fr); -} - -.hero-copy { - display: grid; - gap: var(--space-4); -} - -.hero-highlights { - display: grid; - gap: var(--space-4); -} - -.highlight-card { - display: grid; - gap: 0.2rem; - padding: 1.15rem 1.2rem; - border-radius: 1rem; - border: 1px solid rgba(16, 24, 32, 0.08); - background: rgba(255, 255, 255, 0.78); - box-shadow: 0 12px 28px rgba(16, 24, 32, 0.08); - backdrop-filter: blur(8px); -} - -.highlight-card strong { - font-size: 1.35rem; - line-height: 1.1; -} - -.highlight-label { - color: var(--color-text-muted); - text-transform: uppercase; - letter-spacing: 0.08em; - font-size: 0.72rem; - font-weight: 700; +.hero-actions { + gap: var(--space-3); } .shop-layout { @@ -59,6 +17,7 @@ align-items: start; grid-template-columns: minmax(270px, 320px) minmax(0, 1fr); padding-bottom: var(--space-12); + padding-top: var(--space-6); } .shop-sidebar { @@ -116,8 +75,8 @@ .category-link:hover, .category-link.active { - background: rgba(250, 207, 10, 0.14); - border-color: rgba(250, 207, 10, 0.34); + background: rgba(250, 207, 10, 0.12); + border-color: rgba(16, 24, 32, 0.12); transform: translateX(1px); } @@ -213,7 +172,7 @@ padding: 0.2rem; border-radius: 999px; border: 1px solid var(--color-border); - background: rgba(255, 255, 255, 0.68); + background: #fff; } .qty-control button { @@ -262,7 +221,6 @@ gap: var(--space-6); } -.featured-strip, .catalog-panel { display: grid; gap: var(--space-5); @@ -286,7 +244,6 @@ white-space: nowrap; } -.featured-grid, .product-grid { display: grid; gap: var(--space-5); @@ -314,7 +271,6 @@ } @media (max-width: 1080px) { - .shop-hero-grid, .shop-layout { grid-template-columns: 1fr; } @@ -325,7 +281,6 @@ } @media (max-width: 760px) { - .featured-grid, .product-grid { grid-template-columns: 1fr; } diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts index ed1ef37..13911d9 100644 --- a/frontend/src/app/features/shop/shop-page.component.ts +++ b/frontend/src/app/features/shop/shop-page.component.ts @@ -11,7 +11,7 @@ import { import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { Router, RouterLink } from '@angular/router'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { catchError, combineLatest, finalize, forkJoin, map, of, switchMap, tap } from 'rxjs'; +import { catchError, combineLatest, finalize, forkJoin, of, switchMap, tap } from 'rxjs'; import { SeoService } from '../../core/services/seo.service'; import { LanguageService } from '../../core/services/language.service'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; @@ -57,7 +57,6 @@ export class ShopPageComponent { readonly categoryNodes = signal([]); readonly selectedCategory = signal(null); readonly products = signal([]); - readonly featuredProducts = signal([]); readonly cartMutating = signal(false); readonly busyLineItemId = signal(null); @@ -98,18 +97,12 @@ export class ShopPageComponent { forkJoin({ categories: this.shopService.getCategories(), catalog: this.shopService.getProductCatalog(categorySlug ?? null), - featuredProducts: categorySlug - ? of([]) - : this.shopService - .getProductCatalog(null, true) - .pipe(map((response) => response.products)), }).pipe( catchError((error) => { this.categories.set([]); this.categoryNodes.set([]); this.selectedCategory.set(null); this.products.set([]); - this.featuredProducts.set([]); this.error.set( error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR', ); @@ -135,7 +128,6 @@ export class ShopPageComponent { ); this.selectedCategory.set(result.catalog.category ?? null); this.products.set(result.catalog.products); - this.featuredProducts.set(result.featuredProducts); this.applySeo(result.catalog.category ?? null); }); } diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 5842a47..f59f113 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -177,6 +177,7 @@ "CATALOG_LABEL": "Catalogo", "CATALOG_TITLE": "Tutti i prodotti", "CATALOG_META_DESCRIPTION": "Scopri prodotti stampati in 3D, accessori tecnici e soluzioni pronte all uso con lo stesso checkout del calcolatore.", + "CUSTOM_PART_CTA": "Non trovi quello che cerchi? Richiedi un pezzo personalizzato.", "CATEGORY_META": "{{count}} prodotti disponibili in questa categoria", "CATEGORY_PANEL_KICKER": "Navigazione", "CATEGORY_PANEL_TITLE": "Categorie", @@ -206,7 +207,7 @@ "CART_TITLE": "Carrello", "CART_SUMMARY_TITLE": "Riepilogo attuale", "CART_LOADING": "Caricamento carrello in corso.", - "CART_EMPTY": "Il carrello è vuoto. Aggiungi un prodotto per ritrovarlo subito anche al prossimo accesso.", + "CART_EMPTY": "Il carrello è vuoto. Aggiungi un prodotto.", "CART_SUBTOTAL": "Subtotale prodotti", "CART_SHIPPING": "Spedizione", "CART_TOTAL": "Totale stimato", diff --git a/frontend/src/styles/_ui.scss b/frontend/src/styles/_ui.scss index bf6c542..17a239f 100644 --- a/frontend/src/styles/_ui.scss +++ b/frontend/src/styles/_ui.scss @@ -19,6 +19,31 @@ font-size: 1.05rem; } +.ui-simple-hero { + padding: var(--space-12) 0; + text-align: center; +} + +.ui-simple-hero__title { + margin: 0 0 var(--space-2); + font-size: clamp(2rem, 4vw, 2.75rem); + margin-top: var(--space-6); +} + +.ui-simple-hero__subtitle { + max-width: 600px; + margin: var(--space-6) auto; + color: var(--color-text-muted); + font-size: 1.25rem; + +} + +.ui-simple-hero__actions { + display: flex; + justify-content: center; + margin-top: var(--space-6); +} + .ui-stack { display: grid; gap: var(--space-4); @@ -153,6 +178,7 @@ font-size: 1.1rem; line-height: 1.6; color: var(--color-text-muted); + margin-bottom: var(--space-4); } .ui-inline-actions {