From 3f228ef6e27515d4ca859098bd8d51769e5189fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 10 Mar 2026 15:04:49 +0100 Subject: [PATCH] feat(back-end front-end): shop feature --- .../dto/AdminShopProductDto.java | 72 +++ .../dto/AdminUpsertShopProductRequest.java | 72 +++ .../printcalculator/entity/ShopProduct.java | 118 ++++ .../AdminShopProductControllerService.java | 92 +++- .../service/media/MediaStorageService.java | 7 +- .../shop/PublicShopCatalogService.java | 10 +- ...AdminShopProductControllerServiceTest.java | 137 +++++ db.sql | 18 + .../pages/admin-dashboard.component.html | 3 - .../pages/admin-dashboard.component.scss | 6 - .../admin/pages/admin-shop.component.html | 296 +++++----- .../admin/pages/admin-shop.component.scss | 96 +++- .../admin/pages/admin-shop.component.ts | 514 ++++++++++++++---- .../admin/services/admin-shop.service.ts | 16 + .../product-card/product-card.component.html | 37 +- .../product-card/product-card.component.scss | 72 ++- .../product-card/product-card.component.ts | 49 +- .../shop/product-detail.component.html | 246 +++++++-- .../shop/product-detail.component.scss | 506 +++++++++++++++-- .../features/shop/product-detail.component.ts | 327 ++++++++++- .../shop/services/shop.service.spec.ts | 153 +++++- .../features/shop/services/shop.service.ts | 29 +- .../features/shop/shop-page.component.html | 24 +- .../features/shop/shop-page.component.scss | 37 +- .../app/features/shop/shop-page.component.ts | 5 +- frontend/src/app/features/shop/shop.routes.ts | 7 + frontend/src/assets/i18n/de.json | 18 + frontend/src/assets/i18n/en.json | 18 + frontend/src/assets/i18n/fr.json | 18 + frontend/src/assets/i18n/it.json | 16 + 30 files changed, 2584 insertions(+), 435 deletions(-) create mode 100644 backend/src/test/java/com/printcalculator/service/admin/AdminShopProductControllerServiceTest.java diff --git a/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java b/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java index c7d70c6..67d7278 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopProductDto.java @@ -27,7 +27,15 @@ public class AdminShopProductDto { private String descriptionDe; private String descriptionFr; private String seoTitle; + private String seoTitleIt; + private String seoTitleEn; + private String seoTitleDe; + private String seoTitleFr; private String seoDescription; + private String seoDescriptionIt; + private String seoDescriptionEn; + private String seoDescriptionDe; + private String seoDescriptionFr; private String ogTitle; private String ogDescription; private Boolean indexable; @@ -215,6 +223,38 @@ public class AdminShopProductDto { this.seoTitle = seoTitle; } + public String getSeoTitleIt() { + return seoTitleIt; + } + + public void setSeoTitleIt(String seoTitleIt) { + this.seoTitleIt = seoTitleIt; + } + + public String getSeoTitleEn() { + return seoTitleEn; + } + + public void setSeoTitleEn(String seoTitleEn) { + this.seoTitleEn = seoTitleEn; + } + + public String getSeoTitleDe() { + return seoTitleDe; + } + + public void setSeoTitleDe(String seoTitleDe) { + this.seoTitleDe = seoTitleDe; + } + + public String getSeoTitleFr() { + return seoTitleFr; + } + + public void setSeoTitleFr(String seoTitleFr) { + this.seoTitleFr = seoTitleFr; + } + public String getSeoDescription() { return seoDescription; } @@ -223,6 +263,38 @@ public class AdminShopProductDto { this.seoDescription = seoDescription; } + public String getSeoDescriptionIt() { + return seoDescriptionIt; + } + + public void setSeoDescriptionIt(String seoDescriptionIt) { + this.seoDescriptionIt = seoDescriptionIt; + } + + public String getSeoDescriptionEn() { + return seoDescriptionEn; + } + + public void setSeoDescriptionEn(String seoDescriptionEn) { + this.seoDescriptionEn = seoDescriptionEn; + } + + public String getSeoDescriptionDe() { + return seoDescriptionDe; + } + + public void setSeoDescriptionDe(String seoDescriptionDe) { + this.seoDescriptionDe = seoDescriptionDe; + } + + public String getSeoDescriptionFr() { + return seoDescriptionFr; + } + + public void setSeoDescriptionFr(String seoDescriptionFr) { + this.seoDescriptionFr = seoDescriptionFr; + } + public String getOgTitle() { return ogTitle; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java index 0c00744..8bebb25 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductRequest.java @@ -22,7 +22,15 @@ public class AdminUpsertShopProductRequest { private String descriptionDe; private String descriptionFr; private String seoTitle; + private String seoTitleIt; + private String seoTitleEn; + private String seoTitleDe; + private String seoTitleFr; private String seoDescription; + private String seoDescriptionIt; + private String seoDescriptionEn; + private String seoDescriptionDe; + private String seoDescriptionFr; private String ogTitle; private String ogDescription; private Boolean indexable; @@ -175,6 +183,38 @@ public class AdminUpsertShopProductRequest { this.seoTitle = seoTitle; } + public String getSeoTitleIt() { + return seoTitleIt; + } + + public void setSeoTitleIt(String seoTitleIt) { + this.seoTitleIt = seoTitleIt; + } + + public String getSeoTitleEn() { + return seoTitleEn; + } + + public void setSeoTitleEn(String seoTitleEn) { + this.seoTitleEn = seoTitleEn; + } + + public String getSeoTitleDe() { + return seoTitleDe; + } + + public void setSeoTitleDe(String seoTitleDe) { + this.seoTitleDe = seoTitleDe; + } + + public String getSeoTitleFr() { + return seoTitleFr; + } + + public void setSeoTitleFr(String seoTitleFr) { + this.seoTitleFr = seoTitleFr; + } + public String getSeoDescription() { return seoDescription; } @@ -183,6 +223,38 @@ public class AdminUpsertShopProductRequest { this.seoDescription = seoDescription; } + public String getSeoDescriptionIt() { + return seoDescriptionIt; + } + + public void setSeoDescriptionIt(String seoDescriptionIt) { + this.seoDescriptionIt = seoDescriptionIt; + } + + public String getSeoDescriptionEn() { + return seoDescriptionEn; + } + + public void setSeoDescriptionEn(String seoDescriptionEn) { + this.seoDescriptionEn = seoDescriptionEn; + } + + public String getSeoDescriptionDe() { + return seoDescriptionDe; + } + + public void setSeoDescriptionDe(String seoDescriptionDe) { + this.seoDescriptionDe = seoDescriptionDe; + } + + public String getSeoDescriptionFr() { + return seoDescriptionFr; + } + + public void setSeoDescriptionFr(String seoDescriptionFr) { + this.seoDescriptionFr = seoDescriptionFr; + } + public String getOgTitle() { return ogTitle; } diff --git a/backend/src/main/java/com/printcalculator/entity/ShopProduct.java b/backend/src/main/java/com/printcalculator/entity/ShopProduct.java index d5fd86d..2c6bda8 100644 --- a/backend/src/main/java/com/printcalculator/entity/ShopProduct.java +++ b/backend/src/main/java/com/printcalculator/entity/ShopProduct.java @@ -86,9 +86,33 @@ public class ShopProduct { @Column(name = "seo_title", length = Integer.MAX_VALUE) private String seoTitle; + @Column(name = "seo_title_it", length = Integer.MAX_VALUE) + private String seoTitleIt; + + @Column(name = "seo_title_en", length = Integer.MAX_VALUE) + private String seoTitleEn; + + @Column(name = "seo_title_de", length = Integer.MAX_VALUE) + private String seoTitleDe; + + @Column(name = "seo_title_fr", length = Integer.MAX_VALUE) + private String seoTitleFr; + @Column(name = "seo_description", length = Integer.MAX_VALUE) private String seoDescription; + @Column(name = "seo_description_it", length = Integer.MAX_VALUE) + private String seoDescriptionIt; + + @Column(name = "seo_description_en", length = Integer.MAX_VALUE) + private String seoDescriptionEn; + + @Column(name = "seo_description_de", length = Integer.MAX_VALUE) + private String seoDescriptionDe; + + @Column(name = "seo_description_fr", length = Integer.MAX_VALUE) + private String seoDescriptionFr; + @Column(name = "og_title", length = Integer.MAX_VALUE) private String ogTitle; @@ -319,6 +343,70 @@ public class ShopProduct { this.seoDescription = seoDescription; } + public String getSeoTitleIt() { + return seoTitleIt; + } + + public void setSeoTitleIt(String seoTitleIt) { + this.seoTitleIt = seoTitleIt; + } + + public String getSeoTitleEn() { + return seoTitleEn; + } + + public void setSeoTitleEn(String seoTitleEn) { + this.seoTitleEn = seoTitleEn; + } + + public String getSeoTitleDe() { + return seoTitleDe; + } + + public void setSeoTitleDe(String seoTitleDe) { + this.seoTitleDe = seoTitleDe; + } + + public String getSeoTitleFr() { + return seoTitleFr; + } + + public void setSeoTitleFr(String seoTitleFr) { + this.seoTitleFr = seoTitleFr; + } + + public String getSeoDescriptionIt() { + return seoDescriptionIt; + } + + public void setSeoDescriptionIt(String seoDescriptionIt) { + this.seoDescriptionIt = seoDescriptionIt; + } + + public String getSeoDescriptionEn() { + return seoDescriptionEn; + } + + public void setSeoDescriptionEn(String seoDescriptionEn) { + this.seoDescriptionEn = seoDescriptionEn; + } + + public String getSeoDescriptionDe() { + return seoDescriptionDe; + } + + public void setSeoDescriptionDe(String seoDescriptionDe) { + this.seoDescriptionDe = seoDescriptionDe; + } + + public String getSeoDescriptionFr() { + return seoDescriptionFr; + } + + public void setSeoDescriptionFr(String seoDescriptionFr) { + this.seoDescriptionFr = seoDescriptionFr; + } + public String getOgTitle() { return ogTitle; } @@ -428,6 +516,36 @@ public class ShopProduct { } } + public String getSeoTitleForLanguage(String language) { + return resolveLocalizedValue(language, seoTitle, seoTitleIt, seoTitleEn, seoTitleDe, seoTitleFr); + } + + public void setSeoTitleForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> seoTitleIt = value; + case "en" -> seoTitleEn = value; + case "de" -> seoTitleDe = value; + case "fr" -> seoTitleFr = value; + default -> { + } + } + } + + public String getSeoDescriptionForLanguage(String language) { + return resolveLocalizedValue(language, seoDescription, seoDescriptionIt, seoDescriptionEn, seoDescriptionDe, seoDescriptionFr); + } + + public void setSeoDescriptionForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> seoDescriptionIt = value; + case "en" -> seoDescriptionEn = value; + case "de" -> seoDescriptionDe = value; + case "fr" -> seoDescriptionFr = value; + default -> { + } + } + } + private String resolveLocalizedValue(String language, String fallback, String valueIt, 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 c90ef08..7a3d262 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java @@ -163,7 +163,13 @@ public class AdminShopProductControllerService { } } - shopProductModelAssetRepository.findByProduct_Id(productId).ifPresent(asset -> deleteExistingModelFile(asset, productId)); + shopProductModelAssetRepository.findByProduct_Id(productId).ifPresent(asset -> { + deleteExistingModelFile(asset, productId); + shopProductModelAssetRepository.delete(asset); + }); + if (!variants.isEmpty()) { + shopProductVariantRepository.deleteAll(variants); + } shopProductRepository.delete(product); } @@ -315,10 +321,18 @@ public class AdminShopProductControllerService { 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())); - product.setOgDescription(normalizeOptional(payload.getOgDescription())); + product.setSeoTitle(localizedContent.defaultSeoTitle()); + product.setSeoTitleIt(localizedContent.seoTitles().get("it")); + product.setSeoTitleEn(localizedContent.seoTitles().get("en")); + product.setSeoTitleDe(localizedContent.seoTitles().get("de")); + product.setSeoTitleFr(localizedContent.seoTitles().get("fr")); + product.setSeoDescription(localizedContent.defaultSeoDescription()); + product.setSeoDescriptionIt(localizedContent.seoDescriptions().get("it")); + product.setSeoDescriptionEn(localizedContent.seoDescriptions().get("en")); + product.setSeoDescriptionDe(localizedContent.seoDescriptions().get("de")); + product.setSeoDescriptionFr(localizedContent.seoDescriptions().get("fr")); + product.setOgTitle(localizedContent.defaultSeoTitle()); + product.setOgDescription(localizedContent.defaultSeoDescription()); product.setIndexable(payload.getIndexable() == null || payload.getIndexable()); product.setIsFeatured(Boolean.TRUE.equals(payload.getIsFeatured())); product.setIsActive(payload.getIsActive() == null || payload.getIsActive()); @@ -374,16 +388,23 @@ public class AdminShopProductControllerService { } List normalized = new ArrayList<>(payloads); - Set colorKeys = new LinkedHashSet<>(); + Set variantKeys = new LinkedHashSet<>(); int defaultCount = 0; for (AdminUpsertShopProductVariantRequest payload : normalized) { if (payload == null) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Variant payload is required"); } String colorName = normalizeRequired(payload.getColorName(), "Variant colorName is required"); - String colorKey = colorName.toLowerCase(Locale.ROOT); - if (!colorKeys.add(colorKey)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Duplicate variant colorName: " + colorName); + String materialCode = normalizeRequired( + payload.getInternalMaterialCode(), + "Variant internalMaterialCode is required" + ).toUpperCase(Locale.ROOT); + String variantKey = materialCode + "|" + colorName.toLowerCase(Locale.ROOT); + if (!variantKeys.add(variantKey)) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Duplicate variant combination: " + materialCode + " / " + colorName + ); } if (Boolean.TRUE.equals(payload.getIsDefault())) { defaultCount++; @@ -467,7 +488,15 @@ public class AdminShopProductControllerService { dto.setDescriptionDe(product.getDescriptionDe()); dto.setDescriptionFr(product.getDescriptionFr()); dto.setSeoTitle(product.getSeoTitle()); + dto.setSeoTitleIt(product.getSeoTitleIt()); + dto.setSeoTitleEn(product.getSeoTitleEn()); + dto.setSeoTitleDe(product.getSeoTitleDe()); + dto.setSeoTitleFr(product.getSeoTitleFr()); dto.setSeoDescription(product.getSeoDescription()); + dto.setSeoDescriptionIt(product.getSeoDescriptionIt()); + dto.setSeoDescriptionEn(product.getSeoDescriptionEn()); + dto.setSeoDescriptionDe(product.getSeoDescriptionDe()); + dto.setSeoDescriptionFr(product.getSeoDescriptionFr()); dto.setOgTitle(product.getOgTitle()); dto.setOgDescription(product.getOgDescription()); dto.setIndexable(product.getIndexable()); @@ -596,13 +625,43 @@ public class AdminShopProductControllerService { descriptions.put("de", firstNonBlank(normalizeOptional(payload.getDescriptionDe()), fallbackDescription)); descriptions.put("fr", firstNonBlank(normalizeOptional(payload.getDescriptionFr()), fallbackDescription)); + String fallbackSeoTitle = firstNonBlank( + normalizeOptional(payload.getSeoTitle()), + normalizeOptional(payload.getSeoTitleIt()), + normalizeOptional(payload.getSeoTitleEn()), + normalizeOptional(payload.getSeoTitleDe()), + normalizeOptional(payload.getSeoTitleFr()) + ); + Map seoTitles = new LinkedHashMap<>(); + seoTitles.put("it", firstNonBlank(normalizeOptional(payload.getSeoTitleIt()), fallbackSeoTitle)); + seoTitles.put("en", firstNonBlank(normalizeOptional(payload.getSeoTitleEn()), fallbackSeoTitle)); + seoTitles.put("de", firstNonBlank(normalizeOptional(payload.getSeoTitleDe()), fallbackSeoTitle)); + seoTitles.put("fr", firstNonBlank(normalizeOptional(payload.getSeoTitleFr()), fallbackSeoTitle)); + + String fallbackSeoDescription = firstNonBlank( + normalizeOptional(payload.getSeoDescription()), + normalizeOptional(payload.getSeoDescriptionIt()), + normalizeOptional(payload.getSeoDescriptionEn()), + normalizeOptional(payload.getSeoDescriptionDe()), + normalizeOptional(payload.getSeoDescriptionFr()) + ); + Map seoDescriptions = new LinkedHashMap<>(); + seoDescriptions.put("it", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionIt()), fallbackSeoDescription), "Italian")); + seoDescriptions.put("en", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionEn()), fallbackSeoDescription), "English")); + seoDescriptions.put("de", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionDe()), fallbackSeoDescription), "German")); + seoDescriptions.put("fr", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionFr()), fallbackSeoDescription), "French")); + return new LocalizedProductContent( names.get("it"), firstNonBlank(excerpts.get("it"), fallbackExcerpt), firstNonBlank(descriptions.get("it"), fallbackDescription), + firstNonBlank(seoTitles.get("it"), fallbackSeoTitle), + firstNonBlank(seoDescriptions.get("it"), fallbackSeoDescription), names, excerpts, - descriptions + descriptions, + seoTitles, + seoDescriptions ); } @@ -670,6 +729,13 @@ public class AdminShopProductControllerService { return normalized.toUpperCase(Locale.ROOT); } + private String validateSeoDescriptionLength(String value, String languageLabel) { + if (value != null && value.length() > 160) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, languageLabel + " SEO description must be at most 160 characters"); + } + return value; + } + private void validateModelUpload(MultipartFile file) { if (file == null || file.isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "3D model file is required"); @@ -786,9 +852,13 @@ public class AdminShopProductControllerService { String defaultName, String defaultExcerpt, String defaultDescription, + String defaultSeoTitle, + String defaultSeoDescription, Map names, Map excerpts, - Map descriptions + Map descriptions, + Map seoTitles, + Map seoDescriptions ) { } } diff --git a/backend/src/main/java/com/printcalculator/service/media/MediaStorageService.java b/backend/src/main/java/com/printcalculator/service/media/MediaStorageService.java index d890211..f1ee7e1 100644 --- a/backend/src/main/java/com/printcalculator/service/media/MediaStorageService.java +++ b/backend/src/main/java/com/printcalculator/service/media/MediaStorageService.java @@ -21,7 +21,7 @@ public class MediaStorageService { private final String frontendBaseUrl; public MediaStorageService(@Value("${media.storage.root:storage_media}") String storageRoot, - @Value("${app.frontend.base-url:${APP_FRONTEND_BASE_URL:http://localhost:8080}}") String frontendBaseUrl) { + @Value("${app.frontend.base-url:${APP_FRONTEND_BASE_URL:http://localhost:8081}}") String frontendBaseUrl) { this.normalizedRootLocation = Paths.get(storageRoot).toAbsolutePath().normalize(); this.originalRootLocation = normalizedRootLocation.resolve("original").normalize(); this.publicRootLocation = normalizedRootLocation.resolve("public").normalize(); @@ -131,8 +131,11 @@ public class MediaStorageService { private String buildMediaBaseUrl() { String normalized = frontendBaseUrl != null ? frontendBaseUrl.trim() : ""; + if (normalized.contains("localhost")){ + return "http://localhost:8081"; + } if (normalized.isBlank()) { - normalized = "http://localhost:4200"; + normalized = "http://localhost:8081"; } if (normalized.endsWith("/")) { normalized = normalized.substring(0, normalized.length() - 1); 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 10f8fc0..9323706 100644 --- a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java +++ b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java @@ -375,16 +375,18 @@ public class PublicShopCatalogService { Map> productMediaBySlug, String language) { List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); + String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language); + String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language); return new ShopProductDetailDto( entry.product().getId(), entry.product().getSlug(), entry.product().getNameForLanguage(language), entry.product().getExcerptForLanguage(language), entry.product().getDescriptionForLanguage(language), - entry.product().getSeoTitle(), - entry.product().getSeoDescription(), - entry.product().getOgTitle(), - entry.product().getOgDescription(), + localizedSeoTitle, + localizedSeoDescription, + localizedSeoTitle, + localizedSeoDescription, entry.product().getIndexable(), entry.product().getIsFeatured(), entry.product().getSortOrder(), diff --git a/backend/src/test/java/com/printcalculator/service/admin/AdminShopProductControllerServiceTest.java b/backend/src/test/java/com/printcalculator/service/admin/AdminShopProductControllerServiceTest.java new file mode 100644 index 0000000..a4fe132 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/admin/AdminShopProductControllerServiceTest.java @@ -0,0 +1,137 @@ +package com.printcalculator.service.admin; + +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.entity.ShopProductModelAsset; +import com.printcalculator.entity.ShopProductVariant; +import com.printcalculator.repository.OrderItemRepository; +import com.printcalculator.repository.QuoteLineItemRepository; +import com.printcalculator.repository.ShopCategoryRepository; +import com.printcalculator.repository.ShopProductModelAssetRepository; +import com.printcalculator.repository.ShopProductRepository; +import com.printcalculator.repository.ShopProductVariantRepository; +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.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminShopProductControllerServiceTest { + + @Mock + private ShopProductRepository shopProductRepository; + @Mock + private ShopCategoryRepository shopCategoryRepository; + @Mock + private ShopProductVariantRepository shopProductVariantRepository; + @Mock + private ShopProductModelAssetRepository shopProductModelAssetRepository; + @Mock + private QuoteLineItemRepository quoteLineItemRepository; + @Mock + private OrderItemRepository orderItemRepository; + @Mock + private PublicMediaQueryService publicMediaQueryService; + @Mock + private AdminMediaControllerService adminMediaControllerService; + @Mock + private ShopStorageService shopStorageService; + @Mock + private SlicerService slicerService; + @Mock + private ClamAVService clamAVService; + + private AdminShopProductControllerService service; + + @BeforeEach + void setUp() { + service = new AdminShopProductControllerService( + shopProductRepository, + shopCategoryRepository, + shopProductVariantRepository, + shopProductModelAssetRepository, + quoteLineItemRepository, + orderItemRepository, + publicMediaQueryService, + adminMediaControllerService, + shopStorageService, + slicerService, + clamAVService, + 104857600L + ); + } + + @Test + void deleteProduct_shouldDeleteManagedDependenciesBeforeDeletingProduct() { + UUID productId = UUID.randomUUID(); + UUID variantId = UUID.randomUUID(); + + ShopProduct product = new ShopProduct(); + product.setId(productId); + + ShopProductVariant variant = new ShopProductVariant(); + variant.setId(variantId); + variant.setProduct(product); + + ShopProductModelAsset asset = new ShopProductModelAsset(); + asset.setId(UUID.randomUUID()); + asset.setProduct(product); + asset.setStoredRelativePath("products/" + productId + "/model.stl"); + + when(shopProductRepository.findById(productId)).thenReturn(Optional.of(product)); + when(quoteLineItemRepository.existsByShopProduct_Id(productId)).thenReturn(false); + when(orderItemRepository.existsByShopProduct_Id(productId)).thenReturn(false); + when(shopProductVariantRepository.findByProduct_IdOrderBySortOrderAscColorNameAsc(productId)).thenReturn(List.of(variant)); + when(quoteLineItemRepository.existsByShopProductVariant_Id(variantId)).thenReturn(false); + when(orderItemRepository.existsByShopProductVariant_Id(variantId)).thenReturn(false); + when(shopProductModelAssetRepository.findByProduct_Id(productId)).thenReturn(Optional.of(asset)); + when(shopStorageService.resolveStoredProductPath(asset.getStoredRelativePath(), productId)) + .thenReturn(Path.of("/tmp/shop-model.stl")); + + service.deleteProduct(productId); + + InOrder inOrder = inOrder(shopProductModelAssetRepository, shopProductVariantRepository, shopProductRepository); + inOrder.verify(shopProductModelAssetRepository).delete(asset); + inOrder.verify(shopProductVariantRepository).deleteAll(List.of(variant)); + inOrder.verify(shopProductRepository).delete(product); + + verify(shopStorageService).resolveStoredProductPath(asset.getStoredRelativePath(), productId); + } + + @Test + void deleteProduct_shouldSkipDependencyDeletesWhenNothingIsAttached() { + UUID productId = UUID.randomUUID(); + + ShopProduct product = new ShopProduct(); + product.setId(productId); + + when(shopProductRepository.findById(productId)).thenReturn(Optional.of(product)); + when(quoteLineItemRepository.existsByShopProduct_Id(productId)).thenReturn(false); + when(orderItemRepository.existsByShopProduct_Id(productId)).thenReturn(false); + when(shopProductVariantRepository.findByProduct_IdOrderBySortOrderAscColorNameAsc(productId)).thenReturn(List.of()); + when(shopProductModelAssetRepository.findByProduct_Id(productId)).thenReturn(Optional.empty()); + + service.deleteProduct(productId); + + verify(shopProductRepository).delete(product); + verify(shopProductVariantRepository, never()).deleteAll(any()); + verify(shopProductModelAssetRepository, never()).delete(any()); + } +} diff --git a/db.sql b/db.sql index 4d424a0..c3975e6 100644 --- a/db.sql +++ b/db.sql @@ -1055,7 +1055,15 @@ CREATE TABLE IF NOT EXISTS shop_product description_de text, description_fr text, seo_title text, + seo_title_it text, + seo_title_en text, + seo_title_de text, + seo_title_fr text, seo_description text, + seo_description_it text, + seo_description_en text, + seo_description_de text, + seo_description_fr text, og_title text, og_description text, indexable boolean NOT NULL DEFAULT true, @@ -1066,6 +1074,16 @@ CREATE TABLE IF NOT EXISTS shop_product updated_at timestamptz NOT NULL DEFAULT now() ); +ALTER TABLE shop_product + ADD COLUMN IF NOT EXISTS seo_title_it text, + ADD COLUMN IF NOT EXISTS seo_title_en text, + ADD COLUMN IF NOT EXISTS seo_title_de text, + ADD COLUMN IF NOT EXISTS seo_title_fr text, + ADD COLUMN IF NOT EXISTS seo_description_it text, + ADD COLUMN IF NOT EXISTS seo_description_en text, + ADD COLUMN IF NOT EXISTS seo_description_de text, + ADD COLUMN IF NOT EXISTS seo_description_fr text; + CREATE INDEX IF NOT EXISTS ix_shop_product_category_active_sort ON shop_product (shop_category_id, is_active, sort_order, created_at DESC); diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.html b/frontend/src/app/features/admin/pages/admin-dashboard.component.html index b43993f..ea08475 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.html +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.html @@ -104,7 +104,6 @@ {{ orderKindLabel(order) }} @@ -133,7 +132,6 @@

Dettaglio ordine {{ selectedOrder.orderNumber }}

{{ isShopItem(item) ? "Shop" : "Calcolatore" }} diff --git a/frontend/src/app/features/admin/pages/admin-dashboard.component.scss b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss index 3b6c1f1..ed746e7 100644 --- a/frontend/src/app/features/admin/pages/admin-dashboard.component.scss +++ b/frontend/src/app/features/admin/pages/admin-dashboard.component.scss @@ -224,12 +224,6 @@ tbody tr.no-results:hover { white-space: nowrap; } -.order-type-badge--shop, -.item-kind-badge--shop { - background: color-mix(in srgb, var(--color-brand) 12%, white); - color: var(--color-brand); -} - .order-type-badge--mixed { background: color-mix(in srgb, #f59e0b 16%, white); color: #9a5b00; diff --git a/frontend/src/app/features/admin/pages/admin-shop.component.html b/frontend/src/app/features/admin/pages/admin-shop.component.html index 59a19d3..d8f8311 100644 --- a/frontend/src/app/features/admin/pages/admin-shop.component.html +++ b/frontend/src/app/features/admin/pages/admin-shop.component.html @@ -473,7 +473,7 @@
-
+

Dati base

@@ -565,7 +565,7 @@
-
+

Contenuti localizzati

@@ -584,13 +584,28 @@
@@ -634,106 +649,147 @@
-
+
-

SEO e social

-

Metadati globali per risultato organico e preview.

+

SEO localizzata

+

+ I campi seguono la lingua attiva. Open Graph usa gli stessi + valori della SEO. +

+
+
+ +
+
+ Lingua SEO +

Stessa lingua attiva dell'editor contenuti

+
+
+
-
-
+
-

Varianti

-

Colori, materiale interno, SKU e prezzi.

+

Materiali e prezzi

+

+ Scegli i materiali disponibili a stock. I colori vengono presi + automaticamente dai filamenti attivi a magazzino. +

+
+ Nessun materiale attivo con stock disponibile. +
+

- {{ - variant.variantLabel || - variant.colorName || - "Nuova variante" - }} + {{ material.materialCode || "Nuovo materiale" }}

-

Ordine {{ variant.sortOrder }}

+

+ {{ materialColorCount(material.materialCode) }} colori da + stock · ordine {{ material.sortOrder }} +

@@ -742,62 +798,24 @@
- - - - - - @@ -817,8 +835,8 @@ @@ -826,9 +844,9 @@
+ +
+ Colori disponibili: + + {{ materialColorPreview(material.materialCode).join(", ") }} + + +{{ materialColorCount(material.materialCode) - 6 }} altri + + + + Nessun colore disponibile attualmente a stock. + +
-
+

Immagini e modello 3D

@@ -914,15 +947,30 @@
diff --git a/frontend/src/app/features/admin/pages/admin-shop.component.scss b/frontend/src/app/features/admin/pages/admin-shop.component.scss index 4ab1882..6c0cd5c 100644 --- a/frontend/src/app/features/admin/pages/admin-shop.component.scss +++ b/frontend/src/app/features/admin/pages/admin-shop.component.scss @@ -4,6 +4,62 @@ gap: var(--space-5); } +.admin-shop .image-language-button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-width: 3.15rem; + background: #ffffff; + color: var(--color-text-muted); +} + +.admin-shop .image-language-button.empty { + opacity: 0.76; +} + +.admin-shop .image-language-button.complete { + border-color: #b8ddc2; +} + +.admin-shop .image-language-button.incomplete { + border-color: #e8c8c2; +} + +.admin-shop .image-language-button.active { + background: #fff5b8; + border-color: var(--color-brand); + color: var(--color-text); + opacity: 1; +} + +.image-language-button__label { + line-height: 1; +} + +.image-language-button__state { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1rem; + height: 1rem; + padding: 0 0.2rem; + border-radius: 999px; + background: rgba(0, 0, 0, 0.08); + font-size: 0.62rem; + font-weight: 800; + line-height: 1; +} + +.admin-shop .image-language-button.complete .image-language-button__state { + background: #dcefdc; + color: #25603b; +} + +.admin-shop .image-language-button.incomplete .image-language-button__state { + background: #f7ddd7; + color: #944329; +} + .section-header { display: flex; justify-content: space-between; @@ -61,7 +117,6 @@ .category-manager, .category-editor, .detail-stack, -.form-section, .variant-stack, .image-stack, .media-grid, @@ -71,6 +126,12 @@ gap: var(--space-3); } +.form-section { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + .panel-heading { display: flex; justify-content: space-between; @@ -211,6 +272,17 @@ gap: var(--space-2); } +.seo-counter { + justify-self: end; + font-size: 0.72rem; + font-weight: 700; + color: var(--color-text-muted); +} + +.seo-counter--danger { + color: var(--color-danger-500); +} + .product-cell { display: grid; gap: 4px; @@ -224,10 +296,6 @@ tbody tr.selected { background: #fff4c0; } -.detail-panel { - gap: var(--space-4); -} - .detail-panel .ui-meta-item { padding: var(--space-2); } @@ -277,15 +345,16 @@ tbody tr.selected { } .detail-stack { - gap: var(--space-4); + gap: var(--space-3); } .variant-card, .image-item { border: 1px solid var(--color-border); border-radius: var(--radius-md); - background: linear-gradient(180deg, #fcfcfb 0%, #ffffff 100%); + background: var(--color-bg-card); padding: var(--space-3); + box-shadow: var(--shadow-sm); } .variant-card { @@ -316,6 +385,19 @@ tbody tr.selected { justify-content: flex-end; } +.material-stock-summary { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: baseline; + color: var(--color-text-muted); + font-size: 0.95rem; +} + +.material-stock-summary strong { + color: var(--color-text); +} + .locked-panel, .loading-state, .image-fallback { diff --git a/frontend/src/app/features/admin/pages/admin-shop.component.ts b/frontend/src/app/features/admin/pages/admin-shop.component.ts index 5dc50fd..c16c6ed 100644 --- a/frontend/src/app/features/admin/pages/admin-shop.component.ts +++ b/frontend/src/app/features/admin/pages/admin-shop.component.ts @@ -22,6 +22,10 @@ import { AdminUpsertShopProductVariantPayload, AdminPublicMediaUsage, } from '../services/admin-shop.service'; +import { + AdminFilamentVariant, + AdminOperationsService, +} from '../services/admin-operations.service'; import { AdminMediaLanguage, AdminMediaTranslation, @@ -47,13 +51,8 @@ interface CategoryFormState { sortOrder: number; } -interface ProductVariantFormState { - id: string | null; - sku: string; - variantLabel: string; - colorName: string; - colorHex: string; - internalMaterialCode: string; +interface ProductMaterialFormState { + materialCode: string; priceChf: string; isDefault: boolean; isActive: boolean; @@ -66,15 +65,13 @@ interface ProductFormState { names: Record; excerpts: Record; descriptions: Record; - seoTitle: string; - seoDescription: string; - ogTitle: string; - ogDescription: string; + seoTitles: Record; + seoDescriptions: Record; indexable: boolean; isFeatured: boolean; isActive: boolean; sortOrder: number; - variants: ProductVariantFormState[]; + materials: ProductMaterialFormState[]; } interface ProductImageItem { @@ -128,6 +125,7 @@ const MAX_LIST_PANEL_WIDTH_PERCENT = 68; }) export class AdminShopComponent implements OnInit, OnDestroy { private readonly adminShopService = inject(AdminShopService); + private readonly adminOperationsService = inject(AdminOperationsService); @ViewChild('workspaceRef') private readonly workspaceRef?: ElementRef; @@ -142,6 +140,7 @@ export class AdminShopComponent implements OnInit, OnDestroy { listPanelWidthPercent = 53; categories: AdminShopCategory[] = []; products: AdminShopProduct[] = []; + stockFilamentVariants: AdminFilamentVariant[] = []; filteredProducts: AdminShopProduct[] = []; selectedProduct: AdminShopProduct | null = null; selectedProductId: string | null = null; @@ -210,10 +209,13 @@ export class AdminShopComponent implements OnInit, OnDestroy { forkJoin({ categories: this.adminShopService.getCategories(), products: this.adminShopService.getProducts(), + filamentVariants: this.adminOperationsService.getFilamentVariants(), }).subscribe({ - next: ({ categories, products }) => { + next: ({ categories, products, filamentVariants }) => { this.categories = categories; this.products = products; + this.stockFilamentVariants = + this.filterStockedFilamentVariants(filamentVariants); this.applyProductFilters(); this.ensureCategoryFilterStillValid(); this.loading = false; @@ -233,6 +235,9 @@ export class AdminShopComponent implements OnInit, OnDestroy { this.selectedProduct = null; this.selectedProductId = null; this.productImages = []; + if (this.productForm.materials.length === 0) { + this.resetProductForm(); + } return; } @@ -527,43 +532,111 @@ export class AdminShopComponent implements OnInit, OnDestroy { 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), + isContentLanguageStarted(language: ShopLanguage): boolean { + return ( + !!this.productForm.names[language].trim() || + !!this.productForm.excerpts[language].trim() || + !!this.productForm.descriptions[language].trim() + ); + } + + isContentLanguageIncomplete(language: ShopLanguage): boolean { + return ( + this.isContentLanguageStarted(language) && + !this.isContentLanguageComplete(language) + ); + } + + isSeoLanguageComplete(language: ShopLanguage): boolean { + return ( + !!this.productForm.seoTitles[language].trim() && + !!this.productForm.seoDescriptions[language].trim() + ); + } + + isSeoLanguageStarted(language: ShopLanguage): boolean { + return ( + !!this.productForm.seoTitles[language].trim() || + !!this.productForm.seoDescriptions[language].trim() + ); + } + + isSeoLanguageIncomplete(language: ShopLanguage): boolean { + return ( + this.isSeoLanguageStarted(language) && + !this.isSeoLanguageComplete(language) + ); + } + + addMaterial(): void { + const nextMaterialCode = this.nextAvailableMaterialCode(); + if (!nextMaterialCode) { + return; + } + const sortOrder = (this.productForm.materials.at(-1)?.sortOrder ?? -1) + 1; + const firstMaterial = this.productForm.materials.length === 0; + this.productForm.materials = [ + ...this.productForm.materials, + this.createEmptyMaterialForm(sortOrder, firstMaterial, nextMaterialCode), ]; } - removeVariant(index: number): void { - if (this.productForm.variants.length <= 1) { + removeMaterial(index: number): void { + if (this.productForm.materials.length <= 1) { return; } - const nextVariants = this.productForm.variants.filter( + const nextMaterials = this.productForm.materials.filter( (_, currentIndex) => currentIndex !== index, ); - if (!nextVariants.some((variant) => variant.isDefault)) { - nextVariants[0].isDefault = true; + if (!nextMaterials.some((material) => material.isDefault)) { + nextMaterials[0].isDefault = true; } - this.productForm.variants = nextVariants; + this.productForm.materials = nextMaterials; } - setDefaultVariant(index: number): void { - this.productForm.variants = this.productForm.variants.map( - (variant, currentIndex) => ({ - ...variant, + setDefaultMaterial(index: number): void { + this.productForm.materials = this.productForm.materials.map( + (material, currentIndex) => ({ + ...material, isDefault: currentIndex === index, }), ); } - onColorHexBlur(variant: ProductVariantFormState): void { - if (!variant.colorHex.trim()) { - return; + availableMaterialChoices(currentMaterialCode: string): string[] { + const normalizedCurrentMaterialCode = currentMaterialCode.trim().toUpperCase(); + const selectedCodes = new Set( + this.productForm.materials + .map((material) => material.materialCode.trim().toUpperCase()) + .filter(Boolean), + ); + + const availableCodes = this.stockMaterialCodes().filter( + (materialCode) => + materialCode === normalizedCurrentMaterialCode || + !selectedCodes.has(materialCode), + ); + + if ( + normalizedCurrentMaterialCode && + !availableCodes.includes(normalizedCurrentMaterialCode) + ) { + return [normalizedCurrentMaterialCode, ...availableCodes]; } - variant.colorHex = variant.colorHex.trim().toUpperCase(); + + return availableCodes; + } + + materialColorCount(materialCode: string): number { + return this.stockVariantsForMaterial(materialCode).length; + } + + materialColorPreview(materialCode: string): string[] { + return this.stockVariantsForMaterial(materialCode) + .map((variant) => variant.colorName.trim()) + .filter(Boolean) + .slice(0, 6); } onModelFileSelected(event: Event): void { @@ -729,6 +802,21 @@ export class AdminShopComponent implements OnInit, OnDestroy { ); } + isImageLanguageStarted(language: AdminMediaLanguage): boolean { + const translation = this.imageUploadState.translations[language]; + return ( + !!translation.title.trim() || + !!translation.altText.trim() + ); + } + + isImageLanguageIncomplete(language: AdminMediaLanguage): boolean { + return ( + this.isImageLanguageStarted(language) && + !this.isImageLanguageComplete(language) + ); + } + uploadProductImage(): void { if ( !this.selectedProduct || @@ -907,8 +995,8 @@ export class AdminShopComponent implements OnInit, OnDestroy { return product.id; } - trackVariant(_: number, variant: ProductVariantFormState): string { - return variant.id ?? `${variant.colorName}-${variant.sortOrder}`; + trackMaterial(_: number, material: ProductMaterialFormState): string { + return `${material.materialCode || 'material'}-${material.sortOrder}`; } trackImage(_: number, image: ProductImageItem): string { @@ -1087,6 +1175,7 @@ export class AdminShopComponent implements OnInit, OnDestroy { this.categoryFilter !== 'ALL' ? this.categoryFilter : (this.categories[0]?.id ?? ''); + const defaultMaterialCode = this.stockMaterialCodes()[0] ?? ''; return { categoryId: defaultCategoryId, slug: '', @@ -1108,15 +1197,25 @@ export class AdminShopComponent implements OnInit, OnDestroy { de: '', fr: '', }, - seoTitle: '', - seoDescription: '', - ogTitle: '', - ogDescription: '', + seoTitles: { + it: '', + en: '', + de: '', + fr: '', + }, + seoDescriptions: { + it: '', + en: '', + de: '', + fr: '', + }, indexable: true, isFeatured: false, isActive: true, sortOrder: 0, - variants: [this.createEmptyVariantForm(0, true)], + materials: defaultMaterialCode + ? [this.createEmptyMaterialForm(0, true, defaultMaterialCode)] + : [], }; } @@ -1124,17 +1223,13 @@ export class AdminShopComponent implements OnInit, OnDestroy { Object.assign(this.productForm, this.createEmptyProductForm()); } - private createEmptyVariantForm( + private createEmptyMaterialForm( sortOrder: number, isDefault: boolean, - ): ProductVariantFormState { + materialCode = '', + ): ProductMaterialFormState { return { - id: null, - sku: '', - variantLabel: '', - colorName: '', - colorHex: '', - internalMaterialCode: '', + materialCode, priceChf: '0.00', isDefault, isActive: true, @@ -1164,35 +1259,70 @@ export class AdminShopComponent implements OnInit, OnDestroy { de: product.descriptionDe ?? '', fr: product.descriptionFr ?? '', }, - seoTitle: product.seoTitle ?? '', - seoDescription: product.seoDescription ?? '', - ogTitle: product.ogTitle ?? '', - ogDescription: product.ogDescription ?? '', + seoTitles: { + it: product.seoTitleIt ?? '', + en: product.seoTitleEn ?? '', + de: product.seoTitleDe ?? '', + fr: product.seoTitleFr ?? '', + }, + seoDescriptions: { + it: product.seoDescriptionIt ?? '', + en: product.seoDescriptionEn ?? '', + de: product.seoDescriptionDe ?? '', + fr: product.seoDescriptionFr ?? '', + }, 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)], + materials: this.toMaterialForms(product.variants), }); } - 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 toMaterialForms( + variants: AdminShopProductVariant[], + ): ProductMaterialFormState[] { + if (!variants.length) { + const defaultMaterialCode = this.stockMaterialCodes()[0] ?? ''; + return defaultMaterialCode + ? [this.createEmptyMaterialForm(0, true, defaultMaterialCode)] + : []; + } + + const groups = new Map(); + for (const variant of variants) { + const materialCode = (variant.internalMaterialCode ?? '').trim().toUpperCase(); + if (!materialCode) { + continue; + } + const group = groups.get(materialCode) ?? []; + group.push(variant); + groups.set(materialCode, group); + } + + const materials = Array.from(groups.entries()) + .map(([materialCode, materialVariants]) => { + const sortedVariants = [...materialVariants].sort( + (left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0), + ); + const firstVariant = sortedVariants[0]; + return { + materialCode, + priceChf: Number(firstVariant?.priceChf ?? 0).toFixed(2), + isDefault: materialVariants.some((variant) => variant.isDefault), + isActive: materialVariants.some((variant) => variant.isActive), + sortOrder: Math.min( + ...materialVariants.map((variant) => variant.sortOrder ?? 0), + ), + }; + }) + .sort((left, right) => left.sortOrder - right.sortOrder); + + if (!materials.some((material) => material.isDefault) && materials[0]) { + materials[0].isDefault = true; + } + + return materials; } private validateProductForm(): string | null { @@ -1206,60 +1336,49 @@ export class AdminShopComponent implements OnInit, OnDestroy { if (!this.productForm.names[language].trim()) { return `Il nome prodotto ${this.languageLabels[language]} è obbligatorio.`; } + if (this.productForm.seoDescriptions[language].trim().length > 160) { + return `La SEO description ${this.languageLabels[language]} deve stare sotto i 160 caratteri.`; + } } - if (this.productForm.variants.length === 0) { - return 'È richiesta almeno una variante.'; + if (this.productForm.materials.length === 0) { + return 'Seleziona almeno un materiale disponibile a stock.'; } - 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 materialCodes = new Set(); + for (const material of this.productForm.materials) { + const materialCode = material.materialCode.trim().toUpperCase(); + if (!materialCode) { + return 'Ogni riga materiale richiede un materiale selezionato.'; } - const colorKey = variant.colorName.trim().toLowerCase(); - if (colorNames.has(colorKey)) { - return `Il colore "${variant.colorName.trim()}" è duplicato.`; + if (materialCodes.has(materialCode)) { + return `Il materiale "${materialCode}" è duplicato.`; } - colorNames.add(colorKey); - if (!variant.internalMaterialCode.trim()) { - return `La variante "${variant.colorName.trim()}" richiede un codice materiale interno.`; + materialCodes.add(materialCode); + if (!this.stockMaterialCodes().includes(materialCode)) { + return `Il materiale "${materialCode}" non è disponibile nello stock attivo.`; } - const price = Number(variant.priceChf); + if (this.stockVariantsForMaterial(materialCode).length === 0) { + return `Il materiale "${materialCode}" non ha colori disponibili a stock.`; + } + + const price = Number(material.priceChf); if (!Number.isFinite(price) || price < 0) { - return `La variante "${variant.colorName.trim()}" ha un prezzo non valido.`; + return `Il materiale "${materialCode}" 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) { + if (material.isDefault) { defaultCount += 1; } } if (defaultCount !== 1) { - return 'Devi impostare una sola variante predefinita.'; + return 'Devi impostare un solo materiale predefinito.'; } 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, - })); + const variants = this.buildVariantsFromMaterials(); return { categoryId: this.productForm.categoryId, @@ -1279,10 +1398,26 @@ export class AdminShopComponent implements OnInit, OnDestroy { 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), + seoTitle: this.optionalValue(this.productForm.seoTitles['it']), + seoTitleIt: this.optionalValue(this.productForm.seoTitles['it']), + seoTitleEn: this.optionalValue(this.productForm.seoTitles['en']), + seoTitleDe: this.optionalValue(this.productForm.seoTitles['de']), + seoTitleFr: this.optionalValue(this.productForm.seoTitles['fr']), + seoDescription: this.optionalValue(this.productForm.seoDescriptions['it']), + seoDescriptionIt: this.optionalValue( + this.productForm.seoDescriptions['it'], + ), + seoDescriptionEn: this.optionalValue( + this.productForm.seoDescriptions['en'], + ), + seoDescriptionDe: this.optionalValue( + this.productForm.seoDescriptions['de'], + ), + seoDescriptionFr: this.optionalValue( + this.productForm.seoDescriptions['fr'], + ), + ogTitle: this.optionalValue(this.productForm.seoTitles['it']), + ogDescription: this.optionalValue(this.productForm.seoDescriptions['it']), indexable: this.productForm.indexable, isFeatured: this.productForm.isFeatured, isActive: this.productForm.isActive, @@ -1291,6 +1426,163 @@ export class AdminShopComponent implements OnInit, OnDestroy { }; } + private buildVariantsFromMaterials(): AdminUpsertShopProductVariantPayload[] { + const persistedDefaultVariant = this.selectedProduct?.variants.find( + (variant) => variant.isDefault, + ); + const existingVariantsByKey = new Map( + (this.selectedProduct?.variants ?? []).map((variant) => [ + this.variantKey( + variant.internalMaterialCode, + variant.colorName, + variant.colorHex, + ), + variant, + ]), + ); + const persistedDefaultKey = persistedDefaultVariant + ? this.variantKey( + persistedDefaultVariant.internalMaterialCode, + persistedDefaultVariant.colorName, + persistedDefaultVariant.colorHex, + ) + : null; + + const variants: AdminUpsertShopProductVariantPayload[] = []; + let defaultAssigned = false; + + const sortedMaterials = [...this.productForm.materials].sort( + (left, right) => left.sortOrder - right.sortOrder, + ); + + for (const material of sortedMaterials) { + const materialCode = material.materialCode.trim().toUpperCase(); + const stockVariants = this.stockVariantsForMaterial(materialCode); + let defaultVariantKeyForMaterial: string | null = null; + + if (material.isDefault && persistedDefaultKey) { + defaultVariantKeyForMaterial = stockVariants + .map((variant) => + this.variantKey(materialCode, variant.colorName, variant.colorHex), + ) + .find((variantKey) => variantKey === persistedDefaultKey) ?? null; + } + + stockVariants.forEach((stockVariant, colorIndex) => { + const variantKey = this.variantKey( + materialCode, + stockVariant.colorName, + stockVariant.colorHex, + ); + const existingVariant = existingVariantsByKey.get(variantKey); + const isDefault = + material.isDefault && + !defaultAssigned && + (defaultVariantKeyForMaterial + ? variantKey === defaultVariantKeyForMaterial + : colorIndex === 0); + + variants.push({ + id: existingVariant?.id, + sku: this.optionalValue(existingVariant?.sku ?? ''), + variantLabel: materialCode, + colorName: stockVariant.colorName.trim(), + colorHex: this.optionalValue(stockVariant.colorHex ?? '')?.toUpperCase(), + internalMaterialCode: materialCode, + priceChf: Number(material.priceChf), + isDefault, + isActive: material.isActive, + sortOrder: material.sortOrder * 100 + colorIndex, + }); + + if (isDefault) { + defaultAssigned = true; + } + }); + } + + if (!defaultAssigned && variants[0]) { + variants[0].isDefault = true; + } + + return variants; + } + + stockMaterialCodes(): string[] { + return Array.from( + new Set( + this.stockFilamentVariants.map((variant) => + variant.materialCode.trim().toUpperCase(), + ), + ), + ).sort((left, right) => left.localeCompare(right)); + } + + private stockVariantsForMaterial(materialCode: string): AdminFilamentVariant[] { + const targetMaterialCode = materialCode.trim().toUpperCase(); + const seenKeys = new Set(); + + return this.stockFilamentVariants + .filter( + (variant) => + variant.materialCode.trim().toUpperCase() === targetMaterialCode, + ) + .sort((left, right) => { + const leftName = `${left.colorName} ${left.variantDisplayName}`.trim(); + const rightName = `${right.colorName} ${right.variantDisplayName}`.trim(); + return leftName.localeCompare(rightName); + }) + .filter((variant) => { + const key = this.variantKey( + targetMaterialCode, + variant.colorName, + variant.colorHex, + ); + if (seenKeys.has(key)) { + return false; + } + seenKeys.add(key); + return true; + }); + } + + private nextAvailableMaterialCode(): string | null { + const selectedCodes = new Set( + this.productForm.materials + .map((material) => material.materialCode.trim().toUpperCase()) + .filter(Boolean), + ); + + return ( + this.stockMaterialCodes().find((materialCode) => !selectedCodes.has(materialCode)) ?? + null + ); + } + + private filterStockedFilamentVariants( + filamentVariants: AdminFilamentVariant[], + ): AdminFilamentVariant[] { + return filamentVariants.filter( + (variant) => + variant.isActive && + Number(variant.stockFilamentGrams ?? 0) > 0 && + !!variant.materialCode?.trim() && + !!variant.colorName?.trim(), + ); + } + + private variantKey( + materialCode: string | null | undefined, + colorName: string | null | undefined, + colorHex: string | null | undefined, + ): string { + return [ + (materialCode ?? '').trim().toUpperCase(), + (colorName ?? '').trim().toLowerCase(), + (colorHex ?? '').trim().toUpperCase(), + ].join('|'); + } + private updateSelectedProduct(product: AdminShopProduct): void { this.selectedProduct = product; this.selectedProductId = product.id; @@ -1454,6 +1746,10 @@ export class AdminShopComponent implements OnInit, OnDestroy { return normalized ? normalized : undefined; } + seoDescriptionLength(language: ShopLanguage): number { + return this.productForm.seoDescriptions[language].trim().length; + } + private slugify(source: string): string { return source .normalize('NFD') diff --git a/frontend/src/app/features/admin/services/admin-shop.service.ts b/frontend/src/app/features/admin/services/admin-shop.service.ts index 2e73890..bb14e24 100644 --- a/frontend/src/app/features/admin/services/admin-shop.service.ts +++ b/frontend/src/app/features/admin/services/admin-shop.service.ts @@ -131,7 +131,15 @@ export interface AdminShopProduct { descriptionDe: string | null; descriptionFr: string | null; seoTitle: string | null; + seoTitleIt: string | null; + seoTitleEn: string | null; + seoTitleDe: string | null; + seoTitleFr: string | null; seoDescription: string | null; + seoDescriptionIt: string | null; + seoDescriptionEn: string | null; + seoDescriptionDe: string | null; + seoDescriptionFr: string | null; ogTitle: string | null; ogDescription: string | null; indexable: boolean; @@ -189,7 +197,15 @@ export interface AdminUpsertShopProductPayload { descriptionDe?: string; descriptionFr?: string; seoTitle?: string; + seoTitleIt?: string; + seoTitleEn?: string; + seoTitleDe?: string; + seoTitleFr?: string; seoDescription?: string; + seoDescriptionIt?: string; + seoDescriptionEn?: string; + seoDescriptionDe?: string; + seoDescriptionFr?: string; ogTitle?: string; ogDescription?: string; indexable: boolean; 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 bf32fb5..34c3587 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 @@ -1,5 +1,5 @@
- + @if (imageUrl(); as imageUrl) { + @if (hasModelPreview()) { + + {{ "SHOP.MODEL_3D" | translate }} + + } @if (cartQuantity() > 0) { {{ "SHOP.IN_CART_SHORT" | translate: { count: cartQuantity() } }} @@ -24,13 +29,12 @@ - {{ - "SHOP.DETAILS" | translate - }} +
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 ddda67b..65885ad 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 @@ -21,8 +21,9 @@ .media { position: relative; display: block; - min-height: 244px; + aspect-ratio: 1 / 1; background: #f2eee5; + overflow: hidden; } .media img { @@ -35,7 +36,6 @@ .image-fallback { width: 100%; height: 100%; - min-height: 244px; display: flex; align-items: flex-end; padding: var(--space-5); @@ -87,6 +87,12 @@ color: #fff; } +.badge-model { + background: rgba(255, 255, 255, 0.92); + color: var(--color-text-muted); + border: 1px solid rgba(16, 24, 32, 0.08); +} + .content { display: grid; gap: var(--space-4); @@ -100,8 +106,7 @@ gap: 0.55rem; } -.category, -.model-pill { +.category { font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.08em; @@ -112,15 +117,6 @@ font-weight: 700; } -.model-pill { - display: inline-flex; - padding: 0.18rem 0.55rem; - border-radius: 999px; - border: 1px solid rgba(16, 24, 32, 0.08); - color: var(--color-text-muted); - background: rgba(255, 255, 255, 0.72); -} - .name { margin: 0; font-size: 1.2rem; @@ -135,7 +131,13 @@ .excerpt { margin: 0; color: var(--color-text-muted); - line-height: 1.55; + line-height: 1.45; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + text-overflow: ellipsis; } .footer { @@ -145,6 +147,12 @@ gap: var(--space-4); } +.footer-actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + .pricing { display: grid; gap: 0.1rem; @@ -160,27 +168,49 @@ color: var(--color-text-muted); } +.cart-btn, .view-btn { display: inline-flex; align-items: center; + justify-content: center; min-height: 2.35rem; padding: 0 0.9rem; border-radius: 999px; - background: rgba(16, 24, 32, 0.06); - color: var(--color-neutral-900); font-size: 0.9rem; font-weight: 600; + border: 1px solid transparent; +} + +.cart-btn { + background: var(--color-brand); + color: var(--color-neutral-900); + border-color: color-mix(in srgb, var(--color-brand) 72%, #d5ac00); + cursor: pointer; +} + +.cart-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.view-btn { + background: rgba(16, 24, 32, 0.06); + color: var(--color-neutral-900); text-decoration: none; } @media (max-width: 640px) { - .media, - .image-fallback { - min-height: 220px; - } - .footer { align-items: start; flex-direction: column; } + + .footer-actions { + width: 100%; + } + + .cart-btn, + .view-btn { + flex: 1; + } } diff --git a/frontend/src/app/features/shop/components/product-card/product-card.component.ts b/frontend/src/app/features/shop/components/product-card/product-card.component.ts index c38d99c..81e1b65 100644 --- a/frontend/src/app/features/shop/components/product-card/product-card.component.ts +++ b/frontend/src/app/features/shop/components/product-card/product-card.component.ts @@ -1,8 +1,10 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, inject, input } from '@angular/core'; -import { RouterLink } from '@angular/router'; +import { Component, computed, inject, input, signal } from '@angular/core'; +import { Router, RouterLink } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; +import { finalize } from 'rxjs'; import { ShopProductSummary, ShopService } from '../../services/shop.service'; +import { ShopRouteService } from '../../services/shop-route.service'; @Component({ selector: 'app-product-card', @@ -12,16 +14,17 @@ import { ShopProductSummary, ShopService } from '../../services/shop.service'; styleUrl: './product-card.component.scss', }) export class ProductCardComponent { + private readonly router = inject(Router); private readonly shopService = inject(ShopService); + private readonly shopRouteService = inject(ShopRouteService); readonly product = input.required(); readonly cartQuantity = input(0); + readonly addingToCart = signal(false); - readonly productLink = computed(() => [ - '/shop', - this.product().category.slug, - this.product().slug, - ]); + readonly productLink = computed(() => + this.shopRouteService.productCommands(this.product()), + ); readonly imageUrl = computed(() => { const image = this.product().primaryImage; @@ -32,6 +35,11 @@ export class ProductCardComponent { ); }); + readonly hasModelPreview = computed(() => { + const model = this.product().model3d; + return !!(model?.url && model.originalFilename); + }); + priceLabel(): number { return this.product().priceFromChf; } @@ -39,4 +47,31 @@ export class ProductCardComponent { hasPriceRange(): boolean { return this.product().priceFromChf !== this.product().priceToChf; } + + defaultVariantId(): string | null { + return this.product().defaultVariant?.id ?? null; + } + + addToCart(): void { + const variantId = this.defaultVariantId(); + if (!variantId || this.addingToCart()) { + return; + } + + this.addingToCart.set(true); + this.shopService + .addToCart(variantId, 1) + .pipe(finalize(() => this.addingToCart.set(false))) + .subscribe({ + error: () => { + // Keep card UX simple: product detail handles error messaging in depth. + }, + }); + } + + navigationState(): { shopReturnUrl: string } { + return { + shopReturnUrl: this.router.url, + }; + } } diff --git a/frontend/src/app/features/shop/product-detail.component.html b/frontend/src/app/features/shop/product-detail.component.html index 6a05961..e0d50e9 100644 --- a/frontend/src/app/features/shop/product-detail.component.html +++ b/frontend/src/app/features/shop/product-detail.component.html @@ -1,8 +1,8 @@
- + @if (loading()) {
@@ -13,18 +13,39 @@ @if (error()) {
{{ error() | translate }}
} @else { - @if (product(); as p) { + @if (product(); as p) {
+ @if (galleryImages().length > 1) { + + + } + @if (imageUrl(selectedImage()); as imageUrl) { @if (galleryImages().length > 1) { -
+
@for (image of galleryImages(); track image.mediaAssetId) { +
}
@@ -120,12 +125,12 @@

- +

- {{ "SHOP.SELECT_COLOR" | translate }} + {{ "SHOP.SELECT_MATERIAL" | translate }}

{{ priceLabel() | currency: "CHF" }}

@@ -140,29 +145,119 @@ }
-
- @for (variant of p.variants; track variant.id) { + @if (materialOptions().length > 1) { +
+ @for (material of materialOptions(); track material.key) { + + } +
+ } + + @if ( + materialOptions().length > 1 && + selectedMaterialProperties().length > 0 + ) { +
+ @for ( + property of selectedMaterialProperties(); + track property.labelKey + ) { +
+ {{ property.labelKey | translate }} + {{ property.valueKey | translate }} +
+ } +
+ } + +
+
+

+ {{ "SHOP.SELECT_COLOR" | translate }} +

+
+ + @if (selectedVariant(); as activeVariant) { } + + @if (colorPopupOpen()) { + +
+
+ {{ (selectedMaterial()?.label || "") | uppercase }} +
+ +
+ @for (variant of colorOptions(); track variant.id) { + + } +
+
+ }
@@ -213,6 +308,51 @@ }
+ + @if (modelModalOpen()) { + + + } } } } diff --git a/frontend/src/app/features/shop/product-detail.component.scss b/frontend/src/app/features/shop/product-detail.component.scss index e5d66af..0b5765a 100644 --- a/frontend/src/app/features/shop/product-detail.component.scss +++ b/frontend/src/app/features/shop/product-detail.component.scss @@ -1,11 +1,11 @@ .product-page { - padding: var(--space-8) 0 var(--space-12); - background: linear-gradient(180deg, #faf7ef 0%, var(--color-bg) 15rem); + padding: var(--space-5) 0 var(--space-10); + background: var(--color-bg); } .wrapper { display: grid; - gap: var(--space-6); + gap: var(--space-4); } .back-link, @@ -13,6 +13,15 @@ color: var(--color-text-muted); } +.back-link { + padding: 0; + border: 0; + background: none; + font: inherit; + cursor: pointer; + text-align: left; +} + .breadcrumbs { display: flex; flex-wrap: wrap; @@ -32,26 +41,35 @@ gap: var(--space-5); } +.info-column { + gap: var(--space-3); + align-content: start; +} + .hero-media { - min-height: 480px; + position: relative; + aspect-ratio: 1 / 1; + min-height: 420px; + max-height: 620px; overflow: hidden; border-radius: 1.25rem; - border: 1px solid rgba(16, 24, 32, 0.08); + border: 1px solid rgba(16, 24, 32, 0.12); background: #f2eee5; - box-shadow: 0 12px 28px rgba(16, 24, 32, 0.05); + box-shadow: 0 6px 14px rgba(16, 24, 32, 0.04); } .hero-image { width: 100%; height: 100%; display: block; - object-fit: cover; + object-fit: contain; + background: #f2eee5; } .image-fallback { width: 100%; height: 100%; - min-height: 480px; + min-height: 420px; display: flex; align-items: flex-end; padding: var(--space-6); @@ -75,25 +93,62 @@ font-weight: 700; } -.thumb-grid { +.hero-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 2; + width: 2.2rem; + height: 2.2rem; + border-radius: 999px; + border: 1px solid rgba(16, 24, 32, 0.14); + background: rgba(255, 255, 255, 0.92); + color: var(--color-text); + font-size: 1.4rem; + line-height: 1; display: grid; - gap: var(--space-3); - grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); + place-items: center; + cursor: pointer; +} + +.hero-nav:hover { + border-color: rgba(16, 24, 32, 0.28); +} + +.hero-nav-prev { + left: 0.6rem; +} + +.hero-nav-next { + right: 0.6rem; +} + +.thumb-grid { + display: contents; +} + +.thumb-strip { + display: flex; + gap: 0.65rem; + overflow-x: auto; + padding: 0.1rem; + scrollbar-width: thin; } .thumb { - min-height: 92px; + flex: 0 0 92px; + height: 92px; overflow: hidden; border-radius: 0.85rem; - border: 1px solid var(--color-border); - background: rgba(255, 255, 255, 0.78); + border: 1px solid rgba(16, 24, 32, 0.12); + background: #fff; padding: 0; cursor: pointer; } .thumb.active { - border-color: rgba(250, 207, 10, 0.65); - box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.22); + border-color: rgba(250, 207, 10, 0.9); + box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.2); } .thumb img, @@ -105,15 +160,30 @@ object-fit: cover; } -.viewer-card { - display: block; -} - -.viewer-head { +.model-launch-row { display: flex; justify-content: space-between; gap: var(--space-4); - margin-bottom: var(--space-4); + align-items: center; + padding: 0.9rem 1rem; + border: 1px solid rgba(16, 24, 32, 0.12); + border-radius: 1rem; + background: #fff; +} + +.model-open-btn { + height: 2.35rem; + padding: 0 0.95rem; + border-radius: 0.65rem; + border: 1px solid rgba(16, 24, 32, 0.18); + background: #fff; + color: var(--color-text); + font-weight: 600; + cursor: pointer; +} + +.model-open-btn:hover { + border-color: rgba(16, 24, 32, 0.3); } .viewer-kicker, @@ -134,6 +204,12 @@ text-align: right; } +.dimensions-inline { + display: flex; + gap: 0.8rem; + text-align: left; +} + .viewer-state { display: grid; place-items: center; @@ -147,9 +223,70 @@ color: var(--color-danger-600); } +.viewer-loading { + gap: 0.6rem; +} + +.spinner { + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + border: 2px solid rgba(16, 24, 32, 0.16); + border-top-color: var(--color-secondary-600); + animation: spin 0.9s linear infinite; +} + +.model-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1100; + border: 0; + background: rgba(16, 24, 32, 0.36); +} + +.model-modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(940px, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + display: grid; + grid-template-rows: auto 1fr; + gap: 0.9rem; + z-index: 1101; + padding: 1rem; + border-radius: 1rem; + border: 1px solid rgba(16, 24, 32, 0.16); + background: #fff; + box-shadow: 0 22px 40px rgba(16, 24, 32, 0.3); +} + +.model-modal-head { + display: flex; + justify-content: space-between; + gap: var(--space-3); + align-items: start; +} + +.model-close-btn { + width: 2rem; + height: 2rem; + border-radius: 999px; + border: 1px solid rgba(16, 24, 32, 0.16); + background: #fff; + cursor: pointer; +} + +.model-modal-body { + min-height: 260px; + max-height: calc(100vh - 11rem); + overflow: auto; +} + .title-block { display: grid; - gap: var(--space-3); + gap: 0.4rem; } .title-meta { @@ -171,19 +308,21 @@ } h1 { - font-size: clamp(2rem, 2vw + 1.2rem, 3.2rem); + margin: 0; + font-size: clamp(1.9rem, 1.45vw + 1.15rem, 2.8rem); + line-height: 1.06; } .excerpt, .description-block p { margin: 0; color: var(--color-text-muted); - line-height: 1.7; + line-height: 1.6; } .purchase-card { display: grid; - gap: var(--space-5); + gap: 0.78rem; } .price-row, @@ -206,52 +345,243 @@ h1 { font-weight: 600; } -.variant-grid { +.material-grid { display: grid; - gap: 0.7rem; + gap: 0.55rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } -.variant-option { +.material-option { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); - padding: 0.9rem 1rem; - border-radius: 1rem; - border: 1px solid var(--color-border); - background: rgba(255, 255, 255, 0.86); + padding: 0.68rem 0.8rem; + border-radius: 0.85rem; + border: 1px solid rgba(16, 24, 32, 0.14); + background: #fff; cursor: pointer; text-align: left; transition: border-color 0.18s ease, - transform 0.18s ease, + background 0.18s ease, box-shadow 0.18s ease; } -.variant-option.active { - border-color: rgba(250, 207, 10, 0.6); - box-shadow: 0 10px 24px rgba(16, 24, 32, 0.08); - transform: translateY(-1px); +.material-option:hover { + border-color: rgba(16, 24, 32, 0.26); + box-shadow: 0 2px 8px rgba(16, 24, 32, 0.06); } -.variant-swatch { - width: 1.2rem; - height: 1.2rem; - border-radius: 50%; - border: 1px solid rgba(16, 24, 32, 0.12); - flex: 0 0 auto; +.material-option.active { + border-color: rgba(250, 207, 10, 0.95); + background: rgba(250, 207, 10, 0.1); + box-shadow: 0 0 0 1px rgba(250, 207, 10, 0.35); } -.variant-copy { +.material-copy { display: grid; - gap: 0.12rem; - flex: 1; + gap: 0.14rem; } -.variant-copy small { +.material-copy strong { + font-size: 0.95rem; +} + +.material-copy small { + font-size: 0.82rem; color: var(--color-text-muted); } +.material-option > strong { + font-size: 1.04rem; +} + +.property-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; +} + +.property-pill { + display: grid; + gap: 0.16rem; + padding: 0.58rem 0.65rem; + border-radius: 0.8rem; + border: 1px solid rgba(16, 24, 32, 0.12); + background: #fff; +} + +.property-pill span { + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.property-pill strong { + font-size: 0.9rem; +} + +.property-pill.tone-strong { + border-left: 3px solid rgba(4, 120, 87, 0.7); +} + +.property-pill.tone-neutral { + border-left: 3px solid rgba(37, 99, 235, 0.7); +} + +.property-pill.tone-soft { + border-left: 3px solid rgba(245, 158, 11, 0.7); +} + +.color-selector-block { + position: relative; + display: grid; + gap: 0.4rem; + padding: 0; + border-radius: 1rem; + border: 0; + background: transparent; +} + +.selector-head { + display: block; +} + +.color-trigger { + width: 100%; + max-width: 230px; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.35rem 0.55rem; + border-radius: 0.72rem; + border: 1px solid rgba(16, 24, 32, 0.14); + background: #fff; + text-align: left; + cursor: pointer; + transition: + border-color 0.16s ease, + box-shadow 0.16s ease; +} + +.color-trigger:hover { + border-color: rgba(16, 24, 32, 0.28); +} + +.color-trigger.open { + border-color: rgba(250, 207, 10, 0.95); + box-shadow: 0 0 0 2px rgba(250, 207, 10, 0.28); +} + +.color-trigger__ring { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + border: 2px solid rgba(250, 207, 10, 0.95); +} + +.color-trigger__swatch { + width: 1.22rem; + height: 1.22rem; + border-radius: 50%; + border: 1px solid rgba(16, 24, 32, 0.16); +} + +.color-trigger__copy { + display: grid; + gap: 0.08rem; +} + +.color-trigger__copy strong { + font-size: 0.88rem; +} + +.color-trigger__copy small { + font-size: 0.76rem; + color: var(--color-text-muted); +} + +.color-popup-backdrop { + position: fixed; + inset: 0; + z-index: 999; + border: 0; + background: transparent; +} + +.color-popup { + position: absolute; + top: calc(100% + 0.45rem); + left: 0; + z-index: 1000; + width: min(340px, 100%); + padding: 0.9rem; + border-radius: 0.85rem; + border: 1px solid rgba(16, 24, 32, 0.12); + background: #fff; + box-shadow: 0 12px 24px rgba(16, 24, 32, 0.14); +} + +.color-popup__category { + margin-bottom: 0.75rem; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--color-text-muted); +} + +.color-popup__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem 0.55rem; +} + +.color-popup__item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; + border: 0; + background: transparent; + cursor: pointer; + text-align: center; + color: inherit; +} + +.color-popup__ring { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + border: 2px solid transparent; + transition: border-color 0.15s ease; +} + +.color-popup__ring.active { + border-color: rgba(250, 207, 10, 0.95); +} + +.color-popup__swatch { + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + border: 1px solid rgba(16, 24, 32, 0.18); +} + +.color-popup__name { + font-size: 0.8rem; + line-height: 1.2; + color: var(--color-text); +} + .qty-control { display: inline-flex; align-items: center; @@ -292,11 +622,21 @@ h1 { .description-block { display: grid; - gap: var(--space-3); + gap: 0.55rem; } .description-block h2 { - font-size: 1.2rem; + margin: 0; + font-size: 1.45rem; +} + +.description-block p { + font-size: 1.06rem; + line-height: 1.75; +} + +:host ::ng-deep app-card.purchase-shell .card-body { + padding: 0.95rem 1rem; } .state-card, @@ -334,15 +674,28 @@ h1 { } } +@keyframes spin { + to { + transform: rotate(360deg); + } +} + @media (max-width: 960px) { .detail-grid, .skeleton-grid { grid-template-columns: 1fr; } + + .property-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .purchase-card { + gap: 0.82rem; + } } @media (max-width: 640px) { - .viewer-head, .price-row, .quantity-row { flex-direction: column; @@ -351,6 +704,55 @@ h1 { .hero-media, .image-fallback { - min-height: 320px; + min-height: 300px; + } + + .selector-head { + display: block; + } + + .property-grid { + grid-template-columns: 1fr; + } + + .material-grid { + grid-template-columns: 1fr; + } + + .color-trigger { + max-width: 100%; + } + + .color-popup { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(340px, calc(100vw - 1.5rem)); + max-height: min(72vh, 440px); + overflow-y: auto; + } + + .model-launch-row { + flex-direction: column; + align-items: flex-start; + } + + .dimensions-inline { + flex-wrap: wrap; + } + + .model-modal { + width: calc(100vw - 1rem); + max-height: calc(100vh - 1rem); + padding: 0.8rem; + } + + .model-modal-body { + max-height: calc(100vh - 9.5rem); + } + + :host ::ng-deep app-card.purchase-shell .card-body { + padding: 0.82rem 0.82rem; } } diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index e9e74c5..c9c0a77 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -22,6 +22,20 @@ import { ShopProductVariantOption, ShopService, } from './services/shop.service'; +import { ShopRouteService } from './services/shop-route.service'; + +interface ShopMaterialOption { + key: string; + label: string; + variants: ShopProductVariantOption[]; + priceFromChf: number; +} + +interface ShopMaterialProperty { + labelKey: string; + valueKey: string; + tone: 'neutral' | 'strong' | 'soft'; +} @Component({ selector: 'app-product-detail', @@ -44,6 +58,7 @@ export class ProductDetailComponent { private readonly translate = inject(TranslateService); private readonly seoService = inject(SeoService); private readonly languageService = inject(LanguageService); + private readonly shopRouteService = inject(ShopRouteService); readonly shopService = inject(ShopService); readonly categorySlug = input(); @@ -57,6 +72,9 @@ export class ProductDetailComponent { readonly quantity = signal(1); readonly isAddingToCart = signal(false); readonly addSuccess = signal(false); + readonly selectedMaterialKey = signal(null); + readonly colorPopupOpen = signal(false); + readonly modelModalOpen = signal(false); readonly modelLoading = signal(false); readonly modelError = signal(false); @@ -76,6 +94,59 @@ export class ProductDetailComponent { ); }); + readonly materialOptions = computed(() => { + const product = this.product(); + if (!product) { + return []; + } + + const groups = new Map(); + for (const variant of product.variants) { + const label = this.materialLabelForVariant(variant); + const key = label.toLowerCase(); + const group = groups.get(key); + if (group) { + group.variants.push(variant); + group.priceFromChf = Math.min(group.priceFromChf, variant.priceChf); + continue; + } + + groups.set(key, { + key, + label, + variants: [variant], + priceFromChf: variant.priceChf, + }); + } + + return Array.from(groups.values()); + }); + + readonly selectedMaterial = computed(() => { + const selectedKey = this.selectedMaterialKey(); + const materials = this.materialOptions(); + if (!materials.length) { + return null; + } + return ( + materials.find((material) => material.key === selectedKey) ?? + materials.find((material) => + material.variants.some( + (variant) => variant.id === this.selectedVariant()?.id, + ), + ) ?? + materials[0] + ); + }); + + readonly colorOptions = computed(() => + this.selectedMaterial()?.variants ?? [], + ); + + readonly selectedMaterialProperties = computed(() => + this.materialPropertiesFor(this.selectedMaterial()?.label), + ); + readonly galleryImages = computed(() => { const product = this.product(); if (!product) { @@ -103,6 +174,15 @@ export class ProductDetailComponent { ); }); + readonly selectedImageIndex = computed(() => { + const images = this.galleryImages(); + const selectedAssetId = this.selectedImageAssetId(); + const index = images.findIndex( + (image) => image.mediaAssetId === selectedAssetId, + ); + return index >= 0 ? index : 0; + }); + readonly selectedVariantCartQuantity = computed(() => this.shopService.quantityForVariant(this.selectedVariant()?.id), ); @@ -131,6 +211,8 @@ export class ProductDetailComponent { this.error.set(null); this.addSuccess.set(false); this.modelError.set(false); + this.colorPopupOpen.set(false); + this.modelModalOpen.set(false); }), switchMap(([productSlug]) => { if (!productSlug) { @@ -139,7 +221,7 @@ export class ProductDetailComponent { return of(null); } - return this.shopService.getProduct(productSlug).pipe( + return this.shopService.getProductByPublicPath(productSlug).pipe( catchError((error) => { this.product.set(null); this.selectedVariantId.set(null); @@ -165,24 +247,22 @@ export class ProductDetailComponent { this.selectedVariantId.set( product.defaultVariant?.id ?? product.variants[0]?.id ?? null, ); + this.selectedMaterialKey.set( + this.materialKeyForVariant( + product.defaultVariant ?? product.variants[0] ?? null, + ), + ); this.selectedImageAssetId.set( product.primaryImage?.mediaAssetId ?? product.images[0]?.mediaAssetId ?? null, ); this.quantity.set(1); + this.syncPublicUrl(product); this.applySeo(product); - - if (product.model3d?.url && product.model3d.originalFilename) { - this.loadModelPreview( - product.model3d.url, - product.model3d.originalFilename, - ); - } else { - this.modelFile.set(null); - this.modelLoading.set(false); - this.modelError.set(false); - } + this.modelFile.set(null); + this.modelLoading.set(false); + this.modelError.set(false); }); } @@ -201,8 +281,45 @@ export class ProductDetailComponent { this.selectedImageAssetId.set(mediaAssetId); } + showPreviousImage(): void { + const images = this.galleryImages(); + if (images.length < 2) { + return; + } + const nextIndex = + (this.selectedImageIndex() - 1 + images.length) % images.length; + this.selectedImageAssetId.set(images[nextIndex].mediaAssetId); + } + + showNextImage(): void { + const images = this.galleryImages(); + if (images.length < 2) { + return; + } + const nextIndex = (this.selectedImageIndex() + 1) % images.length; + this.selectedImageAssetId.set(images[nextIndex].mediaAssetId); + } + selectVariant(variant: ShopProductVariantOption): void { this.selectedVariantId.set(variant.id); + this.selectedMaterialKey.set(this.materialKeyForVariant(variant)); + this.colorPopupOpen.set(false); + this.addSuccess.set(false); + } + + selectMaterial(materialKey: string): void { + this.selectedMaterialKey.set(materialKey); + this.colorPopupOpen.set(false); + const material = this.materialOptions().find( + (item) => item.key === materialKey, + ); + const nextVariant = + material?.variants.find((variant) => variant.isDefault) ?? + material?.variants[0] ?? + null; + if (nextVariant) { + this.selectedVariantId.set(nextVariant.id); + } this.addSuccess.set(false); } @@ -263,9 +380,67 @@ export class ProductDetailComponent { return variant.colorHex || '#d5d8de'; } + materialPriceLabel(material: ShopMaterialOption): number { + return material.priceFromChf; + } + + materialColorCount(material: ShopMaterialOption): number { + return material.variants.length; + } + + toggleColorPopup(): void { + this.colorPopupOpen.update((open) => !open); + } + + closeColorPopup(): void { + this.colorPopupOpen.set(false); + } + + openModelModal(): void { + const model = this.product()?.model3d; + if (!model) { + return; + } + + this.colorPopupOpen.set(false); + this.modelModalOpen.set(true); + + if (this.modelFile() || this.modelLoading()) { + return; + } + + this.loadModelPreview(model.url, model.originalFilename); + } + + closeModelModal(): void { + this.modelModalOpen.set(false); + } + + shopRootLink(): string[] { + return this.shopRouteService.shopRootCommands(); + } + + categoryLink(slug: string | null | undefined): string[] { + return this.shopRouteService.shopRootCommands(slug); + } + productLinkRoot(): string[] { const categorySlug = this.product()?.category.slug || this.categorySlug(); - return categorySlug ? ['/shop', categorySlug] : ['/shop']; + return this.shopRouteService.shopRootCommands(categorySlug); + } + + goBackToShop(): void { + const returnUrl = + typeof history.state?.shopReturnUrl === 'string' + ? history.state.shopReturnUrl + : null; + + if (returnUrl && this.shopRouteService.isCatalogUrl(returnUrl)) { + void this.router.navigateByUrl(returnUrl); + return; + } + + void this.router.navigate(this.productLinkRoot()); } private loadModelPreview(urlOrPath: string, filename: string): void { @@ -318,4 +493,130 @@ export class ProductDetailComponent { ogDescription: description, }); } + + private materialLabelForVariant(variant: ShopProductVariantOption | null): string { + return String(variant?.variantLabel || '').trim() || 'Standard'; + } + + private materialKeyForVariant(variant: ShopProductVariantOption | null): string | null { + if (!variant) { + return null; + } + return this.materialLabelForVariant(variant).toLowerCase(); + } + + private materialPropertiesFor( + materialLabel: string | null | undefined, + ): ShopMaterialProperty[] { + const normalized = String(materialLabel ?? '').trim().toUpperCase(); + + if (normalized.includes('ASA')) { + return [ + { + labelKey: 'SHOP.PROPERTY_UV', + valueKey: 'SHOP.PROPERTY_HIGH', + tone: 'strong', + }, + { + labelKey: 'SHOP.PROPERTY_WEATHER', + valueKey: 'SHOP.PROPERTY_HIGH', + tone: 'strong', + }, + { + labelKey: 'SHOP.PROPERTY_RIGIDITY', + valueKey: 'SHOP.PROPERTY_RIGID', + tone: 'neutral', + }, + ]; + } + + if (normalized.includes('PETG') || normalized.includes('PC')) { + return [ + { + labelKey: 'SHOP.PROPERTY_UV', + valueKey: 'SHOP.PROPERTY_MEDIUM', + tone: 'neutral', + }, + { + labelKey: 'SHOP.PROPERTY_WEATHER', + valueKey: 'SHOP.PROPERTY_HIGH', + tone: 'strong', + }, + { + labelKey: 'SHOP.PROPERTY_RIGIDITY', + valueKey: normalized.includes('PC') + ? 'SHOP.PROPERTY_HIGH' + : 'SHOP.PROPERTY_RIGID', + tone: 'neutral', + }, + ]; + } + + if (normalized.includes('TPU')) { + return [ + { + labelKey: 'SHOP.PROPERTY_UV', + valueKey: 'SHOP.PROPERTY_MEDIUM', + tone: 'neutral', + }, + { + labelKey: 'SHOP.PROPERTY_WEATHER', + valueKey: 'SHOP.PROPERTY_MEDIUM', + tone: 'soft', + }, + { + labelKey: 'SHOP.PROPERTY_RIGIDITY', + valueKey: 'SHOP.PROPERTY_FLEXIBLE', + tone: 'soft', + }, + ]; + } + + return [ + { + labelKey: 'SHOP.PROPERTY_UV', + valueKey: 'SHOP.PROPERTY_LOW', + tone: 'soft', + }, + { + labelKey: 'SHOP.PROPERTY_WEATHER', + valueKey: 'SHOP.PROPERTY_LOW', + tone: 'soft', + }, + { + labelKey: 'SHOP.PROPERTY_RIGIDITY', + valueKey: 'SHOP.PROPERTY_RIGID', + tone: 'neutral', + }, + ]; + } + + private syncPublicUrl(product: ShopProductDetail): void { + const currentProductSlug = this.productSlug()?.trim().toLowerCase() ?? ''; + const targetProductSlug = this.shopRouteService.productPathSegment(product); + if (currentProductSlug === targetProductSlug) { + return; + } + + const currentTree = this.router.parseUrl(this.router.url); + const targetTree = this.router.createUrlTree( + ['/', this.languageService.selectedLang(), 'shop', 'p', targetProductSlug], + { + queryParams: currentTree.queryParams, + fragment: currentTree.fragment ?? undefined, + }, + ); + + if ( + this.router.serializeUrl(targetTree) === + this.router.serializeUrl(currentTree) + ) { + return; + } + + void this.router.navigateByUrl(targetTree, { + replaceUrl: true, + state: history.state, + }); + } } 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 c8858d2..cc22cf7 100644 --- a/frontend/src/app/features/shop/services/shop.service.spec.ts +++ b/frontend/src/app/features/shop/services/shop.service.spec.ts @@ -3,7 +3,12 @@ import { HttpClientTestingModule, HttpTestingController, } from '@angular/common/http/testing'; -import { ShopCartResponse, ShopService } from './shop.service'; +import { + ShopCartResponse, + ShopProductCatalogResponse, + ShopProductDetail, + ShopService, +} from './shop.service'; import { LanguageService } from '../../../core/services/language.service'; describe('ShopService', () => { @@ -85,6 +90,60 @@ describe('ShopService', () => { grandTotalChf: 36.8, }); + const buildCatalog = (): ShopProductCatalogResponse => ({ + categorySlug: null, + featuredOnly: false, + category: null, + products: [ + { + id: '12345678-abcd-4abc-9abc-1234567890ab', + slug: 'desk-cable-clip', + name: 'Supporto cavo scrivania', + excerpt: 'Accessorio tecnico', + isFeatured: true, + sortOrder: 0, + category: { + id: 'category-1', + slug: 'accessori', + name: 'Accessori', + }, + priceFromChf: 9.9, + priceToChf: 12.5, + defaultVariant: null, + primaryImage: null, + model3d: null, + }, + ], + }); + + const buildProduct = (): ShopProductDetail => ({ + id: '12345678-abcd-4abc-9abc-1234567890ab', + slug: 'desk-cable-clip', + name: 'Supporto cavo scrivania', + excerpt: 'Accessorio tecnico', + description: 'Descrizione prodotto', + seoTitle: null, + seoDescription: null, + ogTitle: null, + ogDescription: null, + indexable: true, + isFeatured: true, + sortOrder: 0, + category: { + id: 'category-1', + slug: 'accessori', + name: 'Accessori', + }, + breadcrumbs: [], + priceFromChf: 9.9, + priceToChf: 12.5, + defaultVariant: null, + variants: [], + primaryImage: null, + images: [], + model3d: null, + }); + beforeEach(() => { TestBed.configureTestingModule({ imports: [HttpClientTestingModule], @@ -143,4 +202,96 @@ describe('ShopService', () => { expect(service.cart()?.session?.id).toBe('session-1'); expect(service.cartItemCount()).toBe(3); }); + + it('resolves product detail from the public product slug', () => { + let response: ShopProductDetail | undefined; + + service + .getProductByPublicPath('12345678-supporto-cavo-scrivania') + .subscribe((product) => { + response = product; + }); + + const catalogRequest = httpMock.expectOne((request) => { + return ( + request.method === 'GET' && + request.url === 'http://localhost:8000/api/shop/products' && + request.params.get('lang') === 'it' + ); + }); + catalogRequest.flush(buildCatalog()); + + const detailRequest = httpMock.expectOne((request) => { + return ( + request.method === 'GET' && + request.url === + 'http://localhost:8000/api/shop/products/desk-cable-clip' && + request.params.get('lang') === 'it' + ); + }); + detailRequest.flush(buildProduct()); + + expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab'); + expect(response?.name).toBe('Supporto cavo scrivania'); + }); + + it('resolves product detail from uuid prefix even when slug tail does not match', () => { + let response: ShopProductDetail | undefined; + + service + .getProductByPublicPath('12345678-qualunque-nome') + .subscribe((product) => { + response = product; + }); + + const catalogRequest = httpMock.expectOne((request) => { + return ( + request.method === 'GET' && + request.url === 'http://localhost:8000/api/shop/products' && + request.params.get('lang') === 'it' + ); + }); + catalogRequest.flush(buildCatalog()); + + const detailRequest = httpMock.expectOne((request) => { + return ( + request.method === 'GET' && + request.url === + 'http://localhost:8000/api/shop/products/desk-cable-clip' && + request.params.get('lang') === 'it' + ); + }); + detailRequest.flush(buildProduct()); + + expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab'); + }); + + it('resolves product detail from bare uuid prefix without slug tail', () => { + let response: ShopProductDetail | undefined; + + service.getProductByPublicPath('12345678').subscribe((product) => { + response = product; + }); + + const catalogRequest = httpMock.expectOne((request) => { + return ( + request.method === 'GET' && + request.url === 'http://localhost:8000/api/shop/products' && + request.params.get('lang') === 'it' + ); + }); + catalogRequest.flush(buildCatalog()); + + const detailRequest = httpMock.expectOne((request) => { + return ( + request.method === 'GET' && + request.url === + 'http://localhost:8000/api/shop/products/desk-cable-clip' && + request.params.get('lang') === 'it' + ); + }); + detailRequest.flush(buildProduct()); + + expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab'); + }); }); diff --git a/frontend/src/app/features/shop/services/shop.service.ts b/frontend/src/app/features/shop/services/shop.service.ts index d5715cc..884d880 100644 --- a/frontend/src/app/features/shop/services/shop.service.ts +++ b/frontend/src/app/features/shop/services/shop.service.ts @@ -1,12 +1,13 @@ import { computed, inject, Injectable, signal } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { map, Observable, tap } from 'rxjs'; +import { map, Observable, switchMap, tap, throwError } from 'rxjs'; import { environment } from '../../../../environments/environment'; import { PublicMediaUsageDto, PublicMediaVariantDto, } from '../../../core/services/public-media.service'; import { LanguageService } from '../../../core/services/language.service'; +import { ShopRouteService } from './shop-route.service'; export interface ShopCategoryRef { id: string; @@ -179,6 +180,7 @@ export interface ShopCategoryNavNode { export class ShopService { private readonly http = inject(HttpClient); private readonly languageService = inject(LanguageService); + private readonly shopRouteService = inject(ShopRouteService); private readonly apiUrl = `${environment.apiUrl}/api/shop`; readonly cart = signal(null); @@ -268,6 +270,31 @@ export class ShopService { ); } + getProductByPublicPath( + productPathSegment: string, + ): Observable { + const lookup = this.shopRouteService.resolveProductLookup(productPathSegment); + if (!lookup.idPrefix && lookup.slugHint) { + return this.getProduct(lookup.slugHint); + } + + return this.getProductCatalog().pipe( + map((catalog) => + catalog.products.find( + (product) => product.id.toLowerCase().startsWith(lookup.idPrefix ?? ''), + ), + ), + switchMap((product) => { + if (!product) { + return throwError(() => ({ + status: 404, + })); + } + return this.getProduct(product.slug); + }), + ); + } + loadCart(): Observable { this.cartLoading.set(true); return this.http diff --git a/frontend/src/app/features/shop/shop-page.component.html b/frontend/src/app/features/shop/shop-page.component.html index dc63721..acfb6cc 100644 --- a/frontend/src/app/features/shop/shop-page.component.html +++ b/frontend/src/app/features/shop/shop-page.component.html @@ -7,14 +7,9 @@ ? selectedCategory()?.description || ("SHOP.CATEGORY_META" | translate: { count: selectedCategory()?.productCount || 0 }) - : ("SHOP.CUSTOM_PART_CTA" | translate) + : ("SHOP.SUBTITLE" | translate) }}

-
- - {{ "NAV.CONTACT" | translate }} - -
@@ -227,4 +222,21 @@ }
+ +
+ +
+
+

{{ "SHOP.CUSTOM_PART_FOOTER_TITLE" | translate }}

+

+ {{ "SHOP.CUSTOM_PART_FOOTER_TEXT" | translate }} +

+
+ + + {{ "NAV.CONTACT" | translate }} + +
+
+
diff --git a/frontend/src/app/features/shop/shop-page.component.scss b/frontend/src/app/features/shop/shop-page.component.scss index 1ea292d..900007f 100644 --- a/frontend/src/app/features/shop/shop-page.component.scss +++ b/frontend/src/app/features/shop/shop-page.component.scss @@ -7,16 +7,12 @@ line-height: 1.5; } -.hero-actions { - gap: var(--space-3); -} - .shop-layout { display: grid; gap: var(--space-8); align-items: start; grid-template-columns: minmax(270px, 320px) minmax(0, 1fr); - padding-bottom: var(--space-12); + padding-bottom: var(--space-8); padding-top: var(--space-6); } @@ -226,6 +222,32 @@ gap: var(--space-5); } +.shop-custom-cta { + padding-bottom: var(--space-12); +} + +.shop-custom-cta-card { + display: block; +} + +.shop-custom-cta-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-5); +} + +.shop-custom-cta-copy { + display: grid; + gap: var(--space-1); +} + +.shop-custom-cta-title { + margin: 0; + font-size: clamp(1.15rem, 0.45vw + 1.02rem, 1.45rem); + line-height: 1.25; +} + .section-title { margin: 0; font-size: clamp(1.5rem, 1vw + 1.2rem, 2rem); @@ -284,6 +306,11 @@ grid-template-columns: 1fr; } + .shop-custom-cta-inner { + align-items: start; + flex-direction: column; + } + .catalog-head, .cart-line-controls, .panel-head { diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts index 83de945..3dbf4e9 100644 --- a/frontend/src/app/features/shop/shop-page.component.ts +++ b/frontend/src/app/features/shop/shop-page.component.ts @@ -33,6 +33,7 @@ import { ShopProductSummary, ShopService, } from './services/shop.service'; +import { ShopRouteService } from './services/shop-route.service'; @Component({ selector: 'app-shop-page', @@ -55,6 +56,7 @@ export class ShopPageComponent { private readonly translate = inject(TranslateService); private readonly seoService = inject(SeoService); private readonly languageService = inject(LanguageService); + private readonly shopRouteService = inject(ShopRouteService); readonly shopService = inject(ShopService); readonly categorySlug = input(); @@ -167,8 +169,7 @@ export class ShopPageComponent { } navigateToCategory(slug?: string | null): void { - const commands = slug ? ['/shop', slug] : ['/shop']; - this.router.navigate(commands); + this.router.navigate(this.shopRouteService.shopRootCommands(slug)); } increaseQuantity(item: ShopCartItem): void { diff --git a/frontend/src/app/features/shop/shop.routes.ts b/frontend/src/app/features/shop/shop.routes.ts index 9654817..7915fec 100644 --- a/frontend/src/app/features/shop/shop.routes.ts +++ b/frontend/src/app/features/shop/shop.routes.ts @@ -12,6 +12,13 @@ export const SHOP_ROUTES: Routes = [ 'Catalogo prodotti stampati in 3D, accessori tecnici e soluzioni pratiche pronte all uso.', }, }, + { + path: 'p/:productSlug', + component: ProductDetailComponent, + data: { + seoTitle: 'Prodotto | 3D fab', + }, + }, { path: ':categorySlug/:productSlug', component: ProductDetailComponent, diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index 2ace005..d92fcbd 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -104,7 +104,10 @@ "WIP_CTA_CALC": "Zum Rechner", "WIP_RETURN_LATER": "Kommen Sie später wieder", "WIP_NOTE": "Wir legen Wert darauf, die Dinge richtig zu machen: In der Zwischenzeit können Sie Preis und Lieferzeit einer 3D-Datei sofort mit unserem Rechner berechnen.", + "CUSTOM_PART_FOOTER_TITLE": "Nicht das gefunden, was Sie suchen?", + "CUSTOM_PART_FOOTER_TEXT": "Kontaktieren Sie uns für individuelle Teile.", "ADD_CART": "In den Warenkorb", + "ADDING": "Wird hinzugefügt", "BACK": "Zurück zum Shop", "NOT_FOUND": "Produkt nicht gefunden.", "DETAILS": "Details", @@ -112,6 +115,21 @@ "SUCCESS_TITLE": "Zum Warenkorb hinzugefügt", "SUCCESS_DESC": "Das Produkt wurde erfolgreich zum Warenkorb hinzugefügt.", "CONTINUE": "Weiter", + "MODEL_OPEN": "3D-Ansicht öffnen", + "MODEL_CLOSE": "3D-Ansicht schließen", + "PREVIOUS_IMAGE": "Vorheriges Bild", + "NEXT_IMAGE": "Nächstes Bild", + "SELECT_MATERIAL": "Material", + "SELECT_COLOR": "Farbe", + "MATERIAL_COLOR_COUNT": "{{count}} Farben verfügbar", + "PROPERTY_UV": "UV-Beständigkeit", + "PROPERTY_WEATHER": "Außeneinsatz", + "PROPERTY_RIGIDITY": "Steifigkeit", + "PROPERTY_HIGH": "Hoch", + "PROPERTY_MEDIUM": "Mittel", + "PROPERTY_LOW": "Niedrig", + "PROPERTY_RIGID": "Steif", + "PROPERTY_FLEXIBLE": "Flexibel", "CATEGORIES": { "FILAMENTS": "Filamente", "ACCESSORIES": "Zubehör" diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index 56fba30..3403fa2 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -104,7 +104,10 @@ "WIP_CTA_CALC": "Check our calculator", "WIP_RETURN_LATER": "Come back soon", "WIP_NOTE": "We care about doing this right. In the meantime, you can get instant pricing and lead time from our calculator.", + "CUSTOM_PART_FOOTER_TITLE": "Can't find what you're looking for?", + "CUSTOM_PART_FOOTER_TEXT": "Contact us for custom parts.", "ADD_CART": "Add to Cart", + "ADDING": "Adding to cart", "BACK": "Back to Shop", "NOT_FOUND": "Product not found.", "DETAILS": "Details", @@ -112,6 +115,21 @@ "SUCCESS_TITLE": "Added to cart", "SUCCESS_DESC": "The product has been added to the cart.", "CONTINUE": "Continue", + "MODEL_OPEN": "Open 3D view", + "MODEL_CLOSE": "Close 3D view", + "PREVIOUS_IMAGE": "Previous image", + "NEXT_IMAGE": "Next image", + "SELECT_MATERIAL": "Material", + "SELECT_COLOR": "Color", + "MATERIAL_COLOR_COUNT": "{{count}} colors available", + "PROPERTY_UV": "UV resistance", + "PROPERTY_WEATHER": "Outdoor use", + "PROPERTY_RIGIDITY": "Rigidity", + "PROPERTY_HIGH": "High", + "PROPERTY_MEDIUM": "Medium", + "PROPERTY_LOW": "Low", + "PROPERTY_RIGID": "Rigid", + "PROPERTY_FLEXIBLE": "Flexible", "CATEGORIES": { "FILAMENTS": "Filaments", "ACCESSORIES": "Accessories" diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json index ecd13b0..dd537db 100644 --- a/frontend/src/assets/i18n/fr.json +++ b/frontend/src/assets/i18n/fr.json @@ -161,7 +161,10 @@ "WIP_CTA_CALC": "Aller au calculateur", "WIP_RETURN_LATER": "Revenez dans un moment", "WIP_NOTE": "Nous tenons à bien faire les choses : en attendant, vous pouvez calculer immédiatement prix et délais d'un fichier 3D avec notre calculateur.", + "CUSTOM_PART_FOOTER_TITLE": "Vous ne trouvez pas ce que vous cherchez ?", + "CUSTOM_PART_FOOTER_TEXT": "Contactez-nous pour des pièces personnalisées.", "ADD_CART": "Ajouter au panier", + "ADDING": "Ajout en cours", "BACK": "Retour à la boutique", "NOT_FOUND": "Produit introuvable.", "DETAILS": "Détails", @@ -169,6 +172,21 @@ "SUCCESS_TITLE": "Ajouté au panier", "SUCCESS_DESC": "Le produit a été ajouté au panier avec succès.", "CONTINUE": "Continuer", + "MODEL_OPEN": "Ouvrir la vue 3D", + "MODEL_CLOSE": "Fermer la vue 3D", + "PREVIOUS_IMAGE": "Image précédente", + "NEXT_IMAGE": "Image suivante", + "SELECT_MATERIAL": "Matériau", + "SELECT_COLOR": "Couleur", + "MATERIAL_COLOR_COUNT": "{{count}} couleurs disponibles", + "PROPERTY_UV": "Résistance UV", + "PROPERTY_WEATHER": "Usage extérieur", + "PROPERTY_RIGIDITY": "Rigidité", + "PROPERTY_HIGH": "Élevée", + "PROPERTY_MEDIUM": "Moyenne", + "PROPERTY_LOW": "Faible", + "PROPERTY_RIGID": "Rigide", + "PROPERTY_FLEXIBLE": "Flexible", "CATEGORIES": { "FILAMENTS": "Filaments", "ACCESSORIES": "Accessoires" diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index f59f113..9e2ad80 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -178,6 +178,8 @@ "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.", + "CUSTOM_PART_FOOTER_TITLE": "Non trovi quello che cerchi?", + "CUSTOM_PART_FOOTER_TEXT": "Contattaci per pezzi personalizzati.", "CATEGORY_META": "{{count}} prodotti disponibili in questa categoria", "CATEGORY_PANEL_KICKER": "Navigazione", "CATEGORY_PANEL_TITLE": "Categorie", @@ -194,11 +196,25 @@ "EXCERPT_FALLBACK": "Scheda prodotto in preparazione.", "MODEL_3D": "3D preview", "MODEL_TITLE": "Anteprima del modello", + "MODEL_OPEN": "Apri vista 3D", + "MODEL_CLOSE": "Chiudi vista 3D", "MODEL_LOADING": "Stiamo caricando il modello 3D.", "MODEL_UNAVAILABLE": "Preview 3D non disponibile.", + "PREVIOUS_IMAGE": "Immagine precedente", + "NEXT_IMAGE": "Immagine successiva", "BREADCRUMB_ROOT": "Shop", + "SELECT_MATERIAL": "Materiale", "SELECT_COLOR": "Colore", + "MATERIAL_COLOR_COUNT": "{{count}} colori disponibili", "VARIANT": "Variante", + "PROPERTY_UV": "Resistenza UV", + "PROPERTY_WEATHER": "Uso esterno", + "PROPERTY_RIGIDITY": "Rigidità", + "PROPERTY_HIGH": "Alta", + "PROPERTY_MEDIUM": "Media", + "PROPERTY_LOW": "Bassa", + "PROPERTY_RIGID": "Rigido", + "PROPERTY_FLEXIBLE": "Flessibile", "QUANTITY": "Quantità", "GO_TO_CHECKOUT": "Vai al checkout", "IN_CART_SHORT": "Nel carrello x{{count}}",